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.
- package/CHANGELOG.md +213 -41
- package/dist/api/branch.d.ts +2 -2
- package/dist/api/branch.js +4 -31
- package/dist/authorization/branch.js +3 -4
- package/dist/cli/cli.js +0 -18
- package/dist/cli/generate-ai-content.js +0 -6
- package/dist/cli/init.js +0 -6
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +3 -3
- package/dist/editor/Editor.js +253 -128
- package/dist/editor/EntryNavigator.d.ts +44 -9
- package/dist/editor/EntryNavigator.js +275 -103
- package/dist/editor/branch-lock.d.ts +42 -0
- package/dist/editor/branch-lock.js +79 -0
- package/dist/editor/branch-status.d.ts +0 -6
- package/dist/editor/branch-status.js +0 -10
- package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
- package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
- package/dist/editor/components/BranchPill.d.ts +3 -18
- package/dist/editor/components/BranchPill.js +7 -10
- package/dist/editor/components/EditorHeader.d.ts +24 -52
- package/dist/editor/components/EditorHeader.js +151 -71
- package/dist/editor/components/EditorRail.d.ts +8 -8
- package/dist/editor/components/EditorRail.js +7 -5
- package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
- package/dist/editor/components/EntryBreadcrumb.js +3 -3
- package/dist/editor/components/EntryHeader.d.ts +60 -0
- package/dist/editor/components/EntryHeader.js +45 -0
- package/dist/editor/components/EntrySaveButton.d.ts +24 -0
- package/dist/editor/components/EntrySaveButton.js +101 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
- package/dist/editor/components/NoEditPermissionNotice.js +1 -1
- package/dist/editor/components/RetryToastMessage.d.ts +3 -1
- package/dist/editor/components/RetryToastMessage.js +8 -4
- package/dist/editor/components/SaveAllButton.d.ts +25 -0
- package/dist/editor/components/SaveAllButton.js +81 -0
- package/dist/editor/components/UnsavedMark.d.ts +1 -1
- package/dist/editor/components/UnsavedMark.js +1 -1
- package/dist/editor/components/index.d.ts +1 -1
- package/dist/editor/components/index.js +1 -1
- package/dist/editor/copy.d.ts +0 -1
- package/dist/editor/copy.js +0 -1
- package/dist/editor/editor-utils.d.ts +2 -2
- package/dist/editor/editor-utils.js +2 -2
- package/dist/editor/entry-search.d.ts +10 -16
- package/dist/editor/entry-search.js +4 -24
- package/dist/editor/hooks/useBranchManager.d.ts +7 -3
- package/dist/editor/hooks/useBranchManager.js +13 -9
- package/dist/editor/hooks/useEntryManager.d.ts +3 -0
- package/dist/editor/hooks/useEntryManager.js +10 -3
- package/dist/editor/shell/ActivityRail.d.ts +4 -0
- package/dist/editor/shell/ActivityRail.js +13 -4
- package/dist/editor/shell/EditorShell.d.ts +6 -7
- package/dist/editor/shell/EditorShell.js +9 -13
- package/dist/editor/shell/PaneLayout.d.ts +1 -0
- package/dist/editor/shell/PaneLayout.js +23 -5
- package/dist/editor/shell/SidePanelHost.d.ts +11 -4
- package/dist/editor/shell/SidePanelHost.js +95 -7
- package/dist/editor/shell/TopBar.d.ts +4 -4
- package/dist/editor/shell/TopBar.js +6 -3
- package/dist/editor/utils/focus-return.d.ts +10 -2
- package/dist/editor/utils/focus-return.js +40 -6
- package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
- package/dist/operating-mode/client-safe-strategy.js +1 -10
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/editor/components/BranchLockBanner.d.ts +0 -23
- package/dist/editor/components/BranchLockBanner.js +0 -66
- package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
- package/dist/editor/components/QuickOpenPalette.js +0 -71
- package/dist/editor/components/SaveSplitButton.d.ts +0 -31
- 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
|
|
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
|
|
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
|
-
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
package/dist/editor/copy.d.ts
CHANGED
package/dist/editor/copy.js
CHANGED
|
@@ -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
|
|
80
|
-
*
|
|
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
|
|
226
|
-
*
|
|
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
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
13
|
+
export declare const entryDetailLine: ({ typeLabel, route, }: {
|
|
14
|
+
typeLabel?: string;
|
|
15
|
+
route?: string;
|
|
16
|
+
}) => string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Entry search
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
321
|
-
//
|
|
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
|
|
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:
|
|
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>>;
|