canopycms 0.12.1 → 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 (93) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/api/branch-review.d.ts +20 -2
  3. package/dist/api/branch-review.js +112 -23
  4. package/dist/api/branch-status.d.ts +11 -2
  5. package/dist/api/branch-status.js +3 -2
  6. package/dist/api/branch-update-from-base.js +57 -6
  7. package/dist/api/branch.d.ts +5 -0
  8. package/dist/api/branch.js +27 -2
  9. package/dist/api/client.d.ts +2 -2
  10. package/dist/api/client.js +2 -2
  11. package/dist/api/content.js +1 -1
  12. package/dist/api/merge-conflicts.d.ts +10 -1
  13. package/dist/api/merge-conflicts.js +42 -7
  14. package/dist/api/types.d.ts +3 -2
  15. package/dist/base-update.d.ts +6 -0
  16. package/dist/branch-metadata-file.d.ts +15 -0
  17. package/dist/branch-metadata-file.js +5 -1
  18. package/dist/cli/cli.js +238 -108
  19. package/dist/cli/generate-ai-content.js +20 -6
  20. package/dist/cli/github-app-manifest.d.ts +4 -1
  21. package/dist/cli/github-app-manifest.js +5 -1
  22. package/dist/cli/init.js +7 -1
  23. package/dist/editor/Editor.js +39 -16
  24. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  25. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  26. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  27. package/dist/editor/base-update-wait.d.ts +4 -0
  28. package/dist/editor/base-update-wait.js +7 -0
  29. package/dist/editor/components/ConflictsGroup.d.ts +13 -1
  30. package/dist/editor/components/ConflictsGroup.js +52 -12
  31. package/dist/editor/components/EditorHeader.d.ts +3 -1
  32. package/dist/editor/components/EditorHeader.js +11 -7
  33. package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
  34. package/dist/editor/components/EntryChangesDrawer.js +58 -6
  35. package/dist/editor/conflict-cards.d.ts +4 -1
  36. package/dist/editor/conflict-cards.js +14 -9
  37. package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
  38. package/dist/editor/hooks/useBaseUpdate.js +12 -2
  39. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  40. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  42. package/dist/editor/hooks/useBranchManager.js +80 -6
  43. package/dist/editor/hooks/useDraftManager.d.ts +8 -1
  44. package/dist/editor/hooks/useDraftManager.js +16 -1
  45. package/dist/editor/hooks/useEntryManager.d.ts +8 -3
  46. package/dist/editor/hooks/useEntryManager.js +6 -5
  47. package/dist/git-manager.d.ts +1 -0
  48. package/dist/git-manager.js +8 -3
  49. package/dist/merge/entry-merge.js +4 -3
  50. package/dist/merge/list-alignment.d.ts +9 -6
  51. package/dist/merge/list-alignment.js +65 -16
  52. package/dist/merge/rekey.d.ts +11 -0
  53. package/dist/merge/rekey.js +75 -0
  54. package/dist/operating-mode/client-safe-strategy.js +1 -1
  55. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  56. package/dist/operating-mode/types.d.ts +10 -2
  57. package/dist/services.d.ts +12 -4
  58. package/dist/services.js +56 -0
  59. package/dist/types.d.ts +35 -3
  60. package/dist/utils/git.d.ts +7 -0
  61. package/dist/utils/git.js +35 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/worker/branch-merge.d.ts +27 -3
  65. package/dist/worker/branch-merge.js +106 -5
  66. package/dist/worker/branch-policy.d.ts +18 -2
  67. package/dist/worker/branch-policy.js +36 -0
  68. package/dist/worker/cms-worker.d.ts +21 -0
  69. package/dist/worker/cms-worker.js +29 -4
  70. package/dist/worker/conflicts-status.d.ts +20 -0
  71. package/dist/worker/conflicts-status.js +204 -0
  72. package/dist/worker/field-merge.d.ts +17 -2
  73. package/dist/worker/field-merge.js +95 -2
  74. package/dist/worker/git-sync.d.ts +3 -2
  75. package/dist/worker/git-sync.js +9 -0
  76. package/dist/worker/github-commits.d.ts +3 -3
  77. package/dist/worker/github-commits.js +192 -40
  78. package/dist/worker/github-errors.d.ts +15 -0
  79. package/dist/worker/github-errors.js +47 -0
  80. package/dist/worker/github-gateway-http.js +7 -0
  81. package/dist/worker/github-gateway-wire.d.ts +30 -10
  82. package/dist/worker/github-gateway-wire.js +14 -2
  83. package/dist/worker/github-gateway.d.ts +17 -0
  84. package/dist/worker/github-gateway.js +37 -1
  85. package/dist/worker/history-rewrite.d.ts +40 -8
  86. package/dist/worker/history-rewrite.js +51 -23
  87. package/dist/worker/publish-decision.d.ts +37 -0
  88. package/dist/worker/publish-decision.js +16 -0
  89. package/dist/worker/rebase.d.ts +25 -5
  90. package/dist/worker/rebase.js +225 -81
  91. package/dist/worker/task-runner.js +16 -39
  92. package/dist/worker/worker-context.d.ts +6 -0
  93. package/package.json +1 -1
@@ -1214,7 +1214,7 @@ var init_client_safe_strategy = __esm({
1214
1214
  return false;
1215
1215
  }
1216
1216
  syncsBranchesWithBase() {
1217
- return false;
1217
+ return true;
1218
1218
  }
1219
1219
  getPermissionsFileName() {
1220
1220
  return "permissions.json";
@@ -1405,6 +1405,9 @@ var init_client_unsafe_strategy = __esm({
1405
1405
  autoDetectRemotePath: path9.join(this.getWorkspaceRoot(), "remote.git")
1406
1406
  };
1407
1407
  }
1408
+ baseUpdateRunsInRequest() {
1409
+ return false;
1410
+ }
1408
1411
  requiresExistingRepo() {
1409
1412
  return false;
1410
1413
  }
@@ -1457,6 +1460,9 @@ var init_client_unsafe_strategy = __esm({
1457
1460
  envVarName: "CANOPYCMS_REMOTE_URL"
1458
1461
  };
1459
1462
  }
1463
+ baseUpdateRunsInRequest() {
1464
+ return true;
1465
+ }
1460
1466
  requiresExistingRepo() {
1461
1467
  return false;
1462
1468
  }
@@ -8031,6 +8037,7 @@ var branchMetadataShape = {
8031
8037
  archivedAt: z7.string().optional(),
8032
8038
  rebaseFailure: z7.object({ message: z7.string(), firstAt: z7.string(), lastAt: z7.string() }).optional(),
8033
8039
  historyRewrittenFrom: z7.string().optional(),
8040
+ historyRewrittenTo: z7.string().optional(),
8034
8041
  lastGitHubTip: z7.string().optional(),
8035
8042
  keptOverGitHub: z7.array(z7.string()).optional(),
8036
8043
  rewrittenOnGitHub: z7.string().optional(),
@@ -8045,13 +8052,16 @@ var branchMetadataShape = {
8045
8052
  z7.object({ kind: z7.literal("conflicts") }),
8046
8053
  z7.object({ kind: z7.literal("retargeted"), base: z7.string() })
8047
8054
  ]).optional().catch(void 0),
8048
- // Dropped when malformed: it only decorates the PR body, so it never fails a read.
8055
+ // Dropped when malformed, never failing a read: it decorates the PR body, and without `commit`
8056
+ // the worker judges a move against the clone's head before it (worker/github-commits.ts).
8049
8057
  review: z7.object({
8050
8058
  state: z7.enum(["approved", "changes-requested"]),
8051
8059
  by: z7.object({ userId: z7.string(), name: z7.string().optional() }),
8052
- at: z7.string()
8060
+ at: z7.string(),
8061
+ commit: z7.string().optional()
8053
8062
  }).optional().catch(void 0),
8054
8063
  baseUpdateRequestedAt: z7.string().optional().catch(void 0),
8064
+ latestChangesWaiting: z7.string().optional().catch(void 0),
8055
8065
  // Dropped when malformed: it only ends the editor's wait, which then times out instead.
8056
8066
  lastBaseUpdate: z7.object({
8057
8067
  at: z7.string(),
@@ -10312,10 +10322,14 @@ var GitManager = class _GitManager {
10312
10322
  const status = await this.status();
10313
10323
  return status.files.map((f) => f.path);
10314
10324
  }
10325
+ /** The remote's URL as configured; `git remote -v`'s listing would split a path with a space. */
10315
10326
  async getRemoteUrl() {
10316
- const remotes = await this.git.getRemotes(true);
10317
- const remote = remotes.find((r) => r.name === this.remote);
10318
- return remote?.refs.push || remote?.refs.fetch;
10327
+ try {
10328
+ const url = (await this.git.raw(["remote", "get-url", this.remote])).trim();
10329
+ return url === "" ? void 0 : url;
10330
+ } catch {
10331
+ return void 0;
10332
+ }
10319
10333
  }
10320
10334
  /**
10321
10335
  * Excludes `pattern` (e.g. `.canopy-meta/`) from this workspace's git, so it
@@ -11,7 +11,7 @@ export type PermissionSet = Readonly<Record<string, PermissionLevel>>;
11
11
  * The App's entire security surface. Every entry names the call site that
12
12
  * forces it — a permission justified only in a commit message can't be
13
13
  * re-checked. Enumerated from `github-service.ts`,
14
- * `worker/{task-runner,rebase}.ts`, and every git call reaching github.com;
14
+ * `worker/{task-runner,rebase,conflicts-status}.ts`, and every git call reaching github.com;
15
15
  * `canopycms-cdk` makes no REST calls at all.
16
16
  * - `contents: write` — git over HTTPS (first-boot clone, per-cycle branch
17
17
  * fetch, pushes) and `octokit.git.deleteRef`. GitHub's reference puts ref
@@ -21,6 +21,9 @@ export type PermissionSet = Readonly<Record<string, PermissionLevel>>;
21
21
  * - `pull_requests: write` — `pulls.create`/`pulls.update`, and the GraphQL
22
22
  * mutations `markPullRequestReadyForReview`/`convertPullRequestToDraft`,
23
23
  * derived by analogy since GitHub's reference has no line for them.
24
+ * - `statuses: write` — `repos.createCommitStatus`, in worker/github-gateway.ts
25
+ * `setConflictsStatus`: the "CanopyCMS conflicts" status the git sync sets on
26
+ * each pull request's head (worker/conflicts-status.ts).
24
27
  * - `metadata: read` — implied by any repository permission; declared so
25
28
  * this object states the whole surface, not just the non-automatic part.
26
29
  * Nothing else: no `issues`, `administration`, `actions`, or organisation
@@ -9,7 +9,7 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
9
9
  * The App's entire security surface. Every entry names the call site that
10
10
  * forces it — a permission justified only in a commit message can't be
11
11
  * re-checked. Enumerated from `github-service.ts`,
12
- * `worker/{task-runner,rebase}.ts`, and every git call reaching github.com;
12
+ * `worker/{task-runner,rebase,conflicts-status}.ts`, and every git call reaching github.com;
13
13
  * `canopycms-cdk` makes no REST calls at all.
14
14
  * - `contents: write` — git over HTTPS (first-boot clone, per-cycle branch
15
15
  * fetch, pushes) and `octokit.git.deleteRef`. GitHub's reference puts ref
@@ -19,6 +19,9 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
19
19
  * - `pull_requests: write` — `pulls.create`/`pulls.update`, and the GraphQL
20
20
  * mutations `markPullRequestReadyForReview`/`convertPullRequestToDraft`,
21
21
  * derived by analogy since GitHub's reference has no line for them.
22
+ * - `statuses: write` — `repos.createCommitStatus`, in worker/github-gateway.ts
23
+ * `setConflictsStatus`: the "CanopyCMS conflicts" status the git sync sets on
24
+ * each pull request's head (worker/conflicts-status.ts).
22
25
  * - `metadata: read` — implied by any repository permission; declared so
23
26
  * this object states the whole surface, not just the non-automatic part.
24
27
  * Nothing else: no `issues`, `administration`, `actions`, or organisation
@@ -30,6 +33,7 @@ const PERMISSION_LEVELS = ['read', 'write', 'admin'];
30
33
  export const CANOPY_APP_PERMISSIONS = {
31
34
  contents: 'write',
32
35
  pull_requests: 'write',
36
+ statuses: 'write',
33
37
  metadata: 'read',
34
38
  };
35
39
  /**
package/dist/cli/init.js CHANGED
@@ -1588,7 +1588,7 @@ var DevClientSafeStrategy = class {
1588
1588
  return false;
1589
1589
  }
1590
1590
  syncsBranchesWithBase() {
1591
- return false;
1591
+ return true;
1592
1592
  }
1593
1593
  getPermissionsFileName() {
1594
1594
  return "permissions.json";
@@ -1777,6 +1777,9 @@ var ProdStrategy = class extends ProdClientSafeStrategy {
1777
1777
  autoDetectRemotePath: path.join(this.getWorkspaceRoot(), "remote.git")
1778
1778
  };
1779
1779
  }
1780
+ baseUpdateRunsInRequest() {
1781
+ return false;
1782
+ }
1780
1783
  requiresExistingRepo() {
1781
1784
  return false;
1782
1785
  }
@@ -1829,6 +1832,9 @@ var DevStrategy = class extends DevClientSafeStrategy {
1829
1832
  envVarName: "CANOPYCMS_REMOTE_URL"
1830
1833
  };
1831
1834
  }
1835
+ baseUpdateRunsInRequest() {
1836
+ return true;
1837
+ }
1832
1838
  requiresExistingRepo() {
1833
1839
  return false;
1834
1840
  }
@@ -197,6 +197,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
197
197
  const requestLatestRef = useRef(async () => { });
198
198
  const modifiedCountRef = useRef(0);
199
199
  const namesBranchRef = useRef(() => false);
200
+ // Set once the entries and the changes read exist.
201
+ const rereadPinnedBranchRef = useRef(async () => { });
200
202
  const { branchName: branchNameState, setBranchName, branches, branchSummaries, currentBranch, currentAddress, listedBranch, namesBranch, urlBranchName, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleArchive, handleReloadBranchData, loadBranches, } = useBranchManager({
201
203
  initialBranch: branchName,
202
204
  operatingMode,
@@ -206,6 +208,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
206
208
  getUnsaved: () => resolveUnsavedRef.current(),
207
209
  onStamped: (stamped) => applySubmitStampsRef.current(stamped),
208
210
  onChangesRequested: (branch) => reloadCommentsRef.current(branch),
211
+ onReviewPinned: (branch) => rereadPinnedBranchRef.current(branch),
209
212
  // Only the open branch, with nothing unsaved: its wait and answer show where the user is.
210
213
  latestChangesAfterWithdraw: (branch) => namesBranchRef.current(branch) && modifiedCountRef.current === 0
211
214
  ? () => requestLatestRef.current()
@@ -270,7 +273,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
270
273
  // Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
271
274
  const switchBranchRef = useRef(async () => { });
272
275
  // Set once the changes read exists; it waits on the panel state, declared after the entries.
273
- const reloadChangesRef = useRef(() => { });
276
+ const reloadChangesRef = useRef(async () => { });
277
+ const rereadChangesRef = useRef(async () => { });
274
278
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
275
279
  const { selectedPath, setSelectedPath, closeEntry, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, entriesLoadFailed, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
276
280
  initialEntries: entries,
@@ -291,7 +295,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
291
295
  // A short name in the URL is no switch to another branch.
292
296
  sameBranch: namesBranch,
293
297
  urlBranchName,
294
- onContentRefresh: (branch) => reloadChangesRef.current(branch),
298
+ onContentRefresh: (branch) => void reloadChangesRef.current(branch),
295
299
  conflictResolutions: {
296
300
  pending: conflictChoices.pending,
297
301
  onSaved: (branch, contentId, sent) => {
@@ -376,7 +380,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
376
380
  setPreviewError(null);
377
381
  }, [currentEntry?.contentId]);
378
382
  // 3. Draft manager (depends on branchNameState, selectedPath from useEntryManager)
379
- const { drafts, setDrafts, loadedValues, setLoadedValues, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, singleSaving, lastSaved, handleSaveAll, saveAllProgress, isSaveAllRunning, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, isDirtyForEntry, resolveUnsaved, fieldErrors, draftStorageFailed, applySubmitStamps, } = useDraftManager({
383
+ const { drafts, setDrafts, loadedValues, setLoadedValues, forgetLoadedValues, readGeneration, loadedValue, effectiveValue, modifiedCount, editedFiles, handleSave, singleSaving, lastSaved, handleSaveAll, saveAllProgress, isSaveAllRunning, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, isDirtyForEntry, resolveUnsaved, fieldErrors, draftStorageFailed, applySubmitStamps, } = useDraftManager({
380
384
  branchName: branchNameState,
381
385
  selectedPath,
382
386
  currentEntry,
@@ -420,6 +424,17 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
420
424
  onShowConflicts: () => showConflictsRef.current(),
421
425
  });
422
426
  requestLatestRef.current = baseUpdate.request;
427
+ // The open branch was pinned for its reviews: its entries, their changes and every clean entry
428
+ // are read again, every one while it is locked, which shows the saved copy over a draft.
429
+ rereadPinnedBranchRef.current = async (branch) => {
430
+ if (!namesBranch(branch, branchNameState))
431
+ return;
432
+ forgetLoadedValues((id) => !branchContentLocked && draftsRef.current[id] !== undefined);
433
+ await Promise.all([
434
+ refreshEntries(branchNameState, { withChanges: false }),
435
+ rereadChangesRef.current(),
436
+ ]);
437
+ };
423
438
  modifiedCountRef.current = modifiedCount;
424
439
  namesBranchRef.current = (branch) => namesBranch(branch, branchNameState);
425
440
  // The request in flight counts as waiting: a save landing before the worker reads the branch
@@ -792,6 +807,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
792
807
  // write still the state of the branch on screen?".
793
808
  const requestBranch = branchNameState;
794
809
  const inFlightKey = `${requestBranch}:${contentId ?? ''}`;
810
+ const generation = readGeneration.current;
811
+ const loadingKey = `${inFlightKey}#${generation}`;
795
812
  const load = async () => {
796
813
  if (!currentEntry || !contentId || loadedValues[contentId] !== undefined)
797
814
  return;
@@ -801,13 +818,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
801
818
  // Guard against overlapping renders re-firing the same in-flight load
802
819
  // (e.g. `currentEntry` getting a new object reference from an entries
803
820
  // refresh while the fetch below is still pending).
804
- if (loadingEntryIdsRef.current.has(inFlightKey))
821
+ if (loadingEntryIdsRef.current.has(loadingKey))
805
822
  return;
806
- loadingEntryIdsRef.current.add(inFlightKey);
823
+ loadingEntryIdsRef.current.add(loadingKey);
807
824
  setEntriesLoading(true);
808
825
  setLoadFailedKey((failed) => (failed === inFlightKey ? null : failed));
809
826
  try {
810
- const loaded = await loadEntry(currentEntry);
827
+ const loaded = await loadEntry(currentEntry, () => readGeneration.current === generation);
811
828
  // A branch switch during the await makes this response another
812
829
  // branch's content. Writing it would (a) show it as the new branch's
813
830
  // content and (b) satisfy the skip gate above, permanently suppressing
@@ -816,6 +833,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
816
833
  // instead; the new branch has its own load running under its own key.
817
834
  if (currentBranchRef.current !== requestBranch)
818
835
  return;
836
+ if (readGeneration.current !== generation)
837
+ return;
819
838
  setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
820
839
  // No draft is seeded here. `effectiveValue` is
821
840
  // `drafts[id] ?? loadedValues[id]`, so the form renders from the line
@@ -825,7 +844,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
825
844
  // session as a dirty, stale full-content snapshot.
826
845
  }
827
846
  finally {
828
- loadingEntryIdsRef.current.delete(inFlightKey);
847
+ loadingEntryIdsRef.current.delete(loadingKey);
829
848
  // Clear the shared flag if this load's entry is still selected on the
830
849
  // branch it was loaded for, OR if no other load remains in flight. The
831
850
  // second arm matters when the user navigates from a slow-loading entry
@@ -834,7 +853,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
834
853
  // this stale settle would leave `entriesLoading` (and therefore
835
854
  // `busy`) stuck true with nothing left to clear it.
836
855
  if ((currentBranchRef.current === requestBranch &&
837
- currentContentIdRef.current === contentId) ||
856
+ currentContentIdRef.current === contentId &&
857
+ readGeneration.current === generation) ||
838
858
  loadingEntryIdsRef.current.size === 0) {
839
859
  setEntriesLoading(false);
840
860
  }
@@ -850,7 +870,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
850
870
  // Same staleness guard as above: don't tell the user an entry failed
851
871
  // to load when they've already navigated to a different one (or to a
852
872
  // different branch).
853
- if (currentBranchRef.current === requestBranch && currentContentIdRef.current === contentId) {
873
+ if (currentBranchRef.current === requestBranch &&
874
+ currentContentIdRef.current === contentId &&
875
+ readGeneration.current === generation) {
854
876
  notifications.show({ message: 'Failed to load entry', color: 'red' });
855
877
  setLoadFailedKey(inFlightKey);
856
878
  }
@@ -1348,10 +1370,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1348
1370
  const changesAvailable = !!currentBranch && currentBranch.isProtected === false;
1349
1371
  const changesRead = (contentPanelShown || !!currentEntry) && changesAvailable;
1350
1372
  const branchChanges = useBranchChangesData(apiClient, branchNameState, changesRead);
1351
- reloadChangesRef.current = (branch) => {
1373
+ reloadChangesRef.current = async (branch) => {
1352
1374
  if (branch === branchNameState)
1353
- void branchChanges.reload();
1375
+ await branchChanges.reload();
1354
1376
  };
1377
+ rereadChangesRef.current = branchChanges.reread;
1355
1378
  // Submit, withdraw and a review move the branch's status, which no entries refresh follows.
1356
1379
  const changesStatusRef = useRef({ branch: branchNameState, status: currentBranch?.status });
1357
1380
  useEffect(() => {
@@ -1424,7 +1447,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1424
1447
  focusFieldTarget(target.element);
1425
1448
  });
1426
1449
  };
1427
- const resolveEntryConflict = async (logicalPath, id, mergeSha, choice) => {
1450
+ const resolveEntryConflict = async (logicalPath, id, mergeSha, choice, entryGone) => {
1428
1451
  const branch = branchNameState;
1429
1452
  const failed = {
1430
1453
  tone: 'error',
@@ -1432,7 +1455,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1432
1455
  };
1433
1456
  let answer;
1434
1457
  try {
1435
- answer = await apiClient.conflicts.resolveEntry({ branch, entryPath: logicalPath }, { id: id, mergeSha, choice });
1458
+ answer = await apiClient.conflicts.resolveEntry({ branch, entryPath: logicalPath }, { id: id, mergeSha, choice, ...(entryGone && { entryGone }) });
1436
1459
  }
1437
1460
  catch (err) {
1438
1461
  if (!isUnanswered(err))
@@ -1513,7 +1536,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1513
1536
  });
1514
1537
  };
1515
1538
  const changesResolveEntry = changesSubject && entryConflict.data
1516
- ? async (choice) => {
1539
+ ? async (choice, { entryGone }) => {
1517
1540
  const { contentId: id, mergeSha, kind } = entryConflict.data ?? {};
1518
1541
  if (!id || !mergeSha)
1519
1542
  return undefined;
@@ -1528,7 +1551,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1528
1551
  setChangesOpen(false);
1529
1552
  return undefined;
1530
1553
  }
1531
- return resolveEntryConflict(changesSubject.logicalPath, id, mergeSha, choice);
1554
+ return resolveEntryConflict(changesSubject.logicalPath, id, mergeSha, choice, entryGone);
1532
1555
  }
1533
1556
  : undefined;
1534
1557
  const confirmEntryChoice = (target) => {
@@ -2077,7 +2100,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
2077
2100
  // fail-closed computation of "are writes blocked" in this component.
2078
2101
  branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, openConflicts: openConflictCount, conflictRecordHeld: branchConflicts.data?.held
2079
2102
  ? { ...(userIsAdmin && { onRelease: handleReleaseConflictsHold }) }
2080
- : undefined, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, baseUpdate: baseUpdateControl, latestWait: latestWait, onBranchReloadData: () => {
2103
+ : undefined, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, baseUpdate: baseUpdateControl, latestWait: latestWait, latestChangesWaiting: baseUpdate.latestChangesWaiting, onBranchReloadData: () => {
2081
2104
  void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
2082
2105
  }, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
2083
2106
  // On any entry: EntryHeader's `saving` is the open entry's alone.
@@ -0,0 +1,8 @@
1
+ import type { ConflictsStatusFailure } from '../../types.js';
2
+ /**
3
+ * The last git sync could not set the "CanopyCMS conflicts" status on some pull requests
4
+ * (worker/conflicts-status.ts).
5
+ */
6
+ export declare function ConflictsStatusAlert({ failure }: {
7
+ failure: ConflictsStatusFailure;
8
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Alert, Code, Group, List, Stack, Text } from '@mantine/core';
4
+ import { IconAlertTriangle } from '@tabler/icons-react';
5
+ import { escapeForLog } from '../../utils/escape-text.js';
6
+ import { RelativeTime } from '../components/RelativeTime.js';
7
+ /** A long unbroken name wraps inside the alert rather than widening it. */
8
+ const WRAP = { overflowWrap: 'anywhere' };
9
+ const WHY = {
10
+ permission: (_jsxs(_Fragment, { children: [_jsx(Text, { size: "sm", children: "GitHub refused it: the worker's GitHub credential lacks the \"Commit statuses\" permission." }), _jsxs(List, { size: "sm", spacing: 2, children: [_jsxs(List.Item, { children: ["A GitHub App: its owner adds \"Commit statuses: read and write\" in the App's permissions on GitHub, then an owner of the account it is installed on accepts the request in the installation's settings.", ' ', _jsx(Code, { style: { whiteSpace: 'nowrap' }, children: "canopycms init-github-app verify" }), " confirms it."] }), _jsx(List.Item, { children: "A fine-grained token: give it \"Commit statuses: read and write\". A classic token's repo scope already has it." })] })] })),
11
+ unreachable: (_jsx(Text, { size: "sm", children: "GitHub couldn't be reached, or was busy. The next git sync tries again." })),
12
+ other: (_jsx(Text, { size: "sm", children: "GitHub refused it, or the branch's record of conflicts couldn't be read. The next git sync tries again; if it keeps failing, the worker log names each branch and why." })),
13
+ };
14
+ /**
15
+ * The last git sync could not set the "CanopyCMS conflicts" status on some pull requests
16
+ * (worker/conflicts-status.ts).
17
+ */
18
+ export function ConflictsStatusAlert({ failure }) {
19
+ const unlisted = failure.count - failure.branches.length;
20
+ const pullRequests = `${failure.count} pull request${failure.count === 1 ? '' : 's'}`;
21
+ return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `Conflicts status not set on ${pullRequests}`, "data-testid": "conflicts-status-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "The \"CanopyCMS conflicts\" check on the pull request of each branch below keeps its last result, or has none yet. Where it is a required check, merging waits on it. Saving, submitting and pull request updates are unaffected." }), WHY[failure.reason], _jsx(Group, { gap: 4, children: failure.branches.map((name) => (_jsx(Code, { style: WRAP, children: escapeForLog(name) }, name))) }), unlisted > 0 && _jsxs(Text, { size: "sm", children: ["And ", unlisted, " more."] }), _jsxs(Text, { size: "xs", c: "dimmed", style: WRAP, children: ["The last error: \u201C", escapeForLog(failure.message), "\u201D"] }), _jsx(RelativeTime, { iso: failure.at, prefix: "Last tried ", size: "xs", c: "dimmed" })] }) }));
22
+ }
@@ -21,6 +21,7 @@ import { ageDeletionHold, archivedBefore, archivedCutoff, deletableByAge, } from
21
21
  import { escapeForLog } from '../../utils/escape-text.js';
22
22
  import { TooltipDetail } from '../components/TooltipDetail.js';
23
23
  import { DISMISS_UNPUBLISHED_ATTRIBUTE, UnpublishedAtMergeAlert } from './UnpublishedAtMergeAlert.js';
24
+ import { ConflictsStatusAlert } from './ConflictsStatusAlert.js';
24
25
  import { HOLD_BADGE, branchHoldMessage, branchStatusPresentation, endSentence, } from '../branch-status.js';
25
26
  // ============================================================================
26
27
  // Small pure helpers
@@ -132,7 +133,7 @@ function BaseHoldAlert({ hold }) {
132
133
  * and never listed. Names come from GitHub and are attacker-chosen, so each is shown escaped.
133
134
  */
134
135
  function DivergedBranchesAlert({ names }) {
135
- return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree. A branch that only gained commits on GitHub clears on its own at the next sync." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
136
+ return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree. A branch that only gained commits on GitHub clears on its own at the next sync." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { style: { overflowWrap: 'anywhere' }, children: escapeForLog(name) }, name))) })] }) }));
136
137
  }
137
138
  /**
138
139
  * The branch namespace the CMS creates branches in, against the one the GitHub gateway writes. A
@@ -150,7 +151,7 @@ function BranchNamespaceAlert({ own, gateway, }) {
150
151
  }
151
152
  /** Branch refs from metadata the CMS Lambda can write, so each is shown escaped. */
152
153
  function BranchRefs({ branches }) {
153
- return (_jsx(Group, { gap: 4, children: branches.map((branch, i) => (_jsx(Code, { children: escapeForLog(branch.name) }, i))) }));
154
+ return (_jsx(Group, { gap: 4, children: branches.map((branch, i) => (_jsx(Code, { style: { overflowWrap: 'anywhere' }, children: escapeForLog(branch.name) }, i))) }));
154
155
  }
155
156
  /**
156
157
  * Editor branches the GitHub gateway will not publish, by ref, which is what a developer lists
@@ -307,7 +308,7 @@ function OverviewTab({ health, overviewTabRef, }) {
307
308
  workerVersion !== build.canopycmsVersion;
308
309
  return (_jsxs(Stack, { ref: overviewRef, gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
309
310
  ? 'The worker failed to start'
310
- : 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), unpublished.length > 0 && (_jsx(UnpublishedAtMergeAlert, { branches: unpublished, onDismiss: handleDismissUnpublished })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
311
+ : 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), unpublished.length > 0 && (_jsx(UnpublishedAtMergeAlert, { branches: unpublished, onDismiss: handleDismissUnpublished })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.conflictsStatusFailure && (_jsx(ConflictsStatusAlert, { failure: status.workerStatus.conflictsStatusFailure })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
311
312
  ` · drain deadline hit, aborted ${lastShutdown.abandoned?.join(', ') ?? 'in-flight work'}`, lastShutdown.outcome === 'drained' &&
312
313
  lastShutdown.drainMs !== undefined &&
313
314
  ` · drained in ${(lastShutdown.drainMs / 1000).toFixed(1)}s`, lastShutdown.outcome === 'not-drained' && ' (a crash or a forced stop)'] })), lastFatalError?.phase === 'startup' && (_jsxs(Text, { size: "xs", c: "orange", "data-testid": "build-failed-start", children: ["Last start failed at ", lastFatalError.at] })), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
@@ -38,6 +38,10 @@ export declare const GET_LATEST_CHANGES_TOOLTIP = "Bring in changes made on the
38
38
  /** The Tooltip; an admin also learns which branch the changes come from. */
39
39
  export declare function getLatestChangesTooltip(baseBranch: string | undefined, isAdmin: boolean): string;
40
40
  export declare const GETTING_LATEST_CHANGES = "Getting latest changes\u2026";
41
+ /** The bar's badge while the automatic sync leaves the base out for the branch's saved work. */
42
+ export declare const LATEST_CHANGES_WAITING = "Latest changes waiting";
43
+ /** The badge's detail: below `md` the action is in the branch menu, and unsaved changes hold it. */
44
+ export declare function latestChangesWaitingDetail(inMenu: boolean, saveFirst: boolean): string;
41
45
  export declare const SAVE_FIRST_REASON = "Save your changes first.";
42
46
  export declare const WAITING_NOTICE = "Getting the latest changes. You can edit again when it finishes.";
43
47
  export declare const SLOW_NOTICE = "This is taking longer than usual. You can keep editing; the latest changes will come in when the server gets to them.";
@@ -50,6 +50,13 @@ export function getLatestChangesTooltip(baseBranch, isAdmin) {
50
50
  : GET_LATEST_CHANGES_TOOLTIP;
51
51
  }
52
52
  export const GETTING_LATEST_CHANGES = 'Getting latest changes…';
53
+ /** The bar's badge while the automatic sync leaves the base out for the branch's saved work. */
54
+ export const LATEST_CHANGES_WAITING = 'Latest changes waiting';
55
+ /** The badge's detail: below `md` the action is in the branch menu, and unsaved changes hold it. */
56
+ export function latestChangesWaitingDetail(inMenu, saveFirst) {
57
+ const control = `"Get latest changes"${inMenu ? ' in the branch menu' : ''}`;
58
+ return `Newer changes made on the site are waiting for this branch. ${saveFirst ? `Save your changes, then choose ${control}` : `Choose ${control}`} to bring them in.`;
59
+ }
53
60
  export const SAVE_FIRST_REASON = 'Save your changes first.';
54
61
  export const WAITING_NOTICE = 'Getting the latest changes. You can edit again when it finishes.';
55
62
  export const SLOW_NOTICE = 'This is taking longer than usual. You can keep editing; the latest changes will come in when the server gets to them.';
@@ -56,6 +56,8 @@ export declare const LATEST_UNPLACEABLE_REASON = "The latest version of this can
56
56
  * @internal Exported for tests.
57
57
  */
58
58
  export declare function conflictHeading(card: ConflictCard): string;
59
+ /** @internal Exported for tests: how long a settled entry card waits for the reload to take it. */
60
+ export declare const SETTLED_HOLD_MS = 10000;
59
61
  /**
60
62
  * Why an entry-level choice didn't act: `warning` where nothing is lost or the outcome is unknown,
61
63
  * `error` for a failure a retry may pass.
@@ -64,6 +66,14 @@ export interface EntryChoiceRefusal {
64
66
  message: string;
65
67
  tone: 'warning' | 'error';
66
68
  }
69
+ /** An entry-level choice settled in place: what to say, and whether the entry stays deleted. */
70
+ export interface EntryChoiceDone {
71
+ message: string;
72
+ entryGone: boolean;
73
+ }
74
+ type ResolveEntryChoice = (choice: EntryConflictChoice, options: {
75
+ entryGone?: true;
76
+ }) => Promise<EntryChoiceRefusal | undefined>;
67
77
  /** The open entry's form, which field choices change. */
68
78
  interface ConflictsForm {
69
79
  value: FormValue;
@@ -85,7 +95,9 @@ export interface ConflictsGroupProps {
85
95
  /** Closes the drawer and focuses the field in the form. */
86
96
  onEdit?: (field: FieldConflict) => void;
87
97
  /** Settles an entry-level conflict; resolves with why it couldn't, when it couldn't. */
88
- onResolveEntry?: (choice: EntryConflictChoice) => Promise<EntryChoiceRefusal | undefined>;
98
+ onResolveEntry?: ResolveEntryChoice;
99
+ /** An entry-level choice settled in place, its card about to go. */
100
+ onEntryDone?: (done: EntryChoiceDone) => void;
89
101
  /** Why this user can't change the entry: the choices are hidden. */
90
102
  noEditReason?: string;
91
103
  /** Why the choices can't act now: they stay, disabled, saying so. */