canopycms 0.6.0 → 0.7.0

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 (104) hide show
  1. package/CHANGELOG.md +174 -47
  2. package/dist/api/branch-review.d.ts +11 -1
  3. package/dist/api/branch-review.js +96 -18
  4. package/dist/api/branch-status.d.ts +26 -2
  5. package/dist/api/branch-status.js +38 -2
  6. package/dist/api/branch-withdraw.js +4 -7
  7. package/dist/api/branch.d.ts +2 -0
  8. package/dist/api/client.d.ts +3 -3
  9. package/dist/api/client.js +4 -4
  10. package/dist/api/comments.d.ts +2 -2
  11. package/dist/api/comments.js +4 -0
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/github-sync.d.ts +4 -0
  14. package/dist/api/github-sync.js +38 -6
  15. package/dist/api/pr-comment-sync.d.ts +18 -0
  16. package/dist/api/pr-comment-sync.js +77 -0
  17. package/dist/api/request-deadline.d.ts +15 -7
  18. package/dist/api/request-deadline.js +15 -7
  19. package/dist/api/schema.d.ts +16 -16
  20. package/dist/authorization/review-role.d.ts +33 -0
  21. package/dist/authorization/review-role.js +17 -0
  22. package/dist/branch-metadata-file.d.ts +3 -0
  23. package/dist/branch-metadata-file.js +1 -0
  24. package/dist/cli/cli.js +699 -130
  25. package/dist/cli/generate-ai-content.js +524 -59
  26. package/dist/cli/github-app-output.d.ts +34 -0
  27. package/dist/cli/github-app-output.js +85 -0
  28. package/dist/cli/init-github-app.d.ts +18 -0
  29. package/dist/cli/init-github-app.js +66 -58
  30. package/dist/cli/init.js +10 -1
  31. package/dist/comment-store.d.ts +5 -0
  32. package/dist/comment-store.js +24 -0
  33. package/dist/config/helpers.js +1 -3
  34. package/dist/config/schemas/collection.d.ts +2 -2
  35. package/dist/config/schemas/config.d.ts +3 -0
  36. package/dist/config/schemas/config.js +2 -1
  37. package/dist/config/schemas/url.d.ts +6 -0
  38. package/dist/config/schemas/url.js +16 -0
  39. package/dist/config/types.d.ts +12 -1
  40. package/dist/content-id-index.d.ts +9 -0
  41. package/dist/content-id-index.js +31 -0
  42. package/dist/editor/BranchManager.d.ts +3 -0
  43. package/dist/editor/BranchManager.js +75 -44
  44. package/dist/editor/CanopyEditor.js +2 -2
  45. package/dist/editor/CanopyEditorPage.js +3 -4
  46. package/dist/editor/Editor.js +47 -8
  47. package/dist/editor/branch-lock.d.ts +7 -0
  48. package/dist/editor/branch-lock.js +9 -2
  49. package/dist/editor/client-reference-resolver.d.ts +5 -3
  50. package/dist/editor/client-reference-resolver.js +10 -5
  51. package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
  52. package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
  53. package/dist/editor/components/EditorHeader.d.ts +10 -4
  54. package/dist/editor/components/EditorHeader.js +108 -28
  55. package/dist/editor/components/EntryHeader.d.ts +5 -1
  56. package/dist/editor/components/EntryHeader.js +3 -1
  57. package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
  58. package/dist/editor/components/ReasonedMenuItem.js +2 -2
  59. package/dist/editor/components/ReviewDialogs.d.ts +19 -0
  60. package/dist/editor/components/ReviewDialogs.js +41 -0
  61. package/dist/editor/copy.d.ts +4 -0
  62. package/dist/editor/copy.js +4 -0
  63. package/dist/editor/fields/MarkdownField.js +1 -1
  64. package/dist/editor/hooks/create-branch-request.d.ts +6 -2
  65. package/dist/editor/hooks/create-branch-request.js +7 -2
  66. package/dist/editor/hooks/useBranchManager.d.ts +7 -2
  67. package/dist/editor/hooks/useBranchManager.js +252 -37
  68. package/dist/editor-url.d.ts +20 -0
  69. package/dist/editor-url.js +71 -0
  70. package/dist/git-manager.js +10 -6
  71. package/dist/github-service.d.ts +2 -2
  72. package/dist/github-service.js +4 -2
  73. package/dist/pr-comments.d.ts +33 -0
  74. package/dist/pr-comments.js +41 -0
  75. package/dist/schema/schema-store.d.ts +8 -8
  76. package/dist/services.d.ts +2 -17
  77. package/dist/services.js +38 -1
  78. package/dist/settings-workspace.d.ts +21 -7
  79. package/dist/settings-workspace.js +91 -16
  80. package/dist/submission-attribution.d.ts +38 -1
  81. package/dist/submission-attribution.js +197 -18
  82. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  83. package/dist/types.d.ts +6 -0
  84. package/dist/utils/content-serialize.js +7 -6
  85. package/dist/utils/markdown-body-splice.d.ts +15 -10
  86. package/dist/utils/markdown-body-splice.js +71 -28
  87. package/dist/utils/markdown-export-options.d.ts +13 -0
  88. package/dist/utils/markdown-export-options.js +13 -0
  89. package/dist/utils/markdown-restyle.d.ts +58 -0
  90. package/dist/utils/markdown-restyle.js +482 -0
  91. package/dist/version.d.ts +1 -1
  92. package/dist/version.js +1 -1
  93. package/dist/worker/bundle-header.d.ts +13 -9
  94. package/dist/worker/bundle-header.js +17 -11
  95. package/dist/worker/git-process.d.ts +2 -2
  96. package/dist/worker/github-gateway-http.js +3 -2
  97. package/dist/worker/github-gateway-wire.d.ts +3 -0
  98. package/dist/worker/github-gateway-wire.js +1 -0
  99. package/dist/worker/github-gateway.d.ts +1 -1
  100. package/dist/worker/github-transfer.js +72 -2
  101. package/dist/worker/task-runner.js +53 -3
  102. package/package.json +2 -2
  103. package/dist/editor/fields/markdown-export-options.d.ts +0 -2
  104. package/dist/editor/fields/markdown-export-options.js +0 -8
@@ -25,6 +25,9 @@ import path from 'node:path';
25
25
  import { isValidId } from './id.js';
26
26
  import { isNotFoundError } from './utils/error.js';
27
27
  import { canopyLogWarn } from './utils/logger.js';
28
+ import { entryLogicalPath, normalizeFilesystemPath } from './paths/normalize.js';
29
+ import { parseTypedFilename } from './utils/typed-filename.js';
30
+ import { getFormatExtension } from './utils/format.js';
28
31
  /** Logical path representing entries stored at the branch root (no parent collection). Rare in practice. */
29
32
  const EMPTY_LOGICAL_PATH = '';
30
33
  /**
@@ -39,6 +42,34 @@ function toLogicalCollectionPath(physicalPath) {
39
42
  .map((seg) => extractSlugFromFilename(seg))
40
43
  .join('/');
41
44
  }
45
+ /** The extension of every ContentFormat, dot included; keyed so a new format fails to compile. */
46
+ const ENTRY_EXTENSIONS = new Set(Object.keys({ md: true, mdx: true, json: true, yaml: true }).map((format) => getFormatExtension(format)));
47
+ /**
48
+ * The path the editor lists the entry a file stores under (`EditorEntry.path`, which its
49
+ * `entry` param matches), or null for a file that cannot be a listed entry: outside
50
+ * `contentRoot`, under a dot-prefixed or `_ids_` segment, or not named
51
+ * `{type}.{slug}.{id}.{ext}` with a content format's extension (parseTypedFilename). The content root is kept as
52
+ * spelled, as the schema spells it; the directories below lose their IDs. No I/O, and no
53
+ * check that the directory is a collection or the type one of its entry types.
54
+ */
55
+ export function entryLogicalPathOfFile(relativePath, contentRoot) {
56
+ const root = normalizeFilesystemPath(contentRoot);
57
+ const file = normalizeFilesystemPath(relativePath);
58
+ if (!file.startsWith(`${root}/`))
59
+ return null;
60
+ const below = file.slice(root.length + 1).split('/');
61
+ if (below.some((s) => s.startsWith('.') || s === '_ids_'))
62
+ return null;
63
+ const name = below[below.length - 1];
64
+ if (!ENTRY_EXTENSIONS.has(name.slice(name.lastIndexOf('.'))))
65
+ return null;
66
+ const parsed = parseTypedFilename(name);
67
+ if (!parsed)
68
+ return null;
69
+ const dirs = below.slice(0, -1);
70
+ const collection = dirs.length > 0 ? `${root}/${toLogicalCollectionPath(dirs.join('/'))}` : root;
71
+ return entryLogicalPath(collection, parsed.slug);
72
+ }
42
73
  /**
43
74
  * Bidirectional ContentId <-> path index over `{type}.{slug}.{12-char-id}.{ext}`
44
75
  * files and `{slug}.{12-char-id}/` directories, scanned lazily on first access
@@ -59,6 +59,8 @@ export declare const getBranchPermissions: (branch: BranchSummary, user: UserCon
59
59
  canSubmit: boolean;
60
60
  canWithdraw: boolean;
61
61
  canDelete: boolean;
62
+ /** Approve is open to this user now: a reviewer other than the creator, on a submitted branch with a live PR. */
63
+ canApprove: boolean;
62
64
  canRequestChanges: boolean;
63
65
  };
64
66
  export interface BranchManagerProps {
@@ -87,6 +89,7 @@ export interface BranchManagerProps {
87
89
  onDelete?: (name: string) => void;
88
90
  onSubmit?: (name: string) => void;
89
91
  onWithdraw?: (name: string) => void;
92
+ onApprove?: (name: string) => void;
90
93
  onRequestChanges?: (name: string) => void;
91
94
  onClose?: () => void;
92
95
  comments?: CommentThread[];
@@ -13,7 +13,8 @@ import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
13
13
  import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
14
14
  import { RelativeTime } from './components/RelativeTime.js';
15
15
  import { branchStatusPresentation } from './branch-status.js';
16
- import { EDITOR_ACTIONS } from './copy.js';
16
+ import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
17
+ import { isReviewerOfOthersReview } from '../authorization/review-role.js';
17
18
  import { SYSTEM_USER_ID } from '../system-users.js';
18
19
  /**
19
20
  * What the current user can do to a branch, by the backend's hybrid model. Workflow access
@@ -30,6 +31,7 @@ export const getBranchPermissions = (branch, user) => {
30
31
  canSubmit: false,
31
32
  canWithdraw: false,
32
33
  canDelete: false,
34
+ canApprove: false,
33
35
  canRequestChanges: false,
34
36
  };
35
37
  }
@@ -58,8 +60,8 @@ export const getBranchPermissions = (branch, user) => {
58
60
  // fail-open for anything constructing a BranchSummary directly.
59
61
  const canSubmit = canPerformWorkflowActions && !(branch.submitBlocked ?? true);
60
62
  // Withdraw: can perform workflow actions AND branch is submitted or
61
- // approved -- 'approved' is included because withdraw is that status's only
62
- // non-destructive exit, and getBranchPermissions must grant canWithdraw for
63
+ // approved -- 'approved' is included because withdraw is the author's way back from it (an
64
+ // author is offered no Request changes), and getBranchPermissions must grant canWithdraw for
63
65
  // it to be reachable. Allowed even when the PR was closed without merging:
64
66
  // that's the deliberate recovery path for a closed-unmerged PR (server-side
65
67
  // skip in api/branch-withdraw.ts). Not additionally gated on
@@ -73,26 +75,29 @@ export const getBranchPermissions = (branch, user) => {
73
75
  branch.status !== 'submitted' &&
74
76
  branch.status !== 'approved' &&
75
77
  !branch.isProtected;
76
- // Request changes: Only Reviewers or Admins can request changes on submitted
77
- // branches. Same closed-PR restriction as withdraw applies (converts PR to draft).
78
- const canRequestChanges = (userIsAdmin || userIsReviewer) &&
79
- branch.status === 'submitted' &&
80
- branch.pullRequestState !== 'closed';
78
+ // Approve and Request changes: a Reviewer or Admin other than the creator, who for an author
79
+ // would only do what Withdraw does. Neither applies to the protected base branch, whose way
80
+ // out of review is Withdraw. A closed PR refuses both (Request changes converts the PR to
81
+ // a draft); Approve also needs the branch submitted, Request changes accepts approved too.
82
+ const reviewsOthers = (userIsAdmin || userIsReviewer) && !userIsCreator && !branch.isProtected;
83
+ const prLive = branch.pullRequestState !== 'closed';
84
+ const canApprove = reviewsOthers && branch.status === 'submitted' && prLive;
85
+ const canRequestChanges = reviewsOthers && (branch.status === 'submitted' || branch.status === 'approved') && prLive;
81
86
  return {
82
87
  hasWorkflowAccess: canPerformWorkflowActions,
83
88
  canSubmit,
84
89
  canWithdraw,
85
90
  canDelete,
91
+ canApprove,
86
92
  canRequestChanges,
87
93
  };
88
94
  };
89
95
  /** The lookup when a card has none: an owner reads "Name unavailable" and access users are counted, not named. */
90
96
  const noUserLookup = async () => null;
91
- const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Withdraw the branch from review, then submit it again to open a new one.';
92
97
  /**
93
98
  * The workflow action a branch's status calls for, or undefined when none applies: Submit for
94
- * an unprotected branch in editing, Withdraw once it is in review. 'approved' withdraws too, as
95
- * its only non-destructive exit. A user without workflow access is offered neither, since it
99
+ * an unprotected branch in editing, Withdraw once it is in review, 'approved' included: it is the
100
+ * author's way back. A user without workflow access is offered neither, since it
96
101
  * would never apply to them; a reason is given only for a block that depends on the branch's state.
97
102
  */
98
103
  const workflowAction = (b, perms, onSubmit, onWithdraw) => {
@@ -117,58 +122,84 @@ const workflowAction = (b, perms, onSubmit, onWithdraw) => {
117
122
  }
118
123
  return undefined;
119
124
  };
120
- /** Whether `user` reviews `b` rather than owns it: a reviewer or admin who neither made it nor was given access. */
121
- const isReviewerOfOthersSubmission = (b, user) => !!user &&
122
- isReviewer(user.groups) &&
123
- b.status === 'submitted' &&
124
- b.createdBy !== user.userId &&
125
- !b.access?.users?.includes(user.userId) &&
126
- !user.groups?.some((g) => b.access?.groups?.includes(g));
125
+ /** Whether `user` reviews `b` rather than owns it; see `isReviewerOfOthersReview`. */
126
+ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
127
+ status: b.status,
128
+ createdBy: b.createdBy,
129
+ users: b.access?.users,
130
+ groups: b.access?.groups,
131
+ isProtected: b.isProtected,
132
+ pullRequestState: b.pullRequestState,
133
+ }, user);
127
134
  /**
128
135
  * One branch: its state, and one primary action, with the rest in a "…" menu. The current branch
129
- * shows its workflow action, or Request changes to a reviewer looking at someone else's
130
- * submission; the others show Open, which the current one never needs. No action is shown twice.
136
+ * shows its workflow action; to a reviewer of someone else's submission it shows Approve, with
137
+ * Request changes beside it (Request changes alone once approved). Another such branch shows that
138
+ * reviewer Review, which opens it; the other cards show Open, which the current one never needs.
139
+ * No action is shown twice.
131
140
  */
132
- const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
141
+ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
133
142
  const statusBadge = branchStatusPresentation(b.status, b.isProtected);
134
143
  const perms = getBranchPermissions(b, user);
135
144
  const action = workflowAction(b, perms, onSubmit, onWithdraw);
136
145
  const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
137
146
  const userIsAdmin = isAdmin(user?.groups);
138
- const requestChanges = isReviewer(user?.groups) && b.status === 'submitted'
147
+ // The open branch holds back its review actions as it does its workflow action; the reason is
148
+ // given only for the open branch.
149
+ const heldBack = (a) => a && workflowBlockedReason ? { ...a, blockedReason: workflowBlockedReason } : a;
150
+ // Shown to a reviewer other than the creator; a closed PR or a held-back open branch blocks it.
151
+ const reviewsOthers = isReviewer(user?.groups) && !!user && b.createdBy !== user.userId && !b.isProtected;
152
+ const approve = heldBack(reviewsOthers && b.status === 'submitted'
153
+ ? {
154
+ label: EDITOR_ACTIONS.approve,
155
+ color: 'brand',
156
+ testId: `approve-branch-button-${b.name}`,
157
+ run: () => onApprove?.(b.name),
158
+ blockedReason: perms.canApprove ? undefined : CLOSED_PULL_REQUEST_REASON,
159
+ }
160
+ : undefined);
161
+ const requestChanges = heldBack(reviewsOthers && (b.status === 'submitted' || b.status === 'approved')
139
162
  ? {
140
163
  label: EDITOR_ACTIONS.requestChanges,
141
164
  color: 'orange',
142
165
  testId: `request-changes-branch-button-${b.name}`,
143
166
  run: () => onRequestChanges?.(b.name),
144
- blockedReason: b.pullRequestState === 'closed' ? CLOSED_PULL_REQUEST_REASON : undefined,
167
+ blockedReason: perms.canRequestChanges ? undefined : CLOSED_PULL_REQUEST_REASON,
168
+ // With Approve above it, blocked by the same closed pull request, the reason prints once.
169
+ reasonShownBy: approve && !perms.canApprove && b.pullRequestState === 'closed'
170
+ ? approve.testId
171
+ : undefined,
145
172
  }
146
- : undefined;
147
- const primary = !isCurrent
148
- ? undefined
149
- : isReviewerOfOthersSubmission(b, user)
150
- ? requestChanges
151
- : workflow;
152
- const menuActions = [workflow, requestChanges].filter((a) => !!a && a !== primary);
173
+ : undefined);
174
+ const reviewing = reviewsOthersBranch(b, user);
175
+ // Reviewing the open branch: Approve leads with Request changes beside it, or Request changes
176
+ // alone once approved. Reviewing another one, Review opens it and the actions wait in the menu.
177
+ const primary = !isCurrent ? undefined : reviewing ? (approve ?? requestChanges) : workflow;
178
+ const secondary = isCurrent && reviewing && approve ? requestChanges : undefined;
179
+ // Two visible actions, or the long "Withdraw from review", would crowd the title at the drawer's
180
+ // width, so they take a row below it. Open and Review stay beside the title.
181
+ const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview;
182
+ const menuActions = [approve, requestChanges, workflow].filter((a) => !!a && a !== primary && a !== secondary);
153
183
  const showViewPullRequest = !!b.pullRequestUrl;
154
184
  const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
155
185
  const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
156
186
  const menuLabel = `More actions for ${b.name}`;
157
187
  const [menuOpen, setMenuOpen] = useState(false);
158
- return (_jsx(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: b.name }), 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'
159
- ? 'red'
160
- : b.pullRequestState === 'merged'
161
- ? 'violet'
162
- : '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": `switch-to-branch-button-${b.name}`, children: "Open" })), primary && (_jsx(Tooltip, { label: primary.blockedReason, disabled: !primary.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: primary.color, onClick: () => {
163
- if (!primary.blockedReason)
164
- primary.run();
165
- }, "data-disabled": primary.blockedReason ? true : undefined, "aria-disabled": primary.blockedReason ? true : undefined, "data-testid": primary.testId, children: primary.label }) })), 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, 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
166
- ? `View pull request #${b.pullRequestNumber}`
167
- : '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'
168
- ? 'This branch is in review. Withdraw it from review before deleting it.'
169
- : undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }) }));
188
+ 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: b.name }), 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'
189
+ ? 'red'
190
+ : b.pullRequestState === 'merged'
191
+ ? 'violet'
192
+ : '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
193
+ ? `View pull request #${b.pullRequestNumber}`
194
+ : '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'
195
+ ? 'This branch is in review. Withdraw it from review before deleting it.'
196
+ : 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 })] }))] }));
170
197
  };
171
- export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onRequestChanges, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
198
+ const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
199
+ if (!action.blockedReason)
200
+ action.run();
201
+ }, "data-disabled": action.blockedReason ? true : undefined, "aria-disabled": action.blockedReason ? true : undefined, "data-testid": action.testId, children: action.label }) }));
202
+ export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
172
203
  const supportsBranching = clientOperatingStrategy(mode).supportsBranching();
173
204
  const [showCreateForm, setShowCreateForm] = useState(startCreating);
174
205
  const [newBranchName, setNewBranchName] = useState('');
@@ -209,5 +240,5 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
209
240
  ]
210
241
  : branches;
211
242
  const hasBranchThreads = comments.some((t) => t.type === 'branch');
212
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
243
+ return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onApprove: onApprove, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
213
244
  };
@@ -8,8 +8,8 @@ import { buildEditorCollections } from './editor-config.js';
8
8
  export const CanopyEditor = ({ config, entries = [], initialSelectedId, initialValues, renderPreview, onCreateEntry, branchName, customRenderers, }) => {
9
9
  const collections = useMemo(() => buildEditorCollections(config.flatSchema), [config.flatSchema]);
10
10
  // Empty string = branchless start; useBranchManager adopts the server's
11
- // detected default branch on the first branches.list() load.
12
- const resolvedBranchName = branchName ?? config.defaultActiveBranch ?? config.defaultBaseBranch ?? '';
11
+ // default branch on the first branches.list() load.
12
+ const resolvedBranchName = branchName ?? '';
13
13
  const resolvedTitle = config.editor?.title ?? 'CanopyCMS Editor';
14
14
  const resolvedTheme = config.editor?.theme ?? undefined;
15
15
  // The gate owns the SWRProvider (keyed by user id), so it sits between the API client and the
@@ -9,10 +9,9 @@ import { CanopyEditor } from './CanopyEditor.js';
9
9
  */
10
10
  export const CanopyEditorPage = (config, customRenderers) => {
11
11
  const CanopyEditorPageInner = ({ searchParams, }) => {
12
- // No 'main' fallback: when nothing pins a branch, the editor starts
13
- // branchless and adopts the server's detected default from the branches
14
- // API (see useBranchManager.loadBranches).
15
- const branchName = searchParams?.branch ?? config.defaultActiveBranch ?? config.defaultBaseBranch;
12
+ // Without ?branch= the editor starts branchless and useBranchManager adopts
13
+ // the branches API's defaultBranch.
14
+ const branchName = searchParams?.branch;
16
15
  const initialSelectedId = searchParams?.entry;
17
16
  return (_jsx(CanopyEditor, { config: config, branchName: branchName, initialSelectedId: initialSelectedId, entries: [], customRenderers: customRenderers }));
18
17
  };
@@ -24,6 +24,7 @@ import { PermissionManager } from './PermissionManager.js';
24
24
  import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
25
25
  // Import directly from helpers to avoid server-only code in authorization barrel
26
26
  import { isAdmin, isPrivileged, isReviewer } from '../authorization/helpers.js';
27
+ import { reviewRoleOf } from '../authorization/review-role.js';
27
28
  import { fieldBreadcrumb } from './field-breadcrumb.js';
28
29
  import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
29
30
  import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
@@ -44,6 +45,7 @@ import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
44
45
  import { ReferencedByList, referencedDeleteMessage } from './components/ReferencedByList.js';
45
46
  import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
46
47
  import { BranchNotAvailableNotice } from './components/BranchNotAvailableNotice.js';
48
+ import { BranchesLoadFailedNotice } from './components/BranchesLoadFailedNotice.js';
47
49
  import { ReadOnlyDraftNotice } from './components/ReadOnlyDraftNotice.js';
48
50
  import { UnavailableEntryNotice } from './components/UnavailableEntryNotice.js';
49
51
  import { unavailableSchemaRefs } from './unavailable-entry-type.js';
@@ -160,7 +162,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
160
162
  labels: [],
161
163
  }));
162
164
  const applySubmitStampsRef = useRef(() => { });
163
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
165
+ // Reloads the open branch's comments after a request for changes, whose note is a comment; the
166
+ // comment system is built further down.
167
+ const reloadCommentsRef = useRef(() => { });
168
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
164
169
  initialBranch: branchName,
165
170
  operatingMode,
166
171
  setBusy: setBranchesLoading,
@@ -168,6 +173,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
168
173
  // The draft manager comes later, as it depends on this hook's branch.
169
174
  getUnsaved: () => resolveUnsavedRef.current(),
170
175
  onStamped: (stamped) => applySubmitStampsRef.current(stamped),
176
+ onChangesRequested: (branch) => reloadCommentsRef.current(branch),
171
177
  });
172
178
  // Content mutations (add/delete/rename/reorder entries) are locked whenever the
173
179
  // server says writes are blocked -- the protected base branch, or a workflow
@@ -185,6 +191,17 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
185
191
  // No lock reason is true yet while the list is unanswered; once it has failed, or answered
186
192
  // without this branch, the lock says which.
187
193
  const branchPending = !currentBranch && !branchesLoaded && !branchesLoadFailed;
194
+ // The same inputs EditorHeader gives its own check, for the lock copy that names the way forward.
195
+ const branchReviewRole = reviewRoleOf({
196
+ status: currentBranch?.status,
197
+ createdBy: currentBranch?.createdBy,
198
+ users: currentBranch?.access.allowedUsers,
199
+ groups: currentBranch?.access.allowedGroups,
200
+ isProtected: currentBranch?.isProtected ?? true,
201
+ pullRequestState: currentBranch?.pullRequestState,
202
+ }, userContext);
203
+ const branchReviewing = branchReviewRole === 'reviewing';
204
+ const branchPullRequestClosed = branchReviewRole === 'reviewing-closed';
188
205
  const branchMissing = !currentBranch && branchesLoaded;
189
206
  // The bar's lock-state row, with the inputs EditorHeader gets, for the states that must agree.
190
207
  const branchBarState = branchBarStatus({
@@ -226,6 +243,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
226
243
  // The "Not available" empty states wait for the load to answer with nothing: content that does
227
244
  // load, for a branch the list lags in granting, shows as usual.
228
245
  const branchShowsNotAvailable = branchNotAvailable && !entriesInitializing && entriesState.length === 0;
246
+ // An unpinned editor takes its branch from the branch list, so without the list it has none.
247
+ const branchListFailed = !branchNameState && branchesLoadFailed;
229
248
  // The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
230
249
  // overlay's. Anything that focuses something in the form reveals it first. Opening an entry
231
250
  // closes the overlay only, so the preview stays maximized across entries (see
@@ -336,7 +355,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
336
355
  await handleBranchChange(branch);
337
356
  };
338
357
  // 5. Comment system (depends on branchNameState)
339
- const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
358
+ const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, loadComments, } = useCommentSystem({
340
359
  branchName: branchNameState,
341
360
  selectedPath,
342
361
  currentEntry,
@@ -347,6 +366,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
347
366
  onCommentsChange: setCommentsForBranchSummaries,
348
367
  onRevealForm: revealForm,
349
368
  });
369
+ reloadCommentsRef.current = (branch) => {
370
+ if (sanitizeBranchName(branch) === sanitizeBranchName(branchNameState))
371
+ void loadComments(branch);
372
+ };
350
373
  const { groupsData, groupsLoading, handleSaveGroups, handleSearchUsers, handleGetUserMetadata, handleSearchExternalGroups, } = useGroupManager({ isOpen: groupManagerOpen });
351
374
  // The comments and branches responses name their people, without email; a privileged lookup
352
375
  // adds the email.
@@ -1368,7 +1391,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1368
1391
  display: 'flex',
1369
1392
  alignItems: 'center',
1370
1393
  justifyContent: 'center',
1371
- }, children: !currentEntry && branchShowsNotAvailable ? (_jsx(BranchNotAvailableNotice, { align: "center", "data-testid": "preview-branch-not-available" })) : (_jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
1394
+ }, children: !currentEntry && branchShowsNotAvailable ? (_jsx(BranchNotAvailableNotice, { align: "center", "data-testid": "preview-branch-not-available" })) : branchListFailed ? (_jsx(BranchesLoadFailedNotice, { align: "center", "data-testid": "preview-branches-load-failed" })) : (_jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
1372
1395
  ? 'Setting up your branch workspace…'
1373
1396
  : entriesInitializing
1374
1397
  ? 'Loading content…'
@@ -1396,8 +1419,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1396
1419
  }, [tabTitle]);
1397
1420
  const openBranchManager = (options) => {
1398
1421
  openBranchesDrawer(options?.create === true);
1399
- // Always retry, not only when branchless. Opening the manager is
1400
- // the one in-app retry for a failed branches fetch, and the fetch
1422
+ // Always retry, not only when branchless. Besides the form pane's Retry on a
1423
+ // branchless start, opening the manager retries a failed branches fetch, and the fetch
1401
1424
  // is terminal on its own: SWRProvider sets shouldRetryOnError and
1402
1425
  // revalidateOnFocus false, and useBranchesData's refreshInterval
1403
1426
  // is 0 without data. Reloading is idempotent.
@@ -1434,6 +1457,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1434
1457
  branchReadOnly: currentBranch?.readOnly,
1435
1458
  branchWriteBlocked: branchContentLocked,
1436
1459
  branchMissing,
1460
+ reviewing: branchReviewing,
1461
+ pullRequestClosed: branchPullRequestClosed,
1437
1462
  }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), changedOnly: changedOnly, onChangedOnlyChange: setChangedOnly, unsavedCount: modifiedCount, unsavedEntries: editedFiles, entryListFailed: entriesLoadFailed && entriesState.length === 0, branchNotAvailable: branchShowsNotAvailable, onSwitchBranch: () => openBranchManager(), filterRef: entryFilterRef, filterShortcut: findShortcut, onFilterEnter: contentPanelTransient
1438
1463
  ? (path) => {
1439
1464
  openEntry(path);
@@ -1441,7 +1466,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1441
1466
  setFormFocusPath(path);
1442
1467
  }
1443
1468
  : undefined, onFilterEscape: contentPanelTransient ? () => setContentPanelTransient(false) : undefined, onRowMenuOpen: keepContentPanelOpen }) }) }));
1444
- return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
1469
+ return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchPullRequestState: currentBranch?.pullRequestState, branchAccess: currentBranch?.access,
1445
1470
  // Fail-closed for the same reason branchContentLocked above is:
1446
1471
  // isProtected/writeBlocked gate real actions in EditorHeader and EntryHeader
1447
1472
  // (hiding Submit, disabling Save and Save all), so a missing wire value must read as
@@ -1463,6 +1488,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1463
1488
  if (branchNameState && !isSaveAllRunning()) {
1464
1489
  handleWithdraw(branchNameState, returnFocusTo).catch(console.error);
1465
1490
  }
1491
+ }, onApprove: (returnFocusTo) => {
1492
+ if (branchNameState && !isSaveAllRunning()) {
1493
+ handleApprove(branchNameState, returnFocusTo).catch(console.error);
1494
+ }
1495
+ }, onRequestChanges: (returnFocusTo) => {
1496
+ if (branchNameState && !isSaveAllRunning()) {
1497
+ handleRequestChanges(branchNameState, returnFocusTo).catch(console.error);
1498
+ }
1466
1499
  } }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelTransient
1467
1500
  ? keepContentPanelOpen()
1468
1501
  : contentPanelShown
@@ -1474,7 +1507,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1474
1507
  // True exactly while the form below shows "Setting up…" or "Loading content…".
1475
1508
  entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
1476
1509
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1477
- 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, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, 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 &&
1510
+ 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, 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 &&
1478
1511
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
1479
1512
  lastSaved.contentId === currentEntry?.contentId }), _jsx(Box, { style: { flex: '1 1 0%', minHeight: 0 }, children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
1480
1513
  // Sizing the pane, by pointer or key, gives the page the pane's width.
@@ -1487,7 +1520,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1487
1520
  setPreviewWidth('fit');
1488
1521
  }, preview: renderPreview && currentEntry && !currentEntryUnavailable
1489
1522
  ? renderPreview(currentEntry, displayValue)
1490
- : defaultPreview, form: !currentEntry && branchShowsNotAvailable ? (_jsx(Center, { h: "100%", children: _jsx(BranchNotAvailableNotice, { align: "center", onSwitchBranch: () => openBranchManager(), "data-testid": "form-branch-not-available" }) })) : !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
1523
+ : defaultPreview, form: !currentEntry && branchShowsNotAvailable ? (_jsx(Center, { h: "100%", children: _jsx(BranchNotAvailableNotice, { align: "center", onSwitchBranch: () => openBranchManager(), "data-testid": "form-branch-not-available" }) })) : branchListFailed ? (_jsx(Center, { h: "100%", children: _jsx(BranchesLoadFailedNotice, { align: "center", onRetry: () => void loadBranches().catch(console.error), retrying: busy, "data-testid": "form-branches-load-failed" }) })) : !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
1491
1524
  ? 'Setting up your branch workspace…'
1492
1525
  : entriesInitializing
1493
1526
  ? 'Loading content…'
@@ -1532,7 +1565,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1532
1565
  if (name === branchNameState && isSaveAllRunning())
1533
1566
  return;
1534
1567
  handleWithdraw(name).catch((err) => console.error(err));
1568
+ }, onApprove: (name) => {
1569
+ if (name === branchNameState && isSaveAllRunning())
1570
+ return;
1571
+ handleApprove(name).catch((err) => console.error(err));
1535
1572
  }, onRequestChanges: (name) => {
1573
+ if (name === branchNameState && isSaveAllRunning())
1574
+ return;
1536
1575
  handleRequestChanges(name).catch((err) => console.error(err));
1537
1576
  }, onDelete: (name) => {
1538
1577
  handleDelete(name).catch((err) => console.error(err));
@@ -8,6 +8,13 @@ export interface BranchLockInput {
8
8
  branchPending?: boolean;
9
9
  /** The branch list answered without this branch: no access to it, or it no longer exists. */
10
10
  branchMissing?: boolean;
11
+ /** The user reviews this branch (`isReviewerOfOthersReview`), so Approve and Request changes lead, not Withdraw. */
12
+ reviewing?: boolean;
13
+ /**
14
+ * The user would review this branch but its pull request was closed (`reviewRoleOf`), so the
15
+ * way forward is Withdraw and the reason says why Approve is not offered.
16
+ */
17
+ pullRequestClosed?: boolean;
11
18
  }
12
19
  /**
13
20
  * Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
@@ -1,4 +1,5 @@
1
1
  import { branchStatusPresentation } from './branch-status.js';
2
+ import { CLOSED_PULL_REQUEST_REASON } from './copy.js';
2
3
  /**
3
4
  * Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
4
5
  * nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
@@ -26,10 +27,16 @@ export function describeBranchLock(lock) {
26
27
  : 'Branch details could not be loaded. Use "Refresh branch" to try again.';
27
28
  if (!statusLocked)
28
29
  return undefined;
30
+ if (lock.pullRequestClosed)
31
+ return CLOSED_PULL_REQUEST_REASON;
29
32
  if (lock.branchStatus === 'submitted')
30
- return 'This branch is in review. Withdraw it to make changes.';
33
+ return lock.reviewing
34
+ ? 'This branch is in review. Approve it, or request changes to send it back to its editor.'
35
+ : 'This branch is in review. Withdraw it to make changes.';
31
36
  if (lock.branchStatus === 'approved')
32
- return 'This branch is approved. Withdraw it to make changes.';
37
+ return lock.reviewing
38
+ ? 'This branch is approved. Request changes to send it back to its editor.'
39
+ : 'This branch is approved. Withdraw it to make changes.';
33
40
  return `This branch is ${lock.branchStatus}. Its content is read-only.`;
34
41
  }
35
42
  const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
@@ -38,9 +38,11 @@ export declare function expireReferences(cache: ReferenceCache): void;
38
38
  /**
39
39
  * The draft as the preview sees it, computed synchronously from the cache.
40
40
  *
41
- * Each reference becomes its cached value (the target, or an `unavailable` reference), or `null`
42
- * while it has none (still resolving, a failed lookup, or a malformed id). A list field's array maps element by element, a
43
- * non-string element to `null`; anything else is left as the form holds it, as on the server.
41
+ * Each id becomes its cached value (the target, or an `unavailable` reference), or `null` while it
42
+ * has none (still resolving, a failed lookup, or a malformed id). A reference the form holds as an
43
+ * object is the target the entry's read resolved, and passes through as it is, alone or in a list.
44
+ * A list field's array maps element by element, any other non-string element to `null`; anything
45
+ * else is left as the form holds it, as on the server.
44
46
  * `loadingState` holds a boolean only at reference positions, `true` while resolving, at the
45
47
  * reference's own path (`boolean[]` for a list).
46
48
  */
@@ -1,7 +1,7 @@
1
1
  import { createApiClient } from '../api/client.js';
2
2
  import { flattenGroupFields } from '../utils/flatten-group-fields.js';
3
3
  import { isValidContentId } from '../paths/validation.js';
4
- import { traverseFields } from '../validation/field-traversal.js';
4
+ import { referenceValueId, traverseFields } from '../validation/field-traversal.js';
5
5
  /** The resolve-references endpoint's per-request id cap (`MAX_RESOLVE_REFERENCE_IDS`). */
6
6
  const MAX_IDS_PER_REQUEST = 100;
7
7
  /**
@@ -135,9 +135,11 @@ function setCreating(root, path, leaf, source) {
135
135
  /**
136
136
  * The draft as the preview sees it, computed synchronously from the cache.
137
137
  *
138
- * Each reference becomes its cached value (the target, or an `unavailable` reference), or `null`
139
- * while it has none (still resolving, a failed lookup, or a malformed id). A list field's array maps element by element, a
140
- * non-string element to `null`; anything else is left as the form holds it, as on the server.
138
+ * Each id becomes its cached value (the target, or an `unavailable` reference), or `null` while it
139
+ * has none (still resolving, a failed lookup, or a malformed id). A reference the form holds as an
140
+ * object is the target the entry's read resolved, and passes through as it is, alone or in a list.
141
+ * A list field's array maps element by element, any other non-string element to `null`; anything
142
+ * else is left as the form holds it, as on the server.
141
143
  * `loadingState` holds a boolean only at reference positions, `true` while resolving, at the
142
144
  * reference's own path (`boolean[]` for a list).
143
145
  */
@@ -146,7 +148,10 @@ export function applyReferenceCache(fields, value, branch, cache) {
146
148
  const loadingState = {};
147
149
  const copies = new WeakSet();
148
150
  const lookup = (id) => {
149
- if (typeof id !== 'string' || !isValidContentId(id))
151
+ if (typeof id !== 'string') {
152
+ return { value: referenceValueId(id) === undefined ? null : id, loading: false };
153
+ }
154
+ if (!isValidContentId(id))
150
155
  return { value: null, loading: false };
151
156
  const entry = cache.get(cacheKey(branch, id));
152
157
  return entry ? { value: entry.value, loading: false } : { value: null, loading: true };
@@ -0,0 +1,13 @@
1
+ export interface BranchesLoadFailedNoticeProps {
2
+ /** Loads the branch list again. Without it the notice is text alone. */
3
+ onRetry?: () => void;
4
+ /** A retry is in flight. */
5
+ retrying?: boolean;
6
+ align?: 'flex-start' | 'center';
7
+ 'data-testid'?: string;
8
+ }
9
+ /**
10
+ * The error state of an editor no branch is pinned in, while the branch list it takes its
11
+ * branch from has failed to load. It outlasts the dismissible toast for the same failure.
12
+ */
13
+ export declare function BranchesLoadFailedNotice({ onRetry, retrying, align, 'data-testid': testId, }: BranchesLoadFailedNoticeProps): import("react/jsx-runtime").JSX.Element;