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.
- package/CHANGELOG.md +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- 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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/dist/git-manager.d.ts
CHANGED
|
@@ -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
|
package/dist/git-manager.js
CHANGED
|
@@ -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
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
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
|