canopycms 0.1.0 → 0.2.0-int.119

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 (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -23,7 +23,7 @@ import { SystemHealthPanel } from './admin/SystemHealthPanel.js';
23
23
  // Import directly from helpers to avoid server-only code in authorization barrel
24
24
  import { isAdmin, isPrivileged, isReviewer } from '../authorization/helpers.js';
25
25
  import { fieldBreadcrumb } from './field-breadcrumb.js';
26
- import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, } from './editor-utils.js';
26
+ import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, entryTypeLine, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
27
27
  import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
28
28
  import { useBranchActions } from './hooks/useBranchActions.js';
29
29
  import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
@@ -52,7 +52,7 @@ import { hideCollectionOutcomeUnknown } from './hooks/useSchemaManager.js';
52
52
  import { AssetContextProvider, SiteMdxAllowContext, authenticatedAssetBase, useApiClient, } from './context/index.js';
53
53
  import { EntryLinkContext } from './fields/entry-link/index.js';
54
54
  import { MediaLibrary } from './media/MediaLibrary.js';
55
- import { removalAnchors, restoreRemoval, openThreadsWithin, restoredFieldPath, } from './undo-removal.js';
55
+ import { removalAnchors, restoreRemoval, movedThreadsNote, openThreadsWithin, restoredFieldPath, } from './undo-removal.js';
56
56
  import { formatCanopyPath } from './canopy-path.js';
57
57
  import { itemKeysOnPath, resolveThreadPath, withItemKeys } from '../item-keys.js';
58
58
  import { ENTRY_NOT_LOADED_COMMENT_MESSAGE, NEW_ITEM_COMMENT_MESSAGE, } from './comments/item-anchor-copy.js';
@@ -122,7 +122,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
122
122
  const [deleteInProgress, setDeleteInProgress] = useState(false);
123
123
  // A rename or delete asked for from the top bar's entry menu, whose item is gone once it acts.
124
124
  const [entryChangeFromMenu, setEntryChangeFromMenu] = useState(false);
125
- const allContentCrumbRef = useRef(null);
125
+ const contentToggleRef = useRef(null);
126
126
  // Draft compile/render error reported by the preview page (null = renders cleanly)
127
127
  const [previewError, setPreviewError] = useState(null);
128
128
  const apiClient = useApiClient();
@@ -140,12 +140,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
140
140
  const commentUserId = userContext?.userId ?? currentUser;
141
141
  // Below 64em (1024px) the editor starts stacked and the content panel never docks.
142
142
  const wideWindow = useMediaQuery('(min-width: 64em)', true, { getInitialValueInEffect: false }) !== false;
143
- const { loaded: layoutLoaded, layout, setLayout, highlightEnabled, setHighlightEnabled, sideSplitPercent, stackedSplitPercent, setSplitPercent, previewWidth, setPreviewWidth, contentPanelOpen, setContentPanelOpen, contentPanelWidth, setContentPanelWidth, } = useEditorLayout({
143
+ const { loaded: layoutLoaded, layout, setLayout, highlightEnabled, setHighlightEnabled, sideSplitPercent, stackedSplitPercent, setSplitPercent, previewWidth, setPreviewWidth, siteChrome, setSiteChrome, contentPanelOpen, setContentPanelOpen, contentPanelWidth, setContentPanelWidth, } = useEditorLayout({
144
144
  storageKey: editorLayoutStorageKey(basePath),
145
145
  defaultLayout: wideWindow ? 'side' : 'stacked',
146
146
  });
147
- // In the stacked layout the content panel lays over the form only, so the preview stays in view.
148
- const contentPanelDocked = layout === 'side' && wideWindow;
147
+ // A wide window docks the content panel: beside the panes side by side, and beside the form row
148
+ // only when stacked, so the preview keeps its full width. A narrow one lays it over the form.
149
+ const contentPanelDocked = wideWindow;
149
150
  // Comments state (shared between useCommentSystem and useBranchManager)
150
151
  const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
151
152
  // 1. Branch manager (provides branchNameState, no dependencies)
@@ -188,6 +189,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
188
189
  contentRoot,
189
190
  basePath,
190
191
  }),
192
+ resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
191
193
  setBusy: setEntriesLoading,
192
194
  });
193
195
  // The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
@@ -204,8 +206,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
204
206
  setNavigatorOpen(false);
205
207
  };
206
208
  // An open overlay docks open, as the URL's ask for no entry does at load; `navigatorOpen` is only
207
- // the overlay's, so a docked panel never resurfaces as one. Waits for the stored layout, whose
208
- // arrival could otherwise dock an overlay the stacked layout keeps.
209
+ // the overlay's, so a docked panel never resurfaces as one. Waits for the stored preferences, so
210
+ // the open state it sets lands on them, not on the defaults they replace.
209
211
  useEffect(() => {
210
212
  if (layoutLoaded && contentPanelDocked && navigatorOpen) {
211
213
  setContentPanelOpen(true);
@@ -305,7 +307,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
305
307
  contentReadOnlyRef.current = contentReadOnly;
306
308
  // Undo for a form Remove: a toast whose Undo puts the item back into the entry's draft as it
307
309
  // is then, through a functional update, so edits made since are kept. It acts only on the
308
- // branch and entry it was raised for, and its toast closes when either changes.
310
+ // branch and entry it was raised for, and its toast closes when either changes. An object's
311
+ // Clear offers its Undo in the form instead (FormRenderer), which a save ends via `savedValue`.
309
312
  const loadedValuesRef = useRef(loadedValues);
310
313
  loadedValuesRef.current = loadedValues;
311
314
  const draftsRef = useRef(drafts);
@@ -332,7 +335,6 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
332
335
  ...removal,
333
336
  anchors: displayValue ? removalAnchors(fields, displayValue, removal) : undefined,
334
337
  };
335
- const doing = removal.action === 'clear' ? 'clearing' : 'removing';
336
338
  // Open threads keyed inside what was removed leave their fields for the entry's thread list.
337
339
  const movedThreads = currentEntry
338
340
  ? openThreadsWithin(displayComments, currentEntry.path, removal.kind === 'list-item' ? [...removal.listPath, removal.index] : removal.path)
@@ -350,7 +352,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
350
352
  undoToastsRef.current.delete(id);
351
353
  if (!open) {
352
354
  notifications.show({
353
- message: `Too late to undo ${doing} ${removal.label}.`,
355
+ message: `Too late to undo removing ${removal.label}.`,
354
356
  color: 'yellow',
355
357
  });
356
358
  return;
@@ -363,7 +365,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
363
365
  const now = draftsRef.current[contentId] ?? loadedValuesRef.current[contentId];
364
366
  if (!now || !restoreRemoval(fields, now, anchored)) {
365
367
  notifications.show({
366
- message: `Couldn't undo ${doing} ${removal.label}: that part of the entry has changed. ${removal.action === 'clear' ? 'Fill it in again' : 'Add it again'} if you still need it.`,
368
+ message: `Couldn't undo removing ${removal.label}: that part of the entry has changed. Add it again if you still need it.`,
367
369
  color: 'yellow',
368
370
  });
369
371
  return;
@@ -385,9 +387,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
385
387
  undoToastsRef.current.set(id, contentId);
386
388
  const toast = {
387
389
  id,
388
- message: (_jsx(UndoToastMessage, { label: removal.label, action: removal.action, note: movedThreads > 0
389
- ? `Its ${movedThreads === 1 ? 'comment thread' : `${movedThreads} comment threads`} moved to "Comment on this entry".`
390
- : undefined, onUndo: undo, onHold: () => notifications.update({ ...toast, autoClose: false }), onRelease: () => notifications.update(toast) })),
390
+ message: (_jsx(UndoToastMessage, { label: removal.label, note: movedThreadsNote(movedThreads), onUndo: undo, onHold: () => notifications.update({ ...toast, autoClose: false }), onRelease: () => notifications.update(toast) })),
391
391
  autoClose: getNotificationDuration(8000),
392
392
  withCloseButton: true,
393
393
  onClose: () => undoToastsRef.current.delete(id),
@@ -976,8 +976,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
976
976
  list.push({
977
977
  path: entry.path,
978
978
  label: entry.label,
979
+ title: entry.title,
979
980
  slug: entry.slug,
980
981
  status: entryStatusLabel(activeCollections, entry),
982
+ missing: entry.status === 'missing' || undefined,
983
+ noPage: !entry.previewSrc || undefined,
981
984
  contentId: entry.contentId,
982
985
  readOnly: entry.canEdit === false || undefined,
983
986
  conflictNotice: !!(entry.contentId && currentBranch?.conflictFiles?.includes(entry.contentId)),
@@ -1048,13 +1051,15 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1048
1051
  const [contentPanelFocusRequest, setContentPanelFocusRequest] = useState(0);
1049
1052
  const openNavigator = () => {
1050
1053
  navigatorOpenerRef.current = rememberOpener();
1051
- // In the same commit as the overlay mounts, so its focus request meets no inert form panel.
1054
+ // In the commit that opens the overlay, so the form panel is no longer inert when the overlay
1055
+ // mounts, then or a frame later as its slide starts, and serves its focus request.
1052
1056
  if (!contentPanelDocked)
1053
1057
  setPreviewMaximized(false);
1054
1058
  setContentPanelShown(true);
1055
1059
  setContentPanelFocusRequest((request) => request + 1);
1056
1060
  };
1057
- // A collection crumb opens the panel as "All content" does, then its collection's row takes focus.
1061
+ // A collection crumb opens the panel as the rail's Content button does, then its collection's row
1062
+ // takes focus.
1058
1063
  const [collectionReveal, setCollectionReveal] = useState();
1059
1064
  // Only ever rises: the navigator serves each seq once, and the request is cleared once served.
1060
1065
  const revealSeqRef = useRef(0);
@@ -1084,9 +1089,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1084
1089
  })
1085
1090
  .catch(console.error);
1086
1091
  };
1087
- // The entry menu's trigger stands in for its item; once the entry is deleted, "All content".
1092
+ // The entry menu's trigger stands in for its item; once the entry is deleted, the rail's Content
1093
+ // button, where another entry is found.
1088
1094
  const entryChangeReturnTarget = entryChangeFromMenu
1089
- ? () => navigatorToggleRef.current ?? allContentCrumbRef.current
1095
+ ? () => navigatorToggleRef.current ?? contentToggleRef.current
1090
1096
  : undefined;
1091
1097
  const navigatorReturnTarget = () => returnTarget(navigatorOpenerRef.current, () => navigatorToggleRef.current);
1092
1098
  const branchReturnTarget = () => returnTarget(branchOpenerRef.current, () => branchToggleRef.current);
@@ -1145,11 +1151,23 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1145
1151
  entries: entriesState.map((e) => ({
1146
1152
  contentId: e.contentId,
1147
1153
  label: e.label,
1154
+ title: e.title,
1148
1155
  slug: e.slug,
1149
1156
  collectionPath: e.collectionPath,
1150
1157
  collectionName: e.collectionName,
1151
1158
  })),
1152
1159
  }), [entriesState]);
1160
+ // Quick open names each entry's type and route under its label.
1161
+ const quickOpenEntries = useMemo(() => entriesState.map((e) => ({
1162
+ path: e.path,
1163
+ label: e.label,
1164
+ title: e.title,
1165
+ slug: e.slug,
1166
+ collectionPath: e.collectionPath,
1167
+ collectionName: e.collectionName,
1168
+ typeLabel: entryTypeLine(activeCollections, e),
1169
+ route: buildPreviewRoute(e, { previewBaseByCollection, contentRoot }),
1170
+ })), [entriesState, activeCollections, previewBaseByCollection, contentRoot]);
1153
1171
  const CenteredMessage = ({ children }) => (_jsx("div", { style: {
1154
1172
  display: 'flex',
1155
1173
  alignItems: 'center',
@@ -1162,11 +1180,15 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1162
1180
  fields: schema,
1163
1181
  data: displayValue,
1164
1182
  });
1165
- // Only the default preview has the toolbar that maximizes and restores it. With no value to
1166
- // preview (an entry loading, or failed to load, with no draft) the form shows instead, and the
1167
- // choice reapplies once a value arrives.
1183
+ // Only the default preview has the toolbar that maximizes and restores it. It stays maximized
1184
+ // while an entry loads. With no value after a failed load and no draft, the form shows instead,
1185
+ // and the choice reapplies once a value arrives.
1168
1186
  const previewCanMaximize = !renderPreview && Boolean(currentEntry?.previewSrc) && !currentEntryUnavailable;
1169
- const previewShownMaximized = previewMaximized && previewCanMaximize && Boolean(previewFrameData);
1187
+ // The open entry's data is loading, not failed: the preview keeps its last page, dimmed.
1188
+ const previewPending = previewCanMaximize &&
1189
+ !previewFrameData &&
1190
+ loadFailedKey !== `${branchNameState}:${currentEntry?.contentId}`;
1191
+ const previewShownMaximized = previewMaximized && previewCanMaximize && (Boolean(previewFrameData) || previewPending);
1170
1192
  useEffect(() => {
1171
1193
  if (!previewCanMaximize)
1172
1194
  setPreviewMaximized(false);
@@ -1176,8 +1198,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1176
1198
  ? fieldBreadcrumb(schema, displayValue, previewError.fieldPath).join(' › ')
1177
1199
  : '';
1178
1200
  // An unavailable entry previews nothing: the site cannot read it, and a stored draft would
1179
- // otherwise mount the frame at a page that answers not-found.
1180
- const defaultPreview = currentEntry?.previewSrc && previewFrameData && !currentEntryUnavailable ? (_jsx(PreviewPane, { src: currentEntry.previewSrc, data: previewFrameData, isLoading: previewLoadingState, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError, onMarks: onPreviewMarks, assetBase: authenticatedAssetBase(basePath), mount: { basePath, previewPrefix }, previewWidth: previewWidth, onPreviewWidthChange: setPreviewWidth, onHighlightToggle: () => {
1201
+ // otherwise mount the frame at a page that answers not-found. The pane stays mounted while an
1202
+ // entry loads, so its toolbar never leaves on a switch.
1203
+ const defaultPreview = currentEntry?.previewSrc && (previewFrameData || previewPending) && !currentEntryUnavailable ? (_jsx(PreviewPane, { src: currentEntry.previewSrc, pending: previewPending, data: previewFrameData, isLoading: previewLoadingState, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError, onMarks: onPreviewMarks, assetBase: authenticatedAssetBase(basePath), mount: { basePath, previewPrefix }, previewWidth: previewWidth, onPreviewWidthChange: setPreviewWidth, onHighlightToggle: () => {
1181
1204
  setHighlightEnabled(!highlightEnabled);
1182
1205
  clearPreviewMarks();
1183
1206
  }, marksNothing: previewMarksNothing, inexactMarks: previewInexactMarks, showMarkDiagnostics: userIsAdmin, layout: layout, maximized: previewShownMaximized, onMaximizedChange: (maximized) => {
@@ -1185,7 +1208,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1185
1208
  if (maximized && !contentPanelDocked)
1186
1209
  setNavigatorOpen(false);
1187
1210
  setPreviewMaximized(maximized);
1188
- }, resizing: paneResizing, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1211
+ }, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1189
1212
  ? `${previewErrorField}: ${previewError.message}`
1190
1213
  : previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
1191
1214
  display: 'flex',
@@ -1237,8 +1260,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1237
1260
  : entriesState.map((e) => ({
1238
1261
  path: e.path,
1239
1262
  label: e.label,
1263
+ title: e.title,
1240
1264
  slug: e.slug,
1241
1265
  status: entryStatusLabel(activeCollections, e),
1266
+ missing: e.status === 'missing' || undefined,
1267
+ noPage: !e.previewSrc || undefined,
1242
1268
  readOnly: e.canEdit === false || undefined,
1243
1269
  })), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked, dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
1244
1270
  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,
@@ -1253,7 +1279,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1253
1279
  // fail-closed computation of "are writes blocked" in this component.
1254
1280
  branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
1255
1281
  // A failed fetch leaves the list empty too; the banner says that one.
1256
- branchLoading: !currentBranch && (branchesLoading || branchSummaries.length === 0), onNavigatorOpen: openNavigator, 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), allContentRef: allContentCrumbRef, entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
1282
+ branchLoading: !currentBranch && (branchesLoading || branchSummaries.length === 0), 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: () => {
1257
1283
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
1258
1284
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1259
1285
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
@@ -1267,14 +1293,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1267
1293
  if (branchNameState && !isSaveAllRunning()) {
1268
1294
  handleWithdraw(branchNameState).catch(console.error);
1269
1295
  }
1270
- } }), banner: _jsx(BranchLockBanner, { branchName: branchNameState, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, onBranchManagerOpen: openBranchManager }), rail: _jsx(EditorRail, { siteTitle: siteTitle, onHomeOpen: () => {
1271
- const first = entriesState[0];
1272
- if (first)
1273
- openEntry(first.path);
1274
- }, contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelShown ? setContentPanelShown(false) : openNavigator(), onQuickOpen: openQuickOpen, quickOpenShortcut: quickOpenShortcut, quickOpenKeys: quickOpenKeys, branchesOpen: branchManagerOpen, onBranchesOpen: openBranchManager, onCommentsOpen: clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
1296
+ } }), 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() &&
1275
1297
  branchNameState
1276
1298
  ? () => setCommentsPanelOpen(true)
1277
- : 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 ? contentPanel : undefined, main: _jsx(Box, { h: "100%", children: _jsx(PaneLayout, { formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
1299
+ : 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,
1278
1300
  // Sizing the pane, by pointer or key, gives the page the pane's width.
1279
1301
  onSeparatorDrag: (dragging) => {
1280
1302
  setPaneResizing(dragging);
@@ -1294,7 +1316,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1294
1316
  // A hidden draft's discard changes nothing on screen, so it says so.
1295
1317
  onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1296
1318
  announce: true,
1297
- }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1319
+ }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1298
1320
  title: currentEntry.label,
1299
1321
  typeLabel: entryTypeLabel(activeCollections, currentEntry),
1300
1322
  },
@@ -1311,7 +1333,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1311
1333
  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: () => {
1312
1334
  setLoadFailedKey(null);
1313
1335
  setLoadAttempt((attempt) => attempt + 1);
1314
- }, children: "Retry" }) })) }) }), overlays: _jsxs(_Fragment, { children: [_jsx(QuickOpenPalette, { opened: quickOpenOpen, onOpen: openQuickOpen, onClose: () => setQuickOpenOpen(false), returnFocusTo: quickOpenFromOverlay ? navigatorReturnTarget : undefined, entries: entriesState,
1336
+ }, children: "Retry" }) })) }) }), overlays: _jsxs(_Fragment, { children: [_jsx(QuickOpenPalette, { opened: quickOpenOpen, onOpen: openQuickOpen, onClose: () => setQuickOpenOpen(false), returnFocusTo: quickOpenFromOverlay ? navigatorReturnTarget : undefined, entries: quickOpenEntries,
1315
1337
  // The hidden root's entries sit at the top of the tree, so they get no heading here either.
1316
1338
  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) => {
1317
1339
  try {
@@ -1343,7 +1365,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1343
1365
  : undefined, onRemoveEntryType: editingCollection ? (path, name) => removeEntryType(path, name) : undefined, onReloadEntryTypes: editingCollection ? readEntryTypes : undefined, onClose: handleCloseCollectionEditor, isSaving: schemaLoading, error: collectionEditorError, restored: restoredCollectionInput, onDiscardRestored: () => {
1344
1366
  collectionDraftRef.current = null;
1345
1367
  setRestoredCollectionInput(null);
1346
- } })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit,
1368
+ } })), renamingEntry && (_jsx(RenameEntryModal, { isOpen: renameModalOpen, entryLabel: renamingEntry.label, nameSource: entryNameSource(activeCollections, renamingEntry, entryValue(renamingEntry)), hasPage: !!renamingEntry.previewSrc, currentSlug: renamingEntry.slug || '', onSave: handleRenameSubmit,
1347
1369
  // `renamingEntry` stays until the next rename, so the dialog plays its exit and
1348
1370
  // returns focus.
1349
1371
  onClose: () => {
@@ -5,9 +5,16 @@ type TreeController = ReturnType<typeof useTree>;
5
5
  export interface EntryNavItem {
6
6
  path: LogicalPath;
7
7
  label: string;
8
+ /** The entry's title, matched by the filter where the label is a singleton's type label. */
9
+ title?: string;
8
10
  /** Matched by the filter; the last segment of `path` when absent. */
9
11
  slug?: string;
12
+ /** The row's second line: the entry's type, or "Missing" (`entryStatusLabel`). */
10
13
  status?: string;
14
+ /** The entry's file is gone: its second line is error text. */
15
+ missing?: boolean;
16
+ /** The entry has no page on the site, so its menu changes its slug rather than its URL. */
17
+ noPage?: boolean;
11
18
  collectionPath?: LogicalPath;
12
19
  contentId?: ContentId;
13
20
  /** True when this entry's file conflicted during rebase */
@@ -46,7 +53,7 @@ export interface EntryNavigatorProps {
46
53
  onExpandedStateChange?: (state: Record<string, boolean>) => void;
47
54
  /** Called when user requests to delete an entry */
48
55
  onDeleteEntry?: (path: LogicalPath) => void;
49
- /** Called when user requests to rename an entry */
56
+ /** Called when the user asks to change an entry's URL (its slug) */
50
57
  onRenameEntry?: (path: LogicalPath) => void;
51
58
  /** Called when user reorders an entry within a collection */
52
59
  onReorderEntry?: (collectionPath: LogicalPath, contentId: string, direction: 'up' | 'down') => void;
@@ -5,7 +5,9 @@ import { ActionIcon, Badge, Box, Button, CloseButton, Group, Highlight, Loader,
5
5
  import { IconArrowDown, IconAlertTriangle, IconArrowUp, IconDots, IconEdit, IconFolderPlus, IconLock, IconPlus, IconSearch, IconTrash, } from '@tabler/icons-react';
6
6
  import { calculatePathToEntry } from './editor-utils.js';
7
7
  import { matchesEntryQuery } from './entry-search.js';
8
+ import { EDITOR_ACTIONS } from './copy.js';
8
9
  import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
10
+ import { UnsavedMark } from './components/UnsavedMark.js';
9
11
  import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
10
12
  import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
11
13
  const UnavailableTypeRow = ({ schemaRefs }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", align: "flex-start", mt: 4, "data-testid": "unavailable-type-message", children: [_jsx(Box, { c: "orange", style: { display: 'flex', flexShrink: 0, paddingTop: 2 }, children: _jsx(IconAlertTriangle, { size: 14, "aria-hidden": true }) }), _jsx(Text, { size: "xs", c: "orange", children: _jsx(UnavailableTypeMessage, { schemaRefs: schemaRefs }) })] }));
@@ -44,7 +46,8 @@ const filterTree = (nodes, query) => {
44
46
  for (const node of nodes) {
45
47
  const label = String(node.label ?? '');
46
48
  if (node.nodeProps?.isEntry) {
47
- if (matchesEntryQuery({ label, slug: node.nodeProps.slug }, query)) {
49
+ const { slug, title } = node.nodeProps;
50
+ if (matchesEntryQuery({ label, title, slug }, query)) {
48
51
  kept.push(node);
49
52
  }
50
53
  }
@@ -71,14 +74,19 @@ const firstEntryPath = (nodes, query) => {
71
74
  }
72
75
  };
73
76
  walk(nodes);
74
- const match = entries.find((node) => matchesEntryQuery({ label: String(node.label ?? ''), slug: node.nodeProps?.slug }, query));
77
+ const match = entries.find((node) => matchesEntryQuery({
78
+ label: String(node.label ?? ''),
79
+ title: node.nodeProps?.title,
80
+ slug: node.nodeProps?.slug,
81
+ }, query));
75
82
  return (match ?? entries[0])?.value;
76
83
  };
77
84
  /**
78
- * A row's title, truncated with an ellipsis. Its full text shows in a tooltip, and only while the
79
- * text is cut: the width is measured when the pointer or focus enters the row, not on every render.
85
+ * A line of a row, truncated with an ellipsis. Its full text shows in a tooltip, and only while
86
+ * the text is cut: the width is measured when the pointer or focus enters the row, not on every
87
+ * render.
80
88
  */
81
- const TruncatedLabel = ({ label, highlight, fw, }) => {
89
+ const TruncatedLabel = ({ label, highlight = '', fw, size = 'sm', c }) => {
82
90
  const elementRef = useRef(null);
83
91
  const [truncated, setTruncated] = useState(false);
84
92
  const setElement = useCallback((node) => {
@@ -100,7 +108,7 @@ const TruncatedLabel = ({ label, highlight, fw, }) => {
100
108
  row.removeEventListener('focusin', measure);
101
109
  };
102
110
  }, []);
103
- return (_jsx(Tooltip, { label: label, openDelay: 400, withinPortal: true, disabled: !truncated, children: highlight ? (_jsx(Highlight, { ref: setElement, highlight: highlight, size: "sm", fw: fw, truncate: "end", style: { minWidth: 0 }, children: label })) : (_jsx(Text, { ref: setElement, size: "sm", fw: fw, truncate: "end", style: { minWidth: 0 }, children: label })) }));
111
+ return (_jsx(Tooltip, { label: label, openDelay: 400, withinPortal: true, disabled: !truncated, children: highlight ? (_jsx(Highlight, { ref: setElement, highlight: highlight, size: size, fw: fw, truncate: "end", style: { minWidth: 0 }, children: label })) : (_jsx(Text, { ref: setElement, size: size, fw: fw, c: c, truncate: "end", style: { minWidth: 0 }, children: label })) }));
104
112
  };
105
113
  /** The keys Mantine's tree acts on, matched as it matches them, by `code`. */
106
114
  const TREE_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space']);
@@ -114,13 +122,7 @@ const stopTreeKeys = (event) => {
114
122
  if (TREE_KEYS.has(event.nativeEvent.code))
115
123
  event.stopPropagation();
116
124
  };
117
- const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(Box, { component: "span", role: "img", "aria-label": label, "data-testid": "unsaved-dot", style: {
118
- flexShrink: 0,
119
- width: 8,
120
- height: 8,
121
- borderRadius: '50%',
122
- backgroundColor: 'var(--mantine-color-brand-6)',
123
- } }) }));
125
+ const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
124
126
  export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, revealCollection, onRevealed, }) => {
125
127
  // Shadow the entry-mutation callbacks with undefined when readOnly, so every
126
128
  // downstream reference (hasEntryMenu, canReorder, the menu items themselves)
@@ -147,6 +149,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
147
149
  label: entry.label,
148
150
  nodeProps: {
149
151
  status: entry.status,
152
+ title: entry.title,
153
+ missing: entry.missing,
154
+ noPage: entry.noPage,
150
155
  isEntry: true,
151
156
  entryPath: entry.path,
152
157
  slug: entry.slug ?? lastSegment(entry.path),
@@ -168,6 +173,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
168
173
  label: entry?.label ?? col.label,
169
174
  nodeProps: {
170
175
  status: entry?.status,
176
+ title: entry?.title,
177
+ missing: entry?.missing,
178
+ noPage: entry?.noPage,
171
179
  isEntry: true,
172
180
  entryPath: entry?.path,
173
181
  collectionPath: col.path,
@@ -275,6 +283,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
275
283
  label: item.label,
276
284
  nodeProps: {
277
285
  status: item.status,
286
+ title: item.title,
287
+ missing: item.missing,
288
+ noPage: item.noPage,
278
289
  isEntry: true,
279
290
  entryPath: item.path,
280
291
  slug: item.slug ?? lastSegment(item.path),
@@ -463,6 +474,8 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
463
474
  const Chevron = ({ expanded, visible }) => (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": "true", style: { flexShrink: 0, opacity: visible ? 0.9 : 0 }, children: expanded ? (_jsx("path", { d: "M7.41 8.59 12 13.17 16.59 8.59 18 10l-6 6-6-6z" })) : (_jsx("path", { d: "M10 6 8.59 7.41 13.17 12 8.59 16.59 10 18l6-6z" })) }));
464
475
  const renderNode = ({ node, elementProps, hasChildren, expanded, level, }) => {
465
476
  const status = node.nodeProps?.status;
477
+ const entryMissing = !!node.nodeProps?.missing;
478
+ const entryNoPage = !!node.nodeProps?.noPage;
466
479
  // Collection-level mutation callbacks are nulled when readOnly, same as
467
480
  // the entry-level ones above -- this removes the collection menu button
468
481
  // entirely (hasCollectionMenu below) rather than leaving it enabled.
@@ -533,7 +546,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
533
546
  flexShrink: 0,
534
547
  alignItems: 'center',
535
548
  justifyContent: 'center',
536
- }, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }), status && (_jsx(Badge, { size: "xs", variant: "light", color: "gray", style: { flexShrink: 0, maxWidth: 96 }, children: status })), conflictNotice && (_jsx(Tooltip, { label: "This content was updated on the base branch \u2014 a reviewer will reconcile", withArrow: true, children: _jsx(Badge, { size: "xs", variant: "light", color: "orange", "data-testid": "conflict-badge", style: { flexShrink: 0 }, children: "Conflict" }) })), entryLocked && (_jsx(Tooltip, { label: "You can't edit this entry", withinPortal: true, children: _jsx(Box, { component: "span", role: "img", "aria-label": "You can't edit this entry", "data-testid": "entry-lock", c: "dimmed", style: { display: 'inline-flex', flexShrink: 0 }, children: _jsx(IconLock, { size: 14, "aria-hidden": true }) }) }))] }), _jsxs(Group, { gap: 4, wrap: "nowrap", style: { flexShrink: 0 }, children: [hasCollectionMenu && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Collection actions", "data-testid": `collection-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: (event) => {
549
+ }, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsxs(Stack, { gap: 0, style: { flex: 1, minWidth: 0 }, children: [_jsxs(Group, { gap: 6, wrap: "nowrap", style: { minWidth: 0 }, children: [_jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }), conflictNotice && (_jsx(Tooltip, { label: "This content was updated on the base branch \u2014 a reviewer will reconcile", withArrow: true, children: _jsx(Badge, { size: "xs", variant: "light", color: "orange", "data-testid": "conflict-badge", style: { flexShrink: 0 }, children: "Conflict" }) })), entryLocked && (_jsx(Tooltip, { label: "You can't edit this entry", withinPortal: true, children: _jsx(Box, { component: "span", role: "img", "aria-label": "You can't edit this entry", "data-testid": "entry-lock", c: "dimmed", style: { display: 'inline-flex', flexShrink: 0 }, children: _jsx(IconLock, { size: 14, "aria-hidden": true }) }) }))] }), status && (_jsx(Box, { "data-testid": "entry-nav-type", style: { minWidth: 0 }, children: _jsx(TruncatedLabel, { label: status, size: "xs",
550
+ // A missing file is an error, in error text, not a dimmed type.
551
+ c: entryMissing ? 'var(--canopy-danger-text)' : 'dimmed' }) }))] })] }), _jsxs(Group, { gap: 4, wrap: "nowrap", style: { flexShrink: 0 }, children: [hasCollectionMenu && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Collection actions", "data-testid": `collection-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: (event) => {
537
552
  event.stopPropagation();
538
553
  onAdd();
539
554
  }, "data-testid": "add-entry-menu-item", children: "Add entry\u2026" })), canReorder && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
@@ -551,10 +566,10 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
551
566
  }, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
552
567
  event.stopPropagation();
553
568
  onDelete();
554
- }, children: "Delete collection" }))] })] })), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsx(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
569
+ }, children: "Delete collection" }))] })] })), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsxs(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
555
570
  event.stopPropagation();
556
571
  onRenameEntry(entryPath);
557
- }, "data-testid": "rename-entry-menu-item", children: "Rename\u2026" })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
572
+ }, "data-testid": "rename-entry-menu-item", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
558
573
  event.stopPropagation();
559
574
  onReorderEntry(parentCollectionPath, contentId, 'up');
560
575
  }, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
@@ -9,6 +9,7 @@ export interface OutlineNode {
9
9
  children?: OutlineNode[];
10
10
  }
11
11
  export interface FormHeaderProps {
12
+ /** The entry's name, for assistive tech only: the top bar's breadcrumb shows it. */
12
13
  title: string;
13
14
  /** The entry type's label, shown as a badge. */
14
15
  typeLabel?: string;
@@ -19,7 +20,7 @@ export interface FormHeaderProps {
19
20
  onExpandAll?: (expanded: boolean) => void;
20
21
  }
21
22
  /**
22
- * The form's sticky header: which entry this is, an outline of its fields to jump to, and
23
+ * The form's sticky header: the entry's type, an outline of its fields to jump to, and
23
24
  * Expand all / Collapse all for every list and block field in it.
24
25
  */
25
26
  export declare const FormHeader: React.FC<FormHeaderProps>;
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
4
4
  import { ActionIcon, Badge, Box, Button, Group, Popover, ScrollArea, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
5
  import { useIsomorphicEffect } from '@mantine/hooks';
6
6
  import { IconChevronsDown, IconChevronsUp, IconListTree } from '@tabler/icons-react';
7
+ import { PANE_HEADER_HEIGHT } from './pane-header.js';
7
8
  /** The padding of the scroller the form sits in, which `shell/PaneLayout.tsx` sets; 0 where unset. */
8
9
  const PANE_PADDING = 'var(--canopy-form-pane-padding, 0px)';
9
10
  /** Below this header width "Outline" shows its icon alone, so the controls never clip. */
@@ -28,7 +29,7 @@ const OutlineList = ({ nodes, field, nested = false, onChoose }) => (_jsx("ul",
28
29
  return (_jsxs("li", { children: [node.path === undefined ? (_jsx(Text, { size: "xs", fw: 600, c: "dimmed", px: "xs", pt: 6, pb: 2, children: node.label })) : (_jsx(UnstyledButton, { onClick: () => onChoose(node.path), "aria-label": field ? `${field} › ${name}` : undefined, display: "block", w: "100%", py: 4, px: "xs", style: { borderRadius: 'var(--mantine-radius-sm)' }, className: "canopy-outline-item", "data-testid": "form-outline-item", children: _jsxs(Text, { size: "sm", truncate: "end", fw: field ? undefined : 500, children: [node.label, node.secondary && (_jsxs(Text, { span: true, c: "dimmed", size: "sm", children: [' ', node.secondary] }))] }) })), node.children && node.children.length > 0 && (_jsx(OutlineList, { nodes: node.children, field: node.path === undefined ? undefined : node.label, nested: true, onChoose: onChoose }))] }, node.path ?? `group-${idx}`));
29
30
  }) }));
30
31
  /**
31
- * The form's sticky header: which entry this is, an outline of its fields to jump to, and
32
+ * The form's sticky header: the entry's type, an outline of its fields to jump to, and
32
33
  * Expand all / Collapse all for every list and block field in it.
33
34
  */
34
35
  export const FormHeader = ({ title, typeLabel, outline, onSelect, onExpandAll, }) => {
@@ -63,12 +64,15 @@ export const FormHeader = ({ title, typeLabel, outline, onSelect, onExpandAll, }
63
64
  top: `calc(-1 * ${PANE_PADDING})`,
64
65
  zIndex: 3,
65
66
  margin: `calc(-1 * ${PANE_PADDING}) calc(-1 * ${PANE_PADDING}) 0`,
66
- padding: `8px ${PANE_PADDING}`,
67
+ display: 'flex',
68
+ alignItems: 'center',
69
+ minHeight: PANE_HEADER_HEIGHT,
70
+ padding: `2px ${PANE_PADDING}`,
67
71
  backgroundColor: 'var(--canopy-surface-muted)',
68
72
  borderBottom: '1px solid var(--mantine-color-default-border)',
69
- }, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [_jsx(Text, { fw: 600, size: "sm", truncate: "end", style: { minWidth: 0 }, children: title }), typeLabel && (_jsx(Badge, { variant: "light", color: "gray", size: "sm", tt: "none",
70
- // Gives way before the title in a narrow pane.
71
- style: { flexShrink: 2, minWidth: 0 }, children: typeLabel })), _jsxs(Group, { gap: 4, wrap: "nowrap", ml: "auto", style: { flexShrink: 0 }, children: [_jsxs(Popover, { opened: outlineOpen, onChange: setOutlineOpen, position: "bottom-end", width: 300, shadow: "md", withinPortal: true,
73
+ }, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", style: { flex: 1, minWidth: 0 }, children: [_jsx(VisuallyHidden, { "data-testid": "form-header-title", children: title }), typeLabel && (_jsx(Badge, { variant: "light", color: "gray", size: "sm", tt: "none",
74
+ // Gives way before the controls in a narrow pane.
75
+ style: { minWidth: 0 }, children: typeLabel })), _jsxs(Group, { gap: 4, wrap: "nowrap", ml: "auto", style: { flexShrink: 0 }, children: [_jsxs(Popover, { opened: outlineOpen, onChange: setOutlineOpen, position: "bottom-end", width: 300, shadow: "md", withinPortal: true,
72
76
  // Focus enters the list; Escape hands it back to "Outline" (`closeOnEscape`). A chosen
73
77
  // field takes it instead, so closing returns it nowhere.
74
78
  trapFocus: true, returnFocus: false, children: [_jsx(Tooltip, { label: "Outline", disabled: !compact, opened: compact ? undefined : false, children: _jsx(Popover.Target, { children: _jsx(Button, { variant: "subtle", color: "gray", c: "dimmed", size: "compact-sm", px: compact ? 6 : undefined, leftSection: _jsx(IconListTree, { size: 14, "aria-hidden": true }), styles: compact ? { section: { marginInlineEnd: 0 } } : undefined, onClick: () => setOutlineOpen((open) => !open), "data-testid": "form-outline-button", children: compact ? _jsx(VisuallyHidden, { children: "Outline" }) : 'Outline' }) }) }), _jsx(Popover.Dropdown, { p: 4, children: _jsx(ScrollArea.Autosize, { mah: 400, type: "auto", children: _jsx("nav", { "aria-label": "Outline", "data-testid": "form-outline", children: _jsx(OutlineList, { nodes: outline, onChoose: (path) => {
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import type { ContentFormat, EntrySchema, FieldConfig } from '../config/index.js';
3
3
  import type { CommentThread } from '../comment-store.js';
4
4
  import type { UserSearchResult } from '../auth/types.js';
5
- import type { FieldRemoval } from './undo-removal.js';
5
+ import { type FieldRemoval } from './undo-removal.js';
6
6
  export type FormValue = Record<string, unknown>;
7
7
  export interface CustomFieldRenderProps {
8
8
  field: FieldConfig;
@@ -41,8 +41,13 @@ export interface FormRendererProps {
41
41
  format?: ContentFormat;
42
42
  /** Shows the value without accepting edits. Comments stay open. */
43
43
  readOnly?: boolean;
44
- /** Told of each list item, block or image Remove takes out, or object Clear resets, for Undo. */
44
+ /** Told of each list item, block or image Remove takes out, for Undo. */
45
45
  onRemoved?: (removal: FieldRemoval) => void;
46
+ /**
47
+ * The entry's saved copy, which `value` is while there is no draft. A new one (a save, a reload)
48
+ * or showing it unchanged (a discard) ends every object's Undo clear.
49
+ */
50
+ savedValue?: FormValue;
46
51
  /** Resolves comment authors to names in field and entry threads; without it they show as ids. */
47
52
  onGetUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
48
53
  /**