canopycms 0.2.0-int.121 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +241 -78
  2. package/dist/api/branch.d.ts +2 -2
  3. package/dist/cli/cli.js +0 -18
  4. package/dist/cli/generate-ai-content.js +0 -6
  5. package/dist/cli/init.js +0 -6
  6. package/dist/dev-content-watcher.js +8 -1
  7. package/dist/editor/BranchManager.d.ts +2 -0
  8. package/dist/editor/BranchManager.js +3 -3
  9. package/dist/editor/Editor.js +276 -130
  10. package/dist/editor/EntryNavigator.d.ts +44 -9
  11. package/dist/editor/EntryNavigator.js +278 -104
  12. package/dist/editor/branch-lock.d.ts +42 -0
  13. package/dist/editor/branch-lock.js +79 -0
  14. package/dist/editor/branch-status.d.ts +0 -6
  15. package/dist/editor/branch-status.js +0 -10
  16. package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
  17. package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
  18. package/dist/editor/components/BranchPill.d.ts +3 -18
  19. package/dist/editor/components/BranchPill.js +7 -10
  20. package/dist/editor/components/BranchRowLock.d.ts +3 -1
  21. package/dist/editor/components/BranchRowLock.js +4 -2
  22. package/dist/editor/components/EditorHeader.d.ts +24 -52
  23. package/dist/editor/components/EditorHeader.js +151 -71
  24. package/dist/editor/components/EditorRail.d.ts +8 -8
  25. package/dist/editor/components/EditorRail.js +7 -5
  26. package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
  27. package/dist/editor/components/EntryBreadcrumb.js +3 -3
  28. package/dist/editor/components/EntryHeader.d.ts +60 -0
  29. package/dist/editor/components/EntryHeader.js +45 -0
  30. package/dist/editor/components/EntrySaveButton.d.ts +24 -0
  31. package/dist/editor/components/EntrySaveButton.js +101 -0
  32. package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
  33. package/dist/editor/components/NoEditPermissionNotice.js +1 -1
  34. package/dist/editor/components/RetryToastMessage.d.ts +3 -1
  35. package/dist/editor/components/RetryToastMessage.js +8 -4
  36. package/dist/editor/components/SaveAllButton.d.ts +25 -0
  37. package/dist/editor/components/SaveAllButton.js +81 -0
  38. package/dist/editor/components/UnsavedMark.d.ts +1 -1
  39. package/dist/editor/components/UnsavedMark.js +1 -1
  40. package/dist/editor/components/index.d.ts +1 -1
  41. package/dist/editor/components/index.js +1 -1
  42. package/dist/editor/copy.d.ts +0 -1
  43. package/dist/editor/copy.js +0 -1
  44. package/dist/editor/editor-utils.d.ts +2 -2
  45. package/dist/editor/editor-utils.js +2 -2
  46. package/dist/editor/entry-search.d.ts +10 -16
  47. package/dist/editor/entry-search.js +4 -24
  48. package/dist/editor/hooks/editor-history.d.ts +7 -0
  49. package/dist/editor/hooks/editor-history.js +80 -0
  50. package/dist/editor/hooks/useBranchActions.js +3 -1
  51. package/dist/editor/hooks/useBranchManager.d.ts +7 -3
  52. package/dist/editor/hooks/useBranchManager.js +18 -11
  53. package/dist/editor/hooks/useEntryManager.d.ts +12 -2
  54. package/dist/editor/hooks/useEntryManager.js +141 -20
  55. package/dist/editor/shell/ActivityRail.d.ts +4 -0
  56. package/dist/editor/shell/ActivityRail.js +13 -4
  57. package/dist/editor/shell/EditorShell.d.ts +6 -7
  58. package/dist/editor/shell/EditorShell.js +9 -13
  59. package/dist/editor/shell/PaneLayout.d.ts +1 -0
  60. package/dist/editor/shell/PaneLayout.js +23 -5
  61. package/dist/editor/shell/SidePanelHost.d.ts +11 -4
  62. package/dist/editor/shell/SidePanelHost.js +95 -7
  63. package/dist/editor/shell/TopBar.d.ts +4 -4
  64. package/dist/editor/shell/TopBar.js +6 -3
  65. package/dist/editor/theme.js +22 -1
  66. package/dist/editor/utils/focus-return.d.ts +10 -2
  67. package/dist/editor/utils/focus-return.js +40 -6
  68. package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
  69. package/dist/operating-mode/client-safe-strategy.js +1 -10
  70. package/dist/operating-mode/types.d.ts +0 -3
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/package.json +1 -1
  74. package/dist/editor/components/BranchLockBanner.d.ts +0 -23
  75. package/dist/editor/components/BranchLockBanner.js +0 -66
  76. package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
  77. package/dist/editor/components/QuickOpenPalette.js +0 -71
  78. package/dist/editor/components/SaveSplitButton.d.ts +0 -31
  79. package/dist/editor/components/SaveSplitButton.js +0 -121
@@ -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(' · ');
@@ -0,0 +1,7 @@
1
+ /** Shows `url`, pushed, or replaced keeping the entry's other state; never rewrites the URL shown. */
2
+ export declare function writeEditorUrl(url: string, push: boolean): void;
3
+ /** Stamps the history entry the editor opened on, keeping Next.js's keys: the URL is unchanged. */
4
+ export declare function markEditorHistoryStart(): void;
5
+ /** On `popstate`: entries moved, undefined if an end is not this run's, null for an undo's. */
6
+ export declare function recordHistoryMove(): number | undefined | null;
7
+ export declare function undoHistoryMove(moved: number): void;
@@ -0,0 +1,80 @@
1
+ /** Session-history writes stamped with their position, so a refused Back can be undone. */
2
+ const STAMP_KEY = 'canopyHistory';
3
+ // Next.js's router keys: its patched history methods copy `__NA` and the tree back and record the
4
+ // URL, but do neither for state already holding `__NA` or `_N` (app-router.js, next 15.5).
5
+ const ROUTER_KEYS = ['__NA', '__PRIVATE_NEXTJS_INTERNALS_TREE', '_N'];
6
+ // A run restarts wherever the position is lost, so positions from two runs are never compared.
7
+ let run = '';
8
+ let position;
9
+ let undoTarget;
10
+ const newRun = () => {
11
+ run = Math.random().toString(36).slice(2);
12
+ position = undefined;
13
+ };
14
+ const currentState = () => {
15
+ const state = window.history.state;
16
+ return state !== null && typeof state === 'object' ? { ...state } : {};
17
+ };
18
+ const stampIn = (state) => {
19
+ const value = state[STAMP_KEY];
20
+ if (value === null || typeof value !== 'object')
21
+ return undefined;
22
+ const [stampRun, at] = [Reflect.get(value, 'run'), Reflect.get(value, 'position')];
23
+ return typeof stampRun === 'string' && typeof at === 'number'
24
+ ? { run: stampRun, position: at }
25
+ : undefined;
26
+ };
27
+ const samePage = (a, b) => {
28
+ const [x, y] = [new URL(a, window.location.href), new URL(b, window.location.href)];
29
+ const query = (u) => new URLSearchParams(u.search).toString();
30
+ return (x.origin === y.origin && x.pathname === y.pathname && query(x) === query(y) && x.hash === y.hash);
31
+ };
32
+ /** Shows `url`, pushed, or replaced keeping the entry's other state; never rewrites the URL shown. */
33
+ export function writeEditorUrl(url, push) {
34
+ if (samePage(url, window.location.href))
35
+ return;
36
+ if (!run)
37
+ newRun();
38
+ const at = (position ?? 0) + (push ? 1 : 0);
39
+ const kept = push ? {} : currentState();
40
+ for (const key of ROUTER_KEYS)
41
+ delete kept[key];
42
+ const method = push ? window.history.pushState : window.history.replaceState;
43
+ method.call(window.history, { ...kept, [STAMP_KEY]: { run, position: at } }, '', url);
44
+ position = at;
45
+ }
46
+ /** Stamps the history entry the editor opened on, keeping Next.js's keys: the URL is unchanged. */
47
+ export function markEditorHistoryStart() {
48
+ const state = currentState();
49
+ const stamp = stampIn(state);
50
+ if (stamp) {
51
+ run = stamp.run;
52
+ position = stamp.position;
53
+ return;
54
+ }
55
+ newRun();
56
+ position = 0;
57
+ window.history.replaceState({ ...state, [STAMP_KEY]: { run, position } }, '', window.location.href);
58
+ }
59
+ /** On `popstate`: entries moved, undefined if an end is not this run's, null for an undo's. */
60
+ export function recordHistoryMove() {
61
+ const arrived = stampIn(currentState());
62
+ const undone = undoTarget !== undefined &&
63
+ arrived?.run === undoTarget.run &&
64
+ arrived.position === undoTarget.position;
65
+ undoTarget = undefined;
66
+ const moved = arrived?.run === run && position !== undefined ? arrived.position - position : undefined;
67
+ if (arrived) {
68
+ run = arrived.run;
69
+ position = arrived.position;
70
+ }
71
+ else
72
+ newRun();
73
+ return undone ? null : moved;
74
+ }
75
+ export function undoHistoryMove(moved) {
76
+ if (position === undefined)
77
+ return;
78
+ undoTarget = { run, position: position - moved };
79
+ window.history.go(-moved);
80
+ }
@@ -5,6 +5,7 @@ import { notifications } from '@mantine/notifications';
5
5
  import { Text } from '@mantine/core';
6
6
  import { useApiClient } from '../context/index.js';
7
7
  import { requestBranchCreate } from './create-branch-request.js';
8
+ import { writeEditorUrl } from './editor-history.js';
8
9
  const MAX_LISTED_LABELS = 5;
9
10
  /** 'Unsaved changes in: "A", "B".' */
10
11
  export const describeUnsaved = ({ labels }) => {
@@ -25,10 +26,11 @@ export function useBranchActions(options) {
25
26
  const [confirmOpen, setConfirmOpen] = useState(false);
26
27
  const performBranchSwitch = (next) => {
27
28
  options.setBranchName(next);
29
+ // Its own history entry; a Back onto another branch already shows this URL, so writes nothing.
28
30
  if (typeof window !== 'undefined') {
29
31
  const url = new URL(window.location.href);
30
32
  url.searchParams.set('branch', next);
31
- window.history.replaceState({}, '', url.toString());
33
+ writeEditorUrl(url.toString(), true);
32
34
  }
33
35
  options.onBranchSwitch?.(next);
34
36
  };
@@ -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>;