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
@@ -0,0 +1,38 @@
1
+ import type { BranchListItem } from '../../api/branch.js';
2
+ import type { OperatingMode } from '../../operating-mode/types.js';
3
+ import { type BaseUpdateWait } from '../base-update-wait.js';
4
+ export interface UseBaseUpdateOptions {
5
+ /** The open branch. */
6
+ branch: BranchListItem | undefined;
7
+ operatingMode: OperatingMode;
8
+ /** The user may take the branch's workflow actions, which asking for the latest changes is. */
9
+ permitted: boolean;
10
+ /** Entries with unsaved changes on the branch; asking waits until they are saved. */
11
+ unsavedCount: number;
12
+ /** Re-read the branch's entries and the open one, keeping drafts: the worker rewrote them. */
13
+ onUpdated: () => void;
14
+ /** Opens the branch's conflicts, offered by the toast of a merge that left some. */
15
+ onShowConflicts?: () => void;
16
+ }
17
+ export interface UseBaseUpdateReturn {
18
+ /** "Get latest changes" applies here: a worker answers, the branch is editing, the user may ask. */
19
+ available: boolean;
20
+ /** The open branch's wait; `waiting` makes its form read-only. */
21
+ wait: BaseUpdateWait;
22
+ /** The open branch's request itself is in flight. */
23
+ requesting: boolean;
24
+ /** Why it can't be asked now; undefined when it can. */
25
+ blockedReason?: string;
26
+ /** Asks for the open branch, unless unsaved changes hold it. */
27
+ request: () => Promise<void>;
28
+ /** The base has changes the branch's saved work keeps the automatic sync from bringing in. */
29
+ latestChangesWaiting: boolean;
30
+ }
31
+ /**
32
+ * "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
33
+ * waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
34
+ * reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
35
+ * clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
36
+ * it lands after it.
37
+ */
38
+ export declare function useBaseUpdate(options: UseBaseUpdateOptions): UseBaseUpdateReturn;
@@ -0,0 +1,279 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useSWRConfig } from 'swr';
5
+ import { notifications } from '@mantine/notifications';
6
+ import { clientOperatingStrategy } from '../../operating-mode/client.js';
7
+ import { isBaseUpdatePending } from '../../base-update.js';
8
+ import { useApiClient } from '../context/index.js';
9
+ import { getErrorMessage } from '../../utils/error.js';
10
+ import { BRANCHES_KEY } from './useBranchesData.js';
11
+ import { RetryToastMessage } from '../components/RetryToastMessage.js';
12
+ import { EDITOR_ACTIONS } from '../copy.js';
13
+ import { GAVE_UP_MESSAGE, REQUEST_FAILED_MESSAGE, SAVE_FIRST_REASON, UNANSWERED_MESSAGE, baseUpdatePollMs, baseUpdateResultMessage, baseUpdateWait, nextBaseUpdateWaitChange, } from '../base-update-wait.js';
14
+ import { serverNow } from '../server-clock.js';
15
+ import { UnansweredError, isGatewayFailure, isUnanswered } from '../unanswered.js';
16
+ const RESULT_TOAST_ID = 'canopy-base-update-result';
17
+ /** Writes the branch a request answered into the listing, so its wait shows at once. */
18
+ function withBranch(data, branch) {
19
+ if (!data)
20
+ return data;
21
+ return { ...data, branches: data.branches.map((b) => (b.name === branch.name ? branch : b)) };
22
+ }
23
+ /** Whether `a` is a later request time than `b`, an absent one being earliest. */
24
+ const laterThan = (a, b) => b === undefined || Date.parse(a) > Date.parse(b);
25
+ const sameBranch = (branch, record) => record?.name === branch.name && record.createdAt === branch.createdAt;
26
+ /** The listed branch, carrying this tab's newer request. */
27
+ function withAsked(branch, asked) {
28
+ if (!branch || !sameBranch(branch, asked))
29
+ return branch;
30
+ return laterThan(asked.requestedAt, branch.baseUpdateRequestedAt)
31
+ ? { ...branch, baseUpdateRequestedAt: asked.requestedAt }
32
+ : branch;
33
+ }
34
+ /** The branch, carrying the answer this tab already reported when the listing hasn't caught up. */
35
+ function withSettled(branch, settled) {
36
+ if (!branch || !sameBranch(branch, settled))
37
+ return branch;
38
+ // Read before the answer, so what it says was waiting has come in.
39
+ const cameIn = settled.lastBaseUpdate.outcome !== 'failed' ? { latestChangesWaiting: undefined } : {};
40
+ if (!isBaseUpdatePending(branch)) {
41
+ return laterThan(settled.requestedAt, branch.baseUpdateRequestedAt)
42
+ ? { ...branch, ...cameIn }
43
+ : branch;
44
+ }
45
+ return laterThan(branch.baseUpdateRequestedAt ?? '', settled.requestedAt) &&
46
+ branch.baseUpdateRequestedAt !== undefined
47
+ ? branch
48
+ : {
49
+ ...branch,
50
+ baseUpdateRequestedAt: settled.requestedAt,
51
+ lastBaseUpdate: settled.lastBaseUpdate,
52
+ ...cameIn,
53
+ };
54
+ }
55
+ /**
56
+ * A toast in the one slot this hook uses, replacing what it showed before. `onAction` offers
57
+ * Retry, or `actionLabel`.
58
+ */
59
+ function showResult(color, message, onAction, actionLabel) {
60
+ notifications.hide(RESULT_TOAST_ID);
61
+ notifications.show({
62
+ id: RESULT_TOAST_ID,
63
+ color,
64
+ autoClose: color === 'green' ? undefined : false,
65
+ message: onAction ? (_jsx(RetryToastMessage, { message: message, onRetry: onAction, actionLabel: actionLabel })) : (message),
66
+ });
67
+ }
68
+ /**
69
+ * "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
70
+ * waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
71
+ * reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
72
+ * clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
73
+ * it lands after it.
74
+ */
75
+ export function useBaseUpdate(options) {
76
+ const { operatingMode, permitted, unsavedCount, onUpdated } = options;
77
+ const apiClient = useApiClient();
78
+ const { mutate } = useSWRConfig();
79
+ const [requestingFor, setRequestingFor] = useState(null);
80
+ // The last request this tab made, by branch, which a listing that predates it can't undo.
81
+ const [asked, setAsked] = useState(null);
82
+ // The last answer this tab reported, by branch, which a listing that predates it can't undo:
83
+ // another host can serve the branch list from an older snapshot.
84
+ const [settled, setSettled] = useState(null);
85
+ const [, setTick] = useState(0);
86
+ const listed = options.branch;
87
+ const name = listed?.name;
88
+ const branch = withSettled(withAsked(listed, asked), settled);
89
+ const available = !!branch &&
90
+ clientOperatingStrategy(operatingMode).syncsBranchesWithBase() &&
91
+ branch.status === 'editing' &&
92
+ branch.isProtected === false &&
93
+ permitted;
94
+ const wait = baseUpdateWait(branch, serverNow());
95
+ const requestedAt = branch?.baseUpdateRequestedAt;
96
+ const answer = branch?.lastBaseUpdate;
97
+ const status = branch?.status;
98
+ const createdAt = branch?.createdAt;
99
+ // The wait changes on its own at the slow and give-up marks; nothing else would re-render then.
100
+ const changeAt = nextBaseUpdateWaitChange(branch, serverNow());
101
+ useEffect(() => {
102
+ if (changeAt === undefined)
103
+ return;
104
+ const timer = setTimeout(() => setTick((n) => n + 1), Math.max(0, changeAt - serverNow()) + 50);
105
+ return () => clearTimeout(timer);
106
+ }, [changeAt]);
107
+ // Re-read the listing while the branch waits, on a timer of its own: SWR stops polling a key
108
+ // whose last fetch failed, and one failed read must not end the wait. Each read starts only once
109
+ // the last has settled, since SWR drops an in-flight read's result when another starts.
110
+ const pollMs = baseUpdatePollMs(wait);
111
+ useEffect(() => {
112
+ if (pollMs === 0)
113
+ return;
114
+ let stopped = false;
115
+ let timer;
116
+ const next = () => {
117
+ timer = setTimeout(async () => {
118
+ if (typeof document === 'undefined' || document.visibilityState !== 'hidden') {
119
+ await mutate(BRANCHES_KEY).catch(() => undefined);
120
+ }
121
+ if (!stopped)
122
+ next();
123
+ }, pollMs);
124
+ };
125
+ next();
126
+ return () => {
127
+ stopped = true;
128
+ clearTimeout(timer);
129
+ };
130
+ }, [pollMs, mutate]);
131
+ const latest = useRef({ name, unsavedCount, available });
132
+ latest.current = { name, unsavedCount, available };
133
+ const onUpdatedRef = useRef(onUpdated);
134
+ onUpdatedRef.current = onUpdated;
135
+ const onShowConflictsRef = useRef(options.onShowConflicts);
136
+ onShowConflictsRef.current = options.onShowConflicts;
137
+ const requestRef = useRef(async () => { });
138
+ // Stable, and reads only refs, so the toasts that carry it keep working.
139
+ const retry = useCallback(() => {
140
+ notifications.hide(RESULT_TOAST_ID);
141
+ void requestRef.current();
142
+ }, []);
143
+ const report = useCallback((on, result) => {
144
+ setSettled({ ...on, lastBaseUpdate: result });
145
+ if (result.outcome !== 'failed')
146
+ onUpdatedRef.current();
147
+ if (result.outcome === 'conflicts') {
148
+ // The toast counts the choices left, which the branch's conflicts hold.
149
+ void apiClient.conflicts
150
+ .list({ branch: on.name })
151
+ .then((answer) => (answer.ok ? answer.data?.openCount : undefined))
152
+ .catch(() => undefined)
153
+ .then((count) => {
154
+ if (latest.current.name !== on.name)
155
+ return;
156
+ const { color, message } = baseUpdateResultMessage(result, count);
157
+ const show = () => {
158
+ notifications.hide(RESULT_TOAST_ID);
159
+ onShowConflictsRef.current?.();
160
+ };
161
+ showResult(color, message, onShowConflictsRef.current && count !== 0 ? show : undefined, EDITOR_ACTIONS.showChanges);
162
+ });
163
+ return;
164
+ }
165
+ const { color, message } = baseUpdateResultMessage(result);
166
+ // Retry only for someone who may ask; another viewer just learns how it went.
167
+ const canRetry = result.outcome === 'failed' && latest.current.available;
168
+ showResult(color, message, canRetry ? retry : undefined);
169
+ }, [apiClient, retry]);
170
+ // A result is about the branch it came from: switching away closes it.
171
+ useEffect(() => () => {
172
+ notifications.hide(RESULT_TOAST_ID);
173
+ }, [name]);
174
+ // A wait this tab saw, by branch and request, reports when its answer lands, even after the
175
+ // editor stopped waiting on it; giving up says so, once.
176
+ const watched = useRef(undefined);
177
+ useEffect(() => {
178
+ if (!name || !requestedAt)
179
+ return;
180
+ const seen = watched.current;
181
+ const same = seen?.name === name && seen.requestedAt === requestedAt;
182
+ if (wait !== 'none') {
183
+ if (!same)
184
+ watched.current = { name, requestedAt, gaveUp: false };
185
+ return;
186
+ }
187
+ if (!seen || !same)
188
+ return;
189
+ if (answer &&
190
+ !isBaseUpdatePending({ baseUpdateRequestedAt: requestedAt, lastBaseUpdate: answer })) {
191
+ watched.current = undefined;
192
+ report({ name, createdAt: createdAt ?? '', requestedAt }, answer);
193
+ }
194
+ else if (!seen.gaveUp && status === 'editing') {
195
+ seen.gaveUp = true;
196
+ showResult('yellow', GAVE_UP_MESSAGE);
197
+ }
198
+ }, [name, createdAt, requestedAt, answer, wait, status, report]);
199
+ const request = useCallback(async () => {
200
+ const target = latest.current.name;
201
+ if (!target)
202
+ return;
203
+ if (latest.current.unsavedCount > 0) {
204
+ showResult('yellow', SAVE_FIRST_REASON);
205
+ return;
206
+ }
207
+ // What lands after the user moved to another branch is about that one, not this.
208
+ const stillOpen = () => latest.current.name === target;
209
+ setRequestingFor(target);
210
+ try {
211
+ try {
212
+ const result = await apiClient.workflow.updateFromBase({ branch: target });
213
+ if (isGatewayFailure(result))
214
+ throw new UnansweredError();
215
+ if (result.ok && result.data) {
216
+ const { branch: answered, requestedAt: at } = result.data;
217
+ await mutate(BRANCHES_KEY, (data) => withBranch(data, answered), {
218
+ revalidate: false,
219
+ });
220
+ if (!stillOpen())
221
+ return;
222
+ const on = { name: target, createdAt: answered.createdAt, requestedAt: at };
223
+ setAsked(on);
224
+ if (!isBaseUpdatePending(answered) && answered.lastBaseUpdate) {
225
+ report(on, answered.lastBaseUpdate);
226
+ }
227
+ else {
228
+ watched.current = { name: target, requestedAt: at, gaveUp: false };
229
+ }
230
+ return;
231
+ }
232
+ // A refusal can mean the branch moved on (submitted meanwhile): re-read it, and offer Retry
233
+ // only if it can still ask.
234
+ const fresh = await mutate(BRANCHES_KEY);
235
+ if (!stillOpen())
236
+ return;
237
+ const now = fresh?.branches.find((b) => b.name === target);
238
+ const canRetry = now === undefined || now.status === 'editing';
239
+ // A 409 is a conflict with the branch's state, which the guidelines colour yellow.
240
+ showResult(result.status === 409 ? 'yellow' : 'red', result.error ?? REQUEST_FAILED_MESSAGE, canRetry ? retry : undefined);
241
+ }
242
+ catch (err) {
243
+ if (!isUnanswered(err))
244
+ throw err;
245
+ // It may have gone through, and a reload that shows the wait settles it.
246
+ const fresh = await mutate(BRANCHES_KEY);
247
+ if (!stillOpen())
248
+ return;
249
+ const reloaded = fresh?.branches.find((b) => b.name === target);
250
+ if (baseUpdateWait(reloaded, serverNow()) === 'none') {
251
+ showResult('yellow', UNANSWERED_MESSAGE, retry);
252
+ }
253
+ }
254
+ }
255
+ catch (err) {
256
+ if (!stillOpen())
257
+ return;
258
+ notifications.hide(RESULT_TOAST_ID);
259
+ notifications.show({
260
+ id: RESULT_TOAST_ID,
261
+ color: 'red',
262
+ autoClose: false,
263
+ message: (_jsx(RetryToastMessage, { message: REQUEST_FAILED_MESSAGE, onRetry: retry, details: getErrorMessage(err) })),
264
+ });
265
+ }
266
+ finally {
267
+ setRequestingFor((current) => (current === target ? null : current));
268
+ }
269
+ }, [apiClient, mutate, report, retry]);
270
+ requestRef.current = request;
271
+ return {
272
+ available,
273
+ wait,
274
+ requesting: !!name && requestingFor === name,
275
+ blockedReason: unsavedCount > 0 ? SAVE_FIRST_REASON : undefined,
276
+ request,
277
+ latestChangesWaiting: available && !!branch?.latestChangesWaiting,
278
+ };
279
+ }
@@ -45,7 +45,13 @@ interface ChangesState<T, E> {
45
45
  */
46
46
  reload: () => Promise<void>;
47
47
  }
48
- export type BranchChangesState = ChangesState<BranchChangesData, BranchChangesError>;
48
+ export type BranchChangesState = ChangesState<BranchChangesData, BranchChangesError> & {
49
+ /**
50
+ * Reads the list afresh, shown or not, rejecting when the read fails, and empties every entry
51
+ * change of the branch, so none shows a comparison read before this.
52
+ */
53
+ reread: () => Promise<void>;
54
+ };
49
55
  /**
50
56
  * The branch's changes. `reload` also revalidates every entry change of the branch that is
51
57
  * shown, so one save refreshes the list and an open "Show changes" alike.
@@ -98,12 +98,25 @@ export function useBranchChangesData(apiClient, branch, enabled) {
98
98
  mutateKeys((k) => typeof k === 'string' && k.startsWith(entryPrefix)),
99
99
  ]);
100
100
  }, [mutate, mutateKeys, branch]);
101
+ const reread = useCallback(async () => {
102
+ if (!branch)
103
+ return;
104
+ const entryPrefix = `${branchChangesKey(branch)}:`;
105
+ const [fresh] = await Promise.all([
106
+ fetchBranchChanges(apiClient, branch),
107
+ mutateKeys((k) => typeof k === 'string' && k.startsWith(entryPrefix), undefined, {
108
+ revalidate: true,
109
+ }),
110
+ ]);
111
+ await mutateKeys(branchChangesKey(branch), fresh, { revalidate: false });
112
+ }, [apiClient, mutateKeys, branch]);
101
113
  return {
102
114
  data: swr.data,
103
115
  error: key && swr.error !== undefined ? toError(swr.error) : undefined,
104
116
  isLoading: swr.isLoading,
105
117
  isValidating: swr.isValidating,
106
118
  reload,
119
+ reread,
107
120
  };
108
121
  }
109
122
  /** One entry's change on the branch, fetched while `target` is set (the drawer is open). */
@@ -51,7 +51,7 @@ interface BranchSummary {
51
51
  syncFailureReason?: string;
52
52
  /** Whether this branch has unresolved merge conflicts with the base branch */
53
53
  conflictStatus?: ConflictStatus;
54
- /** ContentIds of entries where --theirs was applied during rebase; cleared on clean rebase */
54
+ /** ContentIds of entries with a merge conflict a person has yet to resolve */
55
55
  conflictFiles?: string[];
56
56
  /** What is stuck and needs a person (api/branch.ts). */
57
57
  hold?: BranchHold;
@@ -92,6 +92,13 @@ export interface UseBranchManagerOptions {
92
92
  onStamped?: (stamped: StampedEntry[]) => void;
93
93
  /** A request for changes went through; the branch may now carry the reviewer's note as a comment. */
94
94
  onChangesRequested?: (branchName: string) => void;
95
+ /** A branch is being pinned for its reviews: read its content again if it is open, settling once read. */
96
+ onReviewPinned?: (branchName: string) => Promise<void> | void;
97
+ /**
98
+ * How to ask for the latest changes on `branchName` once it is withdrawn, or undefined where
99
+ * that can't apply. With it, withdrawing a branch that conflicts with its base asks right after.
100
+ */
101
+ latestChangesAfterWithdraw?: (branchName: string) => (() => Promise<void>) | undefined;
95
102
  }
96
103
  export interface UseBranchManagerReturn {
97
104
  /**
@@ -6,7 +6,8 @@ import { Alert, Stack, Text } from '@mantine/core';
6
6
  import { IconAlertTriangle } from '@tabler/icons-react';
7
7
  import { openConfirm } from '../utils/confirm-modal.js';
8
8
  import { focusReturnProps } from '../utils/focus-return.js';
9
- import { EDITOR_ACTIONS } from '../copy.js';
9
+ import { EDITOR_ACTIONS, WITHDRAW_AND_GET_LATEST } from '../copy.js';
10
+ import { clientOperatingStrategy } from '../../operating-mode/client.js';
10
11
  import { notifications } from '@mantine/notifications';
11
12
  import { useApiClient, useBranchSegments } from '../context/index.js';
12
13
  import { BRANCHES_KEY, fetchBranches, useBranchesData } from './useBranchesData.js';
@@ -32,6 +33,13 @@ class StaleStatusError extends Error {
32
33
  this.name = 'StaleStatusError';
33
34
  }
34
35
  }
36
+ /** The API's `BRANCH_MOVED`: the branch's head isn't the one the reviewer's page was pinned to. */
37
+ class BranchMovedError extends Error {
38
+ constructor() {
39
+ super(...arguments);
40
+ this.name = 'BranchMovedError';
41
+ }
42
+ }
35
43
  /** An answer that refused the action, with its HTTP status: a 4xx is a refusal no retry passes. */
36
44
  class RefusedError extends Error {
37
45
  constructor(message, status) {
@@ -50,7 +58,13 @@ function actionError(result, fallback, staleIsConflict = false) {
50
58
  const message = result.error || fallback;
51
59
  if (result.ok)
52
60
  return new Error(message);
53
- return staleIsConflict && result.status === 409 && !isNonApiResponse(result)
61
+ if (result.code === 'BRANCH_MOVED')
62
+ return new BranchMovedError(message);
63
+ // Open conflicts refuse the action where the branch still is, so they are no stale status.
64
+ return staleIsConflict &&
65
+ result.status === 409 &&
66
+ result.code !== 'OPEN_CONFLICTS' &&
67
+ !isNonApiResponse(result)
54
68
  ? new StaleStatusError(message)
55
69
  : new RefusedError(message, result.status);
56
70
  }
@@ -142,16 +156,22 @@ export function withdrawPullRequestBullet(pr) {
142
156
  return `Leave the merged ${label} as it is`;
143
157
  return `Convert ${label} to a draft`;
144
158
  }
145
- const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo) => {
159
+ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo,
160
+ /** The branch conflicts with its base, so withdrawing also gets the latest changes. */
161
+ thenGetLatest = false) => {
146
162
  const bullets = [
147
163
  withdrawPullRequestBullet(pr),
148
164
  'Change the branch status back to “editing”',
149
165
  'Remove from review queue',
166
+ thenGetLatest ? 'Bring in the latest changes made on the site' : undefined,
150
167
  ].filter((b) => b !== undefined);
151
168
  openConfirm({
152
169
  title: EDITOR_ACTIONS.withdrawFromReview,
153
170
  children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Withdraw “${branchName}” from review?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}` })),
154
- labels: { confirm: EDITOR_ACTIONS.withdrawFromReview, cancel: 'Cancel' },
171
+ labels: {
172
+ confirm: thenGetLatest ? WITHDRAW_AND_GET_LATEST : EDITOR_ACTIONS.withdrawFromReview,
173
+ cancel: 'Cancel',
174
+ },
155
175
  confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
156
176
  ...confirmModalHandlers(onConfirm, onDismiss),
157
177
  ...focusReturnProps(returnFocusTo),
@@ -467,6 +487,52 @@ export function useBranchManager(options) {
467
487
  // these at confirm time rather than from that render's closure.
468
488
  const latest = useRef({ branchName, resolved, branches, branchesData });
469
489
  latest.current = { branchName, resolved, branches, branchesData };
490
+ // The head a review sends, never newer than what the page shows of the branch. One not shown
491
+ // follows each listing; opening one, a head listed for the open one while its pin is null, and a
492
+ // refusal's recovery pin it from a listing already fetched once its content is read again.
493
+ const reviewPins = useRef(new Map());
494
+ const shown = useRef(new Set());
495
+ const pinning = useRef(new Map());
496
+ const lastOpen = useRef(undefined);
497
+ const pinThenReread = async (branch, data) => {
498
+ const headSha = data?.branches.find((b) => b.name === branch)?.headSha;
499
+ const claim = Symbol(branch);
500
+ pinning.current.set(branch, claim);
501
+ reviewPins.current.set(branch, null);
502
+ try {
503
+ if (headSha)
504
+ await options.onReviewPinned?.(branch);
505
+ // A failed read leaves it null, for the next listing to try again.
506
+ if (pinning.current.get(branch) === claim)
507
+ reviewPins.current.set(branch, headSha ?? null);
508
+ }
509
+ finally {
510
+ if (pinning.current.get(branch) === claim)
511
+ pinning.current.delete(branch);
512
+ }
513
+ };
514
+ useEffect(() => {
515
+ const listed = branchesData?.branches ?? [];
516
+ for (const b of listed) {
517
+ if (!shown.current.has(b.name))
518
+ reviewPins.current.set(b.name, b.headSha ?? null);
519
+ }
520
+ const open = currentBranchName && listed.find((b) => b.name === currentBranchName);
521
+ if (!open)
522
+ return;
523
+ const firstHead = open.headSha && reviewPins.current.get(open.name) === null && !pinning.current.has(open.name);
524
+ // Coming back to a branch opens it again: its entries are read afresh, so its pin is too.
525
+ const reopened = lastOpen.current !== open.name;
526
+ lastOpen.current = open.name;
527
+ if (!shown.current.has(open.name) || firstHead || reopened) {
528
+ shown.current.add(open.name);
529
+ pinThenReread(open.name, branchesData).catch(console.error);
530
+ }
531
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `pinThenReread` reads only refs and options
532
+ }, [branchesData, currentBranchName]);
533
+ const reviewedHead = (branch) => ({
534
+ headSha: reviewPins.current.get(branch) ?? undefined,
535
+ });
470
536
  // A card, deep link or URL can carry a short name, an exact form or the raw form of a listed
471
537
  // name; the actions resolve it once, the way an arriving name resolves, then key, look up and
472
538
  // send the listed name.
@@ -577,6 +643,11 @@ export function useBranchManager(options) {
577
643
  }
578
644
  };
579
645
  const reviewFailureId = (kind, branch) => `canopy-${kind}-failed-${branch}`;
646
+ /** A verdict that went through answers either kind's refusal. */
647
+ const hideReviewFailures = (branch) => {
648
+ notifications.hide(reviewFailureId('approve', branch));
649
+ notifications.hide(reviewFailureId('request-changes', branch));
650
+ };
580
651
  const submitRefusedId = (branch) => `canopy-submit-refused-${branch}`;
581
652
  const deleteRefusedId = (branch) => `canopy-delete-refused-${branch}`;
582
653
  /**
@@ -586,6 +657,22 @@ export function useBranchManager(options) {
586
657
  * submit didn't finish (see `showLeftReview`, which shows the server's reason either way).
587
658
  */
588
659
  const reportReviewFailure = async (err, kind, branch, details) => {
660
+ if (err instanceof BranchMovedError) {
661
+ // A listing that fails, or leaves the branch out, pins none for now.
662
+ void pinThenReread(branch, await reloadBranches()).catch(console.error);
663
+ const id = reviewFailureId(kind, branch);
664
+ notifications.hide(id);
665
+ notifications.show({
666
+ id,
667
+ message: details.note
668
+ ? `${err.message} Your note is kept for when you request changes again.`
669
+ : err.message,
670
+ color: 'yellow',
671
+ autoClose: false,
672
+ withCloseButton: true,
673
+ });
674
+ return;
675
+ }
589
676
  if (err instanceof StaleStatusError) {
590
677
  showLeftReview(branch, err.message, details.note);
591
678
  // What the branch is now decides whether a Request changes note is still wanted.
@@ -609,8 +696,9 @@ export function useBranchManager(options) {
609
696
  notifications.show({
610
697
  id,
611
698
  message: details.note ? (_jsx(NoteNotSavedMessage, { message: message, note: details.note, hint: "Copy it to keep it." })) : (message),
612
- color: 'red',
613
- ...(details.note ? { autoClose: false } : {}),
699
+ // A 409, such as open conflicts, loses nothing and says what to do: yellow, until read.
700
+ color: err.status === 409 ? 'yellow' : 'red',
701
+ ...(details.note || err.status === 409 ? { autoClose: false, withCloseButton: true } : {}),
614
702
  });
615
703
  await loadBranches();
616
704
  return;
@@ -719,6 +807,12 @@ export function useBranchManager(options) {
719
807
  const handleWithdraw = (requested, returnFocusTo) => {
720
808
  const branchNameToWithdraw = listedName(requested);
721
809
  return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
810
+ // The conflict that held it in review is what the latest changes settle.
811
+ const requestLatest = clientOperatingStrategy(options.operatingMode).syncsBranchesWithBase() &&
812
+ listedBranch(branchNameToWithdraw)?.hold?.kind === 'conflicts'
813
+ ? options.latestChangesAfterWithdraw?.(branchNameToWithdraw)
814
+ : undefined;
815
+ const thenGetLatest = !!requestLatest;
722
816
  showWithdrawConfirmation(shownName(branchNameToWithdraw), pullRequestOf(branchNameToWithdraw), async () => {
723
817
  options.setBusy(true);
724
818
  try {
@@ -731,6 +825,9 @@ export function useBranchManager(options) {
731
825
  updateCreatedBranch(branchNameToWithdraw, result.data?.branch);
732
826
  await loadBranches();
733
827
  resolve();
828
+ // Reports its own outcome, so a failure here never reads as a failed withdraw.
829
+ if (requestLatest)
830
+ void requestLatest();
734
831
  }
735
832
  catch (err) {
736
833
  await reportActionFailure(err, 'Failed to withdraw branch', EDITOR_ACTIONS.withdrawFromReview);
@@ -739,7 +836,7 @@ export function useBranchManager(options) {
739
836
  finally {
740
837
  options.setBusy(false);
741
838
  }
742
- }, () => resolve(), returnFocusTo);
839
+ }, () => resolve(), returnFocusTo, thenGetLatest);
743
840
  }));
744
841
  };
745
842
  const handleArchive = (requested) => {
@@ -787,12 +884,10 @@ export function useBranchManager(options) {
787
884
  showApproveConfirmation(shownName(branchNameToApprove), pullRequestOf(branchNameToApprove), async () => {
788
885
  options.setBusy(true);
789
886
  try {
790
- const result = await apiClient.workflow.approve({
791
- branch: sendName(branchNameToApprove),
792
- });
887
+ const result = await apiClient.workflow.approve({ branch: sendName(branchNameToApprove) }, reviewedHead(branchNameToApprove));
793
888
  if (!result.ok)
794
889
  throw actionError(result, 'Failed to approve branch', true);
795
- notifications.hide(reviewFailureId('approve', branchNameToApprove));
890
+ hideReviewFailures(branchNameToApprove);
796
891
  notifications.show({ message: 'Branch approved', color: 'green' });
797
892
  updateCreatedBranch(branchNameToApprove, result.data?.branch);
798
893
  await loadBranches();
@@ -825,11 +920,11 @@ export function useBranchManager(options) {
825
920
  }, async (note) => {
826
921
  options.setBusy(true);
827
922
  try {
828
- const result = await apiClient.workflow.requestChanges({ branch: sendName(branchNameForChanges) }, { note: note.trim() || undefined });
923
+ const result = await apiClient.workflow.requestChanges({ branch: sendName(branchNameForChanges) }, { note: note.trim() || undefined, ...reviewedHead(branchNameForChanges) });
829
924
  if (!result.ok)
830
925
  throw actionError(result, 'Failed to request changes', true);
831
926
  noteDrafts.current.delete(branchNameForChanges);
832
- notifications.hide(reviewFailureId('request-changes', branchNameForChanges));
927
+ hideReviewFailures(branchNameForChanges);
833
928
  notifications.show({ message: 'Changes requested', color: 'blue' });
834
929
  // The note is saved apart from the status change, so it can fail on its own;
835
930
  // the warning stays up, with the note to copy, because the words are not saved.
@@ -15,6 +15,7 @@
15
15
  import useSWR, { useSWRConfig } from 'swr';
16
16
  import { isNonApiResponse } from '../../api/client.js';
17
17
  import { seedUserProfiles } from './useUserMetadata.js';
18
+ import { noteServerTime } from '../server-clock.js';
18
19
  /** Cache key for the branches list. */
19
20
  export const BRANCHES_KEY = 'canopy:branches';
20
21
  /**
@@ -33,6 +34,8 @@ export async function fetchBranches(apiClient, swr) {
33
34
  if (!result.ok) {
34
35
  throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
35
36
  }
37
+ if (result.data?.serverTime !== undefined)
38
+ noteServerTime(result.data.serverTime);
36
39
  const users = result.data?.users ?? [];
37
40
  await seedUserProfiles(swr, users);
38
41
  return {
@@ -0,0 +1,24 @@
1
+ import type { ResolvedFieldConflict } from '../../merge-conflicts.js';
2
+ import type { EntryChoices, FieldChoice } from '../conflict-choices.js';
3
+ export interface UseConflictChoicesReturn {
4
+ /** The open branch's choices for an entry, by conflict key. */
5
+ choicesFor: (contentId: string) => EntryChoices;
6
+ /** Records a choice, or clears it with undefined. */
7
+ choose: (contentId: string, key: string, mergeSha: string, choice: FieldChoice | undefined, decided: boolean) => void;
8
+ /** Entries with a decided choice no save has sent. */
9
+ pendingIds: ReadonlySet<string>;
10
+ /** What a save of an entry on `branch` resolves; nothing for another branch. */
11
+ pending: (branch: string, contentId: string) => ResolvedFieldConflict[];
12
+ /** Drops the choices a save sent: resolved, or no longer open. */
13
+ sent: (branch: string, contentId: string, resolved: readonly ResolvedFieldConflict[]) => void;
14
+ /** Drops an entry's choices, or every entry's when `contentId` is undefined. */
15
+ clear: (contentId?: string) => void;
16
+ /** Keeps only the choices of the entries `open` names, which have conflicts open. */
17
+ keepEntries: (open: ReadonlySet<string>) => void;
18
+ /** Keeps only an entry's choices for the conflicts `open` names, each on its merge. */
19
+ retain: (contentId: string, open: ReadonlyArray<{
20
+ key: string;
21
+ mergeSha: string;
22
+ }>) => void;
23
+ }
24
+ export declare function useConflictChoices(branch: string): UseConflictChoicesReturn;