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
@@ -0,0 +1,269 @@
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) || !isBaseUpdatePending(branch))
37
+ return branch;
38
+ return laterThan(branch.baseUpdateRequestedAt ?? '', settled.requestedAt) &&
39
+ branch.baseUpdateRequestedAt !== undefined
40
+ ? branch
41
+ : {
42
+ ...branch,
43
+ baseUpdateRequestedAt: settled.requestedAt,
44
+ lastBaseUpdate: settled.lastBaseUpdate,
45
+ };
46
+ }
47
+ /**
48
+ * A toast in the one slot this hook uses, replacing what it showed before. `onAction` offers
49
+ * Retry, or `actionLabel`.
50
+ */
51
+ function showResult(color, message, onAction, actionLabel) {
52
+ notifications.hide(RESULT_TOAST_ID);
53
+ notifications.show({
54
+ id: RESULT_TOAST_ID,
55
+ color,
56
+ autoClose: color === 'green' ? undefined : false,
57
+ message: onAction ? (_jsx(RetryToastMessage, { message: message, onRetry: onAction, actionLabel: actionLabel })) : (message),
58
+ });
59
+ }
60
+ /**
61
+ * "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
62
+ * waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
63
+ * reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
64
+ * clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
65
+ * it lands after it.
66
+ */
67
+ export function useBaseUpdate(options) {
68
+ const { operatingMode, permitted, unsavedCount, onUpdated } = options;
69
+ const apiClient = useApiClient();
70
+ const { mutate } = useSWRConfig();
71
+ const [requestingFor, setRequestingFor] = useState(null);
72
+ // The last request this tab made, by branch, which a listing that predates it can't undo.
73
+ const [asked, setAsked] = useState(null);
74
+ // The last answer this tab reported, by branch, which a listing that predates it can't undo:
75
+ // another host can serve the branch list from an older snapshot.
76
+ const [settled, setSettled] = useState(null);
77
+ const [, setTick] = useState(0);
78
+ const listed = options.branch;
79
+ const name = listed?.name;
80
+ const branch = withSettled(withAsked(listed, asked), settled);
81
+ const available = !!branch &&
82
+ clientOperatingStrategy(operatingMode).syncsBranchesWithBase() &&
83
+ branch.status === 'editing' &&
84
+ branch.isProtected === false &&
85
+ permitted;
86
+ const wait = baseUpdateWait(branch, serverNow());
87
+ const requestedAt = branch?.baseUpdateRequestedAt;
88
+ const answer = branch?.lastBaseUpdate;
89
+ const status = branch?.status;
90
+ const createdAt = branch?.createdAt;
91
+ // The wait changes on its own at the slow and give-up marks; nothing else would re-render then.
92
+ const changeAt = nextBaseUpdateWaitChange(branch, serverNow());
93
+ useEffect(() => {
94
+ if (changeAt === undefined)
95
+ return;
96
+ const timer = setTimeout(() => setTick((n) => n + 1), Math.max(0, changeAt - serverNow()) + 50);
97
+ return () => clearTimeout(timer);
98
+ }, [changeAt]);
99
+ // Re-read the listing while the branch waits, on a timer of its own: SWR stops polling a key
100
+ // whose last fetch failed, and one failed read must not end the wait. Each read starts only once
101
+ // the last has settled, since SWR drops an in-flight read's result when another starts.
102
+ const pollMs = baseUpdatePollMs(wait);
103
+ useEffect(() => {
104
+ if (pollMs === 0)
105
+ return;
106
+ let stopped = false;
107
+ let timer;
108
+ const next = () => {
109
+ timer = setTimeout(async () => {
110
+ if (typeof document === 'undefined' || document.visibilityState !== 'hidden') {
111
+ await mutate(BRANCHES_KEY).catch(() => undefined);
112
+ }
113
+ if (!stopped)
114
+ next();
115
+ }, pollMs);
116
+ };
117
+ next();
118
+ return () => {
119
+ stopped = true;
120
+ clearTimeout(timer);
121
+ };
122
+ }, [pollMs, mutate]);
123
+ const latest = useRef({ name, unsavedCount, available });
124
+ latest.current = { name, unsavedCount, available };
125
+ const onUpdatedRef = useRef(onUpdated);
126
+ onUpdatedRef.current = onUpdated;
127
+ const onShowConflictsRef = useRef(options.onShowConflicts);
128
+ onShowConflictsRef.current = options.onShowConflicts;
129
+ const requestRef = useRef(async () => { });
130
+ // Stable, and reads only refs, so the toasts that carry it keep working.
131
+ const retry = useCallback(() => {
132
+ notifications.hide(RESULT_TOAST_ID);
133
+ void requestRef.current();
134
+ }, []);
135
+ const report = useCallback((on, result) => {
136
+ setSettled({ ...on, lastBaseUpdate: result });
137
+ if (result.outcome !== 'failed')
138
+ onUpdatedRef.current();
139
+ if (result.outcome === 'conflicts') {
140
+ // The toast counts the choices left, which the branch's conflicts hold.
141
+ void apiClient.conflicts
142
+ .list({ branch: on.name })
143
+ .then((answer) => (answer.ok ? answer.data?.openCount : undefined))
144
+ .catch(() => undefined)
145
+ .then((count) => {
146
+ if (latest.current.name !== on.name)
147
+ return;
148
+ const { color, message } = baseUpdateResultMessage(result, count);
149
+ const show = () => {
150
+ notifications.hide(RESULT_TOAST_ID);
151
+ onShowConflictsRef.current?.();
152
+ };
153
+ showResult(color, message, onShowConflictsRef.current && count !== 0 ? show : undefined, EDITOR_ACTIONS.showChanges);
154
+ });
155
+ return;
156
+ }
157
+ const { color, message } = baseUpdateResultMessage(result);
158
+ // Retry only for someone who may ask; another viewer just learns how it went.
159
+ const canRetry = result.outcome === 'failed' && latest.current.available;
160
+ showResult(color, message, canRetry ? retry : undefined);
161
+ }, [apiClient, retry]);
162
+ // A result is about the branch it came from: switching away closes it.
163
+ useEffect(() => () => {
164
+ notifications.hide(RESULT_TOAST_ID);
165
+ }, [name]);
166
+ // A wait this tab saw, by branch and request, reports when its answer lands, even after the
167
+ // editor stopped waiting on it; giving up says so, once.
168
+ const watched = useRef(undefined);
169
+ useEffect(() => {
170
+ if (!name || !requestedAt)
171
+ return;
172
+ const seen = watched.current;
173
+ const same = seen?.name === name && seen.requestedAt === requestedAt;
174
+ if (wait !== 'none') {
175
+ if (!same)
176
+ watched.current = { name, requestedAt, gaveUp: false };
177
+ return;
178
+ }
179
+ if (!seen || !same)
180
+ return;
181
+ if (answer &&
182
+ !isBaseUpdatePending({ baseUpdateRequestedAt: requestedAt, lastBaseUpdate: answer })) {
183
+ watched.current = undefined;
184
+ report({ name, createdAt: createdAt ?? '', requestedAt }, answer);
185
+ }
186
+ else if (!seen.gaveUp && status === 'editing') {
187
+ seen.gaveUp = true;
188
+ showResult('yellow', GAVE_UP_MESSAGE);
189
+ }
190
+ }, [name, createdAt, requestedAt, answer, wait, status, report]);
191
+ const request = useCallback(async () => {
192
+ const target = latest.current.name;
193
+ if (!target)
194
+ return;
195
+ if (latest.current.unsavedCount > 0) {
196
+ showResult('yellow', SAVE_FIRST_REASON);
197
+ return;
198
+ }
199
+ // What lands after the user moved to another branch is about that one, not this.
200
+ const stillOpen = () => latest.current.name === target;
201
+ setRequestingFor(target);
202
+ try {
203
+ try {
204
+ const result = await apiClient.workflow.updateFromBase({ branch: target });
205
+ if (isGatewayFailure(result))
206
+ throw new UnansweredError();
207
+ if (result.ok && result.data) {
208
+ const { branch: answered, requestedAt: at } = result.data;
209
+ await mutate(BRANCHES_KEY, (data) => withBranch(data, answered), {
210
+ revalidate: false,
211
+ });
212
+ if (!stillOpen())
213
+ return;
214
+ const on = { name: target, createdAt: answered.createdAt, requestedAt: at };
215
+ setAsked(on);
216
+ if (!isBaseUpdatePending(answered) && answered.lastBaseUpdate) {
217
+ report(on, answered.lastBaseUpdate);
218
+ }
219
+ else {
220
+ watched.current = { name: target, requestedAt: at, gaveUp: false };
221
+ }
222
+ return;
223
+ }
224
+ // A refusal can mean the branch moved on (submitted meanwhile): re-read it, and offer Retry
225
+ // only if it can still ask.
226
+ const fresh = await mutate(BRANCHES_KEY);
227
+ if (!stillOpen())
228
+ return;
229
+ const now = fresh?.branches.find((b) => b.name === target);
230
+ const canRetry = now === undefined || now.status === 'editing';
231
+ showResult('red', result.error ?? REQUEST_FAILED_MESSAGE, canRetry ? retry : undefined);
232
+ }
233
+ catch (err) {
234
+ if (!isUnanswered(err))
235
+ throw err;
236
+ // It may have gone through, and a reload that shows the wait settles it.
237
+ const fresh = await mutate(BRANCHES_KEY);
238
+ if (!stillOpen())
239
+ return;
240
+ const reloaded = fresh?.branches.find((b) => b.name === target);
241
+ if (baseUpdateWait(reloaded, serverNow()) === 'none') {
242
+ showResult('yellow', UNANSWERED_MESSAGE, retry);
243
+ }
244
+ }
245
+ }
246
+ catch (err) {
247
+ if (!stillOpen())
248
+ return;
249
+ notifications.hide(RESULT_TOAST_ID);
250
+ notifications.show({
251
+ id: RESULT_TOAST_ID,
252
+ color: 'red',
253
+ autoClose: false,
254
+ message: (_jsx(RetryToastMessage, { message: REQUEST_FAILED_MESSAGE, onRetry: retry, details: getErrorMessage(err) })),
255
+ });
256
+ }
257
+ finally {
258
+ setRequestingFor((current) => (current === target ? null : current));
259
+ }
260
+ }, [apiClient, mutate, report, retry]);
261
+ requestRef.current = request;
262
+ return {
263
+ available,
264
+ wait,
265
+ requesting: !!name && requestingFor === name,
266
+ blockedReason: unsavedCount > 0 ? SAVE_FIRST_REASON : undefined,
267
+ request,
268
+ };
269
+ }
@@ -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,11 @@ 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
+ /**
96
+ * How to ask for the latest changes on `branchName` once it is withdrawn, or undefined where
97
+ * that can't apply. With it, withdrawing a branch that conflicts with its base asks right after.
98
+ */
99
+ latestChangesAfterWithdraw?: (branchName: string) => (() => Promise<void>) | undefined;
95
100
  }
96
101
  export interface UseBranchManagerReturn {
97
102
  /**
@@ -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';
@@ -50,7 +51,11 @@ function actionError(result, fallback, staleIsConflict = false) {
50
51
  const message = result.error || fallback;
51
52
  if (result.ok)
52
53
  return new Error(message);
53
- return staleIsConflict && result.status === 409 && !isNonApiResponse(result)
54
+ // Open conflicts refuse the action where the branch still is, so they are no stale status.
55
+ return staleIsConflict &&
56
+ result.status === 409 &&
57
+ result.code !== 'OPEN_CONFLICTS' &&
58
+ !isNonApiResponse(result)
54
59
  ? new StaleStatusError(message)
55
60
  : new RefusedError(message, result.status);
56
61
  }
@@ -142,16 +147,22 @@ export function withdrawPullRequestBullet(pr) {
142
147
  return `Leave the merged ${label} as it is`;
143
148
  return `Convert ${label} to a draft`;
144
149
  }
145
- const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo) => {
150
+ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo,
151
+ /** The branch conflicts with its base, so withdrawing also gets the latest changes. */
152
+ thenGetLatest = false) => {
146
153
  const bullets = [
147
154
  withdrawPullRequestBullet(pr),
148
155
  'Change the branch status back to “editing”',
149
156
  'Remove from review queue',
157
+ thenGetLatest ? 'Bring in the latest changes made on the site' : undefined,
150
158
  ].filter((b) => b !== undefined);
151
159
  openConfirm({
152
160
  title: EDITOR_ACTIONS.withdrawFromReview,
153
161
  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' },
162
+ labels: {
163
+ confirm: thenGetLatest ? WITHDRAW_AND_GET_LATEST : EDITOR_ACTIONS.withdrawFromReview,
164
+ cancel: 'Cancel',
165
+ },
155
166
  confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
156
167
  ...confirmModalHandlers(onConfirm, onDismiss),
157
168
  ...focusReturnProps(returnFocusTo),
@@ -609,8 +620,9 @@ export function useBranchManager(options) {
609
620
  notifications.show({
610
621
  id,
611
622
  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 } : {}),
623
+ // A 409, such as open conflicts, loses nothing and says what to do: yellow, until read.
624
+ color: err.status === 409 ? 'yellow' : 'red',
625
+ ...(details.note || err.status === 409 ? { autoClose: false, withCloseButton: true } : {}),
614
626
  });
615
627
  await loadBranches();
616
628
  return;
@@ -719,6 +731,12 @@ export function useBranchManager(options) {
719
731
  const handleWithdraw = (requested, returnFocusTo) => {
720
732
  const branchNameToWithdraw = listedName(requested);
721
733
  return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
734
+ // The conflict that held it in review is what the latest changes settle.
735
+ const requestLatest = clientOperatingStrategy(options.operatingMode).syncsBranchesWithBase() &&
736
+ listedBranch(branchNameToWithdraw)?.hold?.kind === 'conflicts'
737
+ ? options.latestChangesAfterWithdraw?.(branchNameToWithdraw)
738
+ : undefined;
739
+ const thenGetLatest = !!requestLatest;
722
740
  showWithdrawConfirmation(shownName(branchNameToWithdraw), pullRequestOf(branchNameToWithdraw), async () => {
723
741
  options.setBusy(true);
724
742
  try {
@@ -731,6 +749,9 @@ export function useBranchManager(options) {
731
749
  updateCreatedBranch(branchNameToWithdraw, result.data?.branch);
732
750
  await loadBranches();
733
751
  resolve();
752
+ // Reports its own outcome, so a failure here never reads as a failed withdraw.
753
+ if (requestLatest)
754
+ void requestLatest();
734
755
  }
735
756
  catch (err) {
736
757
  await reportActionFailure(err, 'Failed to withdraw branch', EDITOR_ACTIONS.withdrawFromReview);
@@ -739,7 +760,7 @@ export function useBranchManager(options) {
739
760
  finally {
740
761
  options.setBusy(false);
741
762
  }
742
- }, () => resolve(), returnFocusTo);
763
+ }, () => resolve(), returnFocusTo, thenGetLatest);
743
764
  }));
744
765
  };
745
766
  const handleArchive = (requested) => {
@@ -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;
@@ -0,0 +1,75 @@
1
+ 'use client';
2
+ /**
3
+ * The choices made on conflict cards that no save has sent yet, per entry of the open branch
4
+ * (conflict-choices.ts). A decided choice makes its entry count as unsaved, so a save writes it,
5
+ * and that save sends the conflict's key in `resolvedConflicts`. Choices belong to the branch they
6
+ * were made on, and go with a discard of the entry's drafts.
7
+ */
8
+ import { useCallback, useMemo, useRef, useState } from 'react';
9
+ const NONE = {};
10
+ export function useConflictChoices(branch) {
11
+ const [state, setState] = useState({
12
+ branch,
13
+ choices: {},
14
+ });
15
+ const choices = state.branch === branch ? state.choices : {};
16
+ const latest = useRef({ branch, choices });
17
+ latest.current = { branch, choices };
18
+ // Choices are held for the open branch only: one made after a switch starts its branch afresh,
19
+ // and a late answer about another branch leaves them alone.
20
+ const update = useCallback((forBranch, change, starts = false) => setState((prev) => {
21
+ if (prev.branch !== forBranch) {
22
+ return starts ? { branch: forBranch, choices: change({}) } : prev;
23
+ }
24
+ const next = change(prev.choices);
25
+ return next === prev.choices ? prev : { branch: forBranch, choices: next };
26
+ }), []);
27
+ const choose = useCallback((contentId, key, mergeSha, choice, decided) => update(latest.current.branch, (current) => {
28
+ const { [key]: _old, ...rest } = current[contentId] ?? {};
29
+ const entry = choice ? { ...rest, [key]: { mergeSha, choice, decided } } : rest;
30
+ const { [contentId]: _entry, ...others } = current;
31
+ return Object.keys(entry).length > 0 ? { ...others, [contentId]: entry } : others;
32
+ }, true), [update]);
33
+ const pendingIds = useMemo(() => new Set(Object.entries(choices)
34
+ .filter(([, entry]) => Object.values(entry).some((c) => c.decided))
35
+ .map(([id]) => id)), [choices]);
36
+ const pending = useCallback((forBranch, contentId) => {
37
+ if (forBranch !== latest.current.branch)
38
+ return [];
39
+ return Object.entries(latest.current.choices[contentId] ?? {})
40
+ .filter(([, c]) => c.decided)
41
+ .map(([key, c]) => ({ mergeSha: c.mergeSha, key }));
42
+ }, []);
43
+ const sent = useCallback((forBranch, contentId, resolved) => update(forBranch, (current) => {
44
+ const entry = current[contentId];
45
+ if (!entry)
46
+ return current;
47
+ const left = Object.fromEntries(Object.entries(entry).filter(([key, c]) => !resolved.some((r) => r.key === key && r.mergeSha === c.mergeSha)));
48
+ const { [contentId]: _sent, ...others } = current;
49
+ return Object.keys(left).length > 0 ? { ...others, [contentId]: left } : others;
50
+ }), [update]);
51
+ const clear = useCallback((contentId) => update(latest.current.branch, (current) => {
52
+ if (contentId === undefined)
53
+ return Object.keys(current).length > 0 ? {} : current;
54
+ if (!(contentId in current))
55
+ return current;
56
+ const { [contentId]: _cleared, ...others } = current;
57
+ return others;
58
+ }), [update]);
59
+ const retain = useCallback((contentId, open) => update(latest.current.branch, (current) => {
60
+ const entry = current[contentId];
61
+ if (!entry)
62
+ return current;
63
+ const kept = Object.entries(entry).filter(([key, c]) => open.some((o) => o.key === key && o.mergeSha === c.mergeSha));
64
+ if (kept.length === Object.keys(entry).length)
65
+ return current;
66
+ const { [contentId]: _stale, ...others } = current;
67
+ return kept.length > 0 ? { ...others, [contentId]: Object.fromEntries(kept) } : others;
68
+ }), [update]);
69
+ const keepEntries = useCallback((open) => update(latest.current.branch, (current) => {
70
+ const kept = Object.entries(current).filter(([id]) => open.has(id));
71
+ return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept);
72
+ }), [update]);
73
+ const choicesFor = useCallback((contentId) => choices[contentId] ?? NONE, [choices]);
74
+ return { choicesFor, choose, pendingIds, pending, sent, clear, retain, keepEntries };
75
+ }
@@ -0,0 +1,26 @@
1
+ import type { ApiClient } from '../context/index.js';
2
+ import type { EntryConflictData, MergeConflictsData } from '../../api/merge-conflicts.js';
3
+ export interface EntryConflictTarget {
4
+ logicalPath: string;
5
+ /** The entry's content id, which tells it from a collection at the same path. */
6
+ id?: string;
7
+ }
8
+ export interface ConflictsState<T> {
9
+ data: T | undefined;
10
+ /** Why the last read failed, in the server's words where it gave some. */
11
+ error: string | undefined;
12
+ isValidating: boolean;
13
+ reload: () => Promise<void>;
14
+ }
15
+ /**
16
+ * The branch's open conflicts, read while its listing names any (`conflictFiles`); undefined
17
+ * otherwise, which means none.
18
+ */
19
+ export declare function useBranchConflictsData(apiClient: Pick<ApiClient, 'conflicts'>, branch: string, conflictFiles: readonly string[] | undefined,
20
+ /** Read even with no entry named, as the editor does to learn of a hold on the branch. */
21
+ always?: boolean): ConflictsState<MergeConflictsData>;
22
+ /**
23
+ * One entry's conflicts, read while `target` is set (its drawer is open): null when it has none,
24
+ * which the API answers with a 404.
25
+ */
26
+ export declare function useEntryConflictData(apiClient: Pick<ApiClient, 'conflicts'>, branch: string, target: EntryConflictTarget | undefined): ConflictsState<EntryConflictData | null>;
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+ /**
3
+ * SWR-backed reads of the conflicts a merge left on a branch (api/merge-conflicts.ts): the list,
4
+ * for Submit's reason, the bar's badge and an entry's notice, and one entry's conflicts for its
5
+ * "Show changes" drawer. A null key fetches nothing.
6
+ */
7
+ import { useCallback, useEffect, useRef } from 'react';
8
+ import useSWR, { useSWRConfig } from 'swr';
9
+ const KEY_PREFIX = 'canopy:conflicts:';
10
+ /**
11
+ * The list's key, one per branch, so a re-read keeps the last answer showing until it lands: a
12
+ * branch's hold must not blink off while its list is read again.
13
+ */
14
+ const listKey = (branch) => `${KEY_PREFIX}${branch}|`;
15
+ /** An entry's key, under its branch's prefix and a `:`, which no branch name holds. */
16
+ const entryKey = (branch, target) => `${KEY_PREFIX}${branch}:${target.logicalPath}${target.id ? `#${target.id}` : ''}`;
17
+ class ConflictsLoadError extends Error {
18
+ }
19
+ /**
20
+ * The branch's open conflicts, read while its listing names any (`conflictFiles`); undefined
21
+ * otherwise, which means none.
22
+ */
23
+ export function useBranchConflictsData(apiClient, branch, conflictFiles,
24
+ /** Read even with no entry named, as the editor does to learn of a hold on the branch. */
25
+ always = false) {
26
+ const key = branch && (always || conflictFiles?.length) ? listKey(branch) : null;
27
+ const swr = useSWR(key, async () => {
28
+ const result = await apiClient.conflicts.list({ branch });
29
+ if (result.ok && result.data && Array.isArray(result.data.conflicts))
30
+ return result.data;
31
+ throw new ConflictsLoadError(result.error ?? `HTTP ${result.status}`);
32
+ });
33
+ const { mutate } = swr;
34
+ // A listing that changes what is open (`conflictFiles`) reads the list again.
35
+ const open = (conflictFiles ?? []).join(',');
36
+ const lastOpen = useRef(open);
37
+ useEffect(() => {
38
+ if (lastOpen.current === open)
39
+ return;
40
+ lastOpen.current = open;
41
+ if (key)
42
+ void mutate();
43
+ }, [open, key, mutate]);
44
+ const { mutate: mutateKeys } = useSWRConfig();
45
+ const reload = useCallback(async () => {
46
+ const prefix = `${KEY_PREFIX}${branch}`;
47
+ await Promise.all([
48
+ mutate(),
49
+ mutateKeys((k) => typeof k === 'string' && (k.startsWith(`${prefix}:`) || k.startsWith(`${prefix}|`))),
50
+ ]);
51
+ }, [mutate, mutateKeys, branch]);
52
+ return {
53
+ data: key ? swr.data : undefined,
54
+ error: key && swr.error instanceof Error ? swr.error.message : undefined,
55
+ isValidating: swr.isValidating,
56
+ reload,
57
+ };
58
+ }
59
+ /**
60
+ * One entry's conflicts, read while `target` is set (its drawer is open): null when it has none,
61
+ * which the API answers with a 404.
62
+ */
63
+ export function useEntryConflictData(apiClient, branch, target) {
64
+ const key = branch && target ? entryKey(branch, target) : null;
65
+ const swr = useSWR(key, async () => {
66
+ if (!target)
67
+ return null;
68
+ const result = await apiClient.conflicts.entry({
69
+ branch,
70
+ entryPath: target.logicalPath,
71
+ ...(target.id && { id: target.id }),
72
+ });
73
+ if (result.ok && result.data && Array.isArray(result.data.fields))
74
+ return result.data;
75
+ if (result.status === 404)
76
+ return null;
77
+ throw new ConflictsLoadError(result.error ?? `HTTP ${result.status}`);
78
+ });
79
+ const { mutate } = swr;
80
+ const reload = useCallback(async () => {
81
+ await mutate();
82
+ }, [mutate]);
83
+ return {
84
+ data: key ? swr.data : undefined,
85
+ error: key && swr.error instanceof Error ? swr.error.message : undefined,
86
+ isValidating: swr.isValidating,
87
+ reload,
88
+ };
89
+ }
@@ -63,6 +63,11 @@ export interface UseDraftManagerOptions {
63
63
  followMovedEntry?: (entry: EditorEntry) => Promise<EditorEntry | undefined>;
64
64
  /** Creates a deleted entry again from its draft (useEntryManager's `recreateEntry`). */
65
65
  recreateEntry?: (entry: EditorEntry, value: FormValue) => Promise<EditorEntry | undefined>;
66
+ /**
67
+ * Entries with a conflict choice a save has yet to send (useConflictChoices.ts): unsaved even
68
+ * when the choice left the value as it was, such as "Keep your version".
69
+ */
70
+ pendingIds?: ReadonlySet<string>;
66
71
  }
67
72
  /** The unsaved work a branch-level action would leave behind. `labels` names the entries (it may be shorter than `count`). */
68
73
  export interface UnsavedSummary {