canopycms 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +213 -41
  2. package/dist/api/branch.d.ts +2 -2
  3. package/dist/api/branch.js +4 -31
  4. package/dist/authorization/branch.js +3 -4
  5. package/dist/cli/cli.js +0 -18
  6. package/dist/cli/generate-ai-content.js +0 -6
  7. package/dist/cli/init.js +0 -6
  8. package/dist/editor/BranchManager.d.ts +2 -0
  9. package/dist/editor/BranchManager.js +3 -3
  10. package/dist/editor/Editor.js +253 -128
  11. package/dist/editor/EntryNavigator.d.ts +44 -9
  12. package/dist/editor/EntryNavigator.js +275 -103
  13. package/dist/editor/branch-lock.d.ts +42 -0
  14. package/dist/editor/branch-lock.js +79 -0
  15. package/dist/editor/branch-status.d.ts +0 -6
  16. package/dist/editor/branch-status.js +0 -10
  17. package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
  18. package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
  19. package/dist/editor/components/BranchPill.d.ts +3 -18
  20. package/dist/editor/components/BranchPill.js +7 -10
  21. package/dist/editor/components/EditorHeader.d.ts +24 -52
  22. package/dist/editor/components/EditorHeader.js +151 -71
  23. package/dist/editor/components/EditorRail.d.ts +8 -8
  24. package/dist/editor/components/EditorRail.js +7 -5
  25. package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
  26. package/dist/editor/components/EntryBreadcrumb.js +3 -3
  27. package/dist/editor/components/EntryHeader.d.ts +60 -0
  28. package/dist/editor/components/EntryHeader.js +45 -0
  29. package/dist/editor/components/EntrySaveButton.d.ts +24 -0
  30. package/dist/editor/components/EntrySaveButton.js +101 -0
  31. package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
  32. package/dist/editor/components/NoEditPermissionNotice.js +1 -1
  33. package/dist/editor/components/RetryToastMessage.d.ts +3 -1
  34. package/dist/editor/components/RetryToastMessage.js +8 -4
  35. package/dist/editor/components/SaveAllButton.d.ts +25 -0
  36. package/dist/editor/components/SaveAllButton.js +81 -0
  37. package/dist/editor/components/UnsavedMark.d.ts +1 -1
  38. package/dist/editor/components/UnsavedMark.js +1 -1
  39. package/dist/editor/components/index.d.ts +1 -1
  40. package/dist/editor/components/index.js +1 -1
  41. package/dist/editor/copy.d.ts +0 -1
  42. package/dist/editor/copy.js +0 -1
  43. package/dist/editor/editor-utils.d.ts +2 -2
  44. package/dist/editor/editor-utils.js +2 -2
  45. package/dist/editor/entry-search.d.ts +10 -16
  46. package/dist/editor/entry-search.js +4 -24
  47. package/dist/editor/hooks/useBranchManager.d.ts +7 -3
  48. package/dist/editor/hooks/useBranchManager.js +13 -9
  49. package/dist/editor/hooks/useEntryManager.d.ts +3 -0
  50. package/dist/editor/hooks/useEntryManager.js +10 -3
  51. package/dist/editor/shell/ActivityRail.d.ts +4 -0
  52. package/dist/editor/shell/ActivityRail.js +13 -4
  53. package/dist/editor/shell/EditorShell.d.ts +6 -7
  54. package/dist/editor/shell/EditorShell.js +9 -13
  55. package/dist/editor/shell/PaneLayout.d.ts +1 -0
  56. package/dist/editor/shell/PaneLayout.js +23 -5
  57. package/dist/editor/shell/SidePanelHost.d.ts +11 -4
  58. package/dist/editor/shell/SidePanelHost.js +95 -7
  59. package/dist/editor/shell/TopBar.d.ts +4 -4
  60. package/dist/editor/shell/TopBar.js +6 -3
  61. package/dist/editor/utils/focus-return.d.ts +10 -2
  62. package/dist/editor/utils/focus-return.js +40 -6
  63. package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
  64. package/dist/operating-mode/client-safe-strategy.js +1 -10
  65. package/dist/operating-mode/types.d.ts +0 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +1 -1
  69. package/dist/editor/components/BranchLockBanner.d.ts +0 -23
  70. package/dist/editor/components/BranchLockBanner.js +0 -66
  71. package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
  72. package/dist/editor/components/QuickOpenPalette.js +0 -71
  73. package/dist/editor/components/SaveSplitButton.d.ts +0 -31
  74. package/dist/editor/components/SaveSplitButton.js +0 -121
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useId, useState } from 'react';
4
+ import { Box, Button, Loader, Tooltip, VisuallyHidden } from '@mantine/core';
5
+ import { useReducedMotion } from '@mantine/hooks';
6
+ import { IconCheck, IconLoader2 } from '@tabler/icons-react';
7
+ import { EDITOR_ACTIONS } from '../copy.js';
8
+ const KEPT_HINT = 'Unsaved changes are kept on this device until you save.';
9
+ const SAVE_ALL_WAIT = 'Wait for Save all to finish.';
10
+ const SAVED_SHOWN_MS = 2000;
11
+ // Below `sm` the status words are visually hidden, so Save keeps its own width beside an icon.
12
+ const LABEL_TEXT_CLASS = 'canopy-save-label-text';
13
+ const LABEL_STYLES = `
14
+ @media (max-width: 47.99em) {
15
+ .${LABEL_TEXT_CLASS} {
16
+ position: absolute; width: 1px; height: 1px; overflow: hidden;
17
+ clip: rect(0 0 0 0); white-space: nowrap;
18
+ }
19
+ }
20
+ `;
21
+ /**
22
+ * Every label in one grid cell, only `shown` visible, so the button keeps the widest one's width
23
+ * and the header never shifts.
24
+ */
25
+ function SaveLabels({ shown, spinner }) {
26
+ const labels = {
27
+ save: EDITOR_ACTIONS.save,
28
+ saving: (_jsxs(_Fragment, { children: [_jsx(Box, { component: "span", display: "inline-flex", w: 14, children: shown === 'saving' &&
29
+ (spinner ? (_jsx(Loader, { size: 14, color: "currentColor" })) : (_jsx(IconLoader2, { size: 14, "aria-hidden": "true" }))) }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saving\u2026" })] })),
30
+ saved: (_jsxs(_Fragment, { children: [_jsx(IconCheck, { size: 14, "aria-hidden": "true" }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saved" })] })),
31
+ };
32
+ return (_jsx(Box, { component: "span", pos: "relative", style: { display: 'inline-grid' }, children: Object.keys(labels).map((key) => (_jsx(Box, { component: "span", "aria-hidden": key === shown ? undefined : true, style: {
33
+ gridArea: '1 / 1',
34
+ display: 'inline-flex',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ gap: 6,
38
+ visibility: key === shown ? 'visible' : 'hidden',
39
+ }, children: labels[key] }, key))) }));
40
+ }
41
+ /**
42
+ * The open entry's Save, reading "Saving…" then "Saved" as a save lands. While the entry has
43
+ * unsaved changes, its Tooltip and description say they are kept on this device.
44
+ */
45
+ export function EntrySaveButton({ onSave, saveBlockedReason, busy, saveAllRunning, saving = false, savedSignal, savedHere = false, entryDirty, draftStorageFailed, }) {
46
+ const keptHintId = useId();
47
+ const reducedMotion = useReducedMotion();
48
+ // Each landed Save shows "Saved" once, as it lands and only on its own entry: one that landed
49
+ // before mount or while another entry was open shows nothing later.
50
+ const [seenSignal, setSeenSignal] = useState(savedSignal ?? 0);
51
+ const [flashing, setFlashing] = useState();
52
+ // Leaving the saved entry ends its "Saved", so a quick return doesn't show or announce it again.
53
+ const [wasHere, setWasHere] = useState(savedHere);
54
+ if (wasHere !== savedHere) {
55
+ setWasHere(savedHere);
56
+ if (!savedHere)
57
+ setFlashing(undefined);
58
+ }
59
+ // Save all ends any "Saved", so a run that finishes within its window doesn't bring it back.
60
+ const [wasRunning, setWasRunning] = useState(saveAllRunning);
61
+ if (wasRunning !== saveAllRunning) {
62
+ setWasRunning(saveAllRunning);
63
+ if (saveAllRunning)
64
+ setFlashing(undefined);
65
+ }
66
+ if (savedSignal !== undefined && savedSignal > seenSignal) {
67
+ setSeenSignal(savedSignal);
68
+ setFlashing(savedHere && !saveAllRunning ? savedSignal : undefined);
69
+ }
70
+ useEffect(() => {
71
+ if (flashing === undefined)
72
+ return;
73
+ const timer = setTimeout(() => setFlashing(undefined), SAVED_SHOWN_MS);
74
+ return () => clearTimeout(timer);
75
+ }, [flashing]);
76
+ // Blocked, Save stays focusable (`aria-disabled`, not `disabled`), so its reason opens on focus.
77
+ const saveBlocked = !!saveBlockedReason || busy || saveAllRunning;
78
+ const label = saving
79
+ ? 'saving'
80
+ : flashing !== undefined &&
81
+ flashing === savedSignal &&
82
+ savedHere &&
83
+ !entryDirty &&
84
+ !saveAllRunning
85
+ ? 'saved'
86
+ : 'save';
87
+ // A "Saving…" or "Saved" label stays readable: disabled styling takes it under 4.5:1.
88
+ const saveGreyed = saveBlocked && label === 'save';
89
+ const blockedReason = saveAllRunning ? SAVE_ALL_WAIT : saveBlockedReason;
90
+ // Why Save is blocked, else where unsaved changes are held; a blocked Save's reason leads.
91
+ const keptHint = entryDirty && !draftStorageFailed && !blockedReason ? KEPT_HINT : '';
92
+ const saveTooltip = blockedReason || keptHint;
93
+ return (_jsxs(_Fragment, { children: [_jsx(VisuallyHidden, { role: "status", children: label === 'saved' ? 'Saved' : '' }), keptHint && (_jsx("span", { hidden: true, id: keptHintId, children: keptHint })), _jsx("style", { children: LABEL_STYLES }), _jsx(Tooltip, { label: saveTooltip,
94
+ // Not beside "Saving…" or "Saved", nor while busy, except Save all's wait: its reloads flip `busy`.
95
+ disabled: !saveTooltip || label !== 'save' || (busy && !saveAllRunning), multiline: true, maw: 280,
96
+ // Down, away from the top bar, whose Save all a reason opening upward would cover.
97
+ position: "bottom-end", children: _jsx(Button, { "data-testid": "save-button", variant: "filled", size: "sm", style: { flexShrink: 0 }, onClick: () => {
98
+ if (!saveBlocked)
99
+ onSave();
100
+ }, "data-disabled": saveGreyed ? true : undefined, "aria-disabled": saveBlocked ? true : undefined, ...(keptHint ? { 'aria-describedby': keptHintId } : {}), children: _jsx(SaveLabels, { shown: label, spinner: !reducedMotion }) }) })] }));
101
+ }
@@ -5,7 +5,7 @@ export interface NoEditPermissionNoticeProps {
5
5
  /**
6
6
  * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
7
7
  * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives. A locked
8
- * or protected branch instead shows its banner in the header over a read-only form, the rule
8
+ * or protected branch instead says so in the branch bar's status over a read-only form, the rule
9
9
  * `contentReadOnly` in `Editor.tsx` holds.
10
10
  */
11
11
  export declare function NoEditPermissionNotice({ entryLabel }: NoEditPermissionNoticeProps): import("react/jsx-runtime").JSX.Element;
@@ -3,7 +3,7 @@ import { Text } from '@mantine/core';
3
3
  /**
4
4
  * Explains why an entry cannot be edited and whom to ask. An entry's `canEdit` is false only
5
5
  * when a path rule (or `defaultPathAccess`) denies edit, so that is the reason it gives. A locked
6
- * or protected branch instead shows its banner in the header over a read-only form, the rule
6
+ * or protected branch instead says so in the branch bar's status over a read-only form, the rule
7
7
  * `contentReadOnly` in `Editor.tsx` holds.
8
8
  */
9
9
  export function NoEditPermissionNotice({ entryLabel }) {
@@ -4,6 +4,8 @@ export interface RetryToastMessageProps {
4
4
  onRetry: () => void;
5
5
  /** The button's label, when the way forward is not a plain retry. */
6
6
  actionLabel?: string;
7
+ /** A developer diagnostic, shown only once "Details" is opened. */
8
+ details?: string;
7
9
  }
8
10
  /** The body of an error toast whose failure the user can try again from the toast. */
9
- export declare function RetryToastMessage({ message, onRetry, actionLabel, }: RetryToastMessageProps): import("react/jsx-runtime").JSX.Element;
11
+ export declare function RetryToastMessage({ message, onRetry, actionLabel, details, }: RetryToastMessageProps): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,10 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button, Group, Text } from '@mantine/core';
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useId, useState } from 'react';
4
+ import { Anchor, Button, Code, Group, Stack, Text } from '@mantine/core';
3
5
  /** The body of an error toast whose failure the user can try again from the toast. */
4
- export function RetryToastMessage({ message, onRetry, actionLabel = 'Retry', }) {
5
- return (_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, children: actionLabel })] }));
6
+ export function RetryToastMessage({ message, onRetry, actionLabel = 'Retry', details, }) {
7
+ const [detailsOpen, setDetailsOpen] = useState(false);
8
+ const detailsId = useId();
9
+ return (_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", children: [_jsx(Text, { size: "sm", miw: 0, children: message }), _jsx(Button, { size: "compact-sm", variant: "subtle", onClick: onRetry, children: actionLabel })] }), details && (_jsxs(_Fragment, { children: [_jsx(Anchor, { component: "button", type: "button", size: "xs", style: { alignSelf: 'flex-start' }, "aria-expanded": detailsOpen, "aria-controls": detailsOpen ? detailsId : undefined, onClick: () => setDetailsOpen((open) => !open), children: "Details" }), detailsOpen && (_jsx(Code, { id: detailsId, block: true, style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' }, children: details }))] }))] }));
6
10
  }
@@ -0,0 +1,25 @@
1
+ import React, { type ReactNode, type Ref } from 'react';
2
+ import type { SaveAllProgress } from '../hooks/save-all.js';
3
+ export interface SaveAllButtonProps {
4
+ onSaveAll: () => void;
5
+ /** Entries on the branch with unsaved changes; the branch bar shows the count. */
6
+ unsavedCount: number;
7
+ /** Why Save all can't act now, other than its own run, a save in flight or nothing unsaved. */
8
+ blockedReason?: string;
9
+ /** Set while Save all runs. */
10
+ saveAllProgress: SaveAllProgress | null;
11
+ /** A single Save is in flight, on any entry; Save all can't start beside it. */
12
+ singleSaving: boolean;
13
+ /** The editor is loading or saving. */
14
+ busy: boolean;
15
+ /** A caret menu joined to the button, making it a split button. */
16
+ caret?: ReactNode;
17
+ buttonRef?: Ref<HTMLButtonElement>;
18
+ }
19
+ /** Saves every unsaved entry on the branch; the result is a toast (`SaveAllResult`). */
20
+ export declare function SaveAllButton({ onSaveAll, unsavedCount, blockedReason, saveAllProgress, singleSaving, busy, caret, buttonRef, }: SaveAllButtonProps): import("react/jsx-runtime").JSX.Element;
21
+ export declare const HIDDEN_STYLE: React.CSSProperties;
22
+ /** Unsaved changes live only in this tab. Icon-only below `lg`, its text then its Tooltip. */
23
+ export declare function DraftStorageWarning({ shown }: {
24
+ shown: boolean;
25
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { Box, Button, Loader, Text, Tooltip, VisuallyHidden } from '@mantine/core';
5
+ import { useReducedMotion } from '@mantine/hooks';
6
+ import { IconAlertTriangle, IconLoader2 } from '@tabler/icons-react';
7
+ import { EDITOR_ACTIONS } from '../copy.js';
8
+ /**
9
+ * Every label in one grid cell, only `shown` visible, so the button keeps the widest one's width
10
+ * and the top bar doesn't shift as the count or progress changes.
11
+ */
12
+ function SaveAllLabels({ shown, labels }) {
13
+ return (_jsx(Box, { component: "span", style: { display: 'inline-grid', fontVariantNumeric: 'tabular-nums' }, children: labels.map((label, index) => (_jsx(Box, { component: "span", "aria-hidden": index === shown ? undefined : true, style: {
14
+ gridArea: '1 / 1',
15
+ display: 'inline-flex',
16
+ alignItems: 'center',
17
+ justifyContent: 'center',
18
+ gap: 6,
19
+ visibility: index === shown ? 'visible' : 'hidden',
20
+ }, children: label }, index))) }));
21
+ }
22
+ /** Saves every unsaved entry on the branch; the result is a toast (`SaveAllResult`). */
23
+ export function SaveAllButton({ onSaveAll, unsavedCount, blockedReason, saveAllProgress, singleSaving, busy, caret, buttonRef, }) {
24
+ const reducedMotion = useReducedMotion();
25
+ // A running Save all shows only its progress: a lock arriving mid-run would grey it.
26
+ const reason = saveAllProgress
27
+ ? undefined
28
+ : (blockedReason ??
29
+ (singleSaving
30
+ ? 'Wait for the save to finish.'
31
+ : unsavedCount === 0
32
+ ? 'No entry on this branch has unsaved changes.'
33
+ : busy
34
+ ? 'Wait for the current save or load to finish.'
35
+ : undefined));
36
+ // Blocked, it stays focusable (`aria-disabled`, not `disabled`), so its reason opens on focus.
37
+ const blocked = !!reason || !!saveAllProgress;
38
+ // The branch's largest count yet sizes the progress, so the width grows only as a digit is added.
39
+ const count = Math.max(unsavedCount, saveAllProgress?.total ?? 0, 1);
40
+ const [widest, setWidest] = useState(count);
41
+ if (count > widest)
42
+ setWidest(count);
43
+ const progress = (done, total) => (_jsxs(_Fragment, { children: [_jsx(Box, { component: "span", display: "inline-flex", w: 14, children: saveAllProgress &&
44
+ (reducedMotion ? (_jsx(IconLoader2, { size: 14, "aria-hidden": "true" })) : (_jsx(Loader, { size: 14, color: "currentColor" }))) }), `${Math.min(done + 1, total)} of ${total}…`] }));
45
+ // Progress reads "2 of 3…" beside the spinner.
46
+ const labels = [
47
+ EDITOR_ACTIONS.saveAll,
48
+ saveAllProgress ? progress(saveAllProgress.done, saveAllProgress.total) : progress(0, 1),
49
+ progress(widest, widest),
50
+ ];
51
+ return (_jsxs(_Fragment, { children: [_jsx(VisuallyHidden, { role: "status", children: saveAllProgress
52
+ ? `Saving ${saveAllProgress.total} ${saveAllProgress.total === 1 ? 'entry' : 'entries'}…`
53
+ : '' }), _jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => (_jsx("div", { ...props, role: caret ? 'group' : undefined, "aria-label": caret ? 'Save all and branch actions' : undefined })), children: [_jsx(Tooltip, { label: reason, disabled: !reason, multiline: true, maw: 300,
54
+ // Beside its button, in the bar's free middle, so it covers neither the entry's Save
55
+ // below nor a stacked row of the bar; below when the left edge has no room for it.
56
+ position: "left", middlewares: { flip: { fallbackPlacements: ['bottom-end'] }, shift: true }, children: _jsx(Button, { ref: buttonRef, "data-testid": "save-all-button",
57
+ // The open entry's Save is the editor's one filled primary.
58
+ variant: "light", size: "sm", style: { flexShrink: 0 }, onClick: () => {
59
+ if (!blocked)
60
+ onSaveAll();
61
+ }, "data-disabled": reason ? true : undefined, "aria-disabled": blocked ? true : undefined, "aria-label": saveAllProgress
62
+ ? `Saving ${Math.min(saveAllProgress.done + 1, saveAllProgress.total)} of ${saveAllProgress.total}…`
63
+ : undefined, children: _jsx(SaveAllLabels, { shown: saveAllProgress ? 1 : 0, labels: labels }) }) }), caret] })] }));
64
+ }
65
+ const STORAGE_FAILED_TEXT = 'Unsaved · not kept on this device';
66
+ const STORAGE_FAILED_HINT = "Save before you leave or reload, or they'll be lost.";
67
+ // Visually hidden, as Mantine's VisuallyHidden, for a region that must stay mounted.
68
+ export const HIDDEN_STYLE = {
69
+ position: 'absolute',
70
+ width: 1,
71
+ height: 1,
72
+ overflow: 'hidden',
73
+ clip: 'rect(0 0 0 0)',
74
+ whiteSpace: 'nowrap',
75
+ };
76
+ /** Unsaved changes live only in this tab. Icon-only below `lg`, its text then its Tooltip. */
77
+ export function DraftStorageWarning({ shown }) {
78
+ return (
79
+ // Always mounted so the warning is announced; visible, at a fixed width, only when shown.
80
+ _jsx(Box, { role: "status", ta: "right", w: { base: 20, lg: 112 }, style: shown ? { flexShrink: 0 } : HIDDEN_STYLE, children: shown && (_jsx(Tooltip, { label: `${STORAGE_FAILED_TEXT}. ${STORAGE_FAILED_HINT}`, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", lh: 1.2, c: "var(--canopy-danger-text)", "data-testid": "unsaved-indicator", children: [_jsx(Box, { component: "span", visibleFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsx(Box, { component: "span", hiddenFrom: "lg", display: "inline-flex", "aria-hidden": "true", children: _jsx(IconAlertTriangle, { size: 18 }) }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsxs(VisuallyHidden, { component: "span", children: [". ", STORAGE_FAILED_HINT] })] }) })) }));
81
+ }
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { type ElementProps } from '@mantine/core';
3
3
  /**
4
- * The editor's one "unsaved" mark, a dot, in the breadcrumb, the branch pill and the content tree.
4
+ * The editor's one "unsaved" mark, a dot.
5
5
  * `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
6
6
  * The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
7
7
  * It forwards its ref, so a Tooltip can wrap it.
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { forwardRef } from 'react';
4
4
  import { Box } from '@mantine/core';
5
5
  /**
6
- * The editor's one "unsaved" mark, a dot, in the breadcrumb, the branch pill and the content tree.
6
+ * The editor's one "unsaved" mark, a dot.
7
7
  * `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
8
8
  * The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
9
9
  * It forwards its ref, so a Tooltip can wrap it.
@@ -1,3 +1,3 @@
1
- export * from './BranchLockBanner.js';
2
1
  export * from './EditorHeader.js';
3
2
  export * from './EditorRail.js';
3
+ export * from './EntryHeader.js';
@@ -1,3 +1,3 @@
1
- export * from './BranchLockBanner.js';
2
1
  export * from './EditorHeader.js';
3
2
  export * from './EditorRail.js';
3
+ export * from './EntryHeader.js';
@@ -16,7 +16,6 @@ export declare const EDITOR_ACTIONS: {
16
16
  readonly restore: "Restore";
17
17
  readonly clear: "Clear";
18
18
  readonly undoClear: "Undo clear";
19
- readonly openEntry: "Open entry";
20
19
  readonly changeUrl: "Change URL";
21
20
  readonly changeSlug: "Change slug";
22
21
  };
@@ -16,7 +16,6 @@ export const EDITOR_ACTIONS = {
16
16
  restore: 'Restore',
17
17
  clear: 'Clear',
18
18
  undoClear: 'Undo clear',
19
- openEntry: 'Open entry',
20
19
  changeUrl: 'Change URL',
21
20
  changeSlug: 'Change slug',
22
21
  };
@@ -76,8 +76,8 @@ export declare const buildCollectionLabels: (collections?: EditorCollection[]) =
76
76
  */
77
77
  export declare const entryTypeLabel: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
78
78
  /**
79
- * The type shown under an entry's label, in the content tree and quick open: `entryTypeLabel`,
80
- * except for a singleton, whose label already is its type's (`singletonLabel`).
79
+ * The type shown under an entry's label in the content tree: `entryTypeLabel`, except for a
80
+ * singleton, whose label already is its type's (`singletonLabel`). @internal Exported for tests.
81
81
  */
82
82
  export declare const entryTypeLine: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
83
83
  /**
@@ -222,8 +222,8 @@ export const entryTypeLabel = (collections, entry) => {
222
222
  return findEntryType(collections, entry)?.label ?? humanizeName(entry.entryType);
223
223
  };
224
224
  /**
225
- * The type shown under an entry's label, in the content tree and quick open: `entryTypeLabel`,
226
- * except for a singleton, whose label already is its type's (`singletonLabel`).
225
+ * The type shown under an entry's label in the content tree: `entryTypeLabel`, except for a
226
+ * singleton, whose label already is its type's (`singletonLabel`). @internal Exported for tests.
227
227
  */
228
228
  export const entryTypeLine = (collections, entry) => {
229
229
  const type = findEntryType(collections, entry);
@@ -1,22 +1,16 @@
1
- /** Entry search shared by the navigator filter and the quick-open palette. */
2
- export interface SearchableEntry {
1
+ /** Entry search and the detail line shared by the content tree's rows. */
2
+ /** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
3
+ export declare function matchesEntryQuery(entry: {
3
4
  label: string;
4
5
  /** The entry's title, when its label is something else (a singleton's type label). */
5
6
  title?: string;
6
7
  slug?: string;
7
- collectionPath?: string;
8
- collectionName?: string;
9
- }
10
- /** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
11
- export declare function matchesEntryQuery(entry: Pick<SearchableEntry, 'label' | 'title' | 'slug'>, query: string): boolean;
12
- export declare function filterEntries<T extends SearchableEntry>(entries: readonly T[], query: string): T[];
13
- export interface EntryGroup<T> {
14
- heading: string;
15
- entries: T[];
16
- }
8
+ }, query: string): boolean;
17
9
  /**
18
- * Groups entries by collection, in first-seen order, each under its display heading:
19
- * `labelFor(collectionPath)`, else the collection name or path, else `Root`. Two collections
20
- * that share a heading stay separate groups.
10
+ * An entry's detail line, "type · route", with whichever of the two it has: a singleton's label
11
+ * already names its type, and an entry with no page has no route.
21
12
  */
22
- export declare function groupEntriesByCollection<T extends SearchableEntry>(entries: readonly T[], labelFor?: (collectionPath: string) => string | undefined): EntryGroup<T>[];
13
+ export declare const entryDetailLine: ({ typeLabel, route, }: {
14
+ typeLabel?: string;
15
+ route?: string;
16
+ }) => string;
@@ -1,4 +1,4 @@
1
- /** Entry search shared by the navigator filter and the quick-open palette. */
1
+ /** Entry search and the detail line shared by the content tree's rows. */
2
2
  /** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
3
3
  export function matchesEntryQuery(entry, query) {
4
4
  const needle = query.trim().toLowerCase();
@@ -6,28 +6,8 @@ export function matchesEntryQuery(entry, query) {
6
6
  return true;
7
7
  return [entry.label, entry.title, entry.slug].some((text) => text?.toLowerCase().includes(needle));
8
8
  }
9
- export function filterEntries(entries, query) {
10
- return entries.filter((entry) => matchesEntryQuery(entry, query));
11
- }
12
9
  /**
13
- * Groups entries by collection, in first-seen order, each under its display heading:
14
- * `labelFor(collectionPath)`, else the collection name or path, else `Root`. Two collections
15
- * that share a heading stay separate groups.
10
+ * An entry's detail line, "type · route", with whichever of the two it has: a singleton's label
11
+ * already names its type, and an entry with no page has no route.
16
12
  */
17
- export function groupEntriesByCollection(entries, labelFor) {
18
- const groups = new Map();
19
- for (const entry of entries) {
20
- const key = entry.collectionPath ?? entry.collectionName ?? '';
21
- const group = groups.get(key);
22
- if (group) {
23
- group.entries.push(entry);
24
- continue;
25
- }
26
- const heading = (entry.collectionPath ? labelFor?.(entry.collectionPath) : undefined) ??
27
- entry.collectionName ??
28
- entry.collectionPath ??
29
- 'Root';
30
- groups.set(key, { heading, entries: [entry] });
31
- }
32
- return [...groups.values()];
33
- }
13
+ export const entryDetailLine = ({ typeLabel, route, }) => [typeLabel, route].filter(Boolean).join(' · ');
@@ -55,7 +55,7 @@ interface BranchSummary {
55
55
  /**
56
56
  * Server-computed: content writes are rejected, because the branch is the
57
57
  * read-only base branch OR its status is past 'editing'. `readOnly` still
58
- * says WHICH, for banner copy.
58
+ * says WHICH, for the status copy.
59
59
  */
60
60
  writeBlocked: boolean;
61
61
  /**
@@ -90,10 +90,14 @@ export interface UseBranchManagerReturn {
90
90
  currentBranch: BranchListItem | undefined;
91
91
  /** The listing failed and nothing earlier stands in for it. */
92
92
  branchesLoadFailed: boolean;
93
+ /** A listing has arrived, so a branch missing from it is not still loading. */
94
+ branchesLoaded: boolean;
93
95
  /** Shows a branch the server just created before any listing includes it. */
94
96
  addCreatedBranch: (branch: BranchListItem) => void;
95
- handleSubmit: (branchName: string) => Promise<void>;
96
- handleWithdraw: (branchName: string) => Promise<void>;
97
+ /** Confirms first; the dialog returns focus to `returnFocusTo()` when given, else its opener. */
98
+ handleSubmit: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
99
+ /** Confirms first; the dialog returns focus to `returnFocusTo()` when given, else its opener. */
100
+ handleWithdraw: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
97
101
  handleRequestChanges: (branchName: string) => Promise<void>;
98
102
  handleDelete: (branchName: string) => Promise<void>;
99
103
  handleReloadBranchData: () => Promise<void>;
@@ -5,6 +5,7 @@ import { useSWRConfig } from 'swr';
5
5
  import { Alert, Stack, Text } from '@mantine/core';
6
6
  import { IconAlertTriangle } from '@tabler/icons-react';
7
7
  import { openConfirm } from '../utils/confirm-modal.js';
8
+ import { focusReturnProps } from '../utils/focus-return.js';
8
9
  import { EDITOR_ACTIONS } from '../copy.js';
9
10
  import { notifications } from '@mantine/notifications';
10
11
  import { useApiClient } from '../context/index.js';
@@ -71,7 +72,7 @@ const confirmModalHandlers = (onConfirm, onDismiss) => {
71
72
  * With `unsaved` edits on the branch, the dialog says they are left out: a submit sends only what
72
73
  * is saved, and the branch is read-only in review, so they wait as drafts until it is editable.
73
74
  */
74
- const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss) => {
75
+ const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss, returnFocusTo) => {
75
76
  const leftOut = unsaved && unsaved.count > 0 ? unsaved : undefined;
76
77
  openConfirm({
77
78
  title: EDITOR_ACTIONS.submitForReview,
@@ -82,6 +83,7 @@ const showSubmitConfirmation = (branchName, unsaved, onConfirm, onDismiss) => {
82
83
  },
83
84
  confirmProps: { color: 'brand', 'data-testid': 'confirm-submit-branch' },
84
85
  ...confirmModalHandlers(onConfirm, onDismiss),
86
+ ...focusReturnProps(returnFocusTo),
85
87
  });
86
88
  };
87
89
  /**
@@ -102,7 +104,7 @@ export function withdrawPullRequestBullet(pr) {
102
104
  return `Leave the merged ${label} as it is`;
103
105
  return `Convert ${label} to a draft`;
104
106
  }
105
- const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss) => {
107
+ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo) => {
106
108
  const bullets = [
107
109
  withdrawPullRequestBullet(pr),
108
110
  'Change the branch status back to "editing"',
@@ -114,6 +116,7 @@ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss) => {
114
116
  labels: { confirm: EDITOR_ACTIONS.withdrawFromReview, cancel: 'Cancel' },
115
117
  confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
116
118
  ...confirmModalHandlers(onConfirm, onDismiss),
119
+ ...focusReturnProps(returnFocusTo),
117
120
  });
118
121
  };
119
122
  const showRequestChangesConfirmation = (branchName, onConfirm, onDismiss) => {
@@ -317,11 +320,11 @@ export function useBranchManager(options) {
317
320
  // (see Editor.tsx's `branchContentLocked` for the full rationale, which
318
321
  // applies identically here).
319
322
  isProtected: b.isProtected ?? true,
320
- // NOT flipped to `?? true`: `readOnly` only selects WHICH lock banner
321
- // to show (base-branch read-only vs. status lock) once something is
323
+ // NOT flipped to `?? true`: `readOnly` only selects WHICH lock the status
324
+ // names (base-branch read-only vs. status lock) once something is
322
325
  // already known to be locked via `writeBlocked`/`submitBlocked` --
323
326
  // it never gates a write by itself. Defaulting it true on missing data
324
- // would mislabel a status lock as a base-branch lock (wrong banner
327
+ // would mislabel a status lock as a base-branch lock (wrong status
325
328
  // copy), not under-lock anything, so `?? false` stays correct here.
326
329
  readOnly: b.readOnly ?? false,
327
330
  writeBlocked: b.writeBlocked ?? true,
@@ -407,7 +410,7 @@ export function useBranchManager(options) {
407
410
  }
408
411
  notifications.show({ message: err instanceof Error ? err.message : fallback, color: 'red' });
409
412
  };
410
- const handleSubmit = (requested) => {
413
+ const handleSubmit = (requested, returnFocusTo) => {
411
414
  const branchNameToSubmit = listedName(requested);
412
415
  return singleFlight('submit', branchNameToSubmit, async () => {
413
416
  // Drafts belong to the open branch, so only its submit can leave edits behind. Without
@@ -464,11 +467,11 @@ export function useBranchManager(options) {
464
467
  },
465
468
  // Dismissal is not an error: the call sites log rejections to the console. Settled
466
469
  // once, by onCancel OR onClose, so an Escape/overlay dismissal can't leave this pending.
467
- () => resolve());
470
+ () => resolve(), returnFocusTo);
468
471
  });
469
472
  });
470
473
  };
471
- const handleWithdraw = (requested) => {
474
+ const handleWithdraw = (requested, returnFocusTo) => {
472
475
  const branchNameToWithdraw = listedName(requested);
473
476
  return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
474
477
  showWithdrawConfirmation(branchNameToWithdraw, pullRequestOf(branchNameToWithdraw), async () => {
@@ -491,7 +494,7 @@ export function useBranchManager(options) {
491
494
  finally {
492
495
  options.setBusy(false);
493
496
  }
494
- }, () => resolve());
497
+ }, () => resolve(), returnFocusTo);
495
498
  }));
496
499
  };
497
500
  const handleRequestChanges = (branchNameForChanges) => singleFlight('request-changes', branchNameForChanges, () => new Promise((resolve) => {
@@ -605,6 +608,7 @@ export function useBranchManager(options) {
605
608
  branchSummaries,
606
609
  currentBranch,
607
610
  branchesLoadFailed: !!branchesError && !branchesData,
611
+ branchesLoaded: !!branchesData,
608
612
  addCreatedBranch,
609
613
  handleSubmit,
610
614
  handleWithdraw,
@@ -3,6 +3,7 @@ import type { EditorEntry, EditorCollection } from '../Editor.js';
3
3
  import type { LogicalPath } from '../../paths/types.js';
4
4
  import type { FormValue } from '../FormRenderer.js';
5
5
  import type { EntryFieldError } from '../../validation/entry-validator.js';
6
+ import type { BranchBarStatus } from '../branch-lock.js';
6
7
  /** @internal Exported for tests. */
7
8
  export declare const RESTORED_ENTRY_GONE_MESSAGE = "The entry you returned to is no longer on this branch, so the home page opened.";
8
9
  /** @internal Exported for tests. */
@@ -39,6 +40,8 @@ export interface UseEntryManagerOptions {
39
40
  /** An entry's route on the site (`buildPreviewRoute`), which picks the home page to open first. */
40
41
  resolvePreviewRoute?: (entry: EditorEntry) => string | undefined;
41
42
  setBusy: (busy: boolean) => void;
43
+ /** The bar's row: a failed load is toasted after 'loading' ends, and never on 'not-available'. */
44
+ branchBarState?: BranchBarStatus['value'];
42
45
  /** Switches to the branch a Back or Forward reached; rejects when the switch does not happen. */
43
46
  switchBranch?: (branch: string) => Promise<void>;
44
47
  /** Called when a Back or Forward has opened another entry. */
@@ -11,6 +11,7 @@ import { entriesKey, fetchEntriesAndSchema } from './useEntriesData.js';
11
11
  import { RetryToastMessage } from '../components/RetryToastMessage.js';
12
12
  import { isGatewayFailure, isUnanswered, MayHaveLandedError, UnansweredError } from '../unanswered.js';
13
13
  import { sanitizeBranchName } from '../../paths/branch-name.js';
14
+ import { isApiTimeoutError } from '../../api/client.js';
14
15
  import { markEditorHistoryStart, recordHistoryMove, undoHistoryMove, writeEditorUrl, } from './editor-history.js';
15
16
  const ENTRIES_LOAD_FAILED_TOAST_ID = 'canopy-entries-load-failed';
16
17
  /** @internal Exported for tests. */
@@ -706,12 +707,14 @@ export function useEntryManager(options) {
706
707
  // to a branch that fails again still reports.
707
708
  const notifiedErrorBranchRef = useRef(null);
708
709
  useEffect(() => {
709
- if (!entriesError || !options.branchName) {
710
+ if (!entriesError || !options.branchName || options.branchBarState === 'not-available') {
710
711
  if (notifiedErrorBranchRef.current !== null)
711
712
  notifications.hide(ENTRIES_LOAD_FAILED_TOAST_ID);
712
713
  notifiedErrorBranchRef.current = null;
713
714
  return;
714
715
  }
716
+ if (options.branchBarState === 'loading')
717
+ return;
715
718
  if (notifiedErrorBranchRef.current === options.branchName)
716
719
  return;
717
720
  const branch = options.branchName;
@@ -722,8 +725,12 @@ export function useEntryManager(options) {
722
725
  notifications.show({
723
726
  id: ENTRIES_LOAD_FAILED_TOAST_ID,
724
727
  title: 'Could not load content',
728
+ // A timeout's message is written for people; any other is a diagnostic, kept for Details.
725
729
  message: createElement(RetryToastMessage, {
726
- message: getErrorMessage(entriesError),
730
+ message: isApiTimeoutError(entriesError)
731
+ ? entriesError.message
732
+ : "The editor couldn't fetch this branch's entries.",
733
+ details: isApiTimeoutError(entriesError) ? undefined : getErrorMessage(entriesError),
727
734
  onRetry: () => {
728
735
  notifications.hide(ENTRIES_LOAD_FAILED_TOAST_ID);
729
736
  notifiedErrorBranchRef.current = null;
@@ -733,7 +740,7 @@ export function useEntryManager(options) {
733
740
  color: 'red',
734
741
  autoClose: false,
735
742
  });
736
- }, [entriesError, options.branchName]);
743
+ }, [entriesError, options.branchName, options.branchBarState]);
737
744
  // Validate selected entry when entries change
738
745
  useEffect(() => {
739
746
  if (selectedPath)
@@ -26,10 +26,14 @@ export interface RailButtonProps extends Omit<ComponentPropsWithoutRef<'button'>
26
26
  shortcut?: string;
27
27
  /** A count badge, hidden at zero; the label should say it too. */
28
28
  count?: number;
29
+ /** A mark at the button's top-right corner, such as an unsaved dot; the label should say it too. */
30
+ corner?: ReactNode;
29
31
  countTestId?: string;
30
32
  tooltipDisabled?: boolean;
31
33
  /** Disables the button, which stays focusable, and its tooltip gives this reason instead. */
32
34
  disabledReason?: string;
33
35
  }
36
+ /** Sets the buttons after it apart. It carries no `data-rail-item`, so the arrow keys skip it. */
37
+ export declare function RailDivider(): import("react/jsx-runtime").JSX.Element;
34
38
  /** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
35
39
  export declare const RailButton: React.ForwardRefExoticComponent<RailButtonProps & React.RefAttributes<HTMLButtonElement>>;