canopycms 0.7.1 → 0.8.1

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 (152) hide show
  1. package/CHANGELOG.md +197 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-schema-cache.d.ts +22 -2
  39. package/dist/branch-schema-cache.js +88 -12
  40. package/dist/branch-workspace.js +22 -15
  41. package/dist/cli/cli.js +1122 -181
  42. package/dist/cli/generate-ai-content.js +791 -109
  43. package/dist/cli/init.js +126 -0
  44. package/dist/comment-store.d.ts +3 -0
  45. package/dist/comment-store.js +4 -1
  46. package/dist/config/schemas/config.d.ts +3 -0
  47. package/dist/config/schemas/config.js +2 -0
  48. package/dist/config/types.d.ts +11 -0
  49. package/dist/content-id-index.d.ts +5 -0
  50. package/dist/content-id-index.js +1 -1
  51. package/dist/content-listing.d.ts +53 -0
  52. package/dist/content-listing.js +123 -27
  53. package/dist/content-reader.d.ts +24 -10
  54. package/dist/content-reader.js +66 -22
  55. package/dist/context.js +51 -48
  56. package/dist/editor/BranchManager.d.ts +7 -3
  57. package/dist/editor/BranchManager.js +51 -6
  58. package/dist/editor/Editor.js +166 -34
  59. package/dist/editor/EntryNavigator.d.ts +34 -10
  60. package/dist/editor/EntryNavigator.js +185 -29
  61. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  62. package/dist/editor/admin/useSystemHealth.js +1 -0
  63. package/dist/editor/branch-names.d.ts +43 -0
  64. package/dist/editor/branch-names.js +75 -0
  65. package/dist/editor/components/BranchPill.d.ts +2 -2
  66. package/dist/editor/components/BranchPill.js +2 -2
  67. package/dist/editor/components/EditorHeader.d.ts +3 -1
  68. package/dist/editor/components/EditorHeader.js +2 -2
  69. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  70. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  71. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  72. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  73. package/dist/editor/components/EntryHeader.d.ts +9 -1
  74. package/dist/editor/components/EntryHeader.js +25 -3
  75. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  76. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  77. package/dist/editor/components/ReviewDialogs.js +3 -3
  78. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  79. package/dist/editor/context/ApiClientContext.js +26 -2
  80. package/dist/editor/context/index.d.ts +1 -1
  81. package/dist/editor/context/index.js +1 -1
  82. package/dist/editor/copy.d.ts +1 -0
  83. package/dist/editor/copy.js +1 -0
  84. package/dist/editor/editor-utils.d.ts +2 -0
  85. package/dist/editor/editor-utils.js +15 -0
  86. package/dist/editor/entry-diff.d.ts +143 -0
  87. package/dist/editor/entry-diff.js +593 -0
  88. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  89. package/dist/editor/hooks/create-branch-request.js +16 -6
  90. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  91. package/dist/editor/hooks/useBranchActions.js +29 -20
  92. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  93. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  94. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  95. package/dist/editor/hooks/useBranchManager.js +142 -54
  96. package/dist/editor/hooks/useDraftManager.js +6 -2
  97. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  98. package/dist/editor/hooks/useEntryManager.js +36 -10
  99. package/dist/editor/utils/editor-errors.d.ts +5 -2
  100. package/dist/git-manager.d.ts +71 -0
  101. package/dist/git-manager.js +270 -1
  102. package/dist/http/handler.js +11 -9
  103. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  104. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  105. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  106. package/dist/operating-mode/branch-namespace.js +121 -0
  107. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  108. package/dist/operating-mode/types.d.ts +8 -0
  109. package/dist/paths/branch-name.d.ts +11 -19
  110. package/dist/paths/branch-name.js +17 -27
  111. package/dist/paths/index.d.ts +2 -2
  112. package/dist/paths/index.js +2 -2
  113. package/dist/paths/resolve.d.ts +0 -1
  114. package/dist/paths/resolve.js +0 -1
  115. package/dist/paths/validation.d.ts +40 -1
  116. package/dist/paths/validation.js +83 -4
  117. package/dist/pr-comments.d.ts +17 -9
  118. package/dist/pr-comments.js +53 -15
  119. package/dist/server.d.ts +6 -1
  120. package/dist/server.js +6 -1
  121. package/dist/services.js +2 -1
  122. package/dist/submission-attribution.d.ts +22 -0
  123. package/dist/submission-attribution.js +62 -6
  124. package/dist/task-queue/worker-status.d.ts +6 -1
  125. package/dist/task-queue/worker-status.js +23 -2
  126. package/dist/types.d.ts +59 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/dist/worker/branch-policy.d.ts +111 -16
  130. package/dist/worker/branch-policy.js +222 -17
  131. package/dist/worker/cms-worker.d.ts +24 -2
  132. package/dist/worker/cms-worker.js +35 -24
  133. package/dist/worker/git-sync.d.ts +3 -3
  134. package/dist/worker/git-sync.js +42 -1
  135. package/dist/worker/github-gateway-http.js +2 -2
  136. package/dist/worker/github-gateway-wire.d.ts +42 -27
  137. package/dist/worker/github-gateway-wire.js +14 -3
  138. package/dist/worker/github-gateway.d.ts +17 -6
  139. package/dist/worker/github-gateway.js +62 -6
  140. package/dist/worker/github-mirror.d.ts +7 -4
  141. package/dist/worker/github-mirror.js +27 -16
  142. package/dist/worker/history-rewrite.d.ts +23 -10
  143. package/dist/worker/history-rewrite.js +43 -20
  144. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  145. package/dist/worker/legacy-branch-fixtures.js +71 -0
  146. package/dist/worker/rebase.d.ts +2 -1
  147. package/dist/worker/rebase.js +93 -36
  148. package/dist/worker/task-runner.d.ts +4 -1
  149. package/dist/worker/task-runner.js +77 -48
  150. package/dist/worker/unpublishable-branches.d.ts +34 -0
  151. package/dist/worker/unpublishable-branches.js +182 -0
  152. package/package.json +2 -1
@@ -8,13 +8,15 @@ import { openConfirm } from '../utils/confirm-modal.js';
8
8
  import { focusReturnProps } from '../utils/focus-return.js';
9
9
  import { EDITOR_ACTIONS } from '../copy.js';
10
10
  import { notifications } from '@mantine/notifications';
11
- import { useApiClient } from '../context/index.js';
11
+ import { useApiClient, useBranchSegments } from '../context/index.js';
12
12
  import { BRANCHES_KEY, fetchBranches, useBranchesData } from './useBranchesData.js';
13
13
  // branch-name, NOT branch or the '../../paths' barrel: both of those pull
14
14
  // node:fs/promises + node:path at the top level (path RESOLUTION helpers),
15
15
  // which breaks adopters' production `next build` of the editor bundle.
16
16
  // paths/branch-name.ts is the dependency-free home of sanitizeBranchName.
17
17
  import { sanitizeBranchName } from '../../paths/branch-name.js';
18
+ import { exactBranchName, exactBranchSegment } from '../../paths/validation.js';
19
+ import { branchAddress, branchUrlName, displayBranchName, requestSegments, unlistedSegment, resolveListedBranch, } from '../branch-names.js';
18
20
  import { RetryToastMessage } from '../components/RetryToastMessage.js';
19
21
  import { isNonApiResponse } from '../../api/client.js';
20
22
  import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
@@ -112,7 +114,7 @@ const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss, retur
112
114
  const leftOut = unsaved && unsaved.count > 0 ? unsaved : undefined;
113
115
  openConfirm({
114
116
  title: EDITOR_ACTIONS.submitForReview,
115
- children: (_jsxs(Stack, { gap: "sm", children: [leftOut && (_jsxs(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${leftOut.count} ${leftOut.count === 1 ? "entry has unsaved changes that won't" : "entries have unsaved changes that won't"} be submitted`, "data-testid": "submit-unsaved-warning", children: [describeUnsaved(leftOut), " To include them, cancel and save first. If you submit now, those changes stay unsaved on this branch while it is in review."] })), _jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Submit "${branchName}" for review?\n\nThis will:\n• Create a pull request for review\n• Change the branch status to "In review"\n• Notify reviewers of pending changes` })] })),
117
+ children: (_jsxs(Stack, { gap: "sm", children: [leftOut && (_jsxs(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${leftOut.count} ${leftOut.count === 1 ? "entry has unsaved changes that won't" : "entries have unsaved changes that won't"} be submitted`, "data-testid": "submit-unsaved-warning", children: [describeUnsaved(leftOut), " To include them, cancel and save first. If you submit now, those changes stay unsaved on this branch while it is in review."] })), _jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Submit “${branchName}” for review?\n\nThis will:\n• Create a pull request for review\n• Change the branch status to “In review”\n• Notify reviewers of pending changes` })] })),
116
118
  labels: {
117
119
  confirm: leftOut ? 'Submit anyway' : EDITOR_ACTIONS.submitForReview,
118
120
  cancel: 'Cancel',
@@ -143,12 +145,12 @@ export function withdrawPullRequestBullet(pr) {
143
145
  const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo) => {
144
146
  const bullets = [
145
147
  withdrawPullRequestBullet(pr),
146
- 'Change the branch status back to "editing"',
148
+ 'Change the branch status back to “editing”',
147
149
  'Remove from review queue',
148
150
  ].filter((b) => b !== undefined);
149
151
  openConfirm({
150
152
  title: EDITOR_ACTIONS.withdrawFromReview,
151
- children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Withdraw "${branchName}" from review?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}` })),
153
+ children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Withdraw “${branchName}” from review?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}` })),
152
154
  labels: { confirm: EDITOR_ACTIONS.withdrawFromReview, cancel: 'Cancel' },
153
155
  confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
154
156
  ...confirmModalHandlers(onConfirm, onDismiss),
@@ -201,7 +203,7 @@ const showDeleteConfirmation = (branchName, pr, onConfirm, onDismiss) => {
201
203
  ].filter((b) => b !== undefined);
202
204
  openConfirm({
203
205
  title: 'Delete branch',
204
- children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Delete "${branchName}"?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}\n\nThis cannot be undone.` })),
206
+ children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Delete “${branchName}”?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}\n\nThis cannot be undone.` })),
205
207
  labels: { confirm: 'Delete branch', cancel: 'Cancel' },
206
208
  // data-testid is load-bearing, not decoration: the e2e helper
207
209
  // (apps/test-app/e2e/fixtures/branch-page.ts's deleteBranch) already
@@ -227,6 +229,15 @@ function mergePendingBranches(listed, pending) {
227
229
  const missing = pending.filter((p) => !listedNames.has(p.branch.name));
228
230
  return missing.length === 0 ? listed : [...listed, ...missing.map((p) => p.branch)];
229
231
  }
232
+ /**
233
+ * What a request sends for `name`, a full name the listing no longer holds: the exact form when a
234
+ * listed short name is `name`, which would open that namespace branch instead, else as unlisted.
235
+ */
236
+ function unlistedName(listed, name) {
237
+ return listed.some((b) => b.name !== name && displayBranchName(b) === name)
238
+ ? exactBranchSegment(name)
239
+ : unlistedSegment(name);
240
+ }
230
241
  /**
231
242
  * Applies one received listing to the pending branches: drops those it arrived
232
243
  * past the grace window for, and takes the listing's copy of the rest. Written
@@ -258,10 +269,31 @@ function reconcilePendingBranches(pending, listed, receivedAt) {
258
269
  export function useBranchManager(options) {
259
270
  const apiClient = useApiClient();
260
271
  const { cache, mutate: globalMutate } = useSWRConfig();
261
- const [branchName, setBranchName] = useState(options.initialBranch);
272
+ // The open branch. Once `exact`, `name` is the listed branch's full name, matched only by that
273
+ // name: a listing that changes which branch a short name opens never moves it. Until then
274
+ // `name` is what a URL, link, history entry or card gave, which `resolveListedBranch` resolves
275
+ // once a listing holds it.
276
+ const [open, setOpen] = useState({ name: options.initialBranch, exact: false });
277
+ const setBranchName = useCallback((name) => setOpen({ name, exact: false }), []);
278
+ const openExactly = useCallback((name) => setOpen({ name, exact: true }), []);
262
279
  const { data: branchesData, error: branchesError, isValidating: branchesIsValidating, } = useBranchesData(apiClient);
263
280
  const [pendingBranches, setPendingBranches] = useState([]);
264
281
  const branches = useMemo(() => mergePendingBranches(branchesData?.branches ?? [], pendingBranches), [branchesData, pendingBranches]);
282
+ const arriving = open.exact ? undefined : resolveListedBranch(branches, open.name);
283
+ const branchName = arriving?.name ?? open.name;
284
+ const resolved = open.exact || !!arriving;
285
+ const currentBranch = open.exact ? branches.find((b) => b.name === open.name) : arriving;
286
+ const currentBranchName = currentBranch?.name;
287
+ // A listed branch by `requestSegments`; the open branch, once the listing drops it, as
288
+ // `unlistedName` does; any other name by the client's own lookup (`unlistedSegment`).
289
+ const segments = useMemo(() => {
290
+ const table = requestSegments(branches);
291
+ if (resolved && branchName && !table.has(branchName)) {
292
+ table.set(branchName, unlistedName(branches, branchName));
293
+ }
294
+ return table;
295
+ }, [branches, resolved, branchName]);
296
+ useBranchSegments(segments);
265
297
  const addCreatedBranch = useCallback((branch) => {
266
298
  setPendingBranches((prev) => [
267
299
  ...prev.filter((p) => p.branch.name !== branch.name),
@@ -296,10 +328,9 @@ export function useBranchManager(options) {
296
328
  }, [branchesData]);
297
329
  // Adopt the server's default branch once data arrives, if nothing pinned one.
298
330
  useEffect(() => {
299
- if (!branchName && branchesData?.defaultBranch) {
300
- setBranchName(branchesData.defaultBranch);
301
- }
302
- }, [branchesData, branchName]);
331
+ if (!branchName && branchesData?.defaultBranch)
332
+ openExactly(branchesData.defaultBranch);
333
+ }, [branchesData, branchName, openExactly]);
303
334
  // One toast, replaced on each failure (a shown id ignores `show`); sticky because the editor
304
335
  // cannot function without the branch list.
305
336
  const showBranchesLoadFailed = (message) => {
@@ -334,18 +365,19 @@ export function useBranchManager(options) {
334
365
  options.setBusy(branchesIsValidating);
335
366
  // eslint-disable-next-line react-hooks/exhaustive-deps -- options is a new object every Editor.tsx render; only setBusy (a stable setState) matters here
336
367
  }, [branchesIsValidating, options.setBusy]);
337
- // Exact match first; fall back to comparing sanitized forms so a legacy
338
- // deep-link carrying the raw, unsanitized name (e.g. "?branch=feature%2Fx"
339
- // from before a branch was created, or from an old bookmark) still
340
- // resolves to the branch the server actually persisted (e.g. "feature-x").
341
- const currentBranch = branches.find((b) => b.name === branchName) ??
342
- branches.find((b) => b.name === sanitizeBranchName(branchName));
368
+ // Resolved once, as it arrives: from here the full name is the branch, and drafts, comments and
369
+ // everything else keyed per branch see one name for it.
370
+ useEffect(() => {
371
+ if (!open.exact && arriving)
372
+ openExactly(arriving.name);
373
+ }, [open, arriving, openExactly]);
343
374
  const branchSummaries = useMemo(() => {
344
375
  return branches.map((b) => {
345
- const branchComments = b.name === branchName ? options.comments : [];
376
+ const branchComments = b.name === currentBranchName ? options.comments : [];
346
377
  const unresolvedCount = branchComments.filter((t) => !t.resolved).length;
347
378
  return {
348
379
  name: b.name,
380
+ shortName: b.shortName,
349
381
  status: b.status,
350
382
  createdBy: b.createdBy,
351
383
  updatedAt: b.updatedAt,
@@ -386,7 +418,7 @@ export function useBranchManager(options) {
386
418
  submitBlocked: b.submitBlocked ?? true,
387
419
  };
388
420
  });
389
- }, [branches, branchName, options.comments]);
421
+ }, [branches, currentBranchName, options.comments]);
390
422
  // Explicit reload: always issues a fresh, independent fetch (raw call, not
391
423
  // SWR's `mutate()` revalidate path) so callers requesting a reload right
392
424
  // after mount aren't coalesced against the still-in-flight automatic load
@@ -416,11 +448,38 @@ export function useBranchManager(options) {
416
448
  };
417
449
  // The confirm dialogs' handlers run long after the render that opened them, so they read
418
450
  // these at confirm time rather than from that render's closure.
419
- const latest = useRef({ branchName, branches, branchesData });
420
- latest.current = { branchName, branches, branchesData };
421
- // A legacy deep link can carry the raw form of a listed name; the actions key, look up and
422
- // send the listed name, resolved the way `currentBranch` resolves it.
423
- const listedName = (name) => latest.current.branches.some((b) => b.name === name) ? name : sanitizeBranchName(name);
451
+ const latest = useRef({ branchName, resolved, branches, branchesData });
452
+ latest.current = { branchName, resolved, branches, branchesData };
453
+ // A card, deep link or URL can carry a short name, an exact form or the raw form of a listed
454
+ // name; the actions resolve it once, the way an arriving name resolves, then key, look up and
455
+ // send the listed name.
456
+ const listedBranch = (name) => resolveListedBranch(latest.current.branches, name);
457
+ const listedName = (name) => listedBranch(name)?.name ?? sanitizeBranchName(name);
458
+ // What an action sends for `name`, resolved when it was asked for: the listing can drop the
459
+ // branch while its dialog is open.
460
+ const sendName = (name) => {
461
+ const listed = latest.current.branches;
462
+ return listed.some((b) => b.name === name) ? name : unlistedName(listed, name);
463
+ };
464
+ // The listed name of the open branch: the state itself once resolved.
465
+ const openListedName = () => latest.current.resolved ? latest.current.branchName : listedName(latest.current.branchName);
466
+ const namesBranch = (name, branch) => {
467
+ const found = listedBranch(name);
468
+ return found ? found.name === branch : sanitizeBranchName(name) === sanitizeBranchName(branch);
469
+ };
470
+ // What a dialog or toast calls the listed branch `name`; the keys, lookups and requests keep `name`.
471
+ const shownName = (name) => {
472
+ const found = latest.current.branches.find((b) => b.name === name);
473
+ return found ? displayBranchName(found) : name;
474
+ };
475
+ // The `?branch=` value for `name`, a full name or a card's address: the URL form of the branch
476
+ // listed by that full name, else `name` as given.
477
+ const urlBranchName = (name) => {
478
+ const listed = latest.current.branches;
479
+ const full = exactBranchName(name) ?? name;
480
+ const found = listed.find((b) => b.name === full);
481
+ return found ? branchUrlName(listed, found) : name;
482
+ };
424
483
  const pullRequestOf = (name) => {
425
484
  const b = latest.current.branches.find((x) => x.name === name);
426
485
  return { pullRequestNumber: b?.pullRequestNumber, pullRequestState: b?.pullRequestState };
@@ -564,7 +623,7 @@ export function useBranchManager(options) {
564
623
  return singleFlight('submit', branchNameToSubmit, async () => {
565
624
  // Drafts belong to the open branch, so only its submit can leave edits behind. Without
566
625
  // that check the dialog opens in this same tick.
567
- const checking = branchNameToSubmit === listedName(latest.current.branchName)
626
+ const checking = branchNameToSubmit === openListedName()
568
627
  ? options.getUnsaved?.().catch((err) => {
569
628
  console.error(err);
570
629
  return undefined;
@@ -575,7 +634,7 @@ export function useBranchManager(options) {
575
634
  options.setBusy(true);
576
635
  const unsaved = checking && (await checking.finally(() => options.setBusy(false)));
577
636
  return new Promise((resolve, reject) => {
578
- showSubmitConfirmation(branchNameToSubmit, unsaved, async () => {
637
+ showSubmitConfirmation(shownName(branchNameToSubmit), unsaved, async () => {
579
638
  // A listing that arrived while the dialog was open may show the branch already
580
639
  // submitted; submitting it again would only fail.
581
640
  const listed = latest.current.branches.find((b) => b.name === branchNameToSubmit);
@@ -589,13 +648,13 @@ export function useBranchManager(options) {
589
648
  }
590
649
  options.setBusy(true);
591
650
  try {
592
- const result = await apiClient.workflow.submit({ branch: branchNameToSubmit },
651
+ const result = await apiClient.workflow.submit({ branch: sendName(branchNameToSubmit) },
593
652
  // The PR body links back here; the server checks the host (editor-url.ts).
594
653
  { editorUrl: `${window.location.origin}${window.location.pathname}` });
595
654
  if (!result.ok)
596
655
  throw actionError(result, 'Failed to submit branch');
597
656
  const stamped = result.data?.stamped;
598
- if (stamped && branchNameToSubmit === listedName(latest.current.branchName)) {
657
+ if (stamped && branchNameToSubmit === openListedName()) {
599
658
  options.onStamped?.(stamped);
600
659
  }
601
660
  notifications.show({
@@ -623,11 +682,11 @@ export function useBranchManager(options) {
623
682
  const handleWithdraw = (requested, returnFocusTo) => {
624
683
  const branchNameToWithdraw = listedName(requested);
625
684
  return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
626
- showWithdrawConfirmation(branchNameToWithdraw, pullRequestOf(branchNameToWithdraw), async () => {
685
+ showWithdrawConfirmation(shownName(branchNameToWithdraw), pullRequestOf(branchNameToWithdraw), async () => {
627
686
  options.setBusy(true);
628
687
  try {
629
688
  const result = await apiClient.workflow.withdraw({
630
- branch: branchNameToWithdraw,
689
+ branch: sendName(branchNameToWithdraw),
631
690
  });
632
691
  if (!result.ok)
633
692
  throw actionError(result, 'Failed to withdraw branch');
@@ -649,11 +708,11 @@ export function useBranchManager(options) {
649
708
  const handleApprove = (requested, returnFocusTo) => {
650
709
  const branchNameToApprove = listedName(requested);
651
710
  return singleFlight('approve', branchNameToApprove, () => new Promise((resolve, reject) => {
652
- showApproveConfirmation(branchNameToApprove, pullRequestOf(branchNameToApprove), async () => {
711
+ showApproveConfirmation(shownName(branchNameToApprove), pullRequestOf(branchNameToApprove), async () => {
653
712
  options.setBusy(true);
654
713
  try {
655
714
  const result = await apiClient.workflow.approve({
656
- branch: branchNameToApprove,
715
+ branch: sendName(branchNameToApprove),
657
716
  });
658
717
  if (!result.ok)
659
718
  throw actionError(result, 'Failed to approve branch', true);
@@ -666,7 +725,7 @@ export function useBranchManager(options) {
666
725
  catch (err) {
667
726
  await reportReviewFailure(err, 'approve', branchNameToApprove, {
668
727
  fallback: 'Failed to approve branch',
669
- label: `${EDITOR_ACTIONS.approve} "${branchNameToApprove}"`,
728
+ label: `${EDITOR_ACTIONS.approve} “${shownName(branchNameToApprove)}”`,
670
729
  retry: () => void handleApprove(branchNameToApprove, returnFocusTo).catch(() => { }),
671
730
  });
672
731
  reject(err);
@@ -682,7 +741,7 @@ export function useBranchManager(options) {
682
741
  // A draft is for the review cycle it was typed in; a branch already out of review starts clean.
683
742
  dropDraftsOutOfReview(latest.current.branches.filter((b) => b.name === branchNameForChanges));
684
743
  return singleFlight('request-changes', branchNameForChanges, () => new Promise((resolve) => {
685
- showRequestChangesConfirmation(branchNameForChanges, pullRequestOf(branchNameForChanges), noteDrafts.current.get(branchNameForChanges) ?? '', (draft) => {
744
+ showRequestChangesConfirmation(shownName(branchNameForChanges), pullRequestOf(branchNameForChanges), noteDrafts.current.get(branchNameForChanges) ?? '', (draft) => {
686
745
  if (draft)
687
746
  noteDrafts.current.set(branchNameForChanges, draft);
688
747
  else
@@ -690,7 +749,7 @@ export function useBranchManager(options) {
690
749
  }, async (note) => {
691
750
  options.setBusy(true);
692
751
  try {
693
- const result = await apiClient.workflow.requestChanges({ branch: branchNameForChanges }, { note: note.trim() || undefined });
752
+ const result = await apiClient.workflow.requestChanges({ branch: sendName(branchNameForChanges) }, { note: note.trim() || undefined });
694
753
  if (!result.ok)
695
754
  throw actionError(result, 'Failed to request changes', true);
696
755
  noteDrafts.current.delete(branchNameForChanges);
@@ -714,7 +773,7 @@ export function useBranchManager(options) {
714
773
  catch (err) {
715
774
  await reportReviewFailure(err, 'request-changes', branchNameForChanges, {
716
775
  fallback: 'Failed to request changes',
717
- label: `${EDITOR_ACTIONS.requestChanges} on "${branchNameForChanges}"`,
776
+ label: `${EDITOR_ACTIONS.requestChanges} on “${shownName(branchNameForChanges)}”`,
718
777
  note: note.trim() || undefined,
719
778
  retry: () => void handleRequestChanges(branchNameForChanges, returnFocusTo).catch(() => { }),
720
779
  check: REQUEST_CHANGES_CHECK,
@@ -733,32 +792,38 @@ export function useBranchManager(options) {
733
792
  }, () => resolve(), returnFocusTo);
734
793
  }));
735
794
  };
736
- /** After a delete: drop it from the created-branch overlay, and leave it if it is open. */
737
- const leaveDeletedBranch = (deleted, fresh) => {
795
+ /**
796
+ * After a delete: drop it from the created-branch overlay, and leave it if it is open. A name
797
+ * still unresolved is resolved against the listing from before the delete (`listedBefore`),
798
+ * since the deleted branch is no longer listed to resolve against.
799
+ */
800
+ const leaveDeletedBranch = (deleted, listedBefore, fresh) => {
738
801
  forgetCreatedBranch(deleted);
739
- // Matched the way `currentBranch` resolves a name, since the deleted branch is no longer
740
- // listed to resolve against.
741
- const open = latest.current.branchName;
742
- if (open === deleted || sanitizeBranchName(open) === deleted) {
802
+ const { branchName: openName, resolved: openResolved } = latest.current;
803
+ const openListed = openResolved
804
+ ? openName
805
+ : (resolveListedBranch(listedBefore, openName)?.name ?? sanitizeBranchName(openName));
806
+ if (openListed === deleted) {
743
807
  // Nothing is left to keep, so no unsaved-changes prompt. With no default known yet, the
744
808
  // empty name lets the default-branch adoption effect pick it.
745
- setBranchName(fresh?.defaultBranch ?? latest.current.branchesData?.defaultBranch ?? '');
809
+ openExactly(fresh?.defaultBranch ?? latest.current.branchesData?.defaultBranch ?? '');
746
810
  }
747
811
  };
748
812
  const handleDelete = (requested) => {
749
813
  const branchNameToDelete = listedName(requested);
814
+ const listedBefore = latest.current.branches;
750
815
  return singleFlight('delete', branchNameToDelete, () => new Promise((resolve) => {
751
- showDeleteConfirmation(branchNameToDelete, pullRequestOf(branchNameToDelete), async () => {
816
+ showDeleteConfirmation(shownName(branchNameToDelete), pullRequestOf(branchNameToDelete), async () => {
752
817
  options.setBusy(true);
753
818
  try {
754
819
  const result = await apiClient.branches.delete({
755
- branch: branchNameToDelete,
820
+ branch: sendName(branchNameToDelete),
756
821
  });
757
822
  // The API's own 404: the branch is already gone, by an earlier unanswered delete or
758
823
  // another user's.
759
824
  if (!result.ok && result.status === 404 && !isNonApiResponse(result)) {
760
825
  notifications.show({ message: 'Branch deleted', color: 'green' });
761
- leaveDeletedBranch(branchNameToDelete, await reloadBranches());
826
+ leaveDeletedBranch(branchNameToDelete, listedBefore, await reloadBranches());
762
827
  return;
763
828
  }
764
829
  if (!result.ok)
@@ -771,7 +836,7 @@ export function useBranchManager(options) {
771
836
  autoClose: false,
772
837
  }
773
838
  : { message: 'Branch deleted', color: 'green' });
774
- leaveDeletedBranch(branchNameToDelete, await reloadBranches());
839
+ leaveDeletedBranch(branchNameToDelete, listedBefore, await reloadBranches());
775
840
  }
776
841
  catch (err) {
777
842
  if (!isUnanswered(err)) {
@@ -784,7 +849,7 @@ export function useBranchManager(options) {
784
849
  const listed = mergePendingBranches(fresh?.branches ?? [], recent).some((b) => sanitizeBranchName(b.name) === sanitizeBranchName(branchNameToDelete));
785
850
  if (fresh && !listed) {
786
851
  notifications.show({ message: 'Branch deleted', color: 'green' });
787
- leaveDeletedBranch(branchNameToDelete, fresh);
852
+ leaveDeletedBranch(branchNameToDelete, listedBefore, fresh);
788
853
  }
789
854
  else {
790
855
  showOutcomeUnknown('Delete branch');
@@ -803,24 +868,47 @@ export function useBranchManager(options) {
803
868
  };
804
869
  // The editor's own page; a Back off it to a page of the host leaves that page's URL alone.
805
870
  const editorPathRef = useRef(typeof window === 'undefined' ? '' : window.location.pathname);
871
+ const shownBranch = currentBranch ? branchUrlName(branches, currentBranch) : undefined;
872
+ // Each Back or Forward, which can arrive at an entry naming this branch in another form.
873
+ const [historyMoves, setHistoryMoves] = useState(0);
806
874
  useEffect(() => {
807
875
  if (typeof window === 'undefined')
808
876
  return;
809
- if (!branchName || window.location.pathname !== editorPathRef.current)
877
+ const moved = () => setHistoryMoves((n) => n + 1);
878
+ window.addEventListener('popstate', moved);
879
+ return () => window.removeEventListener('popstate', moved);
880
+ }, []);
881
+ const seenMovesRef = useRef(historyMoves);
882
+ // Written once the listing resolves the branch, so a link's own form stays until then; a form
883
+ // other than the shown one (the full name, a raw name) is replaced, never pushed. After a Back
884
+ // or Forward only a form of this branch is: one naming another is the entry manager's switch.
885
+ useEffect(() => {
886
+ const popped = seenMovesRef.current !== historyMoves;
887
+ seenMovesRef.current = historyMoves;
888
+ if (typeof window === 'undefined')
889
+ return;
890
+ if (!shownBranch || window.location.pathname !== editorPathRef.current)
810
891
  return;
811
892
  const url = new URL(window.location.href);
812
- const current = url.searchParams.get('branch');
813
- if (current !== branchName) {
814
- url.searchParams.set('branch', branchName);
815
- writeEditorUrl(url.toString(), false);
816
- }
817
- }, [branchName]);
893
+ const named = url.searchParams.get('branch');
894
+ if (named === shownBranch)
895
+ return;
896
+ if (popped && named !== null && listedBranch(named)?.name !== latest.current.branchName)
897
+ return;
898
+ url.searchParams.set('branch', shownBranch);
899
+ writeEditorUrl(url.toString(), false);
900
+ }, [shownBranch, historyMoves]);
818
901
  return {
819
902
  branchName,
820
903
  setBranchName,
821
904
  branches,
822
905
  branchSummaries,
823
906
  currentBranch,
907
+ currentAddress: currentBranch ? branchAddress(branches, currentBranch) : branchName,
908
+ listedBranch,
909
+ listedName,
910
+ namesBranch,
911
+ urlBranchName,
824
912
  branchesLoadFailed: !!branchesError && !branchesData,
825
913
  branchesLoaded: !!branchesData,
826
914
  addCreatedBranch,
@@ -335,9 +335,13 @@ export function useDraftManager(options) {
335
335
  // Tracks which storage key the current in-memory `drafts` were loaded for.
336
336
  // Lags one render behind a branch switch (it only updates once `drafts`
337
337
  // itself has been reset/restored for the new key), which is what lets the
338
- // persist effect below detect and skip the stale write.
339
- const draftsStorageKeyRef = useRef(storageKey);
338
+ // persist effect below detect and skip the stale write. Null while `drafts` is
339
+ // still the empty set it started as, so a load stores nothing under its first key.
340
+ const initialDraftsRef = useRef(drafts);
341
+ const draftsStorageKeyRef = useRef(null);
340
342
  useEffect(() => {
343
+ if (drafts === initialDraftsRef.current && Object.keys(drafts).length === 0)
344
+ return;
341
345
  draftsStorageKeyRef.current = storageKey;
342
346
  }, [drafts]);
343
347
  // Keep the per-draft base versions in step with `drafts`.
@@ -42,10 +42,26 @@ export interface UseEntryManagerOptions {
42
42
  setBusy: (busy: boolean) => void;
43
43
  /** The bar's row: a failed load is toasted after 'loading' ends, and never on 'not-available'. */
44
44
  branchBarState?: BranchBarStatus['value'];
45
- /** Switches to the branch a Back or Forward reached; rejects when the switch does not happen. */
46
- switchBranch?: (branch: string) => Promise<void>;
45
+ /**
46
+ * Switches to the branch a Back or Forward reached; rejects when the switch does not happen,
47
+ * which it must not once `superseded()` holds: a later move has the URL.
48
+ */
49
+ switchBranch?: (branch: string, superseded: () => boolean) => Promise<void>;
50
+ /**
51
+ * Whether `url`, a `?branch=` value, opens the branch `branchName` names, as a short name opens
52
+ * its namespace branch. A change of `branchName` from a branch whose URL form opens the next one
53
+ * is no switch: the open entry stays. Defaults to comparing sanitized forms.
54
+ */
55
+ sameBranch?: (url: string, branchName: string) => boolean;
56
+ /** What `?branch=` carries for a branch (its short name); defaults to the name as given. */
57
+ urlBranchName?: (branch: string) => string;
47
58
  /** Called when a Back or Forward has opened another entry. */
48
59
  onHistoryRestore?: () => void;
60
+ /**
61
+ * Called as each explicit refresh starts: every save, create, rename, delete and schema change
62
+ * refreshes the entries, so what reads the same branch state reloads here.
63
+ */
64
+ onContentRefresh?: (branch: string) => void;
49
65
  }
50
66
  export interface UseEntryManagerReturn {
51
67
  selectedPath: string;
@@ -1,7 +1,7 @@
1
1
  import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import useSWR, { useSWRConfig } from 'swr';
3
3
  import { notifications } from '@mantine/notifications';
4
- import { buildWritePayload, normalizeContentPayload, resolveHomeEntry } from '../editor-utils.js';
4
+ import { buildWritePayload, normalizeContentPayload, resolveHomeEntry, withPreviewBranch, } from '../editor-utils.js';
5
5
  import { isDataOnlyFormat } from '../../utils/format.js';
6
6
  import { withTitleAtPath } from '../../utils/title-field.js';
7
7
  import { getErrorMessage } from '../../utils/error.js';
@@ -49,6 +49,8 @@ export class EntrySchemaUnavailableError extends Error {
49
49
  this.name = 'EntrySchemaUnavailableError';
50
50
  }
51
51
  }
52
+ /** A legacy name and its sanitized form are one branch. */
53
+ const sameSanitizedName = (a, b) => sanitizeBranchName(a) === sanitizeBranchName(b);
52
54
  // Module-level singletons for the derived-empty fallbacks. These must be
53
55
  // referentially stable: `collectionByPath` and `currentEntry` memoize on them,
54
56
  // and a fresh `[]` each render would invalidate both on every render while a
@@ -196,6 +198,11 @@ export function useEntryManager(options) {
196
198
  // the user switches mid-flight, which is exactly when the check matters).
197
199
  const currentBranchRef = useRef(options.branchName);
198
200
  currentBranchRef.current = options.branchName;
201
+ const sameBranchRef = useRef(options.sameBranch ?? sameSanitizedName);
202
+ sameBranchRef.current = options.sameBranch ?? sameSanitizedName;
203
+ const urlBranchNameRef = useRef(options.urlBranchName);
204
+ urlBranchNameRef.current = options.urlBranchName;
205
+ const urlBranchOf = (branch) => urlBranchNameRef.current?.(branch) ?? branch;
199
206
  // Read by the selection check, which runs only when the entries or the selection change.
200
207
  const resolvePreviewRouteRef = useRef(options.resolvePreviewRoute);
201
208
  resolvePreviewRouteRef.current = options.resolvePreviewRoute;
@@ -247,7 +254,16 @@ export function useEntryManager(options) {
247
254
  walk(collectionsState);
248
255
  return map;
249
256
  }, [collectionsState]);
250
- const currentEntry = useMemo(() => entriesState.find((e) => e.path === selectedPath), [entriesState, selectedPath]);
257
+ // The entries keep the URL form of their fetch's time, which the listing can change since; the
258
+ // frame keys on `src`, so its branch is the current form.
259
+ const previewBranch = urlBranchOf(options.branchName);
260
+ const currentEntry = useMemo(() => {
261
+ const entry = entriesState.find((e) => e.path === selectedPath);
262
+ const previewSrc = entry?.previewSrc && previewBranch
263
+ ? withPreviewBranch(entry.previewSrc, previewBranch)
264
+ : entry?.previewSrc;
265
+ return entry && previewSrc !== entry.previewSrc ? { ...entry, previewSrc } : entry;
266
+ }, [entriesState, selectedPath, previewBranch]);
251
267
  // Slugs already used within the collection currently open in the create
252
268
  // modal. Collision is keyed on collectionPath + slug only (not entryType):
253
269
  // ContentStore resolves an existing entry by slug alone within a
@@ -441,6 +457,7 @@ export function useEntryManager(options) {
441
457
  const refreshContent = async (branch = options.branchName) => {
442
458
  if (!branch)
443
459
  return { entries: [], collections: [] };
460
+ options.onContentRefresh?.(branch);
444
461
  const seq = claimRefreshSeq(branch);
445
462
  const fetched = await fetchEntriesAndSchema(apiClient, branch, {
446
463
  resolvePreviewSrc: options.resolvePreviewSrc,
@@ -597,18 +614,28 @@ export function useEntryManager(options) {
597
614
  // Clear selection when branch changes (reactive pattern). Data loading
598
615
  // itself is handled below by the SWR-backed fetch -- this effect only
599
616
  // owns the "clear stale selection" side effect.
617
+ // The shown branch's URL form, refreshed while it stays shown: a full name can be another
618
+ // branch's short name, and only the URL form says which branch it is against a later listing.
619
+ const shownBranchRef = useRef({ name: options.branchName, url: urlBranchOf(options.branchName) });
620
+ if (shownBranchRef.current.name === options.branchName) {
621
+ shownBranchRef.current.url = urlBranchOf(options.branchName);
622
+ }
600
623
  useEffect(() => {
601
624
  if (!options.branchName)
602
625
  return;
626
+ const previous = shownBranchRef.current.url;
627
+ shownBranchRef.current = { name: options.branchName, url: urlBranchOf(options.branchName) };
603
628
  // On initial mount, preserve the initial selection from URL
604
- // On subsequent branch changes, clear selection
629
+ // On a later switch to another branch, clear selection
605
630
  if (isInitialMount.current) {
606
631
  isInitialMount.current = false;
607
632
  }
608
- else {
633
+ else if (!sameBranchRef.current(previous, options.branchName)) {
609
634
  const restored = entryAfterBranchChangeRef.current;
610
635
  entryAfterBranchChangeRef.current = undefined;
611
- const entry = restored?.branch === options.branchName ? restored.entry : undefined;
636
+ const entry = restored && sameBranchRef.current(restored.branch, options.branchName)
637
+ ? restored.entry
638
+ : undefined;
612
639
  noEntryRequested.current = entry === '';
613
640
  setSelectedPath(entry ?? '');
614
641
  if (entry !== undefined) {
@@ -782,7 +809,7 @@ export function useEntryManager(options) {
782
809
  return;
783
810
  if (window.location.pathname !== editorPathRef.current)
784
811
  return;
785
- writeEditorUrl(entryUrl(selectedPath, noEntryRequested.current, currentBranchRef.current), push);
812
+ writeEditorUrl(entryUrl(selectedPath, noEntryRequested.current, urlBranchOf(currentBranchRef.current)), push);
786
813
  }, [selectedPath]);
787
814
  const entriesRef = useRef(entriesState);
788
815
  entriesRef.current = entriesState;
@@ -803,8 +830,7 @@ export function useEntryManager(options) {
803
830
  const entry = params.get('entry');
804
831
  const branch = params.get('branch');
805
832
  const current = currentBranchRef.current;
806
- // A legacy name and its sanitized form are one branch; a switch would key drafts by the other.
807
- if (branch && sanitizeBranchName(branch) !== sanitizeBranchName(current)) {
833
+ if (branch && !sameBranchRef.current(branch, current)) {
808
834
  const move = historyMovesRef.current;
809
835
  const stay = () => {
810
836
  if (move !== historyMovesRef.current)
@@ -812,7 +838,7 @@ export function useEntryManager(options) {
812
838
  if (moved)
813
839
  undoHistoryMove(moved);
814
840
  else {
815
- const url = entryUrl(selectedPathRef.current, noEntryRequested.current, currentBranchRef.current);
841
+ const url = entryUrl(selectedPathRef.current, noEntryRequested.current, urlBranchOf(currentBranchRef.current));
816
842
  writeEditorUrl(url, false);
817
843
  }
818
844
  };
@@ -824,7 +850,7 @@ export function useEntryManager(options) {
824
850
  entryAfterBranchChangeRef.current = { branch, entry };
825
851
  if (!hasSyncedFromUrl.current)
826
852
  initialUrlEntry.current = entry;
827
- switchBranch(branch).catch(() => {
853
+ switchBranch(branch, () => move !== historyMovesRef.current).catch(() => {
828
854
  if (move === historyMovesRef.current)
829
855
  entryAfterBranchChangeRef.current = undefined;
830
856
  stay();
@@ -1,7 +1,10 @@
1
1
  /** Where in the editor an error was caught, for the log line and the copied details. */
2
2
  export interface EditorErrorContext {
3
- /** `editor` for the crash screen, `field` for a field boundary, `rich-text` for MDXEditor. */
4
- boundary: 'editor' | 'field' | 'rich-text';
3
+ /**
4
+ * `editor` for the crash screen, `field` for a field boundary, `rich-text` for MDXEditor,
5
+ * `changes` for the "Show changes" drawer.
6
+ */
7
+ boundary: 'editor' | 'field' | 'rich-text' | 'changes';
5
8
  /** The field's canopy path, for a field or rich-text boundary. */
6
9
  fieldPath?: string;
7
10
  /** React's component stack, when React supplied one. */