canopycms 0.12.0 → 0.13.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 (177) hide show
  1. package/CHANGELOG.md +326 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-review.d.ts +20 -2
  4. package/dist/api/branch-review.js +153 -32
  5. package/dist/api/branch-status.d.ts +23 -2
  6. package/dist/api/branch-status.js +16 -2
  7. package/dist/api/branch-update-from-base.d.ts +23 -0
  8. package/dist/api/branch-update-from-base.js +227 -0
  9. package/dist/api/branch.d.ts +10 -0
  10. package/dist/api/branch.js +28 -2
  11. package/dist/api/client.d.ts +15 -2
  12. package/dist/api/client.js +24 -2
  13. package/dist/api/comments.d.ts +2 -2
  14. package/dist/api/content.d.ts +38 -1
  15. package/dist/api/content.js +41 -5
  16. package/dist/api/merge-conflicts.d.ts +235 -0
  17. package/dist/api/merge-conflicts.js +709 -0
  18. package/dist/api/routes.js +2 -0
  19. package/dist/api/types.d.ts +8 -3
  20. package/dist/base-update.d.ts +32 -0
  21. package/dist/base-update.js +31 -0
  22. package/dist/branch-metadata-file.d.ts +66 -0
  23. package/dist/branch-metadata-file.js +16 -1
  24. package/dist/branch-schema-cache.d.ts +12 -0
  25. package/dist/branch-schema-cache.js +27 -2
  26. package/dist/cli/backfill-item-keys.d.ts +77 -0
  27. package/dist/cli/backfill-item-keys.js +320 -0
  28. package/dist/cli/cli.d.ts +1 -1
  29. package/dist/cli/cli.js +9798 -1509
  30. package/dist/cli/generate-ai-content.js +373 -249
  31. package/dist/cli/github-app-manifest.d.ts +4 -1
  32. package/dist/cli/github-app-manifest.js +5 -1
  33. package/dist/cli/init.js +12 -0
  34. package/dist/cli/site-config.d.ts +14 -2
  35. package/dist/cli/site-config.js +20 -4
  36. package/dist/comment-store.d.ts +8 -0
  37. package/dist/comment-store.js +28 -0
  38. package/dist/content-index-generation.d.ts +1 -4
  39. package/dist/content-index-generation.js +1 -4
  40. package/dist/content-store.d.ts +39 -3
  41. package/dist/content-store.js +48 -15
  42. package/dist/editor/BranchManager.d.ts +4 -1
  43. package/dist/editor/BranchManager.js +9 -5
  44. package/dist/editor/Editor.js +462 -62
  45. package/dist/editor/EntryNavigator.d.ts +10 -2
  46. package/dist/editor/EntryNavigator.js +12 -4
  47. package/dist/editor/FormRenderer.d.ts +8 -2
  48. package/dist/editor/FormRenderer.js +5 -6
  49. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  50. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  51. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  52. package/dist/editor/base-update-wait.d.ts +78 -0
  53. package/dist/editor/base-update-wait.js +98 -0
  54. package/dist/editor/branch-status.js +1 -1
  55. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  56. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  57. package/dist/editor/components/BranchPill.d.ts +4 -1
  58. package/dist/editor/components/BranchPill.js +5 -2
  59. package/dist/editor/components/ConflictsGroup.d.ts +112 -0
  60. package/dist/editor/components/ConflictsGroup.js +404 -0
  61. package/dist/editor/components/EditorHeader.d.ts +18 -1
  62. package/dist/editor/components/EditorHeader.js +99 -33
  63. package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
  64. package/dist/editor/components/EntryChangesDrawer.js +85 -167
  65. package/dist/editor/components/EntryHeader.d.ts +3 -1
  66. package/dist/editor/components/EntryHeader.js +2 -2
  67. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  68. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  69. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  70. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  71. package/dist/editor/components/RetryToastMessage.js +1 -1
  72. package/dist/editor/components/change-parts.d.ts +51 -0
  73. package/dist/editor/components/change-parts.js +171 -0
  74. package/dist/editor/conflict-cards.d.ts +93 -0
  75. package/dist/editor/conflict-cards.js +284 -0
  76. package/dist/editor/conflict-choices.d.ts +60 -0
  77. package/dist/editor/conflict-choices.js +108 -0
  78. package/dist/editor/conflict-notice.d.ts +25 -0
  79. package/dist/editor/conflict-notice.js +39 -0
  80. package/dist/editor/copy.d.ts +12 -0
  81. package/dist/editor/copy.js +12 -0
  82. package/dist/editor/entry-diff.d.ts +2 -1
  83. package/dist/editor/entry-diff.js +5 -21
  84. package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
  85. package/dist/editor/hooks/useBaseUpdate.js +279 -0
  86. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  87. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  88. package/dist/editor/hooks/useBranchManager.d.ts +8 -1
  89. package/dist/editor/hooks/useBranchManager.js +108 -13
  90. package/dist/editor/hooks/useBranchesData.js +3 -0
  91. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  92. package/dist/editor/hooks/useConflictChoices.js +75 -0
  93. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  94. package/dist/editor/hooks/useConflictsData.js +89 -0
  95. package/dist/editor/hooks/useDraftManager.d.ts +13 -1
  96. package/dist/editor/hooks/useDraftManager.js +40 -9
  97. package/dist/editor/hooks/useEntryManager.d.ts +17 -3
  98. package/dist/editor/hooks/useEntryManager.js +11 -5
  99. package/dist/editor/release-hold.d.ts +12 -0
  100. package/dist/editor/release-hold.js +39 -0
  101. package/dist/editor/server-clock.d.ts +4 -0
  102. package/dist/editor/server-clock.js +15 -0
  103. package/dist/editor/undo-change.d.ts +7 -0
  104. package/dist/editor/undo-change.js +19 -4
  105. package/dist/git-manager.d.ts +6 -0
  106. package/dist/git-manager.js +38 -3
  107. package/dist/item-keys.d.ts +13 -0
  108. package/dist/item-keys.js +36 -14
  109. package/dist/merge/conflict-path.d.ts +40 -0
  110. package/dist/merge/conflict-path.js +128 -0
  111. package/dist/merge/diff3.d.ts +27 -0
  112. package/dist/merge/diff3.js +113 -0
  113. package/dist/merge/entry-merge.d.ts +94 -0
  114. package/dist/merge/entry-merge.js +876 -0
  115. package/dist/merge/list-alignment.d.ts +55 -0
  116. package/dist/merge/list-alignment.js +490 -0
  117. package/dist/merge/rekey.d.ts +11 -0
  118. package/dist/merge/rekey.js +75 -0
  119. package/dist/merge/text-merge.d.ts +29 -0
  120. package/dist/merge/text-merge.js +59 -0
  121. package/dist/merge/value-equality.d.ts +18 -0
  122. package/dist/merge/value-equality.js +50 -0
  123. package/dist/merge-conflicts.d.ts +232 -0
  124. package/dist/merge-conflicts.js +451 -0
  125. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  126. package/dist/operating-mode/client-safe-strategy.js +6 -0
  127. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  128. package/dist/operating-mode/types.d.ts +13 -0
  129. package/dist/services.d.ts +24 -1
  130. package/dist/services.js +138 -22
  131. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  132. package/dist/types.d.ts +63 -5
  133. package/dist/utils/content-serialize.d.ts +18 -1
  134. package/dist/utils/content-serialize.js +12 -0
  135. package/dist/utils/content-write-lock.d.ts +3 -3
  136. package/dist/utils/content-write-lock.js +3 -3
  137. package/dist/utils/git.d.ts +7 -0
  138. package/dist/utils/git.js +35 -0
  139. package/dist/utils/markdown-body-splice.d.ts +1 -1
  140. package/dist/utils/markdown-body-splice.js +1 -19
  141. package/dist/utils/markdown-parse.d.ts +9 -0
  142. package/dist/utils/markdown-parse.js +27 -0
  143. package/dist/version.d.ts +1 -1
  144. package/dist/version.js +1 -1
  145. package/dist/worker/branch-merge.d.ts +80 -40
  146. package/dist/worker/branch-merge.js +244 -58
  147. package/dist/worker/branch-policy.d.ts +18 -2
  148. package/dist/worker/branch-policy.js +36 -0
  149. package/dist/worker/cms-worker.d.ts +37 -3
  150. package/dist/worker/cms-worker.js +88 -16
  151. package/dist/worker/conflicts-status.d.ts +20 -0
  152. package/dist/worker/conflicts-status.js +204 -0
  153. package/dist/worker/field-merge.d.ts +80 -0
  154. package/dist/worker/field-merge.js +729 -0
  155. package/dist/worker/git-sync.d.ts +3 -2
  156. package/dist/worker/git-sync.js +16 -6
  157. package/dist/worker/github-commits.d.ts +42 -10
  158. package/dist/worker/github-commits.js +444 -52
  159. package/dist/worker/github-errors.d.ts +15 -0
  160. package/dist/worker/github-errors.js +47 -0
  161. package/dist/worker/github-gateway-http.js +24 -9
  162. package/dist/worker/github-gateway-wire.d.ts +49 -20
  163. package/dist/worker/github-gateway-wire.js +19 -3
  164. package/dist/worker/github-gateway.d.ts +27 -1
  165. package/dist/worker/github-gateway.js +55 -7
  166. package/dist/worker/github-transfer.d.ts +5 -3
  167. package/dist/worker/github-transfer.js +8 -5
  168. package/dist/worker/history-rewrite.d.ts +40 -8
  169. package/dist/worker/history-rewrite.js +51 -23
  170. package/dist/worker/publish-decision.d.ts +37 -0
  171. package/dist/worker/publish-decision.js +16 -0
  172. package/dist/worker/rebase.d.ts +29 -8
  173. package/dist/worker/rebase.js +492 -211
  174. package/dist/worker/task-runner.d.ts +1 -1
  175. package/dist/worker/task-runner.js +30 -41
  176. package/dist/worker/worker-context.d.ts +24 -2
  177. package/package.json +1 -1
@@ -21,7 +21,7 @@ export interface EntryNavItem {
21
21
  noPage?: boolean;
22
22
  collectionPath?: LogicalPath;
23
23
  contentId?: ContentId;
24
- /** True when this entry's file conflicted during rebase */
24
+ /** The entry has conflicts open: values changed by the branch and the latest version alike. */
25
25
  conflictNotice?: boolean;
26
26
  /** True when the entry's type is unavailable; it shows no menu, since every item in it writes. */
27
27
  unavailable?: boolean;
@@ -36,8 +36,16 @@ export interface EntryNavCollection {
36
36
  order?: readonly string[];
37
37
  entries?: EntryNavItem[];
38
38
  children?: EntryNavCollection[];
39
- /** True when this collection's .collection.json conflicted during rebase */
39
+ /** The collection's settings have conflicts open. */
40
40
  conflictNotice?: boolean;
41
+ /**
42
+ * An admin's way to keep the branch's version of the collection's conflicting settings or other
43
+ * files, which no entry's "Show changes" reaches: the menu item's label, and its action.
44
+ */
45
+ conflictKeep?: {
46
+ label: string;
47
+ onKeep: () => void;
48
+ };
41
49
  /** Registry keys of this collection's unavailable entry types; non-empty shows the message once. */
42
50
  unavailableSchemaRefs?: readonly string[];
43
51
  /** True when this user's path rules refuse a new entry here: the collection says so once. */
@@ -384,6 +384,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
384
384
  type: col.type,
385
385
  collectionPath: col.path,
386
386
  conflictNotice: col.conflictNotice,
387
+ conflictKeep: col.conflictKeep,
387
388
  unavailableSchemaRefs: col.unavailableSchemaRefs,
388
389
  addRefused: col.addRefused,
389
390
  onAdd: col.onAdd,
@@ -742,6 +743,9 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
742
743
  const onEdit = readOnly ? undefined : rawOnEdit;
743
744
  const onAddSubCollection = readOnly ? undefined : rawOnAddSubCollection;
744
745
  const onDelete = readOnly ? undefined : rawOnDelete;
746
+ const conflictKeep = readOnly
747
+ ? undefined
748
+ : node.nodeProps?.conflictKeep;
745
749
  const entryPath = node.nodeProps?.entryPath;
746
750
  const contentId = node.nodeProps?.contentId;
747
751
  const parentCollectionPath = node.nodeProps?.parentCollectionPath;
@@ -766,7 +770,8 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
766
770
  const canMoveUp = canReorder && childIndex > 0;
767
771
  const canMoveDown = canReorder && typeof totalChildrenCount === 'number' && childIndex < totalChildrenCount - 1;
768
772
  // Collections show menu for add/edit/delete actions OR for reordering (subcollections)
769
- const hasCollectionMenu = isCollection && (onAdd || onEdit || onAddSubCollection || onDelete || canReorder);
773
+ const hasCollectionMenu = isCollection &&
774
+ (onAdd || onEdit || onAddSubCollection || onDelete || conflictKeep || canReorder);
770
775
  // An unavailable entry cannot be written, so its menu (rename, delete, reorder) is not offered.
771
776
  const entryUnavailable = node.nodeProps?.unavailable;
772
777
  // The same holds for an entry this user may not edit, on any branch: its own lock stays, since a
@@ -800,7 +805,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
800
805
  : undefined;
801
806
  const showEntryDirtyDot = isEntry && !!entryPath && !!dirtyPaths?.has(entryPath);
802
807
  const showCollectionDirtyDot = !isEntry && dirtyCollections.has(node.value);
803
- const adminItems = onAddSubCollection || onEdit || onDelete;
808
+ const adminItems = onAddSubCollection || onEdit || onDelete || conflictKeep;
804
809
  const hasCollectionContentItems = onAdd || canReorder;
805
810
  return (_jsx(RowDetailTip, { detail: detail, children: _jsxs(Box, { ...elementProps, ref: selected ? selectedNodeRef : undefined, "data-nav-row": true, "data-nav-selected": selected ? '' : undefined, "data-testid": `entry-nav-item-${testIdSuffix}`, onClick: (event) => {
806
811
  elementProps.onClick(event);
@@ -825,7 +830,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
825
830
  flexShrink: 0,
826
831
  alignItems: 'center',
827
832
  justifyContent: 'center',
828
- }, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsxs(Stack, { gap: 0, style: { flex: 1, minWidth: 0 }, children: [_jsxs(Group, { gap: 6, wrap: "nowrap", style: { minWidth: 0 }, children: [_jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), changeMarker && (_jsx(ChangeMarker, { kind: changeMarker, context: (rowChange && movedFrom(rowChange)) ?? undefined })), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }), conflictNotice && (_jsx(Tooltip, { label: "This content was updated on the base branch \u2014 a reviewer will reconcile", withArrow: true, children: _jsx(Badge, { size: "xs", variant: "light", color: "orange", "data-testid": "conflict-badge", style: { flexShrink: 0 }, children: "Conflict" }) })), entryLocked && (_jsx(Tooltip, { label: "You can't edit this entry", withinPortal: true, children: _jsx(Box, { component: "span", role: "img", "aria-label": "You can't edit this entry", "data-testid": "entry-lock", c: "dimmed", style: { display: 'inline-flex', flexShrink: 0 }, children: _jsx(IconLock, { size: 14, "aria-hidden": true }) }) }))] }), detail && (_jsx(Box, { "data-testid": "entry-nav-detail", style: { minWidth: 0 }, children: _jsx(TruncatedLabel, { label: detail, size: "xs",
833
+ }, children: _jsx(Chevron, { expanded: expanded, visible: showChevron }) }), _jsxs(Stack, { gap: 0, style: { flex: 1, minWidth: 0 }, children: [_jsxs(Group, { gap: 6, wrap: "nowrap", style: { minWidth: 0 }, children: [_jsx(TruncatedLabel, { label: label, highlight: filtering ? query.trim() : '', fw: selected ? 600 : 500 }), changeMarker && (_jsx(ChangeMarker, { kind: changeMarker, context: (rowChange && movedFrom(rowChange)) ?? undefined })), showEntryDirtyDot && _jsx(UnsavedDot, { label: "Unsaved changes" }), showCollectionDirtyDot && _jsx(UnsavedDot, { label: "Contains unsaved changes" }), conflictNotice && (_jsx(Tooltip, { label: 'Changed by you and by someone else. Choose what to keep in "Show changes".', withArrow: true, children: _jsxs(Badge, { size: "xs", variant: "light", color: "yellow", "data-testid": "conflict-badge", style: { flexShrink: 0 }, children: ["Conflict", _jsx(VisuallyHidden, { children: ". Changed by you and by someone else. Choose what to keep in \"Show changes\"." })] }) })), entryLocked && (_jsx(Tooltip, { label: "You can't edit this entry", withinPortal: true, children: _jsx(Box, { component: "span", role: "img", "aria-label": "You can't edit this entry", "data-testid": "entry-lock", c: "dimmed", style: { display: 'inline-flex', flexShrink: 0 }, children: _jsx(IconLock, { size: 14, "aria-hidden": true }) }) }))] }), detail && (_jsx(Box, { "data-testid": "entry-nav-detail", style: { minWidth: 0 }, children: _jsx(TruncatedLabel, { label: detail, size: "xs",
829
834
  // A missing file is an error, in error text, not a dimmed type.
830
835
  c: entryMissing ? 'var(--canopy-danger-text)' : 'dimmed' }) }))] })] }), _jsxs(Group, { gap: 4, wrap: "nowrap", style: { flexShrink: 0 }, children: [hasCollectionMenu && (_jsxs(Menu, { shadow: "md", width: 200, withinPortal: true, position: "bottom-end", onOpen: onRowMenuOpen, children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Collection actions", "data-testid": `collection-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onAdd && (_jsx(Menu.Item, { leftSection: _jsx(IconPlus, { size: 14 }), onClick: (event) => {
831
836
  event.stopPropagation();
@@ -836,7 +841,10 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
836
841
  }, children: "Move up" }), _jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowDown, { size: 14 }), blockedReason: canMoveDown ? undefined : 'Already last', onClick: (event) => {
837
842
  event.stopPropagation();
838
843
  onReorderEntry?.(parentCollectionPath, contentId, 'down');
839
- }, children: "Move down" })] })), adminItems && (_jsxs(_Fragment, { children: [hasCollectionContentItems && _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Admin" })] })), onAddSubCollection && (_jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: (event) => {
844
+ }, children: "Move down" })] })), adminItems && (_jsxs(_Fragment, { children: [hasCollectionContentItems && _jsx(Menu.Divider, {}), _jsx(Menu.Label, { children: "Admin" })] })), conflictKeep && (_jsx(Menu.Item, { leftSection: _jsx(IconAlertTriangle, { size: 14 }), onClick: (event) => {
845
+ event.stopPropagation();
846
+ conflictKeep.onKeep();
847
+ }, "data-testid": `collection-keep-conflicts-${testIdSuffix}`, children: conflictKeep.label })), onAddSubCollection && (_jsx(Menu.Item, { leftSection: _jsx(IconFolderPlus, { size: 14 }), onClick: (event) => {
840
848
  event.stopPropagation();
841
849
  onAddSubCollection();
842
850
  }, children: "Add sub-collection\u2026" })), onEdit && (_jsx(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
@@ -28,8 +28,14 @@ export interface FormRendererProps {
28
28
  highlightThreadId?: string;
29
29
  onAddComment?: (text: string, type: 'field' | 'entry' | 'branch', entryPath?: string, canopyPath?: string, threadId?: string) => Promise<void>;
30
30
  onResolveThread?: (threadId: string) => Promise<void>;
31
- /** True when this entry's content conflicts with a recent change on the base branch */
32
- conflictNotice?: boolean;
31
+ /**
32
+ * The entry's open conflicts, said once above the fields (conflict-notice.ts), with the way to
33
+ * "Show changes" when it is offered.
34
+ */
35
+ conflictNotice?: {
36
+ message: string;
37
+ onShowChanges?: () => void;
38
+ };
33
39
  /**
34
40
  * Per-field validation errors keyed by canonical canopy path (e.g.
35
41
  * `blocks[0].title`). Rendered as a summary alert plus an inline message
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { Alert, Stack, Text } from '@mantine/core';
5
- import { IconInfoCircle } from '@tabler/icons-react';
4
+ import { Alert, Button, Group, Stack, Text } from '@mantine/core';
5
+ import { IconAlertTriangle } from '@tabler/icons-react';
6
+ import { EDITOR_ACTIONS } from './copy.js';
6
7
  import { MarkdownField } from './fields/MarkdownField.js';
7
8
  import { useSiteMdxAllow } from './context/index.js';
8
9
  import { markdownPolicyOf } from '../validation/mdx-allowlist.js';
@@ -99,7 +100,7 @@ const FieldBoundary = ({ canopyPath, resetKey, update, build, fallback }) => {
99
100
  setGeneration(liveGeneration.current);
100
101
  }, fallback: fallback, children: _jsx(FieldControl, { build: () => build(guardedUpdate) }) }));
101
102
  };
102
- export const FormRenderer = ({ fields, value, onChange, customRenderers, branch = 'main', comments = [], currentEntryPath, currentUserId, canResolve = false, focusedFieldPath, highlightThreadId, onAddComment, onResolveThread, conflictNotice = false, fieldErrors, format, readOnly = false, onRemoved, savedValue, onGetUserMetadata, header, notice, commentBlockedReason, removedItemFieldLabel, }) => {
103
+ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch = 'main', comments = [], currentEntryPath, currentUserId, canResolve = false, focusedFieldPath, highlightThreadId, onAddComment, onResolveThread, conflictNotice, fieldErrors, format, readOnly = false, onRemoved, savedValue, onGetUserMetadata, header, notice, commentBlockedReason, removedItemFieldLabel, }) => {
103
104
  const emit = readOnly ? noop : onChange;
104
105
  const formRef = useRef(null);
105
106
  const customTypes = new Set(Object.keys(customRenderers ?? {}));
@@ -310,9 +311,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
310
311
  }, onExpandAll: hasCollapsibleList(fields, customTypes)
311
312
  ? (expanded) => setExpansionCommand((prev) => ({ seq: (prev?.seq ?? 0) + 1, expanded }))
312
313
  : undefined })), notice, readOnly && _jsx(ReadOnlyFormStyles, {}), currentEntryPath && currentUserId && onAddComment && onResolveThread && (_jsx(EntryComments, { comments: comments, entryPath: currentEntryPath, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetUserMetadata, resetKey: boundaryResetKey, removedItemFieldLabel: (thread) => removedItemFieldLabel?.(thread) ??
313
- fieldBreadcrumb(fields, undefined, thread.canopyPath ?? '', { items: false }).join(' › ') })), conflictNotice && (_jsx(Alert, { icon: _jsx(IconInfoCircle, { size: 16 }), color: "orange", variant: "light", title: "Page updated since your draft started", "data-testid": "conflict-alert", children: readOnly
314
- ? 'Someone else has recently changed this page.'
315
- : 'Someone else has recently changed this page. You can keep editing — a reviewer will reconcile your changes when you submit.' })), fieldErrors && _jsx(ValidationSummary, { fields: fields, value: value, errors: fieldErrors }), fields.map((field) => {
314
+ fieldBreadcrumb(fields, undefined, thread.canopyPath ?? '', { items: false }).join(' › ') })), conflictNotice && (_jsx(Alert, { icon: _jsx(IconAlertTriangle, { size: 16 }), color: "yellow", variant: "light", "data-testid": "conflict-alert", children: _jsxs(Group, { gap: "xs", justify: "space-between", wrap: "wrap", children: [_jsx(Text, { size: "sm", children: conflictNotice.message }), conflictNotice.onShowChanges && (_jsx(Button, { size: "compact-sm", variant: "subtle", onClick: conflictNotice.onShowChanges, "data-testid": "conflict-alert-show-changes", children: EDITOR_ACTIONS.showChanges }))] }) })), fieldErrors && _jsx(ValidationSummary, { fields: fields, value: value, errors: fieldErrors }), fields.map((field) => {
316
315
  // Inline groups are transparent to the value — render them directly with parent value/onChange
317
316
  if (field.type === 'group') {
318
317
  const groupField = field;
@@ -0,0 +1,8 @@
1
+ import type { ConflictsStatusFailure } from '../../types.js';
2
+ /**
3
+ * The last git sync could not set the "CanopyCMS conflicts" status on some pull requests
4
+ * (worker/conflicts-status.ts).
5
+ */
6
+ export declare function ConflictsStatusAlert({ failure }: {
7
+ failure: ConflictsStatusFailure;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Alert, Code, Group, List, Stack, Text } from '@mantine/core';
4
+ import { IconAlertTriangle } from '@tabler/icons-react';
5
+ import { escapeForLog } from '../../utils/escape-text.js';
6
+ import { RelativeTime } from '../components/RelativeTime.js';
7
+ /** A long unbroken name wraps inside the alert rather than widening it. */
8
+ const WRAP = { overflowWrap: 'anywhere' };
9
+ const WHY = {
10
+ permission: (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", children: "GitHub refused it: the worker's GitHub credential lacks the \"Commit statuses\" permission." }), _jsxs(List, { size: "sm", spacing: 2, children: [_jsxs(List.Item, { children: ["A GitHub App: its owner adds \"Commit statuses: read and write\" in the App's permissions on GitHub, then an owner of the account it is installed on accepts the request in the installation's settings.", ' ', _jsx(Code, { style: { whiteSpace: 'nowrap' }, children: "canopycms init-github-app verify" }), " confirms it."] }), _jsx(List.Item, { children: "A fine-grained token: give it \"Commit statuses: read and write\". A classic token's repo scope already has it." })] })] })),
11
+ unreachable: (_jsx(Text, { size: "sm", children: "GitHub couldn't be reached, or was busy. The next git sync tries again." })),
12
+ other: (_jsx(Text, { size: "sm", children: "GitHub refused it, or the branch's record of conflicts couldn't be read. The next git sync tries again; if it keeps failing, the worker log names each branch and why." })),
13
+ };
14
+ /**
15
+ * The last git sync could not set the "CanopyCMS conflicts" status on some pull requests
16
+ * (worker/conflicts-status.ts).
17
+ */
18
+ export function ConflictsStatusAlert({ failure }) {
19
+ const unlisted = failure.count - failure.branches.length;
20
+ const pullRequests = `${failure.count} pull request${failure.count === 1 ? '' : 's'}`;
21
+ return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `Conflicts status not set on ${pullRequests}`, "data-testid": "conflicts-status-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "The \"CanopyCMS conflicts\" check on the pull request of each branch below keeps its last result, or has none yet. Where it is a required check, merging waits on it. Saving, submitting and pull request updates are unaffected." }), WHY[failure.reason], _jsx(Group, { gap: 4, children: failure.branches.map((name) => (_jsx(Code, { style: WRAP, children: escapeForLog(name) }, name))) }), unlisted > 0 && _jsxs(Text, { size: "sm", children: ["And ", unlisted, " more."] }), _jsxs(Text, { size: "xs", c: "dimmed", style: WRAP, children: ["The last error: \u201C", escapeForLog(failure.message), "\u201D"] }), _jsx(RelativeTime, { iso: failure.at, prefix: "Last tried ", size: "xs", c: "dimmed" })] }) }));
22
+ }
@@ -21,6 +21,7 @@ import { ageDeletionHold, archivedBefore, archivedCutoff, deletableByAge, } from
21
21
  import { escapeForLog } from '../../utils/escape-text.js';
22
22
  import { TooltipDetail } from '../components/TooltipDetail.js';
23
23
  import { DISMISS_UNPUBLISHED_ATTRIBUTE, UnpublishedAtMergeAlert } from './UnpublishedAtMergeAlert.js';
24
+ import { ConflictsStatusAlert } from './ConflictsStatusAlert.js';
24
25
  import { HOLD_BADGE, branchHoldMessage, branchStatusPresentation, endSentence, } from '../branch-status.js';
25
26
  // ============================================================================
26
27
  // Small pure helpers
@@ -132,7 +133,7 @@ function BaseHoldAlert({ hold }) {
132
133
  * and never listed. Names come from GitHub and are attacker-chosen, so each is shown escaped.
133
134
  */
134
135
  function DivergedBranchesAlert({ names }) {
135
- return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree. A branch that only gained commits on GitHub clears on its own at the next sync." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
136
+ return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree. A branch that only gained commits on GitHub clears on its own at the next sync." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { style: { overflowWrap: 'anywhere' }, children: escapeForLog(name) }, name))) })] }) }));
136
137
  }
137
138
  /**
138
139
  * The branch namespace the CMS creates branches in, against the one the GitHub gateway writes. A
@@ -150,7 +151,7 @@ function BranchNamespaceAlert({ own, gateway, }) {
150
151
  }
151
152
  /** Branch refs from metadata the CMS Lambda can write, so each is shown escaped. */
152
153
  function BranchRefs({ branches }) {
153
- return (_jsx(Group, { gap: 4, children: branches.map((branch, i) => (_jsx(Code, { children: escapeForLog(branch.name) }, i))) }));
154
+ return (_jsx(Group, { gap: 4, children: branches.map((branch, i) => (_jsx(Code, { style: { overflowWrap: 'anywhere' }, children: escapeForLog(branch.name) }, i))) }));
154
155
  }
155
156
  /**
156
157
  * Editor branches the GitHub gateway will not publish, by ref, which is what a developer lists
@@ -307,7 +308,7 @@ function OverviewTab({ health, overviewTabRef, }) {
307
308
  workerVersion !== build.canopycmsVersion;
308
309
  return (_jsxs(Stack, { ref: overviewRef, gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
309
310
  ? 'The worker failed to start'
310
- : 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), unpublished.length > 0 && (_jsx(UnpublishedAtMergeAlert, { branches: unpublished, onDismiss: handleDismissUnpublished })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
311
+ : 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), unpublished.length > 0 && (_jsx(UnpublishedAtMergeAlert, { branches: unpublished, onDismiss: handleDismissUnpublished })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.conflictsStatusFailure && (_jsx(ConflictsStatusAlert, { failure: status.workerStatus.conflictsStatusFailure })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
311
312
  ` · drain deadline hit, aborted ${lastShutdown.abandoned?.join(', ') ?? 'in-flight work'}`, lastShutdown.outcome === 'drained' &&
312
313
  lastShutdown.drainMs !== undefined &&
313
314
  ` · drained in ${(lastShutdown.drainMs / 1000).toFixed(1)}s`, lastShutdown.outcome === 'not-drained' && ' (a crash or a forced stop)'] })), lastFatalError?.phase === 'startup' && (_jsxs(Text, { size: "xs", c: "orange", "data-testid": "build-failed-start", children: ["Last start failed at ", lastFatalError.at] })), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
@@ -0,0 +1,78 @@
1
+ import type { BranchMetadata, LastBaseUpdate } from '../types.js';
2
+ /**
3
+ * The editor's side of a request for the latest changes (base-update.ts): whether the open
4
+ * branch is waiting on one, how often to poll meanwhile, and what to say. The request and the
5
+ * answer both live on branch.json, so every viewer whose branch list shows the request waits,
6
+ * and a reload keeps waiting.
7
+ */
8
+ /**
9
+ * How long the form stays read-only before the wait reads as slow and the form unlocks.
10
+ * @internal Exported for tests.
11
+ */
12
+ export declare const BASE_UPDATE_SLOW_MS: number;
13
+ /**
14
+ * The poll while the form is read-only.
15
+ * @internal Exported for tests.
16
+ */
17
+ export declare const BASE_UPDATE_POLL_MS = 3000;
18
+ /**
19
+ * The poll once the wait is slow.
20
+ * @internal Exported for tests.
21
+ */
22
+ export declare const BASE_UPDATE_SLOW_POLL_MS = 15000;
23
+ /**
24
+ * `waiting`: asked under {@link BASE_UPDATE_SLOW_MS} ago; the form is read-only. `slow`: asked
25
+ * longer ago, under {@link BASE_UPDATE_GIVE_UP_MS}; the form is editable again. `none`: no
26
+ * request is standing, or it is too old to wait on, or the branch left editing, which the worker
27
+ * never brings the base into.
28
+ */
29
+ export type BaseUpdateWait = 'none' | 'waiting' | 'slow';
30
+ type WaitFields = Pick<BranchMetadata, 'status' | 'baseUpdateRequestedAt' | 'lastBaseUpdate'>;
31
+ export declare function baseUpdateWait(branch: WaitFields | undefined, now: number): BaseUpdateWait;
32
+ /** When a branch's wait next changes on its own, for a timer to re-render at; undefined for never. */
33
+ export declare function nextBaseUpdateWaitChange(branch: WaitFields | undefined, now: number): number | undefined;
34
+ /** How often the open branch's listing is re-read while it waits; 0 when it doesn't. */
35
+ export declare function baseUpdatePollMs(wait: BaseUpdateWait): number;
36
+ /** @internal Exported for tests. */
37
+ export declare const GET_LATEST_CHANGES_TOOLTIP = "Bring in changes made on the site since you started this branch.";
38
+ /** The Tooltip; an admin also learns which branch the changes come from. */
39
+ export declare function getLatestChangesTooltip(baseBranch: string | undefined, isAdmin: boolean): string;
40
+ export declare const GETTING_LATEST_CHANGES = "Getting latest changes\u2026";
41
+ /** The bar's badge while the automatic sync leaves the base out for the branch's saved work. */
42
+ export declare const LATEST_CHANGES_WAITING = "Latest changes waiting";
43
+ /** The badge's detail: below `md` the action is in the branch menu, and unsaved changes hold it. */
44
+ export declare function latestChangesWaitingDetail(inMenu: boolean, saveFirst: boolean): string;
45
+ export declare const SAVE_FIRST_REASON = "Save your changes first.";
46
+ export declare const WAITING_NOTICE = "Getting the latest changes. You can edit again when it finishes.";
47
+ export declare const SLOW_NOTICE = "This is taking longer than usual. You can keep editing; the latest changes will come in when the server gets to them.";
48
+ /** Why Submit for review waits while the latest changes come in. */
49
+ export declare const WAITING_SUBMIT_REASON = "Wait for the latest changes to come in.";
50
+ /** The waiting notice's addition for a viewer whose unsaved changes it hides meanwhile. */
51
+ export declare const WAITING_DRAFT_NOTE = "Your unsaved changes come back when it finishes.";
52
+ /** When the editor stops waiting on a request the server hasn't answered. */
53
+ export declare const GAVE_UP_MESSAGE = "The latest changes haven't come in yet. They will when the server gets to them; use \"Refresh branch\" in the branch menu to see them.";
54
+ /** A request the server didn't answer, which may still have gone through. */
55
+ export declare const UNANSWERED_MESSAGE = "Your request for the latest changes may have gone through even though the server didn't answer. Choose \"Refresh branch\" in the branch menu to check.";
56
+ export declare const REQUEST_FAILED_MESSAGE = "The latest changes couldn't be requested.";
57
+ /**
58
+ * The toast an answer gets: its colour, its text, and whether it stays until dismissed.
59
+ * `openConflicts` is the choices the branch has open once a merge with conflicts lands, read from
60
+ * the branch's conflicts; undefined when they couldn't be counted.
61
+ */
62
+ export declare function baseUpdateResultMessage(answer: LastBaseUpdate, openConflicts?: number): {
63
+ color: 'green' | 'yellow' | 'red';
64
+ message: string;
65
+ sticky: boolean;
66
+ };
67
+ /** What the bar and the branch card need to offer "Get latest changes" on the open branch. */
68
+ export interface BaseUpdateControl {
69
+ wait: BaseUpdateWait;
70
+ /** The request itself is in flight. */
71
+ requesting: boolean;
72
+ /** Why it can't be asked now. */
73
+ blockedReason?: string;
74
+ /** The Tooltip while it can be asked ({@link getLatestChangesTooltip}). */
75
+ tooltip: string;
76
+ onRequest: () => void;
77
+ }
78
+ export {};
@@ -0,0 +1,98 @@
1
+ import { BASE_UPDATE_CLOCK_SKEW_MS, BASE_UPDATE_GIVE_UP_MS, isBaseUpdatePending, } from '../base-update.js';
2
+ import { latestChangesConflictsMessage } from './conflict-notice.js';
3
+ /**
4
+ * The editor's side of a request for the latest changes (base-update.ts): whether the open
5
+ * branch is waiting on one, how often to poll meanwhile, and what to say. The request and the
6
+ * answer both live on branch.json, so every viewer whose branch list shows the request waits,
7
+ * and a reload keeps waiting.
8
+ */
9
+ /**
10
+ * How long the form stays read-only before the wait reads as slow and the form unlocks.
11
+ * @internal Exported for tests.
12
+ */
13
+ export const BASE_UPDATE_SLOW_MS = 2 * 60_000;
14
+ /**
15
+ * The poll while the form is read-only.
16
+ * @internal Exported for tests.
17
+ */
18
+ export const BASE_UPDATE_POLL_MS = 3_000;
19
+ /**
20
+ * The poll once the wait is slow.
21
+ * @internal Exported for tests.
22
+ */
23
+ export const BASE_UPDATE_SLOW_POLL_MS = 15_000;
24
+ export function baseUpdateWait(branch, now) {
25
+ if (!branch || branch.status !== 'editing' || !isBaseUpdatePending(branch))
26
+ return 'none';
27
+ const age = now - Date.parse(branch.baseUpdateRequestedAt ?? '');
28
+ if (!(age < BASE_UPDATE_GIVE_UP_MS) || age < -BASE_UPDATE_CLOCK_SKEW_MS)
29
+ return 'none';
30
+ return age < BASE_UPDATE_SLOW_MS ? 'waiting' : 'slow';
31
+ }
32
+ /** When a branch's wait next changes on its own, for a timer to re-render at; undefined for never. */
33
+ export function nextBaseUpdateWaitChange(branch, now) {
34
+ const wait = baseUpdateWait(branch, now);
35
+ if (wait === 'none')
36
+ return undefined;
37
+ const requestedAt = Date.parse(branch?.baseUpdateRequestedAt ?? '');
38
+ return requestedAt + (wait === 'waiting' ? BASE_UPDATE_SLOW_MS : BASE_UPDATE_GIVE_UP_MS);
39
+ }
40
+ /** How often the open branch's listing is re-read while it waits; 0 when it doesn't. */
41
+ export function baseUpdatePollMs(wait) {
42
+ return wait === 'waiting' ? BASE_UPDATE_POLL_MS : wait === 'slow' ? BASE_UPDATE_SLOW_POLL_MS : 0;
43
+ }
44
+ /** @internal Exported for tests. */
45
+ export const GET_LATEST_CHANGES_TOOLTIP = 'Bring in changes made on the site since you started this branch.';
46
+ /** The Tooltip; an admin also learns which branch the changes come from. */
47
+ export function getLatestChangesTooltip(baseBranch, isAdmin) {
48
+ return isAdmin && baseBranch
49
+ ? `${GET_LATEST_CHANGES_TOOLTIP.slice(0, -1)} (from ${baseBranch}).`
50
+ : GET_LATEST_CHANGES_TOOLTIP;
51
+ }
52
+ export const GETTING_LATEST_CHANGES = 'Getting latest changes…';
53
+ /** The bar's badge while the automatic sync leaves the base out for the branch's saved work. */
54
+ export const LATEST_CHANGES_WAITING = 'Latest changes waiting';
55
+ /** The badge's detail: below `md` the action is in the branch menu, and unsaved changes hold it. */
56
+ export function latestChangesWaitingDetail(inMenu, saveFirst) {
57
+ const control = `"Get latest changes"${inMenu ? ' in the branch menu' : ''}`;
58
+ return `Newer changes made on the site are waiting for this branch. ${saveFirst ? `Save your changes, then choose ${control}` : `Choose ${control}`} to bring them in.`;
59
+ }
60
+ export const SAVE_FIRST_REASON = 'Save your changes first.';
61
+ export const WAITING_NOTICE = 'Getting the latest changes. You can edit again when it finishes.';
62
+ export const SLOW_NOTICE = 'This is taking longer than usual. You can keep editing; the latest changes will come in when the server gets to them.';
63
+ /** Why Submit for review waits while the latest changes come in. */
64
+ export const WAITING_SUBMIT_REASON = 'Wait for the latest changes to come in.';
65
+ /** The waiting notice's addition for a viewer whose unsaved changes it hides meanwhile. */
66
+ export const WAITING_DRAFT_NOTE = 'Your unsaved changes come back when it finishes.';
67
+ /** When the editor stops waiting on a request the server hasn't answered. */
68
+ export const GAVE_UP_MESSAGE = 'The latest changes haven\'t come in yet. They will when the server gets to them; use "Refresh branch" in the branch menu to see them.';
69
+ /** A request the server didn't answer, which may still have gone through. */
70
+ export const UNANSWERED_MESSAGE = 'Your request for the latest changes may have gone through even though the server didn\'t answer. Choose "Refresh branch" in the branch menu to check.';
71
+ export const REQUEST_FAILED_MESSAGE = "The latest changes couldn't be requested.";
72
+ /**
73
+ * The toast an answer gets: its colour, its text, and whether it stays until dismissed.
74
+ * `openConflicts` is the choices the branch has open once a merge with conflicts lands, read from
75
+ * the branch's conflicts; undefined when they couldn't be counted.
76
+ */
77
+ export function baseUpdateResultMessage(answer, openConflicts) {
78
+ switch (answer.outcome) {
79
+ case 'merged':
80
+ return { color: 'green', message: 'Latest changes added', sticky: false };
81
+ case 'up-to-date':
82
+ return { color: 'green', message: 'You already have the latest changes', sticky: false };
83
+ case 'conflicts':
84
+ if (openConflicts === 0)
85
+ return { color: 'green', message: 'Latest changes added', sticky: false };
86
+ return {
87
+ color: 'yellow',
88
+ message: latestChangesConflictsMessage(openConflicts),
89
+ sticky: true,
90
+ };
91
+ case 'failed':
92
+ return {
93
+ color: 'red',
94
+ message: "The latest changes couldn't be added. Your work is safe.",
95
+ sticky: true,
96
+ };
97
+ }
98
+ }
@@ -42,7 +42,7 @@ export function branchHoldMessage(hold, viewer) {
42
42
  return `${stuck} Use "Request changes" to send it back to its editor.`;
43
43
  if (viewer.role === 'none')
44
44
  return `${stuck} Its editor needs to withdraw it from review.`;
45
- return `${stuck} Use "Withdraw from review", check any flagged entries, then submit it again.`;
45
+ return `${stuck} Use "Withdraw from review" to bring in the latest changes, check any flagged entries, then submit it again.`;
46
46
  }
47
47
  case 'retargeted':
48
48
  return `This pull request now merges into ${escapeForLog(hold.base)} on GitHub, so CanopyCMS isn't updating it. Update it on the pull request${viewer.isAdmin ? '' : ', or ask an admin'}.`;
@@ -0,0 +1,12 @@
1
+ import { type BaseUpdateWait } from '../base-update-wait.js';
2
+ /**
3
+ * The one notice above the form while the open branch gets the latest changes: blue while the
4
+ * form is read-only, then yellow once the wait is slow and the form is editable again, which can
5
+ * be dismissed until the wait changes. The bar's status region announces the wait starting, so the
6
+ * blue notice is not a live region; the yellow one, which the bar doesn't announce, is polite.
7
+ */
8
+ export declare function BaseUpdateNotice({ wait, hidesDraft, }: {
9
+ wait: Exclude<BaseUpdateWait, 'none'>;
10
+ /** The read-only form hides this viewer's unsaved changes meanwhile. */
11
+ hidesDraft?: boolean;
12
+ }): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { Alert } from '@mantine/core';
5
+ import { IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
6
+ import { SLOW_NOTICE, WAITING_DRAFT_NOTE, WAITING_NOTICE, } from '../base-update-wait.js';
7
+ /**
8
+ * The one notice above the form while the open branch gets the latest changes: blue while the
9
+ * form is read-only, then yellow once the wait is slow and the form is editable again, which can
10
+ * be dismissed until the wait changes. The bar's status region announces the wait starting, so the
11
+ * blue notice is not a live region; the yellow one, which the bar doesn't announce, is polite.
12
+ */
13
+ export function BaseUpdateNotice({ wait, hidesDraft = false, }) {
14
+ // Dismissed for one wait only: a new phase shows it again.
15
+ const [dismissedFor, setDismissedFor] = useState(null);
16
+ if (wait === 'waiting') {
17
+ return (_jsx(Alert, { color: "blue", variant: "light",
18
+ // Mantine sets role="alert" after the props it's given, so the root sets the role.
19
+ renderRoot: (props) => _jsx("div", { ...props, role: "note" }), icon: _jsx(IconInfoCircle, { size: 16, "aria-hidden": "true" }), "data-testid": "base-update-waiting-notice", children: hidesDraft ? `${WAITING_NOTICE} ${WAITING_DRAFT_NOTE}` : WAITING_NOTICE }));
20
+ }
21
+ if (dismissedFor === wait)
22
+ return null;
23
+ return (_jsx(Alert, { color: "yellow", variant: "light", renderRoot: (props) => _jsx("div", { ...props, role: "status" }), icon: _jsx(IconAlertTriangle, { size: 16, "aria-hidden": "true" }), withCloseButton: true, closeButtonLabel: "Dismiss", onClose: () => setDismissedFor(wait), "data-testid": "base-update-slow-notice", children: SLOW_NOTICE }));
24
+ }
@@ -1,4 +1,5 @@
1
1
  import { type Ref } from 'react';
2
+ import type { BaseUpdateControl } from '../base-update-wait.js';
2
3
  export interface BranchPillProps {
3
4
  branchName: string;
4
5
  /** The editor is busy; with no branch name yet, that is the branch list loading. */
@@ -10,9 +11,11 @@ export interface BranchPillProps {
10
11
  onDiscardAll: () => void;
11
12
  /** The pill, which stands in for a menu item that opened something. */
12
13
  pillRef?: Ref<HTMLButtonElement>;
14
+ /** "Get latest changes" as a menu item, where the bar has no room for its button. */
15
+ getLatest?: BaseUpdateControl;
13
16
  }
14
17
  /**
15
18
  * The branch's name. Its menu, headed by the name untruncated, holds the branch actions. Below
16
19
  * `sm` the name truncates shorter, so the actions don't clip.
17
20
  */
18
- export declare function BranchPill({ branchName, loading, unsavedCount: unsaved, onSwitchBranch, onRefreshBranch, onDiscardAll, pillRef, }: BranchPillProps): import("react/jsx-runtime").JSX.Element;
21
+ export declare function BranchPill({ branchName, loading, unsavedCount: unsaved, onSwitchBranch, onRefreshBranch, onDiscardAll, pillRef, getLatest, }: BranchPillProps): import("react/jsx-runtime").JSX.Element;
@@ -8,12 +8,13 @@ import { EDITOR_ACTIONS } from '../copy.js';
8
8
  import { DestructiveMenuItem } from './DestructiveMenuItem.js';
9
9
  import { ReasonedMenuItem } from './ReasonedMenuItem.js';
10
10
  import { useMenuItemFocusReturn } from '../utils/focus-return.js';
11
+ import { getLatestChangesReason } from './GetLatestChangesButton.js';
11
12
  const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
12
13
  /**
13
14
  * The branch's name. Its menu, headed by the name untruncated, holds the branch actions. Below
14
15
  * `sm` the name truncates shorter, so the actions don't clip.
15
16
  */
16
- export function BranchPill({ branchName, loading, unsavedCount: unsaved, onSwitchBranch, onRefreshBranch, onDiscardAll, pillRef, }) {
17
+ export function BranchPill({ branchName, loading, unsavedCount: unsaved, onSwitchBranch, onRefreshBranch, onDiscardAll, pillRef, getLatest, }) {
17
18
  const trigger = useRef(null);
18
19
  const triggerRef = useMergedRef(trigger, pillRef);
19
20
  const menuFocus = useMenuItemFocusReturn(trigger);
@@ -22,7 +23,9 @@ export function BranchPill({ branchName, loading, unsavedCount: unsaved, onSwitc
22
23
  const name = branchName || (loading ? 'Loading branches…' : 'No branch open');
23
24
  return (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-start", ...menuFocus.menu, children: [_jsx(Tooltip, { label: name, disabled: wide, events: { hover: true, focus: true, touch: true }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: triggerRef, variant: "default", size: "sm", miw: 0, px: { base: 8, sm: 14 },
24
25
  // The bar's row wraps before the pill's name clips.
25
- 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: { base: 120, sm: 200 }, children: name }) }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && _jsx(Menu.Label, { children: branchName }), _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(DestructiveMenuItem, { "data-testid": "discard-all-drafts-menu-item", onClick: onDiscardAll, blockedReason: noBranch ?? (unsaved === 0 ? 'Nothing to discard.' : undefined), children: unsaved > 0
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: { base: 120, sm: 200 }, children: name }) }) }) }), _jsxs(Menu.Dropdown, { "data-testid": "branch-menu", ...menuFocus.dropdown, children: [branchName && _jsx(Menu.Label, { children: branchName }), _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" }), getLatest && (_jsx(ReasonedMenuItem, { onClick: getLatest.onRequest, blockedReason: getLatest.requesting || getLatest.wait !== 'none'
27
+ ? getLatestChangesReason(getLatest)
28
+ : getLatest.blockedReason, "data-testid": "get-latest-changes-menu-item", children: EDITOR_ACTIONS.getLatestChanges })), _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
26
29
  ? `Discard all changes (${plural(unsaved, 'entry', 'entries')})`
27
30
  : 'Discard all changes' })] })] }));
28
31
  }