canopycms 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CHANGELOG.md +326 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.d.ts +20 -2
  4. package/dist/api/branch-review.js +153 -32
  5. package/dist/api/branch-status.d.ts +23 -2
  6. package/dist/api/branch-status.js +16 -2
  7. package/dist/api/branch-update-from-base.d.ts +23 -0
  8. package/dist/api/branch-update-from-base.js +227 -0
  9. package/dist/api/branch.d.ts +10 -0
  10. package/dist/api/branch.js +28 -2
  11. package/dist/api/client.d.ts +15 -2
  12. package/dist/api/client.js +24 -2
  13. package/dist/api/comments.d.ts +2 -2
  14. package/dist/api/content.d.ts +38 -1
  15. package/dist/api/content.js +41 -5
  16. package/dist/api/merge-conflicts.d.ts +235 -0
  17. package/dist/api/merge-conflicts.js +709 -0
  18. package/dist/api/routes.js +2 -0
  19. package/dist/api/types.d.ts +8 -3
  20. package/dist/base-update.d.ts +32 -0
  21. package/dist/base-update.js +31 -0
  22. package/dist/branch-metadata-file.d.ts +66 -0
  23. package/dist/branch-metadata-file.js +16 -1
  24. package/dist/branch-schema-cache.d.ts +12 -0
  25. package/dist/branch-schema-cache.js +27 -2
  26. package/dist/cli/backfill-item-keys.d.ts +77 -0
  27. package/dist/cli/backfill-item-keys.js +320 -0
  28. package/dist/cli/cli.d.ts +1 -1
  29. package/dist/cli/cli.js +9798 -1509
  30. package/dist/cli/generate-ai-content.js +373 -249
  31. package/dist/cli/github-app-manifest.d.ts +4 -1
  32. package/dist/cli/github-app-manifest.js +5 -1
  33. package/dist/cli/init.js +12 -0
  34. package/dist/cli/site-config.d.ts +14 -2
  35. package/dist/cli/site-config.js +20 -4
  36. package/dist/comment-store.d.ts +8 -0
  37. package/dist/comment-store.js +28 -0
  38. package/dist/content-index-generation.d.ts +1 -4
  39. package/dist/content-index-generation.js +1 -4
  40. package/dist/content-store.d.ts +39 -3
  41. package/dist/content-store.js +48 -15
  42. package/dist/editor/BranchManager.d.ts +4 -1
  43. package/dist/editor/BranchManager.js +9 -5
  44. package/dist/editor/Editor.js +462 -62
  45. package/dist/editor/EntryNavigator.d.ts +10 -2
  46. package/dist/editor/EntryNavigator.js +12 -4
  47. package/dist/editor/FormRenderer.d.ts +8 -2
  48. package/dist/editor/FormRenderer.js +5 -6
  49. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  50. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  51. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  52. package/dist/editor/base-update-wait.d.ts +78 -0
  53. package/dist/editor/base-update-wait.js +98 -0
  54. package/dist/editor/branch-status.js +1 -1
  55. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  56. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  57. package/dist/editor/components/BranchPill.d.ts +4 -1
  58. package/dist/editor/components/BranchPill.js +5 -2
  59. package/dist/editor/components/ConflictsGroup.d.ts +112 -0
  60. package/dist/editor/components/ConflictsGroup.js +404 -0
  61. package/dist/editor/components/EditorHeader.d.ts +18 -1
  62. package/dist/editor/components/EditorHeader.js +99 -33
  63. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  64. package/dist/editor/components/EntryChangesDrawer.js +85 -167
  65. package/dist/editor/components/EntryHeader.d.ts +3 -1
  66. package/dist/editor/components/EntryHeader.js +2 -2
  67. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  68. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  69. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  70. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  71. package/dist/editor/components/RetryToastMessage.js +1 -1
  72. package/dist/editor/components/change-parts.d.ts +51 -0
  73. package/dist/editor/components/change-parts.js +171 -0
  74. package/dist/editor/conflict-cards.d.ts +93 -0
  75. package/dist/editor/conflict-cards.js +284 -0
  76. package/dist/editor/conflict-choices.d.ts +60 -0
  77. package/dist/editor/conflict-choices.js +108 -0
  78. package/dist/editor/conflict-notice.d.ts +25 -0
  79. package/dist/editor/conflict-notice.js +39 -0
  80. package/dist/editor/copy.d.ts +12 -0
  81. package/dist/editor/copy.js +12 -0
  82. package/dist/editor/entry-diff.d.ts +2 -1
  83. package/dist/editor/entry-diff.js +5 -21
  84. package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
  85. package/dist/editor/hooks/useBaseUpdate.js +279 -0
  86. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  87. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  88. package/dist/editor/hooks/useBranchManager.d.ts +8 -1
  89. package/dist/editor/hooks/useBranchManager.js +108 -13
  90. package/dist/editor/hooks/useBranchesData.js +3 -0
  91. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  92. package/dist/editor/hooks/useConflictChoices.js +75 -0
  93. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  94. package/dist/editor/hooks/useConflictsData.js +89 -0
  95. package/dist/editor/hooks/useDraftManager.d.ts +13 -1
  96. package/dist/editor/hooks/useDraftManager.js +40 -9
  97. package/dist/editor/hooks/useEntryManager.d.ts +17 -3
  98. package/dist/editor/hooks/useEntryManager.js +11 -5
  99. package/dist/editor/release-hold.d.ts +12 -0
  100. package/dist/editor/release-hold.js +39 -0
  101. package/dist/editor/server-clock.d.ts +4 -0
  102. package/dist/editor/server-clock.js +15 -0
  103. package/dist/editor/undo-change.d.ts +7 -0
  104. package/dist/editor/undo-change.js +19 -4
  105. package/dist/git-manager.d.ts +6 -0
  106. package/dist/git-manager.js +38 -3
  107. package/dist/item-keys.d.ts +13 -0
  108. package/dist/item-keys.js +36 -14
  109. package/dist/merge/conflict-path.d.ts +40 -0
  110. package/dist/merge/conflict-path.js +128 -0
  111. package/dist/merge/diff3.d.ts +27 -0
  112. package/dist/merge/diff3.js +113 -0
  113. package/dist/merge/entry-merge.d.ts +94 -0
  114. package/dist/merge/entry-merge.js +876 -0
  115. package/dist/merge/list-alignment.d.ts +55 -0
  116. package/dist/merge/list-alignment.js +490 -0
  117. package/dist/merge/rekey.d.ts +11 -0
  118. package/dist/merge/rekey.js +75 -0
  119. package/dist/merge/text-merge.d.ts +29 -0
  120. package/dist/merge/text-merge.js +59 -0
  121. package/dist/merge/value-equality.d.ts +18 -0
  122. package/dist/merge/value-equality.js +50 -0
  123. package/dist/merge-conflicts.d.ts +232 -0
  124. package/dist/merge-conflicts.js +451 -0
  125. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  126. package/dist/operating-mode/client-safe-strategy.js +6 -0
  127. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  128. package/dist/operating-mode/types.d.ts +13 -0
  129. package/dist/services.d.ts +24 -1
  130. package/dist/services.js +138 -22
  131. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  132. package/dist/types.d.ts +63 -5
  133. package/dist/utils/content-serialize.d.ts +18 -1
  134. package/dist/utils/content-serialize.js +12 -0
  135. package/dist/utils/content-write-lock.d.ts +3 -3
  136. package/dist/utils/content-write-lock.js +3 -3
  137. package/dist/utils/git.d.ts +7 -0
  138. package/dist/utils/git.js +35 -0
  139. package/dist/utils/markdown-body-splice.d.ts +1 -1
  140. package/dist/utils/markdown-body-splice.js +1 -19
  141. package/dist/utils/markdown-parse.d.ts +9 -0
  142. package/dist/utils/markdown-parse.js +27 -0
  143. package/dist/version.d.ts +1 -1
  144. package/dist/version.js +1 -1
  145. package/dist/worker/branch-merge.d.ts +80 -40
  146. package/dist/worker/branch-merge.js +244 -58
  147. package/dist/worker/branch-policy.d.ts +18 -2
  148. package/dist/worker/branch-policy.js +36 -0
  149. package/dist/worker/cms-worker.d.ts +37 -3
  150. package/dist/worker/cms-worker.js +88 -16
  151. package/dist/worker/conflicts-status.d.ts +20 -0
  152. package/dist/worker/conflicts-status.js +204 -0
  153. package/dist/worker/field-merge.d.ts +80 -0
  154. package/dist/worker/field-merge.js +729 -0
  155. package/dist/worker/git-sync.d.ts +3 -2
  156. package/dist/worker/git-sync.js +16 -6
  157. package/dist/worker/github-commits.d.ts +42 -10
  158. package/dist/worker/github-commits.js +444 -52
  159. package/dist/worker/github-errors.d.ts +15 -0
  160. package/dist/worker/github-errors.js +47 -0
  161. package/dist/worker/github-gateway-http.js +24 -9
  162. package/dist/worker/github-gateway-wire.d.ts +49 -20
  163. package/dist/worker/github-gateway-wire.js +19 -3
  164. package/dist/worker/github-gateway.d.ts +27 -1
  165. package/dist/worker/github-gateway.js +55 -7
  166. package/dist/worker/github-transfer.d.ts +5 -3
  167. package/dist/worker/github-transfer.js +8 -5
  168. package/dist/worker/history-rewrite.d.ts +40 -8
  169. package/dist/worker/history-rewrite.js +51 -23
  170. package/dist/worker/publish-decision.d.ts +37 -0
  171. package/dist/worker/publish-decision.js +16 -0
  172. package/dist/worker/rebase.d.ts +29 -8
  173. package/dist/worker/rebase.js +492 -211
  174. package/dist/worker/task-runner.d.ts +1 -1
  175. package/dist/worker/task-runner.js +30 -41
  176. package/dist/worker/worker-context.d.ts +24 -2
  177. 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,12 @@ 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);
200
+ // Set once the entries and the changes read exist.
201
+ const rereadPinnedBranchRef = useRef(async () => { });
185
202
  const { branchName: branchNameState, setBranchName, branches, branchSummaries, currentBranch, currentAddress, listedBranch, namesBranch, urlBranchName, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleArchive, handleReloadBranchData, loadBranches, } = useBranchManager({
186
203
  initialBranch: branchName,
187
204
  operatingMode,
@@ -191,6 +208,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
191
208
  getUnsaved: () => resolveUnsavedRef.current(),
192
209
  onStamped: (stamped) => applySubmitStampsRef.current(stamped),
193
210
  onChangesRequested: (branch) => reloadCommentsRef.current(branch),
211
+ onReviewPinned: (branch) => rereadPinnedBranchRef.current(branch),
212
+ // Only the open branch, with nothing unsaved: its wait and answer show where the user is.
213
+ latestChangesAfterWithdraw: (branch) => namesBranchRef.current(branch) && modifiedCountRef.current === 0
214
+ ? () => requestLatestRef.current()
215
+ : undefined,
194
216
  });
195
217
  // Content mutations (add/delete/rename/reorder entries) are locked whenever the
196
218
  // server says writes are blocked -- the protected base branch, or a workflow
@@ -210,6 +232,21 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
210
232
  // No lock reason is true yet while the list is unanswered; once it has failed, or answered
211
233
  // without this branch, the lock says which.
212
234
  const branchPending = !currentBranch && !branchesLoaded && !branchesLoadFailed;
235
+ // Conflicts a merge left on the open branch (api/merge-conflicts.ts): read while its listing
236
+ // names any, and the choices made on their cards that no save has sent yet.
237
+ const branchConflicts = useBranchConflictsData(apiClient, branchNameState, currentBranch?.conflictFiles,
238
+ // Every viewer reads it, as a hold on the branch names no entry.
239
+ true);
240
+ const openConflictCount = currentBranch?.conflictFiles?.length
241
+ ? (branchConflicts.data?.openCount ?? currentBranch.conflictFiles.length)
242
+ : 0;
243
+ const conflictChoices = useConflictChoices(branchNameState);
244
+ // A save that sent choices re-reads what they resolved; set once the readers exist, below.
245
+ const conflictsSavedRef = useRef(() => { });
246
+ // An admin's "Keep your version" on a collection's conflicts; set once the resolver exists, below.
247
+ const keepConflictsRef = useRef(() => { });
248
+ // The "Get latest changes" toast's "Show changes"; set once the drawer exists, below.
249
+ const showConflictsRef = useRef(() => { });
213
250
  // The same inputs EditorHeader gives its own check, for the lock copy that names the way forward.
214
251
  const branchReviewRole = reviewRoleOf({
215
252
  status: currentBranch?.status,
@@ -236,7 +273,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
236
273
  // Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
237
274
  const switchBranchRef = useRef(async () => { });
238
275
  // Set once the changes read exists; it waits on the panel state, declared after the entries.
239
- const reloadChangesRef = useRef(() => { });
276
+ const reloadChangesRef = useRef(async () => { });
277
+ const rereadChangesRef = useRef(async () => { });
240
278
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
241
279
  const { selectedPath, setSelectedPath, closeEntry, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, entriesLoadFailed, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
242
280
  initialEntries: entries,
@@ -257,7 +295,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
257
295
  // A short name in the URL is no switch to another branch.
258
296
  sameBranch: namesBranch,
259
297
  urlBranchName,
260
- onContentRefresh: (branch) => reloadChangesRef.current(branch),
298
+ onContentRefresh: (branch) => void reloadChangesRef.current(branch),
299
+ conflictResolutions: {
300
+ pending: conflictChoices.pending,
301
+ onSaved: (branch, contentId, sent) => {
302
+ conflictChoices.sent(branch, contentId, sent);
303
+ conflictsSavedRef.current();
304
+ },
305
+ },
261
306
  // To the entry menu, which names the entry opened; a dialog keeps focus.
262
307
  onHistoryRestore: () => requestAnimationFrame(() => {
263
308
  if (!document.activeElement?.closest('[role="dialog"]')) {
@@ -335,7 +380,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
335
380
  setPreviewError(null);
336
381
  }, [currentEntry?.contentId]);
337
382
  // 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({
383
+ const { drafts, setDrafts, loadedValues, setLoadedValues, forgetLoadedValues, readGeneration, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, singleSaving, lastSaved, handleSaveAll, saveAllProgress, isSaveAllRunning, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, isDirtyForEntry, resolveUnsaved, fieldErrors, draftStorageFailed, applySubmitStamps, } = useDraftManager({
339
384
  branchName: branchNameState,
340
385
  selectedPath,
341
386
  currentEntry,
@@ -355,10 +400,58 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
355
400
  void refreshEntries(branchNameState).catch((err) => console.error('Failed to refresh entries after save', err));
356
401
  },
357
402
  // An Undo built on a discarded draft would put back an item the server's copy already has.
358
- onDraftsReset: (contentId) => dismissUndoToasts(contentId),
403
+ // A conflict choice is part of the draft, so it goes with it.
404
+ onDraftsReset: (contentId) => {
405
+ dismissUndoToasts(contentId);
406
+ conflictChoices.clear(contentId);
407
+ },
408
+ pendingIds: conflictChoices.pendingIds,
359
409
  });
360
410
  resolveUnsavedRef.current = resolveUnsaved;
361
411
  applySubmitStampsRef.current = applySubmitStamps;
412
+ // "Get latest changes" on the open branch: the worker rewrites its files meanwhile, so the form
413
+ // is read-only while it waits (`latestWaiting`), and every clean entry is read again after.
414
+ const currentSummary = branchSummaries.find((b) => b.name === currentBranch?.name);
415
+ const baseUpdate = useBaseUpdate({
416
+ branch: currentBranch,
417
+ operatingMode,
418
+ permitted: !!currentSummary && getBranchPermissions(currentSummary, userContext).canSubmit,
419
+ unsavedCount: modifiedCount,
420
+ onUpdated: () => {
421
+ setLoadedValues((prev) => Object.fromEntries(Object.entries(prev).filter(([contentId]) => draftsRef.current[contentId] !== undefined)));
422
+ void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
423
+ },
424
+ onShowConflicts: () => showConflictsRef.current(),
425
+ });
426
+ requestLatestRef.current = baseUpdate.request;
427
+ // The open branch was pinned for its reviews: its entries, their changes and every clean entry
428
+ // are read again, every one while it is locked, which shows the saved copy over a draft.
429
+ rereadPinnedBranchRef.current = async (branch) => {
430
+ if (!namesBranch(branch, branchNameState))
431
+ return;
432
+ forgetLoadedValues((id) => !branchContentLocked && draftsRef.current[id] !== undefined);
433
+ await Promise.all([
434
+ refreshEntries(branchNameState, { withChanges: false }),
435
+ rereadChangesRef.current(),
436
+ ]);
437
+ };
438
+ modifiedCountRef.current = modifiedCount;
439
+ namesBranchRef.current = (branch) => namesBranch(branch, branchNameState);
440
+ // The request in flight counts as waiting: a save landing before the worker reads the branch
441
+ // would make it fail.
442
+ const latestWait = baseUpdate.requesting && baseUpdate.wait === 'none' ? 'waiting' : baseUpdate.wait;
443
+ const latestWaiting = latestWait === 'waiting';
444
+ const baseUpdateControl = baseUpdate.available
445
+ ? {
446
+ wait: baseUpdate.wait,
447
+ requesting: baseUpdate.requesting,
448
+ blockedReason: baseUpdate.blockedReason,
449
+ tooltip: getLatestChangesTooltip(currentBranch?.baseBranch, userIsAdmin),
450
+ onRequest: () => void baseUpdate.request(),
451
+ }
452
+ : undefined;
453
+ // Content writes wait on the branch's lock, and on the latest changes coming in.
454
+ const contentLocked = branchContentLocked || latestWaiting;
362
455
  // 4. Branch actions (depends on resolveUnsaved, setBranchName)
363
456
  const { handleBranchChange, confirmCreate: confirmCreateBranch, handleCreateBranch, confirmOpen: branchConfirmOpen, } = useBranchActions({
364
457
  branchName: branchNameState,
@@ -422,7 +515,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
422
515
  });
423
516
  // Read-only content shows the saved value, never a draft; a restored draft is kept, unseen,
424
517
  // until it is discarded or the branch unlocks.
425
- const contentReadOnly = branchContentLocked || currentEntry?.canEdit === false;
518
+ const contentReadOnly = contentLocked || currentEntry?.canEdit === false;
426
519
  const displayValue = contentReadOnly ? loadedValue : effectiveValue;
427
520
  // Read when an edit lands, which an upload or other async field work can do renders later.
428
521
  const contentReadOnlyRef = useRef(contentReadOnly);
@@ -518,7 +611,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
518
611
  };
519
612
  useEffect(() => () => dismissUndoToasts(), [currentEntry?.contentId, branchNameState]);
520
613
  // 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();
614
+ const draftHidden = contentReadOnly && currentBranch !== undefined && !!currentEntry && isSelectedDirty();
615
+ // While the latest changes come in, the waiting notice says the draft comes back; it is the one notice.
616
+ const hiddenDraftNotice = draftHidden && !latestWaiting;
522
617
  const collectionLabels = useMemo(() => buildCollectionLabels(activeCollections), [activeCollections]);
523
618
  const schema = currentEntry?.schema ?? EMPTY_SCHEMA;
524
619
  // Field threads follow their items' keys, placed in their entry's value where it is at hand.
@@ -526,7 +621,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
526
621
  if (entry.path === currentEntry?.path)
527
622
  return displayValue;
528
623
  // As `displayValue` would show it: read-only content shows its saved value, not a draft.
529
- const readOnly = branchContentLocked || entry.canEdit === false;
624
+ const readOnly = contentLocked || entry.canEdit === false;
530
625
  return (readOnly ? undefined : drafts[entry.contentId]) ?? loadedValues[entry.contentId];
531
626
  };
532
627
  const displayComments = useMemo(() => {
@@ -544,15 +639,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
544
639
  return path === thread.canopyPath ? thread : { ...thread, canopyPath: path };
545
640
  });
546
641
  // `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
- ]);
642
+ }, [comments, entriesState, currentEntry, displayValue, drafts, loadedValues, contentLocked]);
556
643
  // A jump into another entry waits for its value to place the thread; the stored path may be stale.
557
644
  const [pendingFieldJump, setPendingFieldJump] = useState(null);
558
645
  // A field whose comments are already open is still followed in the preview.
@@ -720,6 +807,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
720
807
  // write still the state of the branch on screen?".
721
808
  const requestBranch = branchNameState;
722
809
  const inFlightKey = `${requestBranch}:${contentId ?? ''}`;
810
+ const generation = readGeneration.current;
811
+ const loadingKey = `${inFlightKey}#${generation}`;
723
812
  const load = async () => {
724
813
  if (!currentEntry || !contentId || loadedValues[contentId] !== undefined)
725
814
  return;
@@ -729,13 +818,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
729
818
  // Guard against overlapping renders re-firing the same in-flight load
730
819
  // (e.g. `currentEntry` getting a new object reference from an entries
731
820
  // refresh while the fetch below is still pending).
732
- if (loadingEntryIdsRef.current.has(inFlightKey))
821
+ if (loadingEntryIdsRef.current.has(loadingKey))
733
822
  return;
734
- loadingEntryIdsRef.current.add(inFlightKey);
823
+ loadingEntryIdsRef.current.add(loadingKey);
735
824
  setEntriesLoading(true);
736
825
  setLoadFailedKey((failed) => (failed === inFlightKey ? null : failed));
737
826
  try {
738
- const loaded = await loadEntry(currentEntry);
827
+ const loaded = await loadEntry(currentEntry, () => readGeneration.current === generation);
739
828
  // A branch switch during the await makes this response another
740
829
  // branch's content. Writing it would (a) show it as the new branch's
741
830
  // content and (b) satisfy the skip gate above, permanently suppressing
@@ -744,6 +833,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
744
833
  // instead; the new branch has its own load running under its own key.
745
834
  if (currentBranchRef.current !== requestBranch)
746
835
  return;
836
+ if (readGeneration.current !== generation)
837
+ return;
747
838
  setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
748
839
  // No draft is seeded here. `effectiveValue` is
749
840
  // `drafts[id] ?? loadedValues[id]`, so the form renders from the line
@@ -753,7 +844,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
753
844
  // session as a dirty, stale full-content snapshot.
754
845
  }
755
846
  finally {
756
- loadingEntryIdsRef.current.delete(inFlightKey);
847
+ loadingEntryIdsRef.current.delete(loadingKey);
757
848
  // Clear the shared flag if this load's entry is still selected on the
758
849
  // branch it was loaded for, OR if no other load remains in flight. The
759
850
  // second arm matters when the user navigates from a slow-loading entry
@@ -762,7 +853,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
762
853
  // this stale settle would leave `entriesLoading` (and therefore
763
854
  // `busy`) stuck true with nothing left to clear it.
764
855
  if ((currentBranchRef.current === requestBranch &&
765
- currentContentIdRef.current === contentId) ||
856
+ currentContentIdRef.current === contentId &&
857
+ readGeneration.current === generation) ||
766
858
  loadingEntryIdsRef.current.size === 0) {
767
859
  setEntriesLoading(false);
768
860
  }
@@ -778,7 +870,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
778
870
  // Same staleness guard as above: don't tell the user an entry failed
779
871
  // to load when they've already navigated to a different one (or to a
780
872
  // different branch).
781
- if (currentBranchRef.current === requestBranch && currentContentIdRef.current === contentId) {
873
+ if (currentBranchRef.current === requestBranch &&
874
+ currentContentIdRef.current === contentId &&
875
+ readGeneration.current === generation) {
782
876
  notifications.show({ message: 'Failed to load entry', color: 'red' });
783
877
  setLoadFailedKey(inFlightKey);
784
878
  }
@@ -1109,6 +1203,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1109
1203
  const entries = grouped.get(node.path) ?? [];
1110
1204
  const children = node.children?.map((child) => build(child)) ?? [];
1111
1205
  const unavailableRefs = unavailableSchemaRefs(node.entryTypes);
1206
+ // Conflicts on the collection's settings or other files, which no entry's "Show changes"
1207
+ // reaches; the API names them to admins only, by the collection's path.
1208
+ const keepable = userIsAdmin
1209
+ ? (branchConflicts.data?.conflicts ?? []).filter((c) => c.logicalPath === node.path && !entriesState.some((e) => e.contentId === c.contentId))
1210
+ : [];
1112
1211
  // No entry can be created where every entry type is unavailable.
1113
1212
  const allUnavailable = !!node.entryTypes?.length && node.entryTypes.every((et) => et.unavailable);
1114
1213
  // A collection that holds only collections has no entry type to add (`entryTypes` is
@@ -1123,7 +1222,16 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1123
1222
  order: node.order,
1124
1223
  entries: entries.length > 0 ? entries : undefined,
1125
1224
  children: children.length > 0 ? children : undefined,
1126
- conflictNotice: !!(node.contentId && currentBranch?.conflictFiles?.includes(node.contentId)),
1225
+ conflictNotice: !!(node.contentId && currentBranch?.conflictFiles?.includes(node.contentId)) ||
1226
+ keepable.length > 0,
1227
+ ...(keepable.length > 0 && {
1228
+ conflictKeep: {
1229
+ label: keepable.every((c) => c.path.endsWith('.collection.json'))
1230
+ ? 'Keep your version of its settings'
1231
+ : 'Keep your version of its files',
1232
+ onKeep: () => keepConflictsRef.current(keepable),
1233
+ },
1234
+ }),
1127
1235
  unavailableSchemaRefs: unavailableRefs.length > 0 ? unavailableRefs : undefined,
1128
1236
  addRefused: (addable && node.canAddEntry === false) || undefined,
1129
1237
  onAdd: canAddEntry
@@ -1152,6 +1260,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1152
1260
  currentBranch,
1153
1261
  schemaUnavailableKeys,
1154
1262
  userIsAdmin,
1263
+ branchConflicts.data,
1155
1264
  ]);
1156
1265
  const findShortcut = modKeyLabel() === '⌘' ? { label: '⌘P', keys: 'Meta+P' } : { label: 'Ctrl+P', keys: 'Control+P' };
1157
1266
  // Mod+P opens a closed docked panel only until Enter, Escape from inside it or focus leaves it;
@@ -1261,10 +1370,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1261
1370
  const changesAvailable = !!currentBranch && currentBranch.isProtected === false;
1262
1371
  const changesRead = (contentPanelShown || !!currentEntry) && changesAvailable;
1263
1372
  const branchChanges = useBranchChangesData(apiClient, branchNameState, changesRead);
1264
- reloadChangesRef.current = (branch) => {
1373
+ reloadChangesRef.current = async (branch) => {
1265
1374
  if (branch === branchNameState)
1266
- void branchChanges.reload();
1375
+ await branchChanges.reload();
1267
1376
  };
1377
+ rereadChangesRef.current = branchChanges.reread;
1268
1378
  // Submit, withdraw and a review move the branch's status, which no entries refresh follows.
1269
1379
  const changesStatusRef = useRef({ branch: branchNameState, status: currentBranch?.status });
1270
1380
  useEffect(() => {
@@ -1301,6 +1411,259 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1301
1411
  id: changesSubject.contentId,
1302
1412
  }
1303
1413
  : undefined);
1414
+ // The drawer's entry's conflicts, read while it shows one the branch lists.
1415
+ const changesConflictTarget = (changesOpen || changesShown) &&
1416
+ changesSubject?.contentId &&
1417
+ currentBranch?.conflictFiles?.includes(changesSubject.contentId)
1418
+ ? { logicalPath: changesSubject.logicalPath, id: changesSubject.contentId }
1419
+ : undefined;
1420
+ const entryConflict = useEntryConflictData(apiClient, branchNameState, changesConflictTarget);
1421
+ conflictsSavedRef.current = () => {
1422
+ void Promise.all([
1423
+ branchConflicts.reload(),
1424
+ entryConflict.reload(),
1425
+ handleReloadBranchData(),
1426
+ ]).catch(console.error);
1427
+ };
1428
+ // "Edit" on a card closes the drawer, and its exit focuses the field in the form.
1429
+ const focusAfterChangesRef = useRef();
1430
+ // An entry-level choice that replaces stored work, "Delete entry" or "Use latest version",
1431
+ // confirms once the drawer has closed; any way out of the confirm but Confirm opens it again.
1432
+ const confirmAfterChangesRef = useRef();
1433
+ const focusConflictField = (field) => {
1434
+ revealForm();
1435
+ requestAnimationFrame(() => {
1436
+ const id = currentEntry?.contentId;
1437
+ const value = id && (draftsRef.current[id] ?? loadedValuesRef.current[id]);
1438
+ const schema = currentEntry?.schema;
1439
+ if (!value || !schema)
1440
+ return;
1441
+ const bodyField = currentEntry.format === 'md' || currentEntry.format === 'mdx'
1442
+ ? findBodyFieldName(schema)
1443
+ : undefined;
1444
+ const at = canopyPathOf(value, placeOf(field), bodyField);
1445
+ const target = at && findFieldTarget(formatCanopyPath(at));
1446
+ if (target)
1447
+ focusFieldTarget(target.element);
1448
+ });
1449
+ };
1450
+ const resolveEntryConflict = async (logicalPath, id, mergeSha, choice, entryGone) => {
1451
+ const branch = branchNameState;
1452
+ const failed = {
1453
+ tone: 'error',
1454
+ message: 'Couldn’t save your choice. Try again.',
1455
+ };
1456
+ let answer;
1457
+ try {
1458
+ answer = await apiClient.conflicts.resolveEntry({ branch, entryPath: logicalPath }, { id: id, mergeSha, choice, ...(entryGone && { entryGone }) });
1459
+ }
1460
+ catch (err) {
1461
+ if (!isUnanswered(err))
1462
+ return failed;
1463
+ }
1464
+ if (!answer || isGatewayFailure(answer) || answer.code === 'WRITE_OUTCOME_UNKNOWN') {
1465
+ // Reading the conflict again settles it: the card goes if the choice went through.
1466
+ void Promise.all([entryConflict.reload(), branchConflicts.reload()]).catch(console.error);
1467
+ return {
1468
+ tone: 'warning',
1469
+ message: 'Your choice may have gone through even though the server didn’t answer. If this card is still here, choose again.',
1470
+ };
1471
+ }
1472
+ if (!answer.ok) {
1473
+ // The record moved on, or the entry moved or left the branch: what is left to choose shows.
1474
+ if (answer.code === 'CONFLICT_CHANGED' || answer.code === 'ENTRY_GONE') {
1475
+ void Promise.all([
1476
+ entryConflict.reload(),
1477
+ branchConflicts.reload(),
1478
+ refreshEntries(branch),
1479
+ ]).catch(console.error);
1480
+ }
1481
+ if (answer.status !== 409)
1482
+ return { ...failed, message: answer.error ?? failed.message };
1483
+ const error = answer.error ?? failed.message;
1484
+ // A latest version that can't replace this entry leaves keeping yours, then editing it.
1485
+ return {
1486
+ tone: 'warning',
1487
+ message: choice === 'use-latest' && !answer.code
1488
+ ? `${error} Choose "${EDITOR_ACTIONS.keepYourVersion}", then edit it to match.`
1489
+ : error,
1490
+ };
1491
+ }
1492
+ // The entry's file may have changed, so its saved copy is read again.
1493
+ setLoadedValues((prev) => {
1494
+ if (!(id in prev))
1495
+ return prev;
1496
+ const { [id]: _reread, ...rest } = prev;
1497
+ return rest;
1498
+ });
1499
+ void Promise.all([
1500
+ branchConflicts.reload(),
1501
+ entryConflict.reload(),
1502
+ handleReloadBranchData(),
1503
+ refreshEntries(branch),
1504
+ ]).catch(console.error);
1505
+ return undefined;
1506
+ };
1507
+ // An admin's release of a branch held as its record of conflicts was set aside.
1508
+ const handleReleaseConflictsHold = async () => {
1509
+ const result = await releaseConflictsHold(apiClient, branchNameState);
1510
+ if ('released' in result) {
1511
+ notifications.show({
1512
+ message: result.wasHeld === true
1513
+ ? 'Hold released.'
1514
+ : result.wasHeld === false
1515
+ ? 'This branch was already released.'
1516
+ : 'This branch is no longer on hold.',
1517
+ color: 'green',
1518
+ });
1519
+ void branchConflicts.reload();
1520
+ }
1521
+ return result;
1522
+ };
1523
+ keepConflictsRef.current = (items) => {
1524
+ void Promise.all(items.map((c) => c.logicalPath
1525
+ ? resolveEntryConflict(c.logicalPath, c.contentId, c.mergeSha, 'keep')
1526
+ : Promise.resolve(undefined))).then((answers) => {
1527
+ const refusals = answers.filter((r) => r !== undefined);
1528
+ if (refusals.length === 0)
1529
+ return;
1530
+ const more = refusals.length > 1 ? ` (${refusals.length} of ${items.length} weren’t kept.)` : '';
1531
+ notifications.show({
1532
+ message: `${refusals[0].message}${more}`,
1533
+ color: refusals.some((r) => r.tone === 'error') ? 'red' : 'yellow',
1534
+ autoClose: false,
1535
+ });
1536
+ });
1537
+ };
1538
+ const changesResolveEntry = changesSubject && entryConflict.data
1539
+ ? async (choice, { entryGone }) => {
1540
+ const { contentId: id, mergeSha, kind } = entryConflict.data ?? {};
1541
+ if (!id || !mergeSha)
1542
+ return undefined;
1543
+ if (choice === 'delete' || (choice === 'use-latest' && kind === 'unreadable')) {
1544
+ confirmAfterChangesRef.current = {
1545
+ logicalPath: changesSubject.logicalPath,
1546
+ id,
1547
+ mergeSha,
1548
+ title: changesSubject.title,
1549
+ choice,
1550
+ };
1551
+ setChangesOpen(false);
1552
+ return undefined;
1553
+ }
1554
+ return resolveEntryConflict(changesSubject.logicalPath, id, mergeSha, choice, entryGone);
1555
+ }
1556
+ : undefined;
1557
+ const confirmEntryChoice = (target) => {
1558
+ const branch = branchNameState;
1559
+ const deleting = target.choice === 'delete';
1560
+ let confirmed = false;
1561
+ openConfirm({
1562
+ title: deleting ? 'Delete entry?' : 'Use the latest version?',
1563
+ children: (_jsx(Text, { size: "sm", children: deleting
1564
+ ? `“${target.title}” was deleted in the latest version. Deleting it here too removes your changes to it.`
1565
+ : `Your saved changes to “${target.title}” are replaced by the latest version.` })),
1566
+ labels: {
1567
+ confirm: deleting ? EDITOR_ACTIONS.deleteEntry : EDITOR_ACTIONS.useLatestVersion,
1568
+ cancel: 'Cancel',
1569
+ },
1570
+ confirmProps: { color: 'red' },
1571
+ // Any way out but Confirm, Escape and the close button included, returns to "Show changes".
1572
+ onClose: () => {
1573
+ if (confirmed)
1574
+ return;
1575
+ setChangesOpen(true);
1576
+ setChangesShown(true);
1577
+ },
1578
+ onConfirm: () => {
1579
+ confirmed = true;
1580
+ void resolveEntryConflict(target.logicalPath, target.id, target.mergeSha, target.choice).then((refusal) => {
1581
+ if (refusal) {
1582
+ notifications.show({
1583
+ message: refusal.message,
1584
+ color: refusal.tone === 'error' ? 'red' : 'yellow',
1585
+ autoClose: false,
1586
+ });
1587
+ }
1588
+ else if (deleting) {
1589
+ closeEntry(target.logicalPath, branch);
1590
+ }
1591
+ });
1592
+ },
1593
+ });
1594
+ };
1595
+ // A choice made on an earlier merge of a field no longer counts once the record moved on, and
1596
+ // an entry whose conflicts are gone keeps none.
1597
+ const retainChoices = conflictChoices.retain;
1598
+ const keepEntryChoices = conflictChoices.keepEntries;
1599
+ // An entry no longer listed with conflicts, resolved elsewhere or by a later merge, keeps none.
1600
+ const listedConflicts = currentBranch?.conflictFiles?.length
1601
+ ? branchConflicts.data?.conflicts
1602
+ : currentBranch
1603
+ ? []
1604
+ : undefined;
1605
+ const listedIds = listedConflicts?.map((c) => c.contentId).join(',');
1606
+ useEffect(() => {
1607
+ if (listedIds !== undefined)
1608
+ keepEntryChoices(new Set(listedIds.split(',').filter(Boolean)));
1609
+ }, [listedIds, keepEntryChoices]);
1610
+ useEffect(() => {
1611
+ const id = changesConflictTarget?.id;
1612
+ if (!id || entryConflict.data === undefined)
1613
+ return;
1614
+ retainChoices(id, entryConflict.data?.fields ?? []);
1615
+ }, [changesConflictTarget?.id, entryConflict.data, retainChoices]);
1616
+ // The toast's "Show changes" opens the first entry with conflicts, then, once it is open and
1617
+ // listed as changed, its drawer.
1618
+ const [pendingConflictShow, setPendingConflictShow] = useState();
1619
+ showConflictsRef.current = () => {
1620
+ const branch = branchNameState;
1621
+ void apiClient.conflicts
1622
+ .list({ branch })
1623
+ .then((answer) => {
1624
+ if (branch !== currentBranchRef.current)
1625
+ return;
1626
+ const first = answer.ok
1627
+ ? answer.data?.conflicts.find((c) => c.logicalPath && entriesState.some((e) => e.path === c.logicalPath))
1628
+ : undefined;
1629
+ if (!first?.logicalPath)
1630
+ return;
1631
+ setPendingConflictShow(first.logicalPath);
1632
+ openEntry(first.logicalPath);
1633
+ })
1634
+ .catch(console.error);
1635
+ };
1636
+ useEffect(() => {
1637
+ if (!pendingConflictShow)
1638
+ return;
1639
+ if (selectedPath !== pendingConflictShow) {
1640
+ setPendingConflictShow(undefined);
1641
+ return;
1642
+ }
1643
+ if (currentEntry?.path !== pendingConflictShow)
1644
+ return;
1645
+ const change = branchChanges.data?.entries.find((e) => e.status !== 'deleted' && e.logicalPath === pendingConflictShow);
1646
+ if (!change)
1647
+ return;
1648
+ setPendingConflictShow(undefined);
1649
+ showEntryChanges(change, currentEntry.label ?? currentEntry.slug ?? '', 'header');
1650
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- showEntryChanges reads this render
1651
+ }, [pendingConflictShow, selectedPath, currentEntry, branchChanges.data]);
1652
+ const showOpenEntryChanges = () => {
1653
+ const change = currentEntry &&
1654
+ branchChanges.data?.entries.find((e) => e.status !== 'deleted' && e.logicalPath === currentEntry.path);
1655
+ if (change)
1656
+ showEntryChanges(change, currentEntry.label ?? currentEntry.slug ?? '', 'header');
1657
+ };
1658
+ // The open entry's conflicts, said above its form, with the way to "Show changes".
1659
+ const openEntryConflictNotice = currentEntry?.contentId && currentBranch?.conflictFiles?.includes(currentEntry.contentId)
1660
+ ? {
1661
+ message: entryConflictNotice(branchConflicts.data?.conflicts.find((c) => c.contentId === currentEntry.contentId)),
1662
+ onShowChanges: branchChanges.data?.entries.some((e) => e.status !== 'deleted' && e.logicalPath === currentEntry.path)
1663
+ ? showOpenEntryChanges
1664
+ : undefined,
1665
+ }
1666
+ : undefined;
1304
1667
  const openEntryChange = currentEntry &&
1305
1668
  branchChanges.data?.entries.find((e) => e.status !== 'deleted' && e.logicalPath === currentEntry.path);
1306
1669
  const referenceTitles = useMemo(() => new Map(entriesState.map((e) => [e.contentId, e.title ?? e.label])), [entriesState]);
@@ -1365,18 +1728,20 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1365
1728
  changesSubject.status !== 'deleted' &&
1366
1729
  changesSubject.logicalPath === currentEntry.path &&
1367
1730
  (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;
1731
+ const changesLockReason = latestWaiting
1732
+ ? WAITING_NOTICE
1733
+ : branchContentLocked
1734
+ ? describeBranchLock({
1735
+ branchStatus: currentBranch?.status,
1736
+ branchReadOnly: currentBranch?.readOnly,
1737
+ branchWriteBlocked: true,
1738
+ branchPending,
1739
+ branchMissing,
1740
+ reviewing: branchReviewing,
1741
+ pullRequestClosed: branchPullRequestClosed,
1742
+ approveBlocked: currentBranch?.approveBlocked,
1743
+ })
1744
+ : undefined;
1380
1745
  const changesUndo = changesSubjectOpen && currentEntry && displayValue
1381
1746
  ? {
1382
1747
  // What the form shows: a draft hidden behind a lock isn't what the cards compare.
@@ -1683,7 +2048,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1683
2048
  }
1684
2049
  : undefined, "data-testid": "content-panel", actions: _jsxs(_Fragment, { children: [navCollections &&
1685
2050
  navCollections.length > 0 &&
1686
- !branchContentLocked &&
2051
+ !contentLocked &&
1687
2052
  (navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end",
1688
2053
  // Its items open dialogs outside the panel, which would close a transient one.
1689
2054
  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 +2063,19 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1698
2063
  missing: e.status === 'missing' || undefined,
1699
2064
  noPage: !e.previewSrc || undefined,
1700
2065
  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
2066
+ })), 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
2067
  ? 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
2068
+ : latestWaiting
2069
+ ? WAITING_NOTICE
2070
+ : describeBranchLock({
2071
+ branchStatus: currentBranch?.status,
2072
+ branchReadOnly: currentBranch?.readOnly,
2073
+ branchWriteBlocked: branchContentLocked,
2074
+ branchMissing,
2075
+ reviewing: branchReviewing,
2076
+ pullRequestClosed: branchPullRequestClosed,
2077
+ approveBlocked: currentBranch?.approveBlocked,
2078
+ }), 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
2079
  ? {
1713
2080
  base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch',
1714
2081
  data: branchChanges.data,
@@ -1731,7 +2098,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1731
2098
  // branchWriteBlocked reuses branchContentLocked itself rather than
1732
2099
  // re-deriving `?? true` a second time here, so there is exactly one
1733
2100
  // 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: () => {
2101
+ branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, openConflicts: openConflictCount, conflictRecordHeld: branchConflicts.data?.held
2102
+ ? { ...(userIsAdmin && { onRelease: handleReleaseConflictsHold }) }
2103
+ : undefined, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, baseUpdate: baseUpdateControl, latestWait: latestWait, latestChangesWaiting: baseUpdate.latestChangesWaiting, onBranchReloadData: () => {
1735
2104
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1736
2105
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
1737
2106
  // On any entry: EntryHeader's `saving` is the open entry's alone.
@@ -1764,7 +2133,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1764
2133
  // True exactly while the form below shows "Setting up…" or "Loading content…".
1765
2134
  entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
1766
2135
  // 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
2136
+ 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
2137
  ? () => showEntryChanges(openEntryChange, currentEntry?.label ?? currentEntry?.slug ?? '', 'header')
1769
2138
  : 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
2139
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
@@ -1783,12 +2152,12 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1783
2152
  ? 'Setting up your branch workspace…'
1784
2153
  : entriesInitializing
1785
2154
  ? '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: {
2155
+ : '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) ||
2156
+ currentBranch?.status === 'archived',
2157
+ // A hidden draft's discard changes nothing on screen, so it says so.
2158
+ onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
2159
+ announce: true,
2160
+ }) }))] })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1792
2161
  title: currentEntry.label,
1793
2162
  typeLabel: entryTypeLabel(activeCollections, currentEntry),
1794
2163
  },
@@ -1801,11 +2170,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1801
2170
  const keyed = withItemKeys(schema, next, 'random');
1802
2171
  setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
1803
2172
  }
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: () => {
2173
+ }, 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
2174
  setLoadFailedKey(null);
1807
2175
  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) => {
2176
+ }, 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
2177
+ ? 'Wait for Save all to finish.'
2178
+ : latestWaiting
2179
+ ? WAITING_SUBMIT_REASON
2180
+ : undefined, currentBaseUpdate: baseUpdateControl, onSelect: async (name) => {
1809
2181
  try {
1810
2182
  await handleBranchChange(name);
1811
2183
  setBranchManagerOpen(false);
@@ -1840,6 +2212,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1840
2212
  handleArchive(name).catch((err) => console.error(err));
1841
2213
  }, 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
2214
  setChangesShown(false);
2215
+ const editing = focusAfterChangesRef.current;
2216
+ focusAfterChangesRef.current = undefined;
2217
+ if (editing)
2218
+ focusConflictField(editing);
2219
+ const confirming = confirmAfterChangesRef.current;
2220
+ confirmAfterChangesRef.current = undefined;
2221
+ if (confirming)
2222
+ confirmEntryChoice(confirming);
1843
2223
  const deleting = deleteAfterChangesRef.current;
1844
2224
  deleteAfterChangesRef.current = undefined;
1845
2225
  // Opened as from the entry menu, whose trigger stands in for the gone button.
@@ -1851,7 +2231,27 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1851
2231
  unsavedEdits: changesSubject.logicalPath === currentEntry?.path && isSelectedDirty(),
1852
2232
  }, state: entryChange, referenceTitle: referenceTitle, onOpenEntry: changesOpenEntry, pullRequestUrl: changesPullRequestUrl, undo: changesUndo, noEditReason: (changesSubjectOpen && currentEntry?.canEdit === false) || restoreRefusedByPath
1853
2233
  ? 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
2234
+ : undefined, lockedReason: changesLockReason, onDeleteEntry: changesDeleteEntry, onRestoreEntry:
2235
+ // A deleted entry with conflicts open is settled on its conflict card first: a
2236
+ // deleted-on-branch card restores it, and with field conflicts a restore would
2237
+ // bring back the latest version unchanged, which "Show changes" doesn't list,
2238
+ // leaving them with nowhere to be chosen. Once they're kept, this offers it.
2239
+ entryConflict.data ? undefined : changesRestoreEntry, conflicts: changesConflictTarget
2240
+ ? {
2241
+ state: entryConflict,
2242
+ adminBaseBranch: userIsAdmin ? currentBranch?.baseBranch : undefined,
2243
+ choices: conflictChoices.choicesFor(changesConflictTarget.id),
2244
+ onChoose: (field, choice, decided) => conflictChoices.choose(changesConflictTarget.id, field.key, field.mergeSha, choice, decided),
2245
+ onEdit: changesSubjectOpen
2246
+ ? (field) => {
2247
+ focusAfterChangesRef.current = field;
2248
+ setChangesOpen(false);
2249
+ }
2250
+ : undefined,
2251
+ onResolveEntry: changesResolveEntry,
2252
+ unsavedEdits: isDirtyForEntry(changesSubject.logicalPath),
2253
+ }
2254
+ : 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
2255
  ? (path, entryType) => addEntryType(path, entryType)
1856
2256
  : undefined, onUpdateEntryType: editingCollection
1857
2257
  ? (path, name, updates) => updateEntryType(path, name, updates)