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,14 +1,16 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { ActionIcon, Badge, Box, Button, CloseButton, Group, Highlight, Loader, Menu, ScrollArea, Stack, Text, TextInput, Tooltip, Tree, useTree, rem, } from '@mantine/core';
4
+ import { ActionIcon, Badge, Box, Button, CloseButton, Group, Highlight, Loader, Menu, ScrollArea, SegmentedControl, Stack, Text, TextInput, Tooltip, Tree, useTree, rem, } from '@mantine/core';
5
+ import { useMergedRef } from '@mantine/hooks';
5
6
  import { IconArrowDown, IconAlertTriangle, IconArrowUp, IconDots, IconEdit, IconFolderPlus, IconLock, IconPlus, IconSearch, IconTrash, } from '@tabler/icons-react';
6
7
  import { calculatePathToEntry } from './editor-utils.js';
7
- import { matchesEntryQuery } from './entry-search.js';
8
+ import { entryDetailLine, matchesEntryQuery } from './entry-search.js';
8
9
  import { EDITOR_ACTIONS } from './copy.js';
9
10
  import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
10
11
  import { UnsavedMark } from './components/UnsavedMark.js';
11
12
  import { BranchRowLock } from './components/BranchRowLock.js';
13
+ import { BranchNotAvailableNotice } from './components/BranchNotAvailableNotice.js';
12
14
  import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
13
15
  import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
14
16
  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 }) })] }));
@@ -38,31 +40,37 @@ const expandedStateFor = (nodes, acc = {}) => {
38
40
  return acc;
39
41
  };
40
42
  /**
41
- * The nodes of `nodes` that match `query`. An entry matches on label or slug; a collection whose
42
- * label matches keeps its whole subtree, and any other collection survives only with its matching
43
- * descendants. Nodes keep the position data (`childIndex`, `totalChildrenCount`) of the full tree.
43
+ * The nodes of `nodes` that match `query` and, given `changed`, are in it. An entry matches on
44
+ * label, title or slug; a collection whose label matches keeps its whole subtree, or under `changed` its
45
+ * changed entries, and any other collection survives only with its matching descendants. Nodes keep
46
+ * the position data (`childIndex`, `totalChildrenCount`) of the full tree.
44
47
  */
45
- const filterTree = (nodes, query) => {
48
+ const filterTree = (nodes, query, changed) => {
46
49
  const kept = [];
47
50
  for (const node of nodes) {
48
51
  const label = String(node.label ?? '');
49
52
  if (node.nodeProps?.isEntry) {
50
53
  const { slug, title } = node.nodeProps;
51
- if (matchesEntryQuery({ label, title, slug }, query)) {
54
+ if (matchesEntryQuery({ label, title, slug }, query) &&
55
+ (!changed || changed.has(node.value))) {
52
56
  kept.push(node);
53
57
  }
58
+ continue;
54
59
  }
55
- else if (matchesEntryQuery({ label }, query)) {
60
+ // An empty query matches every label, so under Changed a collection is never kept whole.
61
+ const labelMatches = matchesEntryQuery({ label }, query);
62
+ if (labelMatches && !changed) {
56
63
  kept.push(node);
64
+ continue;
57
65
  }
58
- else {
59
- const children = filterTree(node.children ?? [], query);
60
- if (children.length > 0)
61
- kept.push({ ...node, children });
62
- }
66
+ const children = filterTree(node.children ?? [], labelMatches ? '' : query, changed);
67
+ if (children.length > 0)
68
+ kept.push({ ...node, children });
63
69
  }
64
70
  return kept;
65
71
  };
72
+ /** Whether `value` is a node of `nodes`, at any depth. */
73
+ const hasNode = (nodes, value) => nodes.some((node) => node.value === value || hasNode(node.children ?? [], value));
66
74
  /** The first entry in tree order that matches `query`, else the first entry of any kind. */
67
75
  const firstEntryPath = (nodes, query) => {
68
76
  const entries = [];
@@ -82,10 +90,11 @@ const firstEntryPath = (nodes, query) => {
82
90
  }, query));
83
91
  return (match ?? entries[0])?.value;
84
92
  };
93
+ const TOOLTIP_DELAY = 400;
85
94
  /**
86
- * A line of a row, truncated with an ellipsis. Its full text shows in a tooltip, and only while
87
- * the text is cut: the width is measured when the pointer or focus enters the row, not on every
88
- * render.
95
+ * A line of a row, truncated with an ellipsis. Its full text shows in a tooltip on hover, and only
96
+ * while the text is cut: the width is measured when the pointer or focus enters the row, not on
97
+ * every render.
89
98
  */
90
99
  const TruncatedLabel = ({ label, highlight = '', fw, size = 'sm', c }) => {
91
100
  const elementRef = useRef(null);
@@ -102,14 +111,88 @@ const TruncatedLabel = ({ label, highlight = '', fw, size = 'sm', c }) => {
102
111
  const element = elementRef.current;
103
112
  setTruncated(!!element && element.scrollWidth > element.clientWidth);
104
113
  };
114
+ // The tree focuses the row's treeitem, which also holds the rows nested under it.
115
+ const item = row.closest('[role="treeitem"]') ?? row;
116
+ const focusIn = (event) => {
117
+ const target = event.target;
118
+ if (target === item || (target instanceof Node && row.contains(target)))
119
+ measure();
120
+ };
105
121
  row.addEventListener('mouseenter', measure);
106
- row.addEventListener('focusin', measure);
122
+ item.addEventListener('focusin', focusIn);
107
123
  return () => {
108
124
  row.removeEventListener('mouseenter', measure);
109
- row.removeEventListener('focusin', measure);
125
+ item.removeEventListener('focusin', focusIn);
110
126
  };
111
127
  }, []);
112
- 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 })) }));
128
+ return (_jsx(Tooltip, { label: label, openDelay: TOOLTIP_DELAY, 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 })) }));
129
+ };
130
+ /**
131
+ * A row's cut-off detail line in full once keyboard focus has rested on the row for
132
+ * `TOOLTIP_DELAY`. Anchored to the whole row, below its focus ring or, without room, above it, so
133
+ * it never covers the row's title; Escape hides it until focus next arrives (WCAG 1.4.13).
134
+ */
135
+ const RowDetailTip = ({ detail, children, }) => {
136
+ const rowRef = useRef(null);
137
+ const [shown, setShown] = useState(false);
138
+ useEffect(() => {
139
+ const row = rowRef.current;
140
+ const item = row?.closest('[role="treeitem"]');
141
+ if (!row || !item || !detail)
142
+ return;
143
+ let timer;
144
+ let open = false;
145
+ // While shown it also marks the row, so PreviewPane's Escape leaves the key to it.
146
+ const show = (next) => {
147
+ open = next;
148
+ setShown(next);
149
+ if (next)
150
+ item.setAttribute('data-mantine-stop-propagation', 'true');
151
+ else
152
+ item.removeAttribute('data-mantine-stop-propagation');
153
+ };
154
+ const cutOff = () => {
155
+ const line = row.querySelector('[data-testid="entry-nav-detail"] > *');
156
+ return !!line && line.scrollWidth > line.clientWidth;
157
+ };
158
+ const onFocusIn = (event) => {
159
+ if (event.target !== item)
160
+ return;
161
+ clearTimeout(timer);
162
+ // Not after a click, which focuses the treeitem too and would pin it over the next row.
163
+ timer = setTimeout(() => show(item.matches(':focus-visible') && cutOff()), TOOLTIP_DELAY);
164
+ };
165
+ const onFocusOut = (event) => {
166
+ if (event.target !== item)
167
+ return;
168
+ clearTimeout(timer);
169
+ show(false);
170
+ };
171
+ // Handled, so a panel's own Escape waits for the next press.
172
+ const onKeyDown = (event) => {
173
+ if (event.key !== 'Escape' || event.target !== item)
174
+ return;
175
+ clearTimeout(timer);
176
+ if (!open)
177
+ return;
178
+ event.preventDefault();
179
+ show(false);
180
+ };
181
+ item.addEventListener('focusin', onFocusIn);
182
+ item.addEventListener('focusout', onFocusOut);
183
+ item.addEventListener('keydown', onKeyDown);
184
+ return () => {
185
+ item.removeEventListener('focusin', onFocusIn);
186
+ item.removeEventListener('focusout', onFocusOut);
187
+ item.removeEventListener('keydown', onKeyDown);
188
+ clearTimeout(timer);
189
+ show(false);
190
+ };
191
+ }, [detail]);
192
+ // Rendered without a detail too, so the row keeps its place in the tree as one arrives.
193
+ return (_jsx(Tooltip, { ref: rowRef, label: detail ?? '', disabled: !detail, opened: shown, position: "bottom-start",
194
+ // Clears the row's 2px focus ring and its 2px offset.
195
+ offset: 8, withinPortal: true, children: children }));
113
196
  };
114
197
  /** The keys Mantine's tree acts on, matched as it matches them, by `code`. */
115
198
  const TREE_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space']);
@@ -124,7 +207,7 @@ const stopTreeKeys = (event) => {
124
207
  event.stopPropagation();
125
208
  };
126
209
  const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
127
- export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, readOnlyReason, dirtyPaths, revealCollection, onRevealed, }) => {
210
+ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, readOnlyReason, dirtyPaths, reveal, onRevealed, onChangedOnlyChange, changedOnly: changedOnlyProp = false, unsavedCount: unsavedCountProp = 0, unsavedEntries, entryListFailed = false, branchNotAvailable = false, onSwitchBranch, onFilterEnter, onFilterEscape, onRowMenuOpen, filterRef, filterShortcut, }) => {
128
211
  // Shadow the entry-mutation callbacks with undefined when readOnly, so every
129
212
  // downstream reference (hasEntryMenu, canReorder, the menu items themselves)
130
213
  // sees them as absent rather than threading a readOnly check through each
@@ -138,7 +221,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
138
221
  const localExpandedStateRef = useRef(expandedStateRef?.current ?? {});
139
222
  const [query, setQuery] = useState('');
140
223
  const filterInputRef = useRef(null);
141
- const filtering = query.trim() !== '';
224
+ const mergedFilterRef = useMergedRef(filterInputRef, filterRef);
225
+ const changedOnly = changedOnlyProp && !!onChangedOnlyChange;
226
+ const filtering = query.trim() !== '' || changedOnly;
142
227
  // Read from the tree's expand/collapse callbacks, which fire from state updaters.
143
228
  const filteringRef = useRef(false);
144
229
  useEffect(() => {
@@ -150,6 +235,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
150
235
  label: entry.label,
151
236
  nodeProps: {
152
237
  status: entry.status,
238
+ route: entry.route,
153
239
  title: entry.title,
154
240
  missing: entry.missing,
155
241
  noPage: entry.noPage,
@@ -174,6 +260,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
174
260
  label: entry?.label ?? col.label,
175
261
  nodeProps: {
176
262
  status: entry?.status,
263
+ route: entry?.route,
177
264
  title: entry?.title,
178
265
  missing: entry?.missing,
179
266
  noPage: entry?.noPage,
@@ -284,6 +371,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
284
371
  label: item.label,
285
372
  nodeProps: {
286
373
  status: item.status,
374
+ route: item.route,
287
375
  title: item.title,
288
376
  missing: item.missing,
289
377
  noPage: item.noPage,
@@ -296,19 +384,28 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
296
384
  }, [collections, items, hiddenRootPath]);
297
385
  // Filtered from the finished tree, so each node keeps its position in the whole collection and
298
386
  // Move up / Move down stay correct while a filter is on.
299
- const filteredTree = useMemo(() => (filtering ? filterTree(treeData, query) : treeData), [filtering, treeData, query]);
387
+ const filteredTree = useMemo(() => filtering
388
+ ? filterTree(treeData, query, changedOnly ? (dirtyPaths ?? new Set()) : undefined)
389
+ : treeData, [filtering, treeData, query, changedOnly, dirtyPaths]);
390
+ // A caller may pass a new but equal `dirtyPaths` as drafts change: that alone never reopens a
391
+ // folder the user closed, while a new query or new folders re-expand.
392
+ const filteredFolders = useMemo(() => Object.keys(expandedStateFor(filteredTree)).join('\n'), [filteredTree]);
300
393
  // Collections that hold an entry with unsaved changes, found over the whole tree so a filter
301
394
  // never hides the reason for a dot.
302
- const dirtyCollections = useMemo(() => {
395
+ // `listedUnsaved` counts the unsaved entries the tree has rows for.
396
+ const { dirtyCollections, listedUnsaved } = useMemo(() => {
303
397
  const dirty = new Set();
398
+ let listed = 0;
304
399
  if (!dirtyPaths?.size)
305
- return dirty;
400
+ return { dirtyCollections: dirty, listedUnsaved: listed };
306
401
  const walk = (nodes) => {
307
402
  let any = false;
308
403
  for (const node of nodes) {
309
404
  if (node.nodeProps?.isEntry) {
310
- if (dirtyPaths.has(node.value))
405
+ if (dirtyPaths.has(node.value)) {
311
406
  any = true;
407
+ listed += 1;
408
+ }
312
409
  }
313
410
  else if (walk(node.children ?? [])) {
314
411
  dirty.add(node.value);
@@ -318,8 +415,21 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
318
415
  return any;
319
416
  };
320
417
  walk(treeData);
321
- return dirty;
418
+ return { dirtyCollections: dirty, listedUnsaved: listed };
322
419
  }, [treeData, dirtyPaths]);
420
+ // Unsaved entries the tree has no row for, which Changed offers beneath it.
421
+ const unlistedNamed = useMemo(() => {
422
+ if (!changedOnly || !unsavedEntries?.length)
423
+ return [];
424
+ const rows = new Set();
425
+ const walk = (nodes) => nodes.forEach((node) => {
426
+ rows.add(node.value);
427
+ walk(node.children ?? []);
428
+ });
429
+ walk(treeData);
430
+ return unsavedEntries.filter((entry) => !rows.has(entry.path));
431
+ }, [changedOnly, unsavedEntries, treeData]);
432
+ const unlistedMatches = useMemo(() => unlistedNamed.filter((entry) => matchesEntryQuery({ label: entry.label }, query)), [unlistedNamed, query]);
323
433
  // The hidden root's own row never renders, so its message is shown once above the tree.
324
434
  const hiddenRootAddRefused = !readOnly &&
325
435
  !!hiddenRootPath &&
@@ -365,6 +475,8 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
365
475
  // The expanded state from before the filter started, put back exactly when it clears. The tree
366
476
  // drops the state of nodes it no longer renders, so it cannot be recovered from the tree itself.
367
477
  const expandedBeforeFilterRef = useRef(null);
478
+ const servedRevealRef = useRef();
479
+ const revealAwaitsRestoreRef = useRef(false);
368
480
  useEffect(() => {
369
481
  if (filtering) {
370
482
  expandedBeforeFilterRef.current ??= tree.expandedState;
@@ -372,14 +484,21 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
372
484
  }
373
485
  else if (expandedBeforeFilterRef.current) {
374
486
  // Plus the ancestors of an entry picked while filtering, so it stays in view.
375
- tree.setExpandedState({
487
+ const next = {
376
488
  ...expandedBeforeFilterRef.current,
377
489
  ...calculatePathToEntry(selectedPath, treeData),
378
- });
490
+ };
491
+ tree.setExpandedState(next);
379
492
  expandedBeforeFilterRef.current = null;
493
+ // A reveal that cleared the filter added its row's folders, kept as its own expansion is.
494
+ if (reveal && servedRevealRef.current !== reveal.seq) {
495
+ revealAwaitsRestoreRef.current = true;
496
+ localExpandedStateRef.current = next;
497
+ onExpandedStateChange?.(next);
498
+ }
380
499
  }
381
- // eslint-disable-next-line react-hooks/exhaustive-deps -- runs when the filter or the filtered tree changes; `tree` is a fresh object each render
382
- }, [filtering, filteredTree]);
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- runs when the filter, its query or its folders change; `tree` is a fresh object each render
501
+ }, [filtering, filteredFolders, query.trim()]);
383
502
  useEffect(() => {
384
503
  if (!selectedPath || !treeData || filtering)
385
504
  return;
@@ -415,25 +534,53 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
415
534
  hasScrolledRef.current = false;
416
535
  }, []);
417
536
  const treeBoxRef = useRef(null);
418
- const servedRevealRef = useRef();
419
537
  // Runs again as the filter clears and the expansion lands, then focuses the row a frame later.
420
538
  useEffect(() => {
421
- if (!revealCollection || !treeData || servedRevealRef.current === revealCollection.seq)
539
+ if (!reveal || !treeData || servedRevealRef.current === reveal.seq) {
540
+ revealAwaitsRestoreRef.current = false;
422
541
  return;
542
+ }
423
543
  const served = () => {
424
- servedRevealRef.current = revealCollection.seq;
544
+ servedRevealRef.current = reveal.seq;
425
545
  onRevealed?.();
426
546
  };
427
- if (query) {
547
+ if (reveal.kind === 'changed') {
428
548
  setQuery('');
549
+ served();
429
550
  return;
430
551
  }
431
- const value = collectionNodeValue(treeData, revealCollection.path);
552
+ // An empty tree that is still loading gets its rows; this runs again when they arrive.
553
+ if (treeData.length === 0 && loading)
554
+ return;
555
+ // This commit's expanded state predates the restore's, which re-runs this effect.
556
+ if (revealAwaitsRestoreRef.current) {
557
+ revealAwaitsRestoreRef.current = false;
558
+ return;
559
+ }
560
+ const value = reveal.kind === 'collection'
561
+ ? collectionNodeValue(treeData, reveal.path)
562
+ : hasNode(treeData, reveal.path)
563
+ ? reveal.path
564
+ : undefined;
432
565
  if (!value) {
433
566
  served();
434
567
  return;
435
568
  }
436
- const wanted = { ...calculatePathToEntry(value, treeData), [value]: true };
569
+ const wanted = {
570
+ ...calculatePathToEntry(value, treeData),
571
+ ...(reveal.kind === 'collection' ? { [value]: true } : {}),
572
+ };
573
+ if (filtering) {
574
+ // The filter's restore expands the row's folders along with what was open before it.
575
+ expandedBeforeFilterRef.current = {
576
+ ...(expandedBeforeFilterRef.current ?? tree.expandedState),
577
+ ...wanted,
578
+ };
579
+ setQuery('');
580
+ if (changedOnly)
581
+ onChangedOnlyChange?.(false);
582
+ return;
583
+ }
437
584
  if (Object.keys(wanted).some((key) => !tree.expandedState[key])) {
438
585
  const next = { ...tree.expandedState, ...wanted };
439
586
  tree.setExpandedState(next);
@@ -449,7 +596,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
449
596
  });
450
597
  return () => cancelAnimationFrame(frame);
451
598
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `tree` and the callbacks are fresh each render
452
- }, [revealCollection, treeData, query, tree.expandedState]);
599
+ }, [reveal, treeData, filtering, loading, tree.expandedState]);
453
600
  const clearFilter = () => {
454
601
  setQuery('');
455
602
  filterInputRef.current?.focus();
@@ -458,17 +605,20 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
458
605
  // Enter and Escape during IME composition commit or cancel the composition, nothing more.
459
606
  if (event.nativeEvent.isComposing)
460
607
  return;
461
- if (event.key === 'Escape' && query) {
462
- // Clears the filter and nothing else: the surrounding drawer stays open.
608
+ if (event.key === 'Escape' && (query || onFilterEscape)) {
609
+ // A query clears and nothing else: the surrounding drawer stays open.
463
610
  event.preventDefault();
464
611
  event.stopPropagation();
465
- setQuery('');
612
+ if (query)
613
+ setQuery('');
614
+ else
615
+ onFilterEscape?.();
466
616
  }
467
617
  else if (event.key === 'Enter' && filtering) {
468
- const path = firstEntryPath(filteredTree, query);
618
+ const path = firstEntryPath(filteredTree, query) ?? unlistedMatches[0]?.path;
469
619
  if (path) {
470
620
  event.preventDefault();
471
- onSelect(path);
621
+ (onFilterEnter ?? onSelect)(path);
472
622
  }
473
623
  }
474
624
  };
@@ -477,6 +627,10 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
477
627
  const status = node.nodeProps?.status;
478
628
  const entryMissing = !!node.nodeProps?.missing;
479
629
  const entryNoPage = !!node.nodeProps?.noPage;
630
+ const route = node.nodeProps?.route;
631
+ const detail = entryMissing
632
+ ? status
633
+ : entryDetailLine({ typeLabel: status, route: entryNoPage ? undefined : route });
480
634
  // Collection-level mutation callbacks are nulled when readOnly, same as the entry-level ones
481
635
  // above, so hasCollectionMenu is false; a row that would have had a menu shows its lock.
482
636
  const rawOnAdd = node.nodeProps?.onAdd;
@@ -522,8 +676,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
522
676
  !entryUnavailable &&
523
677
  !entryLocked &&
524
678
  (onDeleteEntry || onRenameEntry || onReorderEntry);
525
- // The lock stands where the menu would, on exactly the rows that would otherwise have one. It
526
- // is always shown: on hover alone, touch and keyboard users would see neither menu nor reason.
679
+ // The lock holds the slot where the menu would be, on exactly the rows that would otherwise have
680
+ // one, so rows never shift. The stylesheet reveals it on the hovered, focused or selected
681
+ // row (`data-nav-selected`), which keeps touch and keyboard users' route to the reason.
527
682
  const wouldHaveCollectionMenu = isCollection &&
528
683
  !!(rawOnAdd ||
529
684
  rawOnEdit ||
@@ -540,66 +695,85 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
540
695
  const showCollectionDirtyDot = !isEntry && dirtyCollections.has(node.value);
541
696
  const adminItems = onAddSubCollection || onEdit || onDelete;
542
697
  const hasCollectionContentItems = onAdd || canReorder;
543
- return (_jsxs(Box, { ...elementProps, ref: selected ? selectedNodeRef : undefined, "data-nav-row": true, "data-testid": `entry-nav-item-${testIdSuffix}`, onClick: (event) => {
544
- elementProps.onClick(event);
545
- if (isLeaf && isEntry) {
546
- onSelect(node.value);
547
- }
548
- }, style: {
549
- ...elementProps.style,
550
- marginBottom: 4,
551
- borderRadius: 10,
552
- paddingInline: 10,
553
- paddingBlock: 6,
554
- paddingLeft: `calc(${rem(8)} + ${rem(level * 12)})`,
555
- backgroundColor: selected ? 'var(--mantine-primary-color-light)' : undefined,
556
- color: selected ? 'var(--mantine-primary-color-light-color)' : undefined,
557
- border: selected
558
- ? '1px solid var(--mantine-primary-color-light-color)'
559
- : '1px solid transparent',
560
- transition: 'background-color 120ms ease, border-color 120ms ease',
561
- }, children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", style: { width: '100%' }, children: [_jsxs(Group, { gap: 6, wrap: "nowrap", style: { flex: 1, minWidth: 0 }, children: [_jsx(Box, { w: 18, h: 18, style: {
562
- display: 'flex',
563
- flexShrink: 0,
564
- alignItems: 'center',
565
- justifyContent: 'center',
566
- }, 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",
567
- // A missing file is an error, in error text, not a dimmed type.
568
- 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) => {
569
- event.stopPropagation();
570
- onAdd();
571
- }, "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) => {
572
- event.stopPropagation();
573
- onReorderEntry?.(parentCollectionPath, contentId, 'up');
574
- }, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
575
- event.stopPropagation();
576
- onReorderEntry?.(parentCollectionPath, contentId, 'down');
577
- }, children: "Move down" })] })), adminItems && (_jsxs(_Fragment, { children: [hasCollectionContentItems && _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Admin" })] })), onAddSubCollection && (_jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: (event) => {
578
- event.stopPropagation();
579
- onAddSubCollection();
580
- }, children: "Add sub-collection\u2026" })), onEdit && (_jsx(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
581
- event.stopPropagation();
582
- onEdit();
583
- }, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
584
- event.stopPropagation();
585
- onDelete();
586
- }, children: "Delete collection" }))] })] })), showsLockSlot &&
587
- (readOnlyReason ? (_jsx(BranchRowLock, { label: isCollection ? 'Collection actions' : 'Entry actions', reason: readOnlyReason })) : (
588
- // The branch's state is still loading: hold the slot so rows do not shift.
589
- _jsx(Box, { w: 18, h: 18, "aria-hidden": true, "data-testid": "branch-lock-pending" }))), 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) => {
590
- event.stopPropagation();
591
- onRenameEntry(entryPath);
592
- }, "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) => {
593
- event.stopPropagation();
594
- onReorderEntry(parentCollectionPath, contentId, 'up');
595
- }, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
596
- event.stopPropagation();
597
- onReorderEntry(parentCollectionPath, contentId, 'down');
598
- }, children: "Move down" })] })), onDeleteEntry && (_jsxs(_Fragment, { children: [(onRenameEntry || (onReorderEntry && contentId && parentCollectionPath)) && (_jsx(Menu.Divider, {})), _jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
599
- event.stopPropagation();
600
- onDeleteEntry(entryPath);
601
- }, "data-testid": "delete-entry-menu-item", children: "Delete entry" })] }))] })] }))] })] }), isCollection && unavailableSchemaRefs && unavailableSchemaRefs.length > 0 && (_jsx(UnavailableTypeRow, { schemaRefs: unavailableSchemaRefs })), isCollection && addRefused && _jsx(AddRefusedRow, {})] }));
698
+ return (_jsx(RowDetailTip, { detail: detail, children: _jsxs(Box, { ...elementProps, ref: selected ? selectedNodeRef : undefined, "data-nav-row": true, "data-nav-selected": selected ? '' : undefined, "data-testid": `entry-nav-item-${testIdSuffix}`, onClick: (event) => {
699
+ elementProps.onClick(event);
700
+ if (isLeaf && isEntry) {
701
+ onSelect(node.value);
702
+ }
703
+ }, style: {
704
+ ...elementProps.style,
705
+ marginBottom: 4,
706
+ borderRadius: 10,
707
+ paddingInline: 10,
708
+ paddingBlock: 6,
709
+ paddingLeft: `calc(${rem(8)} + ${rem(level * 12)})`,
710
+ backgroundColor: selected ? 'var(--mantine-primary-color-light)' : undefined,
711
+ color: selected ? 'var(--mantine-primary-color-light-color)' : undefined,
712
+ border: selected
713
+ ? '1px solid var(--mantine-primary-color-light-color)'
714
+ : '1px solid transparent',
715
+ transition: 'background-color 120ms ease, border-color 120ms ease',
716
+ }, children: [_jsxs(Group, { gap: "xs", justify: "space-between", wrap: "nowrap", style: { width: '100%' }, children: [_jsxs(Group, { gap: 6, wrap: "nowrap", style: { flex: 1, minWidth: 0 }, children: [_jsx(Box, { w: 18, h: 18, style: {
717
+ display: 'flex',
718
+ flexShrink: 0,
719
+ alignItems: 'center',
720
+ justifyContent: 'center',
721
+ }, 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 }) }) }))] }), detail && (_jsx(Box, { "data-testid": "entry-nav-detail", style: { minWidth: 0 }, children: _jsx(TruncatedLabel, { label: detail, size: "xs",
722
+ // A missing file is an error, in error text, not a dimmed type.
723
+ 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", onOpen: onRowMenuOpen, 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) => {
724
+ event.stopPropagation();
725
+ onAdd();
726
+ }, "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) => {
727
+ event.stopPropagation();
728
+ onReorderEntry?.(parentCollectionPath, contentId, 'up');
729
+ }, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
730
+ event.stopPropagation();
731
+ onReorderEntry?.(parentCollectionPath, contentId, 'down');
732
+ }, children: "Move down" })] })), adminItems && (_jsxs(_Fragment, { children: [hasCollectionContentItems && _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Admin" })] })), onAddSubCollection && (_jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: (event) => {
733
+ event.stopPropagation();
734
+ onAddSubCollection();
735
+ }, children: "Add sub-collection\u2026" })), onEdit && (_jsx(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
736
+ event.stopPropagation();
737
+ onEdit();
738
+ }, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
739
+ event.stopPropagation();
740
+ onDelete();
741
+ }, children: "Delete collection" }))] })] })), showsLockSlot &&
742
+ (readOnlyReason ? (_jsx(BranchRowLock, { label: isCollection ? 'Collection actions' : 'Entry actions', reason: readOnlyReason })) : (
743
+ // The branch's state is still loading: hold the slot so rows do not shift. It draws
744
+ // nothing, so it needs no reveal.
745
+ _jsx(Box, { w: 18, h: 18, "aria-hidden": true, "data-testid": "branch-lock-pending" }))), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", onOpen: onRowMenuOpen, 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) => {
746
+ event.stopPropagation();
747
+ onRenameEntry(entryPath);
748
+ }, "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) => {
749
+ event.stopPropagation();
750
+ onReorderEntry(parentCollectionPath, contentId, 'up');
751
+ }, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
752
+ event.stopPropagation();
753
+ onReorderEntry(parentCollectionPath, contentId, 'down');
754
+ }, children: "Move down" })] })), onDeleteEntry && (_jsxs(_Fragment, { children: [(onRenameEntry ||
755
+ (onReorderEntry && contentId && parentCollectionPath)) && (_jsx(Menu.Divider, {})), _jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
756
+ event.stopPropagation();
757
+ onDeleteEntry(entryPath);
758
+ }, "data-testid": "delete-entry-menu-item", children: "Delete entry" })] }))] })] }))] })] }), isCollection && unavailableSchemaRefs && unavailableSchemaRefs.length > 0 && (_jsx(UnavailableTypeRow, { schemaRefs: unavailableSchemaRefs })), isCollection && addRefused && _jsx(AddRefusedRow, {})] }) }));
759
+ };
760
+ // Changed can't list every entry it counts: those with no row are offered beneath the tree, and
761
+ // drafts whose entries aren't named yet are counted there.
762
+ const unsavedCount = Math.max(unsavedCountProp, listedUnsaved + unlistedNamed.length);
763
+ const unnamedUnsaved = unsavedCount - listedUnsaved - unlistedNamed.length;
764
+ const showAll = () => {
765
+ onChangedOnlyChange?.(false);
766
+ filterInputRef.current?.focus();
602
767
  };
603
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "entry-navigator", children: [treeData.length > 0 && (_jsx(TextInput, { ref: filterInputRef, size: "sm", py: 4, "aria-label": "Filter entries", spellCheck: false, "data-autofocus": true, placeholder: "Filter by title or slug", value: query, onChange: (event) => setQuery(event.currentTarget.value), onKeyDown: handleFilterKeyDown, leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), rightSection: query ? _jsx(CloseButton, { size: "sm", "aria-label": "Clear filter", onClick: clearFilter }) : null, "data-mantine-stop-propagation": query ? 'true' : undefined, "data-testid": "entry-nav-filter" })), _jsxs(ScrollArea, { type: "auto", scrollbars: "y", offsetScrollbars: true, style: { flex: 1 }, children: [hiddenRootUnavailableRefs && hiddenRootUnavailableRefs.length > 0 && (_jsx(Box, { pt: "sm", children: _jsx(UnavailableTypeRow, { schemaRefs: hiddenRootUnavailableRefs }) })), hiddenRootAddRefused && (_jsx(Box, { pt: "sm", children: _jsx(AddRefusedRow, {}) })), treeData.length === 0 ? (loading ? (_jsxs(Group, { gap: "xs", py: "sm", children: [_jsx(Loader, { size: "xs" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Loading content\u2026" })] })) : (_jsx(Text, { size: "xs", c: "dimmed", py: "sm", children: "No content" }))) : filteredTree.length === 0 ? (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-filter-empty", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No entries match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: clearFilter, children: "Clear filter" })] })) : (_jsx(Box, { py: "sm", ref: treeBoxRef, children: _jsx(Tree, { data: filteredTree, tree: tree, renderNode: renderNode, selectOnClick: false, levelOffset: "sm" }) }))] })] }));
768
+ const outsideRows = unlistedMatches.length > 0 && (_jsxs(Stack, { gap: 2, py: 4, align: "flex-start", "data-testid": "entry-nav-changed-outside", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Not in the content tree" }), unlistedMatches.map((entry) => (_jsx(Button, { size: "compact-sm", variant: "subtle", color: "gray", leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: () => onSelect(entry.path), children: entry.label }, entry.path)))] }));
769
+ const changedNotice = !changedOnly ? null : entryListFailed ? (_jsx(Text, { size: "sm", c: "dimmed", py: "sm", "data-testid": "entry-nav-changed-failed", children: "The list of entries couldn't be loaded, so unsaved entries can't be shown. Choose \"Refresh branch\" in the branch menu to try again." })) : query.trim() ? (_jsxs(_Fragment, { children: [filteredTree.length === 0 && !outsideRows && (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-changed-no-match", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No unsaved entries match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: showAll, children: "Search all entries" })] })), outsideRows] })) : unsavedCount === 0 ? (treeData.length > 0 && (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-changed-none", children: [_jsx(Text, { size: "sm", c: "dimmed", children: "No unsaved changes on this branch." }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: showAll, children: "Show all entries" })] }))) : (_jsxs(_Fragment, { children: [outsideRows, unnamedUnsaved > 0 && (_jsxs(Text, { size: "sm", c: "dimmed", py: 4, "data-testid": "entry-nav-changed-unlisted", children: [listedUnsaved + unlistedNamed.length > 0
770
+ ? `${unnamedUnsaved} more: `
771
+ : `${unnamedUnsaved} unsaved ${unnamedUnsaved === 1 ? 'entry' : 'entries'}: `, unnamedUnsaved === 1 ? 'loading its name…' : 'loading their names…'] }))] }));
772
+ return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "entry-navigator", children: [treeData.length > 0 && (_jsx(TextInput, { ref: mergedFilterRef, size: "sm", py: 4, "aria-label": "Find entries", "aria-keyshortcuts": filterShortcut?.keys, spellCheck: false, "data-autofocus": true, placeholder: "Find entries", value: query, onChange: (event) => setQuery(event.currentTarget.value), onKeyDown: handleFilterKeyDown, leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), rightSection: query ? (_jsx(Tooltip, { label: "Clear search", withinPortal: true, children: _jsx(CloseButton, { size: "sm", "aria-label": "Clear search", onClick: clearFilter }) })) : filterShortcut ? (
773
+ // The placeholder's colour, so it never reads brighter than the placeholder beside it.
774
+ _jsx(Text, { size: "xs", c: "var(--mantine-color-placeholder)", "aria-hidden": true, "data-testid": "entry-nav-filter-shortcut", children: filterShortcut.label })) : null, rightSectionWidth: query || !filterShortcut ? undefined : 'auto', rightSectionPointerEvents: query ? undefined : 'none', rightSectionProps: query || !filterShortcut ? undefined : { style: { paddingInline: 8 } }, "data-mantine-stop-propagation": query || onFilterEscape ? 'true' : undefined, "data-testid": "entry-nav-filter" })), onChangedOnlyChange && (treeData.length > 0 || unsavedCount > 0) && (_jsx(SegmentedControl, { size: "xs", fullWidth: true, mb: 4, "aria-label": "Entries shown", value: changedOnly ? 'changed' : 'all', onChange: (value) => onChangedOnlyChange(value === 'changed'), data: [
775
+ { value: 'all', label: 'All' },
776
+ { value: 'changed', label: unsavedCount > 0 ? `Changed ${unsavedCount}` : 'Changed' },
777
+ ], "data-testid": "entry-nav-changed-filter" })), _jsxs(ScrollArea, { type: "auto", scrollbars: "y", offsetScrollbars: true, style: { flex: 1 }, children: [hiddenRootUnavailableRefs && hiddenRootUnavailableRefs.length > 0 && (_jsx(Box, { pt: "sm", children: _jsx(UnavailableTypeRow, { schemaRefs: hiddenRootUnavailableRefs }) })), hiddenRootAddRefused && (_jsx(Box, { pt: "sm", children: _jsx(AddRefusedRow, {}) })), treeData.length === 0 ? (branchNotAvailable ? (_jsx(BranchNotAvailableNotice, { onSwitchBranch: onSwitchBranch, "data-testid": "entry-nav-branch-not-available" })) : loading ? (_jsxs(Group, { gap: "xs", py: "sm", children: [_jsx(Loader, { size: "xs" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Loading content\u2026" })] })) : changedNotice ? (changedNotice) : (_jsx(Text, { size: "xs", c: "dimmed", py: "sm", children: "No content" }))) : filteredTree.length === 0 && changedOnly ? (changedNotice) : filteredTree.length === 0 ? (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-filter-empty", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No entries match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: clearFilter, children: "Clear search" })] })) : (_jsxs(Box, { py: "sm", ref: treeBoxRef, children: [_jsx(Tree, { data: filteredTree, tree: tree, renderNode: renderNode, selectOnClick: false, levelOffset: "sm" }), changedNotice] }))] })] }));
604
778
  };
605
779
  export default EntryNavigator;