canopycms 0.12.0 → 0.12.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 (154) hide show
  1. package/CHANGELOG.md +164 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.js +45 -13
  4. package/dist/api/branch-status.d.ts +12 -0
  5. package/dist/api/branch-status.js +13 -0
  6. package/dist/api/branch-update-from-base.d.ts +23 -0
  7. package/dist/api/branch-update-from-base.js +176 -0
  8. package/dist/api/branch.d.ts +5 -0
  9. package/dist/api/branch.js +1 -0
  10. package/dist/api/client.d.ts +14 -1
  11. package/dist/api/client.js +22 -0
  12. package/dist/api/comments.d.ts +2 -2
  13. package/dist/api/content.d.ts +38 -1
  14. package/dist/api/content.js +41 -5
  15. package/dist/api/merge-conflicts.d.ts +226 -0
  16. package/dist/api/merge-conflicts.js +674 -0
  17. package/dist/api/routes.js +2 -0
  18. package/dist/api/types.d.ts +7 -3
  19. package/dist/base-update.d.ts +26 -0
  20. package/dist/base-update.js +31 -0
  21. package/dist/branch-metadata-file.d.ts +51 -0
  22. package/dist/branch-metadata-file.js +11 -0
  23. package/dist/branch-schema-cache.d.ts +12 -0
  24. package/dist/branch-schema-cache.js +27 -2
  25. package/dist/cli/backfill-item-keys.d.ts +77 -0
  26. package/dist/cli/backfill-item-keys.js +320 -0
  27. package/dist/cli/cli.d.ts +1 -1
  28. package/dist/cli/cli.js +9658 -1499
  29. package/dist/cli/generate-ai-content.js +354 -244
  30. package/dist/cli/init.js +6 -0
  31. package/dist/cli/site-config.d.ts +14 -2
  32. package/dist/cli/site-config.js +20 -4
  33. package/dist/comment-store.d.ts +8 -0
  34. package/dist/comment-store.js +28 -0
  35. package/dist/content-index-generation.d.ts +1 -4
  36. package/dist/content-index-generation.js +1 -4
  37. package/dist/content-store.d.ts +39 -3
  38. package/dist/content-store.js +48 -15
  39. package/dist/editor/BranchManager.d.ts +4 -1
  40. package/dist/editor/BranchManager.js +9 -5
  41. package/dist/editor/Editor.js +429 -52
  42. package/dist/editor/EntryNavigator.d.ts +10 -2
  43. package/dist/editor/EntryNavigator.js +12 -4
  44. package/dist/editor/FormRenderer.d.ts +8 -2
  45. package/dist/editor/FormRenderer.js +5 -6
  46. package/dist/editor/base-update-wait.d.ts +74 -0
  47. package/dist/editor/base-update-wait.js +91 -0
  48. package/dist/editor/branch-status.js +1 -1
  49. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  50. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  51. package/dist/editor/components/BranchPill.d.ts +4 -1
  52. package/dist/editor/components/BranchPill.js +5 -2
  53. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  54. package/dist/editor/components/ConflictsGroup.js +364 -0
  55. package/dist/editor/components/EditorHeader.d.ts +16 -1
  56. package/dist/editor/components/EditorHeader.js +92 -30
  57. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  58. package/dist/editor/components/EntryChangesDrawer.js +33 -167
  59. package/dist/editor/components/EntryHeader.d.ts +3 -1
  60. package/dist/editor/components/EntryHeader.js +2 -2
  61. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  62. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  63. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  64. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  65. package/dist/editor/components/RetryToastMessage.js +1 -1
  66. package/dist/editor/components/change-parts.d.ts +51 -0
  67. package/dist/editor/components/change-parts.js +171 -0
  68. package/dist/editor/conflict-cards.d.ts +90 -0
  69. package/dist/editor/conflict-cards.js +279 -0
  70. package/dist/editor/conflict-choices.d.ts +60 -0
  71. package/dist/editor/conflict-choices.js +108 -0
  72. package/dist/editor/conflict-notice.d.ts +25 -0
  73. package/dist/editor/conflict-notice.js +39 -0
  74. package/dist/editor/copy.d.ts +12 -0
  75. package/dist/editor/copy.js +12 -0
  76. package/dist/editor/entry-diff.d.ts +2 -1
  77. package/dist/editor/entry-diff.js +5 -21
  78. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  79. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  80. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  81. package/dist/editor/hooks/useBranchManager.js +28 -7
  82. package/dist/editor/hooks/useBranchesData.js +3 -0
  83. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  84. package/dist/editor/hooks/useConflictChoices.js +75 -0
  85. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  86. package/dist/editor/hooks/useConflictsData.js +89 -0
  87. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  88. package/dist/editor/hooks/useDraftManager.js +24 -8
  89. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  90. package/dist/editor/hooks/useEntryManager.js +5 -0
  91. package/dist/editor/release-hold.d.ts +12 -0
  92. package/dist/editor/release-hold.js +39 -0
  93. package/dist/editor/server-clock.d.ts +4 -0
  94. package/dist/editor/server-clock.js +15 -0
  95. package/dist/editor/undo-change.d.ts +7 -0
  96. package/dist/editor/undo-change.js +19 -4
  97. package/dist/git-manager.d.ts +5 -0
  98. package/dist/git-manager.js +30 -0
  99. package/dist/item-keys.d.ts +13 -0
  100. package/dist/item-keys.js +36 -14
  101. package/dist/merge/conflict-path.d.ts +40 -0
  102. package/dist/merge/conflict-path.js +128 -0
  103. package/dist/merge/diff3.d.ts +27 -0
  104. package/dist/merge/diff3.js +113 -0
  105. package/dist/merge/entry-merge.d.ts +94 -0
  106. package/dist/merge/entry-merge.js +875 -0
  107. package/dist/merge/list-alignment.d.ts +52 -0
  108. package/dist/merge/list-alignment.js +441 -0
  109. package/dist/merge/text-merge.d.ts +29 -0
  110. package/dist/merge/text-merge.js +59 -0
  111. package/dist/merge/value-equality.d.ts +18 -0
  112. package/dist/merge/value-equality.js +50 -0
  113. package/dist/merge-conflicts.d.ts +232 -0
  114. package/dist/merge-conflicts.js +451 -0
  115. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  116. package/dist/operating-mode/client-safe-strategy.js +6 -0
  117. package/dist/operating-mode/types.d.ts +5 -0
  118. package/dist/services.d.ts +16 -1
  119. package/dist/services.js +82 -22
  120. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  121. package/dist/types.d.ts +28 -2
  122. package/dist/utils/content-serialize.d.ts +18 -1
  123. package/dist/utils/content-serialize.js +12 -0
  124. package/dist/utils/content-write-lock.d.ts +3 -3
  125. package/dist/utils/content-write-lock.js +3 -3
  126. package/dist/utils/markdown-body-splice.d.ts +1 -1
  127. package/dist/utils/markdown-body-splice.js +1 -19
  128. package/dist/utils/markdown-parse.d.ts +9 -0
  129. package/dist/utils/markdown-parse.js +27 -0
  130. package/dist/version.d.ts +1 -1
  131. package/dist/version.js +1 -1
  132. package/dist/worker/branch-merge.d.ts +53 -37
  133. package/dist/worker/branch-merge.js +139 -54
  134. package/dist/worker/cms-worker.d.ts +16 -3
  135. package/dist/worker/cms-worker.js +59 -12
  136. package/dist/worker/field-merge.d.ts +65 -0
  137. package/dist/worker/field-merge.js +636 -0
  138. package/dist/worker/git-sync.d.ts +1 -1
  139. package/dist/worker/git-sync.js +7 -6
  140. package/dist/worker/github-commits.d.ts +42 -10
  141. package/dist/worker/github-commits.js +287 -47
  142. package/dist/worker/github-gateway-http.js +17 -9
  143. package/dist/worker/github-gateway-wire.d.ts +21 -12
  144. package/dist/worker/github-gateway-wire.js +5 -1
  145. package/dist/worker/github-gateway.d.ts +10 -1
  146. package/dist/worker/github-gateway.js +18 -6
  147. package/dist/worker/github-transfer.d.ts +5 -3
  148. package/dist/worker/github-transfer.js +8 -5
  149. package/dist/worker/rebase.d.ts +5 -4
  150. package/dist/worker/rebase.js +293 -156
  151. package/dist/worker/task-runner.d.ts +1 -1
  152. package/dist/worker/task-runner.js +14 -2
  153. package/dist/worker/worker-context.d.ts +18 -2
  154. package/package.json +1 -1
@@ -16,7 +16,10 @@ import { EditorShell } from './shell/EditorShell.js';
16
16
  import { FORM_PANE_ID, PaneLayout } from './shell/PaneLayout.js';
17
17
  import { SidePanelHost } from './shell/SidePanelHost.js';
18
18
  import { CanopyCMSProvider } from './theme.js';
19
- import { BranchManager } from './BranchManager.js';
19
+ import { BranchManager, getBranchPermissions } from './BranchManager.js';
20
+ import { useBaseUpdate } from './hooks/useBaseUpdate.js';
21
+ import { BaseUpdateNotice } from './components/BaseUpdateNotice.js';
22
+ import { WAITING_NOTICE, WAITING_SUBMIT_REASON, getLatestChangesTooltip, } from './base-update-wait.js';
20
23
  import { BranchesDrawer } from './components/BranchesDrawer.js';
21
24
  import { CommentsPanel } from './CommentsPanel.js';
22
25
  import { GroupManager } from './GroupManager.js';
@@ -31,6 +34,13 @@ import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, use
31
34
  import { useBranchActions } from './hooks/useBranchActions.js';
32
35
  import { branchAddress, displayBranchName } from './branch-names.js';
33
36
  import { useBranchChangesData, useEntryChangeData } from './hooks/useBranchChangesData.js';
37
+ import { useBranchConflictsData, useEntryConflictData } from './hooks/useConflictsData.js';
38
+ import { useConflictChoices } from './hooks/useConflictChoices.js';
39
+ import { openConfirm } from './utils/confirm-modal.js';
40
+ import { canopyPathOf } from './undo-change.js';
41
+ import { placeOf } from './conflict-choices.js';
42
+ import { findBodyFieldName } from '../utils/body-field.js';
43
+ import { entryConflictNotice } from './conflict-notice.js';
34
44
  import { LazyEntryChangesDrawer } from './components/LazyEntryChangesDrawer.js';
35
45
  import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
36
46
  import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
@@ -38,7 +48,7 @@ import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
38
48
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
39
49
  import { usePreviewMarks } from './hooks/usePreviewMarks.js';
40
50
  import { branchBarStatus, describeBranchLock } from './branch-lock.js';
41
- import { NO_EDIT_ACCESS_REASON } from './copy.js';
51
+ import { EDITOR_ACTIONS, NO_EDIT_ACCESS_REASON } from './copy.js';
42
52
  import { EditorHeader, EditorRail, EntryHeader } from './components/index.js';
43
53
  import { showSaveAllResult } from './components/SaveAllResult.js';
44
54
  import { RenameEntryModal } from './components/RenameEntryModal.js';
@@ -69,6 +79,7 @@ import { PreviewFollowContext, usePreviewFollow } from './hooks/usePreviewFollow
69
79
  import { UndoToastMessage } from './components/UndoToastMessage.js';
70
80
  import { getNotificationDuration } from './utils/env.js';
71
81
  import { MayHaveLandedError, isGatewayFailure, isUnanswered } from './unanswered.js';
82
+ import { releaseConflictsHold } from './release-hold.js';
72
83
  /** The collection at `path` in a collection tree. */
73
84
  const findCollection = (cols, path) => {
74
85
  if (!cols)
@@ -182,6 +193,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
182
193
  // Reloads the open branch's comments after a request for changes, whose note is a comment; the
183
194
  // comment system is built further down.
184
195
  const reloadCommentsRef = useRef(() => { });
196
+ // Set once useBaseUpdate below exists; it depends on the drafts.
197
+ const requestLatestRef = useRef(async () => { });
198
+ const modifiedCountRef = useRef(0);
199
+ const namesBranchRef = useRef(() => false);
185
200
  const { branchName: branchNameState, setBranchName, branches, branchSummaries, currentBranch, currentAddress, listedBranch, namesBranch, urlBranchName, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleArchive, handleReloadBranchData, loadBranches, } = useBranchManager({
186
201
  initialBranch: branchName,
187
202
  operatingMode,
@@ -191,6 +206,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
191
206
  getUnsaved: () => resolveUnsavedRef.current(),
192
207
  onStamped: (stamped) => applySubmitStampsRef.current(stamped),
193
208
  onChangesRequested: (branch) => reloadCommentsRef.current(branch),
209
+ // Only the open branch, with nothing unsaved: its wait and answer show where the user is.
210
+ latestChangesAfterWithdraw: (branch) => namesBranchRef.current(branch) && modifiedCountRef.current === 0
211
+ ? () => requestLatestRef.current()
212
+ : undefined,
194
213
  });
195
214
  // Content mutations (add/delete/rename/reorder entries) are locked whenever the
196
215
  // server says writes are blocked -- the protected base branch, or a workflow
@@ -210,6 +229,21 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
210
229
  // No lock reason is true yet while the list is unanswered; once it has failed, or answered
211
230
  // without this branch, the lock says which.
212
231
  const branchPending = !currentBranch && !branchesLoaded && !branchesLoadFailed;
232
+ // Conflicts a merge left on the open branch (api/merge-conflicts.ts): read while its listing
233
+ // names any, and the choices made on their cards that no save has sent yet.
234
+ const branchConflicts = useBranchConflictsData(apiClient, branchNameState, currentBranch?.conflictFiles,
235
+ // Every viewer reads it, as a hold on the branch names no entry.
236
+ true);
237
+ const openConflictCount = currentBranch?.conflictFiles?.length
238
+ ? (branchConflicts.data?.openCount ?? currentBranch.conflictFiles.length)
239
+ : 0;
240
+ const conflictChoices = useConflictChoices(branchNameState);
241
+ // A save that sent choices re-reads what they resolved; set once the readers exist, below.
242
+ const conflictsSavedRef = useRef(() => { });
243
+ // An admin's "Keep your version" on a collection's conflicts; set once the resolver exists, below.
244
+ const keepConflictsRef = useRef(() => { });
245
+ // The "Get latest changes" toast's "Show changes"; set once the drawer exists, below.
246
+ const showConflictsRef = useRef(() => { });
213
247
  // The same inputs EditorHeader gives its own check, for the lock copy that names the way forward.
214
248
  const branchReviewRole = reviewRoleOf({
215
249
  status: currentBranch?.status,
@@ -258,6 +292,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
258
292
  sameBranch: namesBranch,
259
293
  urlBranchName,
260
294
  onContentRefresh: (branch) => reloadChangesRef.current(branch),
295
+ conflictResolutions: {
296
+ pending: conflictChoices.pending,
297
+ onSaved: (branch, contentId, sent) => {
298
+ conflictChoices.sent(branch, contentId, sent);
299
+ conflictsSavedRef.current();
300
+ },
301
+ },
261
302
  // To the entry menu, which names the entry opened; a dialog keeps focus.
262
303
  onHistoryRestore: () => requestAnimationFrame(() => {
263
304
  if (!document.activeElement?.closest('[role="dialog"]')) {
@@ -335,7 +376,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
335
376
  setPreviewError(null);
336
377
  }, [currentEntry?.contentId]);
337
378
  // 3. Draft manager (depends on branchNameState, selectedPath from useEntryManager)
338
- const { drafts, setDrafts, loadedValues, setLoadedValues, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, singleSaving, lastSaved, handleSaveAll, saveAllProgress, isSaveAllRunning, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, resolveUnsaved, fieldErrors, draftStorageFailed, applySubmitStamps, } = useDraftManager({
379
+ const { drafts, setDrafts, loadedValues, setLoadedValues, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, singleSaving, lastSaved, handleSaveAll, saveAllProgress, isSaveAllRunning, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, isDirtyForEntry, resolveUnsaved, fieldErrors, draftStorageFailed, applySubmitStamps, } = useDraftManager({
339
380
  branchName: branchNameState,
340
381
  selectedPath,
341
382
  currentEntry,
@@ -355,10 +396,47 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
355
396
  void refreshEntries(branchNameState).catch((err) => console.error('Failed to refresh entries after save', err));
356
397
  },
357
398
  // An Undo built on a discarded draft would put back an item the server's copy already has.
358
- onDraftsReset: (contentId) => dismissUndoToasts(contentId),
399
+ // A conflict choice is part of the draft, so it goes with it.
400
+ onDraftsReset: (contentId) => {
401
+ dismissUndoToasts(contentId);
402
+ conflictChoices.clear(contentId);
403
+ },
404
+ pendingIds: conflictChoices.pendingIds,
359
405
  });
360
406
  resolveUnsavedRef.current = resolveUnsaved;
361
407
  applySubmitStampsRef.current = applySubmitStamps;
408
+ // "Get latest changes" on the open branch: the worker rewrites its files meanwhile, so the form
409
+ // is read-only while it waits (`latestWaiting`), and every clean entry is read again after.
410
+ const currentSummary = branchSummaries.find((b) => b.name === currentBranch?.name);
411
+ const baseUpdate = useBaseUpdate({
412
+ branch: currentBranch,
413
+ operatingMode,
414
+ permitted: !!currentSummary && getBranchPermissions(currentSummary, userContext).canSubmit,
415
+ unsavedCount: modifiedCount,
416
+ onUpdated: () => {
417
+ setLoadedValues((prev) => Object.fromEntries(Object.entries(prev).filter(([contentId]) => draftsRef.current[contentId] !== undefined)));
418
+ void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
419
+ },
420
+ onShowConflicts: () => showConflictsRef.current(),
421
+ });
422
+ requestLatestRef.current = baseUpdate.request;
423
+ modifiedCountRef.current = modifiedCount;
424
+ namesBranchRef.current = (branch) => namesBranch(branch, branchNameState);
425
+ // The request in flight counts as waiting: a save landing before the worker reads the branch
426
+ // would make it fail.
427
+ const latestWait = baseUpdate.requesting && baseUpdate.wait === 'none' ? 'waiting' : baseUpdate.wait;
428
+ const latestWaiting = latestWait === 'waiting';
429
+ const baseUpdateControl = baseUpdate.available
430
+ ? {
431
+ wait: baseUpdate.wait,
432
+ requesting: baseUpdate.requesting,
433
+ blockedReason: baseUpdate.blockedReason,
434
+ tooltip: getLatestChangesTooltip(currentBranch?.baseBranch, userIsAdmin),
435
+ onRequest: () => void baseUpdate.request(),
436
+ }
437
+ : undefined;
438
+ // Content writes wait on the branch's lock, and on the latest changes coming in.
439
+ const contentLocked = branchContentLocked || latestWaiting;
362
440
  // 4. Branch actions (depends on resolveUnsaved, setBranchName)
363
441
  const { handleBranchChange, confirmCreate: confirmCreateBranch, handleCreateBranch, confirmOpen: branchConfirmOpen, } = useBranchActions({
364
442
  branchName: branchNameState,
@@ -422,7 +500,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
422
500
  });
423
501
  // Read-only content shows the saved value, never a draft; a restored draft is kept, unseen,
424
502
  // until it is discarded or the branch unlocks.
425
- const contentReadOnly = branchContentLocked || currentEntry?.canEdit === false;
503
+ const contentReadOnly = contentLocked || currentEntry?.canEdit === false;
426
504
  const displayValue = contentReadOnly ? loadedValue : effectiveValue;
427
505
  // Read when an edit lands, which an upload or other async field work can do renders later.
428
506
  const contentReadOnlyRef = useRef(contentReadOnly);
@@ -518,7 +596,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
518
596
  };
519
597
  useEffect(() => () => dismissUndoToasts(), [currentEntry?.contentId, branchNameState]);
520
598
  // Only once the branch has answered, so the fail-closed lock while it loads raises no notice.
521
- const hiddenDraftNotice = contentReadOnly && currentBranch !== undefined && !!currentEntry && isSelectedDirty();
599
+ const draftHidden = contentReadOnly && currentBranch !== undefined && !!currentEntry && isSelectedDirty();
600
+ // While the latest changes come in, the waiting notice says the draft comes back; it is the one notice.
601
+ const hiddenDraftNotice = draftHidden && !latestWaiting;
522
602
  const collectionLabels = useMemo(() => buildCollectionLabels(activeCollections), [activeCollections]);
523
603
  const schema = currentEntry?.schema ?? EMPTY_SCHEMA;
524
604
  // Field threads follow their items' keys, placed in their entry's value where it is at hand.
@@ -526,7 +606,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
526
606
  if (entry.path === currentEntry?.path)
527
607
  return displayValue;
528
608
  // As `displayValue` would show it: read-only content shows its saved value, not a draft.
529
- const readOnly = branchContentLocked || entry.canEdit === false;
609
+ const readOnly = contentLocked || entry.canEdit === false;
530
610
  return (readOnly ? undefined : drafts[entry.contentId]) ?? loadedValues[entry.contentId];
531
611
  };
532
612
  const displayComments = useMemo(() => {
@@ -544,15 +624,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
544
624
  return path === thread.canopyPath ? thread : { ...thread, canopyPath: path };
545
625
  });
546
626
  // `entryValue` reads the open entry, its shown value, and the drafts and loads.
547
- }, [
548
- comments,
549
- entriesState,
550
- currentEntry,
551
- displayValue,
552
- drafts,
553
- loadedValues,
554
- branchContentLocked,
555
- ]);
627
+ }, [comments, entriesState, currentEntry, displayValue, drafts, loadedValues, contentLocked]);
556
628
  // A jump into another entry waits for its value to place the thread; the stored path may be stale.
557
629
  const [pendingFieldJump, setPendingFieldJump] = useState(null);
558
630
  // A field whose comments are already open is still followed in the preview.
@@ -1109,6 +1181,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1109
1181
  const entries = grouped.get(node.path) ?? [];
1110
1182
  const children = node.children?.map((child) => build(child)) ?? [];
1111
1183
  const unavailableRefs = unavailableSchemaRefs(node.entryTypes);
1184
+ // Conflicts on the collection's settings or other files, which no entry's "Show changes"
1185
+ // reaches; the API names them to admins only, by the collection's path.
1186
+ const keepable = userIsAdmin
1187
+ ? (branchConflicts.data?.conflicts ?? []).filter((c) => c.logicalPath === node.path && !entriesState.some((e) => e.contentId === c.contentId))
1188
+ : [];
1112
1189
  // No entry can be created where every entry type is unavailable.
1113
1190
  const allUnavailable = !!node.entryTypes?.length && node.entryTypes.every((et) => et.unavailable);
1114
1191
  // A collection that holds only collections has no entry type to add (`entryTypes` is
@@ -1123,7 +1200,16 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1123
1200
  order: node.order,
1124
1201
  entries: entries.length > 0 ? entries : undefined,
1125
1202
  children: children.length > 0 ? children : undefined,
1126
- conflictNotice: !!(node.contentId && currentBranch?.conflictFiles?.includes(node.contentId)),
1203
+ conflictNotice: !!(node.contentId && currentBranch?.conflictFiles?.includes(node.contentId)) ||
1204
+ keepable.length > 0,
1205
+ ...(keepable.length > 0 && {
1206
+ conflictKeep: {
1207
+ label: keepable.every((c) => c.path.endsWith('.collection.json'))
1208
+ ? 'Keep your version of its settings'
1209
+ : 'Keep your version of its files',
1210
+ onKeep: () => keepConflictsRef.current(keepable),
1211
+ },
1212
+ }),
1127
1213
  unavailableSchemaRefs: unavailableRefs.length > 0 ? unavailableRefs : undefined,
1128
1214
  addRefused: (addable && node.canAddEntry === false) || undefined,
1129
1215
  onAdd: canAddEntry
@@ -1152,6 +1238,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1152
1238
  currentBranch,
1153
1239
  schemaUnavailableKeys,
1154
1240
  userIsAdmin,
1241
+ branchConflicts.data,
1155
1242
  ]);
1156
1243
  const findShortcut = modKeyLabel() === '⌘' ? { label: '⌘P', keys: 'Meta+P' } : { label: 'Ctrl+P', keys: 'Control+P' };
1157
1244
  // Mod+P opens a closed docked panel only until Enter, Escape from inside it or focus leaves it;
@@ -1301,6 +1388,259 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1301
1388
  id: changesSubject.contentId,
1302
1389
  }
1303
1390
  : undefined);
1391
+ // The drawer's entry's conflicts, read while it shows one the branch lists.
1392
+ const changesConflictTarget = (changesOpen || changesShown) &&
1393
+ changesSubject?.contentId &&
1394
+ currentBranch?.conflictFiles?.includes(changesSubject.contentId)
1395
+ ? { logicalPath: changesSubject.logicalPath, id: changesSubject.contentId }
1396
+ : undefined;
1397
+ const entryConflict = useEntryConflictData(apiClient, branchNameState, changesConflictTarget);
1398
+ conflictsSavedRef.current = () => {
1399
+ void Promise.all([
1400
+ branchConflicts.reload(),
1401
+ entryConflict.reload(),
1402
+ handleReloadBranchData(),
1403
+ ]).catch(console.error);
1404
+ };
1405
+ // "Edit" on a card closes the drawer, and its exit focuses the field in the form.
1406
+ const focusAfterChangesRef = useRef();
1407
+ // An entry-level choice that replaces stored work, "Delete entry" or "Use latest version",
1408
+ // confirms once the drawer has closed; any way out of the confirm but Confirm opens it again.
1409
+ const confirmAfterChangesRef = useRef();
1410
+ const focusConflictField = (field) => {
1411
+ revealForm();
1412
+ requestAnimationFrame(() => {
1413
+ const id = currentEntry?.contentId;
1414
+ const value = id && (draftsRef.current[id] ?? loadedValuesRef.current[id]);
1415
+ const schema = currentEntry?.schema;
1416
+ if (!value || !schema)
1417
+ return;
1418
+ const bodyField = currentEntry.format === 'md' || currentEntry.format === 'mdx'
1419
+ ? findBodyFieldName(schema)
1420
+ : undefined;
1421
+ const at = canopyPathOf(value, placeOf(field), bodyField);
1422
+ const target = at && findFieldTarget(formatCanopyPath(at));
1423
+ if (target)
1424
+ focusFieldTarget(target.element);
1425
+ });
1426
+ };
1427
+ const resolveEntryConflict = async (logicalPath, id, mergeSha, choice) => {
1428
+ const branch = branchNameState;
1429
+ const failed = {
1430
+ tone: 'error',
1431
+ message: 'Couldn’t save your choice. Try again.',
1432
+ };
1433
+ let answer;
1434
+ try {
1435
+ answer = await apiClient.conflicts.resolveEntry({ branch, entryPath: logicalPath }, { id: id, mergeSha, choice });
1436
+ }
1437
+ catch (err) {
1438
+ if (!isUnanswered(err))
1439
+ return failed;
1440
+ }
1441
+ if (!answer || isGatewayFailure(answer) || answer.code === 'WRITE_OUTCOME_UNKNOWN') {
1442
+ // Reading the conflict again settles it: the card goes if the choice went through.
1443
+ void Promise.all([entryConflict.reload(), branchConflicts.reload()]).catch(console.error);
1444
+ return {
1445
+ tone: 'warning',
1446
+ message: 'Your choice may have gone through even though the server didn’t answer. If this card is still here, choose again.',
1447
+ };
1448
+ }
1449
+ if (!answer.ok) {
1450
+ // The record moved on, or the entry moved or left the branch: what is left to choose shows.
1451
+ if (answer.code === 'CONFLICT_CHANGED' || answer.code === 'ENTRY_GONE') {
1452
+ void Promise.all([
1453
+ entryConflict.reload(),
1454
+ branchConflicts.reload(),
1455
+ refreshEntries(branch),
1456
+ ]).catch(console.error);
1457
+ }
1458
+ if (answer.status !== 409)
1459
+ return { ...failed, message: answer.error ?? failed.message };
1460
+ const error = answer.error ?? failed.message;
1461
+ // A latest version that can't replace this entry leaves keeping yours, then editing it.
1462
+ return {
1463
+ tone: 'warning',
1464
+ message: choice === 'use-latest' && !answer.code
1465
+ ? `${error} Choose "${EDITOR_ACTIONS.keepYourVersion}", then edit it to match.`
1466
+ : error,
1467
+ };
1468
+ }
1469
+ // The entry's file may have changed, so its saved copy is read again.
1470
+ setLoadedValues((prev) => {
1471
+ if (!(id in prev))
1472
+ return prev;
1473
+ const { [id]: _reread, ...rest } = prev;
1474
+ return rest;
1475
+ });
1476
+ void Promise.all([
1477
+ branchConflicts.reload(),
1478
+ entryConflict.reload(),
1479
+ handleReloadBranchData(),
1480
+ refreshEntries(branch),
1481
+ ]).catch(console.error);
1482
+ return undefined;
1483
+ };
1484
+ // An admin's release of a branch held as its record of conflicts was set aside.
1485
+ const handleReleaseConflictsHold = async () => {
1486
+ const result = await releaseConflictsHold(apiClient, branchNameState);
1487
+ if ('released' in result) {
1488
+ notifications.show({
1489
+ message: result.wasHeld === true
1490
+ ? 'Hold released.'
1491
+ : result.wasHeld === false
1492
+ ? 'This branch was already released.'
1493
+ : 'This branch is no longer on hold.',
1494
+ color: 'green',
1495
+ });
1496
+ void branchConflicts.reload();
1497
+ }
1498
+ return result;
1499
+ };
1500
+ keepConflictsRef.current = (items) => {
1501
+ void Promise.all(items.map((c) => c.logicalPath
1502
+ ? resolveEntryConflict(c.logicalPath, c.contentId, c.mergeSha, 'keep')
1503
+ : Promise.resolve(undefined))).then((answers) => {
1504
+ const refusals = answers.filter((r) => r !== undefined);
1505
+ if (refusals.length === 0)
1506
+ return;
1507
+ const more = refusals.length > 1 ? ` (${refusals.length} of ${items.length} weren’t kept.)` : '';
1508
+ notifications.show({
1509
+ message: `${refusals[0].message}${more}`,
1510
+ color: refusals.some((r) => r.tone === 'error') ? 'red' : 'yellow',
1511
+ autoClose: false,
1512
+ });
1513
+ });
1514
+ };
1515
+ const changesResolveEntry = changesSubject && entryConflict.data
1516
+ ? async (choice) => {
1517
+ const { contentId: id, mergeSha, kind } = entryConflict.data ?? {};
1518
+ if (!id || !mergeSha)
1519
+ return undefined;
1520
+ if (choice === 'delete' || (choice === 'use-latest' && kind === 'unreadable')) {
1521
+ confirmAfterChangesRef.current = {
1522
+ logicalPath: changesSubject.logicalPath,
1523
+ id,
1524
+ mergeSha,
1525
+ title: changesSubject.title,
1526
+ choice,
1527
+ };
1528
+ setChangesOpen(false);
1529
+ return undefined;
1530
+ }
1531
+ return resolveEntryConflict(changesSubject.logicalPath, id, mergeSha, choice);
1532
+ }
1533
+ : undefined;
1534
+ const confirmEntryChoice = (target) => {
1535
+ const branch = branchNameState;
1536
+ const deleting = target.choice === 'delete';
1537
+ let confirmed = false;
1538
+ openConfirm({
1539
+ title: deleting ? 'Delete entry?' : 'Use the latest version?',
1540
+ children: (_jsx(Text, { size: "sm", children: deleting
1541
+ ? `“${target.title}” was deleted in the latest version. Deleting it here too removes your changes to it.`
1542
+ : `Your saved changes to “${target.title}” are replaced by the latest version.` })),
1543
+ labels: {
1544
+ confirm: deleting ? EDITOR_ACTIONS.deleteEntry : EDITOR_ACTIONS.useLatestVersion,
1545
+ cancel: 'Cancel',
1546
+ },
1547
+ confirmProps: { color: 'red' },
1548
+ // Any way out but Confirm, Escape and the close button included, returns to "Show changes".
1549
+ onClose: () => {
1550
+ if (confirmed)
1551
+ return;
1552
+ setChangesOpen(true);
1553
+ setChangesShown(true);
1554
+ },
1555
+ onConfirm: () => {
1556
+ confirmed = true;
1557
+ void resolveEntryConflict(target.logicalPath, target.id, target.mergeSha, target.choice).then((refusal) => {
1558
+ if (refusal) {
1559
+ notifications.show({
1560
+ message: refusal.message,
1561
+ color: refusal.tone === 'error' ? 'red' : 'yellow',
1562
+ autoClose: false,
1563
+ });
1564
+ }
1565
+ else if (deleting) {
1566
+ closeEntry(target.logicalPath, branch);
1567
+ }
1568
+ });
1569
+ },
1570
+ });
1571
+ };
1572
+ // A choice made on an earlier merge of a field no longer counts once the record moved on, and
1573
+ // an entry whose conflicts are gone keeps none.
1574
+ const retainChoices = conflictChoices.retain;
1575
+ const keepEntryChoices = conflictChoices.keepEntries;
1576
+ // An entry no longer listed with conflicts, resolved elsewhere or by a later merge, keeps none.
1577
+ const listedConflicts = currentBranch?.conflictFiles?.length
1578
+ ? branchConflicts.data?.conflicts
1579
+ : currentBranch
1580
+ ? []
1581
+ : undefined;
1582
+ const listedIds = listedConflicts?.map((c) => c.contentId).join(',');
1583
+ useEffect(() => {
1584
+ if (listedIds !== undefined)
1585
+ keepEntryChoices(new Set(listedIds.split(',').filter(Boolean)));
1586
+ }, [listedIds, keepEntryChoices]);
1587
+ useEffect(() => {
1588
+ const id = changesConflictTarget?.id;
1589
+ if (!id || entryConflict.data === undefined)
1590
+ return;
1591
+ retainChoices(id, entryConflict.data?.fields ?? []);
1592
+ }, [changesConflictTarget?.id, entryConflict.data, retainChoices]);
1593
+ // The toast's "Show changes" opens the first entry with conflicts, then, once it is open and
1594
+ // listed as changed, its drawer.
1595
+ const [pendingConflictShow, setPendingConflictShow] = useState();
1596
+ showConflictsRef.current = () => {
1597
+ const branch = branchNameState;
1598
+ void apiClient.conflicts
1599
+ .list({ branch })
1600
+ .then((answer) => {
1601
+ if (branch !== currentBranchRef.current)
1602
+ return;
1603
+ const first = answer.ok
1604
+ ? answer.data?.conflicts.find((c) => c.logicalPath && entriesState.some((e) => e.path === c.logicalPath))
1605
+ : undefined;
1606
+ if (!first?.logicalPath)
1607
+ return;
1608
+ setPendingConflictShow(first.logicalPath);
1609
+ openEntry(first.logicalPath);
1610
+ })
1611
+ .catch(console.error);
1612
+ };
1613
+ useEffect(() => {
1614
+ if (!pendingConflictShow)
1615
+ return;
1616
+ if (selectedPath !== pendingConflictShow) {
1617
+ setPendingConflictShow(undefined);
1618
+ return;
1619
+ }
1620
+ if (currentEntry?.path !== pendingConflictShow)
1621
+ return;
1622
+ const change = branchChanges.data?.entries.find((e) => e.status !== 'deleted' && e.logicalPath === pendingConflictShow);
1623
+ if (!change)
1624
+ return;
1625
+ setPendingConflictShow(undefined);
1626
+ showEntryChanges(change, currentEntry.label ?? currentEntry.slug ?? '', 'header');
1627
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- showEntryChanges reads this render
1628
+ }, [pendingConflictShow, selectedPath, currentEntry, branchChanges.data]);
1629
+ const showOpenEntryChanges = () => {
1630
+ const change = currentEntry &&
1631
+ branchChanges.data?.entries.find((e) => e.status !== 'deleted' && e.logicalPath === currentEntry.path);
1632
+ if (change)
1633
+ showEntryChanges(change, currentEntry.label ?? currentEntry.slug ?? '', 'header');
1634
+ };
1635
+ // The open entry's conflicts, said above its form, with the way to "Show changes".
1636
+ const openEntryConflictNotice = currentEntry?.contentId && currentBranch?.conflictFiles?.includes(currentEntry.contentId)
1637
+ ? {
1638
+ message: entryConflictNotice(branchConflicts.data?.conflicts.find((c) => c.contentId === currentEntry.contentId)),
1639
+ onShowChanges: branchChanges.data?.entries.some((e) => e.status !== 'deleted' && e.logicalPath === currentEntry.path)
1640
+ ? showOpenEntryChanges
1641
+ : undefined,
1642
+ }
1643
+ : undefined;
1304
1644
  const openEntryChange = currentEntry &&
1305
1645
  branchChanges.data?.entries.find((e) => e.status !== 'deleted' && e.logicalPath === currentEntry.path);
1306
1646
  const referenceTitles = useMemo(() => new Map(entriesState.map((e) => [e.contentId, e.title ?? e.label])), [entriesState]);
@@ -1365,18 +1705,20 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1365
1705
  changesSubject.status !== 'deleted' &&
1366
1706
  changesSubject.logicalPath === currentEntry.path &&
1367
1707
  (changesSubject.contentId === undefined || changesSubject.contentId === currentEntry.contentId);
1368
- const changesLockReason = branchContentLocked
1369
- ? describeBranchLock({
1370
- branchStatus: currentBranch?.status,
1371
- branchReadOnly: currentBranch?.readOnly,
1372
- branchWriteBlocked: true,
1373
- branchPending,
1374
- branchMissing,
1375
- reviewing: branchReviewing,
1376
- pullRequestClosed: branchPullRequestClosed,
1377
- approveBlocked: currentBranch?.approveBlocked,
1378
- })
1379
- : undefined;
1708
+ const changesLockReason = latestWaiting
1709
+ ? WAITING_NOTICE
1710
+ : branchContentLocked
1711
+ ? describeBranchLock({
1712
+ branchStatus: currentBranch?.status,
1713
+ branchReadOnly: currentBranch?.readOnly,
1714
+ branchWriteBlocked: true,
1715
+ branchPending,
1716
+ branchMissing,
1717
+ reviewing: branchReviewing,
1718
+ pullRequestClosed: branchPullRequestClosed,
1719
+ approveBlocked: currentBranch?.approveBlocked,
1720
+ })
1721
+ : undefined;
1380
1722
  const changesUndo = changesSubjectOpen && currentEntry && displayValue
1381
1723
  ? {
1382
1724
  // What the form shows: a draft hidden behind a lock isn't what the cards compare.
@@ -1683,7 +2025,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1683
2025
  }
1684
2026
  : undefined, "data-testid": "content-panel", actions: _jsxs(_Fragment, { children: [navCollections &&
1685
2027
  navCollections.length > 0 &&
1686
- !branchContentLocked &&
2028
+ !contentLocked &&
1687
2029
  (navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end",
1688
2030
  // Its items open dialogs outside the panel, which would close a transient one.
1689
2031
  onOpen: keepContentPanelOpen, children: [_jsx(Tooltip, { label: "Content actions", withinPortal: true, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": "Content actions", children: _jsx(IconDots, { size: 16 }) }) }) }), _jsxs(Menu.Dropdown, { children: [navCollections[0].onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: () => navCollections[0].onAdd?.(), children: "Add entry\u2026" })), navCollections[0].onAddSubCollection && (_jsxs(_Fragment, { children: [navCollections[0].onAdd && _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Admin" }), _jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: () => navCollections[0].onAddSubCollection?.(), children: "Add collection\u2026" })] }))] })] })), _jsx(Tooltip, { label: "Collapse all folders", withinPortal: true, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: handleCollapseAll, "aria-label": "Collapse all folders", children: _jsx(IconChevronUp, { size: 16 }) }) }), _jsx(Tooltip, { label: "Expand all folders", withinPortal: true, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: handleExpandAll, "aria-label": "Expand all folders", children: _jsx(IconChevronDown, { size: 16 }) }) })] }), children: _jsx(Box, { px: "md", children: _jsx(EntryNavigator, { collections: navCollections, items: navCollections
@@ -1698,17 +2040,19 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1698
2040
  missing: e.status === 'missing' || undefined,
1699
2041
  noPage: !e.previewSrc || undefined,
1700
2042
  readOnly: e.canEdit === false || undefined,
1701
- })), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked, readOnlyReason: branchPending
2043
+ })), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: contentLocked, readOnlyReason: branchPending
1702
2044
  ? undefined
1703
- : describeBranchLock({
1704
- branchStatus: currentBranch?.status,
1705
- branchReadOnly: currentBranch?.readOnly,
1706
- branchWriteBlocked: branchContentLocked,
1707
- branchMissing,
1708
- reviewing: branchReviewing,
1709
- pullRequestClosed: branchPullRequestClosed,
1710
- approveBlocked: currentBranch?.approveBlocked,
1711
- }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), entryFilter: entryFilter === 'changed' && !changesOffered ? 'all' : entryFilter, onEntryFilterChange: setEntryFilter, onShowUnsaved: showUnsaved, onShowDeletedChanges: (entry, label) => showEntryChanges(entry, label, 'deleted row'), branchChanges: changesOffered
2045
+ : latestWaiting
2046
+ ? WAITING_NOTICE
2047
+ : describeBranchLock({
2048
+ branchStatus: currentBranch?.status,
2049
+ branchReadOnly: currentBranch?.readOnly,
2050
+ branchWriteBlocked: branchContentLocked,
2051
+ branchMissing,
2052
+ reviewing: branchReviewing,
2053
+ pullRequestClosed: branchPullRequestClosed,
2054
+ approveBlocked: currentBranch?.approveBlocked,
2055
+ }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), entryFilter: entryFilter === 'changed' && !changesOffered ? 'all' : entryFilter, onEntryFilterChange: setEntryFilter, onShowUnsaved: showUnsaved, onShowDeletedChanges: (entry, label) => showEntryChanges(entry, label, 'deleted row'), branchChanges: changesOffered
1712
2056
  ? {
1713
2057
  base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch',
1714
2058
  data: branchChanges.data,
@@ -1731,7 +2075,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1731
2075
  // branchWriteBlocked reuses branchContentLocked itself rather than
1732
2076
  // re-deriving `?? true` a second time here, so there is exactly one
1733
2077
  // fail-closed computation of "are writes blocked" in this component.
1734
- branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
2078
+ branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, openConflicts: openConflictCount, conflictRecordHeld: branchConflicts.data?.held
2079
+ ? { ...(userIsAdmin && { onRelease: handleReleaseConflictsHold }) }
2080
+ : undefined, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, baseUpdate: baseUpdateControl, latestWait: latestWait, onBranchReloadData: () => {
1735
2081
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1736
2082
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
1737
2083
  // On any entry: EntryHeader's `saving` is the open entry's alone.
@@ -1764,7 +2110,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1764
2110
  // True exactly while the form below shows "Setting up…" or "Loading content…".
1765
2111
  entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
1766
2112
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1767
- hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, branchApproveBlocked: currentBranch?.approveBlocked, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
2113
+ hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, writesWaitReason: latestWaiting ? WAITING_NOTICE : undefined, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, branchApproveBlocked: currentBranch?.approveBlocked, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
1768
2114
  ? () => showEntryChanges(openEntryChange, currentEntry?.label ?? currentEntry?.slug ?? '', 'header')
1769
2115
  : undefined, showChangesPending: changesOffered && !!currentEntry, showChangesRef: showChangesRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1770
2116
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
@@ -1783,12 +2129,12 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1783
2129
  ? 'Setting up your branch workspace…'
1784
2130
  : entriesInitializing
1785
2131
  ? 'Loading content…'
1786
- : 'Select an item to start editing.' })) : currentEntryUnavailable ? (_jsx(UnavailableEntryNotice, { schemaRefs: currentEntry.unavailable ? [currentEntry.unavailable.schemaRef] : [] })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryLabel: currentEntry.label })) : schema.length > 0 && displayValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(SiteMdxAllowContext.Provider, { value: mdxAllow, children: _jsx(PreviewFollowContext.Provider, { value: followInPreview ?? null, children: _jsx(FormRenderer, { notice: hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
1787
- currentBranch?.status === 'archived',
1788
- // A hidden draft's discard changes nothing on screen, so it says so.
1789
- onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1790
- announce: true,
1791
- }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
2132
+ : 'Select an item to start editing.' })) : currentEntryUnavailable ? (_jsx(UnavailableEntryNotice, { schemaRefs: currentEntry.unavailable ? [currentEntry.unavailable.schemaRef] : [] })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryLabel: currentEntry.label })) : schema.length > 0 && displayValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(SiteMdxAllowContext.Provider, { value: mdxAllow, children: _jsx(PreviewFollowContext.Provider, { value: followInPreview ?? null, children: _jsx(FormRenderer, { notice: (latestWait !== 'none' || hiddenDraftNotice) && (_jsxs(_Fragment, { children: [latestWait !== 'none' && (_jsx(BaseUpdateNotice, { wait: latestWait, hidesDraft: draftHidden && latestWaiting })), hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
2133
+ currentBranch?.status === 'archived',
2134
+ // A hidden draft's discard changes nothing on screen, so it says so.
2135
+ onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
2136
+ announce: true,
2137
+ }) }))] })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1792
2138
  title: currentEntry.label,
1793
2139
  typeLabel: entryTypeLabel(activeCollections, currentEntry),
1794
2140
  },
@@ -1801,11 +2147,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1801
2147
  const keyed = withItemKeys(schema, next, 'random');
1802
2148
  setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
1803
2149
  }
1804
- }, customRenderers: customRenderers, branch: branchNameState, comments: displayComments, currentEntryPath: selectedPath, currentUserId: commentUserId, canResolve: canResolveAnyComment, focusedFieldPath: focusedFieldPath, highlightThreadId: highlightThreadId, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, fieldErrors: fieldErrors, format: currentEntry.format, conflictNotice: !!(currentEntry?.contentId &&
1805
- currentBranch?.conflictFiles?.includes(currentEntry.contentId)) }) }) }) })) : schema.length === 0 ? (_jsx(CenteredMessage, { children: "No fields to edit." })) : loadFailedKey !== `${branchNameState}:${currentEntry.contentId}` ? (_jsx(Stack, { gap: "lg", p: "md", "data-testid": "entry-loading-skeleton", children: [0, 1, 2].map((row) => (_jsxs(Stack, { gap: 6, children: [_jsx(Skeleton, { h: 12, w: 120 }), _jsx(Skeleton, { h: 36 })] }, row))) })) : (_jsx(Alert, { color: "red", variant: "light", m: "md", title: "This entry couldn't be loaded.", "data-testid": "entry-load-failed", children: _jsx(Button, { variant: "default", onClick: () => {
2150
+ }, customRenderers: customRenderers, branch: branchNameState, comments: displayComments, currentEntryPath: selectedPath, currentUserId: commentUserId, canResolve: canResolveAnyComment, focusedFieldPath: focusedFieldPath, highlightThreadId: highlightThreadId, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, fieldErrors: fieldErrors, format: currentEntry.format, conflictNotice: openEntryConflictNotice }) }) }) })) : schema.length === 0 ? (_jsx(CenteredMessage, { children: "No fields to edit." })) : loadFailedKey !== `${branchNameState}:${currentEntry.contentId}` ? (_jsx(Stack, { gap: "lg", p: "md", "data-testid": "entry-loading-skeleton", children: [0, 1, 2].map((row) => (_jsxs(Stack, { gap: 6, children: [_jsx(Skeleton, { h: 12, w: 120 }), _jsx(Skeleton, { h: 36 })] }, row))) })) : (_jsx(Alert, { color: "red", variant: "light", m: "md", title: "This entry couldn't be loaded.", "data-testid": "entry-load-failed", children: _jsx(Button, { variant: "default", onClick: () => {
1806
2151
  setLoadFailedKey(null);
1807
2152
  setLoadAttempt((attempt) => attempt + 1);
1808
- }, children: "Retry" }) })) }) })] }), overlays: _jsxs(_Fragment, { children: [_jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, returnFocusTo: branchReturnTarget, children: _jsx(BranchManager, { startCreating: branchManagerOpening.create, branches: branchSummaries, mode: operatingMode, user: userContext, currentBranchName: currentBranch?.name ?? branchNameState, currentWorkflowBlockedReason: saveAllProgress ? 'Wait for Save all to finish.' : undefined, onSelect: async (name) => {
2153
+ }, children: "Retry" }) })) }) })] }), overlays: _jsxs(_Fragment, { children: [_jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, returnFocusTo: branchReturnTarget, children: _jsx(BranchManager, { startCreating: branchManagerOpening.create, branches: branchSummaries, mode: operatingMode, user: userContext, currentBranchName: currentBranch?.name ?? branchNameState, currentWorkflowBlockedReason: saveAllProgress
2154
+ ? 'Wait for Save all to finish.'
2155
+ : latestWaiting
2156
+ ? WAITING_SUBMIT_REASON
2157
+ : undefined, currentBaseUpdate: baseUpdateControl, onSelect: async (name) => {
1809
2158
  try {
1810
2159
  await handleBranchChange(name);
1811
2160
  setBranchManagerOpen(false);
@@ -1840,6 +2189,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1840
2189
  handleArchive(name).catch((err) => console.error(err));
1841
2190
  }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), changesSubject && (_jsx(LazyEntryChangesDrawer, { opened: changesOpen, onClose: () => setChangesOpen(false), onExited: () => {
1842
2191
  setChangesShown(false);
2192
+ const editing = focusAfterChangesRef.current;
2193
+ focusAfterChangesRef.current = undefined;
2194
+ if (editing)
2195
+ focusConflictField(editing);
2196
+ const confirming = confirmAfterChangesRef.current;
2197
+ confirmAfterChangesRef.current = undefined;
2198
+ if (confirming)
2199
+ confirmEntryChoice(confirming);
1843
2200
  const deleting = deleteAfterChangesRef.current;
1844
2201
  deleteAfterChangesRef.current = undefined;
1845
2202
  // Opened as from the entry menu, whose trigger stands in for the gone button.
@@ -1851,7 +2208,27 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1851
2208
  unsavedEdits: changesSubject.logicalPath === currentEntry?.path && isSelectedDirty(),
1852
2209
  }, state: entryChange, referenceTitle: referenceTitle, onOpenEntry: changesOpenEntry, pullRequestUrl: changesPullRequestUrl, undo: changesUndo, noEditReason: (changesSubjectOpen && currentEntry?.canEdit === false) || restoreRefusedByPath
1853
2210
  ? NO_EDIT_ACCESS_REASON
1854
- : undefined, lockedReason: changesLockReason, onDeleteEntry: changesDeleteEntry, onRestoreEntry: changesRestoreEntry })), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchLabel, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
2211
+ : undefined, lockedReason: changesLockReason, onDeleteEntry: changesDeleteEntry, onRestoreEntry:
2212
+ // A deleted entry with conflicts open is settled on its conflict card first: a
2213
+ // deleted-on-branch card restores it, and with field conflicts a restore would
2214
+ // bring back the latest version unchanged, which "Show changes" doesn't list,
2215
+ // leaving them with nowhere to be chosen. Once they're kept, this offers it.
2216
+ entryConflict.data ? undefined : changesRestoreEntry, conflicts: changesConflictTarget
2217
+ ? {
2218
+ state: entryConflict,
2219
+ adminBaseBranch: userIsAdmin ? currentBranch?.baseBranch : undefined,
2220
+ choices: conflictChoices.choicesFor(changesConflictTarget.id),
2221
+ onChoose: (field, choice, decided) => conflictChoices.choose(changesConflictTarget.id, field.key, field.mergeSha, choice, decided),
2222
+ onEdit: changesSubjectOpen
2223
+ ? (field) => {
2224
+ focusAfterChangesRef.current = field;
2225
+ setChangesOpen(false);
2226
+ }
2227
+ : undefined,
2228
+ onResolveEntry: changesResolveEntry,
2229
+ unsavedEdits: isDirtyForEntry(changesSubject.logicalPath),
2230
+ }
2231
+ : undefined })), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchLabel, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
1855
2232
  ? (path, entryType) => addEntryType(path, entryType)
1856
2233
  : undefined, onUpdateEntryType: editingCollection
1857
2234
  ? (path, name, updates) => updateEntryType(path, name, updates)