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
@@ -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
5
  import { IconChevronDown, IconLock } 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, 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', 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,27 @@ 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
+ // The bar's button carries the spinner where it shows.
133
+ workingSpinner: narrow || !baseUpdate }), releaseHold && (_jsx(ReleaseHoldButton, { branchLabel: branchLabel ?? branchName, onRelease: async () => {
134
+ // Marked while in flight: the re-read that clears the hold can land first.
135
+ releasedHere.current = true;
136
+ const outcome = await releaseHold();
137
+ if (!('released' in outcome))
138
+ releasedHere.current = false;
139
+ return outcome;
140
+ } }))] }), 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
141
  // Its reserved width follows this branch's counts, not the session's largest.
97
142
  , { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
98
143
  (!branchName
99
144
  ? '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'
145
+ : latestWait === 'waiting'
146
+ ? WAITING_NOTICE
147
+ : entryListFailed && modifiedCount > 0
148
+ ? '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.'
149
+ : 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
150
  ? onNewBranch
104
151
  : barAction === 'refreshBranch'
105
152
  ? onBranchReloadData
@@ -131,13 +178,18 @@ function unsavedTooltip(status, count, mayWithdraw) {
131
178
  * The branch's state beside the pill, heard in words with its reason, but not while it loads. A
132
179
  * hold follows it whatever the lock, since a lock doesn't say that the branch is stuck.
133
180
  */
134
- function BranchStatusLabel({ status, reason, holdMessage, }) {
135
- const spoken = status.label && status.value !== 'loading'
136
- ? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
137
- : '';
181
+ function BranchStatusLabel({ status, reason, holdMessage, holdCount, working, workingSpinner = true, }) {
182
+ const spoken = [
183
+ status.label && status.value !== 'loading'
184
+ ? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
185
+ : '',
186
+ working,
187
+ ]
188
+ .filter(Boolean)
189
+ .join('. ');
138
190
  return (
139
191
  // 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 })] }));
192
+ _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 })] }));
141
193
  }
142
194
  const WAY_LABEL = {
143
195
  newBranch: `${EDITOR_ACTIONS.newBranch}…`,
@@ -154,7 +206,7 @@ function BranchWayButton({ action, onClick, caret, buttonRef, }) {
154
206
  * Submit for review, or Withdraw from review once submitted or approved, or Approve and Request
155
207
  * changes to a reviewer of someone else's submission; null when none shows.
156
208
  */
157
- function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, }) {
209
+ function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, rewritten, approveBlocked, waitingForLatest, openConflicts, heldReason, }) {
158
210
  const isSubmitted = branchStatus === 'submitted';
159
211
  const isEditing = branchStatus === 'editing';
160
212
  // 'approved' withdraws too -- it is the author's way back from
@@ -176,7 +228,7 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
176
228
  blocked: !branchName || busy || savingAll,
177
229
  permitted: true,
178
230
  reason: savingAll ? 'Wait for Save all to finish.' : busy ? BUSY_REASON : '',
179
- approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON : undefined,
231
+ approveReason: approveBlocked ? UNFINISHED_SUBMIT_APPROVE_REASON : heldReason,
180
232
  };
181
233
  }
182
234
  // Who may submit/withdraw: creator, ACL access, the system branch, or an
@@ -201,24 +253,34 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
201
253
  return {
202
254
  kind: isWithdrawable ? 'withdraw' : 'submit',
203
255
  // `busy` alone can't hold it: an entry load clears that shared flag mid-run.
204
- blocked: !branchName || busy || savingAll || !canPerformAction || (rewritten && isEditing),
256
+ blocked: !branchName ||
257
+ busy ||
258
+ savingAll ||
259
+ !canPerformAction ||
260
+ (isEditing && (rewritten || waitingForLatest || openConflicts > 0 || !!heldReason)),
205
261
  permitted: canPerformAction,
206
262
  reason: savingAll
207
263
  ? '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
- : '',
264
+ : waitingForLatest && isEditing
265
+ ? WAITING_SUBMIT_REASON
266
+ : heldReason && isEditing
267
+ ? heldReason
268
+ : openConflicts > 0 && isEditing
269
+ ? conflictsSubmitReason(openConflicts)
270
+ : rewritten && isEditing
271
+ ? rewrittenSubmitReason(isAdmin(userContext?.groups ?? []))
272
+ : // No branch data (`branchStatus === undefined`) must not be
273
+ // interpolated raw into this label. Guarded explicitly here rather
274
+ // than left to the early return above, which hides the action
275
+ // today only as a side effect of `branchIsProtected`'s `?? true`
276
+ // default -- not a guarantee this arm stays unreachable.
277
+ branchStatus === undefined
278
+ ? 'Branch details could not be loaded. Use "Refresh branch" to try again.'
279
+ : !statusHasAction
280
+ ? `This branch is ${branchStatus}, so it can't be submitted for review or withdrawn from review.`
281
+ : !userHasPermission
282
+ ? 'You do not have permission to submit this branch for review or withdraw it from review. Ask an admin for access.'
283
+ : '',
222
284
  };
223
285
  }
224
286
  function WorkflowButton({ narrow, ...props }) {
@@ -6,7 +6,11 @@
6
6
  import React from 'react';
7
7
  import type { BranchChangeEntry } from '../../api/branch-changes.js';
8
8
  import type { FieldConfig } from '../../config/index.js';
9
+ import type { CanopyPathSegment } from '../canopy-path.js';
9
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';
10
14
  /** The entry a drawer compares, as the editor knows it. */
11
15
  export interface EntryChangesSubject {
12
16
  logicalPath: string;
@@ -41,11 +45,57 @@ export interface EntryChangesDrawerProps {
41
45
  onOpenEntry?: () => void;
42
46
  /** The branch's pull request, offered where this drawer can't compare. */
43
47
  pullRequestUrl?: string;
48
+ /** Undoing the entry's changes in its form; given only for the open entry. */
49
+ undo?: EntryChangesUndo;
50
+ /**
51
+ * Why this user can't change the entry at all, such as a path rule: its undo, delete and
52
+ * restore controls are hidden and this one line says why.
53
+ */
54
+ noEditReason?: string;
55
+ /**
56
+ * Why those controls can't act now, such as a locked branch: they stay, disabled, saying so,
57
+ * as the form's do.
58
+ */
59
+ lockedReason?: string;
60
+ /** Opens the confirm that deletes an entry added on the branch, closing the drawer first. */
61
+ onDeleteEntry?: () => void;
62
+ /** Puts back an entry deleted on the branch; resolves with why it couldn't, when it couldn't. */
63
+ onRestoreEntry?: () => Promise<RestoreRefusal | undefined>;
64
+ /** The entry's open conflicts, given while the branch lists any for it. */
65
+ conflicts?: EntryChangesConflicts;
44
66
  }
45
- /** The drawer's body: its states, then the summary and one card per change. */
46
- export declare const EntryChangesView: React.FC<Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>>;
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;
73
+ }
74
+ /** Why "Restore entry" didn't: `warning` where nothing is lost, or the outcome is unknown. */
75
+ export interface RestoreRefusal {
76
+ message: string;
77
+ tone: 'warning' | 'error';
78
+ }
79
+ /** The open entry's form value, and the way to change its draft; see `undo-change.ts`. */
80
+ export interface EntryChangesUndo {
81
+ /** The entry's value as its form holds it, unsaved edits included. */
82
+ value: Record<string, unknown>;
83
+ /**
84
+ * Its saved value as the form loaded it, which a redo puts back: references resolved, so a
85
+ * redone entry matches its saved state and isn't left unsaved. Else the comparison's saved side.
86
+ */
87
+ saved?: Record<string, unknown>;
88
+ /** Sets the entry's draft to what `update` makes of its value at that moment. */
89
+ update: (update: (value: Record<string, unknown>) => Record<string, unknown>) => void;
90
+ /** Open field threads inside a canopy path, which taking out an item there moves to the entry. */
91
+ threadsWithin?: (path: CanopyPathSegment[]) => number;
92
+ }
93
+ type EntryChangesViewProps = Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>;
94
+ /** The drawer's body: the entry's conflicts, if any, above its changes. */
95
+ export declare const EntryChangesView: React.FC<EntryChangesViewProps>;
47
96
  /**
48
97
  * A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
49
98
  * and the form and preview wait behind its backdrop.
50
99
  */
51
100
  export declare const EntryChangesDrawer: React.FC<EntryChangesDrawerProps>;
101
+ export {};