canopycms 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +174 -48
  2. package/dist/api/admin.d.ts +4 -4
  3. package/dist/api/branch-changes.d.ts +1 -0
  4. package/dist/api/branch-changes.js +1 -1
  5. package/dist/api/branch-entry-changes.d.ts +92 -7
  6. package/dist/api/branch-entry-changes.js +272 -38
  7. package/dist/api/branch-review.js +45 -13
  8. package/dist/api/branch-status.d.ts +12 -0
  9. package/dist/api/branch-status.js +13 -0
  10. package/dist/api/branch-update-from-base.d.ts +23 -0
  11. package/dist/api/branch-update-from-base.js +176 -0
  12. package/dist/api/branch.d.ts +5 -0
  13. package/dist/api/branch.js +1 -0
  14. package/dist/api/client.d.ts +17 -2
  15. package/dist/api/client.js +26 -0
  16. package/dist/api/comments.d.ts +2 -2
  17. package/dist/api/content.d.ts +38 -1
  18. package/dist/api/content.js +41 -5
  19. package/dist/api/merge-conflicts.d.ts +226 -0
  20. package/dist/api/merge-conflicts.js +674 -0
  21. package/dist/api/routes.js +2 -0
  22. package/dist/api/types.d.ts +7 -3
  23. package/dist/base-update.d.ts +26 -0
  24. package/dist/base-update.js +31 -0
  25. package/dist/branch-metadata-file.d.ts +51 -0
  26. package/dist/branch-metadata-file.js +11 -0
  27. package/dist/branch-schema-cache.d.ts +12 -0
  28. package/dist/branch-schema-cache.js +27 -2
  29. package/dist/cli/backfill-item-keys.d.ts +77 -0
  30. package/dist/cli/backfill-item-keys.js +320 -0
  31. package/dist/cli/cli.d.ts +1 -1
  32. package/dist/cli/cli.js +9713 -1493
  33. package/dist/cli/generate-ai-content.js +415 -244
  34. package/dist/cli/init.js +6 -0
  35. package/dist/cli/site-config.d.ts +14 -2
  36. package/dist/cli/site-config.js +20 -4
  37. package/dist/comment-store.d.ts +8 -0
  38. package/dist/comment-store.js +28 -0
  39. package/dist/content-index-generation.d.ts +1 -4
  40. package/dist/content-index-generation.js +1 -4
  41. package/dist/content-store.d.ts +65 -3
  42. package/dist/content-store.js +121 -15
  43. package/dist/editor/BranchManager.d.ts +4 -1
  44. package/dist/editor/BranchManager.js +9 -5
  45. package/dist/editor/Editor.js +549 -53
  46. package/dist/editor/EntryNavigator.d.ts +10 -2
  47. package/dist/editor/EntryNavigator.js +12 -4
  48. package/dist/editor/FormRenderer.d.ts +8 -2
  49. package/dist/editor/FormRenderer.js +5 -6
  50. package/dist/editor/base-update-wait.d.ts +74 -0
  51. package/dist/editor/base-update-wait.js +91 -0
  52. package/dist/editor/branch-status.js +1 -1
  53. package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
  54. package/dist/editor/components/BaseUpdateNotice.js +24 -0
  55. package/dist/editor/components/BranchPill.d.ts +4 -1
  56. package/dist/editor/components/BranchPill.js +5 -2
  57. package/dist/editor/components/ConflictsGroup.d.ts +100 -0
  58. package/dist/editor/components/ConflictsGroup.js +364 -0
  59. package/dist/editor/components/EditorHeader.d.ts +16 -1
  60. package/dist/editor/components/EditorHeader.js +92 -30
  61. package/dist/editor/components/EntryChangesDrawer.d.ts +52 -2
  62. package/dist/editor/components/EntryChangesDrawer.js +164 -162
  63. package/dist/editor/components/EntryHeader.d.ts +3 -1
  64. package/dist/editor/components/EntryHeader.js +4 -6
  65. package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
  66. package/dist/editor/components/GetLatestChangesButton.js +29 -0
  67. package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
  68. package/dist/editor/components/ReleaseHoldButton.js +36 -0
  69. package/dist/editor/components/RetryToastMessage.js +1 -1
  70. package/dist/editor/components/change-parts.d.ts +51 -0
  71. package/dist/editor/components/change-parts.js +171 -0
  72. package/dist/editor/conflict-cards.d.ts +90 -0
  73. package/dist/editor/conflict-cards.js +279 -0
  74. package/dist/editor/conflict-choices.d.ts +60 -0
  75. package/dist/editor/conflict-choices.js +108 -0
  76. package/dist/editor/conflict-notice.d.ts +25 -0
  77. package/dist/editor/conflict-notice.js +39 -0
  78. package/dist/editor/copy.d.ts +20 -0
  79. package/dist/editor/copy.js +20 -0
  80. package/dist/editor/entry-diff.d.ts +18 -2
  81. package/dist/editor/entry-diff.js +26 -32
  82. package/dist/editor/hooks/useBaseUpdate.d.ts +36 -0
  83. package/dist/editor/hooks/useBaseUpdate.js +269 -0
  84. package/dist/editor/hooks/useBranchManager.d.ts +6 -1
  85. package/dist/editor/hooks/useBranchManager.js +28 -7
  86. package/dist/editor/hooks/useBranchesData.js +3 -0
  87. package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
  88. package/dist/editor/hooks/useConflictChoices.js +75 -0
  89. package/dist/editor/hooks/useConflictsData.d.ts +26 -0
  90. package/dist/editor/hooks/useConflictsData.js +89 -0
  91. package/dist/editor/hooks/useDraftManager.d.ts +5 -0
  92. package/dist/editor/hooks/useDraftManager.js +24 -8
  93. package/dist/editor/hooks/useEntryManager.d.ts +9 -0
  94. package/dist/editor/hooks/useEntryManager.js +5 -0
  95. package/dist/editor/release-hold.d.ts +12 -0
  96. package/dist/editor/release-hold.js +39 -0
  97. package/dist/editor/server-clock.d.ts +4 -0
  98. package/dist/editor/server-clock.js +15 -0
  99. package/dist/editor/undo-change.d.ts +52 -0
  100. package/dist/editor/undo-change.js +241 -0
  101. package/dist/git-manager.d.ts +5 -0
  102. package/dist/git-manager.js +30 -0
  103. package/dist/item-keys.d.ts +13 -0
  104. package/dist/item-keys.js +36 -14
  105. package/dist/merge/conflict-path.d.ts +40 -0
  106. package/dist/merge/conflict-path.js +128 -0
  107. package/dist/merge/diff3.d.ts +27 -0
  108. package/dist/merge/diff3.js +113 -0
  109. package/dist/merge/entry-merge.d.ts +94 -0
  110. package/dist/merge/entry-merge.js +875 -0
  111. package/dist/merge/list-alignment.d.ts +52 -0
  112. package/dist/merge/list-alignment.js +441 -0
  113. package/dist/merge/text-merge.d.ts +29 -0
  114. package/dist/merge/text-merge.js +59 -0
  115. package/dist/merge/value-equality.d.ts +18 -0
  116. package/dist/merge/value-equality.js +50 -0
  117. package/dist/merge-conflicts.d.ts +232 -0
  118. package/dist/merge-conflicts.js +451 -0
  119. package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
  120. package/dist/operating-mode/client-safe-strategy.js +6 -0
  121. package/dist/operating-mode/types.d.ts +5 -0
  122. package/dist/services.d.ts +16 -1
  123. package/dist/services.js +82 -22
  124. package/dist/task-queue/cms-task-queue.d.ts +3 -1
  125. package/dist/types.d.ts +28 -2
  126. package/dist/utils/content-serialize.d.ts +18 -1
  127. package/dist/utils/content-serialize.js +12 -0
  128. package/dist/utils/content-write-lock.d.ts +3 -3
  129. package/dist/utils/content-write-lock.js +3 -3
  130. package/dist/utils/markdown-body-splice.d.ts +1 -1
  131. package/dist/utils/markdown-body-splice.js +1 -19
  132. package/dist/utils/markdown-parse.d.ts +9 -0
  133. package/dist/utils/markdown-parse.js +27 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/dist/worker/branch-merge.d.ts +53 -37
  137. package/dist/worker/branch-merge.js +139 -54
  138. package/dist/worker/cms-worker.d.ts +16 -3
  139. package/dist/worker/cms-worker.js +59 -12
  140. package/dist/worker/field-merge.d.ts +65 -0
  141. package/dist/worker/field-merge.js +636 -0
  142. package/dist/worker/git-sync.d.ts +1 -1
  143. package/dist/worker/git-sync.js +7 -6
  144. package/dist/worker/github-commits.d.ts +42 -10
  145. package/dist/worker/github-commits.js +287 -47
  146. package/dist/worker/github-gateway-http.js +17 -9
  147. package/dist/worker/github-gateway-wire.d.ts +21 -12
  148. package/dist/worker/github-gateway-wire.js +5 -1
  149. package/dist/worker/github-gateway.d.ts +10 -1
  150. package/dist/worker/github-gateway.js +18 -6
  151. package/dist/worker/github-transfer.d.ts +5 -3
  152. package/dist/worker/github-transfer.js +8 -5
  153. package/dist/worker/rebase.d.ts +5 -4
  154. package/dist/worker/rebase.js +293 -156
  155. package/dist/worker/task-runner.d.ts +1 -1
  156. package/dist/worker/task-runner.js +14 -2
  157. package/dist/worker/worker-context.d.ts +18 -2
  158. 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,74 @@
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
+ export declare const SAVE_FIRST_REASON = "Save your changes first.";
42
+ export declare const WAITING_NOTICE = "Getting the latest changes. You can edit again when it finishes.";
43
+ 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.";
44
+ /** Why Submit for review waits while the latest changes come in. */
45
+ export declare const WAITING_SUBMIT_REASON = "Wait for the latest changes to come in.";
46
+ /** The waiting notice's addition for a viewer whose unsaved changes it hides meanwhile. */
47
+ export declare const WAITING_DRAFT_NOTE = "Your unsaved changes come back when it finishes.";
48
+ /** When the editor stops waiting on a request the server hasn't answered. */
49
+ 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.";
50
+ /** A request the server didn't answer, which may still have gone through. */
51
+ 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.";
52
+ export declare const REQUEST_FAILED_MESSAGE = "The latest changes couldn't be requested.";
53
+ /**
54
+ * The toast an answer gets: its colour, its text, and whether it stays until dismissed.
55
+ * `openConflicts` is the choices the branch has open once a merge with conflicts lands, read from
56
+ * the branch's conflicts; undefined when they couldn't be counted.
57
+ */
58
+ export declare function baseUpdateResultMessage(answer: LastBaseUpdate, openConflicts?: number): {
59
+ color: 'green' | 'yellow' | 'red';
60
+ message: string;
61
+ sticky: boolean;
62
+ };
63
+ /** What the bar and the branch card need to offer "Get latest changes" on the open branch. */
64
+ export interface BaseUpdateControl {
65
+ wait: BaseUpdateWait;
66
+ /** The request itself is in flight. */
67
+ requesting: boolean;
68
+ /** Why it can't be asked now. */
69
+ blockedReason?: string;
70
+ /** The Tooltip while it can be asked ({@link getLatestChangesTooltip}). */
71
+ tooltip: string;
72
+ onRequest: () => void;
73
+ }
74
+ export {};
@@ -0,0 +1,91 @@
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
+ export const SAVE_FIRST_REASON = 'Save your changes first.';
54
+ export const WAITING_NOTICE = 'Getting the latest changes. You can edit again when it finishes.';
55
+ 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.';
56
+ /** Why Submit for review waits while the latest changes come in. */
57
+ export const WAITING_SUBMIT_REASON = 'Wait for the latest changes to come in.';
58
+ /** The waiting notice's addition for a viewer whose unsaved changes it hides meanwhile. */
59
+ export const WAITING_DRAFT_NOTE = 'Your unsaved changes come back when it finishes.';
60
+ /** When the editor stops waiting on a request the server hasn't answered. */
61
+ 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.';
62
+ /** A request the server didn't answer, which may still have gone through. */
63
+ 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.';
64
+ export const REQUEST_FAILED_MESSAGE = "The latest changes couldn't be requested.";
65
+ /**
66
+ * The toast an answer gets: its colour, its text, and whether it stays until dismissed.
67
+ * `openConflicts` is the choices the branch has open once a merge with conflicts lands, read from
68
+ * the branch's conflicts; undefined when they couldn't be counted.
69
+ */
70
+ export function baseUpdateResultMessage(answer, openConflicts) {
71
+ switch (answer.outcome) {
72
+ case 'merged':
73
+ return { color: 'green', message: 'Latest changes added', sticky: false };
74
+ case 'up-to-date':
75
+ return { color: 'green', message: 'You already have the latest changes', sticky: false };
76
+ case 'conflicts':
77
+ if (openConflicts === 0)
78
+ return { color: 'green', message: 'Latest changes added', sticky: false };
79
+ return {
80
+ color: 'yellow',
81
+ message: latestChangesConflictsMessage(openConflicts),
82
+ sticky: true,
83
+ };
84
+ case 'failed':
85
+ return {
86
+ color: 'red',
87
+ message: "The latest changes couldn't be added. Your work is safe.",
88
+ sticky: true,
89
+ };
90
+ }
91
+ }
@@ -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
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The "Conflicts" group at the top of "Show changes": one card per place both you and someone else
3
+ * changed since you started the branch, showing how it read before, in the latest version and in
4
+ * yours, and the choices that settle it. A field's choice is an unsaved edit that the entry's next
5
+ * save resolves (conflict-choices.ts); an entry-level choice acts at once, through the API.
6
+ * Authors never see a branch name here; an admin's Tooltip names the branch the latest version
7
+ * comes from.
8
+ */
9
+ import React from 'react';
10
+ import type { EntryConflictChoice, EntryConflictData } from '../../api/merge-conflicts.js';
11
+ import type { ConflictCard, EntryConflicts, FieldConflict } from '../conflict-cards.js';
12
+ import { type EntryChoices, type FieldChoice } from '../conflict-choices.js';
13
+ type FormValue = Record<string, unknown>;
14
+ /**
15
+ * The three versions a card shows, each label with the Tooltip that says what it is.
16
+ * @internal Exported for tests.
17
+ */
18
+ export declare const VERSION_LABELS: {
19
+ readonly before: {
20
+ readonly label: "Before";
21
+ readonly tooltip: "How this field read when you started this branch.";
22
+ };
23
+ readonly latest: {
24
+ readonly label: "Latest version";
25
+ readonly tooltip: "How it reads now on the site's shared copy, changed since you started (by another person's approved changes or a developer).";
26
+ };
27
+ readonly yours: {
28
+ readonly label: "Your version";
29
+ readonly tooltip: "Your change on this branch.";
30
+ };
31
+ };
32
+ /**
33
+ * The latest version's Tooltip; an admin also learns which branch it comes from.
34
+ * @internal Exported for tests.
35
+ */
36
+ export declare const latestTooltip: (adminBaseBranch: string | undefined) => string;
37
+ /**
38
+ * Why a choice can't replace what the form holds: an edit made since, which it would lose.
39
+ * @internal Exported for tests.
40
+ */
41
+ export declare const EDITED_IN_FORM_REASON = "You've edited this in the form. Choose \"Mark resolved\" to keep your edit.";
42
+ /**
43
+ * Why an entry-level choice that rewrites the entry waits: it would replace unsaved edits.
44
+ * @internal Exported for tests.
45
+ */
46
+ export declare const SAVE_FIRST_REASON = "Save or discard this entry's unsaved changes first.";
47
+ /**
48
+ * Why a choice that takes the latest version can't: the latest version couldn't be read, its list
49
+ * items on the way to the place can't be matched to the ones the merge named, or the form no longer
50
+ * has the place. "Mark resolved" shows once the form is edited at the place.
51
+ * @internal Exported for tests.
52
+ */
53
+ export declare const LATEST_UNPLACEABLE_REASON = "The latest version of this can't be placed here. Change it in the form by hand, then choose \"Mark resolved\".";
54
+ /**
55
+ * A card's heading: where, and what happened there, in plain words.
56
+ * @internal Exported for tests.
57
+ */
58
+ export declare function conflictHeading(card: ConflictCard): string;
59
+ /**
60
+ * Why an entry-level choice didn't act: `warning` where nothing is lost or the outcome is unknown,
61
+ * `error` for a failure a retry may pass.
62
+ */
63
+ export interface EntryChoiceRefusal {
64
+ message: string;
65
+ tone: 'warning' | 'error';
66
+ }
67
+ /** The open entry's form, which field choices change. */
68
+ interface ConflictsForm {
69
+ value: FormValue;
70
+ saved: FormValue;
71
+ update: (update: (value: FormValue) => FormValue) => void;
72
+ /** The schema's body field on an md/mdx entry. */
73
+ bodyField?: string;
74
+ }
75
+ export interface ConflictsGroupProps {
76
+ data: EntryConflictData;
77
+ conflicts: EntryConflicts;
78
+ /** The branch the latest version comes from, given only for an admin. */
79
+ adminBaseBranch?: string;
80
+ choices: EntryChoices;
81
+ /** The open entry's form; without it the cards only show, and say to open the entry. */
82
+ form?: ConflictsForm;
83
+ /** Records a field's choice, or clears it with undefined. `decided` says it settles the field. */
84
+ onChoose?: (field: FieldConflict, choice: FieldChoice | undefined, decided: boolean) => void;
85
+ /** Closes the drawer and focuses the field in the form. */
86
+ onEdit?: (field: FieldConflict) => void;
87
+ /** Settles an entry-level conflict; resolves with why it couldn't, when it couldn't. */
88
+ onResolveEntry?: (choice: EntryConflictChoice) => Promise<EntryChoiceRefusal | undefined>;
89
+ /** Why this user can't change the entry: the choices are hidden. */
90
+ noEditReason?: string;
91
+ /** Why the choices can't act now: they stay, disabled, saying so. */
92
+ lockedReason?: string;
93
+ /** The entry has unsaved edits, which an entry-level choice would replace. */
94
+ unsavedEdits?: boolean;
95
+ /** The branch deleted the entry, so its field conflicts have no form to be chosen in. */
96
+ entryDeleted?: boolean;
97
+ }
98
+ /** The "Conflicts" group: a heading, a count heard as it changes, and the cards. */
99
+ export declare const ConflictsGroup: React.FC<ConflictsGroupProps>;
100
+ export {};