canopycms 0.1.0-int.115 → 0.1.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 (145) hide show
  1. package/CHANGELOG.md +267 -231
  2. package/README.md +0 -1
  3. package/dist/ai/references.js +3 -1
  4. package/dist/api/assets.js +2 -2
  5. package/dist/api/branch-merge.d.ts +1 -1
  6. package/dist/api/branch.js +1 -1
  7. package/dist/api/content.js +1 -1
  8. package/dist/api/entries-constants.d.ts +1 -1
  9. package/dist/api/entries-constants.js +1 -1
  10. package/dist/api/entries.d.ts +2 -2
  11. package/dist/api/permissions.d.ts +2 -2
  12. package/dist/api/types.d.ts +3 -2
  13. package/dist/cli/cli.js +16 -14
  14. package/dist/cli/generate-ai-content.js +7 -5
  15. package/dist/cli/init.js +4 -4
  16. package/dist/client.d.ts +0 -1
  17. package/dist/client.js +0 -1
  18. package/dist/config/schemas/config.d.ts +0 -5
  19. package/dist/config/schemas/config.js +0 -1
  20. package/dist/config/types.d.ts +0 -1
  21. package/dist/content-index-generation.d.ts +1 -1
  22. package/dist/content-index-generation.js +1 -1
  23. package/dist/content-listing.js +1 -1
  24. package/dist/content-reader.js +1 -1
  25. package/dist/content-store.d.ts +1 -1
  26. package/dist/content-store.js +1 -1
  27. package/dist/dev-content-watcher.d.ts +1 -1
  28. package/dist/dev-content-watcher.js +1 -1
  29. package/dist/editor/BranchManager.d.ts +2 -0
  30. package/dist/editor/BranchManager.js +5 -4
  31. package/dist/editor/CanopyEditor.d.ts +1 -1
  32. package/dist/editor/CanopyEditor.js +1 -2
  33. package/dist/editor/CommentsPanel.js +3 -6
  34. package/dist/editor/Editor.d.ts +0 -2
  35. package/dist/editor/Editor.js +329 -181
  36. package/dist/editor/EditorAuthGate.js +8 -3
  37. package/dist/editor/EntryNavigator.d.ts +10 -0
  38. package/dist/editor/EntryNavigator.js +51 -2
  39. package/dist/editor/FormHeader.js +1 -1
  40. package/dist/editor/PreviewFrame.d.ts +5 -1
  41. package/dist/editor/PreviewFrame.js +3 -2
  42. package/dist/editor/PreviewPane.d.ts +47 -0
  43. package/dist/editor/PreviewPane.js +147 -0
  44. package/dist/editor/PreviewToolbarTools.d.ts +22 -0
  45. package/dist/editor/PreviewToolbarTools.js +73 -0
  46. package/dist/editor/comments/form-comments.js +2 -1
  47. package/dist/editor/components/BranchLockBanner.d.ts +22 -0
  48. package/dist/editor/components/BranchLockBanner.js +39 -0
  49. package/dist/editor/components/BranchPill.d.ts +32 -0
  50. package/dist/editor/components/BranchPill.js +33 -0
  51. package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
  52. package/dist/editor/components/ConfirmDeleteModal.js +3 -2
  53. package/dist/editor/components/EditorHeader.d.ts +50 -108
  54. package/dist/editor/components/EditorHeader.js +117 -138
  55. package/dist/editor/components/EditorRail.d.ts +49 -0
  56. package/dist/editor/components/EditorRail.js +65 -0
  57. package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
  58. package/dist/editor/components/EntryBreadcrumb.js +56 -0
  59. package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
  60. package/dist/editor/components/QuickOpenPalette.js +3 -2
  61. package/dist/editor/components/RenameEntryModal.d.ts +3 -1
  62. package/dist/editor/components/RenameEntryModal.js +3 -2
  63. package/dist/editor/components/SaveAllResult.d.ts +25 -0
  64. package/dist/editor/components/SaveAllResult.js +86 -0
  65. package/dist/editor/components/SaveSplitButton.d.ts +30 -0
  66. package/dist/editor/components/SaveSplitButton.js +108 -0
  67. package/dist/editor/components/index.d.ts +2 -2
  68. package/dist/editor/components/index.js +2 -2
  69. package/dist/editor/context/EditorStateContext.d.ts +4 -4
  70. package/dist/editor/context/EditorStateContext.js +4 -4
  71. package/dist/editor/copy.d.ts +3 -0
  72. package/dist/editor/copy.js +3 -0
  73. package/dist/editor/editor-utils.d.ts +11 -6
  74. package/dist/editor/editor-utils.js +8 -17
  75. package/dist/editor/fields/MarkdownField.js +3 -0
  76. package/dist/editor/hooks/save-all.d.ts +68 -0
  77. package/dist/editor/hooks/save-all.js +61 -0
  78. package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
  79. package/dist/editor/hooks/useCommentSystem.js +3 -0
  80. package/dist/editor/hooks/useDraftManager.d.ts +24 -1
  81. package/dist/editor/hooks/useDraftManager.js +297 -16
  82. package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
  83. package/dist/editor/hooks/useEditorLayout.js +13 -25
  84. package/dist/editor/hooks/useEntryManager.d.ts +4 -0
  85. package/dist/editor/hooks/useEntryManager.js +30 -7
  86. package/dist/editor/media/MediaLibrary.d.ts +1 -1
  87. package/dist/editor/preview-path.d.ts +12 -0
  88. package/dist/editor/preview-path.js +45 -1
  89. package/dist/editor/preview-scale.d.ts +27 -0
  90. package/dist/editor/preview-scale.js +34 -0
  91. package/dist/editor/shell/ActivityRail.d.ts +37 -0
  92. package/dist/editor/shell/ActivityRail.js +91 -0
  93. package/dist/editor/shell/EditorShell.d.ts +17 -0
  94. package/dist/editor/shell/EditorShell.js +31 -0
  95. package/dist/editor/shell/PaneLayout.d.ts +28 -0
  96. package/dist/editor/shell/PaneLayout.js +116 -0
  97. package/dist/editor/shell/SidePanelHost.d.ts +35 -0
  98. package/dist/editor/shell/SidePanelHost.js +184 -0
  99. package/dist/editor/shell/TopBar.d.ts +12 -0
  100. package/dist/editor/shell/TopBar.js +14 -0
  101. package/dist/editor/shell/host-chrome.d.ts +16 -0
  102. package/dist/editor/shell/host-chrome.js +69 -0
  103. package/dist/editor/theme.js +18 -3
  104. package/dist/git-manager.d.ts +1 -1
  105. package/dist/git-manager.js +2 -2
  106. package/dist/operating-mode/types.d.ts +1 -1
  107. package/dist/resolve-canopy-user.d.ts +1 -1
  108. package/dist/resolve-canopy-user.js +1 -1
  109. package/dist/schema/resolver.d.ts +1 -1
  110. package/dist/schema/resolver.js +1 -1
  111. package/dist/schema/schema-store.d.ts +1 -1
  112. package/dist/schema/schema-store.js +2 -2
  113. package/dist/url-collision.js +3 -3
  114. package/dist/url-exclusivity-fixtures.d.ts +1 -1
  115. package/dist/url-exclusivity-fixtures.js +1 -1
  116. package/dist/utils/content-write-lock.d.ts +1 -1
  117. package/dist/utils/content-write-lock.js +1 -1
  118. package/dist/utils/debug.d.ts +1 -1
  119. package/dist/utils/debug.js +5 -3
  120. package/dist/utils/git.d.ts +4 -0
  121. package/dist/utils/git.js +27 -5
  122. package/dist/validation/block-structural-keys.d.ts +1 -1
  123. package/dist/validation/block-structural-keys.js +1 -1
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/dist/worker/cms-worker.d.ts +1 -1
  127. package/dist/worker/cms-worker.js +2 -2
  128. package/dist/worker/github-auth.d.ts +1 -1
  129. package/dist/worker/github-auth.js +1 -1
  130. package/dist/worker/github-gateway-wire.d.ts +2 -1
  131. package/dist/worker/github-gateway-wire.js +7 -3
  132. package/dist/worker/github-gateway.js +1 -1
  133. package/dist/worker/github-mirror.d.ts +2 -2
  134. package/dist/worker/github-mirror.js +2 -2
  135. package/dist/worker/history-rewrite.d.ts +1 -1
  136. package/dist/worker/history-rewrite.js +1 -1
  137. package/dist/worker/self-checks.d.ts +1 -1
  138. package/dist/worker/shared-repo-git.js +1 -1
  139. package/package.json +5 -4
  140. package/dist/editor/EditorPanes.d.ts +0 -20
  141. package/dist/editor/EditorPanes.js +0 -124
  142. package/dist/editor/components/EditorFooter.d.ts +0 -11
  143. package/dist/editor/components/EditorFooter.js +0 -9
  144. package/dist/editor/components/EditorSidebar.d.ts +0 -70
  145. package/dist/editor/components/EditorSidebar.js +0 -50
@@ -11,10 +11,12 @@ import { ENTRY_CHANGED_MESSAGE } from '../../api/entries-constants.js';
11
11
  import { isUnanswered } from '../unanswered.js';
12
12
  import { withItemKeys } from '../../item-keys.js';
13
13
  import { RetryToastMessage } from '../components/RetryToastMessage.js';
14
+ import { clearSaveAllResult, settleSaveAllEntry } from '../components/SaveAllResult.js';
15
+ import { classifySaveAllError, invalidReason, SAVE_ALL_REASONS, } from './save-all.js';
14
16
  /** @internal Exported for tests. */
15
17
  export const SAVE_TIMED_OUT_MESSAGE = "Save didn't finish, and may not have gone through. Your changes are still here, so save again.";
16
18
  /** @internal Exported for tests. Shown when a save may already have landed, until a reload. */
17
- export const SAVE_MAY_HAVE_LANDED_MESSAGE = 'Your last save may already have gone through. Before saving again, choose "Reload entry" in the menu under the entry\'s name; your unsaved edits will be lost.';
19
+ export const SAVE_MAY_HAVE_LANDED_MESSAGE = 'Your last save may already have gone through. Before saving again, choose "Refresh from server" in the entry\'s menu, on its name; your unsaved edits will be lost.';
18
20
  const saveMayHaveLandedToastId = (contentId) => `canopy-save-may-have-landed:${contentId}`;
19
21
  const saveTimedOutToastId = (contentId) => `canopy-save-timed-out:${contentId}`;
20
22
  /** @internal Exported for tests. A save refused because the entry was deleted. */
@@ -117,7 +119,20 @@ export function useDraftManager(options) {
117
119
  // the behavior before base versions existed.
118
120
  const draftBaseVersionsRef = useRef({});
119
121
  const [draftStorageFailed, setDraftStorageFailed] = useState(false);
122
+ const [lastSaved, setLastSaved] = useState(null);
123
+ const [singleSaving, setSingleSaving] = useState(null);
120
124
  const [saveInFlight, setSaveInFlight] = useState(false);
125
+ const [saveAllProgress, setSaveAllProgress] = useState(null);
126
+ const saveAllRunningRef = useRef(false);
127
+ const mountedRef = useRef(true);
128
+ // Entries a Save, refresh or discard settled while Save all ran: its result no longer applies.
129
+ const settledDuringRunRef = useRef(new Set());
130
+ const settleEntry = (id) => {
131
+ if (saveAllRunningRef.current)
132
+ settledDuringRunRef.current.add(id);
133
+ settleSaveAllEntry(id);
134
+ };
135
+ const singleSaveRef = useRef(false);
121
136
  // contentId -> the token held when a save came back WRITE_OUTCOME_UNKNOWN, or when the retry of
122
137
  // a timed-out save was refused as a version conflict. That save may have landed, so re-sending
123
138
  // the token would bounce off the user's own write; held until re-read.
@@ -142,6 +157,8 @@ export function useDraftManager(options) {
142
157
  const storageKey = useMemo(() => `canopycms:drafts:${options.branchName}`, [options.branchName]);
143
158
  // Draft keys are now content IDs, not paths
144
159
  const currentId = options.currentEntry?.contentId;
160
+ const currentIdRef = useRef(currentId);
161
+ currentIdRef.current = currentId;
145
162
  const selectedValue = currentId ? drafts[currentId] : undefined;
146
163
  const loadedValue = currentId ? loadedValues[currentId] : undefined;
147
164
  const effectiveValue = selectedValue ?? loadedValue;
@@ -199,13 +216,23 @@ export function useDraftManager(options) {
199
216
  options.readEntryValue,
200
217
  ]);
201
218
  // Async callbacks outlive the render that created them; these give them current values.
202
- const latestRef = useRef({ drafts, loadedValues, unresolvedCount: 0, modifiedCount, editedFiles });
219
+ const latestRef = useRef({
220
+ drafts,
221
+ loadedValues,
222
+ unresolvedCount: 0,
223
+ modifiedCount,
224
+ editedFiles,
225
+ dirtyIds,
226
+ entryById,
227
+ });
203
228
  latestRef.current = {
204
229
  drafts,
205
230
  loadedValues,
206
231
  unresolvedCount: unresolvedIds.length,
207
232
  modifiedCount,
208
233
  editedFiles,
234
+ dirtyIds,
235
+ entryById,
209
236
  };
210
237
  const latestOptionsRef = useRef(options);
211
238
  latestOptionsRef.current = options;
@@ -234,10 +261,20 @@ export function useDraftManager(options) {
234
261
  for (const id of goneEntriesRef.current)
235
262
  notifications.hide(entryGoneToastId(id));
236
263
  goneEntriesRef.current.clear();
264
+ // Its entries, and their "Open" and "Save as new entry" buttons, are the old branch's.
265
+ clearSaveAllResult();
237
266
  reconciledDraftIdsRef.current = new Set();
238
267
  }
239
268
  prevBranchRef.current = options.branchName;
240
269
  }, [options.branchName]);
270
+ // A result left up would act through this unmounted instance's callbacks.
271
+ useEffect(() => {
272
+ mountedRef.current = true;
273
+ return () => {
274
+ mountedRef.current = false;
275
+ clearSaveAllResult();
276
+ };
277
+ }, []);
241
278
  // Restore drafts from localStorage on mount or when storageKey changes
242
279
  useEffect(() => {
243
280
  if (typeof window === 'undefined')
@@ -611,6 +648,8 @@ export function useDraftManager(options) {
611
648
  return equal(latest, value) ? rest : { ...rest, [created.contentId]: latest };
612
649
  });
613
650
  }
651
+ if (created)
652
+ settleEntry(id);
614
653
  notifications.show({
615
654
  message: created ? 'Saved as a new entry' : RECREATED_NOT_LISTED_MESSAGE,
616
655
  color: 'green',
@@ -678,7 +717,7 @@ export function useDraftManager(options) {
678
717
  children: 'This draft was saved by an earlier version of the editor, which did not record ' +
679
718
  'which version of the entry it was based on. Saving it will overwrite the copy ' +
680
719
  'currently on the server, including any changes made by someone else since. ' +
681
- 'Choose "Reload entry" or "Discard changes" in the menu under the entry\'s title ' +
720
+ 'Choose "Refresh from server" or "Discard changes" in the entry\'s menu, on its name, ' +
682
721
  'instead if you would rather not risk that.',
683
722
  labels: { confirm: 'Save anyway', cancel: 'Cancel' },
684
723
  confirmProps: { color: 'red' },
@@ -691,6 +730,9 @@ export function useDraftManager(options) {
691
730
  });
692
731
  });
693
732
  const handleSave = async () => {
733
+ // Save all may be sending this entry with the same token; the loser's 409 would be a false conflict.
734
+ if (saveAllRunningRef.current)
735
+ return;
694
736
  const entry = options.currentEntry;
695
737
  const branch = options.branchName;
696
738
  if (!entry || !effectiveValue || !currentId)
@@ -704,7 +746,7 @@ export function useDraftManager(options) {
704
746
  notifications.hide(saveMayHaveLandedToastId(currentId));
705
747
  }
706
748
  // Conflict check first: a stale-based draft must not be schema-validated
707
- // into a green "Saved" -- and nagging about field errors is noise when the
749
+ // into a "Saved" -- and nagging about field errors is noise when the
708
750
  // save is not going out at all.
709
751
  const baseState = draftBaseState();
710
752
  if (baseState === 'stale') {
@@ -723,10 +765,11 @@ export function useDraftManager(options) {
723
765
  }
724
766
  // Client-side pre-save validation (ED-H1): the same pure schema rules the
725
767
  // server enforces authoritatively at the write boundary. Blocks the save
726
- // and surfaces per-field errors in the form instead of a green "Saved".
768
+ // and surfaces per-field errors in the form instead of a "Saved".
727
769
  const validationErrors = validateEntryFormValue(entry.schema, entry.format, effectiveValue);
728
770
  if (validationErrors.length > 0) {
729
771
  setErrorState({ entryId: currentId, errors: toFieldErrorMap(validationErrors) });
772
+ options.onSaveBlocked?.();
730
773
  notifications.show({
731
774
  title: 'Cannot save yet',
732
775
  message: `Fix ${validationErrors.length} validation ${validationErrors.length === 1 ? 'issue' : 'issues'} before saving`,
@@ -739,8 +782,12 @@ export function useDraftManager(options) {
739
782
  setErrorState(null);
740
783
  options.setBusy(true);
741
784
  setSaveInFlight(true);
785
+ setSingleSaving({ branch, contentId: currentId });
786
+ singleSaveRef.current = true;
742
787
  try {
743
788
  const saved = await options.saveEntry(entry, effectiveValue);
789
+ if (latestOptionsRef.current.branchName !== branch)
790
+ return;
744
791
  // Drop the draft now that it has been persisted, rather than
745
792
  // overwriting it with `saved`. `effectiveValue` is `drafts[currentId]
746
793
  // ?? loadedValues[currentId]`, and `loadedValues[currentId]` is about
@@ -761,16 +808,16 @@ export function useDraftManager(options) {
761
808
  notifications.hide(saveTimedOutToastId(currentId));
762
809
  }
763
810
  hideEntryGone(currentId);
764
- notifications.show({
765
- message: 'Saved',
766
- color: 'green',
767
- autoClose: getNotificationDuration(4000),
768
- withCloseButton: true,
769
- });
811
+ settleEntry(currentId);
812
+ // Said on the Save button, not in a toast.
813
+ setLastSaved((prev) => ({ branch, contentId: currentId, seq: (prev?.seq ?? 0) + 1 }));
770
814
  options.onSaved?.();
771
815
  }
772
816
  catch (err) {
773
817
  console.error(err);
818
+ // Content ids repeat across branches: none of what follows belongs on the next one.
819
+ if (latestOptionsRef.current.branchName !== branch)
820
+ return;
774
821
  const isConflict = err instanceof SaveApiError && err.status === 409;
775
822
  // 422 = rejected by server-side schema validation or the adopter's
776
823
  // validateEntry hook; show its message and map per-field errors (e.g.
@@ -795,6 +842,7 @@ export function useDraftManager(options) {
795
842
  err.fieldErrors &&
796
843
  err.fieldErrors.length > 0) {
797
844
  setErrorState({ entryId: currentId, errors: toFieldErrorMap(err.fieldErrors) });
845
+ options.onSaveBlocked?.();
798
846
  }
799
847
  if (isConflict && err.code === ENTRY_GONE_CODE) {
800
848
  // Deleted or renamed since it was read, by someone else or by a delete or rename of this
@@ -879,6 +927,221 @@ export function useDraftManager(options) {
879
927
  }
880
928
  }
881
929
  finally {
930
+ singleSaveRef.current = false;
931
+ setSingleSaving(null);
932
+ setSaveInFlight(false);
933
+ options.setBusy(false);
934
+ }
935
+ };
936
+ /**
937
+ * A save that got no answer landed if the server now holds exactly what was sent. Only that
938
+ * answer settles it: a differing value may be the server's normalisation of a save that did
939
+ * land, so it leaves the token alone and the retry's 409 decides, as for `handleSave`.
940
+ */
941
+ const settleUnansweredSave = async (entry, sent) => {
942
+ const { readEntryValue, loadEntry } = latestOptionsRef.current;
943
+ try {
944
+ if (!readEntryValue || !equal(await readEntryValue(entry), sent))
945
+ return undefined;
946
+ const loaded = await loadEntry(entry);
947
+ return equal(loaded, sent) ? loaded : undefined;
948
+ }
949
+ catch (err) {
950
+ console.error(err);
951
+ return undefined;
952
+ }
953
+ };
954
+ /**
955
+ * One entry of Save all, through the same checks as `handleSave`: held saves, the draft's base
956
+ * version, schema validation, then the OCC-checked write. An entry not opened this session has
957
+ * no token yet, so it is loaded first, which also lets the base check see whether it moved on.
958
+ * Returns undefined when the draft went away or the branch changed meanwhile.
959
+ */
960
+ const saveOneOfAll = async (entry, branch) => {
961
+ const id = entry.contentId;
962
+ const sent = latestRef.current.drafts[id];
963
+ if (sent === undefined)
964
+ return undefined;
965
+ const onBranch = () => latestOptionsRef.current.branchName === branch;
966
+ const fail = (kind, reason, extra) => ({
967
+ saved: false,
968
+ failure: { contentId: id, label: entry.label, path: entry.path, kind, reason, ...extra },
969
+ });
970
+ const { getEntryVersion, loadEntry, saveEntry } = latestOptionsRef.current;
971
+ if (entry.unavailable)
972
+ return fail('blocked', SAVE_ALL_REASONS.unavailable);
973
+ if (entry.canEdit === false)
974
+ return fail('blocked', SAVE_ALL_REASONS.noEditAccess);
975
+ if (id in reloadRequiredRef.current) {
976
+ if (reloadRequiredRef.current[id] === getEntryVersion?.(id)) {
977
+ return fail('unanswered', SAVE_ALL_REASONS.earlierUnanswered);
978
+ }
979
+ delete reloadRequiredRef.current[id];
980
+ notifications.hide(saveMayHaveLandedToastId(id));
981
+ }
982
+ if (getEntryVersion?.(id) === undefined) {
983
+ try {
984
+ await loadEntry(entry);
985
+ }
986
+ catch (err) {
987
+ console.error(err);
988
+ return fail('error', SAVE_ALL_REASONS.loadFailed);
989
+ }
990
+ if (!onBranch())
991
+ return undefined;
992
+ }
993
+ const base = draftBaseVersionsRef.current[id];
994
+ if (base === null)
995
+ return fail('conflict', SAVE_ALL_REASONS.unknownBase);
996
+ if (base !== undefined && base !== getEntryVersion?.(id)) {
997
+ return fail('conflict', SAVE_ALL_REASONS.staleBase);
998
+ }
999
+ const validationErrors = validateEntryFormValue(entry.schema, entry.format, sent);
1000
+ if (validationErrors.length > 0) {
1001
+ if (id === currentIdRef.current) {
1002
+ setErrorState({ entryId: id, errors: toFieldErrorMap(validationErrors) });
1003
+ latestOptionsRef.current.onSaveBlocked?.();
1004
+ }
1005
+ return fail('invalid', invalidReason(validationErrors.length));
1006
+ }
1007
+ const commitSaved = (saved) => {
1008
+ // Decided from `prev`, which holds an edit queued but not yet rendered.
1009
+ setDrafts((prev) => {
1010
+ if (!(id in prev))
1011
+ return prev;
1012
+ if (!equal(prev[id], sent)) {
1013
+ // Edits typed while the save was out stay a draft, based on the version just written; a
1014
+ // new object, so that base is persisted. Idempotent, as a strict-mode rerun requires.
1015
+ const version = latestOptionsRef.current.getEntryVersion?.(id);
1016
+ if (version !== undefined)
1017
+ draftBaseVersionsRef.current[id] = version;
1018
+ return { ...prev };
1019
+ }
1020
+ const next = { ...prev };
1021
+ delete next[id];
1022
+ return next;
1023
+ });
1024
+ setLoadedValues((prev) => ({ ...prev, [id]: saved }));
1025
+ if (timedOutSavesRef.current.delete(id))
1026
+ notifications.hide(saveTimedOutToastId(id));
1027
+ hideEntryGone(id);
1028
+ };
1029
+ try {
1030
+ const saved = await saveEntry(entry, sent);
1031
+ // Another branch's state now: its loaded values and draft bases are not this write's.
1032
+ if (!onBranch())
1033
+ return undefined;
1034
+ commitSaved(saved);
1035
+ return { saved: true };
1036
+ }
1037
+ catch (err) {
1038
+ console.error(err);
1039
+ if (!onBranch())
1040
+ return undefined;
1041
+ const outcome = classifySaveAllError(err, timedOutSavesRef.current.has(id));
1042
+ switch (outcome.kind) {
1043
+ case 'unanswered': {
1044
+ const landed = await settleUnansweredSave(entry, sent);
1045
+ if (!onBranch())
1046
+ return undefined;
1047
+ if (landed) {
1048
+ commitSaved(landed);
1049
+ return { saved: true };
1050
+ }
1051
+ timedOutSavesRef.current.add(id);
1052
+ return fail('unanswered', SAVE_ALL_REASONS.unanswered);
1053
+ }
1054
+ case 'landed?':
1055
+ reloadRequiredRef.current[id] = getEntryVersion?.(id);
1056
+ timedOutSavesRef.current.delete(id);
1057
+ notifications.hide(saveTimedOutToastId(id));
1058
+ return fail('unanswered', SAVE_ALL_REASONS.earlierUnanswered);
1059
+ case 'gone': {
1060
+ if (timedOutSavesRef.current.delete(id))
1061
+ notifications.hide(saveTimedOutToastId(id));
1062
+ let moved;
1063
+ try {
1064
+ moved = await latestOptionsRef.current.followMovedEntry?.(entry);
1065
+ }
1066
+ catch (followErr) {
1067
+ console.error(followErr);
1068
+ return fail('conflict', SAVE_ALL_REASONS.goneUnchecked);
1069
+ }
1070
+ // "Open" in the result goes to where the changes moved.
1071
+ if (moved)
1072
+ return fail('conflict', SAVE_ALL_REASONS.renamed, { path: moved.path });
1073
+ return fail('conflict', SAVE_ALL_REASONS.deleted, {
1074
+ recreate: () => void recreateGoneEntry(entry, branch),
1075
+ });
1076
+ }
1077
+ default:
1078
+ if (outcome.kind === 'invalid' && id === currentIdRef.current) {
1079
+ const fieldErrors = err instanceof SaveApiError ? err.fieldErrors : undefined;
1080
+ if (fieldErrors?.length) {
1081
+ setErrorState({ entryId: id, errors: toFieldErrorMap(fieldErrors) });
1082
+ latestOptionsRef.current.onSaveBlocked?.();
1083
+ }
1084
+ }
1085
+ return fail(outcome.kind, outcome.reason, outcome.retryable ? { retryable: true } : undefined);
1086
+ }
1087
+ }
1088
+ };
1089
+ const handleSaveAll = async () => {
1090
+ // A Save in flight would race this run's write of the same entry, with the same token.
1091
+ if (saveAllRunningRef.current || singleSaveRef.current)
1092
+ return undefined;
1093
+ saveAllRunningRef.current = true;
1094
+ settledDuringRunRef.current = new Set();
1095
+ const branch = options.branchName;
1096
+ // Busy from the first moment, so Save waits through the draft check too.
1097
+ options.setBusy(true);
1098
+ setSaveInFlight(true);
1099
+ setSaveAllProgress({ done: 0, total: latestRef.current.modifiedCount });
1100
+ try {
1101
+ // A restored draft identical to the server is not a change, so it isn't saved.
1102
+ await whenDraftsVerified();
1103
+ if (latestOptionsRef.current.branchName !== branch)
1104
+ return undefined;
1105
+ const { dirtyIds: ids, entryById: byId } = latestRef.current;
1106
+ const current = currentIdRef.current;
1107
+ const queue = [...ids]
1108
+ .sort((a, b) => Number(b === current) - Number(a === current))
1109
+ .flatMap((id) => {
1110
+ const entry = byId.get(id);
1111
+ return entry ? [entry] : [];
1112
+ });
1113
+ const result = { branch, total: queue.length, saved: [], failed: [] };
1114
+ if (queue.length === 0)
1115
+ return result;
1116
+ clearSaveAllResult();
1117
+ setSaveAllProgress({ done: 0, total: queue.length });
1118
+ for (const [index, entry] of queue.entries()) {
1119
+ if (latestOptionsRef.current.branchName !== branch || !mountedRef.current)
1120
+ return undefined;
1121
+ const outcome = await saveOneOfAll(entry, branch);
1122
+ if (outcome?.saved) {
1123
+ result.saved.push({ contentId: entry.contentId, label: entry.label, path: entry.path });
1124
+ }
1125
+ else if (outcome) {
1126
+ result.failed.push(outcome.failure);
1127
+ }
1128
+ setSaveAllProgress({ done: index + 1, total: queue.length });
1129
+ }
1130
+ // Unmounted: a result would act through this gone instance's callbacks.
1131
+ if (latestOptionsRef.current.branchName !== branch || !mountedRef.current)
1132
+ return undefined;
1133
+ // Discarded, refreshed or saved mid-run, so neither saved nor failed by this run.
1134
+ const { drafts: remaining } = latestRef.current;
1135
+ const settled = settledDuringRunRef.current;
1136
+ result.failed = result.failed.filter((failure) => failure.contentId in remaining && !settled.has(failure.contentId));
1137
+ result.total = result.saved.length + result.failed.length;
1138
+ if (result.saved.length > 0)
1139
+ latestOptionsRef.current.onSaved?.();
1140
+ return result;
1141
+ }
1142
+ finally {
1143
+ saveAllRunningRef.current = false;
1144
+ setSaveAllProgress(null);
882
1145
  setSaveInFlight(false);
883
1146
  options.setBusy(false);
884
1147
  }
@@ -890,6 +1153,11 @@ export function useDraftManager(options) {
890
1153
  for (const id of goneEntriesRef.current)
891
1154
  notifications.hide(entryGoneToastId(id));
892
1155
  goneEntriesRef.current.clear();
1156
+ clearSaveAllResult();
1157
+ if (saveAllRunningRef.current) {
1158
+ for (const id of Object.keys(latestRef.current.drafts))
1159
+ settledDuringRunRef.current.add(id);
1160
+ }
893
1161
  options.onDraftsReset?.();
894
1162
  setDrafts({});
895
1163
  setErrorState(null);
@@ -938,6 +1206,7 @@ export function useDraftManager(options) {
938
1206
  }
939
1207
  options.onDraftsReset?.(currentId);
940
1208
  hideEntryGone(currentId);
1209
+ settleEntry(currentId);
941
1210
  setErrorState(null);
942
1211
  setDrafts((prev) => {
943
1212
  const next = { ...prev };
@@ -989,9 +1258,13 @@ export function useDraftManager(options) {
989
1258
  const performReload = async () => {
990
1259
  if (!options.currentEntry || !currentId)
991
1260
  return;
1261
+ const branch = options.branchName;
992
1262
  options.setBusy(true);
993
1263
  try {
994
1264
  const loaded = await options.loadEntry(options.currentEntry);
1265
+ // Content ids repeat across branches; the next branch's state is not this read's.
1266
+ if (latestOptionsRef.current.branchName !== branch)
1267
+ return;
995
1268
  options.onDraftsReset?.(currentId);
996
1269
  delete reloadRequiredRef.current[currentId];
997
1270
  notifications.hide(saveMayHaveLandedToastId(currentId));
@@ -999,6 +1272,7 @@ export function useDraftManager(options) {
999
1272
  notifications.hide(saveTimedOutToastId(currentId));
1000
1273
  }
1001
1274
  hideEntryGone(currentId);
1275
+ settleEntry(currentId);
1002
1276
  setLoadedValues((prev) => ({ ...prev, [currentId]: loaded }));
1003
1277
  // Drop the draft rather than seeding it with `loaded`. `effectiveValue`
1004
1278
  // falls back to `loadedValues`, so the rendered value is identical --
@@ -1013,13 +1287,15 @@ export function useDraftManager(options) {
1013
1287
  return next;
1014
1288
  });
1015
1289
  notifications.show({
1016
- message: 'Reloaded',
1290
+ message: 'Refreshed from server',
1017
1291
  color: 'blue',
1018
1292
  autoClose: getNotificationDuration(3000),
1019
1293
  withCloseButton: true,
1020
1294
  });
1021
1295
  }
1022
1296
  catch (err) {
1297
+ if (latestOptionsRef.current.branchName !== branch)
1298
+ return;
1023
1299
  if (err instanceof EntrySchemaUnavailableError) {
1024
1300
  notifications.show({
1025
1301
  title: 'Not available yet',
@@ -1032,7 +1308,7 @@ export function useDraftManager(options) {
1032
1308
  }
1033
1309
  console.error(err);
1034
1310
  notifications.show({
1035
- message: 'Reload failed',
1311
+ message: "Couldn't refresh from the server. Your changes are still here; try again.",
1036
1312
  color: 'red',
1037
1313
  autoClose: getNotificationDuration(6000),
1038
1314
  withCloseButton: true,
@@ -1056,9 +1332,9 @@ export function useDraftManager(options) {
1056
1332
  return;
1057
1333
  }
1058
1334
  openConfirm({
1059
- title: 'Reload entry',
1060
- children: 'Reload this entry from the server? Unsaved changes to this entry will be lost.',
1061
- labels: { confirm: 'Reload', cancel: 'Cancel' },
1335
+ title: 'Refresh from server',
1336
+ children: "Replace this entry with the server's copy? Unsaved changes to this entry will be lost.",
1337
+ labels: { confirm: 'Refresh', cancel: 'Cancel' },
1062
1338
  confirmProps: { color: 'red' },
1063
1339
  onConfirm: () => {
1064
1340
  void performReload();
@@ -1164,6 +1440,11 @@ export function useDraftManager(options) {
1164
1440
  modifiedCount,
1165
1441
  editedFiles,
1166
1442
  handleSave,
1443
+ singleSaving,
1444
+ lastSaved,
1445
+ handleSaveAll,
1446
+ saveAllProgress,
1447
+ isSaveAllRunning: () => saveAllRunningRef.current,
1167
1448
  handleDiscardDrafts,
1168
1449
  handleDiscardFileDraft,
1169
1450
  handleReload,
@@ -1,9 +1,12 @@
1
- import { type PaneLayout } from '../EditorPanes.js';
2
- /** @internal Consumed by the preview toolbar, which does not exist yet. */
1
+ import { type PaneArrangement } from '../shell/PaneLayout.js';
2
+ /** @internal The preview toolbar's width choice; see `preview-scale.ts`. */
3
3
  export type PreviewWidth = 'fit' | 'desktop' | 'tablet' | 'mobile';
4
+ export declare const CONTENT_PANEL_WIDTH_MIN = 240;
5
+ export declare const CONTENT_PANEL_WIDTH_MAX = 400;
4
6
  /** The persisted layout preferences: one JSON object under one storage key. */
5
7
  interface EditorLayoutPrefs {
6
- layout: PaneLayout;
8
+ /** Unset until the user picks one, so it follows `defaultLayout`; JSON drops it while unset. */
9
+ layout?: PaneArrangement;
7
10
  highlightEnabled: boolean;
8
11
  sideSplitPercent: number;
9
12
  stackedSplitPercent: number;
@@ -27,29 +30,30 @@ export declare function parseEditorLayoutPrefs(raw: string | undefined): EditorL
27
30
  export interface UseEditorLayoutOptions {
28
31
  /** Where the preferences persist; defaults to `editorLayoutStorageKey()`. */
29
32
  storageKey?: string;
33
+ /** The layout until the user picks one; defaults to side by side. */
34
+ defaultLayout?: PaneArrangement;
30
35
  }
31
36
  export interface UseEditorLayoutReturn {
32
- layout: PaneLayout;
33
- setLayout: (layout: PaneLayout) => void;
37
+ /** The stored preferences have been read; until then every value is its default. */
38
+ loaded: boolean;
39
+ layout: PaneArrangement;
40
+ setLayout: (layout: PaneArrangement) => void;
34
41
  highlightEnabled: boolean;
35
42
  setHighlightEnabled: (enabled: boolean) => void;
36
43
  sideSplitPercent: number;
37
44
  stackedSplitPercent: number;
38
45
  /** Stores the clamped primary-pane size for one layout; a non-finite percent is ignored. */
39
- setSplitPercent: (layout: PaneLayout, percent: number) => void;
46
+ setSplitPercent: (layout: PaneArrangement, percent: number) => void;
40
47
  previewWidth: PreviewWidth;
41
48
  setPreviewWidth: (width: PreviewWidth) => void;
42
49
  contentPanelOpen: boolean;
43
50
  setContentPanelOpen: (open: boolean) => void;
44
51
  contentPanelWidth: number;
45
52
  setContentPanelWidth: (width: number) => void;
46
- headerRef: React.RefObject<HTMLDivElement>;
47
- headerHeight: number;
48
53
  }
49
54
  /**
50
- * Editor layout: preferences persisted to localStorage (read after mount, so SSR renders the
51
- * defaults; a throwing storage keeps them in memory) plus the measured, unpersisted header height.
52
- * `previewWidth` and `contentPanel*` persist ahead of the toolbar and panel that will use them.
55
+ * Editor layout preferences, persisted to localStorage (read after mount, so SSR renders the
56
+ * defaults; a throwing storage keeps them in memory).
53
57
  */
54
58
  export declare function useEditorLayout(options?: UseEditorLayoutOptions): UseEditorLayoutReturn;
55
59
  export {};
@@ -1,17 +1,16 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react';
2
- import { useLocalStorage } from '@mantine/hooks';
3
- import { DEFAULT_SIDE_SPLIT_PERCENT, DEFAULT_STACKED_SPLIT_PERCENT, SPLIT_PERCENT_MAX, SPLIT_PERCENT_MIN, } from '../EditorPanes.js';
1
+ import { useCallback } from 'react';
2
+ import { useLocalStorage, useMounted } from '@mantine/hooks';
3
+ import { DEFAULT_SIDE_SPLIT_PERCENT, DEFAULT_STACKED_SPLIT_PERCENT, SPLIT_PERCENT_MAX, SPLIT_PERCENT_MIN, } from '../shell/PaneLayout.js';
4
4
  import { isPlainRecord } from '../../validation/field-traversal.js';
5
5
  const PREVIEW_WIDTHS = ['fit', 'desktop', 'tablet', 'mobile'];
6
6
  const PANE_LAYOUTS = ['side', 'stacked'];
7
- const CONTENT_PANEL_WIDTH_MIN = 240;
8
- const CONTENT_PANEL_WIDTH_MAX = 400;
7
+ export const CONTENT_PANEL_WIDTH_MIN = 240;
8
+ export const CONTENT_PANEL_WIDTH_MAX = 400;
9
9
  /**
10
10
  * @internal Exported for tests. Module-level so its identity is stable: Mantine memoises its
11
11
  * storage reader on the default value.
12
12
  */
13
13
  export const DEFAULT_EDITOR_LAYOUT_PREFS = {
14
- layout: 'side',
15
14
  highlightEnabled: false,
16
15
  sideSplitPercent: DEFAULT_SIDE_SPLIT_PERCENT,
17
16
  stackedSplitPercent: DEFAULT_STACKED_SPLIT_PERCENT,
@@ -46,7 +45,7 @@ export function parseEditorLayoutPrefs(raw) {
46
45
  if (!isPlainRecord(parsed))
47
46
  return defaults;
48
47
  return {
49
- layout: oneOf(PANE_LAYOUTS, parsed.layout, defaults.layout),
48
+ layout: PANE_LAYOUTS.find((layout) => layout === parsed.layout),
50
49
  highlightEnabled: boolOr(parsed.highlightEnabled, defaults.highlightEnabled),
51
50
  sideSplitPercent: numberOr(parsed.sideSplitPercent, defaults.sideSplitPercent, clampSplitPercent),
52
51
  stackedSplitPercent: numberOr(parsed.stackedSplitPercent, defaults.stackedSplitPercent, clampSplitPercent),
@@ -56,9 +55,8 @@ export function parseEditorLayoutPrefs(raw) {
56
55
  };
57
56
  }
58
57
  /**
59
- * Editor layout: preferences persisted to localStorage (read after mount, so SSR renders the
60
- * defaults; a throwing storage keeps them in memory) plus the measured, unpersisted header height.
61
- * `previewWidth` and `contentPanel*` persist ahead of the toolbar and panel that will use them.
58
+ * Editor layout preferences, persisted to localStorage (read after mount, so SSR renders the
59
+ * defaults; a throwing storage keeps them in memory).
62
60
  */
63
61
  export function useEditorLayout(options) {
64
62
  const [prefs, setPrefs] = useLocalStorage({
@@ -68,8 +66,9 @@ export function useEditorLayout(options) {
68
66
  // Each tab keeps its own layout; a change elsewhere reaches only tabs opened later.
69
67
  sync: false,
70
68
  });
71
- const [headerHeight, setHeaderHeight] = useState(80);
72
- const headerRef = useRef(null);
69
+ // Mounted in the same effect pass as the storage read above, and after it, so the render that
70
+ // first sees `loaded` also sees the stored values.
71
+ const loaded = useMounted();
73
72
  const setLayout = useCallback((layout) => setPrefs((prev) => ({ ...prev, layout })), [setPrefs]);
74
73
  const setHighlightEnabled = useCallback((highlightEnabled) => setPrefs((prev) => ({ ...prev, highlightEnabled })), [setPrefs]);
75
74
  const setSplitPercent = useCallback((layout, percent) => {
@@ -88,18 +87,9 @@ export function useEditorLayout(options) {
88
87
  const contentPanelWidth = clampContentPanelWidth(width);
89
88
  setPrefs((prev) => ({ ...prev, contentPanelWidth }));
90
89
  }, [setPrefs]);
91
- useEffect(() => {
92
- if (!headerRef.current)
93
- return;
94
- const node = headerRef.current;
95
- const updateHeight = () => setHeaderHeight(node.getBoundingClientRect().height || 80);
96
- updateHeight();
97
- const observer = new ResizeObserver(updateHeight);
98
- observer.observe(node);
99
- return () => observer.disconnect();
100
- }, []);
101
90
  return {
102
- layout: prefs.layout,
91
+ loaded,
92
+ layout: prefs.layout ?? options?.defaultLayout ?? 'side',
103
93
  setLayout,
104
94
  highlightEnabled: prefs.highlightEnabled,
105
95
  setHighlightEnabled,
@@ -112,7 +102,5 @@ export function useEditorLayout(options) {
112
102
  setContentPanelOpen,
113
103
  contentPanelWidth: prefs.contentPanelWidth,
114
104
  setContentPanelWidth,
115
- headerRef,
116
- headerHeight,
117
105
  };
118
106
  }
@@ -39,6 +39,8 @@ export interface UseEntryManagerOptions {
39
39
  export interface UseEntryManagerReturn {
40
40
  selectedPath: string;
41
41
  setSelectedPath: (path: string) => void;
42
+ /** Leaves no entry open, as after deleting the open one, instead of opening the first. */
43
+ closeEntry: () => void;
42
44
  entries: EditorEntry[];
43
45
  setEntries: (entries: EditorEntry[]) => void;
44
46
  collections: EditorCollection[];
@@ -52,6 +54,8 @@ export interface UseEntryManagerReturn {
52
54
  availableSchemas: string[];
53
55
  /** True while the first entry load for the current branch is in flight (initial load, per branch). */
54
56
  entriesInitializing: boolean;
57
+ /** The branch's last entries fetch failed; its "Could not load content" toast offers Retry. */
58
+ entriesLoadFailed: boolean;
55
59
  navigatorOpen: boolean;
56
60
  setNavigatorOpen: (open: boolean) => void;
57
61
  refreshEntries: (branch?: string) => Promise<EditorEntry[]>;