canopycms 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +213 -41
  2. package/dist/api/branch.d.ts +2 -2
  3. package/dist/api/branch.js +4 -31
  4. package/dist/authorization/branch.js +3 -4
  5. package/dist/cli/cli.js +0 -18
  6. package/dist/cli/generate-ai-content.js +0 -6
  7. package/dist/cli/init.js +0 -6
  8. package/dist/editor/BranchManager.d.ts +2 -0
  9. package/dist/editor/BranchManager.js +3 -3
  10. package/dist/editor/Editor.js +253 -128
  11. package/dist/editor/EntryNavigator.d.ts +44 -9
  12. package/dist/editor/EntryNavigator.js +275 -103
  13. package/dist/editor/branch-lock.d.ts +42 -0
  14. package/dist/editor/branch-lock.js +79 -0
  15. package/dist/editor/branch-status.d.ts +0 -6
  16. package/dist/editor/branch-status.js +0 -10
  17. package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
  18. package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
  19. package/dist/editor/components/BranchPill.d.ts +3 -18
  20. package/dist/editor/components/BranchPill.js +7 -10
  21. package/dist/editor/components/EditorHeader.d.ts +24 -52
  22. package/dist/editor/components/EditorHeader.js +151 -71
  23. package/dist/editor/components/EditorRail.d.ts +8 -8
  24. package/dist/editor/components/EditorRail.js +7 -5
  25. package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
  26. package/dist/editor/components/EntryBreadcrumb.js +3 -3
  27. package/dist/editor/components/EntryHeader.d.ts +60 -0
  28. package/dist/editor/components/EntryHeader.js +45 -0
  29. package/dist/editor/components/EntrySaveButton.d.ts +24 -0
  30. package/dist/editor/components/EntrySaveButton.js +101 -0
  31. package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
  32. package/dist/editor/components/NoEditPermissionNotice.js +1 -1
  33. package/dist/editor/components/RetryToastMessage.d.ts +3 -1
  34. package/dist/editor/components/RetryToastMessage.js +8 -4
  35. package/dist/editor/components/SaveAllButton.d.ts +25 -0
  36. package/dist/editor/components/SaveAllButton.js +81 -0
  37. package/dist/editor/components/UnsavedMark.d.ts +1 -1
  38. package/dist/editor/components/UnsavedMark.js +1 -1
  39. package/dist/editor/components/index.d.ts +1 -1
  40. package/dist/editor/components/index.js +1 -1
  41. package/dist/editor/copy.d.ts +0 -1
  42. package/dist/editor/copy.js +0 -1
  43. package/dist/editor/editor-utils.d.ts +2 -2
  44. package/dist/editor/editor-utils.js +2 -2
  45. package/dist/editor/entry-search.d.ts +10 -16
  46. package/dist/editor/entry-search.js +4 -24
  47. package/dist/editor/hooks/useBranchManager.d.ts +7 -3
  48. package/dist/editor/hooks/useBranchManager.js +13 -9
  49. package/dist/editor/hooks/useEntryManager.d.ts +3 -0
  50. package/dist/editor/hooks/useEntryManager.js +10 -3
  51. package/dist/editor/shell/ActivityRail.d.ts +4 -0
  52. package/dist/editor/shell/ActivityRail.js +13 -4
  53. package/dist/editor/shell/EditorShell.d.ts +6 -7
  54. package/dist/editor/shell/EditorShell.js +9 -13
  55. package/dist/editor/shell/PaneLayout.d.ts +1 -0
  56. package/dist/editor/shell/PaneLayout.js +23 -5
  57. package/dist/editor/shell/SidePanelHost.d.ts +11 -4
  58. package/dist/editor/shell/SidePanelHost.js +95 -7
  59. package/dist/editor/shell/TopBar.d.ts +4 -4
  60. package/dist/editor/shell/TopBar.js +6 -3
  61. package/dist/editor/utils/focus-return.d.ts +10 -2
  62. package/dist/editor/utils/focus-return.js +40 -6
  63. package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
  64. package/dist/operating-mode/client-safe-strategy.js +1 -10
  65. package/dist/operating-mode/types.d.ts +0 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +1 -1
  69. package/dist/editor/components/BranchLockBanner.d.ts +0 -23
  70. package/dist/editor/components/BranchLockBanner.js +0 -66
  71. package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
  72. package/dist/editor/components/QuickOpenPalette.js +0 -71
  73. package/dist/editor/components/SaveSplitButton.d.ts +0 -31
  74. package/dist/editor/components/SaveSplitButton.js +0 -121
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useMemo, useState, useRef } from 'react';
4
- import { ActionIcon, Alert, Box, Button, Menu, Paper, Skeleton, Stack, Text, Tooltip, } from '@mantine/core';
5
- import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, IconSearch, } from '@tabler/icons-react';
6
- import { useMediaQuery } from '@mantine/hooks';
4
+ import { ActionIcon, Alert, Box, Button, Center, Menu, Paper, Skeleton, Stack, Text, Tooltip, } from '@mantine/core';
5
+ import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, } from '@tabler/icons-react';
6
+ import { useHotkeys, useMediaQuery } from '@mantine/hooks';
7
7
  import { notifications } from '@mantine/notifications';
8
8
  const noop = () => { };
9
9
  import { EntryNavigator } from './EntryNavigator.js';
@@ -13,7 +13,7 @@ import { displayPreviewPath, previewPageKey } from './preview-path.js';
13
13
  import { showPreviewLinkOutcome } from './components/PreviewLinkToast.js';
14
14
  import { clientOperatingStrategy } from '../operating-mode/client.js';
15
15
  import { EditorShell } from './shell/EditorShell.js';
16
- import { PaneLayout } from './shell/PaneLayout.js';
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
19
  import { BranchManager } from './BranchManager.js';
@@ -25,7 +25,7 @@ import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
25
25
  // Import directly from helpers to avoid server-only code in authorization barrel
26
26
  import { isAdmin, isPrivileged, isReviewer } from '../authorization/helpers.js';
27
27
  import { fieldBreadcrumb } from './field-breadcrumb.js';
28
- import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, entryTypeLine, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
28
+ import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
29
29
  import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
30
30
  import { useBranchActions } from './hooks/useBranchActions.js';
31
31
  import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
@@ -33,17 +33,17 @@ import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
33
33
  import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
34
34
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
35
35
  import { usePreviewMarks } from './hooks/usePreviewMarks.js';
36
- import { BranchLockBanner, describeBranchLock, EditorHeader, EditorRail } from './components/index.js';
36
+ import { branchBarStatus, describeBranchLock } from './branch-lock.js';
37
+ import { EditorHeader, EditorRail, EntryHeader } from './components/index.js';
37
38
  import { showSaveAllResult } from './components/SaveAllResult.js';
38
39
  import { RenameEntryModal } from './components/RenameEntryModal.js';
39
- import { QuickOpenPalette } from './components/QuickOpenPalette.js';
40
- import { rememberOpener, returnTarget } from './utils/focus-return.js';
40
+ import { focusIsIdle, rememberOpener, returnTarget } from './utils/focus-return.js';
41
41
  import { modKeyLabel } from './platform.js';
42
- import { EDITOR_ACTIONS } from './copy.js';
43
42
  import { EntryCreateModal } from './components/EntryCreateModal.js';
44
43
  import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
45
44
  import { ReferencedByList, referencedDeleteMessage } from './components/ReferencedByList.js';
46
45
  import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
46
+ import { BranchNotAvailableNotice } from './components/BranchNotAvailableNotice.js';
47
47
  import { ReadOnlyDraftNotice } from './components/ReadOnlyDraftNotice.js';
48
48
  import { UnavailableEntryNotice } from './components/UnavailableEntryNotice.js';
49
49
  import { unavailableSchemaRefs } from './unavailable-entry-type.js';
@@ -81,6 +81,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
81
81
  const [groupManagerOpen, setGroupManagerOpen] = useState(false);
82
82
  const [permissionManagerOpen, setPermissionManagerOpen] = useState(false);
83
83
  const [branchManagerOpen, setBranchManagerOpen] = useState(false);
84
+ // Each opening remounts Branches; one from New branch… starts on its new-branch form.
85
+ const [branchManagerOpening, setBranchManagerOpening] = useState({ seq: 0, create: false });
86
+ const openBranchesDrawer = (create) => {
87
+ branchOpenerRef.current = rememberOpener();
88
+ setBranchManagerOpening(({ seq }) => ({ seq: seq + 1, create }));
89
+ setBranchManagerOpen(true);
90
+ };
84
91
  const [mediaLibraryOpen, setMediaLibraryOpen] = useState(false);
85
92
  const [systemHealthOpen, setSystemHealthOpen] = useState(false);
86
93
  const [collectionEditorOpen, setCollectionEditorOpen] = useState(false);
@@ -96,8 +103,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
96
103
  const [restoredCollectionInput, setRestoredCollectionInput] = useState(null);
97
104
  // Each open or close of the collection dialog moves it on, so a slow open answers only if current.
98
105
  const collectionOpeningRef = useRef(0);
99
- const [quickOpenOpen, setQuickOpenOpen] = useState(false);
100
- const [quickOpenFromOverlay, setQuickOpenFromOverlay] = useState(false);
101
106
  const navigatorToggleRef = useRef(null);
102
107
  const mediaButtonRef = useRef(null);
103
108
  const branchToggleRef = useRef(null);
@@ -106,10 +111,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
106
111
  const branchOpenerRef = useRef(null);
107
112
  // Null when the panel opened from a menu item, which unmounts on click, or with focus on the page.
108
113
  const navigatorOpenerRef = useRef(null);
109
- const quickOpenOpenRef = useRef(false);
110
- useEffect(() => {
111
- quickOpenOpenRef.current = quickOpenOpen;
112
- }, [quickOpenOpen]);
114
+ const entryFilterRef = useRef(null);
113
115
  const [renameModalOpen, setRenameModalOpen] = useState(false);
114
116
  const [renamingEntry, setRenamingEntry] = useState(null);
115
117
  const [renameModalError, setRenameModalError] = useState(null);
@@ -158,7 +160,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
158
160
  labels: [],
159
161
  }));
160
162
  const applySubmitStampsRef = useRef(() => { });
161
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
163
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
162
164
  initialBranch: branchName,
163
165
  operatingMode,
164
166
  setBusy: setBranchesLoading,
@@ -180,9 +182,21 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
180
182
  // loading state during that window anyway.
181
183
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
182
184
  const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
183
- // A row's lock reason on an unanswered branch would be "details could not be loaded", which is
184
- // not yet true; once the fetch has failed it is, and the lock says so.
185
- const branchRowsPending = branchLoading && !branchesLoadFailed;
185
+ // No lock reason is true yet while the list is unanswered; once it has failed, or answered
186
+ // without this branch, the lock says which.
187
+ const branchPending = !currentBranch && !branchesLoaded && !branchesLoadFailed;
188
+ const branchMissing = !currentBranch && branchesLoaded;
189
+ // The bar's lock-state row, with the inputs EditorHeader gets, for the states that must agree.
190
+ const branchBarState = branchBarStatus({
191
+ branchStatus: currentBranch?.status,
192
+ branchReadOnly: currentBranch?.readOnly,
193
+ branchWriteBlocked: branchContentLocked,
194
+ branchPending,
195
+ branchMissing,
196
+ isProtected: currentBranch?.isProtected,
197
+ }).value;
198
+ // With no branch named, the bar shows no row, so neither do the empty states.
199
+ const branchNotAvailable = !!branchNameState && branchBarState === 'not-available';
186
200
  // Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
187
201
  const switchBranchRef = useRef(async () => { });
188
202
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
@@ -200,6 +214,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
200
214
  }),
201
215
  resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
202
216
  setBusy: setEntriesLoading,
217
+ branchBarState,
203
218
  switchBranch: (branch) => switchBranchRef.current(branch),
204
219
  // To the entry menu, which names the entry opened; a dialog keeps focus.
205
220
  onHistoryRestore: () => requestAnimationFrame(() => {
@@ -208,6 +223,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
208
223
  }
209
224
  }),
210
225
  });
226
+ // The "Not available" empty states wait for the load to answer with nothing: content that does
227
+ // load, for a branch the list lags in granting, shows as usual.
228
+ const branchShowsNotAvailable = branchNotAvailable && !entriesInitializing && entriesState.length === 0;
211
229
  // The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
212
230
  // overlay's. Anything that focuses something in the form reveals it first. Opening an entry
213
231
  // closes the overlay only, so the preview stays maximized across entries (see
@@ -325,11 +343,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
325
343
  currentUser: commentUserId,
326
344
  canResolveComments: canResolveAnyComment,
327
345
  setSelectedPath,
328
- setBranchManagerOpen: (open) => {
329
- if (open)
330
- branchOpenerRef.current = rememberOpener();
331
- setBranchManagerOpen(open);
332
- },
346
+ setBranchManagerOpen: (open) => open ? openBranchesDrawer(false) : setBranchManagerOpen(false),
333
347
  onCommentsChange: setCommentsForBranchSummaries,
334
348
  onRevealForm: revealForm,
335
349
  });
@@ -1026,6 +1040,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1026
1040
  title: entry.title,
1027
1041
  slug: entry.slug,
1028
1042
  status: entryStatusLabel(activeCollections, entry),
1043
+ route: buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
1029
1044
  missing: entry.status === 'missing' || undefined,
1030
1045
  noPage: !entry.previewSrc || undefined,
1031
1046
  contentId: entry.contentId,
@@ -1078,16 +1093,29 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1078
1093
  onCreateEntry,
1079
1094
  handleCreateEntry,
1080
1095
  contentRoot,
1096
+ previewBaseByCollection,
1081
1097
  currentBranch,
1082
1098
  schemaUnavailableKeys,
1083
1099
  userIsAdmin,
1084
1100
  ]);
1085
- const quickOpenShortcut = modKeyLabel() === '⌘' ? '⌘P' : 'Ctrl+P';
1086
- const quickOpenKeys = modKeyLabel() === '⌘' ? 'Meta+P' : 'Control+P';
1101
+ const findShortcut = modKeyLabel() === '⌘' ? { label: '⌘P', keys: 'Meta+P' } : { label: 'Ctrl+P', keys: 'Control+P' };
1102
+ // Mod+P opens a closed docked panel only until Enter, Escape from inside it or focus leaves it;
1103
+ // the stored preference stays closed. A row menu, the Content button or any other ask opens it
1104
+ // for good.
1105
+ const [contentPanelTransient, setContentPanelTransient] = useState(false);
1106
+ if (contentPanelTransient && !contentPanelDocked)
1107
+ setContentPanelTransient(false);
1087
1108
  // Docked, the content panel's open state is the stored preference; as an overlay it is
1088
1109
  // `navigatorOpen`, which starts open when the URL asks for no entry.
1089
- const contentPanelShown = contentPanelDocked ? contentPanelOpen : navigatorOpen;
1090
- const setContentPanelShown = (open) => contentPanelDocked ? setContentPanelOpen(open) : setNavigatorOpen(open);
1110
+ const contentPanelShown = contentPanelDocked
1111
+ ? contentPanelOpen || contentPanelTransient
1112
+ : navigatorOpen;
1113
+ const setContentPanelShown = (open) => {
1114
+ if (!contentPanelDocked)
1115
+ return setNavigatorOpen(open);
1116
+ setContentPanelOpen(open);
1117
+ setContentPanelTransient(false);
1118
+ };
1091
1119
  // The overlay lives in the form pane, under a maximized preview.
1092
1120
  const contentOverlayShown = !contentPanelDocked && contentPanelShown;
1093
1121
  useEffect(() => {
@@ -1096,30 +1124,78 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1096
1124
  }, [contentOverlayShown]);
1097
1125
  // Bumped on every ask for the panel, so one already open still takes focus.
1098
1126
  const [contentPanelFocusRequest, setContentPanelFocusRequest] = useState(0);
1099
- const openNavigator = () => {
1100
- navigatorOpenerRef.current = rememberOpener();
1127
+ const openNavigator = ({ transient = false } = {}) => {
1128
+ // Focus already in the panel isn't an opener: the panel's own close would hand focus back to it.
1129
+ // Nor is the page, once the panel is shown: focus dropped there from where it came.
1130
+ const opener = rememberOpener();
1131
+ if (!document.getElementById(CONTENT_PANEL_ID)?.contains(document.activeElement) &&
1132
+ (opener || !contentPanelShown)) {
1133
+ navigatorOpenerRef.current = opener;
1134
+ }
1101
1135
  // In the commit that opens the overlay, so the form panel is no longer inert when the overlay
1102
1136
  // mounts, then or a frame later as its slide starts, and serves its focus request.
1103
1137
  if (!contentPanelDocked)
1104
1138
  setPreviewMaximized(false);
1105
- setContentPanelShown(true);
1139
+ // A pending Enter focus nulls the panel's return of focus, and only Enter's close may lose it.
1140
+ setFormFocusPath(null);
1141
+ if (transient && contentPanelDocked && !contentPanelOpen)
1142
+ setContentPanelTransient(true);
1143
+ else
1144
+ setContentPanelShown(true);
1106
1145
  setContentPanelFocusRequest((request) => request + 1);
1107
1146
  };
1108
- // A collection crumb opens the panel as the rail's Content button does, then its collection's row
1109
- // takes focus.
1110
- const [collectionReveal, setCollectionReveal] = useState();
1147
+ const keepContentPanelOpen = () => {
1148
+ if (contentPanelTransient)
1149
+ setContentPanelShown(true);
1150
+ };
1151
+ const [formFocusPath, setFormFocusPath] = useState(null);
1152
+ const formFocusRestoredRef = useRef(false);
1153
+ // `[]` (tagsToIgnore) and `true` (contenteditable) let it fire from fields and the MDX editor.
1154
+ useHotkeys([
1155
+ [
1156
+ 'mod+P',
1157
+ (event) => {
1158
+ // Mantine's `mod` is Ctrl or ⌘ everywhere; on macOS Ctrl+P moves the caret up a line.
1159
+ if (modKeyLabel() === '⌘' && !event.metaKey)
1160
+ return;
1161
+ // Keeps the browser's print dialog shut, even when the panel can't open.
1162
+ event.preventDefault();
1163
+ // Opened under a modal or drawer, it would take the keyboard from the one in use.
1164
+ if (document.querySelector('[role="dialog"][aria-modal="true"]'))
1165
+ return;
1166
+ setChangedOnly(false);
1167
+ const field = entryFilterRef.current;
1168
+ if (field && document.activeElement === field)
1169
+ field.select();
1170
+ else
1171
+ openNavigator({ transient: true });
1172
+ },
1173
+ { preventDefault: false },
1174
+ ],
1175
+ ], [], true);
1176
+ // A collection crumb, or the entry menu's "Show in content panel", opens the panel as the rail's
1177
+ // Content button does, then the row takes focus.
1178
+ const [contentReveal, setContentReveal] = useState();
1111
1179
  // Only ever rises: the navigator serves each seq once, and the request is cleared once served.
1112
1180
  const revealSeqRef = useRef(0);
1113
- const revealCollection = (path) => {
1181
+ const revealInContentPanel = (reveal) => {
1114
1182
  openNavigator();
1115
1183
  revealSeqRef.current += 1;
1116
- setCollectionReveal({ path, seq: revealSeqRef.current });
1184
+ setContentReveal({ ...reveal, seq: revealSeqRef.current });
1117
1185
  };
1118
1186
  // An ask the panel closed on before serving it must not fire when the panel next opens.
1119
1187
  useEffect(() => {
1120
1188
  if (!contentPanelShown)
1121
- setCollectionReveal(undefined);
1189
+ setContentReveal(undefined);
1122
1190
  }, [contentPanelShown]);
1191
+ // The content panel's All / Changed filter. It stays as chosen, emptied by a save or not, except
1192
+ // that Mod+P finds among all entries and another branch has its own unsaved set: both reset it.
1193
+ const [changedOnly, setChangedOnly] = useState(false);
1194
+ useEffect(() => setChangedOnly(false), [branchNameState]);
1195
+ const showUnsaved = () => {
1196
+ setChangedOnly(true);
1197
+ revealInContentPanel({ kind: 'changed' });
1198
+ };
1123
1199
  const runSaveAll = () => {
1124
1200
  void handleSaveAll()
1125
1201
  .then((result) => {
@@ -1144,18 +1220,12 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1144
1220
  const navigatorReturnTarget = () => returnTarget(navigatorOpenerRef.current, () => navigatorToggleRef.current);
1145
1221
  const branchReturnTarget = () => returnTarget(branchOpenerRef.current, () => branchToggleRef.current);
1146
1222
  const settingsReturnTarget = () => settingsToggleRef.current;
1147
- // The palette replaces the overlay panel; a docked one stays. Opened from inside the overlay,
1148
- // whose tree then unmounts, it returns focus to the panel's opener.
1149
- const openQuickOpen = () => {
1150
- if (!quickOpenOpen) {
1151
- const panel = document.getElementById(CONTENT_PANEL_ID);
1152
- setQuickOpenFromOverlay(!contentPanelDocked && navigatorOpen && !!panel?.contains(document.activeElement));
1153
- }
1154
- if (!contentPanelDocked)
1155
- setNavigatorOpen(false);
1156
- setQuickOpenOpen(true);
1157
- };
1158
- const dirtyPaths = useMemo(() => new Set(editedFiles.map((file) => file.path)), [editedFiles]);
1223
+ // Keyed by its members, so the same set keeps its identity as drafts change on every keystroke.
1224
+ const dirtyKey = editedFiles
1225
+ .map((file) => file.path)
1226
+ .sort()
1227
+ .join('\n');
1228
+ const dirtyPaths = useMemo(() => new Set(dirtyKey ? dirtyKey.split('\n') : []), [dirtyKey]);
1159
1229
  // Slugs of the entries beside the one being renamed, for the duplicate check.
1160
1230
  const renameExistingSlugs = useMemo(() => new Set(entriesState
1161
1231
  .filter((e) => e.collectionPath === renamingEntry?.collectionPath &&
@@ -1204,17 +1274,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1204
1274
  collectionName: e.collectionName,
1205
1275
  })),
1206
1276
  }), [entriesState]);
1207
- // Quick open names each entry's type and route under its label.
1208
- const quickOpenEntries = useMemo(() => entriesState.map((e) => ({
1209
- path: e.path,
1210
- label: e.label,
1211
- title: e.title,
1212
- slug: e.slug,
1213
- collectionPath: e.collectionPath,
1214
- collectionName: e.collectionName,
1215
- typeLabel: entryTypeLine(activeCollections, e),
1216
- route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
1217
- })), [entriesState, activeCollections, previewBaseByCollection, contentRoot]);
1218
1277
  const CenteredMessage = ({ children }) => (_jsx("div", { style: {
1219
1278
  display: 'flex',
1220
1279
  alignItems: 'center',
@@ -1240,6 +1299,54 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1240
1299
  if (!previewCanMaximize)
1241
1300
  setPreviewMaximized(false);
1242
1301
  }, [previewCanMaximize]);
1302
+ // Enter in the transient panel focuses the opened entry's first field once it renders, else the
1303
+ // form pane; focus the user moved by then stays. A tree click, preview link or Back moves none.
1304
+ useEffect(() => {
1305
+ if (!formFocusPath)
1306
+ return;
1307
+ if (selectedPath !== formFocusPath)
1308
+ return setFormFocusPath(null);
1309
+ if (currentEntry?.path !== formFocusPath)
1310
+ return;
1311
+ const formShown = schema.length > 0 && !!displayValue;
1312
+ const fieldless = currentEntryUnavailable ||
1313
+ currentEntry.canEdit === false ||
1314
+ schema.length === 0 ||
1315
+ loadFailedKey === `${branchNameState}:${currentEntry.contentId}`;
1316
+ if (!formShown && !fieldless)
1317
+ return;
1318
+ // Restoring the preview hands focus to its Maximize button; that isn't the user moving on.
1319
+ const focusedByRestore = formFocusRestoredRef.current ? document.activeElement : null;
1320
+ formFocusRestoredRef.current = false;
1321
+ const frame = requestAnimationFrame(() => {
1322
+ if (!focusIsIdle() && document.activeElement !== focusedByRestore) {
1323
+ return setFormFocusPath(null);
1324
+ }
1325
+ // The form is in view first; the next run focuses it.
1326
+ if (previewShownMaximized) {
1327
+ formFocusRestoredRef.current = true;
1328
+ return setPreviewMaximized(false);
1329
+ }
1330
+ setFormFocusPath(null);
1331
+ const pane = document.getElementById(FORM_PANE_ID);
1332
+ const field = formShown ? pane?.querySelector('[data-canopy-field]') : null;
1333
+ if (field)
1334
+ focusFieldTarget(field);
1335
+ else
1336
+ pane?.focus({ preventScroll: true });
1337
+ });
1338
+ return () => cancelAnimationFrame(frame);
1339
+ }, [
1340
+ formFocusPath,
1341
+ selectedPath,
1342
+ currentEntry,
1343
+ currentEntryUnavailable,
1344
+ schema.length,
1345
+ displayValue,
1346
+ loadFailedKey,
1347
+ branchNameState,
1348
+ previewShownMaximized,
1349
+ ]);
1243
1350
  const [paneResizing, setPaneResizing] = useState(false);
1244
1351
  const previewErrorField = previewError?.fieldPath
1245
1352
  ? fieldBreadcrumb(schema, displayValue, previewError.fieldPath).join(' › ')
@@ -1261,13 +1368,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1261
1368
  display: 'flex',
1262
1369
  alignItems: 'center',
1263
1370
  justifyContent: 'center',
1264
- }, children: _jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
1371
+ }, children: !currentEntry && branchShowsNotAvailable ? (_jsx(BranchNotAvailableNotice, { align: "center", "data-testid": "preview-branch-not-available" })) : (_jsx(Text, { size: "sm", c: "dimmed", children: !branchNameState && busy
1265
1372
  ? 'Setting up your branch workspace…'
1266
1373
  : entriesInitializing
1267
1374
  ? 'Loading content…'
1268
1375
  : currentEntry && !currentEntry.previewSrc
1269
1376
  ? 'No preview for this entry.'
1270
- : 'Select an item to start editing.' }) }));
1377
+ : 'Select an item to start editing.' })) }));
1271
1378
  const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(currentEntry, collectionLabels), [collectionLabels, currentEntry]);
1272
1379
  // The browser tab names the open entry and the site.
1273
1380
  const tabTitle = currentEntry
@@ -1287,9 +1394,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1287
1394
  document.title = tabTitle;
1288
1395
  writtenTitleRef.current = tabTitle;
1289
1396
  }, [tabTitle]);
1290
- const openBranchManager = () => {
1291
- branchOpenerRef.current = rememberOpener();
1292
- setBranchManagerOpen(true);
1397
+ const openBranchManager = (options) => {
1398
+ openBranchesDrawer(options?.create === true);
1293
1399
  // Always retry, not only when branchless. Opening the manager is
1294
1400
  // the one in-app retry for a failed branches fetch, and the fetch
1295
1401
  // is terminal on its own: SWRProvider sets shouldRetryOnError and
@@ -1297,12 +1403,19 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1297
1403
  // is 0 without data. Reloading is idempotent.
1298
1404
  loadBranches().catch(console.error);
1299
1405
  };
1300
- const contentPanel = (_jsx(SidePanelHost, { id: CONTENT_PANEL_ID, title: "Content", mode: contentPanelDocked ? 'docked' : 'overlay', opened: contentPanelShown, width: contentPanelWidth, minWidth: CONTENT_PANEL_WIDTH_MIN, maxWidth: CONTENT_PANEL_WIDTH_MAX, onWidthChange: setContentPanelWidth, onClose: () => setContentPanelShown(false), focusRequest: contentPanelFocusRequest,
1301
- // Quick open takes focus as it replaces the overlay.
1302
- returnFocusTo: () => (quickOpenOpen ? null : navigatorReturnTarget()), "data-testid": "content-panel", actions: _jsxs(_Fragment, { children: [navCollections &&
1406
+ const contentPanel = (_jsx(SidePanelHost, { id: CONTENT_PANEL_ID, title: "Content", mode: contentPanelDocked ? 'docked' : 'overlay', opened: contentPanelShown, width: contentPanelWidth, minWidth: CONTENT_PANEL_WIDTH_MIN, maxWidth: CONTENT_PANEL_WIDTH_MAX, onWidthChange: setContentPanelWidth, onClose: () => setContentPanelShown(false), focusRequest: contentPanelFocusRequest, closeOnEscape: contentPanelTransient,
1407
+ // An entry opened by Enter takes focus in its form instead.
1408
+ returnFocusTo: () => (formFocusPath ? null : navigatorReturnTarget()), onFocusLeave: contentPanelTransient
1409
+ ? (target) => {
1410
+ if (!contentToggleRef.current?.contains(target))
1411
+ setContentPanelTransient(false);
1412
+ }
1413
+ : undefined, "data-testid": "content-panel", actions: _jsxs(_Fragment, { children: [navCollections &&
1303
1414
  navCollections.length > 0 &&
1304
1415
  !branchContentLocked &&
1305
- (navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", 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: `${EDITOR_ACTIONS.openEntry} (${quickOpenShortcut})`, withinPortal: true, children: _jsx(ActionIcon, { variant: "subtle", color: "gray", onClick: openQuickOpen, "aria-label": EDITOR_ACTIONS.openEntry, "aria-keyshortcuts": quickOpenKeys, "data-testid": "quick-open-button", children: _jsx(IconSearch, { size: 16 }) }) }), _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
1416
+ (navCollections[0].onAdd || navCollections[0].onAddSubCollection) && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end",
1417
+ // Its items open dialogs outside the panel, which would close a transient one.
1418
+ 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
1306
1419
  ? undefined
1307
1420
  : entriesState.map((e) => ({
1308
1421
  path: e.path,
@@ -1310,85 +1423,97 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1310
1423
  title: e.title,
1311
1424
  slug: e.slug,
1312
1425
  status: entryStatusLabel(activeCollections, e),
1426
+ route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
1313
1427
  missing: e.status === 'missing' || undefined,
1314
1428
  noPage: !e.previewSrc || undefined,
1315
1429
  readOnly: e.canEdit === false || undefined,
1316
- })), 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: branchRowsPending
1430
+ })), 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
1317
1431
  ? undefined
1318
1432
  : describeBranchLock({
1319
1433
  branchStatus: currentBranch?.status,
1320
1434
  branchReadOnly: currentBranch?.readOnly,
1321
1435
  branchWriteBlocked: branchContentLocked,
1322
- }), dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1323
- return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount,
1324
- // A draft kept for an unavailable entry stays in storage but cannot be saved.
1325
- hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
1436
+ branchMissing,
1437
+ }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), changedOnly: changedOnly, onChangedOnlyChange: setChangedOnly, unsavedCount: modifiedCount, unsavedEntries: editedFiles, entryListFailed: entriesLoadFailed && entriesState.length === 0, branchNotAvailable: branchShowsNotAvailable, onSwitchBranch: () => openBranchManager(), filterRef: entryFilterRef, filterShortcut: findShortcut, onFilterEnter: contentPanelTransient
1438
+ ? (path) => {
1439
+ openEntry(path);
1440
+ setContentPanelTransient(false);
1441
+ setFormFocusPath(path);
1442
+ }
1443
+ : undefined, onFilterEscape: contentPanelTransient ? () => setContentPanelTransient(false) : undefined, onRowMenuOpen: keepContentPanelOpen }) }) }));
1444
+ return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
1326
1445
  // Fail-closed for the same reason branchContentLocked above is:
1327
- // isProtected/writeBlocked gate real actions in EditorHeader (hiding
1328
- // Submit, disabling Save), so a missing wire value must read as
1446
+ // isProtected/writeBlocked gate real actions in EditorHeader and EntryHeader
1447
+ // (hiding Submit, disabling Save and Save all), so a missing wire value must read as
1329
1448
  // "protected"/"blocked", not "unprotected"/"unblocked".
1330
1449
  // branchWriteBlocked reuses branchContentLocked itself rather than
1331
1450
  // re-deriving `?? true` a second time here, so there is exactly one
1332
1451
  // fail-closed computation of "are writes blocked" in this component.
1333
- branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
1334
- // A failed fetch leaves the list empty too; the banner says that one.
1335
- branchLoading: branchLoading, onCollectionOpen: revealCollection, navigatorToggleRef: navigatorToggleRef, branchToggleRef: branchToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
1452
+ branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
1336
1453
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1337
- }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1338
- singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
1339
- lastSaved.contentId === currentEntry?.contentId,
1454
+ }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
1455
+ // On any entry: EntryHeader's `saving` is the open entry's alone.
1456
+ singleSaving: singleSaving !== null,
1340
1457
  // Save all finds each draft's entry in the list, so with none listed it would save nothing.
1341
- entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: () => {
1458
+ entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: (returnFocusTo) => {
1342
1459
  if (branchNameState && !isSaveAllRunning()) {
1343
- handleSubmit(branchNameState).catch(console.error);
1460
+ handleSubmit(branchNameState, returnFocusTo).catch(console.error);
1344
1461
  }
1345
- }, onWithdraw: () => {
1462
+ }, onWithdraw: (returnFocusTo) => {
1346
1463
  if (branchNameState && !isSaveAllRunning()) {
1347
- handleWithdraw(branchNameState).catch(console.error);
1464
+ handleWithdraw(branchNameState, returnFocusTo).catch(console.error);
1348
1465
  }
1349
- } }), banner: _jsx(BranchLockBanner, { branchName: branchNameState, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, onBranchManagerOpen: openBranchManager }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelShown ? setContentPanelShown(false) : openNavigator(), contentToggleRef: contentToggleRef, onQuickOpen: openQuickOpen, quickOpenShortcut: quickOpenShortcut, quickOpenKeys: quickOpenKeys, branchesOpen: branchManagerOpen, onBranchesOpen: openBranchManager, onCommentsOpen: clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
1466
+ } }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelTransient
1467
+ ? keepContentPanelOpen()
1468
+ : contentPanelShown
1469
+ ? setContentPanelShown(false)
1470
+ : openNavigator(), contentToggleRef: contentToggleRef, contentShortcut: findShortcut, unsavedCount: modifiedCount, onCommentsOpen: clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
1350
1471
  branchNameState
1351
1472
  ? () => setCommentsPanelOpen(true)
1352
- : undefined, commentsOpen: commentsPanelOpen, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, mediaLibraryOpen: mediaLibraryOpen, onMediaLibraryOpen: () => setMediaLibraryOpen(true), mediaButtonRef: mediaButtonRef, layout: layout, onLayoutChange: setLayout, layoutBlocked: previewShownMaximized, onPermissionManagerOpen: userIsAdmin ? () => setPermissionManagerOpen(true) : undefined, onGroupManagerOpen: userIsAdmin ? () => setGroupManagerOpen(true) : undefined, onSystemHealthOpen: userIsAdmin ? () => setSystemHealthOpen(true) : undefined, settingsToggleRef: settingsToggleRef, AccountComponent: AccountComponent, onAccountClick: onAccountClick, onLogoutClick: onLogoutClick }), panel: contentPanelDocked && layout === 'side' ? contentPanel : undefined, main: _jsx(Box, { h: "100%", children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
1353
- // Sizing the pane, by pointer or key, gives the page the pane's width.
1354
- onSeparatorDrag: (dragging) => {
1355
- setPaneResizing(dragging);
1356
- if (dragging)
1357
- setPreviewWidth('fit');
1358
- }, arrangement: layout, sideSplitPercent: sideSplitPercent, stackedSplitPercent: stackedSplitPercent, onSplitPercentChange: (arrangement, percent) => {
1359
- setSplitPercent(arrangement, percent);
1360
- setPreviewWidth('fit');
1361
- }, preview: renderPreview && currentEntry && !currentEntryUnavailable
1362
- ? renderPreview(currentEntry, displayValue)
1363
- : defaultPreview, form: !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
1364
- ? 'Setting up your branch workspace…'
1365
- : entriesInitializing
1366
- ? 'Loading content…'
1367
- : '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(FormRenderer, { notice: hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
1368
- currentBranch?.status === 'archived',
1369
- // A hidden draft's discard changes nothing on screen, so it says so.
1370
- onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1371
- announce: true,
1372
- }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1373
- title: currentEntry.label,
1374
- typeLabel: entryTypeLabel(activeCollections, currentEntry),
1375
- },
1376
- // The one writer of drafts. Read-only content gets none, whatever a
1377
- // field emits (MDXEditor's mount-time normalisation included).
1378
- onChange: (next) => {
1379
- const contentId = currentEntry?.contentId;
1380
- if (contentId && !contentReadOnlyRef.current) {
1381
- // Every list item keeps a key, whatever field made it.
1382
- const keyed = withItemKeys(schema, next, 'random');
1383
- setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
1384
- }
1385
- }, 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 &&
1386
- 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: () => {
1387
- setLoadFailedKey(null);
1388
- setLoadAttempt((attempt) => attempt + 1);
1389
- }, children: "Retry" }) })) }) }), overlays: _jsxs(_Fragment, { children: [_jsx(QuickOpenPalette, { opened: quickOpenOpen, onOpen: openQuickOpen, onClose: () => setQuickOpenOpen(false), returnFocusTo: quickOpenFromOverlay ? navigatorReturnTarget : undefined, entries: quickOpenEntries,
1390
- // The hidden root's entries sit at the top of the tree, so they get no heading here either.
1391
- collectionLabel: (path) => path === hiddenRootPath ? '' : collectionLabels.get(path), onSelect: setSelectedPath }), _jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, returnFocusTo: branchReturnTarget, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, currentBranchName: currentBranch?.name ?? branchNameState, currentWorkflowBlockedReason: saveAllProgress ? 'Wait for Save all to finish.' : undefined, onSelect: async (name) => {
1473
+ : undefined, commentsOpen: commentsPanelOpen, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, mediaLibraryOpen: mediaLibraryOpen, onMediaLibraryOpen: () => setMediaLibraryOpen(true), mediaButtonRef: mediaButtonRef, layout: layout, onLayoutChange: setLayout, layoutBlocked: previewShownMaximized, onPermissionManagerOpen: userIsAdmin ? () => setPermissionManagerOpen(true) : undefined, onGroupManagerOpen: userIsAdmin ? () => setGroupManagerOpen(true) : undefined, onSystemHealthOpen: userIsAdmin ? () => setSystemHealthOpen(true) : undefined, settingsToggleRef: settingsToggleRef, AccountComponent: AccountComponent, onAccountClick: onAccountClick, onLogoutClick: onLogoutClick }), panel: contentPanelDocked && layout === 'side' ? contentPanel : undefined, main: _jsxs(Box, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, children: [_jsx(EntryHeader, { currentEntry: currentEntry,
1474
+ // True exactly while the form below shows "Setting up…" or "Loading content…".
1475
+ entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
1476
+ // A draft kept for an unavailable entry stays in storage but cannot be saved.
1477
+ 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, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, 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 &&
1478
+ singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
1479
+ lastSaved.contentId === currentEntry?.contentId }), _jsx(Box, { style: { flex: '1 1 0%', minHeight: 0 }, children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
1480
+ // Sizing the pane, by pointer or key, gives the page the pane's width.
1481
+ onSeparatorDrag: (dragging) => {
1482
+ setPaneResizing(dragging);
1483
+ if (dragging)
1484
+ setPreviewWidth('fit');
1485
+ }, arrangement: layout, sideSplitPercent: sideSplitPercent, stackedSplitPercent: stackedSplitPercent, onSplitPercentChange: (arrangement, percent) => {
1486
+ setSplitPercent(arrangement, percent);
1487
+ setPreviewWidth('fit');
1488
+ }, preview: renderPreview && currentEntry && !currentEntryUnavailable
1489
+ ? renderPreview(currentEntry, displayValue)
1490
+ : defaultPreview, form: !currentEntry && branchShowsNotAvailable ? (_jsx(Center, { h: "100%", children: _jsx(BranchNotAvailableNotice, { align: "center", onSwitchBranch: () => openBranchManager(), "data-testid": "form-branch-not-available" }) })) : !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
1491
+ ? 'Setting up your branch workspace…'
1492
+ : entriesInitializing
1493
+ ? 'Loading content…'
1494
+ : '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(FormRenderer, { notice: hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
1495
+ currentBranch?.status === 'archived',
1496
+ // A hidden draft's discard changes nothing on screen, so it says so.
1497
+ onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1498
+ announce: true,
1499
+ }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1500
+ title: currentEntry.label,
1501
+ typeLabel: entryTypeLabel(activeCollections, currentEntry),
1502
+ },
1503
+ // The one writer of drafts. Read-only content gets none, whatever a
1504
+ // field emits (MDXEditor's mount-time normalisation included).
1505
+ onChange: (next) => {
1506
+ const contentId = currentEntry?.contentId;
1507
+ if (contentId && !contentReadOnlyRef.current) {
1508
+ // Every list item keeps a key, whatever field made it.
1509
+ const keyed = withItemKeys(schema, next, 'random');
1510
+ setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
1511
+ }
1512
+ }, 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 &&
1513
+ 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: () => {
1514
+ setLoadFailedKey(null);
1515
+ setLoadAttempt((attempt) => attempt + 1);
1516
+ }, 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) => {
1392
1517
  try {
1393
1518
  await handleBranchChange(name);
1394
1519
  setBranchManagerOpen(false);
@@ -1411,7 +1536,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1411
1536
  handleRequestChanges(name).catch((err) => console.error(err));
1412
1537
  }, onDelete: (name) => {
1413
1538
  handleDelete(name).catch((err) => console.error(err));
1414
- }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }) }), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchNameState, 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
1539
+ }, 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) }), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchNameState, 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
1415
1540
  ? (path, entryType) => addEntryType(path, entryType)
1416
1541
  : undefined, onUpdateEntryType: editingCollection
1417
1542
  ? (path, name, updates) => updateEntryType(path, name, updates)