canopycms 0.12.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.js +45 -13
- package/dist/api/branch-status.d.ts +12 -0
- package/dist/api/branch-status.js +13 -0
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +176 -0
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +1 -0
- package/dist/api/client.d.ts +14 -1
- package/dist/api/client.js +22 -0
- 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 +226 -0
- package/dist/api/merge-conflicts.js +674 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +7 -3
- package/dist/base-update.d.ts +26 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +51 -0
- package/dist/branch-metadata-file.js +11 -0
- 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 +9658 -1499
- package/dist/cli/generate-ai-content.js +354 -244
- package/dist/cli/init.js +6 -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 +429 -52
- 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/base-update-wait.d.ts +74 -0
- package/dist/editor/base-update-wait.js +91 -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 +100 -0
- package/dist/editor/components/ConflictsGroup.js +364 -0
- package/dist/editor/components/EditorHeader.d.ts +16 -1
- package/dist/editor/components/EditorHeader.js +92 -30
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +33 -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 +90 -0
- package/dist/editor/conflict-cards.js +279 -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 +36 -0
- package/dist/editor/hooks/useBaseUpdate.js +269 -0
- package/dist/editor/hooks/useBranchManager.d.ts +6 -1
- package/dist/editor/hooks/useBranchManager.js +28 -7
- 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 +5 -0
- package/dist/editor/hooks/useDraftManager.js +24 -8
- package/dist/editor/hooks/useEntryManager.d.ts +9 -0
- package/dist/editor/hooks/useEntryManager.js +5 -0
- 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 +5 -0
- package/dist/git-manager.js +30 -0
- 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 +875 -0
- package/dist/merge/list-alignment.d.ts +52 -0
- package/dist/merge/list-alignment.js +441 -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/types.d.ts +5 -0
- package/dist/services.d.ts +16 -1
- package/dist/services.js +82 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +28 -2
- 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/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 +53 -37
- package/dist/worker/branch-merge.js +139 -54
- package/dist/worker/cms-worker.d.ts +16 -3
- package/dist/worker/cms-worker.js +59 -12
- package/dist/worker/field-merge.d.ts +65 -0
- package/dist/worker/field-merge.js +636 -0
- package/dist/worker/git-sync.d.ts +1 -1
- package/dist/worker/git-sync.js +7 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +287 -47
- package/dist/worker/github-gateway-http.js +17 -9
- package/dist/worker/github-gateway-wire.d.ts +21 -12
- package/dist/worker/github-gateway-wire.js +5 -1
- package/dist/worker/github-gateway.d.ts +10 -1
- package/dist/worker/github-gateway.js +18 -6
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/rebase.d.ts +5 -4
- package/dist/worker/rebase.js +293 -156
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +14 -2
- package/dist/worker/worker-context.d.ts +18 -2
- package/package.json +1 -1
|
@@ -51,7 +51,7 @@ interface BranchSummary {
|
|
|
51
51
|
syncFailureReason?: string;
|
|
52
52
|
/** Whether this branch has unresolved merge conflicts with the base branch */
|
|
53
53
|
conflictStatus?: ConflictStatus;
|
|
54
|
-
/** ContentIds of entries
|
|
54
|
+
/** ContentIds of entries with a merge conflict a person has yet to resolve */
|
|
55
55
|
conflictFiles?: string[];
|
|
56
56
|
/** What is stuck and needs a person (api/branch.ts). */
|
|
57
57
|
hold?: BranchHold;
|
|
@@ -92,6 +92,11 @@ export interface UseBranchManagerOptions {
|
|
|
92
92
|
onStamped?: (stamped: StampedEntry[]) => void;
|
|
93
93
|
/** A request for changes went through; the branch may now carry the reviewer's note as a comment. */
|
|
94
94
|
onChangesRequested?: (branchName: string) => void;
|
|
95
|
+
/**
|
|
96
|
+
* How to ask for the latest changes on `branchName` once it is withdrawn, or undefined where
|
|
97
|
+
* that can't apply. With it, withdrawing a branch that conflicts with its base asks right after.
|
|
98
|
+
*/
|
|
99
|
+
latestChangesAfterWithdraw?: (branchName: string) => (() => Promise<void>) | undefined;
|
|
95
100
|
}
|
|
96
101
|
export interface UseBranchManagerReturn {
|
|
97
102
|
/**
|
|
@@ -6,7 +6,8 @@ import { Alert, Stack, Text } from '@mantine/core';
|
|
|
6
6
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
|
7
7
|
import { openConfirm } from '../utils/confirm-modal.js';
|
|
8
8
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
9
|
-
import { EDITOR_ACTIONS } from '../copy.js';
|
|
9
|
+
import { EDITOR_ACTIONS, WITHDRAW_AND_GET_LATEST } from '../copy.js';
|
|
10
|
+
import { clientOperatingStrategy } from '../../operating-mode/client.js';
|
|
10
11
|
import { notifications } from '@mantine/notifications';
|
|
11
12
|
import { useApiClient, useBranchSegments } from '../context/index.js';
|
|
12
13
|
import { BRANCHES_KEY, fetchBranches, useBranchesData } from './useBranchesData.js';
|
|
@@ -50,7 +51,11 @@ function actionError(result, fallback, staleIsConflict = false) {
|
|
|
50
51
|
const message = result.error || fallback;
|
|
51
52
|
if (result.ok)
|
|
52
53
|
return new Error(message);
|
|
53
|
-
|
|
54
|
+
// Open conflicts refuse the action where the branch still is, so they are no stale status.
|
|
55
|
+
return staleIsConflict &&
|
|
56
|
+
result.status === 409 &&
|
|
57
|
+
result.code !== 'OPEN_CONFLICTS' &&
|
|
58
|
+
!isNonApiResponse(result)
|
|
54
59
|
? new StaleStatusError(message)
|
|
55
60
|
: new RefusedError(message, result.status);
|
|
56
61
|
}
|
|
@@ -142,16 +147,22 @@ export function withdrawPullRequestBullet(pr) {
|
|
|
142
147
|
return `Leave the merged ${label} as it is`;
|
|
143
148
|
return `Convert ${label} to a draft`;
|
|
144
149
|
}
|
|
145
|
-
const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo
|
|
150
|
+
const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo,
|
|
151
|
+
/** The branch conflicts with its base, so withdrawing also gets the latest changes. */
|
|
152
|
+
thenGetLatest = false) => {
|
|
146
153
|
const bullets = [
|
|
147
154
|
withdrawPullRequestBullet(pr),
|
|
148
155
|
'Change the branch status back to “editing”',
|
|
149
156
|
'Remove from review queue',
|
|
157
|
+
thenGetLatest ? 'Bring in the latest changes made on the site' : undefined,
|
|
150
158
|
].filter((b) => b !== undefined);
|
|
151
159
|
openConfirm({
|
|
152
160
|
title: EDITOR_ACTIONS.withdrawFromReview,
|
|
153
161
|
children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Withdraw “${branchName}” from review?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}` })),
|
|
154
|
-
labels: {
|
|
162
|
+
labels: {
|
|
163
|
+
confirm: thenGetLatest ? WITHDRAW_AND_GET_LATEST : EDITOR_ACTIONS.withdrawFromReview,
|
|
164
|
+
cancel: 'Cancel',
|
|
165
|
+
},
|
|
155
166
|
confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
|
|
156
167
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
157
168
|
...focusReturnProps(returnFocusTo),
|
|
@@ -609,8 +620,9 @@ export function useBranchManager(options) {
|
|
|
609
620
|
notifications.show({
|
|
610
621
|
id,
|
|
611
622
|
message: details.note ? (_jsx(NoteNotSavedMessage, { message: message, note: details.note, hint: "Copy it to keep it." })) : (message),
|
|
612
|
-
|
|
613
|
-
|
|
623
|
+
// A 409, such as open conflicts, loses nothing and says what to do: yellow, until read.
|
|
624
|
+
color: err.status === 409 ? 'yellow' : 'red',
|
|
625
|
+
...(details.note || err.status === 409 ? { autoClose: false, withCloseButton: true } : {}),
|
|
614
626
|
});
|
|
615
627
|
await loadBranches();
|
|
616
628
|
return;
|
|
@@ -719,6 +731,12 @@ export function useBranchManager(options) {
|
|
|
719
731
|
const handleWithdraw = (requested, returnFocusTo) => {
|
|
720
732
|
const branchNameToWithdraw = listedName(requested);
|
|
721
733
|
return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
|
|
734
|
+
// The conflict that held it in review is what the latest changes settle.
|
|
735
|
+
const requestLatest = clientOperatingStrategy(options.operatingMode).syncsBranchesWithBase() &&
|
|
736
|
+
listedBranch(branchNameToWithdraw)?.hold?.kind === 'conflicts'
|
|
737
|
+
? options.latestChangesAfterWithdraw?.(branchNameToWithdraw)
|
|
738
|
+
: undefined;
|
|
739
|
+
const thenGetLatest = !!requestLatest;
|
|
722
740
|
showWithdrawConfirmation(shownName(branchNameToWithdraw), pullRequestOf(branchNameToWithdraw), async () => {
|
|
723
741
|
options.setBusy(true);
|
|
724
742
|
try {
|
|
@@ -731,6 +749,9 @@ export function useBranchManager(options) {
|
|
|
731
749
|
updateCreatedBranch(branchNameToWithdraw, result.data?.branch);
|
|
732
750
|
await loadBranches();
|
|
733
751
|
resolve();
|
|
752
|
+
// Reports its own outcome, so a failure here never reads as a failed withdraw.
|
|
753
|
+
if (requestLatest)
|
|
754
|
+
void requestLatest();
|
|
734
755
|
}
|
|
735
756
|
catch (err) {
|
|
736
757
|
await reportActionFailure(err, 'Failed to withdraw branch', EDITOR_ACTIONS.withdrawFromReview);
|
|
@@ -739,7 +760,7 @@ export function useBranchManager(options) {
|
|
|
739
760
|
finally {
|
|
740
761
|
options.setBusy(false);
|
|
741
762
|
}
|
|
742
|
-
}, () => resolve(), returnFocusTo);
|
|
763
|
+
}, () => resolve(), returnFocusTo, thenGetLatest);
|
|
743
764
|
}));
|
|
744
765
|
};
|
|
745
766
|
const handleArchive = (requested) => {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import useSWR, { useSWRConfig } from 'swr';
|
|
16
16
|
import { isNonApiResponse } from '../../api/client.js';
|
|
17
17
|
import { seedUserProfiles } from './useUserMetadata.js';
|
|
18
|
+
import { noteServerTime } from '../server-clock.js';
|
|
18
19
|
/** Cache key for the branches list. */
|
|
19
20
|
export const BRANCHES_KEY = 'canopy:branches';
|
|
20
21
|
/**
|
|
@@ -33,6 +34,8 @@ export async function fetchBranches(apiClient, swr) {
|
|
|
33
34
|
if (!result.ok) {
|
|
34
35
|
throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
|
|
35
36
|
}
|
|
37
|
+
if (result.data?.serverTime !== undefined)
|
|
38
|
+
noteServerTime(result.data.serverTime);
|
|
36
39
|
const users = result.data?.users ?? [];
|
|
37
40
|
await seedUserProfiles(swr, users);
|
|
38
41
|
return {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ResolvedFieldConflict } from '../../merge-conflicts.js';
|
|
2
|
+
import type { EntryChoices, FieldChoice } from '../conflict-choices.js';
|
|
3
|
+
export interface UseConflictChoicesReturn {
|
|
4
|
+
/** The open branch's choices for an entry, by conflict key. */
|
|
5
|
+
choicesFor: (contentId: string) => EntryChoices;
|
|
6
|
+
/** Records a choice, or clears it with undefined. */
|
|
7
|
+
choose: (contentId: string, key: string, mergeSha: string, choice: FieldChoice | undefined, decided: boolean) => void;
|
|
8
|
+
/** Entries with a decided choice no save has sent. */
|
|
9
|
+
pendingIds: ReadonlySet<string>;
|
|
10
|
+
/** What a save of an entry on `branch` resolves; nothing for another branch. */
|
|
11
|
+
pending: (branch: string, contentId: string) => ResolvedFieldConflict[];
|
|
12
|
+
/** Drops the choices a save sent: resolved, or no longer open. */
|
|
13
|
+
sent: (branch: string, contentId: string, resolved: readonly ResolvedFieldConflict[]) => void;
|
|
14
|
+
/** Drops an entry's choices, or every entry's when `contentId` is undefined. */
|
|
15
|
+
clear: (contentId?: string) => void;
|
|
16
|
+
/** Keeps only the choices of the entries `open` names, which have conflicts open. */
|
|
17
|
+
keepEntries: (open: ReadonlySet<string>) => void;
|
|
18
|
+
/** Keeps only an entry's choices for the conflicts `open` names, each on its merge. */
|
|
19
|
+
retain: (contentId: string, open: ReadonlyArray<{
|
|
20
|
+
key: string;
|
|
21
|
+
mergeSha: string;
|
|
22
|
+
}>) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function useConflictChoices(branch: string): UseConflictChoicesReturn;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* The choices made on conflict cards that no save has sent yet, per entry of the open branch
|
|
4
|
+
* (conflict-choices.ts). A decided choice makes its entry count as unsaved, so a save writes it,
|
|
5
|
+
* and that save sends the conflict's key in `resolvedConflicts`. Choices belong to the branch they
|
|
6
|
+
* were made on, and go with a discard of the entry's drafts.
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
9
|
+
const NONE = {};
|
|
10
|
+
export function useConflictChoices(branch) {
|
|
11
|
+
const [state, setState] = useState({
|
|
12
|
+
branch,
|
|
13
|
+
choices: {},
|
|
14
|
+
});
|
|
15
|
+
const choices = state.branch === branch ? state.choices : {};
|
|
16
|
+
const latest = useRef({ branch, choices });
|
|
17
|
+
latest.current = { branch, choices };
|
|
18
|
+
// Choices are held for the open branch only: one made after a switch starts its branch afresh,
|
|
19
|
+
// and a late answer about another branch leaves them alone.
|
|
20
|
+
const update = useCallback((forBranch, change, starts = false) => setState((prev) => {
|
|
21
|
+
if (prev.branch !== forBranch) {
|
|
22
|
+
return starts ? { branch: forBranch, choices: change({}) } : prev;
|
|
23
|
+
}
|
|
24
|
+
const next = change(prev.choices);
|
|
25
|
+
return next === prev.choices ? prev : { branch: forBranch, choices: next };
|
|
26
|
+
}), []);
|
|
27
|
+
const choose = useCallback((contentId, key, mergeSha, choice, decided) => update(latest.current.branch, (current) => {
|
|
28
|
+
const { [key]: _old, ...rest } = current[contentId] ?? {};
|
|
29
|
+
const entry = choice ? { ...rest, [key]: { mergeSha, choice, decided } } : rest;
|
|
30
|
+
const { [contentId]: _entry, ...others } = current;
|
|
31
|
+
return Object.keys(entry).length > 0 ? { ...others, [contentId]: entry } : others;
|
|
32
|
+
}, true), [update]);
|
|
33
|
+
const pendingIds = useMemo(() => new Set(Object.entries(choices)
|
|
34
|
+
.filter(([, entry]) => Object.values(entry).some((c) => c.decided))
|
|
35
|
+
.map(([id]) => id)), [choices]);
|
|
36
|
+
const pending = useCallback((forBranch, contentId) => {
|
|
37
|
+
if (forBranch !== latest.current.branch)
|
|
38
|
+
return [];
|
|
39
|
+
return Object.entries(latest.current.choices[contentId] ?? {})
|
|
40
|
+
.filter(([, c]) => c.decided)
|
|
41
|
+
.map(([key, c]) => ({ mergeSha: c.mergeSha, key }));
|
|
42
|
+
}, []);
|
|
43
|
+
const sent = useCallback((forBranch, contentId, resolved) => update(forBranch, (current) => {
|
|
44
|
+
const entry = current[contentId];
|
|
45
|
+
if (!entry)
|
|
46
|
+
return current;
|
|
47
|
+
const left = Object.fromEntries(Object.entries(entry).filter(([key, c]) => !resolved.some((r) => r.key === key && r.mergeSha === c.mergeSha)));
|
|
48
|
+
const { [contentId]: _sent, ...others } = current;
|
|
49
|
+
return Object.keys(left).length > 0 ? { ...others, [contentId]: left } : others;
|
|
50
|
+
}), [update]);
|
|
51
|
+
const clear = useCallback((contentId) => update(latest.current.branch, (current) => {
|
|
52
|
+
if (contentId === undefined)
|
|
53
|
+
return Object.keys(current).length > 0 ? {} : current;
|
|
54
|
+
if (!(contentId in current))
|
|
55
|
+
return current;
|
|
56
|
+
const { [contentId]: _cleared, ...others } = current;
|
|
57
|
+
return others;
|
|
58
|
+
}), [update]);
|
|
59
|
+
const retain = useCallback((contentId, open) => update(latest.current.branch, (current) => {
|
|
60
|
+
const entry = current[contentId];
|
|
61
|
+
if (!entry)
|
|
62
|
+
return current;
|
|
63
|
+
const kept = Object.entries(entry).filter(([key, c]) => open.some((o) => o.key === key && o.mergeSha === c.mergeSha));
|
|
64
|
+
if (kept.length === Object.keys(entry).length)
|
|
65
|
+
return current;
|
|
66
|
+
const { [contentId]: _stale, ...others } = current;
|
|
67
|
+
return kept.length > 0 ? { ...others, [contentId]: Object.fromEntries(kept) } : others;
|
|
68
|
+
}), [update]);
|
|
69
|
+
const keepEntries = useCallback((open) => update(latest.current.branch, (current) => {
|
|
70
|
+
const kept = Object.entries(current).filter(([id]) => open.has(id));
|
|
71
|
+
return kept.length === Object.keys(current).length ? current : Object.fromEntries(kept);
|
|
72
|
+
}), [update]);
|
|
73
|
+
const choicesFor = useCallback((contentId) => choices[contentId] ?? NONE, [choices]);
|
|
74
|
+
return { choicesFor, choose, pendingIds, pending, sent, clear, retain, keepEntries };
|
|
75
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ApiClient } from '../context/index.js';
|
|
2
|
+
import type { EntryConflictData, MergeConflictsData } from '../../api/merge-conflicts.js';
|
|
3
|
+
export interface EntryConflictTarget {
|
|
4
|
+
logicalPath: string;
|
|
5
|
+
/** The entry's content id, which tells it from a collection at the same path. */
|
|
6
|
+
id?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ConflictsState<T> {
|
|
9
|
+
data: T | undefined;
|
|
10
|
+
/** Why the last read failed, in the server's words where it gave some. */
|
|
11
|
+
error: string | undefined;
|
|
12
|
+
isValidating: boolean;
|
|
13
|
+
reload: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The branch's open conflicts, read while its listing names any (`conflictFiles`); undefined
|
|
17
|
+
* otherwise, which means none.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useBranchConflictsData(apiClient: Pick<ApiClient, 'conflicts'>, branch: string, conflictFiles: readonly string[] | undefined,
|
|
20
|
+
/** Read even with no entry named, as the editor does to learn of a hold on the branch. */
|
|
21
|
+
always?: boolean): ConflictsState<MergeConflictsData>;
|
|
22
|
+
/**
|
|
23
|
+
* One entry's conflicts, read while `target` is set (its drawer is open): null when it has none,
|
|
24
|
+
* which the API answers with a 404.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useEntryConflictData(apiClient: Pick<ApiClient, 'conflicts'>, branch: string, target: EntryConflictTarget | undefined): ConflictsState<EntryConflictData | null>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* SWR-backed reads of the conflicts a merge left on a branch (api/merge-conflicts.ts): the list,
|
|
4
|
+
* for Submit's reason, the bar's badge and an entry's notice, and one entry's conflicts for its
|
|
5
|
+
* "Show changes" drawer. A null key fetches nothing.
|
|
6
|
+
*/
|
|
7
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
8
|
+
import useSWR, { useSWRConfig } from 'swr';
|
|
9
|
+
const KEY_PREFIX = 'canopy:conflicts:';
|
|
10
|
+
/**
|
|
11
|
+
* The list's key, one per branch, so a re-read keeps the last answer showing until it lands: a
|
|
12
|
+
* branch's hold must not blink off while its list is read again.
|
|
13
|
+
*/
|
|
14
|
+
const listKey = (branch) => `${KEY_PREFIX}${branch}|`;
|
|
15
|
+
/** An entry's key, under its branch's prefix and a `:`, which no branch name holds. */
|
|
16
|
+
const entryKey = (branch, target) => `${KEY_PREFIX}${branch}:${target.logicalPath}${target.id ? `#${target.id}` : ''}`;
|
|
17
|
+
class ConflictsLoadError extends Error {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The branch's open conflicts, read while its listing names any (`conflictFiles`); undefined
|
|
21
|
+
* otherwise, which means none.
|
|
22
|
+
*/
|
|
23
|
+
export function useBranchConflictsData(apiClient, branch, conflictFiles,
|
|
24
|
+
/** Read even with no entry named, as the editor does to learn of a hold on the branch. */
|
|
25
|
+
always = false) {
|
|
26
|
+
const key = branch && (always || conflictFiles?.length) ? listKey(branch) : null;
|
|
27
|
+
const swr = useSWR(key, async () => {
|
|
28
|
+
const result = await apiClient.conflicts.list({ branch });
|
|
29
|
+
if (result.ok && result.data && Array.isArray(result.data.conflicts))
|
|
30
|
+
return result.data;
|
|
31
|
+
throw new ConflictsLoadError(result.error ?? `HTTP ${result.status}`);
|
|
32
|
+
});
|
|
33
|
+
const { mutate } = swr;
|
|
34
|
+
// A listing that changes what is open (`conflictFiles`) reads the list again.
|
|
35
|
+
const open = (conflictFiles ?? []).join(',');
|
|
36
|
+
const lastOpen = useRef(open);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (lastOpen.current === open)
|
|
39
|
+
return;
|
|
40
|
+
lastOpen.current = open;
|
|
41
|
+
if (key)
|
|
42
|
+
void mutate();
|
|
43
|
+
}, [open, key, mutate]);
|
|
44
|
+
const { mutate: mutateKeys } = useSWRConfig();
|
|
45
|
+
const reload = useCallback(async () => {
|
|
46
|
+
const prefix = `${KEY_PREFIX}${branch}`;
|
|
47
|
+
await Promise.all([
|
|
48
|
+
mutate(),
|
|
49
|
+
mutateKeys((k) => typeof k === 'string' && (k.startsWith(`${prefix}:`) || k.startsWith(`${prefix}|`))),
|
|
50
|
+
]);
|
|
51
|
+
}, [mutate, mutateKeys, branch]);
|
|
52
|
+
return {
|
|
53
|
+
data: key ? swr.data : undefined,
|
|
54
|
+
error: key && swr.error instanceof Error ? swr.error.message : undefined,
|
|
55
|
+
isValidating: swr.isValidating,
|
|
56
|
+
reload,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One entry's conflicts, read while `target` is set (its drawer is open): null when it has none,
|
|
61
|
+
* which the API answers with a 404.
|
|
62
|
+
*/
|
|
63
|
+
export function useEntryConflictData(apiClient, branch, target) {
|
|
64
|
+
const key = branch && target ? entryKey(branch, target) : null;
|
|
65
|
+
const swr = useSWR(key, async () => {
|
|
66
|
+
if (!target)
|
|
67
|
+
return null;
|
|
68
|
+
const result = await apiClient.conflicts.entry({
|
|
69
|
+
branch,
|
|
70
|
+
entryPath: target.logicalPath,
|
|
71
|
+
...(target.id && { id: target.id }),
|
|
72
|
+
});
|
|
73
|
+
if (result.ok && result.data && Array.isArray(result.data.fields))
|
|
74
|
+
return result.data;
|
|
75
|
+
if (result.status === 404)
|
|
76
|
+
return null;
|
|
77
|
+
throw new ConflictsLoadError(result.error ?? `HTTP ${result.status}`);
|
|
78
|
+
});
|
|
79
|
+
const { mutate } = swr;
|
|
80
|
+
const reload = useCallback(async () => {
|
|
81
|
+
await mutate();
|
|
82
|
+
}, [mutate]);
|
|
83
|
+
return {
|
|
84
|
+
data: key ? swr.data : undefined,
|
|
85
|
+
error: key && swr.error instanceof Error ? swr.error.message : undefined,
|
|
86
|
+
isValidating: swr.isValidating,
|
|
87
|
+
reload,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -63,6 +63,11 @@ export interface UseDraftManagerOptions {
|
|
|
63
63
|
followMovedEntry?: (entry: EditorEntry) => Promise<EditorEntry | undefined>;
|
|
64
64
|
/** Creates a deleted entry again from its draft (useEntryManager's `recreateEntry`). */
|
|
65
65
|
recreateEntry?: (entry: EditorEntry, value: FormValue) => Promise<EditorEntry | undefined>;
|
|
66
|
+
/**
|
|
67
|
+
* Entries with a conflict choice a save has yet to send (useConflictChoices.ts): unsaved even
|
|
68
|
+
* when the choice left the value as it was, such as "Keep your version".
|
|
69
|
+
*/
|
|
70
|
+
pendingIds?: ReadonlySet<string>;
|
|
66
71
|
}
|
|
67
72
|
/** The unsaved work a branch-level action would leave behind. `labels` names the entries (it may be shorter than `count`). */
|
|
68
73
|
export interface UnsavedSummary {
|
|
@@ -187,12 +187,19 @@ export function useDraftManager(options) {
|
|
|
187
187
|
//
|
|
188
188
|
// 3. The comparison is `fast-deep-equal`, so key insertion order of a
|
|
189
189
|
// rehydrated draft does not make it dirty.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
//
|
|
191
|
+
// 4. An entry with a conflict choice to send counts, draft or not (`pendingIds`).
|
|
192
|
+
const pendingIds = options.pendingIds;
|
|
193
|
+
const dirtyIds = useMemo(() => {
|
|
194
|
+
const dirty = Object.keys(drafts).filter((id) => {
|
|
195
|
+
if (entriesKnown && !entryById.has(id))
|
|
196
|
+
return false;
|
|
197
|
+
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
198
|
+
return baseline === undefined || !equal(drafts[id], baseline);
|
|
199
|
+
});
|
|
200
|
+
const pending = [...(pendingIds ?? [])].filter((id) => !dirty.includes(id) && (!entriesKnown || entryById.has(id)));
|
|
201
|
+
return pending.length > 0 ? [...dirty, ...pending] : dirty;
|
|
202
|
+
}, [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById, pendingIds]);
|
|
196
203
|
const modifiedCount = dirtyIds.length;
|
|
197
204
|
const editedFiles = useMemo(() => dirtyIds
|
|
198
205
|
.map((id) => {
|
|
@@ -963,7 +970,10 @@ export function useDraftManager(options) {
|
|
|
963
970
|
*/
|
|
964
971
|
const saveOneOfAll = async (entry, branch) => {
|
|
965
972
|
const id = entry.contentId;
|
|
966
|
-
const sent = latestRef.current.drafts[id]
|
|
973
|
+
const sent = latestRef.current.drafts[id] ??
|
|
974
|
+
(latestOptionsRef.current.pendingIds?.has(id)
|
|
975
|
+
? latestRef.current.loadedValues[id]
|
|
976
|
+
: undefined);
|
|
967
977
|
if (sent === undefined)
|
|
968
978
|
return undefined;
|
|
969
979
|
const onBranch = () => latestOptionsRef.current.branchName === branch;
|
|
@@ -1137,7 +1147,9 @@ export function useDraftManager(options) {
|
|
|
1137
1147
|
// Discarded, refreshed or saved mid-run, so neither saved nor failed by this run.
|
|
1138
1148
|
const { drafts: remaining } = latestRef.current;
|
|
1139
1149
|
const settled = settledDuringRunRef.current;
|
|
1140
|
-
|
|
1150
|
+
const pendingNow = latestOptionsRef.current.pendingIds;
|
|
1151
|
+
result.failed = result.failed.filter((failure) => (failure.contentId in remaining || !!pendingNow?.has(failure.contentId)) &&
|
|
1152
|
+
!settled.has(failure.contentId));
|
|
1141
1153
|
result.total = result.saved.length + result.failed.length;
|
|
1142
1154
|
if (result.saved.length > 0)
|
|
1143
1155
|
latestOptionsRef.current.onSaved?.();
|
|
@@ -1351,6 +1363,8 @@ export function useDraftManager(options) {
|
|
|
1351
1363
|
if (!entry)
|
|
1352
1364
|
return false;
|
|
1353
1365
|
const id = entry.contentId;
|
|
1366
|
+
if (pendingIds?.has(id))
|
|
1367
|
+
return true;
|
|
1354
1368
|
if (!drafts[id])
|
|
1355
1369
|
return false;
|
|
1356
1370
|
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
@@ -1359,6 +1373,8 @@ export function useDraftManager(options) {
|
|
|
1359
1373
|
const isSelectedDirty = () => {
|
|
1360
1374
|
if (!currentId)
|
|
1361
1375
|
return false;
|
|
1376
|
+
if (pendingIds?.has(currentId))
|
|
1377
|
+
return true;
|
|
1362
1378
|
if (!drafts[currentId])
|
|
1363
1379
|
return false;
|
|
1364
1380
|
return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
|
|
@@ -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,14 @@ 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
|
+
};
|
|
65
74
|
}
|
|
66
75
|
export interface UseEntryManagerReturn {
|
|
67
76
|
selectedPath: string;
|
|
@@ -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);
|
|
@@ -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;
|