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
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useRef } from 'react';
3
- import { Badge, Box, Button, Group, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
2
+ import { useLayoutEffect, useRef } from 'react';
3
+ import { Badge, Box, Button, Group, Loader, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
4
4
  import { useMediaQuery, useMergedRef } from '@mantine/hooks';
5
- import { IconChevronDown, IconLock } from '@tabler/icons-react';
5
+ import { IconChevronDown, IconLock, IconRefresh } from '@tabler/icons-react';
6
6
  import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
7
7
  import { branchBarStatus, describeBranchLock } from '../branch-lock.js';
8
8
  import { HOLD_BADGE, branchHoldMessage } from '../branch-status.js';
9
+ import { conflictRecordHeldAdminMessage, conflictRecordHeldMessage, conflictsHoldMessage, conflictsSubmitReason, } from '../conflict-notice.js';
10
+ import { ReleaseHoldButton } from './ReleaseHoldButton.js';
9
11
  import { EDITOR_ACTIONS, UNFINISHED_SUBMIT_APPROVE_REASON, rewrittenSubmitReason } from '../copy.js';
10
12
  import { reviewRoleOf } from '../../authorization/review-role.js';
11
13
  import { SYSTEM_USER_ID } from '../../system-users.js';
@@ -15,13 +17,15 @@ import { TooltipDetail } from './TooltipDetail.js';
15
17
  import { ReasonedMenuItem } from './ReasonedMenuItem.js';
16
18
  import { UnsavedMark } from './UnsavedMark.js';
17
19
  import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButton.js';
20
+ import { GetLatestChangesButton } from './GetLatestChangesButton.js';
21
+ import { GETTING_LATEST_CHANGES, LATEST_CHANGES_WAITING, latestChangesWaitingDetail, WAITING_NOTICE, WAITING_SUBMIT_REASON, } from '../base-update-wait.js';
18
22
  import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.js';
19
23
  /**
20
24
  * The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
21
25
  * warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
22
26
  * submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
23
27
  */
24
- export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
28
+ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, baseUpdate, latestWait = 'none', latestChangesWaiting = false, openConflicts = 0, conflictRecordHeld, }) {
25
29
  const reviewRole = reviewRoleOf({
26
30
  status: branchStatus,
27
31
  createdBy: branchCreatedBy,
@@ -30,6 +34,12 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
30
34
  isProtected: branchIsProtected,
31
35
  pullRequestState: branchPullRequestState,
32
36
  }, userContext);
37
+ const releaseHold = branchName ? conflictRecordHeld?.onRelease : undefined;
38
+ const heldMessage = !conflictRecordHeld
39
+ ? undefined
40
+ : releaseHold
41
+ ? conflictRecordHeldAdminMessage
42
+ : conflictRecordHeldMessage;
33
43
  const workflow = workflowAction({
34
44
  branchName,
35
45
  busy,
@@ -42,6 +52,9 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
42
52
  savingAll: !!saveAllProgress,
43
53
  rewritten: branchHold?.kind === 'rewritten',
44
54
  approveBlocked: branchApproveBlocked,
55
+ waitingForLatest: latestWait === 'waiting',
56
+ openConflicts,
57
+ heldReason: heldMessage,
45
58
  });
46
59
  const lock = {
47
60
  branchStatus,
@@ -55,12 +68,19 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
55
68
  };
56
69
  const branchLockReason = describeBranchLock(lock);
57
70
  const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
71
+ // A hold of the pull request outranks open conflicts, which only hold the next submit.
72
+ const conflictsHeld = openConflicts > 0 && branchStatus === 'editing';
58
73
  const holdMessage = branchName && branchHold && status.value !== 'loading'
59
74
  ? branchHoldMessage(branchHold, {
60
75
  role: workflow?.kind === 'review' ? 'review' : workflow?.permitted ? 'withdraw' : 'none',
61
76
  isAdmin: isAdmin(userContext?.groups ?? []),
62
77
  })
63
- : undefined;
78
+ : branchName && heldMessage && status.value !== 'loading'
79
+ ? heldMessage
80
+ : branchName && conflictsHeld && status.value !== 'loading'
81
+ ? conflictsHoldMessage(openConflicts)
82
+ : undefined;
83
+ const holdCount = !branchHold && !conflictRecordHeld && conflictsHeld ? openConflicts : undefined;
64
84
  const theme = useMantineTheme();
65
85
  // Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
66
86
  const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
@@ -81,6 +101,20 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
81
101
  : status.action;
82
102
  // Controls come and go as a submit or withdraw changes the lock, and focus follows them.
83
103
  const [[wayRef, saveAllRef, workflowRef, secondaryRef], focusTarget] = useFocusHandOff(4);
104
+ // A release unmounts its confirm with the button that opened it, which leaves focus nowhere: it
105
+ // goes to the workflow control, which the release has just freed.
106
+ const releasable = !!releaseHold;
107
+ const wasReleasable = useRef(releasable);
108
+ const releasedHere = useRef(false);
109
+ useLayoutEffect(() => {
110
+ const active = document.activeElement;
111
+ if (wasReleasable.current && !releasable && releasedHere.current) {
112
+ releasedHere.current = false;
113
+ if (!active || active === document.body)
114
+ focusTarget(2)?.focus({ preventScroll: true });
115
+ }
116
+ wasReleasable.current = releasable;
117
+ });
84
118
  const workflowControl = {
85
119
  onSubmit,
86
120
  onWithdraw,
@@ -92,14 +126,31 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
92
126
  returnFocusTo: () => focusTarget(2),
93
127
  };
94
128
  const caretOnWay = narrow && !!barAction && !!workflow;
95
- return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason, holdMessage: holdMessage }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
129
+ return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef,
130
+ // Below `md` the bar has no room for it, so the branch's menu offers it.
131
+ getLatest: narrow ? baseUpdate : undefined }), _jsxs(Group, { gap: "xs", wrap: "nowrap", style: { flexShrink: 0 }, children: [_jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason, holdMessage: holdMessage, holdCount: holdCount, working: latestWait !== 'none' ? GETTING_LATEST_CHANGES : undefined,
132
+ // A hold's way forward comes first.
133
+ latestChangesWaiting: latestChangesWaiting && latestWait === 'none' && !holdMessage
134
+ ? latestChangesWaitingDetail(narrow, !!baseUpdate?.blockedReason)
135
+ : undefined, compact: narrow,
136
+ // The bar's button carries the spinner where it shows.
137
+ workingSpinner: narrow || !baseUpdate }), releaseHold && (_jsx(ReleaseHoldButton, { branchLabel: branchLabel ?? branchName, onRelease: async () => {
138
+ // Marked while in flight: the re-read that clears the hold can land first.
139
+ releasedHere.current = true;
140
+ const outcome = await releaseHold();
141
+ if (!('released' in outcome))
142
+ releasedHere.current = false;
143
+ return outcome;
144
+ } }))] }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), !narrow && baseUpdate && _jsx(GetLatestChangesButton, { control: baseUpdate }), (!narrow || editable) && (_jsx(SaveAllButton
96
145
  // Its reserved width follows this branch's counts, not the session's largest.
97
146
  , { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
98
147
  (!branchName
99
148
  ? 'Open a branch first.'
100
- : entryListFailed && modifiedCount > 0
101
- ? 'The list of entries couldn\'t be loaded, so these changes can\'t be saved yet. Choose "Refresh branch" in the branch menu to try again.'
102
- : undefined), saveAllProgress: saveAllProgress, singleSaving: singleSaving, busy: busy, buttonRef: saveAllRef, caret: split && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined }, branchName)), barAction && (_jsx(BranchWayButton, { action: barAction, buttonRef: wayRef, onClick: barAction === 'newBranch'
149
+ : latestWait === 'waiting'
150
+ ? WAITING_NOTICE
151
+ : entryListFailed && modifiedCount > 0
152
+ ? 'The list of entries couldn\'t be loaded, so these changes can\'t be saved yet. Choose "Refresh branch" in the branch menu to try again.'
153
+ : undefined), saveAllProgress: saveAllProgress, singleSaving: singleSaving, busy: busy, buttonRef: saveAllRef, caret: split && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined }, branchName)), barAction && (_jsx(BranchWayButton, { action: barAction, buttonRef: wayRef, onClick: barAction === 'newBranch'
103
154
  ? onNewBranch
104
155
  : barAction === 'refreshBranch'
105
156
  ? onBranchReloadData
@@ -131,13 +182,18 @@ function unsavedTooltip(status, count, mayWithdraw) {
131
182
  * The branch's state beside the pill, heard in words with its reason, but not while it loads. A
132
183
  * hold follows it whatever the lock, since a lock doesn't say that the branch is stuck.
133
184
  */
134
- function BranchStatusLabel({ status, reason, holdMessage, }) {
135
- const spoken = status.label && status.value !== 'loading'
136
- ? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
137
- : '';
138
- return (
139
- // Always mounted, so a lock that arrives is announced.
140
- _jsxs(Box, { role: "status", style: status.label || holdMessage ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: HOLD_BADGE.label }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }));
185
+ function BranchStatusLabel({ status, reason, holdMessage, holdCount, working, workingSpinner = true, latestChangesWaiting, compact = false, }) {
186
+ const spoken = [
187
+ status.label && status.value !== 'loading'
188
+ ? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
189
+ : '',
190
+ working,
191
+ // The badge sits outside this region, so a detail that follows the unsaved count stays quiet.
192
+ latestChangesWaiting && LATEST_CHANGES_WAITING,
193
+ ]
194
+ .filter(Boolean)
195
+ .join('. ');
196
+ return (_jsxs(_Fragment, { children: [_jsxs(Box, { role: "status", style: status.label || holdMessage || working ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [working && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-working-status", children: [workingSpinner && _jsx(Loader, { size: 12 }), _jsx(Text, { span: true, size: "sm", children: working })] })), status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: holdCount === undefined ? HOLD_BADGE.label : `${HOLD_BADGE.label} (${holdCount})` }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }), latestChangesWaiting && (_jsx(TooltipDetail, { detail: latestChangesWaiting, name: compact ? LATEST_CHANGES_WAITING : undefined, children: _jsx(Badge, { color: "blue", variant: "light", circle: compact, "data-testid": "branch-latest-waiting", children: compact ? (_jsx(IconRefresh, { size: 12, style: { display: 'block' }, "aria-hidden": "true" })) : (LATEST_CHANGES_WAITING) }) }))] }));
141
197
  }
142
198
  const WAY_LABEL = {
143
199
  newBranch: `${EDITOR_ACTIONS.newBranch}…`,
@@ -154,7 +210,7 @@ function BranchWayButton({ action, onClick, caret, buttonRef, }) {
154
210
  * Submit for review, or Withdraw from review once submitted or approved, or Approve and Request
155
211
  * changes to a reviewer of someone else's submission; null when none shows.
156
212
  */
157
- function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, }) {
213
+ function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, waitingForLatest, openConflicts, heldReason, }) {
158
214
  const isSubmitted = branchStatus === 'submitted';
159
215
  const isEditing = branchStatus === 'editing';
160
216
  // 'approved' withdraws too -- it is the author's way back from
@@ -176,7 +232,7 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
176
232
  blocked: !branchName || busy || savingAll,
177
233
  permitted: true,
178
234
  reason: savingAll ? 'Wait for Save all to finish.' : busy ? BUSY_REASON : '',
179
- approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON : undefined,
235
+ approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON : heldReason,
180
236
  };
181
237
  }
182
238
  // Who may submit/withdraw: creator, ACL access, the system branch, or an
@@ -201,24 +257,34 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
201
257
  return {
202
258
  kind: isWithdrawable ? 'withdraw' : 'submit',
203
259
  // `busy` alone can't hold it: an entry load clears that shared flag mid-run.
204
- blocked: !branchName || busy || savingAll || !canPerformAction || (rewritten && isEditing),
260
+ blocked: !branchName ||
261
+ busy ||
262
+ savingAll ||
263
+ !canPerformAction ||
264
+ (isEditing && (rewritten || waitingForLatest || openConflicts > 0 || !!heldReason)),
205
265
  permitted: canPerformAction,
206
266
  reason: savingAll
207
267
  ? 'Wait for Save all to finish.'
208
- : rewritten && isEditing
209
- ? rewrittenSubmitReason(isAdmin(userContext?.groups ?? []))
210
- : // No branch data (`branchStatus === undefined`) must not be
211
- // interpolated raw into this label. Guarded explicitly here rather
212
- // than left to the early return above, which hides the action
213
- // today only as a side effect of `branchIsProtected`'s `?? true`
214
- // default -- not a guarantee this arm stays unreachable.
215
- branchStatus === undefined
216
- ? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
217
- : !statusHasAction
218
- ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
219
- : !userHasPermission
220
- ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
221
- : '',
268
+ : waitingForLatest && isEditing
269
+ ? WAITING_SUBMIT_REASON
270
+ : heldReason && isEditing
271
+ ? heldReason
272
+ : openConflicts > 0 && isEditing
273
+ ? conflictsSubmitReason(openConflicts)
274
+ : rewritten && isEditing
275
+ ? rewrittenSubmitReason(isAdmin(userContext?.groups ?? []))
276
+ : // No branch data (`branchStatus === undefined`) must not be
277
+ // interpolated raw into this label. Guarded explicitly here rather
278
+ // than left to the early return above, which hides the action
279
+ // today only as a side effect of `branchIsProtected`'s `?? true`
280
+ // default -- not a guarantee this arm stays unreachable.
281
+ branchStatus === undefined
282
+ ? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
283
+ : !statusHasAction
284
+ ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
285
+ : !userHasPermission
286
+ ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
287
+ : '',
222
288
  };
223
289
  }
224
290
  function WorkflowButton({ narrow, ...props }) {
@@ -8,6 +8,9 @@ import type { BranchChangeEntry } from '../../api/branch-changes.js';
8
8
  import type { FieldConfig } from '../../config/index.js';
9
9
  import type { CanopyPathSegment } from '../canopy-path.js';
10
10
  import type { EntryChangeState } from '../hooks/useBranchChangesData.js';
11
+ import type { ConflictsState } from '../hooks/useConflictsData.js';
12
+ import type { EntryConflictData } from '../../api/merge-conflicts.js';
13
+ import { type ConflictsGroupProps } from './ConflictsGroup.js';
11
14
  /** The entry a drawer compares, as the editor knows it. */
12
15
  export interface EntryChangesSubject {
13
16
  logicalPath: string;
@@ -58,6 +61,15 @@ export interface EntryChangesDrawerProps {
58
61
  onDeleteEntry?: () => void;
59
62
  /** Puts back an entry deleted on the branch; resolves with why it couldn't, when it couldn't. */
60
63
  onRestoreEntry?: () => Promise<RestoreRefusal | undefined>;
64
+ /** The entry's open conflicts, given while the branch lists any for it. */
65
+ conflicts?: EntryChangesConflicts;
66
+ }
67
+ /** What the "Conflicts" group needs beyond the drawer's own props (ConflictsGroup.tsx). */
68
+ export interface EntryChangesConflicts extends Pick<ConflictsGroupProps, 'adminBaseBranch' | 'choices' | 'onChoose' | 'onEdit'> {
69
+ state: ConflictsState<EntryConflictData | null>;
70
+ onResolveEntry?: ConflictsGroupProps['onResolveEntry'];
71
+ /** The entry has a draft, open or not, which an entry-level choice would leave behind. */
72
+ unsavedEdits?: boolean;
61
73
  }
62
74
  /** Why "Restore entry" didn't: `warning` where nothing is lost, or the outcome is unknown. */
63
75
  export interface RestoreRefusal {
@@ -78,10 +90,12 @@ export interface EntryChangesUndo {
78
90
  /** Open field threads inside a canopy path, which taking out an item there moves to the entry. */
79
91
  threadsWithin?: (path: CanopyPathSegment[]) => number;
80
92
  }
81
- /** The drawer's body: its states, then the summary and one card per change. */
82
- export declare const EntryChangesView: React.FC<Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>>;
93
+ type EntryChangesViewProps = Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>;
94
+ /** Conflicts, what a choice settled, then the changes, which keep their place as conflicts go. */
95
+ export declare const EntryChangesView: React.FC<EntryChangesViewProps>;
83
96
  /**
84
97
  * A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
85
98
  * and the form and preview wait behind its backdrop.
86
99
  */
87
100
  export declare const EntryChangesDrawer: React.FC<EntryChangesDrawerProps>;
101
+ export {};
@@ -6,20 +6,19 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
6
6
  * and jsdiff stay out of the editor's first chunk.
7
7
  */
8
8
  import React, { useEffect, useMemo, useRef, useState } from 'react';
9
- import { Alert, Badge, Box, Button, Group, List, Paper, SimpleGrid, Stack, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
9
+ import { Alert, Badge, Box, Button, Group, Paper, SimpleGrid, Stack, Text, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
10
10
  import { useMediaQuery } from '@mantine/hooks';
11
- import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil, IconPhotoOff, IconPlus, } from '@tabler/icons-react';
12
- import { EDITOR_THUMBNAIL_WIDTHS } from '../../assets/editor-thumbnail-widths.js';
13
- import { useAssetContext } from '../context/index.js';
11
+ import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconCircleCheck, IconInfoCircle, IconMinus, IconPencil, IconPlus, } from '@tabler/icons-react';
14
12
  import { EDITOR_ACTIONS } from '../copy.js';
15
- import { computeEntryDiff, } from '../entry-diff.js';
16
- import { editorImageSrc } from '../media/editor-image-src.js';
13
+ import { computeEntryDiff } from '../entry-diff.js';
14
+ import { buildEntryConflicts } from '../conflict-cards.js';
17
15
  import { findBodyFieldName } from '../../utils/body-field.js';
18
- import { formatRelativeTime } from '../relative-time.js';
19
16
  import { OverlayDrawer } from '../shell/OverlayHeader.js';
20
17
  import { movedThreadsNote } from '../undo-removal.js';
21
18
  import { canSetBack, canopyPathOf, formValueOfSide, holdsAs, setAllBack, setChangeBack, sideOfFormValue, } from '../undo-change.js';
22
19
  import { focusReturnProps } from '../utils/focus-return.js';
20
+ import { DateValue, MarkedText, ReasonedButton, TEXT_STYLE, Value, ordinal, plural, } from './change-parts.js';
21
+ import { ConflictsGroup, SETTLED_HOLD_MS, } from './ConflictsGroup.js';
23
22
  import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
24
23
  const KIND_BADGES = {
25
24
  added: { label: 'Added', icon: _jsx(IconPlus, { size: 10, "aria-hidden": true }) },
@@ -27,98 +26,6 @@ const KIND_BADGES = {
27
26
  changed: { label: 'Changed', icon: _jsx(IconPencil, { size: 10, "aria-hidden": true }) },
28
27
  moved: { label: 'Moved', icon: _jsx(IconArrowsMove, { size: 10, "aria-hidden": true }) },
29
28
  };
30
- /**
31
- * Struck on a tint at least 1.3:1 from the card in either scheme, with text kept at 4.5:1 on it:
32
- * `red.2` in light, `red.6` at 30% over the dark card.
33
- */
34
- const REMOVED_STYLE = {
35
- background: 'light-dark(var(--mantine-color-red-2), color-mix(in srgb, var(--mantine-color-red-6) 30%, transparent))',
36
- color: 'inherit',
37
- textDecoration: 'line-through',
38
- textDecorationThickness: 2,
39
- };
40
- /** Underlined on a tint, measured as REMOVED_STYLE's: `green.3`, or `green.6` at 30%. */
41
- const ADDED_STYLE = {
42
- background: 'light-dark(var(--mantine-color-green-3), color-mix(in srgb, var(--mantine-color-green-6) 30%, transparent))',
43
- color: 'inherit',
44
- textDecoration: 'underline',
45
- textDecorationThickness: 2,
46
- };
47
- const TEXT_STYLE = { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' };
48
- /** Long text on one side only is cut to this many lines once it has more than LONG_TEXT_LINES. */
49
- const COLLAPSED_LINES = 50;
50
- const LONG_TEXT_LINES = 200;
51
- /** Above this share of a line's text changed, one prefix names the whole line instead of each run. */
52
- const WHOLE_LINE_SHARE = 0.6;
53
- const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;
54
- const ordinal = (n) => {
55
- const tens = n % 100;
56
- const suffix = tens >= 11 && tens <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] ?? 'th');
57
- return `${n}${suffix}`;
58
- };
59
- /**
60
- * `side`'s runs of `parts`, a marked run joined to the next of its mark across whitespace alone,
61
- * so a screen reader hears one removal, not one per word.
62
- */
63
- const sideRuns = (parts, side) => {
64
- const runs = [];
65
- for (const part of parts) {
66
- if (side === 'before' ? part.added : part.removed)
67
- continue;
68
- const mark = part.removed ? 'removed' : part.added ? 'added' : undefined;
69
- const last = runs[runs.length - 1];
70
- const gap = runs[runs.length - 2];
71
- if (mark && last && last.mark === mark) {
72
- last.value += part.value;
73
- }
74
- else if (mark && last && !last.mark && /^\s+$/.test(last.value) && gap?.mark === mark) {
75
- runs.pop();
76
- gap.value += last.value + part.value;
77
- }
78
- else {
79
- runs.push({ value: part.value, ...(mark && { mark }) });
80
- }
81
- }
82
- return runs;
83
- };
84
- const WHITESPACE_MARKS = {
85
- ' ': { shown: '·', said: 'space' },
86
- '\t': { shown: '→', said: 'tab' },
87
- '\n': { shown: '⏎', said: 'line break' },
88
- };
89
- /** A run's text; whitespace alone shows a mark for each character, said in words. */
90
- const RunText = ({ value }) => {
91
- if (value === '')
92
- return _jsx("em", { children: "(blank line)" });
93
- if (!/^\s+$/.test(value))
94
- return _jsx(_Fragment, { children: value });
95
- const chars = [...value];
96
- return (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, children: chars.map((c) => WHITESPACE_MARKS[c]?.shown ?? c).join('') }), _jsx(VisuallyHidden, { component: "span", children: chars.map((c) => WHITESPACE_MARKS[c]?.said ?? 'space').join(', ') })] }));
97
- };
98
- const MARK_WORDS = {
99
- removed: { start: 'Removed: ', end: ', end of removal', line: 'Removed line: ' },
100
- added: { start: 'Added: ', end: ', end of addition', line: 'Added line: ' },
101
- };
102
- /**
103
- * One side's text with its runs marked by a strike or an underline and by words, never colour
104
- * alone. A line mostly changed says so once, up front, instead of run by run.
105
- */
106
- const MarkedText = ({ parts, side, unit, }) => {
107
- const runs = sideRuns(parts, side);
108
- const total = runs.reduce((n, run) => n + run.value.length, 0);
109
- const marked = runs.reduce((n, run) => n + (run.mark ? run.value.length : 0), 0);
110
- const mark = side === 'before' ? 'removed' : 'added';
111
- const whole = runs.some((run) => run.mark) && (total === 0 || marked / total > WHOLE_LINE_SHARE);
112
- return (_jsxs(_Fragment, { children: [whole && (_jsx(VisuallyHidden, { component: "span", children: unit === 'line' ? MARK_WORDS[mark].line : MARK_WORDS[mark].start })), runs.map((run, i) => {
113
- if (!run.mark)
114
- return _jsx(React.Fragment, { children: run.value }, i);
115
- const Tag = run.mark === 'removed' ? 'del' : 'ins';
116
- const style = run.mark === 'removed' ? REMOVED_STYLE : ADDED_STYLE;
117
- // Whitespace alone, or a blank line's label, has nothing for a line to cross.
118
- const shown = /^\s*$/.test(run.value) ? { ...style, textDecoration: 'none' } : style;
119
- return (_jsxs(Tag, { style: shown, children: [!whole && (_jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].start })), _jsx(RunText, { value: run.value }), !whole && _jsx(VisuallyHidden, { component: "span", children: MARK_WORDS[run.mark].end })] }, i));
120
- })] }));
121
- };
122
29
  const Gutter = ({ sign }) => (_jsx(Text, { size: "sm", c: "dimmed", "aria-hidden": true, ta: "center", style: { userSelect: 'none' }, children: sign ?? '' }));
123
30
  const Skipped = ({ count, style }) => (_jsx(Text, { size: "xs", c: "dimmed", py: 2, style: style, children: plural(count, 'unchanged line', 'unchanged lines') }));
124
31
  const LINE_GRID = {
@@ -216,77 +123,11 @@ const LineRows = ({ rows }) => {
216
123
  return (_jsxs(Box, { style: narrow ? { ...LINE_GRID, gridTemplateColumns: '1rem minmax(0, 1fr)' } : LINE_GRID, "data-testid": narrow ? 'entry-change-line-list' : 'entry-change-line-grid', children: [!narrow && (_jsxs(_Fragment, { children: [_jsx(Text, { size: "xs", c: "dimmed", style: { gridColumn: '1 / span 2' }, children: "Before" }), _jsx(Text, { size: "xs", c: "dimmed", style: { gridColumn: '3 / span 2' }, children: "After" })] })), segments.map((segment, i) => segment.long ? (_jsx(LongRun, { rows: segment.rows, side: segment.long, renderRows: renderRows }, i)) : (_jsx(React.Fragment, { children: renderRows(segment.rows) }, i)))] }));
217
124
  };
218
125
  const WordsChange = ({ text, side, }) => (_jsx(Text, { size: "sm", style: TEXT_STYLE, children: _jsx(MarkedText, { parts: text.parts, side: side, unit: "text" }) }));
219
- const THUMBNAIL_SIZE = EDITOR_THUMBNAIL_WIDTHS.listRow / 2;
220
- /** An image's thumbnail beside its name, which says what it is; an icon if it won't load. */
221
- const Thumbnail = ({ src }) => {
222
- const { baseUrl } = useAssetContext();
223
- const [failed, setFailed] = useState(false);
224
- if (failed) {
225
- return (_jsx(IconPhotoOff, { size: THUMBNAIL_SIZE, stroke: 1.5, "aria-hidden": true, style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
226
- }
227
- return (_jsx("img", { src: editorImageSrc(src, baseUrl, { width: EDITOR_THUMBNAIL_WIDTHS.listRow }), alt: "", width: THUMBNAIL_SIZE, height: THUMBNAIL_SIZE, onError: () => setFailed(true), style: { objectFit: 'cover', borderRadius: 4, flexShrink: 0 } }));
228
- };
229
- /** Text on one side only; more than LONG_TEXT_LINES lines show COLLAPSED_LINES until asked. */
230
- const LongText = ({ text }) => {
231
- const [expanded, setExpanded] = useState(false);
232
- const firstNew = useRef(null);
233
- useEffect(() => {
234
- if (expanded)
235
- firstNew.current?.focus();
236
- }, [expanded]);
237
- const lines = text.split('\n');
238
- const long = lines.length > LONG_TEXT_LINES;
239
- const collapsed = !expanded && long;
240
- return (_jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: !long ? (text) : (_jsxs(_Fragment, { children: [lines.slice(0, COLLAPSED_LINES).join('\n'), expanded && (_jsxs(_Fragment, { children: ['\n', _jsx("span", { ref: firstNew, tabIndex: -1, children: lines.slice(COLLAPSED_LINES).join('\n') })] }))] })) }), collapsed && (_jsxs(Button, { size: "compact-sm", variant: "subtle", onClick: () => setExpanded(true), children: ["Show all ", lines.length, " lines"] }))] }));
241
- };
242
- /** A value as the form would show it: titles for references, file names for images. */
243
- const Value = ({ value, alt, }) => {
244
- switch (value.kind) {
245
- case 'empty':
246
- return (_jsx(Text, { size: "sm", c: "dimmed", children: "Not set" }));
247
- case 'text':
248
- return _jsx(LongText, { text: value.text });
249
- case 'number':
250
- return _jsx(Text, { size: "sm", children: value.value.toLocaleString() });
251
- case 'boolean':
252
- return _jsx(Text, { size: "sm", children: value.value ? 'Yes' : 'No' });
253
- case 'date':
254
- return _jsx(DateValue, { value: value });
255
- case 'select':
256
- return _jsx(Text, { size: "sm", children: value.label });
257
- case 'reference':
258
- return value.title !== undefined ? (_jsx(Text, { size: "sm", children: value.title })) : (_jsx(Text, { size: "sm", c: "dimmed", children: "Entry unavailable" }));
259
- case 'image':
260
- return (_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(Thumbnail, { src: value.src }, value.src), _jsxs(Stack, { gap: 0, miw: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: value.name }), value.alt && (_jsxs(Text, { size: "xs", style: TEXT_STYLE, children: [_jsxs(Text, { span: true, size: "xs", c: "dimmed", children: ["Alt text:", ' '] }), alt?.text.mode === 'words' ? (_jsx(MarkedText, { parts: alt.text.parts, side: alt.side, unit: "text" })) : (value.alt)] }))] })] }));
261
- case 'position':
262
- return (_jsxs(Text, { size: "sm", children: [ordinal(value.position), " of ", value.of] }));
263
- case 'list':
264
- return (_jsx(List, { size: "sm", spacing: 4, children: value.items.map((item, i) => (_jsx(List.Item, { children: _jsx(Value, { value: item }) }, i))) }));
265
- case 'fields':
266
- return (_jsx(Stack, { gap: 6, children: value.fields.map((field, i) => (_jsxs("div", { children: [_jsx(Text, { size: "xs", fw: 500, children: field.label }), _jsx(Value, { value: field.value })] }, i))) }));
267
- case 'raw':
268
- return (_jsx(Text, { size: "xs", ff: "monospace", style: TEXT_STYLE, children: value.text }));
269
- }
270
- };
271
- /**
272
- * A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
273
- * place the editor shows a date absolute first and relative after. A day has no relative form: it
274
- * may be a future event, and a past one is not hours ago.
275
- */
276
- const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), !value.dateOnly && (_jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.iso) }))] }));
277
126
  const SideColumn = ({ label, change, side, }) => {
278
127
  const value = change[side] ?? { kind: 'empty' };
279
128
  const words = change.text?.mode === 'words' ? change.text : undefined;
280
129
  return (_jsxs(Stack, { gap: 4, miw: 0, children: [_jsx(Text, { size: "xs", c: "dimmed", children: label }), value.kind === 'date' && words ? (_jsx(DateValue, { value: value, text: _jsx(MarkedText, { parts: words.parts, side: side, unit: "text" }) })) : words ? (_jsx(WordsChange, { text: words, side: side })) : (_jsx(Value, { value: value, alt: change.altText && { text: change.altText, side } }))] }));
281
130
  };
282
- /**
283
- * A drawer control that, given a `reason`, stays focusable but doesn't act, its Tooltip saying
284
- * why (`data-disabled` keeps the Tooltip working on focus and touch).
285
- */
286
- const ReasonedButton = ({ reason, onClick, danger, ...props }) => (_jsx(Tooltip, { label: reason, disabled: !reason, multiline: true, maw: 280, position: "bottom-start", children: _jsx(Button, { size: "compact-sm", variant: "subtle", ...(danger && { color: 'red', c: 'var(--canopy-danger-text)' }), ...props, onClick: () => {
287
- if (!reason)
288
- onClick();
289
- }, "data-disabled": reason ? true : undefined, "aria-disabled": reason ? true : undefined }) }));
290
131
  const UNDONE_IN_FORM = 'Undone in the form. Save to keep it.';
291
132
  const ALL_UNDONE_IN_FORM = 'All changes undone in the form. Save to keep them.';
292
133
  // Undo and redo replace what the form holds at a change with the base's or the saved value, so
@@ -332,8 +173,85 @@ const changeSummary = (changes, base) => {
332
173
  ];
333
174
  return `${parts.join(', ')} since ${base}`;
334
175
  };
335
- /** The drawer's body: its states, then the summary and one card per change. */
336
- export const EntryChangesView = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl, undo, noEditReason, lockedReason, onDeleteEntry, onRestoreEntry, }) => {
176
+ /** The conflicts' place: loading, their failure with Retry, or the group; nothing for none. */
177
+ const ConflictsSection = ({ conflicts, subject, referenceTitle, undo, noEditReason, lockedReason, onEntryDone }) => {
178
+ const { data, error, isValidating, reload } = conflicts.state;
179
+ const bodyField = useMemo(() => {
180
+ const side = data && Object.values(data.versions).find((v) => 'body' in v && v.body !== undefined);
181
+ return side && 'data' in side
182
+ ? (side.bodyFieldName ?? findBodyFieldName(subject.fields))
183
+ : undefined;
184
+ }, [data, subject.fields]);
185
+ // Regions apply only to the text your saved value holds, so the cards are built against it.
186
+ const saved = noEditReason ? undefined : undo?.saved;
187
+ const built = useMemo(() => data && buildEntryConflicts(data, subject.fields, bodyField, referenceTitle, saved), [data, subject.fields, bodyField, referenceTitle, saved]);
188
+ if (data === null)
189
+ return null;
190
+ if (error && !data) {
191
+ return (_jsx(Alert, { variant: "light", color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), "data-testid": "conflicts-failed", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: "Couldn\u2019t load this entry\u2019s conflicts." }), _jsx(Button, { size: "compact-sm", variant: "subtle", loading: isValidating, onClick: () => void reload(), children: "Retry" })] }) }));
192
+ }
193
+ if (!data || !built)
194
+ return _jsx(LoadingCards, {});
195
+ return (_jsx(ConflictsGroup, { data: data, conflicts: built, adminBaseBranch: conflicts.adminBaseBranch, choices: conflicts.choices, form: !noEditReason && undo?.saved
196
+ ? { value: undo.value, saved: undo.saved, update: undo.update, bodyField }
197
+ : undefined, onChoose: conflicts.onChoose, onEdit: conflicts.onEdit, onResolveEntry: conflicts.onResolveEntry, onEntryDone: onEntryDone, noEditReason: noEditReason, lockedReason: lockedReason, unsavedEdits: conflicts.unsavedEdits ?? subject.unsavedEdits, entryDeleted: subject.status === 'deleted' }));
198
+ };
199
+ /** Focus once a choice's card is gone: "Restore entry" if it is offered, else the drawer's title. */
200
+ const focusAfterEntryChoice = (root, entryGone) => {
201
+ const restore = entryGone
202
+ ? root?.querySelector('[data-testid="entry-changes-restore"]')
203
+ : null;
204
+ if (restore)
205
+ return restore.focus();
206
+ const titleId = root?.closest('[role="dialog"]')?.getAttribute('aria-labelledby');
207
+ const title = titleId ? document.getElementById(titleId) : null;
208
+ if (!title)
209
+ return;
210
+ title.tabIndex = -1;
211
+ title.focus();
212
+ };
213
+ /** Conflicts, what a choice settled, then the changes, which keep their place as conflicts go. */
214
+ export const EntryChangesView = (props) => {
215
+ const rootRef = useRef(null);
216
+ const [said, setSaid] = useState();
217
+ const entryDone = said?.path === props.subject.logicalPath ? said : undefined;
218
+ // Focus waits for the card to go: only then may "Restore entry" be offered in its place.
219
+ const focusAfter = useRef();
220
+ const onEntryDone = (done) => {
221
+ setSaid((last) => ({
222
+ id: (last?.id ?? 0) + 1,
223
+ message: done.message,
224
+ path: props.subject.logicalPath,
225
+ }));
226
+ focusAfter.current = {
227
+ entryGone: done.entryGone,
228
+ path: props.subject.logicalPath,
229
+ at: Date.now(),
230
+ };
231
+ };
232
+ useEffect(() => {
233
+ const root = rootRef.current;
234
+ const pending = focusAfter.current;
235
+ if (!root || !pending)
236
+ return;
237
+ // No longer than the card holds its choice: a card gone later went for another reason.
238
+ if (pending.path !== props.subject.logicalPath || Date.now() - pending.at > SETTLED_HOLD_MS) {
239
+ focusAfter.current = undefined;
240
+ }
241
+ if (!focusAfter.current || root.querySelector('[data-entry-card]'))
242
+ return;
243
+ focusAfter.current = undefined;
244
+ // Focus anywhere but the page or the drawer itself was put there since, and stays.
245
+ const dialog = root.closest('[role="dialog"]');
246
+ const active = document.activeElement;
247
+ if (active && active !== document.body && active !== dialog)
248
+ return;
249
+ focusAfterEntryChoice(root, pending.entryGone);
250
+ });
251
+ return (_jsxs(Stack, { gap: "lg", ref: rootRef, children: [props.conflicts && (_jsx(ConflictsSection, { conflicts: props.conflicts, subject: props.subject, referenceTitle: props.referenceTitle, undo: props.undo, noEditReason: props.noEditReason, lockedReason: props.lockedReason, onEntryDone: onEntryDone })), _jsxs("div", { children: [(props.conflicts || entryDone) && (_jsx(Group, { gap: 6, wrap: "nowrap", role: "status", mb: entryDone ? 'md' : 0, "data-testid": "entry-choice-done", children: entryDone && (_jsxs(React.Fragment, { children: [_jsx(IconCircleCheck, { size: 16, color: "var(--mantine-color-green-filled)", "aria-hidden": true }), _jsx(Text, { size: "sm", children: entryDone.message })] }, entryDone.id)) })), _jsx(EntryChangesBody, { ...props })] })] }));
252
+ };
253
+ /** The changes: their states, then the summary and one card per change. */
254
+ const EntryChangesBody = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl, undo, noEditReason, lockedReason, onDeleteEntry, onRestoreEntry, }) => {
337
255
  const { data, error } = state;
338
256
  const diff = useMemo(() => data &&
339
257
  computeEntryDiff({
@@ -69,6 +69,8 @@ export interface EntryHeaderProps {
69
69
  branchPullRequestClosed?: boolean;
70
70
  /** The server refuses Approve on this branch (`describeBranchLock`). */
71
71
  branchApproveBlocked?: boolean;
72
+ /** Why content writes wait though the branch isn't locked: the latest changes coming in. */
73
+ writesWaitReason?: string;
72
74
  }
73
75
  /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
74
- export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, branchApproveBlocked, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
76
+ export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, branchApproveBlocked, writesWaitReason, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
@@ -28,7 +28,7 @@ const ShowChangesButton = ({ onClick, buttonRef, }) => {
28
28
  return (_jsx(Button, { ref: placeholder ? undefined : buttonRef, size: "compact-sm", variant: "subtle", leftSection: icon, onClick: onClick, "data-testid": placeholder ? 'entry-show-changes-pending' : 'entry-show-changes', ...hold, children: EDITOR_ACTIONS.showChanges }));
29
29
  };
30
30
  /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
31
- export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending = false, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, branchApproveBlocked = false, }) {
31
+ export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onShowChanges, showChangesPending = false, showChangesRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, branchApproveBlocked = false, writesWaitReason, }) {
32
32
  const branchLockReason = describeBranchLock({
33
33
  branchStatus,
34
34
  branchReadOnly,
@@ -38,7 +38,7 @@ export function EntryHeader({ currentEntry, entryLoading = false, branchName, bu
38
38
  reviewing: branchReviewing,
39
39
  pullRequestClosed: branchPullRequestClosed,
40
40
  approveBlocked: branchApproveBlocked,
41
- });
41
+ }) ?? writesWaitReason;
42
42
  const entryLockReason = entryReadOnly ? NO_EDIT_ACCESS_REASON : undefined;
43
43
  // The Tooltip hides it while the editor is busy.
44
44
  const saveBlockedReason = branchLockReason ??
@@ -0,0 +1,15 @@
1
+ import { type Ref } from 'react';
2
+ import { type BaseUpdateControl } from '../base-update-wait.js';
3
+ /** The Tooltip's text: why it waits, why it can't act, or what it does. */
4
+ export declare function getLatestChangesReason(control: BaseUpdateControl): string;
5
+ /**
6
+ * "Get latest changes", or "Getting latest changes…" with a spinner while the request stands. It
7
+ * takes the wider label's width either way, so the bar never shifts. It can't act while it works
8
+ * or while something blocks it, and stays focusable (`aria-disabled`) with its Tooltip saying why.
9
+ * Only a block dims it: the working label is progress, read at full contrast.
10
+ */
11
+ export declare function GetLatestChangesButton({ control, buttonRef, testId, }: {
12
+ control: BaseUpdateControl;
13
+ buttonRef?: Ref<HTMLButtonElement>;
14
+ testId?: string;
15
+ }): import("react/jsx-runtime").JSX.Element;