canopycms 0.1.0-int.115 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +267 -231
  2. package/README.md +0 -1
  3. package/dist/ai/references.js +3 -1
  4. package/dist/api/assets.js +2 -2
  5. package/dist/api/branch-merge.d.ts +1 -1
  6. package/dist/api/branch.js +1 -1
  7. package/dist/api/content.js +1 -1
  8. package/dist/api/entries-constants.d.ts +1 -1
  9. package/dist/api/entries-constants.js +1 -1
  10. package/dist/api/entries.d.ts +2 -2
  11. package/dist/api/permissions.d.ts +2 -2
  12. package/dist/api/types.d.ts +3 -2
  13. package/dist/cli/cli.js +16 -14
  14. package/dist/cli/generate-ai-content.js +7 -5
  15. package/dist/cli/init.js +4 -4
  16. package/dist/client.d.ts +0 -1
  17. package/dist/client.js +0 -1
  18. package/dist/config/schemas/config.d.ts +0 -5
  19. package/dist/config/schemas/config.js +0 -1
  20. package/dist/config/types.d.ts +0 -1
  21. package/dist/content-index-generation.d.ts +1 -1
  22. package/dist/content-index-generation.js +1 -1
  23. package/dist/content-listing.js +1 -1
  24. package/dist/content-reader.js +1 -1
  25. package/dist/content-store.d.ts +1 -1
  26. package/dist/content-store.js +1 -1
  27. package/dist/dev-content-watcher.d.ts +1 -1
  28. package/dist/dev-content-watcher.js +1 -1
  29. package/dist/editor/BranchManager.d.ts +2 -0
  30. package/dist/editor/BranchManager.js +5 -4
  31. package/dist/editor/CanopyEditor.d.ts +1 -1
  32. package/dist/editor/CanopyEditor.js +1 -2
  33. package/dist/editor/CommentsPanel.js +3 -6
  34. package/dist/editor/Editor.d.ts +0 -2
  35. package/dist/editor/Editor.js +329 -181
  36. package/dist/editor/EditorAuthGate.js +8 -3
  37. package/dist/editor/EntryNavigator.d.ts +10 -0
  38. package/dist/editor/EntryNavigator.js +51 -2
  39. package/dist/editor/FormHeader.js +1 -1
  40. package/dist/editor/PreviewFrame.d.ts +5 -1
  41. package/dist/editor/PreviewFrame.js +3 -2
  42. package/dist/editor/PreviewPane.d.ts +47 -0
  43. package/dist/editor/PreviewPane.js +147 -0
  44. package/dist/editor/PreviewToolbarTools.d.ts +22 -0
  45. package/dist/editor/PreviewToolbarTools.js +73 -0
  46. package/dist/editor/comments/form-comments.js +2 -1
  47. package/dist/editor/components/BranchLockBanner.d.ts +22 -0
  48. package/dist/editor/components/BranchLockBanner.js +39 -0
  49. package/dist/editor/components/BranchPill.d.ts +32 -0
  50. package/dist/editor/components/BranchPill.js +33 -0
  51. package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
  52. package/dist/editor/components/ConfirmDeleteModal.js +3 -2
  53. package/dist/editor/components/EditorHeader.d.ts +50 -108
  54. package/dist/editor/components/EditorHeader.js +117 -138
  55. package/dist/editor/components/EditorRail.d.ts +49 -0
  56. package/dist/editor/components/EditorRail.js +65 -0
  57. package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
  58. package/dist/editor/components/EntryBreadcrumb.js +56 -0
  59. package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
  60. package/dist/editor/components/QuickOpenPalette.js +3 -2
  61. package/dist/editor/components/RenameEntryModal.d.ts +3 -1
  62. package/dist/editor/components/RenameEntryModal.js +3 -2
  63. package/dist/editor/components/SaveAllResult.d.ts +25 -0
  64. package/dist/editor/components/SaveAllResult.js +86 -0
  65. package/dist/editor/components/SaveSplitButton.d.ts +30 -0
  66. package/dist/editor/components/SaveSplitButton.js +108 -0
  67. package/dist/editor/components/index.d.ts +2 -2
  68. package/dist/editor/components/index.js +2 -2
  69. package/dist/editor/context/EditorStateContext.d.ts +4 -4
  70. package/dist/editor/context/EditorStateContext.js +4 -4
  71. package/dist/editor/copy.d.ts +3 -0
  72. package/dist/editor/copy.js +3 -0
  73. package/dist/editor/editor-utils.d.ts +11 -6
  74. package/dist/editor/editor-utils.js +8 -17
  75. package/dist/editor/fields/MarkdownField.js +3 -0
  76. package/dist/editor/hooks/save-all.d.ts +68 -0
  77. package/dist/editor/hooks/save-all.js +61 -0
  78. package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
  79. package/dist/editor/hooks/useCommentSystem.js +3 -0
  80. package/dist/editor/hooks/useDraftManager.d.ts +24 -1
  81. package/dist/editor/hooks/useDraftManager.js +297 -16
  82. package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
  83. package/dist/editor/hooks/useEditorLayout.js +13 -25
  84. package/dist/editor/hooks/useEntryManager.d.ts +4 -0
  85. package/dist/editor/hooks/useEntryManager.js +30 -7
  86. package/dist/editor/media/MediaLibrary.d.ts +1 -1
  87. package/dist/editor/preview-path.d.ts +12 -0
  88. package/dist/editor/preview-path.js +45 -1
  89. package/dist/editor/preview-scale.d.ts +27 -0
  90. package/dist/editor/preview-scale.js +34 -0
  91. package/dist/editor/shell/ActivityRail.d.ts +37 -0
  92. package/dist/editor/shell/ActivityRail.js +91 -0
  93. package/dist/editor/shell/EditorShell.d.ts +17 -0
  94. package/dist/editor/shell/EditorShell.js +31 -0
  95. package/dist/editor/shell/PaneLayout.d.ts +28 -0
  96. package/dist/editor/shell/PaneLayout.js +116 -0
  97. package/dist/editor/shell/SidePanelHost.d.ts +35 -0
  98. package/dist/editor/shell/SidePanelHost.js +184 -0
  99. package/dist/editor/shell/TopBar.d.ts +12 -0
  100. package/dist/editor/shell/TopBar.js +14 -0
  101. package/dist/editor/shell/host-chrome.d.ts +16 -0
  102. package/dist/editor/shell/host-chrome.js +69 -0
  103. package/dist/editor/theme.js +18 -3
  104. package/dist/git-manager.d.ts +1 -1
  105. package/dist/git-manager.js +2 -2
  106. package/dist/operating-mode/types.d.ts +1 -1
  107. package/dist/resolve-canopy-user.d.ts +1 -1
  108. package/dist/resolve-canopy-user.js +1 -1
  109. package/dist/schema/resolver.d.ts +1 -1
  110. package/dist/schema/resolver.js +1 -1
  111. package/dist/schema/schema-store.d.ts +1 -1
  112. package/dist/schema/schema-store.js +2 -2
  113. package/dist/url-collision.js +3 -3
  114. package/dist/url-exclusivity-fixtures.d.ts +1 -1
  115. package/dist/url-exclusivity-fixtures.js +1 -1
  116. package/dist/utils/content-write-lock.d.ts +1 -1
  117. package/dist/utils/content-write-lock.js +1 -1
  118. package/dist/utils/debug.d.ts +1 -1
  119. package/dist/utils/debug.js +5 -3
  120. package/dist/utils/git.d.ts +4 -0
  121. package/dist/utils/git.js +27 -5
  122. package/dist/validation/block-structural-keys.d.ts +1 -1
  123. package/dist/validation/block-structural-keys.js +1 -1
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/dist/worker/cms-worker.d.ts +1 -1
  127. package/dist/worker/cms-worker.js +2 -2
  128. package/dist/worker/github-auth.d.ts +1 -1
  129. package/dist/worker/github-auth.js +1 -1
  130. package/dist/worker/github-gateway-wire.d.ts +2 -1
  131. package/dist/worker/github-gateway-wire.js +7 -3
  132. package/dist/worker/github-gateway.js +1 -1
  133. package/dist/worker/github-mirror.d.ts +2 -2
  134. package/dist/worker/github-mirror.js +2 -2
  135. package/dist/worker/history-rewrite.d.ts +1 -1
  136. package/dist/worker/history-rewrite.js +1 -1
  137. package/dist/worker/self-checks.d.ts +1 -1
  138. package/dist/worker/shared-repo-git.js +1 -1
  139. package/package.json +5 -4
  140. package/dist/editor/EditorPanes.d.ts +0 -20
  141. package/dist/editor/EditorPanes.js +0 -124
  142. package/dist/editor/components/EditorFooter.d.ts +0 -11
  143. package/dist/editor/components/EditorFooter.js +0 -9
  144. package/dist/editor/components/EditorSidebar.d.ts +0 -70
  145. package/dist/editor/components/EditorSidebar.js +0 -50
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useRef } from 'react';
4
+ import { Box, Button, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
5
+ import { useMediaQuery, useMergedRef } from '@mantine/hooks';
6
+ import { IconChevronDown, IconGitBranch } from '@tabler/icons-react';
7
+ import { EDITOR_ACTIONS } from '../copy.js';
8
+ import { DestructiveMenuItem } from './DestructiveMenuItem.js';
9
+ import { ReasonedMenuItem } from './ReasonedMenuItem.js';
10
+ import { useMenuItemFocusReturn } from '../utils/focus-return.js';
11
+ const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
12
+ /**
13
+ * The branch's name, status and unsaved count. Its menu holds the branch actions and lists the
14
+ * unsaved entries, each opening that entry. Below `sm` it is the branch icon and the count, its
15
+ * name then in its Tooltip, accessible name and menu label: the row's actions must not clip.
16
+ */
17
+ export function BranchPill({ branchName, loading, status, unsavedCount: unsaved, unsavedEntries, onSwitchBranch, onRefreshBranch, onDiscardAll, onEntrySelect, pillRef, }) {
18
+ const trigger = useRef(null);
19
+ const triggerRef = useMergedRef(trigger, pillRef);
20
+ const menuFocus = useMenuItemFocusReturn(trigger);
21
+ const noBranch = branchName ? undefined : 'Open a branch first.';
22
+ const wide = useMediaQuery('(min-width: 48em)');
23
+ const name = branchName || (loading ? 'Loading branches…' : 'No branch open');
24
+ return (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", ...menuFocus.menu, children: [_jsx(Tooltip, { label: status ? `${name} · ${status.label}` : name, disabled: wide, events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "default", size: "sm", miw: 0, px: { base: 8, sm: 14 },
25
+ // The count is the bar's one branch-wide unsaved signal: the row wraps before it clips.
26
+ style: { flexShrink: 0 }, leftSection: _jsx(IconGitBranch, { size: 16, "aria-hidden": "true" }), rightSection: wide ? _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) : undefined, loading: !branchName && loading, "data-testid": "branch-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [_jsx(Text, { span: true, inherit: true, truncate: "end", maw: 200, visibleFrom: "sm", children: name }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: name }), branchName && status && (_jsxs(Text, { span: true, size: "xs", c: "dimmed", visibleFrom: "md", style: { flexShrink: 0 }, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), _jsx("span", { "data-testid": status.testId, "data-status": status.value, children: status.label })] })), unsaved > 0 && (_jsxs(Text, { span: true, size: "xs", fw: 600, px: 6, style: {
27
+ flexShrink: 0,
28
+ borderRadius: 'var(--mantine-radius-xl)',
29
+ background: 'var(--canopy-count-bg)',
30
+ }, "data-testid": "branch-unsaved-count", children: [unsaved, _jsx(Box, { component: "span", visibleFrom: "sm", children: ' unsaved' }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "sm", children: ' unsaved' })] }))] }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && status && (_jsx(Menu.Label, { hiddenFrom: "md", children: `${branchName} · ${status.label}` })), _jsx(Menu.Item, { onClick: onSwitchBranch, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(ReasonedMenuItem, { onClick: onRefreshBranch, blockedReason: noBranch, "data-testid": "refresh-branch-menu-item", children: "Refresh branch" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Unsaved changes" }), unsavedEntries.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, "data-testid": "edited-entries-empty", children: unsaved === 0 ? 'No unsaved changes on this branch.' : 'Loading their names…' })) : (unsavedEntries.map((entry) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(entry.path), children: entry.label }, entry.path)))), _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
31
+ ? `Discard all changes (${plural(unsaved, 'entry', 'entries')})`
32
+ : 'Discard all changes' })] })] }));
33
+ }
@@ -9,9 +9,11 @@ export interface ConfirmDeleteModalProps {
9
9
  loading?: boolean;
10
10
  /** Rendered between the message and the buttons. */
11
11
  children?: ReactNode;
12
+ /** Where focus goes once it closes; Mantine's default, the element focused at open, without it. */
13
+ returnFocusTo?: () => HTMLElement | null;
12
14
  }
13
15
  /**
14
16
  * Confirmation modal for delete operations.
15
17
  * Provides a clear warning UI with red/danger theme.
16
18
  */
17
- export declare function ConfirmDeleteModal({ isOpen, title, message, confirmLabel, onConfirm, onClose, loading, children, }: ConfirmDeleteModalProps): import("react/jsx-runtime").JSX.Element;
19
+ export declare function ConfirmDeleteModal({ isOpen, title, message, confirmLabel, onConfirm, onClose, loading, children, returnFocusTo, }: ConfirmDeleteModalProps): import("react/jsx-runtime").JSX.Element;
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Modal, Button, Text, Group, Stack } from '@mantine/core';
4
+ import { focusReturnProps } from '../utils/focus-return.js';
4
5
  /**
5
6
  * Confirmation modal for delete operations.
6
7
  * Provides a clear warning UI with red/danger theme.
7
8
  */
8
- export function ConfirmDeleteModal({ isOpen, title, message, confirmLabel = 'Delete', onConfirm, onClose, loading = false, children, }) {
9
- return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: title, centered: true, size: "md", closeButtonProps: { disabled: loading }, closeOnEscape: !loading, closeOnClickOutside: !loading, children: _jsxs(Stack, { gap: "md", "data-testid": "confirm-delete-modal", children: [_jsx(Text, { size: "sm", children: message }), children, _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "default", onClick: onClose, disabled: loading, children: "Cancel" }), _jsx(Button, { size: "sm", color: "red", onClick: onConfirm, loading: loading, "data-testid": "confirm-delete-submit", children: confirmLabel })] })] }) }));
9
+ export function ConfirmDeleteModal({ isOpen, title, message, confirmLabel = 'Delete', onConfirm, onClose, loading = false, children, returnFocusTo, }) {
10
+ return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: title, centered: true, size: "md", closeButtonProps: { disabled: loading }, closeOnEscape: !loading, closeOnClickOutside: !loading, ...focusReturnProps(returnFocusTo), children: _jsxs(Stack, { gap: "md", "data-testid": "confirm-delete-modal", children: [_jsx(Text, { size: "sm", children: message }), children, _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "default", onClick: onClose, disabled: loading, children: "Cancel" }), _jsx(Button, { size: "sm", color: "red", onClick: onConfirm, loading: loading, "data-testid": "confirm-delete-submit", children: confirmLabel })] })] }) }));
10
11
  }
@@ -3,132 +3,76 @@ import type { OperatingMode } from '../../operating-mode/index.js';
3
3
  import type { BranchStatus } from '../../types.js';
4
4
  import type { EditorEntry } from '../Editor.js';
5
5
  import type { LogicalPath } from '../../paths/types.js';
6
+ import type { BreadcrumbSegment } from '../editor-utils.js';
7
+ import type { SaveAllProgress } from '../hooks/save-all.js';
6
8
  export interface EditorHeaderProps {
7
- /**
8
- * Site title displayed in the top-left.
9
- */
10
- siteTitle: string;
11
- /**
12
- * Optional site subtitle displayed below the title.
13
- */
14
- siteSubtitle?: string;
15
- /**
16
- * Main header title displayed in the center.
17
- */
18
- headerTitle: string;
19
- /**
20
- * Currently selected entry.
21
- */
22
9
  currentEntry: EditorEntry | undefined;
23
- /**
24
- * Current branch name.
25
- */
26
10
  branchName: string;
27
- /**
28
- * Operating mode.
29
- */
30
11
  operatingMode: OperatingMode;
31
- /**
32
- * Whether operations are currently in progress.
33
- */
12
+ /** Whether operations are currently in progress. */
34
13
  busy: boolean;
35
- /**
36
- * Breadcrumb segments to display.
37
- */
38
- breadcrumbSegments: string[];
39
- /**
40
- * List of edited files.
41
- */
14
+ /** The crumbs before the open entry's own. */
15
+ breadcrumbSegments: BreadcrumbSegment[];
16
+ /** The listed entries with unsaved changes on the branch. */
42
17
  editedFiles: Array<{
43
18
  path: LogicalPath;
44
19
  label: string;
45
20
  }>;
46
- /**
47
- * Number of modified files.
48
- */
21
+ /** Entries with unsaved changes on the branch. */
49
22
  modifiedCount: number;
50
- /**
51
- * Number of unresolved comments.
52
- */
53
- unresolvedCommentCount: number;
54
- /**
55
- * Unresolved comments (for filtering).
56
- */
57
- comments: Array<{
58
- resolved: boolean;
59
- }>;
60
- /**
61
- * Callback to open the entry navigator.
62
- */
23
+ /** Opens the content panel. */
63
24
  onNavigatorOpen: () => void;
64
- /** Ref to the button whose menu opens the file navigator. */
25
+ /** Opens the content panel on a collection. */
26
+ onCollectionOpen: (collectionPath: string) => void;
27
+ /** The entry menu's trigger. */
65
28
  navigatorToggleRef?: Ref<HTMLButtonElement>;
66
- /** Ref to the button whose menu opens the branch manager and discards all drafts. */
29
+ /** The branch pill. */
67
30
  branchToggleRef?: Ref<HTMLButtonElement>;
68
- /**
69
- * Callback to reload the current file.
70
- */
31
+ /** Replaces the open entry with the server's copy. */
71
32
  onFileReload: () => void;
72
- /**
73
- * Callback to discard the current file draft.
74
- */
75
33
  onFileDiscardDraft: () => void;
76
- /**
77
- * Callback when an entry is selected.
78
- */
79
- onEntrySelect: (id: string) => void;
80
- /**
81
- * Callback to reload all branch data.
82
- */
34
+ /** Opens the open entry's rename dialog. */
35
+ onEntryRename: () => void;
36
+ /** Asks to delete the open entry. */
37
+ onEntryDelete: () => void;
38
+ /** The "All content" crumb. */
39
+ allContentRef?: Ref<HTMLButtonElement>;
40
+ onEntrySelect: (path: LogicalPath) => void;
41
+ /** Re-reads the branch and its entry list, keeping drafts. */
83
42
  onBranchReloadData: () => void;
84
- /**
85
- * Callback to discard all drafts.
86
- */
87
43
  onBranchDiscardDrafts: () => void;
88
- /**
89
- * Callback to open the branch manager.
90
- */
91
44
  onBranchManagerOpen: () => void;
92
- /**
93
- * Callback to open the comments panel.
94
- */
95
- onCommentsPanelOpen: () => void;
96
- /**
97
- * Callback to save the current file.
98
- */
99
45
  onSave: () => void;
100
- /**
101
- * Callback to submit/publish the branch.
102
- */
46
+ onSaveAll: () => void;
47
+ saveAllProgress: SaveAllProgress | null;
48
+ /** A Save of the open entry is in flight. */
49
+ saving?: boolean;
50
+ /** Rises each time a single Save lands, whichever entry it saved. */
51
+ savedSignal?: number;
52
+ /** The last Save that landed saved the open entry. */
53
+ savedHere?: boolean;
54
+ /** The branch's entry list failed to load and none is held. */
55
+ entryListFailed?: boolean;
103
56
  onSubmit: () => void;
104
- /**
105
- * Whether the current entry has unsaved changes.
106
- */
57
+ onWithdraw: () => void;
58
+ /** The open entry has unsaved changes it could save. */
107
59
  hasUnsavedChanges: boolean;
60
+ /** The open entry has a draft, savable or not (a read-only branch's still discards). */
61
+ entryHasDraft?: boolean;
108
62
  /** Writing drafts to localStorage failed, so they live only in this tab. */
109
63
  draftStorageFailed?: boolean;
110
- /**
111
- * Current branch status (undefined if unknown).
112
- */
64
+ /** The open entry can't be edited by this user (a path rule). */
65
+ entryReadOnly?: boolean;
66
+ /** The open entry's type has no schema in the running code. */
67
+ entryUnavailable?: boolean;
68
+ /** Current branch status (undefined if unknown). */
113
69
  branchStatus: BranchStatus | undefined;
114
- /**
115
- * Callback to withdraw the branch.
116
- */
117
- onWithdraw: () => void;
118
- /**
119
- * Current user context for permission checks.
120
- */
70
+ /** Current user context for permission checks. */
121
71
  userContext?: {
122
72
  userId: string;
123
73
  groups?: string[];
124
74
  };
125
- /**
126
- * Branch creator user ID.
127
- */
128
75
  branchCreatedBy?: string;
129
- /**
130
- * Branch access control lists.
131
- */
132
76
  branchAccess?: {
133
77
  allowedUsers?: string[];
134
78
  allowedGroups?: string[];
@@ -140,20 +84,18 @@ export interface EditorHeaderProps {
140
84
  * stays, as the recovery path). Default false.
141
85
  */
142
86
  branchIsProtected?: boolean;
143
- /**
144
- * Whether the current branch is read-only (protected base branch in prod).
145
- * Disables Save and shows the "create a branch" banner below. Default false.
146
- */
87
+ /** Whether the current branch is read-only (protected base branch in prod); disables Save. */
147
88
  branchReadOnly?: boolean;
89
+ /** Server-computed: content writes are rejected on this branch; see `branchLockState`. */
90
+ branchWriteBlocked?: boolean;
148
91
  /**
149
- * Server-computed: content writes are rejected on this branch, for either
150
- * reason (base-branch read-only, or a status past 'editing'). Combined with
151
- * `branchReadOnly` to pick which of the two banners to show. Default false.
92
+ * No branch details yet (loading, or the list failed and the banner says so): the fail-closed
93
+ * lock defaults are guesses, so neither the chip nor the status names them.
152
94
  */
153
- branchWriteBlocked?: boolean;
95
+ branchLoading?: boolean;
154
96
  }
155
97
  /**
156
- * Header component for the Editor.
157
- * Contains site info, file navigation, breadcrumbs, branch selector, comments button, and action buttons.
98
+ * The top bar's contents: the breadcrumb with the entry menu and "Read-only" chip, then the branch
99
+ * pill, Submit or Withdraw, and Save with Save all.
158
100
  */
159
- export declare const EditorHeader: import("react").ForwardRefExoticComponent<EditorHeaderProps & import("react").RefAttributes<HTMLDivElement>>;
101
+ export declare function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onNavigatorOpen, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, allContentRef, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving, savedSignal, savedHere, entryListFailed, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, branchReadOnly, branchWriteBlocked, branchLoading, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
@@ -1,49 +1,27 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useRef } from 'react';
3
- import { Alert, Badge, Box, Button, Group, Indicator, Menu, Paper, Stack, Text, Title, Tooltip, VisuallyHidden, } from '@mantine/core';
4
- import { useMergedRef } from '@mantine/hooks';
5
- import { IconFolderOpen, IconChevronDown, IconGitBranch, IconLock } from '@tabler/icons-react';
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Tooltip } from '@mantine/core';
6
3
  import { clientOperatingStrategy } from '../../operating-mode/client.js';
7
4
  import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
8
5
  import { branchStatusPresentation } from '../branch-status.js';
9
6
  import { EDITOR_ACTIONS } from '../copy.js';
10
7
  import { SYSTEM_USER_ID } from '../../system-users.js';
11
- import { ReasonedMenuItem } from './ReasonedMenuItem.js';
12
- import { useMenuItemFocusReturn } from '../utils/focus-return.js';
13
- const EDITED_ENTRIES_HINT = 'Entries you edit on this branch appear here.';
14
- /** An empty section of a menu with other items: plain text, so arrow keys skip it. */
15
- function MenuEmptyText({ hint, children, ...rest }) {
16
- return (_jsxs(Text, { size: "sm", c: "dimmed", px: "sm", py: 6, ...rest, children: [children, _jsx(Text, { size: "xs", c: "dimmed", component: "span", display: "block", children: hint })] }));
17
- }
8
+ import { TopBar } from '../shell/TopBar.js';
9
+ import { branchLockState } from './BranchLockBanner.js';
10
+ import { BranchPill } from './BranchPill.js';
11
+ import { EntryBreadcrumb } from './EntryBreadcrumb.js';
12
+ import { SaveSplitButton } from './SaveSplitButton.js';
18
13
  /**
19
- * Header component for the Editor.
20
- * Contains site info, file navigation, breadcrumbs, branch selector, comments button, and action buttons.
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.
21
16
  */
22
- export const EditorHeader = forwardRef(function EditorHeader({ siteTitle, siteSubtitle, headerTitle, currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, unresolvedCommentCount: _, comments, onNavigatorOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onCommentsPanelOpen, onSave, onSubmit, hasUnsavedChanges, draftStorageFailed = false, branchStatus, onWithdraw, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchReadOnly = false, branchWriteBlocked = false, }, ref) {
23
- // `branchWriteBlocked` is the server's own answer (getBranchProtection), so
24
- // Save is never enabled for a write the API would reject; a client-side
25
- // derivation could drift from it. `branchReadOnly` only picks which banner
26
- // to show -- base-branch read-only takes precedence over a workflow status
27
- // lock.
28
- const statusLocked = branchWriteBlocked && !branchReadOnly;
29
- // Covers two cases where `branchStatus` can't be trusted to describe the lock
30
- // honestly: branch data still loading (`branchStatus` is undefined, so the
31
- // banner would otherwise interpolate "Branch is undefined...") and version
32
- // skew (an old server omits `branchWriteBlocked` while still reporting
33
- // status: 'editing', which would otherwise assert a lock the status
34
- // contradicts). In both cases the banner says data is unavailable instead.
35
- const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
36
- const fileToggle = useRef(null);
37
- const fileToggleRef = useMergedRef(fileToggle, navigatorToggleRef);
38
- const branchToggle = useRef(null);
39
- const branchToggleMergedRef = useMergedRef(branchToggle, branchToggleRef);
40
- const fileMenuFocus = useMenuItemFocusReturn(fileToggle);
41
- const branchMenuFocus = useMenuItemFocusReturn(branchToggle);
42
- // Save stays focusable while blocked (`aria-disabled`, not `disabled`), so its reason opens on
43
- // keyboard focus and touch as well as hover; the click does nothing.
44
- const saveBlocked = !branchName || !currentEntry || busy || !hasUnsavedChanges || branchReadOnly || statusLocked;
45
- // Why Save is blocked, with the way forward. The Tooltip hides it while a save is in flight.
46
- const saveBlockedReason = branchReadOnly
17
+ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onNavigatorOpen, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, allContentRef, 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
+ const { statusLocked, branchDataUnavailable } = branchLockState({
19
+ branchStatus,
20
+ branchReadOnly,
21
+ branchWriteBlocked,
22
+ });
23
+ // Why the branch takes no writes, with the way forward; shared by Save, Save all and the chip.
24
+ const branchLockReason = branchReadOnly
47
25
  ? 'The base branch is read-only. Create a branch to make changes.'
48
26
  : branchDataUnavailable
49
27
  ? 'Branch details could not be loaded. Use "Switch branch…" to retry.'
@@ -53,102 +31,103 @@ export const EditorHeader = forwardRef(function EditorHeader({ siteTitle, siteSu
53
31
  : branchStatus === 'approved'
54
32
  ? 'This branch is approved. Withdraw it to make changes.'
55
33
  : `This branch is ${branchStatus}. Its content is read-only.`
56
- : !branchName
57
- ? 'Open a branch first.'
58
- : !currentEntry
59
- ? 'Open an entry to save it.'
60
- : !hasUnsavedChanges
61
- ? 'No changes to save.'
62
- : '';
63
- const statusPresentation = branchStatusPresentation(branchStatus, branchIsProtected);
64
- const unresolvedCount = comments.filter((t) => !t.resolved).length;
65
- return (_jsx(Paper, { ref: ref, shadow: "md", withBorder: true, radius: 0, px: 0, py: 0, style: {
66
- position: 'fixed',
67
- top: 0,
68
- left: 0,
69
- right: 0,
70
- zIndex: 70,
71
- }, children: _jsxs(Box, { px: "md", py: "sm", children: [_jsxs(Group, { justify: "space-between", align: "center", wrap: "nowrap", children: [_jsxs(Stack, { gap: 2, style: { minWidth: 0 }, children: [_jsx(Title, { order: 5, style: { lineHeight: 1.1 }, children: siteTitle }), siteSubtitle && (_jsx(Text, { size: "xs", c: "dimmed", children: siteSubtitle }))] }), _jsxs(Stack, { gap: 6, style: { minWidth: 0, flex: 1, alignItems: 'center' }, children: [_jsx(Title, { order: 4, style: { lineHeight: 1.1 }, children: headerTitle }), _jsxs(Group, { gap: "sm", wrap: "wrap", align: "center", style: { minWidth: 0, justifyContent: 'center' }, children: [_jsxs(Menu, { withinPortal: true, shadow: "sm", ...fileMenuFocus.menu, children: [_jsx(Menu.Target, { children: _jsx(Button, { ref: fileToggleRef, "data-testid": "file-dropdown-button", variant: "outline", color: "gray", leftSection: _jsx(IconFolderOpen, { size: 16 }), rightSection: _jsx(IconChevronDown, { size: 14 }), children: currentEntry?.label ?? 'No entry selected' }) }), _jsxs(Menu.Dropdown, { ...fileMenuFocus.dropdown, children: [_jsx(ReasonedMenuItem, { onClick: onFileReload, blockedReason: !branchName
72
- ? 'Open a branch first.'
73
- : !currentEntry
74
- ? 'Open an entry first.'
75
- : undefined, children: "Reload entry" }), _jsx(ReasonedMenuItem, { "data-testid": "discard-file-draft-menu-item", onClick: onFileDiscardDraft, blockedReason: currentEntry ? undefined : 'Open an entry first.', children: "Discard changes" }), _jsx(Menu.Divider, {}), _jsx(Menu.Item, { "data-testid": "all-files-menu-item", onClick: onNavigatorOpen, children: "All content" }), _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Recently modified" }), editedFiles.slice(0, 3).length === 0 ? (_jsx(MenuEmptyText, { "data-testid": "recent-entries-empty", hint: EDITED_ENTRIES_HINT, children: "No edited entries yet" })) : (editedFiles.slice(0, 3).map((file) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(file.path), children: file.label }, file.path))))] })] }), _jsx(Group, { gap: 4, wrap: "wrap", align: "center", style: { minWidth: 0 }, children: breadcrumbSegments.map((segment, idx) => (_jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [idx > 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "/" })), _jsx(Button, { variant: "subtle", px: "xs", onClick: onNavigatorOpen, "data-testid": "breadcrumb-segment", children: segment })] }, `${segment}-${idx}`))) }), _jsxs(Menu, { withinPortal: true, shadow: "sm", ...branchMenuFocus.menu, children: [_jsx(Menu.Target, { children: _jsx(Button, { ref: branchToggleMergedRef, variant: "outline", color: "gray", leftSection: _jsx(IconGitBranch, { size: 16 }), rightSection: _jsx(IconChevronDown, { size: 14 }), loading: !branchName && busy, "data-testid": "branch-dropdown-button", children: branchName || (busy ? 'Loading branches…' : 'No branch selected') }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...branchMenuFocus.dropdown, children: [_jsx(ReasonedMenuItem, { onClick: onBranchReloadData, blockedReason: branchName ? undefined : 'Open a branch first.', children: "Reload all entries" }), _jsx(ReasonedMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onBranchDiscardDrafts, blockedReason: branchName ? undefined : 'Open a branch first.', children: "Discard all changes" }), _jsx(Menu.Divider, {}), _jsx(Menu.Item, { onClick: onBranchManagerOpen, "data-testid": "manage-branches-menu-item", children: `${EDITOR_ACTIONS.switchBranch}…` }), _jsx(Menu.Divider, {}), modifiedCount > 0 && (_jsx(Menu.Label, { children: `${modifiedCount} ${modifiedCount === 1 ? 'entry' : 'entries'} modified` })), editedFiles.length === 0 ? (_jsx(MenuEmptyText, { "data-testid": "edited-entries-empty", hint: EDITED_ENTRIES_HINT, children: "No edited entries yet" })) : (editedFiles.map((file) => (_jsx(Menu.Item, { onClick: () => onEntrySelect(file.path), children: file.label }, `branch-mod-${file.path}`))))] })] }), clientOperatingStrategy(operatingMode ?? 'prod').supportsStatusBadge() &&
76
- branchName &&
77
- statusPresentation && (_jsx(Badge, { color: statusPresentation.color, variant: statusPresentation.variant, size: "sm", "data-testid": `header-status-badge-${branchIsProtected ? 'protected' : branchStatus}`, "data-status": branchIsProtected ? 'protected' : branchStatus, children: statusPresentation.label })), clientOperatingStrategy(operatingMode ?? 'prod').supportsComments() &&
78
- branchName && (_jsx(Indicator, { label: _jsx("span", { "data-testid": "comments-unresolved-count", children: unresolvedCount }), size: 18, withBorder: true, disabled: unresolvedCount === 0, styles: {
79
- indicator: {
80
- pointerEvents: 'none',
81
- // Mantine paints the pill again in a ::before, from this variable.
82
- '--indicator-color': 'var(--canopy-count-bg)',
83
- '--indicator-text-color': 'var(--mantine-color-text)',
84
- },
85
- }, children: _jsx(Button, { variant: "outline", color: "gray", onClick: onCommentsPanelOpen, "data-testid": "comments-button", "aria-label": unresolvedCount > 0 ? `Comments, ${unresolvedCount} unresolved` : undefined, children: "Comments" }) }))] })] }), _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [_jsx(Box, { role: "status", w: 112, ta: "right", children: hasUnsavedChanges && (_jsx(Tooltip, { label: "Your browser couldn't store these changes. Save before you leave or reload, or they'll be lost.", disabled: !draftStorageFailed, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", lh: 1.2, c: draftStorageFailed ? 'var(--canopy-danger-text)' : 'dimmed', "data-testid": "unsaved-indicator", children: [draftStorageFailed
86
- ? 'Unsaved · not kept on this device'
87
- : 'Unsaved · kept on this device', draftStorageFailed && (_jsx(VisuallyHidden, { component: "span", children: ". Save before you leave or reload, or they'll be lost." }))] }) })) }), _jsx(Tooltip, { label: saveBlockedReason, disabled: !saveBlocked || busy, multiline: true, maw: 280, children: _jsx(Button, { "data-testid": "save-button", variant: "light", size: "sm", onClick: () => {
88
- if (!saveBlocked)
89
- onSave();
90
- }, "data-disabled": saveBlocked ? true : undefined, "aria-disabled": saveBlocked ? true : undefined, children: EDITOR_ACTIONS.save }) }), (() => {
91
- const isSubmitted = branchStatus === 'submitted';
92
- const isEditing = branchStatus === 'editing';
93
- // 'approved' withdraws too -- it is that status's only
94
- // non-destructive exit now that the submit status gate refuses a
95
- // non-editing branch (api/branch-status.ts, api/branch-withdraw.ts).
96
- // This button is a two-state toggle, so every withdrawable status
97
- // has to drive the withdraw side of it, not just 'submitted'.
98
- const isWithdrawable = isSubmitted || branchStatus === 'approved';
99
- // The protected base branch can never be submitted for review (both
100
- // modes); hide the button entirely rather than showing it disabled.
101
- // Withdraw stays available as the recovery path for a base branch
102
- // wrongly stuck in 'submitted'.
103
- if (branchIsProtected && !isWithdrawable)
104
- return null;
105
- // Who may submit/withdraw: creator, ACL access, the system branch, or an
106
- // admin/reviewer (alone on a locked-down branch, and even without ACL access
107
- // on a protected base branch stuck in 'submitted'). Mirrors the server's
108
- // canPerformWorkflowAction (BranchManager.tsx), which is the actual
109
- // enforcer; this only decides whether to show the control disabled.
110
- const userIsCreator = userContext?.userId === branchCreatedBy;
111
- const isSystemBranch = branchCreatedBy === SYSTEM_USER_ID && !branchIsProtected;
112
- const userInACL = userContext &&
113
- branchAccess &&
114
- (branchAccess.allowedUsers?.includes(userContext.userId) ||
115
- userContext.groups?.some((g) => branchAccess.allowedGroups?.includes(g)));
116
- const userIsPrivileged = !!userContext &&
117
- (isAdmin(userContext.groups ?? []) || isReviewer(userContext.groups ?? []));
118
- const userHasPermission = !isLockedOutOfBranch(branchAccess, userContext?.groups) &&
119
- (userIsCreator || userInACL || isSystemBranch || userIsPrivileged);
120
- // A status with no available transition (today: 'archived') is a
121
- // separate condition from lacking permission, and conflating them
122
- // sent people to an admin to fix a non-permissions problem.
123
- const statusHasAction = isEditing || isWithdrawable;
124
- const canPerformAction = userHasPermission && statusHasAction;
125
- // Blocked, it stays focusable (`aria-disabled`), so its reason opens on focus.
126
- const actionBlocked = !branchName || busy || !canPerformAction;
127
- return (_jsx(Tooltip, { label:
128
- // No branch data (`branchStatus === undefined`) must not be
129
- // interpolated raw into this label. Guarded explicitly here rather
130
- // than left to the early return above, which hides this button
131
- // today only as a side effect of `branchIsProtected`'s `?? true`
132
- // default -- not a guarantee this arm stays unreachable.
133
- branchStatus === undefined
134
- ? 'Branch details could not be loaded. Use "Switch branch…" to retry.'
135
- : !statusHasAction
136
- ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
137
- : !userHasPermission
138
- ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
139
- : '', disabled: canPerformAction, multiline: true, maw: 320, children: _jsx(Button, { size: "sm", color: isWithdrawable ? 'orange' : 'brand', onClick: () => {
140
- if (actionBlocked)
141
- return;
142
- if (isWithdrawable)
143
- onWithdraw();
144
- else
145
- onSubmit();
146
- }, "data-disabled": actionBlocked ? true : undefined, "aria-disabled": actionBlocked ? true : undefined, "data-testid": isWithdrawable ? 'withdraw-button' : 'submit-button', children: isWithdrawable
147
- ? EDITOR_ACTIONS.withdrawFromReview
148
- : EDITOR_ACTIONS.submitForReview }) }));
149
- })()] })] }), branchReadOnly && (_jsx(Alert, { icon: _jsx(IconLock, { size: 16 }), color: "yellow", variant: "light", mt: "sm", "data-testid": "protected-branch-banner", children: _jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "wrap", children: [_jsx(Text, { size: "sm", children: `You are viewing the protected base branch "${branchName}". Content is read-only — create a branch to make changes.` }), _jsx(Button, { variant: "default", onClick: onBranchManagerOpen, children: `${EDITOR_ACTIONS.newBranch}…` })] }) })), statusLocked && (_jsx(Alert, { icon: _jsx(IconLock, { size: 16 }), color: "yellow", variant: "light", mt: "sm", "data-testid": "status-locked-banner", children: _jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "wrap", children: [_jsx(Text, { size: "sm", children: branchDataUnavailable
150
- ? `Branch details for "${branchName}" could not be loaded, so content is locked until they arrive. Use Switch branch… to retry.`
151
- : branchStatus === 'submitted'
152
- ? `Branch "${branchName}" is submitted for review and locked for edits. Withdraw it from review to make changes.`
153
- : `Branch "${branchName}" is ${branchStatus} — content is read-only.` }), _jsx(Button, { variant: "default", onClick: onBranchManagerOpen, children: `${EDITOR_ACTIONS.switchBranch}…` })] }) }))] }) }));
154
- });
34
+ : undefined;
35
+ const entryLockReason = entryReadOnly
36
+ ? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
37
+ : undefined;
38
+ // The Tooltip hides it while the editor is busy.
39
+ const saveBlockedReason = branchLockReason ??
40
+ (!branchName
41
+ ? 'Open a branch first.'
42
+ : !currentEntry
43
+ ? 'Open an entry to save it.'
44
+ : (entryLockReason ?? (!hasUnsavedChanges ? 'No changes to save.' : '')));
45
+ const presentation = branchStatusPresentation(branchStatus, branchIsProtected);
46
+ const status = !branchLoading &&
47
+ clientOperatingStrategy(operatingMode ?? 'prod').supportsStatusBadge() &&
48
+ presentation
49
+ ? {
50
+ label: presentation.label,
51
+ value: branchIsProtected ? 'protected' : String(branchStatus),
52
+ testId: `header-status-badge-${branchIsProtected ? 'protected' : branchStatus}`,
53
+ }
54
+ : undefined;
55
+ return (_jsx(TopBar, { leading: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryDirty: hasUnsavedChanges, readOnlyReason: branchLoading
56
+ ? undefined
57
+ : currentEntry || branchName
58
+ ? (branchLockReason ?? entryLockReason)
59
+ : undefined, onAllContent: onNavigatorOpen, onCollection: onCollectionOpen, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
60
+ // A path rule's refusal hides them, as the content tree hides that entry's "…" menu.
61
+ onRename: entryReadOnly ? undefined : onEntryRename, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
62
+ (entryUnavailable
63
+ ? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
64
+ : undefined), entryMenuRef: navigatorToggleRef, allContentRef: allContentRef }), 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 ??
65
+ (!branchName
66
+ ? 'Open a branch first.'
67
+ : entryListFailed && modifiedCount > 0
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.'
69
+ : undefined), saveAllProgress: saveAllProgress, saving: saving, savedSignal: savedSignal, savedHere: savedHere, entryDirty: hasUnsavedChanges, draftStorageFailed: draftStorageFailed })] }) }));
70
+ }
71
+ /** Submit for review, or Withdraw from review once submitted or approved. */
72
+ function WorkflowButton({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, onSubmit, onWithdraw, savingAll, }) {
73
+ const isSubmitted = branchStatus === 'submitted';
74
+ const isEditing = branchStatus === 'editing';
75
+ // 'approved' withdraws too -- it is that status's only
76
+ // non-destructive exit now that the submit status gate refuses a
77
+ // non-editing branch (api/branch-status.ts, api/branch-withdraw.ts).
78
+ // This button is a two-state toggle, so every withdrawable status
79
+ // has to drive the withdraw side of it, not just 'submitted'.
80
+ const isWithdrawable = isSubmitted || branchStatus === 'approved';
81
+ // The protected base branch can never be submitted for review (both
82
+ // modes); hide the button entirely rather than showing it disabled.
83
+ // Withdraw stays available as the recovery path for a base branch
84
+ // wrongly stuck in 'submitted'.
85
+ if (branchIsProtected && !isWithdrawable)
86
+ return null;
87
+ // Who may submit/withdraw: creator, ACL access, the system branch, or an
88
+ // admin/reviewer (alone on a locked-down branch, and even without ACL access
89
+ // on a protected base branch stuck in 'submitted'). Mirrors the server's
90
+ // canPerformWorkflowAction (BranchManager.tsx), which is the actual
91
+ // enforcer; this only decides whether to show the control disabled.
92
+ const userIsCreator = userContext?.userId === branchCreatedBy;
93
+ const isSystemBranch = branchCreatedBy === SYSTEM_USER_ID && !branchIsProtected;
94
+ const userInACL = userContext &&
95
+ branchAccess &&
96
+ (branchAccess.allowedUsers?.includes(userContext.userId) ||
97
+ userContext.groups?.some((g) => branchAccess.allowedGroups?.includes(g)));
98
+ const userIsPrivileged = !!userContext && (isAdmin(userContext.groups ?? []) || isReviewer(userContext.groups ?? []));
99
+ const userHasPermission = !isLockedOutOfBranch(branchAccess, userContext?.groups) &&
100
+ (userIsCreator || userInACL || isSystemBranch || userIsPrivileged);
101
+ // A status with no available transition (today: 'archived') is a
102
+ // separate condition from lacking permission, and conflating them
103
+ // sent people to an admin to fix a non-permissions problem.
104
+ const statusHasAction = isEditing || isWithdrawable;
105
+ const canPerformAction = userHasPermission && statusHasAction;
106
+ // Blocked, it stays focusable (`aria-disabled`), so its reason opens on focus.
107
+ // `busy` alone can't hold it: an entry load clears that shared flag mid-run.
108
+ const actionBlocked = !branchName || busy || savingAll || !canPerformAction;
109
+ const waitReason = savingAll ? 'Wait for Save all to finish.' : undefined;
110
+ return (_jsx(Tooltip, { label:
111
+ // No branch data (`branchStatus === undefined`) must not be
112
+ // interpolated raw into this label. Guarded explicitly here rather
113
+ // than left to the early return above, which hides this button
114
+ // today only as a side effect of `branchIsProtected`'s `?? true`
115
+ // default -- not a guarantee this arm stays unreachable.
116
+ waitReason ??
117
+ (branchStatus === undefined
118
+ ? 'Branch details could not be loaded. Use "Switch branch…" to retry.'
119
+ : !statusHasAction
120
+ ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
121
+ : !userHasPermission
122
+ ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
123
+ : ''), disabled: canPerformAction && !waitReason, multiline: true, maw: 320, children: _jsx(Button, { size: "sm",
124
+ // Save is the top bar's one filled primary; the rest take their colour's light variant.
125
+ variant: "light", color: isWithdrawable ? 'orange' : undefined, style: { flexShrink: 0 }, onClick: () => {
126
+ if (actionBlocked)
127
+ return;
128
+ if (isWithdrawable)
129
+ onWithdraw();
130
+ else
131
+ onSubmit();
132
+ }, "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 }) }));
133
+ }