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
@@ -1,61 +1,118 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Button, Tooltip } from '@mantine/core';
3
- import { clientOperatingStrategy } from '../../operating-mode/client.js';
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef } from 'react';
3
+ import { Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
4
+ import { useMediaQuery, useMergedRef } from '@mantine/hooks';
5
+ import { IconChevronDown, IconLock } from '@tabler/icons-react';
4
6
  import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
5
- import { branchBarStatusPresentation } from '../branch-status.js';
7
+ import { branchBarStatus, describeBranchLock } from '../branch-lock.js';
6
8
  import { EDITOR_ACTIONS } from '../copy.js';
7
9
  import { SYSTEM_USER_ID } from '../../system-users.js';
8
10
  import { TopBar } from '../shell/TopBar.js';
9
- import { describeBranchLock } from './BranchLockBanner.js';
10
11
  import { BranchPill } from './BranchPill.js';
11
- import { EntryBreadcrumb } from './EntryBreadcrumb.js';
12
- import { SaveSplitButton } from './SaveSplitButton.js';
12
+ import { ReasonedMenuItem } from './ReasonedMenuItem.js';
13
+ import { UnsavedMark } from './UnsavedMark.js';
14
+ import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButton.js';
15
+ import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.js';
13
16
  /**
14
- * The top bar's contents: the breadcrumb with the entry menu and "Read-only" chip, then the branch
15
- * pill, Submit or Withdraw, and Save with Save all.
17
+ * The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
18
+ * warning, Save all, the way forward and Submit or Withdraw. The open entry's own controls are in
19
+ * `EntryHeader`.
16
20
  */
17
- export function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving = false, savedSignal, savedHere = false, entryListFailed = false, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, }) {
18
- // Shared by Save and Save all; set exactly when BranchLockBanner renders.
19
- const branchLockReason = describeBranchLock({ branchStatus, branchReadOnly, branchWriteBlocked });
20
- const entryLockReason = entryReadOnly
21
- ? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
22
- : undefined;
23
- // The Tooltip hides it while the editor is busy.
24
- const saveBlockedReason = branchLockReason ??
25
- (!branchName
26
- ? 'Open a branch first.'
27
- : !currentEntry
28
- ? 'Open an entry to save it.'
29
- : (entryLockReason ?? (!hasUnsavedChanges ? 'No changes to save.' : '')));
30
- const presentation = branchBarStatusPresentation(branchStatus, branchIsProtected);
31
- const status = !branchLoading &&
32
- clientOperatingStrategy(operatingMode ?? 'prod').supportsStatusBadge() &&
33
- presentation
34
- ? {
35
- label: presentation.label,
36
- value: branchIsProtected ? 'protected' : String(branchStatus),
37
- testId: `header-status-badge-${branchIsProtected ? 'protected' : branchStatus}`,
38
- }
39
- : undefined;
40
- return (_jsx(TopBar, { leading: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryDirty: hasUnsavedChanges,
41
- // BranchLockBanner already says why a locked branch is read-only, so the chip carries
42
- // only the entry's own reason, even beside the banner: "Withdraw it" would not unlock it.
43
- readOnlyReason: branchLoading ? undefined : currentEntry || branchName ? entryLockReason : undefined, onCollection: onCollectionOpen, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
44
- // A path rule's refusal hides them, as the content tree hides that entry's "…" menu.
45
- onRename: entryReadOnly ? undefined : onEntryRename,
46
- // An entry with a page has a preview `src`; one without changes only its slug.
47
- entryNoPage: !!currentEntry && !currentEntry.previewSrc, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
48
- (entryUnavailable
49
- ? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
50
- : undefined), entryMenuRef: navigatorToggleRef }), trailing: _jsxs(_Fragment, { children: [_jsx(BranchPill, { branchName: branchName, loading: busy, status: status, unsavedCount: modifiedCount, unsavedEntries: editedFiles, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, onEntrySelect: onEntrySelect, pillRef: branchToggleRef }), _jsx(WorkflowButton, { branchName: branchName, busy: busy, branchStatus: branchStatus, userContext: userContext, branchCreatedBy: branchCreatedBy, branchAccess: branchAccess, branchIsProtected: branchIsProtected, onSubmit: onSubmit, onWithdraw: onWithdraw, savingAll: !!saveAllProgress }), _jsx(SaveSplitButton, { onSave: onSave, saveBlockedReason: saveBlockedReason, busy: busy, onSaveAll: onSaveAll, unsavedCount: modifiedCount, saveAllBlockedReason: branchLockReason ??
21
+ export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
22
+ const lock = { branchStatus, branchReadOnly, branchWriteBlocked, branchPending, branchMissing };
23
+ const branchLockReason = describeBranchLock(lock);
24
+ const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
25
+ const theme = useMantineTheme();
26
+ // Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
27
+ const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
28
+ const narrow = useMediaQuery(belowMd, false, { getInitialValueInEffect: false }) === true;
29
+ const editable = !!branchName && !branchLoading && !branchLockReason;
30
+ const workflow = workflowAction({
31
+ branchName,
32
+ busy,
33
+ branchStatus,
34
+ userContext,
35
+ branchCreatedBy,
36
+ branchAccess,
37
+ branchIsProtected,
38
+ savingAll: !!saveAllProgress,
39
+ });
40
+ // Below `md` one split button holds the bar's actions: Save all on an editable branch, else the
41
+ // row's way forward, with the workflow action in its caret. Withdraw leads only for a user who
42
+ // may withdraw; anyone else gets Switch branch…, the blocked Withdraw and its reason in the
43
+ // caret. A protected branch keeps New branch… either way. Wide, a Withdraw the user can't take
44
+ // shows blocked, with no Switch branch… beside it: that stays in the pill's menu.
45
+ const split = narrow && editable;
46
+ const workflowLeads = status.action === 'workflow' && !!workflow?.permitted;
47
+ const barAction = status.action === 'workflow'
48
+ ? narrow && !workflowLeads
49
+ ? 'switchBranch'
50
+ : undefined
51
+ : status.action;
52
+ // Controls come and go as a submit or withdraw changes the lock, and focus follows them.
53
+ const [[wayRef, saveAllRef, workflowRef], focusTarget] = useFocusHandOff(3);
54
+ const workflowControl = {
55
+ onSubmit,
56
+ onWithdraw,
57
+ controlRef: workflowRef,
58
+ // A withdraw that leaves no workflow action returns focus to the way forward, else Save all.
59
+ returnFocusTo: () => focusTarget(2),
60
+ };
61
+ const caretOnWay = narrow && !!barAction && !!workflow;
62
+ return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, !!workflow?.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
63
+ // Its reserved width follows this branch's counts, not the session's largest.
64
+ , { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
51
65
  (!branchName
52
66
  ? 'Open a branch first.'
53
67
  : entryListFailed && modifiedCount > 0
54
68
  ? 'The list of entries couldn\'t be loaded, so these changes can\'t be saved yet. Choose "Refresh branch" in the branch menu to try again.'
55
- : undefined), saveAllProgress: saveAllProgress, saving: saving, savedSignal: savedSignal, savedHere: savedHere, entryDirty: hasUnsavedChanges, draftStorageFailed: draftStorageFailed })] }) }));
69
+ : undefined), saveAllProgress: saveAllProgress, singleSaving: singleSaving, busy: busy, buttonRef: saveAllRef, caret: split && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined }, branchName)), barAction && (_jsx(BranchWayButton, { action: barAction, buttonRef: wayRef, onClick: barAction === 'newBranch'
70
+ ? onNewBranch
71
+ : barAction === 'refreshBranch'
72
+ ? onBranchReloadData
73
+ : onBranchManagerOpen, caret: caretOnWay && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined })), !split && !caretOnWay && workflow && (_jsx(WorkflowButton, { ...workflowControl, action: workflow }))] }) }));
56
74
  }
57
- /** Submit for review, or Withdraw from review once submitted or approved. */
58
- function WorkflowButton({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, onSubmit, onWithdraw, savingAll, }) {
75
+ const TOOLTIP_EVENTS = { hover: true, focus: true, touch: false };
76
+ /**
77
+ * On a locked branch, what would let its drafts be saved. A new branch starts without them, so a
78
+ * branch never editable again leaves only discarding, as `ReadOnlyDraftNotice` says.
79
+ */
80
+ function unsavedTooltip(status, count, mayWithdraw) {
81
+ if (!status?.readOnly)
82
+ return 'Show unsaved entries in the content panel';
83
+ const edits = `${count} unsaved ${count === 1 ? 'edit' : 'edits'} in this browser.`;
84
+ if (status.action === 'workflow' && mayWithdraw)
85
+ return `${edits} Withdraw to save them.`;
86
+ if (status.value === 'unavailable')
87
+ return `${edits} Use "Refresh branch" to load its details.`;
88
+ if (status.value === 'not-available')
89
+ return `${edits} They can't be saved unless this branch is available to you.`;
90
+ if (status.value === 'protected' || status.value === 'archived')
91
+ return `${edits} This branch won't become editable, so they can only be discarded.`;
92
+ return `${edits} They can be saved once the branch is editable again.`;
93
+ }
94
+ /** The branch's state beside the pill, heard in words with its reason, but not while it loads. */
95
+ function BranchStatusLabel({ status, reason }) {
96
+ const spoken = status.label && status.value !== 'loading'
97
+ ? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
98
+ : '';
99
+ return (
100
+ // Always mounted, so a lock that arrives is announced.
101
+ _jsxs(Box, { role: "status", style: status.label ? { flexShrink: 0 } : HIDDEN_STYLE, children: [status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), _jsx(VisuallyHidden, { children: spoken })] }));
102
+ }
103
+ const WAY_LABEL = {
104
+ newBranch: `${EDITOR_ACTIONS.newBranch}…`,
105
+ switchBranch: `${EDITOR_ACTIONS.switchBranch}…`,
106
+ refreshBranch: 'Refresh branch',
107
+ };
108
+ /** The way forward from a locked branch: New branch…, Switch branch… or Refresh branch. */
109
+ function BranchWayButton({ action, onClick, caret, buttonRef, }) {
110
+ return (
111
+ // Grouped even alone, as Save all is, so a caret coming or going never remounts the button.
112
+ _jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => (_jsx("div", { ...props, role: caret ? 'group' : undefined, "aria-label": caret ? 'Branch controls' : undefined })), children: [_jsx(Button, { ref: buttonRef, variant: "light", size: "sm", onClick: () => onClick(), "data-testid": `${action.replace('Branch', '')}-branch-button`, children: WAY_LABEL[action] }), caret] }));
113
+ }
114
+ /** Submit for review, or Withdraw from review once submitted or approved; null when neither shows. */
115
+ function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, savingAll, }) {
59
116
  const isSubmitted = branchStatus === 'submitted';
60
117
  const isEditing = branchStatus === 'editing';
61
118
  // 'approved' withdraws too -- it is that status's only
@@ -89,31 +146,54 @@ function WorkflowButton({ branchName, busy, branchStatus, userContext, branchCre
89
146
  // sent people to an admin to fix a non-permissions problem.
90
147
  const statusHasAction = isEditing || isWithdrawable;
91
148
  const canPerformAction = userHasPermission && statusHasAction;
92
- // Blocked, it stays focusable (`aria-disabled`), so its reason opens on focus.
93
- // `busy` alone can't hold it: an entry load clears that shared flag mid-run.
94
- const actionBlocked = !branchName || busy || savingAll || !canPerformAction;
95
- const waitReason = savingAll ? 'Wait for Save all to finish.' : undefined;
96
- return (_jsx(Tooltip, { label:
97
- // No branch data (`branchStatus === undefined`) must not be
98
- // interpolated raw into this label. Guarded explicitly here rather
99
- // than left to the early return above, which hides this button
100
- // today only as a side effect of `branchIsProtected`'s `?? true`
101
- // default -- not a guarantee this arm stays unreachable.
102
- waitReason ??
103
- (branchStatus === undefined
104
- ? 'Branch details could not be loaded. Use "Switch branch…" to retry.'
105
- : !statusHasAction
106
- ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
107
- : !userHasPermission
108
- ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
109
- : ''), disabled: canPerformAction && !waitReason, multiline: true, maw: 320, children: _jsx(Button, { size: "sm",
110
- // Save is the top bar's one filled primary; the rest take their colour's light variant.
111
- variant: "light", color: isWithdrawable ? 'orange' : undefined, style: { flexShrink: 0 }, onClick: () => {
112
- if (actionBlocked)
149
+ return {
150
+ kind: isWithdrawable ? 'withdraw' : 'submit',
151
+ // `busy` alone can't hold it: an entry load clears that shared flag mid-run.
152
+ blocked: !branchName || busy || savingAll || !canPerformAction,
153
+ permitted: canPerformAction,
154
+ reason: savingAll
155
+ ? 'Wait for Save all to finish.'
156
+ : // No branch data (`branchStatus === undefined`) must not be
157
+ // interpolated raw into this label. Guarded explicitly here rather
158
+ // than left to the early return above, which hides the action
159
+ // today only as a side effect of `branchIsProtected`'s `?? true`
160
+ // default -- not a guarantee this arm stays unreachable.
161
+ branchStatus === undefined
162
+ ? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
163
+ : !statusHasAction
164
+ ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
165
+ : !userHasPermission
166
+ ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
167
+ : '',
168
+ };
169
+ }
170
+ function WorkflowButton({ action, onSubmit, onWithdraw, controlRef, returnFocusTo, }) {
171
+ const withdraw = action.kind === 'withdraw';
172
+ return (_jsx(Tooltip, { label: action.reason, disabled: !action.reason, multiline: true, maw: 320, children: _jsx(Button, { ref: controlRef, size: "sm",
173
+ // The entry's Save is the one filled primary; the rest take their colour's light variant.
174
+ variant: "light", color: withdraw ? 'orange' : undefined, style: { flexShrink: 0 }, onClick: () => {
175
+ if (action.blocked)
113
176
  return;
114
- if (isWithdrawable)
115
- onWithdraw();
177
+ if (withdraw)
178
+ onWithdraw(returnFocusTo);
116
179
  else
117
- onSubmit();
118
- }, "data-disabled": actionBlocked ? true : undefined, "aria-disabled": actionBlocked ? true : undefined, "data-testid": isWithdrawable ? 'withdraw-button' : 'submit-button', children: isWithdrawable ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview }) }));
180
+ onSubmit(returnFocusTo);
181
+ }, "data-disabled": action.blocked ? true : undefined, "aria-disabled": action.blocked ? true : undefined, "data-testid": withdraw ? 'withdraw-button' : 'submit-button', children: withdraw ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview }) }));
182
+ }
183
+ /** Save all's caret below `md`, holding the workflow action. */
184
+ function WorkflowCaret({ action, onSubmit, onWithdraw, controlRef, returnFocusTo, }) {
185
+ const caret = useRef(null);
186
+ const caretRef = useMergedRef(caret, controlRef);
187
+ const menuFocus = useMenuItemFocusReturn(caret);
188
+ const withdraw = action.kind === 'withdraw';
189
+ return (
190
+ // Bounded, so a blocked item's reason wraps on a phone rather than run off the screen.
191
+ _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, ...menuFocus.menu, children: [_jsx(Tooltip, { label: "Branch actions", events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caretRef, variant: "light", size: "sm", px: 8, "aria-label": "Branch actions", "data-testid": "branch-actions-button", style: { borderInlineStart: '1px solid var(--canopy-input-border)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: _jsx(ReasonedMenuItem, { "data-testid": withdraw ? 'withdraw-menu-item' : 'submit-menu-item', blockedReason: action.blocked
192
+ ? action.reason || 'Wait for the current save or load to finish.'
193
+ : undefined, onClick: () => {
194
+ if (withdraw)
195
+ onWithdraw(returnFocusTo);
196
+ else
197
+ onSubmit(returnFocusTo);
198
+ }, children: withdraw ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview }) })] }));
119
199
  }
@@ -7,13 +7,13 @@ export interface EditorRailProps {
7
7
  onContentToggle: () => void;
8
8
  /** The Content button, which takes focus when the open entry it was reached from is gone. */
9
9
  contentToggleRef?: Ref<HTMLButtonElement>;
10
- onQuickOpen: () => void;
11
- /** The quick open shortcut as the platform writes it, such as "⌘P". */
12
- quickOpenShortcut: string;
13
- /** The same shortcut for `aria-keyshortcuts`, such as "Meta+P". */
14
- quickOpenKeys: string;
15
- branchesOpen: boolean;
16
- onBranchesOpen: () => void;
10
+ /** The shortcut that opens the panel at its search field: "⌘P", and "Meta+P" for `aria-keyshortcuts`. */
11
+ contentShortcut: {
12
+ label: string;
13
+ keys: string;
14
+ };
15
+ /** Entries with unsaved changes: the Content button marks them while its panel is closed. */
16
+ unsavedCount: number;
17
17
  /** Without it, the Comments button is not offered (no branch, or a mode without comments). */
18
18
  onCommentsOpen?: () => void;
19
19
  commentsOpen: boolean;
@@ -45,4 +45,4 @@ export declare function SettingsMenu({ items, toggleRef, }: {
45
45
  toggleRef?: Ref<HTMLButtonElement>;
46
46
  }): import("react/jsx-runtime").JSX.Element | null;
47
47
  /** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
48
- export declare function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }: EditorRailProps): import("react/jsx-runtime").JSX.Element;
48
+ export declare function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, contentShortcut, unsavedCount, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }: EditorRailProps): import("react/jsx-runtime").JSX.Element;
@@ -3,11 +3,11 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useRef, useState } from 'react';
4
4
  import { ActionIcon, Menu, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
5
5
  import { useMergedRef } from '@mantine/hooks';
6
- import { IconActivity, IconExternalLink, IconFiles, IconGitBranch, IconInfoCircle, IconLayoutColumns, IconLayoutRows, IconLogout, IconMessageCircle, IconPhoto, IconSearch, IconSettings, IconUserCircle, } from '@tabler/icons-react';
7
- import { ActivityRail, RailButton } from '../shell/ActivityRail.js';
6
+ import { IconActivity, IconExternalLink, IconFiles, IconInfoCircle, IconLayoutColumns, IconLayoutRows, IconLogout, IconMessageCircle, IconPhoto, IconSettings, IconUserCircle, } from '@tabler/icons-react';
7
+ import { ActivityRail, RailButton, RailDivider } from '../shell/ActivityRail.js';
8
8
  import { useMenuItemFocusReturn } from '../utils/focus-return.js';
9
+ import { UnsavedMark } from './UnsavedMark.js';
9
10
  import { CANOPYCMS_VERSION } from '../../version.js';
10
- import { EDITOR_ACTIONS } from '../copy.js';
11
11
  const ABOUT_URL = 'https://github.com/safeinsights/canopycms';
12
12
  /**
13
13
  * @internal Exported for tests. The Settings button and its menu of `items`; with none, no
@@ -41,7 +41,7 @@ function LayoutButton({ layout, onChange, blocked = false, }) {
41
41
  } }), _jsx(VisuallyHidden, { role: "status", "data-testid": "rail-layout-status", children: switchedTo })] }));
42
42
  }
43
43
  /** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
44
- export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }) {
44
+ export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, contentShortcut, unsavedCount, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }) {
45
45
  const adminItems = [
46
46
  onPermissionManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-permissions", onClick: onPermissionManagerOpen, children: "Manage permissions" }, "permissions")),
47
47
  onGroupManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-groups", onClick: onGroupManagerOpen, children: "Manage groups" }, "groups")),
@@ -59,6 +59,8 @@ export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle,
59
59
  _jsx(Menu.Label, { "data-testid": "settings-menu-version", children: `CanopyCMS ${CANOPYCMS_VERSION}` }, "version"),
60
60
  _jsxs(Menu.Item, { component: "a", href: ABOUT_URL, target: "_blank", rel: "noopener noreferrer", "data-testid": "settings-menu-about", leftSection: _jsx(IconInfoCircle, { size: 14 }), rightSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), children: ["About CanopyCMS", _jsx(VisuallyHidden, { component: "span", children: " (opens in a new tab)" })] }, "about"),
61
61
  ];
62
+ const unsaved = !contentPanelOpen && unsavedCount > 0;
63
+ const contentLabel = unsaved ? `Content, ${unsavedCount} unsaved` : 'Content';
62
64
  const commentsLabel = unresolvedCommentCount > 0 ? `Comments, ${unresolvedCommentCount} unresolved` : 'Comments';
63
- return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "content", label: "Content", icon: _jsx(IconFiles, { size: 20 }), active: contentPanelOpen, "aria-expanded": contentPanelOpen, "aria-controls": contentPanelOpen ? contentPanelId : undefined, "data-testid": "content-panel-toggle", ref: contentToggleRef, onClick: onContentToggle }), _jsx(RailButton, { railId: "search", label: EDITOR_ACTIONS.openEntry, shortcut: quickOpenShortcut, "aria-keyshortcuts": quickOpenKeys, icon: _jsx(IconSearch, { size: 20 }), "aria-haspopup": "dialog", "data-testid": "rail-search-button", onClick: onQuickOpen }), _jsx(RailButton, { railId: "branches", label: "Branches", icon: _jsx(IconGitBranch, { size: 20 }), active: branchesOpen, "aria-haspopup": "dialog", "data-testid": "rail-branches-button", onClick: onBranchesOpen }), onCommentsOpen && (_jsx(RailButton, { railId: "comments", label: commentsLabel, icon: _jsx(IconMessageCircle, { size: 20 }), active: commentsOpen, count: unresolvedCommentCount, countTestId: "comments-unresolved-count", "aria-haspopup": "dialog", "data-testid": "comments-button", onClick: onCommentsOpen })), _jsx(RailButton, { railId: "media", label: "Media library", icon: _jsx(IconPhoto, { size: 20 }), active: mediaLibraryOpen, "aria-haspopup": "dialog", "data-testid": "media-library-button", ref: mediaButtonRef, onClick: onMediaLibraryOpen })] }), bottom: _jsxs(_Fragment, { children: [_jsx(LayoutButton, { layout: layout, onChange: onLayoutChange, blocked: layoutBlocked }), _jsx(SettingsMenu, { items: settingsItems, toggleRef: settingsToggleRef })] }), account: AccountComponent ? (_jsx(AccountComponent, {})) : onAccountClick || onLogoutClick ? (_jsxs(Stack, { gap: 6, align: "center", children: [onAccountClick && (_jsx(Tooltip, { label: "Account", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Account", onClick: onAccountClick, children: _jsx(IconUserCircle, { size: 20 }) }) })), onLogoutClick && (_jsx(Tooltip, { label: "Sign out", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Sign out", onClick: onLogoutClick, children: _jsx(IconLogout, { size: 20 }) }) }))] })) : undefined }));
65
+ return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "content", label: contentLabel, corner: unsaved ? _jsx(UnsavedMark, { "aria-hidden": true, "data-testid": "rail-unsaved-mark" }) : undefined, icon: _jsx(IconFiles, { size: 20 }), active: contentPanelOpen, "aria-expanded": contentPanelOpen, "aria-controls": contentPanelOpen ? contentPanelId : undefined, shortcut: contentShortcut.label, "aria-keyshortcuts": contentShortcut.keys, "data-testid": "content-panel-toggle", ref: contentToggleRef, onClick: onContentToggle }), onCommentsOpen && (_jsx(RailButton, { railId: "comments", label: commentsLabel, icon: _jsx(IconMessageCircle, { size: 20 }), active: commentsOpen, count: unresolvedCommentCount, countTestId: "comments-unresolved-count", "aria-haspopup": "dialog", "data-testid": "comments-button", onClick: onCommentsOpen })), _jsx(RailButton, { railId: "media", label: "Media library", icon: _jsx(IconPhoto, { size: 20 }), active: mediaLibraryOpen, "aria-haspopup": "dialog", "data-testid": "media-library-button", ref: mediaButtonRef, onClick: onMediaLibraryOpen }), _jsx(RailDivider, {}), _jsx(LayoutButton, { layout: layout, onChange: onLayoutChange, blocked: layoutBlocked })] }), bottom: _jsx(SettingsMenu, { items: settingsItems, toggleRef: settingsToggleRef }), account: AccountComponent ? (_jsx(AccountComponent, {})) : onAccountClick || onLogoutClick ? (_jsxs(Stack, { gap: 6, align: "center", children: [onAccountClick && (_jsx(Tooltip, { label: "Account", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Account", onClick: onAccountClick, children: _jsx(IconUserCircle, { size: 20 }) }) })), onLogoutClick && (_jsx(Tooltip, { label: "Sign out", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Sign out", onClick: onLogoutClick, children: _jsx(IconLogout, { size: 20 }) }) }))] })) : undefined }));
64
66
  }
@@ -5,11 +5,15 @@ export interface EntryBreadcrumbProps {
5
5
  segments: BreadcrumbSegment[];
6
6
  /** The open entry's label; undefined when no entry is open. */
7
7
  entryLabel: string | undefined;
8
+ /** No entry yet because the branch or its entries are still loading: a placeholder crumb shows. */
9
+ entryLoading?: boolean;
8
10
  /** The open entry has unsaved changes. */
9
11
  entryDirty: boolean;
10
12
  /** Why the open entry is read-only; set, the "Read-only" chip shows. */
11
13
  readOnlyReason?: string;
12
14
  onCollection: (collectionPath: string) => void;
15
+ /** Offers "Show in content panel", which brings the entry's row into view and focuses it. */
16
+ onReveal?: () => void;
13
17
  onRefresh: () => void;
14
18
  onDiscard: () => void;
15
19
  /** With `onDelete`, offers "Change URL…" and "Delete entry"; absent where a path rule refuses them. */
@@ -29,4 +33,4 @@ export interface EntryBreadcrumbProps {
29
33
  * the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
30
34
  * entry's own does.
31
35
  */
32
- export declare function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onCollection, onRefresh, onDiscard, onRename, entryNoPage, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }: EntryBreadcrumbProps): import("react/jsx-runtime").JSX.Element;
36
+ export declare function EntryBreadcrumb({ segments, entryLabel, entryLoading, entryDirty, readOnlyReason, onCollection, onReveal, onRefresh, onDiscard, onRename, entryNoPage, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }: EntryBreadcrumbProps): import("react/jsx-runtime").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { Badge, Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
4
+ import { Badge, Box, Button, Group, Menu, Skeleton, Text, Tooltip, VisuallyHidden, } from '@mantine/core';
5
5
  import { useMergedRef } from '@mantine/hooks';
6
6
  import { IconChevronDown, IconChevronRight, IconLock } from '@tabler/icons-react';
7
7
  import { EDITOR_ACTIONS } from '../copy.js';
@@ -40,7 +40,7 @@ const separator = (_jsx(IconChevronRight, { size: 14, "aria-hidden": "true", sty
40
40
  * the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
41
41
  * entry's own does.
42
42
  */
43
- export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onCollection, onRefresh, onDiscard, onRename, entryNoPage = false, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }) {
43
+ export function EntryBreadcrumb({ segments, entryLabel, entryLoading = false, entryDirty, readOnlyReason, onCollection, onReveal, onRefresh, onDiscard, onRename, entryNoPage = false, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }) {
44
44
  const trigger = useRef(null);
45
45
  const triggerRef = useMergedRef(trigger, entryMenuRef);
46
46
  const menuFocus = useMenuItemFocusReturn(trigger);
@@ -49,5 +49,5 @@ export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReas
49
49
  // Clips crumbs that overflow, padded by the focus ring's reach so the ring is not clipped.
50
50
  style: { flex: '0 1 auto', overflow: 'hidden', padding: 4, margin: -4 }, children: _jsxs(Group, { component: "ol", gap: 2, wrap: "nowrap", miw: 0, style: { listStyle: 'none', margin: 0, padding: 0 }, children: [segments.map((segment, index) => (_jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 0,
51
51
  // Collections give up their width long before the entry's own crumb does.
52
- style: { flex: '0 100 auto' }, children: [index > 0 && separator, segment.folder ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, miw: 0, truncate: "end", children: segment.label })) : (_jsx(CollectionCrumb, { label: segment.label, onClick: () => onCollection(segment.collectionPath) }))] }, `${segment.label}-${index}`))), _jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 64, style: { flex: '0 1 auto' }, children: [segments.length > 0 && separator, entryLabel === undefined ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, "data-testid": "file-dropdown-button", children: "No entry open" })) : (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-start", ...menuFocus.menu, children: [_jsx(CrumbTooltip, { label: entryLabel, truncated: entryTruncated, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "subtle", color: "gray", size: "compact-sm", fw: 600, miw: 0, maw: "100%", "aria-current": "page", rightSection: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }), "data-testid": "file-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [crumbText(entryLabel, entryLabelRef), entryDirty && (_jsxs(_Fragment, { children: [_jsx(UnsavedMark, { "aria-hidden": "true", "data-testid": "entry-unsaved-dot" }), _jsx(VisuallyHidden, { component: "span", children: ", unsaved changes" })] }))] }) }) }), _jsxs(Menu.Dropdown, { ...menuFocus.dropdown, children: [_jsx(ReasonedMenuItem, { onClick: onRefresh, blockedReason: refreshBlockedReason, "data-testid": "refresh-entry-menu-item", children: EDITOR_ACTIONS.refreshFromServer }), _jsx(ReasonedMenuItem, { "data-testid": "discard-file-draft-menu-item", onClick: onDiscard, blockedReason: discardBlockedReason, children: "Discard changes" }), onRename && onDelete && (_jsxs(_Fragment, { children: [_jsx(Menu.Divider, {}), _jsxs(ReasonedMenuItem, { onClick: onRename, blockedReason: changeBlockedReason, "data-testid": "entry-menu-rename", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] }), _jsx(DestructiveMenuItem, { onClick: onDelete, blockedReason: changeBlockedReason, "data-testid": "entry-menu-delete", children: "Delete entry" })] }))] })] }))] })] }) }), readOnlyReason && (_jsx(Tooltip, { label: readOnlyReason, multiline: true, maw: 300, children: _jsxs(Badge, { variant: "light", color: "gray", radius: "sm", leftSection: _jsx(IconLock, { size: 12, "aria-hidden": "true" }), style: { flexShrink: 0, textTransform: 'none' }, "data-testid": "read-only-chip", children: ["Read-only", _jsxs(VisuallyHidden, { component: "span", children: [". ", readOnlyReason] })] }) }))] }));
52
+ style: { flex: '0 100 auto' }, children: [index > 0 && separator, segment.folder ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, miw: 0, truncate: "end", children: segment.label })) : (_jsx(CollectionCrumb, { label: segment.label, onClick: () => onCollection(segment.collectionPath) }))] }, `${segment.label}-${index}`))), _jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 64, style: { flex: '0 1 auto' }, children: [segments.length > 0 && separator, entryLabel === undefined && entryLoading ? (_jsx(Skeleton, { height: 16, width: 120, mx: 4, "data-testid": "entry-crumb-loading", children: _jsx(VisuallyHidden, { children: "Loading\u2026" }) })) : entryLabel === undefined ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, "data-testid": "file-dropdown-button", children: "No entry open" })) : (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-start", ...menuFocus.menu, children: [_jsx(CrumbTooltip, { label: entryLabel, truncated: entryTruncated, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "subtle", color: "gray", size: "compact-sm", fw: 600, miw: 0, maw: "100%", "aria-current": "page", rightSection: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }), "data-testid": "file-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [crumbText(entryLabel, entryLabelRef), entryDirty && (_jsxs(_Fragment, { children: [_jsx(UnsavedMark, { "aria-hidden": "true", "data-testid": "entry-unsaved-dot" }), _jsx(VisuallyHidden, { component: "span", children: ", unsaved changes" })] }))] }) }) }), _jsxs(Menu.Dropdown, { ...menuFocus.dropdown, children: [onReveal && (_jsxs(_Fragment, { children: [_jsx(Menu.Item, { onClick: onReveal, "data-testid": "entry-menu-reveal", children: "Show in content panel" }), _jsx(Menu.Divider, {})] })), _jsx(ReasonedMenuItem, { onClick: onRefresh, blockedReason: refreshBlockedReason, "data-testid": "refresh-entry-menu-item", children: EDITOR_ACTIONS.refreshFromServer }), _jsx(ReasonedMenuItem, { "data-testid": "discard-file-draft-menu-item", onClick: onDiscard, blockedReason: discardBlockedReason, children: "Discard changes" }), onRename && onDelete && (_jsxs(_Fragment, { children: [_jsx(Menu.Divider, {}), _jsxs(ReasonedMenuItem, { onClick: onRename, blockedReason: changeBlockedReason, "data-testid": "entry-menu-rename", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] }), _jsx(DestructiveMenuItem, { onClick: onDelete, blockedReason: changeBlockedReason, "data-testid": "entry-menu-delete", children: "Delete entry" })] }))] })] }))] })] }) }), readOnlyReason && (_jsx(Tooltip, { label: readOnlyReason, multiline: true, maw: 300, children: _jsxs(Badge, { variant: "light", color: "gray", radius: "sm", leftSection: _jsx(IconLock, { size: 12, "aria-hidden": "true" }), style: { flexShrink: 0, textTransform: 'none' }, "data-testid": "read-only-chip", children: ["Read-only", _jsxs(VisuallyHidden, { component: "span", children: [". ", readOnlyReason] })] }) }))] }));
53
53
  }
@@ -0,0 +1,60 @@
1
+ import { type Ref } from 'react';
2
+ import type { BranchStatus } from '../../types.js';
3
+ import type { EditorEntry } from '../Editor.js';
4
+ import type { BreadcrumbSegment } from '../editor-utils.js';
5
+ export interface EntryHeaderProps {
6
+ currentEntry: EditorEntry | undefined;
7
+ /** No entry yet because the branch or its entries are still loading. */
8
+ entryLoading?: boolean;
9
+ branchName: string;
10
+ /** Whether operations are currently in progress. */
11
+ busy: boolean;
12
+ /** The crumbs before the open entry's own. */
13
+ breadcrumbSegments: BreadcrumbSegment[];
14
+ /** Opens the content panel on a collection. */
15
+ onCollectionOpen: (collectionPath: string) => void;
16
+ /** Opens the content panel on the open entry's row. */
17
+ onEntryReveal?: () => void;
18
+ /** The entry menu's trigger. */
19
+ navigatorToggleRef?: Ref<HTMLButtonElement>;
20
+ /** Replaces the open entry with the server's copy. */
21
+ onFileReload: () => void;
22
+ onFileDiscardDraft: () => void;
23
+ /** Opens the open entry's "Change URL" dialog. */
24
+ onEntryRename: () => void;
25
+ /** Asks to delete the open entry. */
26
+ onEntryDelete: () => void;
27
+ onSave: () => void;
28
+ /** Save all is running. */
29
+ saveAllRunning: boolean;
30
+ /** A Save of the open entry is in flight. */
31
+ saving?: boolean;
32
+ /** Rises each time a single Save lands, whichever entry it saved. */
33
+ savedSignal?: number;
34
+ /** The last Save that landed saved the open entry. */
35
+ savedHere?: boolean;
36
+ /** The open entry has unsaved changes it could save. */
37
+ hasUnsavedChanges: boolean;
38
+ /** The open entry has a draft, savable or not (a read-only branch's still discards). */
39
+ entryHasDraft?: boolean;
40
+ /** Writing drafts to localStorage failed, so they live only in this tab. */
41
+ draftStorageFailed?: boolean;
42
+ /** The open entry can't be edited by this user (a path rule). */
43
+ entryReadOnly?: boolean;
44
+ /** The open entry's type has no schema in the running code. */
45
+ entryUnavailable?: boolean;
46
+ /** Current branch status (undefined if unknown). */
47
+ branchStatus: BranchStatus | undefined;
48
+ /** Whether the current branch is read-only (protected base branch in prod); disables Save. */
49
+ branchReadOnly?: boolean;
50
+ /** Server-computed: content writes are rejected on this branch; see `branchLockState`. */
51
+ branchWriteBlocked?: boolean;
52
+ /** No branch details yet: the fail-closed lock defaults are guesses the chip doesn't name. */
53
+ branchLoading?: boolean;
54
+ /** The branch list is still being fetched, so Save waits rather than naming a failure. */
55
+ branchPending?: boolean;
56
+ /** The branch list answered without this branch. */
57
+ branchMissing?: boolean;
58
+ }
59
+ /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
60
+ export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Box, Group } from '@mantine/core';
4
+ import { SHELL_REGION_BORDER } from '../shell/EditorShell.js';
5
+ import { describeBranchLock } from '../branch-lock.js';
6
+ import { EntryBreadcrumb } from './EntryBreadcrumb.js';
7
+ import { EntrySaveButton } from './EntrySaveButton.js';
8
+ const ENTRY_HEADER_HEIGHT = 48;
9
+ /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
10
+ export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
11
+ const branchLockReason = describeBranchLock({
12
+ branchStatus,
13
+ branchReadOnly,
14
+ branchWriteBlocked,
15
+ branchPending,
16
+ branchMissing,
17
+ });
18
+ const entryLockReason = entryReadOnly
19
+ ? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
20
+ : undefined;
21
+ // The Tooltip hides it while the editor is busy.
22
+ const saveBlockedReason = branchLockReason ??
23
+ (!branchName
24
+ ? 'Open a branch first.'
25
+ : !currentEntry
26
+ ? 'Open an entry to save it.'
27
+ : (entryLockReason ?? (!hasUnsavedChanges ? 'No changes to save.' : '')));
28
+ return (_jsxs(Group, { component: "section", "aria-label": "Entry", "data-testid": "entry-header", gap: "xs", wrap: "nowrap", justify: "space-between", px: "sm", py: 6, mih: ENTRY_HEADER_HEIGHT, style: {
29
+ flexShrink: 0,
30
+ borderBottom: SHELL_REGION_BORDER,
31
+ background: 'var(--mantine-color-body)',
32
+ }, children: [_jsx(Box, { miw: 0, style: { flex: '1 1 0%' }, children: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryLoading: entryLoading, entryDirty: hasUnsavedChanges,
33
+ // The branch bar's status already says a locked branch is read-only, so the chip carries
34
+ // only the entry's own reason: "Withdraw it" would not unlock the entry.
35
+ readOnlyReason: branchLoading ? undefined : currentEntry || branchName ? entryLockReason : undefined, onCollection: onCollectionOpen, onReveal: onEntryReveal, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
36
+ // A path rule's refusal hides them, as the content tree hides that entry's "…" menu.
37
+ onRename: entryReadOnly ? undefined : onEntryRename,
38
+ // An entry with a page has a preview `src`; one without changes only its slug.
39
+ entryNoPage: !!currentEntry && !currentEntry.previewSrc, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
40
+ (entryUnavailable
41
+ ? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
42
+ : undefined), entryMenuRef: navigatorToggleRef }) }), _jsx(EntrySaveButton, { onSave: onSave, saveBlockedReason: saveBlockedReason,
43
+ // Loading, "Open an entry to save it." would claim an empty state too soon.
44
+ busy: busy || entryLoading, saveAllRunning: saveAllRunning, saving: saving, savedSignal: savedSignal, savedHere: savedHere, entryDirty: hasUnsavedChanges, draftStorageFailed: draftStorageFailed })] }));
45
+ }
@@ -0,0 +1,24 @@
1
+ export interface EntrySaveButtonProps {
2
+ onSave: () => void;
3
+ /** Why Save can't act now; empty when it can. */
4
+ saveBlockedReason: string;
5
+ /** The editor is loading or saving: Save waits without a reason. */
6
+ busy: boolean;
7
+ /** Save all is running, so Save waits for it, and says so. */
8
+ saveAllRunning: boolean;
9
+ /** A Save of the open entry is in flight. */
10
+ saving?: boolean;
11
+ /** Rises each time a single Save lands, whichever entry it saved. */
12
+ savedSignal?: number;
13
+ /** The last Save that landed saved the open entry. */
14
+ savedHere?: boolean;
15
+ /** The open entry has unsaved changes. */
16
+ entryDirty: boolean;
17
+ /** Writing drafts to localStorage failed, so they live only in this tab. */
18
+ draftStorageFailed: boolean;
19
+ }
20
+ /**
21
+ * The open entry's Save, reading "Saving…" then "Saved" as a save lands. While the entry has
22
+ * unsaved changes, its Tooltip and description say they are kept on this device.
23
+ */
24
+ export declare function EntrySaveButton({ onSave, saveBlockedReason, busy, saveAllRunning, saving, savedSignal, savedHere, entryDirty, draftStorageFailed, }: EntrySaveButtonProps): import("react/jsx-runtime").JSX.Element;