canopycms 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +197 -47
- package/dist/api/admin-branch-health.js +47 -20
- package/dist/api/admin.d.ts +13 -1
- package/dist/api/admin.js +14 -24
- package/dist/api/branch-changes.d.ts +87 -0
- package/dist/api/branch-changes.js +269 -0
- package/dist/api/branch-entry-changes.d.ts +83 -0
- package/dist/api/branch-entry-changes.js +262 -0
- package/dist/api/branch-merge.js +2 -2
- package/dist/api/branch-review.js +35 -4
- package/dist/api/branch-status.js +14 -5
- package/dist/api/branch-withdraw.js +7 -0
- package/dist/api/branch.d.ts +13 -1
- package/dist/api/branch.js +171 -97
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.js +13 -1
- package/dist/api/content.d.ts +18 -18
- package/dist/api/content.js +1 -1
- package/dist/api/github-sync.js +7 -3
- package/dist/api/groups.d.ts +4 -4
- package/dist/api/pr-comment-sync.d.ts +9 -8
- package/dist/api/pr-comment-sync.js +23 -13
- package/dist/api/routes.js +4 -0
- package/dist/api/types.d.ts +3 -1
- package/dist/authorization/groups/schema.d.ts +4 -4
- package/dist/authorization/protected-branch.d.ts +3 -3
- package/dist/authorization/protected-branch.js +3 -3
- package/dist/branch-metadata-file.d.ts +84 -0
- package/dist/branch-metadata-file.js +9 -0
- package/dist/branch-metadata.d.ts +4 -4
- package/dist/branch-metadata.js +4 -5
- package/dist/branch-param.d.ts +24 -0
- package/dist/branch-param.js +87 -0
- package/dist/branch-provisioning.d.ts +6 -3
- package/dist/branch-provisioning.js +12 -10
- package/dist/branch-registry.d.ts +6 -0
- package/dist/branch-registry.js +12 -3
- package/dist/branch-schema-cache.d.ts +22 -2
- package/dist/branch-schema-cache.js +88 -12
- package/dist/branch-workspace.js +22 -15
- package/dist/cli/cli.js +1122 -181
- package/dist/cli/generate-ai-content.js +791 -109
- package/dist/cli/init.js +126 -0
- package/dist/comment-store.d.ts +3 -0
- package/dist/comment-store.js +4 -1
- package/dist/config/schemas/config.d.ts +3 -0
- package/dist/config/schemas/config.js +2 -0
- package/dist/config/types.d.ts +11 -0
- package/dist/content-id-index.d.ts +5 -0
- package/dist/content-id-index.js +1 -1
- package/dist/content-listing.d.ts +53 -0
- package/dist/content-listing.js +123 -27
- package/dist/content-reader.d.ts +24 -10
- package/dist/content-reader.js +66 -22
- package/dist/context.js +51 -48
- package/dist/editor/BranchManager.d.ts +7 -3
- package/dist/editor/BranchManager.js +51 -6
- package/dist/editor/Editor.js +166 -34
- package/dist/editor/EntryNavigator.d.ts +34 -10
- package/dist/editor/EntryNavigator.js +185 -29
- package/dist/editor/admin/SystemHealthPanel.js +36 -1
- package/dist/editor/admin/useSystemHealth.js +1 -0
- package/dist/editor/branch-names.d.ts +43 -0
- package/dist/editor/branch-names.js +75 -0
- package/dist/editor/components/BranchPill.d.ts +2 -2
- package/dist/editor/components/BranchPill.js +2 -2
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +2 -2
- package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
- package/dist/editor/components/EntryChangesDrawer.js +363 -0
- package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
- package/dist/editor/components/EntryChangesSkeleton.js +7 -0
- package/dist/editor/components/EntryHeader.d.ts +9 -1
- package/dist/editor/components/EntryHeader.js +25 -3
- package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
- package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
- package/dist/editor/components/ReviewDialogs.js +3 -3
- package/dist/editor/context/ApiClientContext.d.ts +9 -0
- package/dist/editor/context/ApiClientContext.js +26 -2
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/copy.d.ts +1 -0
- package/dist/editor/copy.js +1 -0
- package/dist/editor/editor-utils.d.ts +2 -0
- package/dist/editor/editor-utils.js +15 -0
- package/dist/editor/entry-diff.d.ts +143 -0
- package/dist/editor/entry-diff.js +593 -0
- package/dist/editor/hooks/create-branch-request.d.ts +11 -1
- package/dist/editor/hooks/create-branch-request.js +16 -6
- package/dist/editor/hooks/useBranchActions.d.ts +19 -2
- package/dist/editor/hooks/useBranchActions.js +29 -20
- package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
- package/dist/editor/hooks/useBranchChangesData.js +134 -0
- package/dist/editor/hooks/useBranchManager.d.ts +16 -0
- package/dist/editor/hooks/useBranchManager.js +142 -54
- package/dist/editor/hooks/useDraftManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +18 -2
- package/dist/editor/hooks/useEntryManager.js +36 -10
- package/dist/editor/utils/editor-errors.d.ts +5 -2
- package/dist/git-manager.d.ts +71 -0
- package/dist/git-manager.js +270 -1
- package/dist/http/handler.js +11 -9
- package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
- package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
- package/dist/operating-mode/branch-namespace.d.ts +34 -0
- package/dist/operating-mode/branch-namespace.js +121 -0
- package/dist/operating-mode/client-unsafe-strategy.js +7 -0
- package/dist/operating-mode/types.d.ts +8 -0
- package/dist/paths/branch-name.d.ts +11 -19
- package/dist/paths/branch-name.js +17 -27
- package/dist/paths/index.d.ts +2 -2
- package/dist/paths/index.js +2 -2
- package/dist/paths/resolve.d.ts +0 -1
- package/dist/paths/resolve.js +0 -1
- package/dist/paths/validation.d.ts +40 -1
- package/dist/paths/validation.js +83 -4
- package/dist/pr-comments.d.ts +17 -9
- package/dist/pr-comments.js +53 -15
- package/dist/server.d.ts +6 -1
- package/dist/server.js +6 -1
- package/dist/services.js +2 -1
- package/dist/submission-attribution.d.ts +22 -0
- package/dist/submission-attribution.js +62 -6
- package/dist/task-queue/worker-status.d.ts +6 -1
- package/dist/task-queue/worker-status.js +23 -2
- package/dist/types.d.ts +59 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-policy.d.ts +111 -16
- package/dist/worker/branch-policy.js +222 -17
- package/dist/worker/cms-worker.d.ts +24 -2
- package/dist/worker/cms-worker.js +35 -24
- package/dist/worker/git-sync.d.ts +3 -3
- package/dist/worker/git-sync.js +42 -1
- package/dist/worker/github-gateway-http.js +2 -2
- package/dist/worker/github-gateway-wire.d.ts +42 -27
- package/dist/worker/github-gateway-wire.js +14 -3
- package/dist/worker/github-gateway.d.ts +17 -6
- package/dist/worker/github-gateway.js +62 -6
- package/dist/worker/github-mirror.d.ts +7 -4
- package/dist/worker/github-mirror.js +27 -16
- package/dist/worker/history-rewrite.d.ts +23 -10
- package/dist/worker/history-rewrite.js +43 -20
- package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
- package/dist/worker/legacy-branch-fixtures.js +71 -0
- package/dist/worker/rebase.d.ts +2 -1
- package/dist/worker/rebase.js +93 -36
- package/dist/worker/task-runner.d.ts +4 -1
- package/dist/worker/task-runner.js +77 -48
- package/dist/worker/unpublishable-branches.d.ts +34 -0
- package/dist/worker/unpublishable-branches.js +182 -0
- 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
|
|
46
|
-
* the
|
|
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,
|
|
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
|
|
226
|
-
const
|
|
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,
|
|
389
|
-
: treeData, [filtering, treeData, query,
|
|
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
|
-
|
|
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
|
|
431
|
-
}, [
|
|
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 === '
|
|
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 (
|
|
581
|
-
|
|
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) ??
|
|
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
|
-
//
|
|
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
|
-
|
|
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-
|
|
769
|
-
const
|
|
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" })),
|
|
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
|
-
{
|
|
777
|
-
|
|
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
|
|
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;
|