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.
- package/CHANGELOG.md +267 -231
- package/README.md +0 -1
- package/dist/ai/references.js +3 -1
- package/dist/api/assets.js +2 -2
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch.js +1 -1
- package/dist/api/content.js +1 -1
- package/dist/api/entries-constants.d.ts +1 -1
- package/dist/api/entries-constants.js +1 -1
- package/dist/api/entries.d.ts +2 -2
- package/dist/api/permissions.d.ts +2 -2
- package/dist/api/types.d.ts +3 -2
- package/dist/cli/cli.js +16 -14
- package/dist/cli/generate-ai-content.js +7 -5
- package/dist/cli/init.js +4 -4
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/config/schemas/config.d.ts +0 -5
- package/dist/config/schemas/config.js +0 -1
- package/dist/config/types.d.ts +0 -1
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.js +1 -1
- package/dist/content-reader.js +1 -1
- package/dist/content-store.d.ts +1 -1
- package/dist/content-store.js +1 -1
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +5 -4
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.js +3 -6
- package/dist/editor/Editor.d.ts +0 -2
- package/dist/editor/Editor.js +329 -181
- package/dist/editor/EditorAuthGate.js +8 -3
- package/dist/editor/EntryNavigator.d.ts +10 -0
- package/dist/editor/EntryNavigator.js +51 -2
- package/dist/editor/FormHeader.js +1 -1
- package/dist/editor/PreviewFrame.d.ts +5 -1
- package/dist/editor/PreviewFrame.js +3 -2
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/comments/form-comments.js +2 -1
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/EditorHeader.d.ts +50 -108
- package/dist/editor/components/EditorHeader.js +117 -138
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
- package/dist/editor/components/QuickOpenPalette.js +3 -2
- package/dist/editor/components/RenameEntryModal.d.ts +3 -1
- package/dist/editor/components/RenameEntryModal.js +3 -2
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/editor-utils.d.ts +11 -6
- package/dist/editor/editor-utils.js +8 -17
- package/dist/editor/fields/MarkdownField.js +3 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
- package/dist/editor/hooks/useCommentSystem.js +3 -0
- package/dist/editor/hooks/useDraftManager.d.ts +24 -1
- package/dist/editor/hooks/useDraftManager.js +297 -16
- package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
- package/dist/editor/hooks/useEditorLayout.js +13 -25
- package/dist/editor/hooks/useEntryManager.d.ts +4 -0
- package/dist/editor/hooks/useEntryManager.js +30 -7
- package/dist/editor/media/MediaLibrary.d.ts +1 -1
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/theme.js +18 -3
- package/dist/git-manager.d.ts +1 -1
- package/dist/git-manager.js +2 -2
- package/dist/operating-mode/types.d.ts +1 -1
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/resolver.d.ts +1 -1
- package/dist/schema/resolver.js +1 -1
- package/dist/schema/schema-store.d.ts +1 -1
- package/dist/schema/schema-store.js +2 -2
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +1 -1
- package/dist/url-exclusivity-fixtures.js +1 -1
- package/dist/utils/content-write-lock.d.ts +1 -1
- package/dist/utils/content-write-lock.js +1 -1
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/git.d.ts +4 -0
- package/dist/utils/git.js +27 -5
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -1
- package/dist/worker/cms-worker.js +2 -2
- package/dist/worker/github-auth.d.ts +1 -1
- package/dist/worker/github-auth.js +1 -1
- package/dist/worker/github-gateway-wire.d.ts +2 -1
- package/dist/worker/github-gateway-wire.js +7 -3
- package/dist/worker/github-gateway.js +1 -1
- package/dist/worker/github-mirror.d.ts +2 -2
- package/dist/worker/github-mirror.js +2 -2
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/self-checks.d.ts +1 -1
- package/dist/worker/shared-repo-git.js +1 -1
- package/package.json +5 -4
- package/dist/editor/EditorPanes.d.ts +0 -20
- package/dist/editor/EditorPanes.js +0 -124
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -70
- 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 "
|
|
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({
|
|
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 "
|
|
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
|
|
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
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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: '
|
|
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: '
|
|
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: '
|
|
1060
|
-
children:
|
|
1061
|
-
labels: { confirm: '
|
|
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
|
|
2
|
-
/** @internal
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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:
|
|
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
|
|
51
|
-
* defaults; a throwing storage keeps them in memory)
|
|
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
|
|
2
|
-
import { useLocalStorage } from '@mantine/hooks';
|
|
3
|
-
import { DEFAULT_SIDE_SPLIT_PERCENT, DEFAULT_STACKED_SPLIT_PERCENT, SPLIT_PERCENT_MAX, SPLIT_PERCENT_MIN, } from '../
|
|
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:
|
|
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
|
|
60
|
-
* defaults; a throwing storage keeps them in memory)
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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[]>;
|