canopycms 0.7.1 → 0.8.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 (147) hide show
  1. package/CHANGELOG.md +187 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-workspace.js +22 -15
  39. package/dist/cli/cli.js +1060 -172
  40. package/dist/cli/generate-ai-content.js +732 -101
  41. package/dist/cli/init.js +126 -0
  42. package/dist/comment-store.d.ts +3 -0
  43. package/dist/comment-store.js +4 -1
  44. package/dist/config/schemas/config.d.ts +3 -0
  45. package/dist/config/schemas/config.js +2 -0
  46. package/dist/config/types.d.ts +11 -0
  47. package/dist/content-id-index.d.ts +5 -0
  48. package/dist/content-id-index.js +1 -1
  49. package/dist/content-listing.d.ts +53 -0
  50. package/dist/content-listing.js +123 -27
  51. package/dist/content-reader.js +23 -16
  52. package/dist/context.js +8 -7
  53. package/dist/editor/BranchManager.d.ts +7 -3
  54. package/dist/editor/BranchManager.js +51 -6
  55. package/dist/editor/Editor.js +166 -34
  56. package/dist/editor/EntryNavigator.d.ts +34 -10
  57. package/dist/editor/EntryNavigator.js +185 -29
  58. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  59. package/dist/editor/admin/useSystemHealth.js +1 -0
  60. package/dist/editor/branch-names.d.ts +43 -0
  61. package/dist/editor/branch-names.js +75 -0
  62. package/dist/editor/components/BranchPill.d.ts +2 -2
  63. package/dist/editor/components/BranchPill.js +2 -2
  64. package/dist/editor/components/EditorHeader.d.ts +3 -1
  65. package/dist/editor/components/EditorHeader.js +2 -2
  66. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  67. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  68. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  69. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  70. package/dist/editor/components/EntryHeader.d.ts +9 -1
  71. package/dist/editor/components/EntryHeader.js +25 -3
  72. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  73. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  74. package/dist/editor/components/ReviewDialogs.js +3 -3
  75. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  76. package/dist/editor/context/ApiClientContext.js +26 -2
  77. package/dist/editor/context/index.d.ts +1 -1
  78. package/dist/editor/context/index.js +1 -1
  79. package/dist/editor/copy.d.ts +1 -0
  80. package/dist/editor/copy.js +1 -0
  81. package/dist/editor/editor-utils.d.ts +2 -0
  82. package/dist/editor/editor-utils.js +15 -0
  83. package/dist/editor/entry-diff.d.ts +143 -0
  84. package/dist/editor/entry-diff.js +593 -0
  85. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  86. package/dist/editor/hooks/create-branch-request.js +16 -6
  87. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  88. package/dist/editor/hooks/useBranchActions.js +29 -20
  89. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  90. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  91. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  92. package/dist/editor/hooks/useBranchManager.js +142 -54
  93. package/dist/editor/hooks/useDraftManager.js +6 -2
  94. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  95. package/dist/editor/hooks/useEntryManager.js +36 -10
  96. package/dist/editor/utils/editor-errors.d.ts +5 -2
  97. package/dist/git-manager.d.ts +71 -0
  98. package/dist/git-manager.js +270 -1
  99. package/dist/http/handler.js +11 -9
  100. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  101. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  102. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  103. package/dist/operating-mode/branch-namespace.js +121 -0
  104. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  105. package/dist/operating-mode/types.d.ts +8 -0
  106. package/dist/paths/branch-name.d.ts +11 -19
  107. package/dist/paths/branch-name.js +17 -27
  108. package/dist/paths/index.d.ts +2 -2
  109. package/dist/paths/index.js +2 -2
  110. package/dist/paths/resolve.d.ts +0 -1
  111. package/dist/paths/resolve.js +0 -1
  112. package/dist/paths/validation.d.ts +40 -1
  113. package/dist/paths/validation.js +83 -4
  114. package/dist/pr-comments.d.ts +17 -9
  115. package/dist/pr-comments.js +53 -15
  116. package/dist/services.js +2 -1
  117. package/dist/submission-attribution.d.ts +22 -0
  118. package/dist/submission-attribution.js +62 -6
  119. package/dist/task-queue/worker-status.d.ts +6 -1
  120. package/dist/task-queue/worker-status.js +23 -2
  121. package/dist/types.d.ts +59 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/dist/worker/branch-policy.d.ts +111 -16
  125. package/dist/worker/branch-policy.js +222 -17
  126. package/dist/worker/cms-worker.d.ts +24 -2
  127. package/dist/worker/cms-worker.js +35 -24
  128. package/dist/worker/git-sync.d.ts +3 -3
  129. package/dist/worker/git-sync.js +42 -1
  130. package/dist/worker/github-gateway-http.js +2 -2
  131. package/dist/worker/github-gateway-wire.d.ts +40 -25
  132. package/dist/worker/github-gateway-wire.js +14 -3
  133. package/dist/worker/github-gateway.d.ts +17 -6
  134. package/dist/worker/github-gateway.js +62 -6
  135. package/dist/worker/github-mirror.d.ts +7 -4
  136. package/dist/worker/github-mirror.js +27 -16
  137. package/dist/worker/history-rewrite.d.ts +23 -10
  138. package/dist/worker/history-rewrite.js +43 -20
  139. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  140. package/dist/worker/legacy-branch-fixtures.js +71 -0
  141. package/dist/worker/rebase.d.ts +2 -1
  142. package/dist/worker/rebase.js +93 -36
  143. package/dist/worker/task-runner.d.ts +4 -1
  144. package/dist/worker/task-runner.js +77 -48
  145. package/dist/worker/unpublishable-branches.d.ts +34 -0
  146. package/dist/worker/unpublishable-branches.js +182 -0
  147. package/package.json +2 -1
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { ActionIcon, Badge, Box, Button, CloseButton, Group, Highlight, Loader, Menu, ScrollArea, SegmentedControl, Stack, Text, TextInput, Tooltip, Tree, useTree, rem, } from '@mantine/core';
3
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { ActionIcon, Alert, Anchor, Badge, Box, Button, CloseButton, Code, Group, Highlight, Loader, Menu, Skeleton, ScrollArea, SegmentedControl, Stack, Text, TextInput, Tooltip, Tree, VisuallyHidden, useTree, rem, } from '@mantine/core';
5
5
  import { useMergedRef } from '@mantine/hooks';
6
- import { IconArrowDown, IconAlertTriangle, IconArrowUp, IconDots, IconEdit, IconFolderPlus, IconLock, IconPlus, IconSearch, IconTrash, } from '@tabler/icons-react';
6
+ import { IconArrowDown, IconAlertCircle, IconAlertTriangle, IconArrowRight, IconChevronRight, IconArrowUp, IconDots, IconEdit, IconFolderPlus, IconLock, IconPencil, IconPlus, IconSearch, IconSettings, IconTrash, } from '@tabler/icons-react';
7
7
  import { calculatePathToEntry } from './editor-utils.js';
8
8
  import { entryDetailLine, matchesEntryQuery } from './entry-search.js';
9
9
  import { EDITOR_ACTIONS } from './copy.js';
10
+ import { humanizeName } from './schema-label.js';
10
11
  import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
11
12
  import { UnsavedMark } from './components/UnsavedMark.js';
12
13
  import { BranchRowLock } from './components/BranchRowLock.js';
@@ -16,6 +17,22 @@ import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
16
17
  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 }) })] }));
17
18
  /** Where path rules refuse new entries, one line says so in place of the "…" menus they hide. */
18
19
  const AddRefusedRow = () => (_jsx(Text, { size: "xs", c: "dimmed", mt: 4, "data-testid": "add-refused-message", children: "You can't add entries here." }));
20
+ const CHANGE_MARKERS = {
21
+ added: { label: 'New', icon: _jsx(IconPlus, { size: 10, "aria-hidden": true }) },
22
+ modified: { label: 'Edited', icon: _jsx(IconPencil, { size: 10, "aria-hidden": true }) },
23
+ moved: { label: 'Moved', icon: _jsx(IconArrowRight, { size: 10, "aria-hidden": true }) },
24
+ deleted: { label: 'Deleted', icon: _jsx(IconTrash, { size: 10, "aria-hidden": true }) },
25
+ settings: { label: 'Settings edited', icon: _jsx(IconSettings, { size: 10, "aria-hidden": true }) },
26
+ };
27
+ /**
28
+ * A row's change against the base: a word and an icon, never colour alone. Its visually hidden
29
+ * words join the row's accessible name: " on this branch", " from <where>" for a move, none
30
+ * where a heading already says it (`context: null`).
31
+ */
32
+ const ChangeMarker = ({ kind, context = ' on this branch', }) => (_jsx(Badge, { size: "xs", variant: "light", color: "gray", leftSection: CHANGE_MARKERS[kind].icon, "data-testid": `change-marker-${kind}`, style: { flexShrink: 0 }, children: kind === 'settings' ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: CHANGE_MARKERS.settings.label }), _jsx(VisuallyHidden, { component: "span", children: "Settings changed on this branch" })] })) : (_jsxs(_Fragment, { children: [CHANGE_MARKERS[kind].label, context && _jsx(VisuallyHidden, { component: "span", children: context })] })) }));
33
+ /** A segment's count, capped so the control keeps its width. */
34
+ const segmentCount = (count) => (count > 99 ? '99+' : String(count));
35
+ const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
19
36
  const lastSegment = (path) => path.split('/').filter(Boolean).pop() ?? path;
20
37
  /** A collection's node value; a single-entry collection's node is its entry. */
21
38
  const collectionNodeValue = (nodes, path) => {
@@ -42,8 +59,9 @@ const expandedStateFor = (nodes, acc = {}) => {
42
59
  /**
43
60
  * The nodes of `nodes` that match `query` and, given `changed`, are in it. An entry matches on
44
61
  * 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.
62
+ * changed entries, and any other collection survives only with its matching descendants or, when
63
+ * `changed` holds the collection itself, a matching label. Nodes keep the position data
64
+ * (`childIndex`, `totalChildrenCount`) of the full tree.
47
65
  */
48
66
  const filterTree = (nodes, query, changed) => {
49
67
  const kept = [];
@@ -64,8 +82,9 @@ const filterTree = (nodes, query, changed) => {
64
82
  continue;
65
83
  }
66
84
  const children = filterTree(node.children ?? [], labelMatches ? '' : query, changed);
67
- if (children.length > 0)
85
+ if (children.length > 0 || (labelMatches && changed?.has(node.value))) {
68
86
  kept.push({ ...node, children });
87
+ }
69
88
  }
70
89
  return kept;
71
90
  };
@@ -207,7 +226,7 @@ const stopTreeKeys = (event) => {
207
226
  event.stopPropagation();
208
227
  };
209
228
  const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
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, }) => {
229
+ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, readOnlyReason, dirtyPaths, reveal, onRevealed, onEntryFilterChange, entryFilter = 'all', branchChanges, onShowUnsaved, onShowDeletedChanges, unsavedCount: unsavedCountProp = 0, unsavedEntries, entryListFailed = false, branchNotAvailable = false, onSwitchBranch, onFilterEnter, onFilterEscape, onRowMenuOpen, filterRef, filterShortcut, }) => {
211
230
  // Shadow the entry-mutation callbacks with undefined when readOnly, so every
212
231
  // downstream reference (hasEntryMenu, canReorder, the menu items themselves)
213
232
  // sees them as absent rather than threading a readOnly check through each
@@ -222,13 +241,31 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
222
241
  const [query, setQuery] = useState('');
223
242
  const filterInputRef = useRef(null);
224
243
  const mergedFilterRef = useMergedRef(filterInputRef, filterRef);
225
- const changedOnly = changedOnlyProp && !!onChangedOnlyChange;
226
- const filtering = query.trim() !== '' || changedOnly;
244
+ const unsavedOnly = entryFilter === 'unsaved' && !!onEntryFilterChange;
245
+ const changedMode = entryFilter === 'changed' && !!onEntryFilterChange && !!branchChanges;
246
+ const filtering = query.trim() !== '' || unsavedOnly || changedMode;
227
247
  // Read from the tree's expand/collapse callbacks, which fire from state updaters.
228
248
  const filteringRef = useRef(false);
229
249
  useEffect(() => {
230
250
  filteringRef.current = filtering;
231
251
  }, [filtering]);
252
+ const [otherFilesOpen, setOtherFilesOpen] = useState(false);
253
+ const otherFilesId = useId();
254
+ // Announced once per answer shown under Changed (SWR keeps an unchanged answer's identity).
255
+ const [changesAnnouncement, setChangesAnnouncement] = useState('');
256
+ const announcedChanges = changedMode ? branchChanges?.data : undefined;
257
+ useEffect(() => {
258
+ if (!announcedChanges)
259
+ return;
260
+ const entries = announcedChanges.entries.length;
261
+ const collections = announcedChanges.collections.length;
262
+ setChangesAnnouncement(entries + collections === 0
263
+ ? 'No changed entries'
264
+ : plural(entries, 'changed entry', 'changed entries') +
265
+ (collections
266
+ ? ` and ${plural(collections, 'changed collection', 'changed collections')}`
267
+ : ''));
268
+ }, [announcedChanges]);
232
269
  const treeData = useMemo(() => {
233
270
  const entryNode = (entry, parent, childIndex, totalChildren) => ({
234
271
  value: entry.path,
@@ -384,9 +421,29 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
384
421
  }, [collections, items, hiddenRootPath]);
385
422
  // Filtered from the finished tree, so each node keeps its position in the whole collection and
386
423
  // Move up / Move down stay correct while a filter is on.
424
+ const changes = changedMode ? branchChanges?.data : undefined;
425
+ // Rows Changed keeps: entries with a row (not deleted ones) and collections whose settings changed.
426
+ const { changedValues, entryChange, settingsChanged } = useMemo(() => {
427
+ const values = new Set();
428
+ const byPath = new Map();
429
+ const settings = new Set();
430
+ for (const entry of changes?.entries ?? []) {
431
+ if (entry.status === 'deleted')
432
+ continue;
433
+ values.add(entry.logicalPath);
434
+ byPath.set(entry.logicalPath, entry);
435
+ }
436
+ for (const collection of changes?.collections ?? []) {
437
+ if (collection.status === 'deleted')
438
+ continue;
439
+ values.add(`collection:${collection.logicalPath}`);
440
+ settings.add(`collection:${collection.logicalPath}`);
441
+ }
442
+ return { changedValues: values, entryChange: byPath, settingsChanged: settings };
443
+ }, [changes]);
387
444
  const filteredTree = useMemo(() => filtering
388
- ? filterTree(treeData, query, changedOnly ? (dirtyPaths ?? new Set()) : undefined)
389
- : treeData, [filtering, treeData, query, changedOnly, dirtyPaths]);
445
+ ? filterTree(treeData, query, unsavedOnly ? (dirtyPaths ?? new Set()) : changedMode ? changedValues : undefined)
446
+ : treeData, [filtering, treeData, query, unsavedOnly, changedMode, dirtyPaths, changedValues]);
390
447
  // A caller may pass a new but equal `dirtyPaths` as drafts change: that alone never reopens a
391
448
  // folder the user closed, while a new query or new folders re-expand.
392
449
  const filteredFolders = useMemo(() => Object.keys(expandedStateFor(filteredTree)).join('\n'), [filteredTree]);
@@ -417,19 +474,61 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
417
474
  walk(treeData);
418
475
  return { dirtyCollections: dirty, listedUnsaved: listed };
419
476
  }, [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 [];
477
+ const treeRows = useMemo(() => {
424
478
  const rows = new Set();
425
479
  const walk = (nodes) => nodes.forEach((node) => {
426
480
  rows.add(node.value);
427
481
  walk(node.children ?? []);
428
482
  });
429
483
  walk(treeData);
430
- return unsavedEntries.filter((entry) => !rows.has(entry.path));
431
- }, [changedOnly, unsavedEntries, treeData]);
484
+ return rows;
485
+ }, [treeData]);
486
+ // Unsaved entries the tree has no row for, which Unsaved offers beneath it.
487
+ const unlistedNamed = useMemo(() => (unsavedOnly ? (unsavedEntries ?? []).filter((entry) => !treeRows.has(entry.path)) : []), [unsavedOnly, unsavedEntries, treeRows]);
432
488
  const unlistedMatches = useMemo(() => unlistedNamed.filter((entry) => matchesEntryQuery({ label: entry.label }, query)), [unlistedNamed, query]);
489
+ // A collection's label from the tree, else its last segment humanised, as for one deleted.
490
+ const collectionLabel = useMemo(() => {
491
+ const labels = new Map();
492
+ const walk = (list) => list.forEach((col) => {
493
+ labels.set(col.path, col.label);
494
+ walk(col.children ?? []);
495
+ });
496
+ walk(collections ?? []);
497
+ return (path) => labels.get(path) ?? humanizeName(lastSegment(path));
498
+ }, [collections]);
499
+ const slugLabel = (path) => humanizeName(lastSegment(path));
500
+ const movedFrom = (entry) => {
501
+ const previous = entry.previousLogicalPath;
502
+ if (!previous)
503
+ return undefined;
504
+ const parent = previous.slice(0, previous.lastIndexOf('/'));
505
+ return parent === entry.collectionPath
506
+ ? ` from ${slugLabel(previous)}`
507
+ : ` from ${collectionLabel(parent)} › ${slugLabel(previous)}`;
508
+ };
509
+ // Under Changed: entries with no row, the deleted ones and any the entry list doesn't hold yet,
510
+ // and collections whose settings row is not drawn, such as the hidden root's.
511
+ const changedBeneath = useMemo(() => {
512
+ const matches = (label) => matchesEntryQuery({ label }, query);
513
+ const unlisted = (changes?.entries ?? [])
514
+ .filter((e) => e.status !== 'deleted' && !treeRows.has(e.logicalPath))
515
+ .map((e) => ({ entry: e, path: e.logicalPath, label: e.title || slugLabel(e.logicalPath) }))
516
+ .filter((e) => matches(e.label));
517
+ const deleted = (changes?.entries ?? [])
518
+ .filter((e) => e.status === 'deleted')
519
+ .map((e) => ({
520
+ entry: e,
521
+ path: e.logicalPath,
522
+ label: e.title || slugLabel(e.logicalPath),
523
+ collection: collectionLabel(e.collectionPath),
524
+ }))
525
+ .filter((e) => matches(e.label));
526
+ const settings = (changes?.collections ?? [])
527
+ .filter((c) => c.status === 'deleted' || !treeRows.has(`collection:${c.logicalPath}`))
528
+ .map((c) => ({ path: c.logicalPath, label: collectionLabel(c.logicalPath) }))
529
+ .filter((c) => matches(c.label));
530
+ return { unlisted, deleted, settings };
531
+ }, [changes, treeRows, query, collectionLabel]);
433
532
  // The hidden root's own row never renders, so its message is shown once above the tree.
434
533
  const hiddenRootAddRefused = !readOnly &&
435
534
  !!hiddenRootPath &&
@@ -544,7 +643,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
544
643
  servedRevealRef.current = reveal.seq;
545
644
  onRevealed?.();
546
645
  };
547
- if (reveal.kind === 'changed') {
646
+ if (reveal.kind === 'unsaved') {
548
647
  setQuery('');
549
648
  served();
550
649
  return;
@@ -577,8 +676,8 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
577
676
  ...wanted,
578
677
  };
579
678
  setQuery('');
580
- if (changedOnly)
581
- onChangedOnlyChange?.(false);
679
+ if (entryFilter !== 'all')
680
+ onEntryFilterChange?.('all');
582
681
  return;
583
682
  }
584
683
  if (Object.keys(wanted).some((key) => !tree.expandedState[key])) {
@@ -615,7 +714,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
615
714
  onFilterEscape?.();
616
715
  }
617
716
  else if (event.key === 'Enter' && filtering) {
618
- const path = firstEntryPath(filteredTree, query) ?? unlistedMatches[0]?.path;
717
+ const path = firstEntryPath(filteredTree, query) ??
718
+ unlistedMatches[0]?.path ??
719
+ (changedMode ? changedBeneath.unlisted[0]?.path : undefined);
619
720
  if (path) {
620
721
  event.preventDefault();
621
722
  (onFilterEnter ?? onSelect)(path);
@@ -691,6 +792,12 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
691
792
  !entryLocked &&
692
793
  !!(onDeleteEntryProp || onRenameEntryProp || onReorderEntryProp);
693
794
  const showsLockSlot = readOnly && (wouldHaveCollectionMenu || wouldHaveEntryMenu);
795
+ const rowChange = isEntry ? entryChange.get(node.value) : undefined;
796
+ const changeMarker = rowChange
797
+ ? rowChange.status
798
+ : !isEntry && settingsChanged.has(node.value)
799
+ ? 'settings'
800
+ : undefined;
694
801
  const showEntryDirtyDot = isEntry && !!entryPath && !!dirtyPaths?.has(entryPath);
695
802
  const showCollectionDirtyDot = !isEntry && dirtyCollections.has(node.value);
696
803
  const adminItems = onAddSubCollection || onEdit || onDelete;
@@ -718,7 +825,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
718
825
  flexShrink: 0,
719
826
  alignItems: 'center',
720
827
  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",
828
+ }, 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 }), changeMarker && (_jsx(ChangeMarker, { kind: changeMarker, context: (rowChange && movedFrom(rowChange)) ?? undefined })), 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
829
  // A missing file is an error, in error text, not a dimmed type.
723
830
  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
831
  event.stopPropagation();
@@ -757,23 +864,72 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
757
864
  onDeleteEntry(entryPath);
758
865
  }, "data-testid": "delete-entry-menu-item", children: "Delete entry" })] }))] })] }))] })] }), isCollection && unavailableSchemaRefs && unavailableSchemaRefs.length > 0 && (_jsx(UnavailableTypeRow, { schemaRefs: unavailableSchemaRefs })), isCollection && addRefused && _jsx(AddRefusedRow, {})] }) }));
759
866
  };
760
- // Changed can't list every entry it counts: those with no row are offered beneath the tree, and
867
+ // Unsaved can't list every entry it counts: those with no row are offered beneath the tree, and
761
868
  // drafts whose entries aren't named yet are counted there.
762
869
  const unsavedCount = Math.max(unsavedCountProp, listedUnsaved + unlistedNamed.length);
763
870
  const unnamedUnsaved = unsavedCount - listedUnsaved - unlistedNamed.length;
764
871
  const showAll = () => {
765
- onChangedOnlyChange?.(false);
872
+ onEntryFilterChange?.('all');
766
873
  filterInputRef.current?.focus();
767
874
  };
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
875
+ const outsideRows = unlistedMatches.length > 0 && (_jsxs(Stack, { gap: 2, py: 4, align: "flex-start", "data-testid": "entry-nav-unsaved-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)))] }));
876
+ const unsavedNotice = !unsavedOnly ? null : entryListFailed ? (_jsx(Text, { size: "sm", c: "dimmed", py: "sm", "data-testid": "entry-nav-unsaved-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-unsaved-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-unsaved-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-unsaved-unlisted", children: [listedUnsaved + unlistedNamed.length > 0
770
877
  ? `${unnamedUnsaved} more: `
771
878
  : `${unnamedUnsaved} unsaved ${unnamedUnsaved === 1 ? 'entry' : 'entries'}: `, unnamedUnsaved === 1 ? 'loading its name…' : 'loading their names…'] }))] }));
879
+ // Changed: what the branch differs from its base in.
880
+ const changesData = branchChanges?.data;
881
+ const changedCount = changesData && changesData.entries.length + changesData.collections.length;
882
+ const changesError = changedMode ? branchChanges?.error : undefined;
883
+ const changesLoading = changedMode && !changesData && !changesError;
884
+ const base = branchChanges?.base ?? '';
885
+ const changedHeader = changedMode && (_jsxs(_Fragment, { children: [changesError && (_jsx(Alert, { my: 4, p: "xs", variant: "light", color: changesError.kind === 'syncing' ? 'yellow' : 'red', icon: changesError.kind === 'syncing' ? (_jsx(IconAlertTriangle, { size: 16 })) : (_jsx(IconAlertCircle, { size: 16 })), "data-testid": `entry-nav-branch-changed-${changesError.kind}`, children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: changesError.kind === 'syncing'
886
+ ? (changesError.message ??
887
+ `This branch is being synced with ${base}. Try again in a moment.`)
888
+ : changesData
889
+ ? 'Couldn’t refresh this branch’s changes. The list may be out of date.'
890
+ : 'Couldn’t load this branch’s changes.' }), _jsx(Button, { size: "compact-sm", variant: "subtle", loading: branchChanges?.retrying, onClick: branchChanges?.onRetry, children: "Retry" })] }) })), changesData?.baseMissing && (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-branch-changed-missing", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["Couldn\u2019t compare this branch with ", base, ": ", base, " no longer exists."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: showAll, children: "Show all entries" })] })), changesData?.baseUnavailable && (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-branch-changed-unavailable", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["Couldn\u2019t compare this branch with ", base, ": they share no history."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: showAll, children: "Show all entries" })] })), changesData?.hiddenChanges && (_jsx(Text, { size: "sm", c: "dimmed", py: 4, "data-testid": "entry-nav-branch-changed-hidden", children: "Some changes are in entries you can\u2019t view." }))] }));
891
+ const { unlisted: changedUnlisted, deleted: changedDeleted, settings } = changedBeneath;
892
+ const changedRows = (changedUnlisted.length > 0 ||
893
+ changedDeleted.length > 0 ||
894
+ settings.length > 0) && (_jsxs(_Fragment, { children: [(changedUnlisted.length > 0 || settings.length > 0) && (_jsxs(Stack, { gap: 2, py: 4, align: "flex-start", "data-testid": "entry-nav-branch-changed-outside", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Not in the content tree" }), changedUnlisted.map((entry) => (_jsx(Button, { size: "compact-sm", variant: "subtle", color: "gray", rightSection: _jsx(ChangeMarker, { kind: entry.entry.status, context: movedFrom(entry.entry) ?? undefined }), onClick: () => onSelect(entry.path), children: entry.label }, entry.path))), settings.map((collection) => (_jsxs(Group, { gap: 6, px: 10, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: collection.label }), _jsx(ChangeMarker, { kind: "settings" })] }, collection.path)))] })), changedDeleted.length > 0 && (_jsxs(Stack, { gap: 2, py: 4, align: "flex-start", "data-testid": "entry-nav-branch-changed-deleted", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Deleted on this branch" }), changedDeleted.map((entry) => {
895
+ const name = (_jsxs(Text, { component: "span", size: "sm", truncate: "end", style: { minWidth: 0 }, children: [entry.label, ' ', _jsx(Text, { component: "span", size: "xs", c: "dimmed", children: entry.collection })] }));
896
+ return onShowDeletedChanges ? (_jsx(Button, { size: "compact-sm", variant: "subtle", color: "gray", maw: "100%", rightSection: _jsx(ChangeMarker, { kind: "deleted", context: null }), "aria-label": `${entry.label} in ${entry.collection}, deleted on this branch. ${EDITOR_ACTIONS.showChanges}`, onClick: () => onShowDeletedChanges(entry.entry, entry.label), "data-testid": "entry-nav-deleted-entry", children: name }, entry.path)) : (_jsxs(Group, { gap: 6, px: 10, wrap: "nowrap", style: { minWidth: 0 }, children: [name, _jsx(ChangeMarker, { kind: "deleted", context: null })] }, entry.path));
897
+ })] }))] }));
898
+ const otherFiles = changedMode ? (changesData?.otherFiles ?? []) : [];
899
+ const otherFilesDetails = otherFiles.length > 0 && (_jsxs(Stack, { gap: 2, py: 4, align: "flex-start", "data-testid": "entry-nav-branch-changed-other", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Other files changed" }), _jsxs(Anchor, { component: "button", type: "button", size: "xs", px: 10, "aria-expanded": otherFilesOpen, "aria-controls": otherFilesOpen ? otherFilesId : undefined, onClick: () => setOtherFilesOpen((open) => !open), style: { display: 'inline-flex', alignItems: 'center', gap: 2 }, children: [_jsx(IconChevronRight, { size: 12, "aria-hidden": true, style: {
900
+ transform: otherFilesOpen ? 'rotate(90deg)' : undefined,
901
+ transition: 'transform 120ms ease',
902
+ } }), "Details"] }), otherFilesOpen && (_jsxs(Stack, { id: otherFilesId, gap: 2, w: "100%", children: [_jsx(Code, { block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-all' }, children: otherFiles.join('\n') }), changesData?.otherFilesTruncated && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["List shows the first ", otherFiles.length, " files."] }))] }))] }));
903
+ const nothingChanged = !!changesData &&
904
+ !changesData.baseUnavailable &&
905
+ !changesData.baseMissing &&
906
+ !changesData.hiddenChanges &&
907
+ changesData.entries.length === 0 &&
908
+ changesData.collections.length === 0;
909
+ const branchChangedNotice = !changedMode || !changesData ? null : query.trim() &&
910
+ filteredTree.length === 0 &&
911
+ !changedRows ? (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-branch-changed-no-match", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No changed entries match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: showAll, children: "Search all entries" })] })) : (_jsxs(_Fragment, { children: [nothingChanged && (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "entry-nav-branch-changed-none", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["Nothing on this branch differs from ", base, " yet."] }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: showAll, children: "Show all entries" }), unsavedCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", c: "dimmed", "data-testid": "entry-nav-branch-changed-unsaved", children: unsavedCount === 1
912
+ ? '1 unsaved entry isn’t included until it’s saved.'
913
+ : `${unsavedCount} unsaved entries aren’t included until they’re saved.` }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onShowUnsaved ?? (() => onEntryFilterChange?.('unsaved')), children: "Show unsaved" })] }))] })), changedRows, otherFilesDetails] }));
914
+ const modeNotice = unsavedOnly ? unsavedNotice : branchChangedNotice;
915
+ const changesSkeleton = (_jsx(Stack, { gap: 10, py: "sm", px: 10, "aria-hidden": true, "data-testid": "entry-nav-branch-changed-loading", children: [70, 55, 80, 45].map((width, i) => (_jsxs(Group, { gap: 6, wrap: "nowrap", pl: i % 2 ? 18 : 0, children: [_jsx(Skeleton, { h: 12, w: `${width}%` }), _jsx(Skeleton, { h: 12, w: 40 })] }, i))) }));
772
916
  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
917
  // 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: [
918
+ _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" })), onEntryFilterChange && (treeData.length > 0 || unsavedCount > 0 || !!branchChanges) && (_jsx(SegmentedControl, { size: "xs", fullWidth: true, mb: 4, "aria-label": "Entries shown", value: unsavedOnly ? 'unsaved' : changedMode ? 'changed' : 'all', onChange: (value) => onEntryFilterChange(value), data: [
775
919
  { 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] }))] })] }));
920
+ {
921
+ value: 'unsaved',
922
+ label: unsavedCount > 0 ? `Unsaved ${segmentCount(unsavedCount)}` : 'Unsaved',
923
+ },
924
+ ...(branchChanges
925
+ ? [
926
+ {
927
+ value: 'changed',
928
+ label: changedCount ? `Changed ${segmentCount(changedCount)}` : 'Changed',
929
+ },
930
+ ]
931
+ : []),
932
+ ], "data-testid": "entry-nav-shown" })), branchChanges && (_jsx(VisuallyHidden, { role: "status", "data-testid": "entry-nav-branch-changed-status", children: changesAnnouncement })), _jsxs(ScrollArea, { type: "auto", scrollbars: "y", offsetScrollbars: true, style: { flex: 1 }, children: [changedHeader, 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" })] })) : changesLoading ? (changesSkeleton) : modeNotice ? (modeNotice) : (_jsx(Text, { size: "xs", c: "dimmed", py: "sm", children: "No content" }))) : changesLoading ? (changesSkeleton) : changedMode && !changesData ? null : filteredTree.length === 0 &&
933
+ (unsavedOnly || changedMode) ? (modeNotice) : 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" }), modeNotice] }))] })] }));
778
934
  };
779
935
  export default EntryNavigator;
@@ -15,6 +15,7 @@ import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
15
15
  import { openConfirm } from '../utils/confirm-modal.js';
16
16
  import { focusReturnProps } from '../utils/focus-return.js';
17
17
  import { formatRelativeTime } from '../relative-time.js';
18
+ import { EDITOR_ACTIONS } from '../copy.js';
18
19
  import { useSystemHealth, } from './useSystemHealth.js';
19
20
  import { escapeForLog } from '../../utils/escape-text.js';
20
21
  import { branchStatusPresentation } from '../branch-status.js';
@@ -122,6 +123,35 @@ function BaseHoldAlert({ hold }) {
122
123
  function DivergedBranchesAlert({ names }) {
123
124
  return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
124
125
  }
126
+ /**
127
+ * The branch namespace the CMS creates branches in, against the one the GitHub gateway writes. A
128
+ * mismatch leaves every new branch unpublishable, so it is shown before anything else needs it.
129
+ */
130
+ function BranchNamespaceAlert({ own, gateway, }) {
131
+ if (own === undefined)
132
+ return null;
133
+ if (!own.ok) {
134
+ return (_jsx(Alert, { color: "red", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "The branch namespace is not valid", "data-testid": "branch-namespace-alert", children: _jsx(Text, { size: "sm", children: own.error }) }));
135
+ }
136
+ if (gateway === undefined || gateway === own.namespace)
137
+ return null;
138
+ return (_jsx(Alert, { color: "red", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "New branches cannot be published", "data-testid": "branch-namespace-alert", children: _jsxs(Text, { size: "sm", children: ["The CMS creates branches starting ", _jsx(Code, { children: escapeForLog(own.namespace) }), ", but the GitHub gateway writes only branches starting ", _jsx(Code, { children: escapeForLog(gateway) }), ". A developer sets branchNamespace, or CANOPYCMS_BRANCH_NAMESPACE, to the same value for both."] }) }));
139
+ }
140
+ /** Branch refs from metadata the CMS Lambda can write, so each is shown escaped. */
141
+ function BranchRefs({ branches }) {
142
+ return (_jsx(Group, { gap: 4, children: branches.map((branch, i) => (_jsx(Code, { children: escapeForLog(branch.name) }, i))) }));
143
+ }
144
+ /**
145
+ * Editor branches the GitHub gateway will not publish, by ref, which is what a developer lists
146
+ * (worker/unpublishable-branches.ts), grouped by what would publish each.
147
+ */
148
+ function UnpublishableBranchesAlert({ report, namespace, }) {
149
+ const unlisted = report.count - report.branches.length;
150
+ const listable = report.branches.filter((b) => b.reason === undefined);
151
+ const blocking = report.branches.filter((b) => b.reason === 'blocks-namespace');
152
+ const unlistable = report.branches.filter((b) => b.reason === 'unlistable');
153
+ return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(report.count, 'branch')} cannot be published`, "data-testid": "unpublishable-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsxs(Text, { size: "sm", children: ["The GitHub gateway refuses to push each branch below, so submitting it for review fails. Either it is outside the branch namespace", namespace !== undefined && (_jsxs(_Fragment, { children: [' ', _jsx(Code, { children: escapeForLog(namespace) })] })), ' ', "and legacyBranches does not list it, or it is protected: the base branch, GitHub's default branch, or a match for an entry in protectedBranches."] }), listable.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", "data-testid": "unpublishable-listable", children: "A developer lists each branch from outside the namespace in legacyBranches (CANOPYCMS_LEGACY_BRANCHES on the GitHub gateway), and takes a protected one out of protectedBranches, or makes another branch GitHub's default. Otherwise, an editor re-enters its changes by hand in a new branch, which is created inside the namespace. A branch named like one inside the namespace with dashes for its slashes needs neither: an older worker renamed it, and the next git sync restores its name." }), _jsx(BranchRefs, { branches: listable })] })), blocking.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { size: "sm", "data-testid": "unpublishable-blocking", children: ["Delete each of these branches in the CMS, and by hand on GitHub too if it is there. One in review needs ", EDITOR_ACTIONS.withdrawFromReview, " first. While one exists, no branch in the namespace can be created or published."] }), _jsx(BranchRefs, { branches: blocking })] })), unlistable.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", "data-testid": "unpublishable-unlistable", children: "legacyBranches cannot hold these names, so an editor re-enters each branch's changes by hand in a new branch, which is created inside the namespace." }), _jsx(BranchRefs, { branches: unlistable })] })), unlisted > 0 && _jsxs(Text, { size: "sm", children: ["And ", unlisted, " more."] }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["As of", ' ', _jsx(TooltipDetail, { detail: absoluteTime(report.at), children: formatRelativeTime(report.at) })] })] }) }));
154
+ }
125
155
  /**
126
156
  * Why the base branch needs an operator, or null when its last refresh needs
127
157
  * nothing. Shared by the overview and the base row's warning tooltip.
@@ -243,7 +273,7 @@ function OverviewTab({ health }) {
243
273
  workerVersion !== build.canopycmsVersion;
244
274
  return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
245
275
  ? 'The worker failed to start'
246
- : 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
276
+ : 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
247
277
  ` · drain deadline hit, aborted ${lastShutdown.abandoned?.join(', ') ?? 'in-flight work'}`, lastShutdown.outcome === 'drained' &&
248
278
  lastShutdown.drainMs !== undefined &&
249
279
  ` · drained in ${(lastShutdown.drainMs / 1000).toFixed(1)}s`, lastShutdown.outcome === 'not-drained' && ' (a crash or a forced stop)'] })), lastFatalError?.phase === 'startup' && (_jsxs(Text, { size: "xs", c: "orange", "data-testid": "build-failed-start", children: ["Last start failed at ", lastFatalError.at] })), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
@@ -344,6 +374,11 @@ function BranchesTab({ health }) {
344
374
  function DuplicateRepairDetails({ dirName, duplicates, }) {
345
375
  return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "sm", children: ["Branch ", _jsx(Code, { children: dirName })] }), _jsx(Text, { size: "sm", children: DUPLICATE_REPAIR_CONFIRM_TEXT }), duplicates.map((d) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "sm", "data-testid": `duplicate-id-${d.id}`, children: [_jsxs(Text, { size: "xs", fw: 600, children: ["Content ID ", _jsx(Code, { children: d.id })] }), _jsxs(Text, { size: "xs", children: ["Keep: ", _jsx(Code, { children: d.keptPath })] }), d.droppedPaths.map((p) => (_jsxs(Text, { size: "xs", children: ["Archive: ", _jsx(Code, { children: p })] }, p)))] }, d.id))), _jsx(Text, { size: "xs", c: "dimmed", children: DUPLICATE_REPAIR_EDITOR_NOTE })] }));
346
376
  }
377
+ /** The absolute time a relative one's tooltip shows, or `iso` when it does not parse. */
378
+ const absoluteTime = (iso) => {
379
+ const date = new Date(iso);
380
+ return Number.isNaN(date.getTime()) ? iso : date.toLocaleString();
381
+ };
347
382
  /** "2d ago, 1/1/2026, 9:00:00 AM": inside a tooltip, so the absolute date sits beside it. */
348
383
  const firstFailed = (iso) => {
349
384
  const date = new Date(iso);
@@ -237,6 +237,7 @@ export function useSystemHealth(options) {
237
237
  const result = await apiClient.workflow.markMerged({ branch: branchName });
238
238
  if (!result.ok)
239
239
  throw new Error(result.error || 'Failed to mark branch as merged');
240
+ // Diagnosis keeps the full ref; the Branches table beside it shows the workspace directory.
240
241
  notifications.show({ message: `Branch "${branchName}" marked as merged`, color: 'green' });
241
242
  await refresh();
242
243
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The names an editor sees one branch by. A listed branch has its full name (`name`, the ref
3
+ * `canopycms/prod/spring`), which keys everything the editor stores, and a short name
4
+ * (`shortName`, `spring`; a branch outside the namespace lists its full name), which the person
5
+ * sees and, unless another branch's short name shadows it, the URL carries (`branchUrlName`).
6
+ * Requests name a listed branch by the exact form instead (`requestSegments`).
7
+ */
8
+ import type { BranchListItem } from '../api/branch.js';
9
+ type Named = Pick<BranchListItem, 'name' | 'shortName'>;
10
+ /** What a person is shown for a branch. */
11
+ export declare function displayBranchName(branch: Named): string;
12
+ /**
13
+ * The listed branch `name` opens: `resolveExactly`'s answer, else the branch whose full name is
14
+ * `name` sanitized, which catches a bookmark carrying a name's raw form ("feature/x" for the
15
+ * persisted "feature-x"). A sanitized match counts only when its own name resolves back to it:
16
+ * the server refuses the raw form, and a shadowed branch would open a different one.
17
+ */
18
+ export declare function resolveListedBranch<B extends Named>(branches: readonly B[], name: string): B | undefined;
19
+ /**
20
+ * The `?branch=` value for `branch`: its short name when that opens the same branch, else its full
21
+ * name when that does, else the exact form. Only the exact form opens a
22
+ * branch outside the namespace whose name a namespace branch's short name takes.
23
+ */
24
+ export declare function branchUrlName<B extends Named>(branches: readonly B[], branch: B): string;
25
+ /**
26
+ * The `/:branch` segment requests name each listed branch by, keyed by full name: the exact form,
27
+ * which the server resolves to that branch alone. Any other form it resolves again on every
28
+ * request, so a dev active-branch change (`git checkout -b spring`) or a branch hidden from the
29
+ * requester could put a different branch behind the same short name.
30
+ */
31
+ export declare function requestSegments<B extends Named>(branches: readonly B[]): Map<string, string>;
32
+ /**
33
+ * The `/:branch` segment of a name no listed branch maps: the exact form when it holds a `/`, as a
34
+ * short name does only when it is a full name (paths/branch-name.ts `branchShortName`), so no path
35
+ * carries `%2F`; else the name as given, which a short name needs.
36
+ */
37
+ export declare function unlistedSegment(name: string): string;
38
+ /**
39
+ * A name the editor resolves (`resolveListedBranch`) to `branch`, for an action on its card: its
40
+ * full name, else its `branchUrlName` when another branch's short name shadows the full name.
41
+ */
42
+ export declare function branchAddress<B extends Named>(branches: readonly B[], branch: B): string;
43
+ export {};
@@ -0,0 +1,75 @@
1
+ // NOT branch or the '../paths' barrel: both pull node:fs into the browser bundle.
2
+ import { sanitizeBranchName } from '../paths/branch-name.js';
3
+ import { exactBranchName, exactBranchSegment, isExactBranchForm } from '../paths/validation.js';
4
+ /** What a person is shown for a branch. */
5
+ export function displayBranchName(branch) {
6
+ return branch.shortName ?? branch.name;
7
+ }
8
+ /**
9
+ * The listed branch `name` opens, as the server resolves it (branch-param.ts): by the exact form
10
+ * (`exactBranchSegment`) only the branch with that full name; else a namespace branch by its short
11
+ * name, then any branch by its full name. The server's first step, the base or active branch
12
+ * exactly, needs none here: while it applies, `shortNameOf` lists no namespace branch under
13
+ * either's name, and when the base cannot be resolved the server skips it too. A branch outside the
14
+ * namespace lists its full name as its short name, so only a namespace branch matches by short name.
15
+ */
16
+ function resolveExactly(branches, name) {
17
+ if (isExactBranchForm(name)) {
18
+ const exact = exactBranchName(name);
19
+ return exact === null ? undefined : branches.find((b) => b.name === exact);
20
+ }
21
+ return (branches.find((b) => b.shortName === name && b.name !== name) ??
22
+ branches.find((b) => b.name === name));
23
+ }
24
+ /**
25
+ * The listed branch `name` opens: `resolveExactly`'s answer, else the branch whose full name is
26
+ * `name` sanitized, which catches a bookmark carrying a name's raw form ("feature/x" for the
27
+ * persisted "feature-x"). A sanitized match counts only when its own name resolves back to it:
28
+ * the server refuses the raw form, and a shadowed branch would open a different one.
29
+ */
30
+ export function resolveListedBranch(branches, name) {
31
+ const exact = resolveExactly(branches, name);
32
+ if (exact)
33
+ return exact;
34
+ const sanitized = branches.find((b) => b.name === sanitizeBranchName(name));
35
+ return sanitized && resolveExactly(branches, sanitized.name) === sanitized ? sanitized : undefined;
36
+ }
37
+ /**
38
+ * The `?branch=` value for `branch`: its short name when that opens the same branch, else its full
39
+ * name when that does, else the exact form. Only the exact form opens a
40
+ * branch outside the namespace whose name a namespace branch's short name takes.
41
+ */
42
+ export function branchUrlName(branches, branch) {
43
+ const short = displayBranchName(branch);
44
+ if (resolveExactly(branches, short) === branch)
45
+ return short;
46
+ if (resolveExactly(branches, branch.name) === branch)
47
+ return branch.name;
48
+ return exactBranchSegment(branch.name);
49
+ }
50
+ /**
51
+ * The `/:branch` segment requests name each listed branch by, keyed by full name: the exact form,
52
+ * which the server resolves to that branch alone. Any other form it resolves again on every
53
+ * request, so a dev active-branch change (`git checkout -b spring`) or a branch hidden from the
54
+ * requester could put a different branch behind the same short name.
55
+ */
56
+ export function requestSegments(branches) {
57
+ return new Map(branches.map((b) => [b.name, exactBranchSegment(b.name)]));
58
+ }
59
+ /**
60
+ * The `/:branch` segment of a name no listed branch maps: the exact form when it holds a `/`, as a
61
+ * short name does only when it is a full name (paths/branch-name.ts `branchShortName`), so no path
62
+ * carries `%2F`; else the name as given, which a short name needs.
63
+ */
64
+ export function unlistedSegment(name) {
65
+ return name.includes('/') ? exactBranchSegment(name) : name;
66
+ }
67
+ /**
68
+ * A name the editor resolves (`resolveListedBranch`) to `branch`, for an action on its card: its
69
+ * full name, else its `branchUrlName` when another branch's short name shadows the full name.
70
+ */
71
+ export function branchAddress(branches, branch) {
72
+ return resolveExactly(branches, branch.name) === branch
73
+ ? branch.name
74
+ : branchUrlName(branches, branch);
75
+ }
@@ -12,7 +12,7 @@ export interface BranchPillProps {
12
12
  pillRef?: Ref<HTMLButtonElement>;
13
13
  }
14
14
  /**
15
- * The branch's name. Its menu, headed by the full name, holds the branch actions. Below `sm` the
16
- * name truncates shorter, so the actions don't clip.
15
+ * The branch's name. Its menu, headed by the name untruncated, holds the branch actions. Below
16
+ * `sm` the name truncates shorter, so the actions don't clip.
17
17
  */
18
18
  export declare function BranchPill({ branchName, loading, unsavedCount: unsaved, onSwitchBranch, onRefreshBranch, onDiscardAll, pillRef, }: BranchPillProps): import("react/jsx-runtime").JSX.Element;