canopycms 0.2.0-int.121 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +241 -78
  2. package/dist/api/branch.d.ts +2 -2
  3. package/dist/cli/cli.js +0 -18
  4. package/dist/cli/generate-ai-content.js +0 -6
  5. package/dist/cli/init.js +0 -6
  6. package/dist/dev-content-watcher.js +8 -1
  7. package/dist/editor/BranchManager.d.ts +2 -0
  8. package/dist/editor/BranchManager.js +3 -3
  9. package/dist/editor/Editor.js +276 -130
  10. package/dist/editor/EntryNavigator.d.ts +44 -9
  11. package/dist/editor/EntryNavigator.js +278 -104
  12. package/dist/editor/branch-lock.d.ts +42 -0
  13. package/dist/editor/branch-lock.js +79 -0
  14. package/dist/editor/branch-status.d.ts +0 -6
  15. package/dist/editor/branch-status.js +0 -10
  16. package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
  17. package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
  18. package/dist/editor/components/BranchPill.d.ts +3 -18
  19. package/dist/editor/components/BranchPill.js +7 -10
  20. package/dist/editor/components/BranchRowLock.d.ts +3 -1
  21. package/dist/editor/components/BranchRowLock.js +4 -2
  22. package/dist/editor/components/EditorHeader.d.ts +24 -52
  23. package/dist/editor/components/EditorHeader.js +151 -71
  24. package/dist/editor/components/EditorRail.d.ts +8 -8
  25. package/dist/editor/components/EditorRail.js +7 -5
  26. package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
  27. package/dist/editor/components/EntryBreadcrumb.js +3 -3
  28. package/dist/editor/components/EntryHeader.d.ts +60 -0
  29. package/dist/editor/components/EntryHeader.js +45 -0
  30. package/dist/editor/components/EntrySaveButton.d.ts +24 -0
  31. package/dist/editor/components/EntrySaveButton.js +101 -0
  32. package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
  33. package/dist/editor/components/NoEditPermissionNotice.js +1 -1
  34. package/dist/editor/components/RetryToastMessage.d.ts +3 -1
  35. package/dist/editor/components/RetryToastMessage.js +8 -4
  36. package/dist/editor/components/SaveAllButton.d.ts +25 -0
  37. package/dist/editor/components/SaveAllButton.js +81 -0
  38. package/dist/editor/components/UnsavedMark.d.ts +1 -1
  39. package/dist/editor/components/UnsavedMark.js +1 -1
  40. package/dist/editor/components/index.d.ts +1 -1
  41. package/dist/editor/components/index.js +1 -1
  42. package/dist/editor/copy.d.ts +0 -1
  43. package/dist/editor/copy.js +0 -1
  44. package/dist/editor/editor-utils.d.ts +2 -2
  45. package/dist/editor/editor-utils.js +2 -2
  46. package/dist/editor/entry-search.d.ts +10 -16
  47. package/dist/editor/entry-search.js +4 -24
  48. package/dist/editor/hooks/editor-history.d.ts +7 -0
  49. package/dist/editor/hooks/editor-history.js +80 -0
  50. package/dist/editor/hooks/useBranchActions.js +3 -1
  51. package/dist/editor/hooks/useBranchManager.d.ts +7 -3
  52. package/dist/editor/hooks/useBranchManager.js +18 -11
  53. package/dist/editor/hooks/useEntryManager.d.ts +12 -2
  54. package/dist/editor/hooks/useEntryManager.js +141 -20
  55. package/dist/editor/shell/ActivityRail.d.ts +4 -0
  56. package/dist/editor/shell/ActivityRail.js +13 -4
  57. package/dist/editor/shell/EditorShell.d.ts +6 -7
  58. package/dist/editor/shell/EditorShell.js +9 -13
  59. package/dist/editor/shell/PaneLayout.d.ts +1 -0
  60. package/dist/editor/shell/PaneLayout.js +23 -5
  61. package/dist/editor/shell/SidePanelHost.d.ts +11 -4
  62. package/dist/editor/shell/SidePanelHost.js +95 -7
  63. package/dist/editor/shell/TopBar.d.ts +4 -4
  64. package/dist/editor/shell/TopBar.js +6 -3
  65. package/dist/editor/theme.js +22 -1
  66. package/dist/editor/utils/focus-return.d.ts +10 -2
  67. package/dist/editor/utils/focus-return.js +40 -6
  68. package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
  69. package/dist/operating-mode/client-safe-strategy.js +1 -10
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/dist/editor/components/BranchLockBanner.d.ts +0 -23
  75. package/dist/editor/components/BranchLockBanner.js +0 -66
  76. package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
  77. package/dist/editor/components/QuickOpenPalette.js +0 -71
  78. package/dist/editor/components/SaveSplitButton.d.ts +0 -31
  79. 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';
@@ -62,6 +62,7 @@ import { findFieldTarget, focusFieldTarget } from './field-target.js';
62
62
  import { UndoToastMessage } from './components/UndoToastMessage.js';
63
63
  import { getNotificationDuration } from './utils/env.js';
64
64
  import { MayHaveLandedError } from './unanswered.js';
65
+ import { sanitizeBranchName } from '../paths/branch-name.js';
65
66
  // Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
66
67
  // while no entry is open.
67
68
  const EMPTY_SCHEMA = [];
@@ -80,6 +81,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
80
81
  const [groupManagerOpen, setGroupManagerOpen] = useState(false);
81
82
  const [permissionManagerOpen, setPermissionManagerOpen] = useState(false);
82
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
+ };
83
91
  const [mediaLibraryOpen, setMediaLibraryOpen] = useState(false);
84
92
  const [systemHealthOpen, setSystemHealthOpen] = useState(false);
85
93
  const [collectionEditorOpen, setCollectionEditorOpen] = useState(false);
@@ -95,8 +103,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
95
103
  const [restoredCollectionInput, setRestoredCollectionInput] = useState(null);
96
104
  // Each open or close of the collection dialog moves it on, so a slow open answers only if current.
97
105
  const collectionOpeningRef = useRef(0);
98
- const [quickOpenOpen, setQuickOpenOpen] = useState(false);
99
- const [quickOpenFromOverlay, setQuickOpenFromOverlay] = useState(false);
100
106
  const navigatorToggleRef = useRef(null);
101
107
  const mediaButtonRef = useRef(null);
102
108
  const branchToggleRef = useRef(null);
@@ -105,10 +111,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
105
111
  const branchOpenerRef = useRef(null);
106
112
  // Null when the panel opened from a menu item, which unmounts on click, or with focus on the page.
107
113
  const navigatorOpenerRef = useRef(null);
108
- const quickOpenOpenRef = useRef(false);
109
- useEffect(() => {
110
- quickOpenOpenRef.current = quickOpenOpen;
111
- }, [quickOpenOpen]);
114
+ const entryFilterRef = useRef(null);
112
115
  const [renameModalOpen, setRenameModalOpen] = useState(false);
113
116
  const [renamingEntry, setRenamingEntry] = useState(null);
114
117
  const [renameModalError, setRenameModalError] = useState(null);
@@ -157,7 +160,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
157
160
  labels: [],
158
161
  }));
159
162
  const applySubmitStampsRef = useRef(() => { });
160
- 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({
161
164
  initialBranch: branchName,
162
165
  operatingMode,
163
166
  setBusy: setBranchesLoading,
@@ -179,9 +182,23 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
179
182
  // loading state during that window anyway.
180
183
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
181
184
  const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
182
- // A row's lock reason on an unanswered branch would be "details could not be loaded", which is
183
- // not yet true; once the fetch has failed it is, and the lock says so.
184
- 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';
200
+ // Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
201
+ const switchBranchRef = useRef(async () => { });
185
202
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
186
203
  const { selectedPath, setSelectedPath, closeEntry, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, entriesLoadFailed, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
187
204
  initialEntries: entries,
@@ -197,7 +214,18 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
197
214
  }),
198
215
  resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
199
216
  setBusy: setEntriesLoading,
217
+ branchBarState,
218
+ switchBranch: (branch) => switchBranchRef.current(branch),
219
+ // To the entry menu, which names the entry opened; a dialog keeps focus.
220
+ onHistoryRestore: () => requestAnimationFrame(() => {
221
+ if (!document.activeElement?.closest('[role="dialog"]')) {
222
+ navigatorToggleRef.current?.focus({ preventScroll: true });
223
+ }
224
+ }),
200
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;
201
229
  // The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
202
230
  // overlay's. Anything that focuses something in the form reveals it first. Opening an entry
203
231
  // closes the overlay only, so the preview stays maximized across entries (see
@@ -296,6 +324,17 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
296
324
  onBranchCreated: addCreatedBranch,
297
325
  userId: userContext?.userId,
298
326
  });
327
+ switchBranchRef.current = async (branch) => {
328
+ const listed = (name) => name === branch || name === sanitizeBranchName(branch);
329
+ if (branchSummaries.length > 0 && !branchSummaries.some((b) => listed(b.name))) {
330
+ notifications.show({
331
+ message: `Branch “${branch}” no longer exists or you can no longer open it, so you're still on “${branchNameState}”.`,
332
+ color: 'yellow',
333
+ });
334
+ throw new Error(`Branch ${branch} is not listed`);
335
+ }
336
+ await handleBranchChange(branch);
337
+ };
299
338
  // 5. Comment system (depends on branchNameState)
300
339
  const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
301
340
  branchName: branchNameState,
@@ -304,11 +343,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
304
343
  currentUser: commentUserId,
305
344
  canResolveComments: canResolveAnyComment,
306
345
  setSelectedPath,
307
- setBranchManagerOpen: (open) => {
308
- if (open)
309
- branchOpenerRef.current = rememberOpener();
310
- setBranchManagerOpen(open);
311
- },
346
+ setBranchManagerOpen: (open) => open ? openBranchesDrawer(false) : setBranchManagerOpen(false),
312
347
  onCommentsChange: setCommentsForBranchSummaries,
313
348
  onRevealForm: revealForm,
314
349
  });
@@ -885,8 +920,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
885
920
  setDeletingEntryReferencedBy(result.referencedBy);
886
921
  return;
887
922
  }
888
- if (result.ok && selectedPath === deletingEntryPath)
889
- closeEntry();
923
+ if (result.ok)
924
+ closeEntry(deletingEntryPath, branchNameState);
890
925
  closeDeleteEntryModal();
891
926
  }
892
927
  finally {
@@ -1005,6 +1040,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1005
1040
  title: entry.title,
1006
1041
  slug: entry.slug,
1007
1042
  status: entryStatusLabel(activeCollections, entry),
1043
+ route: buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
1008
1044
  missing: entry.status === 'missing' || undefined,
1009
1045
  noPage: !entry.previewSrc || undefined,
1010
1046
  contentId: entry.contentId,
@@ -1057,16 +1093,29 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1057
1093
  onCreateEntry,
1058
1094
  handleCreateEntry,
1059
1095
  contentRoot,
1096
+ previewBaseByCollection,
1060
1097
  currentBranch,
1061
1098
  schemaUnavailableKeys,
1062
1099
  userIsAdmin,
1063
1100
  ]);
1064
- const quickOpenShortcut = modKeyLabel() === '⌘' ? '⌘P' : 'Ctrl+P';
1065
- 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);
1066
1108
  // Docked, the content panel's open state is the stored preference; as an overlay it is
1067
1109
  // `navigatorOpen`, which starts open when the URL asks for no entry.
1068
- const contentPanelShown = contentPanelDocked ? contentPanelOpen : navigatorOpen;
1069
- 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
+ };
1070
1119
  // The overlay lives in the form pane, under a maximized preview.
1071
1120
  const contentOverlayShown = !contentPanelDocked && contentPanelShown;
1072
1121
  useEffect(() => {
@@ -1075,30 +1124,78 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1075
1124
  }, [contentOverlayShown]);
1076
1125
  // Bumped on every ask for the panel, so one already open still takes focus.
1077
1126
  const [contentPanelFocusRequest, setContentPanelFocusRequest] = useState(0);
1078
- const openNavigator = () => {
1079
- 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
+ }
1080
1135
  // In the commit that opens the overlay, so the form panel is no longer inert when the overlay
1081
1136
  // mounts, then or a frame later as its slide starts, and serves its focus request.
1082
1137
  if (!contentPanelDocked)
1083
1138
  setPreviewMaximized(false);
1084
- 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);
1085
1145
  setContentPanelFocusRequest((request) => request + 1);
1086
1146
  };
1087
- // A collection crumb opens the panel as the rail's Content button does, then its collection's row
1088
- // takes focus.
1089
- 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();
1090
1179
  // Only ever rises: the navigator serves each seq once, and the request is cleared once served.
1091
1180
  const revealSeqRef = useRef(0);
1092
- const revealCollection = (path) => {
1181
+ const revealInContentPanel = (reveal) => {
1093
1182
  openNavigator();
1094
1183
  revealSeqRef.current += 1;
1095
- setCollectionReveal({ path, seq: revealSeqRef.current });
1184
+ setContentReveal({ ...reveal, seq: revealSeqRef.current });
1096
1185
  };
1097
1186
  // An ask the panel closed on before serving it must not fire when the panel next opens.
1098
1187
  useEffect(() => {
1099
1188
  if (!contentPanelShown)
1100
- setCollectionReveal(undefined);
1189
+ setContentReveal(undefined);
1101
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
+ };
1102
1199
  const runSaveAll = () => {
1103
1200
  void handleSaveAll()
1104
1201
  .then((result) => {
@@ -1123,18 +1220,12 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1123
1220
  const navigatorReturnTarget = () => returnTarget(navigatorOpenerRef.current, () => navigatorToggleRef.current);
1124
1221
  const branchReturnTarget = () => returnTarget(branchOpenerRef.current, () => branchToggleRef.current);
1125
1222
  const settingsReturnTarget = () => settingsToggleRef.current;
1126
- // The palette replaces the overlay panel; a docked one stays. Opened from inside the overlay,
1127
- // whose tree then unmounts, it returns focus to the panel's opener.
1128
- const openQuickOpen = () => {
1129
- if (!quickOpenOpen) {
1130
- const panel = document.getElementById(CONTENT_PANEL_ID);
1131
- setQuickOpenFromOverlay(!contentPanelDocked && navigatorOpen && !!panel?.contains(document.activeElement));
1132
- }
1133
- if (!contentPanelDocked)
1134
- setNavigatorOpen(false);
1135
- setQuickOpenOpen(true);
1136
- };
1137
- 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]);
1138
1229
  // Slugs of the entries beside the one being renamed, for the duplicate check.
1139
1230
  const renameExistingSlugs = useMemo(() => new Set(entriesState
1140
1231
  .filter((e) => e.collectionPath === renamingEntry?.collectionPath &&
@@ -1183,17 +1274,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1183
1274
  collectionName: e.collectionName,
1184
1275
  })),
1185
1276
  }), [entriesState]);
1186
- // Quick open names each entry's type and route under its label.
1187
- const quickOpenEntries = useMemo(() => entriesState.map((e) => ({
1188
- path: e.path,
1189
- label: e.label,
1190
- title: e.title,
1191
- slug: e.slug,
1192
- collectionPath: e.collectionPath,
1193
- collectionName: e.collectionName,
1194
- typeLabel: entryTypeLine(activeCollections, e),
1195
- route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
1196
- })), [entriesState, activeCollections, previewBaseByCollection, contentRoot]);
1197
1277
  const CenteredMessage = ({ children }) => (_jsx("div", { style: {
1198
1278
  display: 'flex',
1199
1279
  alignItems: 'center',
@@ -1219,6 +1299,54 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1219
1299
  if (!previewCanMaximize)
1220
1300
  setPreviewMaximized(false);
1221
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
+ ]);
1222
1350
  const [paneResizing, setPaneResizing] = useState(false);
1223
1351
  const previewErrorField = previewError?.fieldPath
1224
1352
  ? fieldBreadcrumb(schema, displayValue, previewError.fieldPath).join(' › ')
@@ -1240,13 +1368,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1240
1368
  display: 'flex',
1241
1369
  alignItems: 'center',
1242
1370
  justifyContent: 'center',
1243
- }, 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
1244
1372
  ? 'Setting up your branch workspace…'
1245
1373
  : entriesInitializing
1246
1374
  ? 'Loading content…'
1247
1375
  : currentEntry && !currentEntry.previewSrc
1248
1376
  ? 'No preview for this entry.'
1249
- : 'Select an item to start editing.' }) }));
1377
+ : 'Select an item to start editing.' })) }));
1250
1378
  const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(currentEntry, collectionLabels), [collectionLabels, currentEntry]);
1251
1379
  // The browser tab names the open entry and the site.
1252
1380
  const tabTitle = currentEntry
@@ -1266,9 +1394,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1266
1394
  document.title = tabTitle;
1267
1395
  writtenTitleRef.current = tabTitle;
1268
1396
  }, [tabTitle]);
1269
- const openBranchManager = () => {
1270
- branchOpenerRef.current = rememberOpener();
1271
- setBranchManagerOpen(true);
1397
+ const openBranchManager = (options) => {
1398
+ openBranchesDrawer(options?.create === true);
1272
1399
  // Always retry, not only when branchless. Opening the manager is
1273
1400
  // the one in-app retry for a failed branches fetch, and the fetch
1274
1401
  // is terminal on its own: SWRProvider sets shouldRetryOnError and
@@ -1276,12 +1403,19 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1276
1403
  // is 0 without data. Reloading is idempotent.
1277
1404
  loadBranches().catch(console.error);
1278
1405
  };
1279
- 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,
1280
- // Quick open takes focus as it replaces the overlay.
1281
- 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 &&
1282
1414
  navCollections.length > 0 &&
1283
1415
  !branchContentLocked &&
1284
- (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
1285
1419
  ? undefined
1286
1420
  : entriesState.map((e) => ({
1287
1421
  path: e.path,
@@ -1289,85 +1423,97 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1289
1423
  title: e.title,
1290
1424
  slug: e.slug,
1291
1425
  status: entryStatusLabel(activeCollections, e),
1426
+ route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
1292
1427
  missing: e.status === 'missing' || undefined,
1293
1428
  noPage: !e.previewSrc || undefined,
1294
1429
  readOnly: e.canEdit === false || undefined,
1295
- })), 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
1296
1431
  ? undefined
1297
1432
  : describeBranchLock({
1298
1433
  branchStatus: currentBranch?.status,
1299
1434
  branchReadOnly: currentBranch?.readOnly,
1300
1435
  branchWriteBlocked: branchContentLocked,
1301
- }), dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1302
- 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,
1303
- // A draft kept for an unavailable entry stays in storage but cannot be saved.
1304
- 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,
1305
1445
  // Fail-closed for the same reason branchContentLocked above is:
1306
- // isProtected/writeBlocked gate real actions in EditorHeader (hiding
1307
- // 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
1308
1448
  // "protected"/"blocked", not "unprotected"/"unblocked".
1309
1449
  // branchWriteBlocked reuses branchContentLocked itself rather than
1310
1450
  // re-deriving `?? true` a second time here, so there is exactly one
1311
1451
  // fail-closed computation of "are writes blocked" in this component.
1312
- branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
1313
- // A failed fetch leaves the list empty too; the banner says that one.
1314
- 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: () => {
1315
1453
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1316
- }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1317
- singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
1318
- 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,
1319
1457
  // Save all finds each draft's entry in the list, so with none listed it would save nothing.
1320
- entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: () => {
1458
+ entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: (returnFocusTo) => {
1321
1459
  if (branchNameState && !isSaveAllRunning()) {
1322
- handleSubmit(branchNameState).catch(console.error);
1460
+ handleSubmit(branchNameState, returnFocusTo).catch(console.error);
1323
1461
  }
1324
- }, onWithdraw: () => {
1462
+ }, onWithdraw: (returnFocusTo) => {
1325
1463
  if (branchNameState && !isSaveAllRunning()) {
1326
- handleWithdraw(branchNameState).catch(console.error);
1464
+ handleWithdraw(branchNameState, returnFocusTo).catch(console.error);
1327
1465
  }
1328
- } }), 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() &&
1329
1471
  branchNameState
1330
1472
  ? () => setCommentsPanelOpen(true)
1331
- : 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,
1332
- // Sizing the pane, by pointer or key, gives the page the pane's width.
1333
- onSeparatorDrag: (dragging) => {
1334
- setPaneResizing(dragging);
1335
- if (dragging)
1336
- setPreviewWidth('fit');
1337
- }, arrangement: layout, sideSplitPercent: sideSplitPercent, stackedSplitPercent: stackedSplitPercent, onSplitPercentChange: (arrangement, percent) => {
1338
- setSplitPercent(arrangement, percent);
1339
- setPreviewWidth('fit');
1340
- }, preview: renderPreview && currentEntry && !currentEntryUnavailable
1341
- ? renderPreview(currentEntry, displayValue)
1342
- : defaultPreview, form: !currentEntry ? (_jsx(CenteredMessage, { children: !branchNameState && busy
1343
- ? 'Setting up your branch workspace…'
1344
- : entriesInitializing
1345
- ? 'Loading content…'
1346
- : '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) ||
1347
- currentBranch?.status === 'archived',
1348
- // A hidden draft's discard changes nothing on screen, so it says so.
1349
- onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1350
- announce: true,
1351
- }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1352
- title: currentEntry.label,
1353
- typeLabel: entryTypeLabel(activeCollections, currentEntry),
1354
- },
1355
- // The one writer of drafts. Read-only content gets none, whatever a
1356
- // field emits (MDXEditor's mount-time normalisation included).
1357
- onChange: (next) => {
1358
- const contentId = currentEntry?.contentId;
1359
- if (contentId && !contentReadOnlyRef.current) {
1360
- // Every list item keeps a key, whatever field made it.
1361
- const keyed = withItemKeys(schema, next, 'random');
1362
- setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
1363
- }
1364
- }, 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 &&
1365
- 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: () => {
1366
- setLoadFailedKey(null);
1367
- setLoadAttempt((attempt) => attempt + 1);
1368
- }, children: "Retry" }) })) }) }), overlays: _jsxs(_Fragment, { children: [_jsx(QuickOpenPalette, { opened: quickOpenOpen, onOpen: openQuickOpen, onClose: () => setQuickOpenOpen(false), returnFocusTo: quickOpenFromOverlay ? navigatorReturnTarget : undefined, entries: quickOpenEntries,
1369
- // The hidden root's entries sit at the top of the tree, so they get no heading here either.
1370
- 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) => {
1371
1517
  try {
1372
1518
  await handleBranchChange(name);
1373
1519
  setBranchManagerOpen(false);
@@ -1390,7 +1536,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1390
1536
  handleRequestChanges(name).catch((err) => console.error(err));
1391
1537
  }, onDelete: (name) => {
1392
1538
  handleDelete(name).catch((err) => console.error(err));
1393
- }, 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
1394
1540
  ? (path, entryType) => addEntryType(path, entryType)
1395
1541
  : undefined, onUpdateEntryType: editingCollection
1396
1542
  ? (path, name, updates) => updateEntryType(path, name, updates)