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,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
+ }
@@ -25,7 +25,7 @@ export interface UseDraftManagerOptions {
25
25
  currentEntry: EditorEntry | undefined;
26
26
  entries: EditorEntry[];
27
27
  initialValues?: Record<string, FormValue>;
28
- loadEntry: (entry: EditorEntry) => Promise<FormValue>;
28
+ loadEntry: (entry: EditorEntry, isCurrent?: () => boolean) => Promise<FormValue>;
29
29
  /**
30
30
  * Server read that records no OCC token (useEntryManager's `readEntryValue`),
31
31
  * for checking restored drafts of unopened entries. Without it they stay
@@ -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 {
@@ -74,6 +79,13 @@ export interface UseDraftManagerReturn {
74
79
  setDrafts: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
75
80
  loadedValues: Record<string, FormValue>;
76
81
  setLoadedValues: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
82
+ /**
83
+ * Drops the saved copies `keep` doesn't name, so each is read again; a read already in flight
84
+ * then lands nothing, as it can predate the head the branch is pinned to.
85
+ */
86
+ forgetLoadedValues: (keep: (contentId: string) => boolean) => void;
87
+ /** Bumped by {@link forgetLoadedValues}: a read whose generation is gone writes nothing. */
88
+ readGeneration: React.MutableRefObject<number>;
77
89
  selectedValue: FormValue | undefined;
78
90
  loadedValue: FormValue | undefined;
79
91
  effectiveValue: FormValue | undefined;
@@ -100,6 +100,11 @@ const VERIFY_WAIT_CAP_MS = 3000;
100
100
  export function useDraftManager(options) {
101
101
  const [drafts, setDrafts] = useState(() => options.initialValues ?? {});
102
102
  const [loadedValues, setLoadedValues] = useState({});
103
+ const readGeneration = useRef(0);
104
+ const forgetLoadedValues = (keep) => {
105
+ readGeneration.current += 1;
106
+ setLoadedValues((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => keep(id))));
107
+ };
103
108
  // Per-field validation errors from a blocked/rejected save (ED-H1), keyed by
104
109
  // the entry they belong to. Keying by entry id — and deriving the exposed
105
110
  // `fieldErrors` map below instead of clearing it in a separate effect after
@@ -187,12 +192,19 @@ export function useDraftManager(options) {
187
192
  //
188
193
  // 3. The comparison is `fast-deep-equal`, so key insertion order of a
189
194
  // rehydrated draft does not make it dirty.
190
- const dirtyIds = useMemo(() => Object.keys(drafts).filter((id) => {
191
- if (entriesKnown && !entryById.has(id))
192
- return false;
193
- const baseline = loadedValues[id] ?? verifiedBaselines[id];
194
- return baseline === undefined || !equal(drafts[id], baseline);
195
- }), [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById]);
195
+ //
196
+ // 4. An entry with a conflict choice to send counts, draft or not (`pendingIds`).
197
+ const pendingIds = options.pendingIds;
198
+ const dirtyIds = useMemo(() => {
199
+ const dirty = Object.keys(drafts).filter((id) => {
200
+ if (entriesKnown && !entryById.has(id))
201
+ return false;
202
+ const baseline = loadedValues[id] ?? verifiedBaselines[id];
203
+ return baseline === undefined || !equal(drafts[id], baseline);
204
+ });
205
+ const pending = [...(pendingIds ?? [])].filter((id) => !dirty.includes(id) && (!entriesKnown || entryById.has(id)));
206
+ return pending.length > 0 ? [...dirty, ...pending] : dirty;
207
+ }, [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById, pendingIds]);
196
208
  const modifiedCount = dirtyIds.length;
197
209
  const editedFiles = useMemo(() => dirtyIds
198
210
  .map((id) => {
@@ -963,7 +975,10 @@ export function useDraftManager(options) {
963
975
  */
964
976
  const saveOneOfAll = async (entry, branch) => {
965
977
  const id = entry.contentId;
966
- const sent = latestRef.current.drafts[id];
978
+ const sent = latestRef.current.drafts[id] ??
979
+ (latestOptionsRef.current.pendingIds?.has(id)
980
+ ? latestRef.current.loadedValues[id]
981
+ : undefined);
967
982
  if (sent === undefined)
968
983
  return undefined;
969
984
  const onBranch = () => latestOptionsRef.current.branchName === branch;
@@ -1137,7 +1152,9 @@ export function useDraftManager(options) {
1137
1152
  // Discarded, refreshed or saved mid-run, so neither saved nor failed by this run.
1138
1153
  const { drafts: remaining } = latestRef.current;
1139
1154
  const settled = settledDuringRunRef.current;
1140
- result.failed = result.failed.filter((failure) => failure.contentId in remaining && !settled.has(failure.contentId));
1155
+ const pendingNow = latestOptionsRef.current.pendingIds;
1156
+ result.failed = result.failed.filter((failure) => (failure.contentId in remaining || !!pendingNow?.has(failure.contentId)) &&
1157
+ !settled.has(failure.contentId));
1141
1158
  result.total = result.saved.length + result.failed.length;
1142
1159
  if (result.saved.length > 0)
1143
1160
  latestOptionsRef.current.onSaved?.();
@@ -1263,9 +1280,17 @@ export function useDraftManager(options) {
1263
1280
  if (!options.currentEntry || !currentId)
1264
1281
  return;
1265
1282
  const branch = options.branchName;
1283
+ const entry = options.currentEntry;
1266
1284
  options.setBusy(true);
1267
1285
  try {
1268
- const loaded = await options.loadEntry(options.currentEntry);
1286
+ // A read the branch's re-read overtakes is read again, as the reload was asked for.
1287
+ let loaded;
1288
+ let generation;
1289
+ do {
1290
+ generation = readGeneration.current;
1291
+ const read = generation;
1292
+ loaded = await options.loadEntry(entry, () => readGeneration.current === read);
1293
+ } while (readGeneration.current !== generation);
1269
1294
  // Content ids repeat across branches; the next branch's state is not this read's.
1270
1295
  if (latestOptionsRef.current.branchName !== branch)
1271
1296
  return;
@@ -1351,6 +1376,8 @@ export function useDraftManager(options) {
1351
1376
  if (!entry)
1352
1377
  return false;
1353
1378
  const id = entry.contentId;
1379
+ if (pendingIds?.has(id))
1380
+ return true;
1354
1381
  if (!drafts[id])
1355
1382
  return false;
1356
1383
  const baseline = loadedValues[id] ?? verifiedBaselines[id];
@@ -1359,6 +1386,8 @@ export function useDraftManager(options) {
1359
1386
  const isSelectedDirty = () => {
1360
1387
  if (!currentId)
1361
1388
  return false;
1389
+ if (pendingIds?.has(currentId))
1390
+ return true;
1362
1391
  if (!drafts[currentId])
1363
1392
  return false;
1364
1393
  return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
@@ -1438,6 +1467,8 @@ export function useDraftManager(options) {
1438
1467
  setDrafts,
1439
1468
  loadedValues,
1440
1469
  setLoadedValues,
1470
+ forgetLoadedValues,
1471
+ readGeneration,
1441
1472
  selectedValue,
1442
1473
  loadedValue,
1443
1474
  effectiveValue,
@@ -1,3 +1,4 @@
1
+ import type { ResolvedFieldConflict } from '../../merge-conflicts.js';
1
2
  import type { ApiErrorCode } from '../../api/types.js';
2
3
  import type { EditorEntry, EditorCollection } from '../Editor.js';
3
4
  import type { LogicalPath } from '../../paths/types.js';
@@ -62,6 +63,18 @@ export interface UseEntryManagerOptions {
62
63
  * refreshes the entries, so what reads the same branch state reloads here.
63
64
  */
64
65
  onContentRefresh?: (branch: string) => void;
66
+ /**
67
+ * The conflicts a save of an entry resolves (useConflictChoices.ts): `pending` names them as the
68
+ * save sends them, and `onSaved` hears which the save sent and which the server cleared.
69
+ */
70
+ conflictResolutions?: {
71
+ pending: (branch: string, contentId: string) => ResolvedFieldConflict[];
72
+ onSaved: (branch: string, contentId: string, sent: ResolvedFieldConflict[], cleared: ResolvedFieldConflict[]) => void;
73
+ };
74
+ }
75
+ /** `withChanges: false` leaves the branch's changes to the caller, who reads them itself. */
76
+ interface RefreshOptions {
77
+ withChanges?: boolean;
65
78
  }
66
79
  export interface UseEntryManagerReturn {
67
80
  selectedPath: string;
@@ -86,15 +99,16 @@ export interface UseEntryManagerReturn {
86
99
  entriesLoadFailed: boolean;
87
100
  navigatorOpen: boolean;
88
101
  setNavigatorOpen: (open: boolean) => void;
89
- refreshEntries: (branch?: string) => Promise<EditorEntry[]>;
102
+ refreshEntries: (branch?: string, refresh?: RefreshOptions) => Promise<EditorEntry[]>;
90
103
  /** `refreshEntries`, resolving to the collections it read too. */
91
- refreshContent: (branch?: string) => Promise<{
104
+ refreshContent: (branch?: string, refresh?: RefreshOptions) => Promise<{
92
105
  entries: EditorEntry[];
93
106
  collections: EditorCollection[];
94
107
  }>;
95
108
  handleCreateEntry: (collectionPath: LogicalPath, entryTypeName?: string) => Promise<void>;
96
109
  renameEntry: (path: string, newSlug: string) => Promise<void>;
97
- loadEntry: (entry: EditorEntry) => Promise<FormValue>;
110
+ /** `isCurrent`, asked once the read lands, says whether to record its OCC token. */
111
+ loadEntry: (entry: EditorEntry, isCurrent?: () => boolean) => Promise<FormValue>;
98
112
  /** Reads an entry's server value without recording the OCC token that `loadEntry` records. */
99
113
  readEntryValue: (entry: EditorEntry) => Promise<FormValue>;
100
114
  saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
@@ -293,14 +293,14 @@ export function useEntryManager(options) {
293
293
  }
294
294
  return result.data;
295
295
  };
296
- const loadEntry = async (entry) => {
296
+ const loadEntry = async (entry, isCurrent = () => true) => {
297
297
  // Pin the branch this request targets: if the user switches branches
298
298
  // while the read is in flight, the token must be recorded under the
299
299
  // branch that actually served it, not the current one.
300
300
  const requestBranch = options.branchName;
301
301
  const data = await readEntryPayload(entry, requestBranch);
302
302
  // Capture OCC version token for next save
303
- if (entry.contentId && typeof data?.version === 'number') {
303
+ if (entry.contentId && typeof data?.version === 'number' && isCurrent()) {
304
304
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), data.version);
305
305
  }
306
306
  return normalizeContentPayload(data);
@@ -339,15 +339,20 @@ export function useEntryManager(options) {
339
339
  if (expectedVersion === undefined) {
340
340
  throw new SaveApiError(409, "This entry hasn't finished loading, so it can't be saved yet. Reload it and try again (your unsaved edits will be lost).");
341
341
  }
342
+ const resolving = options.conflictResolutions?.pending(requestBranch, entry.contentId) ?? [];
342
343
  const writeBody = {
343
344
  ...payload,
344
345
  expectedVersion,
346
+ ...(resolving.length > 0 && { resolvedConflicts: resolving }),
345
347
  };
346
348
  const result = await apiClient.content.write(writeParams, writeBody);
347
349
  if (isGatewayFailure(result))
348
350
  throw new UnansweredError();
349
351
  if (!result.ok)
350
352
  throw new SaveApiError(result.status, result.error, result.fieldErrors, result.code);
353
+ if (resolving.length > 0) {
354
+ options.conflictResolutions?.onSaved(requestBranch, entry.contentId, resolving, result.data?.resolvedConflicts ?? []);
355
+ }
351
356
  // Update stored version token from write response
352
357
  if (typeof result.data?.version === 'number') {
353
358
  entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
@@ -454,10 +459,11 @@ export function useEntryManager(options) {
454
459
  // refreshSeqRef doc comment above; also warms the SWR cache for `branch`
455
460
  // so a later automatic re-fetch of the same key (e.g. switching away and
456
461
  // back) can reuse it instead of refetching.
457
- const refreshContent = async (branch = options.branchName) => {
462
+ const refreshContent = async (branch = options.branchName, { withChanges = true } = {}) => {
458
463
  if (!branch)
459
464
  return { entries: [], collections: [] };
460
- options.onContentRefresh?.(branch);
465
+ if (withChanges)
466
+ options.onContentRefresh?.(branch);
461
467
  const seq = claimRefreshSeq(branch);
462
468
  const fetched = await fetchEntriesAndSchema(apiClient, branch, {
463
469
  resolvePreviewSrc: options.resolvePreviewSrc,
@@ -477,7 +483,7 @@ export function useEntryManager(options) {
477
483
  }
478
484
  return fetched;
479
485
  };
480
- const refreshEntries = async (branch = options.branchName) => (await refreshContent(branch)).entries;
486
+ const refreshEntries = async (branch = options.branchName, refresh) => (await refreshContent(branch, refresh)).entries;
481
487
  const handleCreateEntry = async (collectionPath, _) => {
482
488
  const col = collectionByPath.get(collectionPath);
483
489
  if (!col || col.type === 'entry') {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * An admin's release of a branch held as its record of conflicts was set aside
3
+ * (api/merge-conflicts.ts `releaseHold`). An unanswered request is settled by reading the record
4
+ * again, as the release may have landed.
5
+ */
6
+ import type { ApiClient } from './context/index.js';
7
+ import type { ReleaseOutcome } from './components/ReleaseHoldButton.js';
8
+ /** A release's outcome, and on release whether it found the branch held, when that is known. */
9
+ export type ReleaseResult = ReleaseOutcome & {
10
+ wasHeld?: boolean;
11
+ };
12
+ export declare function releaseConflictsHold(apiClient: Pick<ApiClient, 'conflicts'>, branch: string): Promise<ReleaseResult>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * An admin's release of a branch held as its record of conflicts was set aside
3
+ * (api/merge-conflicts.ts `releaseHold`). An unanswered request is settled by reading the record
4
+ * again, as the release may have landed.
5
+ */
6
+ import { isGatewayFailure, isUnanswered } from './unanswered.js';
7
+ import { getErrorMessage } from '../utils/error.js';
8
+ const UNKNOWN = {
9
+ error: "The release may have gone through even though the server didn't answer. Reload the page to see whether the branch is still on hold.",
10
+ tone: 'warning',
11
+ };
12
+ const STILL_HELD = {
13
+ error: "The server didn't answer, and the branch is still on hold. Try again in a moment.",
14
+ tone: 'warning',
15
+ };
16
+ export async function releaseConflictsHold(apiClient, branch) {
17
+ const settle = async () => {
18
+ const now = await apiClient.conflicts.list({ branch }).catch(() => undefined);
19
+ if (!now?.ok || !now.data)
20
+ return UNKNOWN;
21
+ // Not held now, but this read can't say whose release that was.
22
+ return now.data.held ? STILL_HELD : { released: true };
23
+ };
24
+ let result;
25
+ try {
26
+ result = await apiClient.conflicts.releaseHold({ branch });
27
+ }
28
+ catch (err) {
29
+ return isUnanswered(err) ? settle() : { error: getErrorMessage(err), tone: 'error' };
30
+ }
31
+ if (result.ok)
32
+ return { released: true, wasHeld: result.data?.released !== false };
33
+ if (isGatewayFailure(result))
34
+ return settle();
35
+ return {
36
+ error: result.error ?? "Couldn't release the hold. Try again in a moment.",
37
+ tone: result.status === 409 ? 'warning' : 'error',
38
+ };
39
+ }
@@ -0,0 +1,4 @@
1
+ /** Record a time the server reported, as of when its answer arrived. */
2
+ export declare function noteServerTime(serverTime: number, receivedAt?: number): void;
3
+ /** Now, by the server's clock as last heard; the browser's until then. */
4
+ export declare function serverNow(): number;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The server's clock as the editor last heard it, so a wait timed against a server timestamp
3
+ * (base-update-wait.ts) doesn't depend on the browser's clock being right. One offset per page:
4
+ * the editor talks to one server.
5
+ */
6
+ let offsetMs = 0;
7
+ /** Record a time the server reported, as of when its answer arrived. */
8
+ export function noteServerTime(serverTime, receivedAt = Date.now()) {
9
+ if (Number.isFinite(serverTime))
10
+ offsetMs = serverTime - receivedAt;
11
+ }
12
+ /** Now, by the server's clock as last heard; the browser's until then. */
13
+ export function serverNow() {
14
+ return Date.now() + offsetMs;
15
+ }
@@ -14,6 +14,13 @@ type FormValue = Record<string, unknown>;
14
14
  export declare const formValueOfSide: (side: EntryChangeSide) => FormValue;
15
15
  /** A form value as a side shaped like `like`, for comparing it with the base. */
16
16
  export declare const sideOfFormValue: (value: FormValue, like: EntryChangeSide) => EntryChangeSide;
17
+ /**
18
+ * `value` with `next` at a change's place, records on the way made as needed; undefined when the
19
+ * place is inside an item `value` no longer has.
20
+ */
21
+ export declare const withValueAt: (value: FormValue, change: Pick<EntryChange, "dataPath">, next: unknown, bodyField?: string) => FormValue | undefined;
22
+ /** What `value` holds at a change's place; undefined where it has nothing there. */
23
+ export declare const valueAtChange: (value: FormValue, change: Pick<EntryChange, "dataPath">, bodyField?: string) => unknown;
17
24
  /**
18
25
  * Whether `value` holds `change` as `side` has it, so setting the change to `side` would change
19
26
  * nothing: a moved item placed as `side` places it, anything else the same value at the change's
@@ -41,6 +41,16 @@ const step = (value, segment) => {
41
41
  return isPlainRecord(value) ? (isPlainRecord(value.value) ? value.value : value) : undefined;
42
42
  };
43
43
  const valueAt = (value, path) => path.reduce(step, value);
44
+ /**
45
+ * `value` with `next` at a change's place, records on the way made as needed; undefined when the
46
+ * place is inside an item `value` no longer has.
47
+ */
48
+ export const withValueAt = (value, change, next, bodyField) => {
49
+ const placed = updateAt(value, formPath(change, bodyField), () => next);
50
+ return placed && isPlainRecord(placed.value) ? placed.value : undefined;
51
+ };
52
+ /** What `value` holds at a change's place; undefined where it has nothing there. */
53
+ export const valueAtChange = (value, change, bodyField) => valueAt(value, formPath(change, bodyField));
44
54
  /** What a `replace` returns when the change has nothing to act on, so it can't be placed. */
45
55
  const NOT_PLACED = Symbol('not-placed');
46
56
  /**
@@ -91,17 +101,22 @@ const updateAt = (value, path, replace) => {
91
101
  };
92
102
  /**
93
103
  * `item` placed in `list` (which lacks it) where `order` has it: after the nearest item before it
94
- * in `order` that `list` holds, else first.
104
+ * in `order` that `list` holds, else before the nearest one after it, else at its place in `order`.
95
105
  */
96
106
  const placeLike = (list, item, key, order) => {
97
107
  const at = indexOfKey(order, key);
108
+ const indexIn = (other) => isPlainRecord(other) ? indexOfKey(list, other[ITEM_KEY]) : -1;
98
109
  for (let i = at - 1; i >= 0; i--) {
99
- const before = order[i];
100
- const index = isPlainRecord(before) ? indexOfKey(list, before[ITEM_KEY]) : -1;
110
+ const index = indexIn(order[i]);
101
111
  if (index >= 0)
102
112
  return [...list.slice(0, index + 1), item, ...list.slice(index + 1)];
103
113
  }
104
- return [item, ...list];
114
+ for (let i = at + 1; at > 0 && i < order.length; i++) {
115
+ const index = indexIn(order[i]);
116
+ if (index >= 0)
117
+ return [...list.slice(0, index), item, ...list.slice(index)];
118
+ }
119
+ return at > 0 ? [...list.slice(0, at), item, ...list.slice(at)] : [item, ...list];
105
120
  };
106
121
  const isNothing = (value) => value === undefined || isEmptyContainer(value);
107
122
  /**
@@ -560,6 +560,11 @@ export declare class GitManager {
560
560
  * path. Any other failure, such as an unknown `rev`, throws.
561
561
  */
562
562
  showFile(rev: string, filePath: string): Promise<string | undefined>;
563
+ /**
564
+ * The text of the blob `sha`, `'too-large'` when it is over `maxBytes`, or `undefined` when this
565
+ * clone has no such blob. Any other failure throws.
566
+ */
567
+ readBlob(sha: string, maxBytes: number): Promise<string | 'too-large' | undefined>;
563
568
  ensureAuthor(author: {
564
569
  name: string;
565
570
  email: string;
@@ -569,6 +574,7 @@ export declare class GitManager {
569
574
  ensureRemote(remoteUrl: string): Promise<void>;
570
575
  hasUncommittedChanges(): Promise<boolean>;
571
576
  getUncommittedFiles(): Promise<string[]>;
577
+ /** The remote's URL as configured; `git remote -v`'s listing would split a path with a space. */
572
578
  getRemoteUrl(): Promise<string | undefined>;
573
579
  /**
574
580
  * Excludes `pattern` (e.g. `.canopy-meta/`) from this workspace's git, so it
@@ -1763,6 +1763,36 @@ export class GitManager {
1763
1763
  throw err;
1764
1764
  }
1765
1765
  }
1766
+ /**
1767
+ * The text of the blob `sha`, `'too-large'` when it is over `maxBytes`, or `undefined` when this
1768
+ * clone has no such blob. Any other failure throws.
1769
+ */
1770
+ async readBlob(sha, maxBytes) {
1771
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(sha))
1772
+ return undefined;
1773
+ let sizeText;
1774
+ try {
1775
+ sizeText = (await this.git.raw(['cat-file', '-s', '--end-of-options', sha])).trim();
1776
+ }
1777
+ catch (err) {
1778
+ // LC_ALL=C (gitChildEnv), so git's own wording is stable to match.
1779
+ if (/could not get object info|Not a valid object name/.test(getErrorMessage(err))) {
1780
+ return undefined;
1781
+ }
1782
+ throw err;
1783
+ }
1784
+ // simple-git resolves "" when git exits non-zero without writing to stderr, so anything but
1785
+ // digits is a failed read rather than an empty blob.
1786
+ if (!/^\d+$/.test(sizeText))
1787
+ throw new Error(`git cat-file -s ${sha} printed no size`);
1788
+ const size = Number(sizeText);
1789
+ if (size > maxBytes)
1790
+ return 'too-large';
1791
+ const text = await this.git.raw(['cat-file', 'blob', '--end-of-options', sha]);
1792
+ if (size > 0 && text === '')
1793
+ throw new Error(`git cat-file blob ${sha} printed nothing`);
1794
+ return text;
1795
+ }
1766
1796
  async ensureAuthor(author) {
1767
1797
  const config = (await this.git.listConfig());
1768
1798
  // Verify this is a CanopyCMS-managed workspace before setting author
@@ -1819,10 +1849,15 @@ export class GitManager {
1819
1849
  const status = await this.status();
1820
1850
  return status.files.map((f) => f.path);
1821
1851
  }
1852
+ /** The remote's URL as configured; `git remote -v`'s listing would split a path with a space. */
1822
1853
  async getRemoteUrl() {
1823
- const remotes = await this.git.getRemotes(true);
1824
- const remote = remotes.find((r) => r.name === this.remote);
1825
- return remote?.refs.push || remote?.refs.fetch;
1854
+ try {
1855
+ const url = (await this.git.raw(['remote', 'get-url', this.remote])).trim();
1856
+ return url === '' ? undefined : url;
1857
+ }
1858
+ catch {
1859
+ return undefined;
1860
+ }
1826
1861
  }
1827
1862
  /**
1828
1863
  * Excludes `pattern` (e.g. `.canopy-meta/`) from this workspace's git, so it