canopycms 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +112 -47
  2. package/dist/api/admin-branch-health.d.ts +11 -0
  3. package/dist/api/admin-branch-health.js +36 -0
  4. package/dist/api/admin.d.ts +8 -1
  5. package/dist/api/branch-merge.js +1 -1
  6. package/dist/api/branch-review.js +20 -3
  7. package/dist/api/branch-status.js +30 -19
  8. package/dist/api/branch.d.ts +8 -3
  9. package/dist/api/branch.js +32 -3
  10. package/dist/api/client.d.ts +3 -1
  11. package/dist/api/client.js +4 -0
  12. package/dist/api/github-sync.d.ts +2 -0
  13. package/dist/api/github-sync.js +6 -1
  14. package/dist/api/pr-comment-sync.js +3 -1
  15. package/dist/branch-metadata-file.d.ts +66 -0
  16. package/dist/branch-metadata-file.js +14 -0
  17. package/dist/branch-metadata.d.ts +4 -2
  18. package/dist/branch-metadata.js +10 -2
  19. package/dist/branch-schema-cache.d.ts +22 -2
  20. package/dist/branch-schema-cache.js +88 -12
  21. package/dist/cli/cli.js +91 -18
  22. package/dist/cli/generate-ai-content.js +72 -11
  23. package/dist/cli/init.js +2 -2
  24. package/dist/content-reader.d.ts +24 -10
  25. package/dist/content-reader.js +43 -6
  26. package/dist/context.js +43 -41
  27. package/dist/editor/BranchManager.d.ts +8 -4
  28. package/dist/editor/BranchManager.js +43 -18
  29. package/dist/editor/Editor.js +3 -2
  30. package/dist/editor/admin/SystemHealthPanel.js +65 -31
  31. package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
  32. package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
  33. package/dist/editor/admin/useSystemHealth.d.ts +2 -0
  34. package/dist/editor/admin/useSystemHealth.js +21 -0
  35. package/dist/editor/branch-lock.d.ts +2 -0
  36. package/dist/editor/branch-lock.js +5 -3
  37. package/dist/editor/branch-status.d.ts +25 -1
  38. package/dist/editor/branch-status.js +75 -0
  39. package/dist/editor/components/EditorHeader.d.ts +6 -2
  40. package/dist/editor/components/EditorHeader.js +51 -30
  41. package/dist/editor/components/EntryHeader.d.ts +3 -1
  42. package/dist/editor/components/EntryHeader.js +2 -1
  43. package/dist/editor/components/TooltipDetail.d.ts +14 -0
  44. package/dist/editor/components/TooltipDetail.js +18 -0
  45. package/dist/editor/copy.d.ts +7 -0
  46. package/dist/editor/copy.js +9 -0
  47. package/dist/editor/hooks/useBranchManager.d.ts +8 -3
  48. package/dist/editor/hooks/useBranchManager.js +48 -5
  49. package/dist/pr-comments.d.ts +2 -0
  50. package/dist/pr-comments.js +20 -14
  51. package/dist/resource-generation.js +2 -3
  52. package/dist/server.d.ts +6 -1
  53. package/dist/server.js +6 -1
  54. package/dist/submission-attribution.d.ts +1 -1
  55. package/dist/submission-attribution.js +20 -2
  56. package/dist/task-queue/task-queue.d.ts +5 -1
  57. package/dist/task-queue/task-queue.js +4 -2
  58. package/dist/types.d.ts +61 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/dist/worker/branch-merge.d.ts +68 -0
  62. package/dist/worker/branch-merge.js +228 -0
  63. package/dist/worker/canopy-state.d.ts +2 -0
  64. package/dist/worker/canopy-state.js +1 -1
  65. package/dist/worker/cms-worker.d.ts +1 -0
  66. package/dist/worker/cms-worker.js +5 -3
  67. package/dist/worker/git-sync.js +15 -8
  68. package/dist/worker/github-commits.d.ts +106 -0
  69. package/dist/worker/github-commits.js +485 -0
  70. package/dist/worker/github-gateway-http.js +3 -2
  71. package/dist/worker/github-gateway-wire.d.ts +24 -15
  72. package/dist/worker/github-gateway-wire.js +4 -1
  73. package/dist/worker/github-gateway.d.ts +16 -5
  74. package/dist/worker/github-gateway.js +22 -5
  75. package/dist/worker/github-mirror.d.ts +8 -0
  76. package/dist/worker/github-mirror.js +12 -1
  77. package/dist/worker/github-transfer.d.ts +3 -1
  78. package/dist/worker/github-transfer.js +3 -2
  79. package/dist/worker/history-rewrite.d.ts +13 -1
  80. package/dist/worker/history-rewrite.js +27 -3
  81. package/dist/worker/rebase.d.ts +13 -7
  82. package/dist/worker/rebase.js +351 -95
  83. package/dist/worker/task-runner.d.ts +1 -1
  84. package/dist/worker/task-runner.js +172 -34
  85. package/dist/worker/worker-context.d.ts +2 -0
  86. package/package.json +1 -1
@@ -16,6 +16,11 @@ import { CONTENT_READER_USER_ID } from './system-users.js';
16
16
  * reads as NOT_FOUND unless `allowCreateBranch` or a `getBranchContext` resolver says otherwise.
17
17
  */
18
18
  export const createContentReader = (options) => {
19
+ const { read } = createContentReaderInternal(options);
20
+ return { read };
21
+ };
22
+ /** @internal {@link createContentReader} plus `readFirst`, for context.ts. */
23
+ export const createContentReaderInternal = (options) => {
19
24
  const services = options.services;
20
25
  const operatingMode = services.config.mode;
21
26
  const basePathOverride = options.basePathOverride;
@@ -127,9 +132,9 @@ export const createContentReader = (options) => {
127
132
  const url = encodedSlug ? `${trimmed}/${encodedSlug}` : trimmed || '/';
128
133
  return appendBranch(url);
129
134
  };
130
- const readDocument = async (input) => {
131
- const { entryPath, slug, branchName, user } = resolveTarget(input);
132
- const { context, branchRoot, store } = await resolveStore(branchName);
135
+ const readDocument = async (input, resolved) => {
136
+ const { entryPath, slug, user } = resolveTarget(input);
137
+ const { context, branchRoot, store } = resolved;
133
138
  // Rule 1 of urlAddressableOnly (see ReadContentInput): a published URL's non-slug segments
134
139
  // are collection names, so a candidate landing on an entry-TYPE item can only ever produce a
135
140
  // URL enumeration never emits. NO_SCHEMA_ITEM is what assertCollection raises for exactly
@@ -216,9 +221,9 @@ export const createContentReader = (options) => {
216
221
  throw err;
217
222
  }
218
223
  };
219
- const read = async (input, message) => {
224
+ const readResolved = async (input, resolved, message) => {
220
225
  const { entryPath, slug, branchName } = resolveTarget(input);
221
- const result = await readDocument(input);
226
+ const result = await readDocument(input, resolved);
222
227
  if (!result || typeof result.doc !== 'object' || !('data' in result.doc)) {
223
228
  const defaultMessage = `Content not found for ${entryPath}${slug ? `/${slug}` : ''} on branch ${branchName}`;
224
229
  throw new ContentStoreError(message ?? defaultMessage, 'NOT_FOUND');
@@ -242,5 +247,37 @@ export const createContentReader = (options) => {
242
247
  });
243
248
  return { data, path, meta: { physicalPath, entryType, entryId, slug: entrySlug, urlPath } };
244
249
  };
245
- return { read };
250
+ const read = async (input, message) => {
251
+ const { branchName } = resolveTarget(input);
252
+ return readResolved(input, await resolveStore(branchName), message);
253
+ };
254
+ const readFirst = async (inputs, isMiss) => {
255
+ const [first] = inputs;
256
+ if (!first)
257
+ return null;
258
+ if (inputs.some((input) => input.branch !== first.branch || input.user !== first.user)) {
259
+ throw new Error('readFirst: every input must name the same branch and user');
260
+ }
261
+ let resolved;
262
+ try {
263
+ resolved = await resolveStore(resolveTarget(first).branchName);
264
+ }
265
+ catch (err) {
266
+ if (isMiss(err, first))
267
+ return null;
268
+ throw err;
269
+ }
270
+ for (const input of inputs) {
271
+ try {
272
+ return await readResolved(input, resolved);
273
+ }
274
+ catch (err) {
275
+ if (isMiss(err, input))
276
+ continue;
277
+ throw err;
278
+ }
279
+ }
280
+ return null;
281
+ };
282
+ return { read, readFirst };
246
283
  };
package/dist/context.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isDeployedStatic, isBuildMode, readsFromCheckout, STATIC_DEPLOY_USER } from './build-mode.js';
2
- import { createContentReader } from './content-reader.js';
2
+ import { createContentReaderInternal } from './content-reader.js';
3
3
  import { ContentStoreError } from './content-store.js';
4
4
  import { createLogicalPath, namesNoWorkspace, parseSlug, resolveBranchPaths, } from './paths/index.js';
5
5
  import { resolveUrlPathCandidates } from './url-path-resolver.js';
@@ -129,7 +129,7 @@ export function createCanopyContext(options) {
129
129
  };
130
130
  // The reader resolves every branch through resolveBranch; a null reads as
131
131
  // NOT_FOUND. Reads from the checkout bypass the resolver inside the reader.
132
- const baseReader = createContentReader({
132
+ const baseReader = createContentReaderInternal({
133
133
  services,
134
134
  defaultBranch: activeBranch,
135
135
  getBranchContext: (branchName) => resolveBranch(otherBranch(branchName)),
@@ -140,7 +140,7 @@ export function createCanopyContext(options) {
140
140
  // function rather than an optional second parameter on the
141
141
  // `CanopyContext['read']`-typed closure, so the two call sites stay visible
142
142
  // and nobody widens the public API by accident.
143
- const readWithOptions = async (input, extra) => {
143
+ const toReadInput = (input, extra) => {
144
144
  const entryPath = createLogicalPath(input.entryPath);
145
145
  let slug;
146
146
  if (input.slug) {
@@ -161,53 +161,55 @@ export function createCanopyContext(options) {
161
161
  resolveReferences: input.resolveReferences ?? true,
162
162
  ...extra,
163
163
  };
164
- return baseReader.read(readInput);
164
+ return readInput;
165
165
  };
166
- const read = (input) => readWithOptions(input);
166
+ const read = async (input) => baseReader.read(toReadInput(input));
167
167
  const readByUrlPath = async (urlPath, options) => {
168
168
  const contentRoot = services.config.contentRoot || 'content';
169
169
  const candidates = resolveUrlPathCandidates(urlPath, contentRoot);
170
170
  if (candidates.length === 0)
171
171
  return null;
172
172
  const { branch, resolveReferences } = options ?? {};
173
- for (const candidate of candidates) {
174
- // Skip a candidate whose slug isn't valid (/favicon.ico, Next internals
175
- // the [...slug] route catches). None can match an entry, so treat it as
176
- // a miss rather than let read() throw "Invalid slug".
177
- if (!parseSlug(candidate.slug).ok)
178
- continue;
179
- try {
180
- return await readWithOptions({
181
- entryPath: candidate.entryPath,
182
- slug: candidate.slug,
183
- branch,
184
- resolveReferences,
185
- },
186
- // A read BY PUBLISHED URL accepts only what listEntries publishes.
187
- // See ReadContentInput.urlAddressableOnly for the two rules, and
188
- // why they live in the reader (which holds the branch-correct
189
- // schema) rather than the candidate builder (pure and schema-free).
190
- { urlAddressableOnly: true });
191
- }
192
- catch (err) {
193
- // Swallow a candidate path's "not found", and FORBIDDEN so a denied
194
- // or anonymous read renders as the adopter's 404 rather than an
195
- // unhandled 500. Real errors (validation, corruption, anything not a
196
- // ContentStoreError) rethrow.
197
- if (err instanceof ContentStoreError && isPageSwallowable(err)) {
198
- if (err.code === 'FORBIDDEN') {
199
- log.debug('readByUrlPath', 'Read denied, treating as not-found: ' + err.message, {
200
- urlPath,
201
- entryPath: candidate.entryPath,
202
- slug: candidate.slug,
203
- });
204
- }
205
- continue;
206
- }
207
- throw err;
173
+ // Swallow a candidate path's "not found", and FORBIDDEN so a denied or
174
+ // anonymous read renders as the adopter's 404 rather than an unhandled
175
+ // 500. Real errors (validation, corruption, anything not a
176
+ // ContentStoreError) rethrow.
177
+ const isMiss = (err, input) => {
178
+ if (!(err instanceof ContentStoreError && isPageSwallowable(err)))
179
+ return false;
180
+ if (err.code === 'FORBIDDEN') {
181
+ log.debug('readByUrlPath', 'Read denied, treating as not-found: ' + err.message, {
182
+ urlPath,
183
+ entryPath: input?.entryPath,
184
+ slug: input?.slug,
185
+ });
208
186
  }
187
+ return true;
188
+ };
189
+ // Converting every candidate up front throws nothing a candidate-by-candidate
190
+ // loop would not: a valid slug holds no `..`, so the index candidate's path
191
+ // fails createLogicalPath only when the entry candidate's does.
192
+ let inputs;
193
+ try {
194
+ inputs = candidates
195
+ // Skip a candidate whose slug isn't valid (/favicon.ico, Next internals
196
+ // the [...slug] route catches). None can match an entry, so treat it as
197
+ // a miss rather than let read() throw "Invalid slug".
198
+ .filter((candidate) => parseSlug(candidate.slug).ok)
199
+ .map((candidate) => toReadInput({ entryPath: candidate.entryPath, slug: candidate.slug, branch, resolveReferences },
200
+ // A read BY PUBLISHED URL accepts only what listEntries publishes.
201
+ // See ReadContentInput.urlAddressableOnly for the two rules, and
202
+ // why they live in the reader (which holds the branch-correct
203
+ // schema) rather than the candidate builder (pure and schema-free).
204
+ { urlAddressableOnly: true }));
209
205
  }
210
- return null;
206
+ catch (err) {
207
+ if (isMiss(err))
208
+ return null;
209
+ throw err;
210
+ }
211
+ // One branch and schema resolve for every candidate.
212
+ return baseReader.readFirst(inputs, isMiss);
211
213
  };
212
214
  /**
213
215
  * What a batch read (listEntries / buildContentTree) lists from: the branch's
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import type { OperatingMode } from '../operating-mode/index.js';
3
- import type { ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
3
+ import type { BranchHold, ConflictStatus, PullRequestState, SyncStatus } from '../types.js';
4
4
  import type { CommentThread } from '../comment-store.js';
5
5
  import type { UserSearchResult } from '../auth/types.js';
6
6
  import type { CreateRefusal } from './hooks/create-branch-request.js';
@@ -32,17 +32,21 @@ export interface BranchSummary {
32
32
  conflictStatus?: ConflictStatus;
33
33
  /** ContentIds of entries where --theirs was applied during rebase; cleared on clean rebase */
34
34
  conflictFiles?: string[];
35
+ /** What is stuck and needs a person (api/branch.ts). */
36
+ hold?: BranchHold;
35
37
  commentCount?: number;
36
38
  isProtected?: boolean;
37
39
  readOnly?: boolean;
38
40
  /**
39
- * Server-computed compound submit rule (base-branch OR non-'editing'
40
- * status) -- see `useBranchManager.tsx`'s `BranchSummary.submitBlocked` and
41
- * `BranchWriteProtection.submitBlockedIncludingStatus` for the full
41
+ * Server-computed compound submit rule (base-branch, non-'editing'
42
+ * status, or rewritten on GitHub) -- see `useBranchManager.tsx`'s
43
+ * `BranchSummary.submitBlocked` and `BranchWriteProtection.submitBlockedIncludingStatus` for the full
42
44
  * rationale. `getBranchPermissions`'s `canSubmit` consumes this directly
43
45
  * instead of re-deriving `status === 'editing' && !isProtected`.
44
46
  */
45
47
  submitBlocked?: boolean;
48
+ /** Server-computed: the API refuses Approve, as the last submit didn't finish. */
49
+ approveBlocked?: boolean;
46
50
  }
47
51
  export interface UserContext {
48
52
  userId: string;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { ActionIcon, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
4
+ import { ActionIcon, Anchor, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
5
5
  import { IconDots, IconExternalLink } from '@tabler/icons-react';
6
6
  import { BranchComments } from './comments/BranchComments.js';
7
7
  import { UserBadge } from './components/UserBadge.js';
@@ -13,9 +13,10 @@ import { parseCreatedBranchName } from '../paths/validation.js';
13
13
  import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
14
14
  import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
15
15
  import { RelativeTime } from './components/RelativeTime.js';
16
- import { branchStatusPresentation } from './branch-status.js';
16
+ import { HOLD_BADGE, SYNC_FAILED_IN_REVIEW_NOTE, branchHoldMessage, endSentence, syncFailedFallback, branchStatusPresentation, } from './branch-status.js';
17
+ import { TooltipDetail } from './components/TooltipDetail.js';
17
18
  import { branchAddress, displayBranchName } from './branch-names.js';
18
- import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
19
+ import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
19
20
  import { isReviewerOfOthersReview } from '../authorization/review-role.js';
20
21
  import { SYSTEM_USER_ID } from '../system-users.js';
21
22
  /**
@@ -51,8 +52,8 @@ export const getBranchPermissions = (branch, user) => {
51
52
  (userIsCreator || userInACL || isSystemBranch || userIsAdmin || userIsReviewer);
52
53
  // Submit: can perform workflow actions AND the server says submit isn't
53
54
  // blocked. `submitBlocked` is the server's own compound answer -- status
54
- // past 'editing' OR the protected base branch -- computed once in
55
- // `getBranchWriteProtection` (protected-branch.ts) and threaded through
55
+ // past 'editing', the protected base branch, or a rewrite on GitHub --
56
+ // computed once in `toBranchListItem` (api/branch.ts) and threaded through
56
57
  // unchanged.
57
58
  //
58
59
  // `?? true`, not a bare `!branch.submitBlocked`: the field is optional (like
@@ -80,10 +81,11 @@ export const getBranchPermissions = (branch, user) => {
80
81
  // Approve and Request changes: a Reviewer or Admin other than the creator, who for an author
81
82
  // would only do what Withdraw does. Neither applies to the protected base branch, whose way
82
83
  // out of review is Withdraw. A closed PR refuses both (Request changes converts the PR to
83
- // a draft); Approve also needs the branch submitted, Request changes accepts approved too.
84
+ // a draft); Approve also needs the branch submitted and its last submit finished, Request changes
85
+ // accepts approved too.
84
86
  const reviewsOthers = (userIsAdmin || userIsReviewer) && !userIsCreator && !branch.isProtected;
85
87
  const prLive = branch.pullRequestState !== 'closed';
86
- const canApprove = reviewsOthers && branch.status === 'submitted' && prLive;
88
+ const canApprove = reviewsOthers && branch.status === 'submitted' && prLive && !branch.approveBlocked;
87
89
  const canRequestChanges = reviewsOthers && (branch.status === 'submitted' || branch.status === 'approved') && prLive;
88
90
  return {
89
91
  hasWorkflowAccess: canPerformWorkflowActions,
@@ -102,7 +104,7 @@ const noUserLookup = async () => null;
102
104
  * author's way back. A user without workflow access is offered neither, since it
103
105
  * would never apply to them; a reason is given only for a block that depends on the branch's state.
104
106
  */
105
- const workflowAction = (b, perms, onSubmit, onWithdraw) => {
107
+ const workflowAction = (b, perms, onSubmit, onWithdraw, userIsAdmin) => {
106
108
  if (!perms.hasWorkflowAccess)
107
109
  return undefined;
108
110
  if (b.status === 'submitted' || b.status === 'approved') {
@@ -119,7 +121,11 @@ const workflowAction = (b, perms, onSubmit, onWithdraw) => {
119
121
  color: 'brand',
120
122
  testId: `submit-branch-button-${b.name}`,
121
123
  run: () => onSubmit?.(b.name),
122
- blockedReason: perms.canSubmit ? undefined : "This branch can't be submitted right now.",
124
+ blockedReason: perms.canSubmit
125
+ ? undefined
126
+ : b.hold?.kind === 'rewritten'
127
+ ? rewrittenSubmitReason(userIsAdmin)
128
+ : "This branch can't be submitted right now.",
123
129
  };
124
130
  }
125
131
  return undefined;
@@ -133,6 +139,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
133
139
  isProtected: b.isProtected,
134
140
  pullRequestState: b.pullRequestState,
135
141
  }, user);
142
+ /** Lets the card's details shrink beside its actions, which a long unbroken name would push out. */
143
+ const CARD_DETAILS = { minWidth: 0, flex: 1 };
144
+ const WRAP_ANYWHERE = { overflowWrap: 'anywhere' };
136
145
  /**
137
146
  * One branch: its state, and one primary action, with the rest in a "…" menu. The current branch
138
147
  * shows its workflow action; to a reviewer of someone else's submission it shows Approve, with
@@ -143,9 +152,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
143
152
  const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
144
153
  const statusBadge = branchStatusPresentation(b.status, b.isProtected);
145
154
  const perms = getBranchPermissions(b, user);
146
- const action = workflowAction(b, perms, onSubmit, onWithdraw);
147
- const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
148
155
  const userIsAdmin = isAdmin(user?.groups);
156
+ const action = workflowAction(b, perms, onSubmit, onWithdraw, userIsAdmin);
157
+ const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
149
158
  // The open branch holds back its review actions as it does its workflow action; the reason is
150
159
  // given only for the open branch.
151
160
  const heldBack = (a) => a && workflowBlockedReason ? { ...a, blockedReason: workflowBlockedReason } : a;
@@ -157,7 +166,11 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
157
166
  color: 'brand',
158
167
  testId: `approve-branch-button-${b.name}`,
159
168
  run: () => onApprove?.(b.name),
160
- blockedReason: perms.canApprove ? undefined : CLOSED_PULL_REQUEST_REASON,
169
+ blockedReason: perms.canApprove
170
+ ? undefined
171
+ : b.pullRequestState === 'closed'
172
+ ? CLOSED_PULL_REQUEST_REASON
173
+ : UNFINISHED_SUBMIT_APPROVE_REASON,
161
174
  }
162
175
  : undefined);
163
176
  const requestChanges = heldBack(reviewsOthers && (b.status === 'submitted' || b.status === 'approved')
@@ -178,24 +191,36 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
178
191
  // alone once approved. Reviewing another one, Review opens it and the actions wait in the menu.
179
192
  const primary = !isCurrent ? undefined : reviewing ? (approve ?? requestChanges) : workflow;
180
193
  const secondary = isCurrent && reviewing && approve ? requestChanges : undefined;
181
- // Two visible actions, or the long "Withdraw from review", would crowd the title at the drawer's
182
- // width, so they take a row below it. Open and Review stay beside the title.
183
- const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview;
194
+ // Two visible actions, the long "Withdraw from review", or any action beside a hold's line would
195
+ // crowd the details at the drawer's width, so they take a row below. Open and Review stay beside
196
+ // the title.
197
+ const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview || (!!primary && !!b.hold);
184
198
  const menuActions = [approve, requestChanges, workflow].filter((a) => !!a && a !== primary && a !== secondary);
185
199
  const showViewPullRequest = !!b.pullRequestUrl;
186
200
  const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
187
201
  const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
188
202
  const menuLabel = `More actions for ${displayBranchName(b)}`;
189
203
  const [menuOpen, setMenuOpen] = useState(false);
190
- return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
204
+ const inReview = b.status === 'submitted' || b.status === 'approved';
205
+ const holdViewer = {
206
+ role: reviewing ? 'review' : perms.canWithdraw ? 'withdraw' : 'none',
207
+ isAdmin: userIsAdmin,
208
+ };
209
+ return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
191
210
  ? 'red'
192
211
  : b.pullRequestState === 'merged'
193
212
  ? 'violet'
194
- : 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Tooltip, { label: "GitHub sync failed \u2014 an admin can retry it from System health.", disabled: Boolean(b.syncFailureReason), multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" }) })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(Tooltip, { label: "This branch's version was kept for conflicting entries during a rebase \u2014 review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.syncStatus === 'sync-failed' && b.syncFailureReason && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, "data-testid": `sync-failed-reason-${b.name}`, children: [b.syncFailureReason, b.status === 'submitted' && ' Withdraw it from review to make changes.'] })), _jsxs(Group, { gap: "xs", align: "center", children: [b.updatedAt && (_jsx(RelativeTime, { iso: b.updatedAt, prefix: "Updated ", size: "xs", c: "dimmed" })), b.createdBy && (_jsxs(Group, { gap: "xs", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "\u2022" }), _jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Owner:" }), _jsx(UserBadge, { userId: b.createdBy, getUserMetadata: onGetOwnerMetadata ?? noUserLookup, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })] })] }))] }), b.access && (_jsx(BranchAccessList, { users: b.access.users, groups: b.access.groups, withheldUsers: b.access.withheldUsers, withheldGroups: b.access.withheldGroups, getUserMetadata: onGetOwnerMetadata ?? noUserLookup }))] }), _jsxs(Group, { gap: 8, wrap: "nowrap", children: [!isCurrent && (_jsx(Button, { variant: "light", onClick: () => onSelect?.(b.name), "data-testid": reviewing ? `review-branch-button-${b.name}` : `switch-to-branch-button-${b.name}`, children: reviewing ? EDITOR_ACTIONS.review : 'Open' })), !actionRow && primary && _jsx(CardActionButton, { action: primary }), hasMenu && (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, onChange: setMenuOpen, children: [_jsx(Tooltip, { label: menuLabel, disabled: menuOpen, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": menuLabel, "data-testid": `branch-actions-menu-${b.name}`, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [menuActions.map((action) => (_jsx(ReasonedMenuItem, { blockedReason: action.blockedReason, showReason: !action.reasonShownBy, reasonId: `${action.testId}-reason`, reasonDescribedBy: action.reasonShownBy ? `${action.reasonShownBy}-reason` : undefined, onClick: action.run, "data-testid": action.testId, children: action.label }, action.testId))), showViewPullRequest && (_jsx(Menu.Item, { component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), "data-testid": `view-pull-request-${b.name}`, children: b.pullRequestNumber
213
+ : 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
214
+ (b.hold ? b.hold.kind !== 'rewritten' && !!b.syncFailureReason?.trim() : true) && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, style: WRAP_ANYWHERE, "data-testid": `sync-failed-reason-${b.name}`, children: [endSentence(b.syncFailureReason?.trim() || syncFailedFallback(userIsAdmin, b.status)), !b.hold && inReview && b.pullRequestNumber && ` ${SYNC_FAILED_IN_REVIEW_NOTE}`, !b.hold &&
215
+ inReview &&
216
+ holdViewer.role === 'withdraw' &&
217
+ ' Withdraw the branch from review to make changes.'] })), _jsxs(Group, { gap: "xs", align: "center", children: [b.updatedAt && (_jsx(RelativeTime, { iso: b.updatedAt, prefix: "Updated ", size: "xs", c: "dimmed" })), b.createdBy && (_jsxs(Group, { gap: "xs", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "\u2022" }), _jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Owner:" }), _jsx(UserBadge, { userId: b.createdBy, getUserMetadata: onGetOwnerMetadata ?? noUserLookup, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })] })] }))] }), b.access && (_jsx(BranchAccessList, { users: b.access.users, groups: b.access.groups, withheldUsers: b.access.withheldUsers, withheldGroups: b.access.withheldGroups, getUserMetadata: onGetOwnerMetadata ?? noUserLookup }))] }), _jsxs(Group, { gap: 8, wrap: "nowrap", children: [!isCurrent && (_jsx(Button, { variant: "light", onClick: () => onSelect?.(b.name), "data-testid": reviewing ? `review-branch-button-${b.name}` : `switch-to-branch-button-${b.name}`, children: reviewing ? EDITOR_ACTIONS.review : 'Open' })), !actionRow && primary && _jsx(CardActionButton, { action: primary }), hasMenu && (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, onChange: setMenuOpen, children: [_jsx(Tooltip, { label: menuLabel, disabled: menuOpen, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": menuLabel, "data-testid": `branch-actions-menu-${b.name}`, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [menuActions.map((action) => (_jsx(ReasonedMenuItem, { blockedReason: action.blockedReason, showReason: !action.reasonShownBy, reasonId: `${action.testId}-reason`, reasonDescribedBy: action.reasonShownBy ? `${action.reasonShownBy}-reason` : undefined, onClick: action.run, "data-testid": action.testId, children: action.label }, action.testId))), showViewPullRequest && (_jsx(Menu.Item, { component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), "data-testid": `view-pull-request-${b.name}`, children: b.pullRequestNumber
195
218
  ? `View pull request #${b.pullRequestNumber}`
196
219
  : 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
197
220
  ? 'This branch is in review. Withdraw it from review before deleting it.'
198
- : undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
221
+ : b.hold?.kind === 'unpublished-at-merge' && !userIsAdmin
222
+ ? 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.'
223
+ : undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
199
224
  };
200
225
  const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
201
226
  if (!action.blockedReason)
@@ -1578,6 +1578,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1578
1578
  branchMissing,
1579
1579
  reviewing: branchReviewing,
1580
1580
  pullRequestClosed: branchPullRequestClosed,
1581
+ approveBlocked: currentBranch?.approveBlocked,
1581
1582
  }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), entryFilter: entryFilter === 'changed' && !changesOffered ? 'all' : entryFilter, onEntryFilterChange: setEntryFilter, onShowUnsaved: showUnsaved, onShowDeletedChanges: (entry, label) => showEntryChanges(entry, label, 'deleted row'), branchChanges: changesOffered
1582
1583
  ? {
1583
1584
  base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch',
@@ -1601,7 +1602,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1601
1602
  // branchWriteBlocked reuses branchContentLocked itself rather than
1602
1603
  // re-deriving `?? true` a second time here, so there is exactly one
1603
1604
  // fail-closed computation of "are writes blocked" in this component.
1604
- branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
1605
+ branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
1605
1606
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1606
1607
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
1607
1608
  // On any entry: EntryHeader's `saving` is the open entry's alone.
@@ -1634,7 +1635,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1634
1635
  // True exactly while the form below shows "Setting up…" or "Loading content…".
1635
1636
  entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
1636
1637
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1637
- hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
1638
+ hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, branchApproveBlocked: currentBranch?.approveBlocked, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
1638
1639
  ? () => showEntryChanges(openEntryChange, currentEntry?.label ?? currentEntry?.slug ?? '', 'header')
1639
1640
  : undefined, showChangesPending: changesOffered && !!currentEntry, showChangesRef: showChangesRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1640
1641
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&