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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a choice on a conflict card does to the open entry's form value (conflict-cards.ts builds
|
|
3
|
+
* the cards). Pure. A choice is an unsaved edit: the save that writes it sends the conflict's key in
|
|
4
|
+
* `resolvedConflicts` (api/content.ts), which is what resolves it.
|
|
5
|
+
*/
|
|
6
|
+
import type { TextConflictRegion } from '../merge/text-merge.js';
|
|
7
|
+
import type { FieldConflict } from './conflict-cards.js';
|
|
8
|
+
import type { EntryChange } from './entry-diff.js';
|
|
9
|
+
type FormValue = Record<string, unknown>;
|
|
10
|
+
/** A region's choice: the latest version's lines, yours, or the latest's followed by yours. */
|
|
11
|
+
export type RegionChoice = 'latest' | 'yours' | 'both';
|
|
12
|
+
/**
|
|
13
|
+
* `latest`: the latest version's value, which for a removed one removes it. `yours`: your saved
|
|
14
|
+
* value as it stands. `edited`: what the form holds now, marked resolved after an edit. `regions`:
|
|
15
|
+
* long text chosen region by region, resolved once every region is chosen.
|
|
16
|
+
*/
|
|
17
|
+
export type FieldChoice = {
|
|
18
|
+
how: 'latest';
|
|
19
|
+
} | {
|
|
20
|
+
how: 'yours';
|
|
21
|
+
} | {
|
|
22
|
+
how: 'edited';
|
|
23
|
+
} | {
|
|
24
|
+
how: 'regions';
|
|
25
|
+
regions: Readonly<Record<number, RegionChoice>>;
|
|
26
|
+
};
|
|
27
|
+
/** An entry's choices, by conflict key, each for the merge it was made on. */
|
|
28
|
+
export type EntryChoices = Readonly<Record<string, {
|
|
29
|
+
mergeSha: string;
|
|
30
|
+
choice: FieldChoice;
|
|
31
|
+
}>>;
|
|
32
|
+
/** The change-shaped handle `undo-change.ts` places a value by. */
|
|
33
|
+
export declare const placeOf: (field: Pick<FieldConflict, "key" | "dataPath" | "item">) => EntryChange;
|
|
34
|
+
/** Whether a choice says everything its conflict needs. */
|
|
35
|
+
export declare const isDecided: (field: FieldConflict, choice: FieldChoice | undefined) => boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The text the merge left with each region as `regions` chooses it. A region's lines sit where the
|
|
38
|
+
* merge put yours (`TextConflictRegion.line`), so regions are placed from the last up.
|
|
39
|
+
* @internal Exported for tests.
|
|
40
|
+
*/
|
|
41
|
+
export declare function textWithChoices(text: {
|
|
42
|
+
merged: string;
|
|
43
|
+
regions: readonly TextConflictRegion[];
|
|
44
|
+
}, regions: Readonly<Record<number, RegionChoice>>): string;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the form still holds the conflict's place as the last choice (or, before any, your saved
|
|
47
|
+
* value) left it. False once it was edited by hand: a choice would then replace that edit.
|
|
48
|
+
*/
|
|
49
|
+
export declare function holdsChoice(current: FormValue, saved: FormValue, field: FieldConflict, choice: FieldChoice | undefined, bodyField: string | undefined): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* `current` with the latest version at the conflict's place; undefined when it can't be placed, as
|
|
52
|
+
* where the latest version couldn't be read or doesn't hold the items on the place's path
|
|
53
|
+
* ({@link FieldConflict.latestPlaceable}).
|
|
54
|
+
*/
|
|
55
|
+
export declare const withLatest: (current: FormValue, field: FieldConflict, bodyField: string | undefined) => FormValue | undefined;
|
|
56
|
+
/** `current` with your saved value back at the conflict's place, as before any choice. */
|
|
57
|
+
export declare const withYours: (current: FormValue, saved: FormValue, field: FieldConflict, bodyField: string | undefined) => FormValue | undefined;
|
|
58
|
+
/** `current` with long text as `regions` chooses it. */
|
|
59
|
+
export declare const withRegions: (current: FormValue, field: FieldConflict, regions: Readonly<Record<number, RegionChoice>>, bodyField: string | undefined) => FormValue | undefined;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a choice on a conflict card does to the open entry's form value (conflict-cards.ts builds
|
|
3
|
+
* the cards). Pure. A choice is an unsaved edit: the save that writes it sends the conflict's key in
|
|
4
|
+
* `resolvedConflicts` (api/content.ts), which is what resolves it.
|
|
5
|
+
*/
|
|
6
|
+
import { holdsAs, setChangeBack, valueAtChange, withValueAt } from './undo-change.js';
|
|
7
|
+
/** The change-shaped handle `undo-change.ts` places a value by. */
|
|
8
|
+
export const placeOf = (field) => ({
|
|
9
|
+
key: field.key,
|
|
10
|
+
dataPath: field.dataPath,
|
|
11
|
+
path: [],
|
|
12
|
+
kind: 'changed',
|
|
13
|
+
...(field.item && { item: true }),
|
|
14
|
+
});
|
|
15
|
+
/** Whether a choice says everything its conflict needs. */
|
|
16
|
+
export const isDecided = (field, choice) => !!choice &&
|
|
17
|
+
(choice.how !== 'regions' ||
|
|
18
|
+
(!!field.text && field.text.regions.every((_, i) => choice.regions[i] !== undefined)));
|
|
19
|
+
const linesOf = (text, count) => count === 0 || (count === undefined && text === '') ? [] : text.split('\n');
|
|
20
|
+
/** A region's lines under `choice`; undecided, they are yours, as the merge left them. */
|
|
21
|
+
const regionLines = (region, choice) => {
|
|
22
|
+
const yours = linesOf(region.branch, region.lines);
|
|
23
|
+
// Counted, as the branch's are: joined, one blank line reads as none.
|
|
24
|
+
const latest = linesOf(region.base, region.baseLines);
|
|
25
|
+
if (choice === 'latest')
|
|
26
|
+
return latest;
|
|
27
|
+
if (choice === 'both')
|
|
28
|
+
return [...latest, ...yours];
|
|
29
|
+
return yours;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The text the merge left with each region as `regions` chooses it. A region's lines sit where the
|
|
33
|
+
* merge put yours (`TextConflictRegion.line`), so regions are placed from the last up.
|
|
34
|
+
* @internal Exported for tests.
|
|
35
|
+
*/
|
|
36
|
+
export function textWithChoices(text, regions) {
|
|
37
|
+
const endBreak = text.merged.endsWith('\n');
|
|
38
|
+
const body = endBreak ? text.merged.slice(0, -1) : text.merged;
|
|
39
|
+
const lines = body === '' && !endBreak ? [] : body.split('\n');
|
|
40
|
+
for (let i = text.regions.length - 1; i >= 0; i--) {
|
|
41
|
+
const region = text.regions[i];
|
|
42
|
+
lines.splice(region.line, region.lines, ...regionLines(region, regions[i]));
|
|
43
|
+
}
|
|
44
|
+
const joined = lines.join('\n');
|
|
45
|
+
return endBreak ? `${joined}\n` : joined;
|
|
46
|
+
}
|
|
47
|
+
/** What the form would hold at the conflict's place under `choice`, given your saved value. */
|
|
48
|
+
const expectedUnder = (field, choice, saved) => {
|
|
49
|
+
if (!choice || choice.how === 'yours')
|
|
50
|
+
return { from: saved };
|
|
51
|
+
if (choice.how === 'latest')
|
|
52
|
+
return field.latest ? { from: field.latest } : undefined;
|
|
53
|
+
if (choice.how === 'regions' && field.text)
|
|
54
|
+
return { text: textWithChoices(field.text, choice.regions) };
|
|
55
|
+
return undefined;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Whether the form still holds the conflict's place as the last choice (or, before any, your saved
|
|
59
|
+
* value) left it. False once it was edited by hand: a choice would then replace that edit.
|
|
60
|
+
*/
|
|
61
|
+
export function holdsChoice(current, saved, field, choice, bodyField) {
|
|
62
|
+
const expected = expectedUnder(field, choice, saved);
|
|
63
|
+
// A place with no path, such as where the entry is, holds nothing the form could edit.
|
|
64
|
+
if (!expected || field.dataPath.length === 0)
|
|
65
|
+
return true;
|
|
66
|
+
if ('text' in expected)
|
|
67
|
+
return valueAtChange(current, field, bodyField) === expected.text;
|
|
68
|
+
// An item on the way that the form no longer has holds nothing, though an empty value would
|
|
69
|
+
// compare equal to the nothing found there.
|
|
70
|
+
const from = expected.from;
|
|
71
|
+
const itemGone = field.dataPath.some((segment, i) => 'item' in segment &&
|
|
72
|
+
valueAtChange(from, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) !== undefined &&
|
|
73
|
+
valueAtChange(current, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) === undefined);
|
|
74
|
+
if (itemGone)
|
|
75
|
+
return false;
|
|
76
|
+
// Long text checks the merged text, which your saved value holds until a region is chosen.
|
|
77
|
+
if (field.text && (!choice || choice.how === 'yours')) {
|
|
78
|
+
return valueAtChange(current, field, bodyField) === valueAtChange(saved, field, bodyField);
|
|
79
|
+
}
|
|
80
|
+
return holdsAs(current, expected.from, placeOf(field), bodyField);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* `current` with the conflict's place as `source` has it: a list item `source` lacks taken out, or
|
|
84
|
+
* one `current` lacks put in where `source` has it, through `undo-change.ts`; anything else, an item
|
|
85
|
+
* both hold included, replaced where it is, by key. Undefined when it can't be placed.
|
|
86
|
+
*/
|
|
87
|
+
const placeFrom = (current, source, field, bodyField) => {
|
|
88
|
+
const place = placeOf(field);
|
|
89
|
+
const wanted = valueAtChange(source, place, bodyField);
|
|
90
|
+
if (wanted === undefined ||
|
|
91
|
+
(field.item && valueAtChange(current, place, bodyField) === undefined))
|
|
92
|
+
return setChangeBack(current, source, place, bodyField);
|
|
93
|
+
return withValueAt(current, place, structuredClone(wanted), bodyField);
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* `current` with the latest version at the conflict's place; undefined when it can't be placed, as
|
|
97
|
+
* where the latest version couldn't be read or doesn't hold the items on the place's path
|
|
98
|
+
* ({@link FieldConflict.latestPlaceable}).
|
|
99
|
+
*/
|
|
100
|
+
export const withLatest = (current, field, bodyField) => field.latest && field.latestPlaceable
|
|
101
|
+
? placeFrom(current, field.latest, field, bodyField)
|
|
102
|
+
: undefined;
|
|
103
|
+
/** `current` with your saved value back at the conflict's place, as before any choice. */
|
|
104
|
+
export const withYours = (current, saved, field, bodyField) => placeFrom(current, saved, field, bodyField);
|
|
105
|
+
/** `current` with long text as `regions` chooses it. */
|
|
106
|
+
export const withRegions = (current, field, regions, bodyField) => field.text
|
|
107
|
+
? withValueAt(current, field, textWithChoices(field.text, regions), bodyField)
|
|
108
|
+
: undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the editor says about a branch's open conflicts outside "Show changes": the open entry's
|
|
3
|
+
* notice, Submit's reason, the bar's badge and the "Get latest changes" toast. A count is of the
|
|
4
|
+
* choices left (`MergeConflictsData.openCount`): one per field, and one per entry-level conflict
|
|
5
|
+
* (`openConflictCount`, merge-conflicts.ts). No copy names a
|
|
6
|
+
* branch: an author may not know the branch theirs was cut from.
|
|
7
|
+
*/
|
|
8
|
+
import type { MergeConflictListItem } from '../api/merge-conflicts.js';
|
|
9
|
+
/** The notice above an entry's form while it has conflicts; the list item when it is loaded. */
|
|
10
|
+
export declare function entryConflictNotice(item: MergeConflictListItem | undefined): string;
|
|
11
|
+
/** Why Submit for review waits while choices are open. */
|
|
12
|
+
export declare const conflictsSubmitReason: (count: number) => string;
|
|
13
|
+
/** The bar's "Needs action" detail while choices are open. */
|
|
14
|
+
export declare const conflictsHoldMessage: (count: number) => string;
|
|
15
|
+
/**
|
|
16
|
+
* The "Get latest changes" toast when the merge left choices; undefined: not yet counted. None
|
|
17
|
+
* left is a plain success, which base-update-wait.ts says instead.
|
|
18
|
+
*/
|
|
19
|
+
export declare const latestChangesConflictsMessage: (count: number | undefined) => string;
|
|
20
|
+
/** The bar's "Needs action" detail while the branch's record of conflicts is held. */
|
|
21
|
+
export declare const conflictRecordHeldMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved until an admin releases the hold.";
|
|
22
|
+
/** The same, to an admin, who has the way out beside it. */
|
|
23
|
+
export declare const conflictRecordHeldAdminMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved. Use \"Release hold\" to let it go on.";
|
|
24
|
+
/** What "Release hold" gives up, in its confirm. */
|
|
25
|
+
export declare const releaseHoldConsequences = "Releasing lets this branch be submitted and approved again. The list of fields that needed a choice is lost for good: each field keeps the value it has now, and no one will be asked to choose. Check the branch's changes first.";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the editor says about a branch's open conflicts outside "Show changes": the open entry's
|
|
3
|
+
* notice, Submit's reason, the bar's badge and the "Get latest changes" toast. A count is of the
|
|
4
|
+
* choices left (`MergeConflictsData.openCount`): one per field, and one per entry-level conflict
|
|
5
|
+
* (`openConflictCount`, merge-conflicts.ts). No copy names a
|
|
6
|
+
* branch: an author may not know the branch theirs was cut from.
|
|
7
|
+
*/
|
|
8
|
+
/** "1 field needs", "2 fields need". */
|
|
9
|
+
const fieldsNeed = (count) => count === 1 ? '1 field needs' : `${count} fields need`;
|
|
10
|
+
const CHOOSE = 'Choose what to keep in "Show changes".';
|
|
11
|
+
/** The notice above an entry's form while it has conflicts; the list item when it is loaded. */
|
|
12
|
+
export function entryConflictNotice(item) {
|
|
13
|
+
switch (item?.kind) {
|
|
14
|
+
case 'deleted-on-base':
|
|
15
|
+
return `This entry was deleted in the latest version, but you changed it. ${CHOOSE}`;
|
|
16
|
+
case 'unreadable':
|
|
17
|
+
case 'deleted-on-branch':
|
|
18
|
+
return `This entry was changed by you and by someone else. ${CHOOSE}`;
|
|
19
|
+
case 'fields':
|
|
20
|
+
return `${item.fieldCount === 1 ? '1 field was' : `${item.fieldCount} fields were`} changed by you and by someone else. ${CHOOSE}`;
|
|
21
|
+
default:
|
|
22
|
+
return `Some fields were changed by you and by someone else. ${CHOOSE}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Why Submit for review waits while choices are open. */
|
|
26
|
+
export const conflictsSubmitReason = (count) => `${fieldsNeed(count)} your choice before this branch can be submitted. Choose what to keep in "Show changes" on each flagged entry.`;
|
|
27
|
+
/** The bar's "Needs action" detail while choices are open. */
|
|
28
|
+
export const conflictsHoldMessage = (count) => `${fieldsNeed(count)} your choice: changed by you and by someone else. Choose what to keep in "Show changes" on each flagged entry, then submit.`;
|
|
29
|
+
/**
|
|
30
|
+
* The "Get latest changes" toast when the merge left choices; undefined: not yet counted. None
|
|
31
|
+
* left is a plain success, which base-update-wait.ts says instead.
|
|
32
|
+
*/
|
|
33
|
+
export const latestChangesConflictsMessage = (count) => `Latest changes added. ${count === undefined ? 'Some fields need' : fieldsNeed(count)} your choice.`;
|
|
34
|
+
/** The bar's "Needs action" detail while the branch's record of conflicts is held. */
|
|
35
|
+
export const conflictRecordHeldMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved until an admin releases the hold.";
|
|
36
|
+
/** The same, to an admin, who has the way out beside it. */
|
|
37
|
+
export const conflictRecordHeldAdminMessage = "This branch's record of conflicts was damaged and set aside, so it's on hold: it can't be submitted or approved. Use \"Release hold\" to let it go on.";
|
|
38
|
+
/** What "Release hold" gives up, in its confirm. */
|
|
39
|
+
export const releaseHoldConsequences = "Releasing lets this branch be submitted and approved again. The list of fields that needed a choice is lost for good: each field keeps the value it has now, and no one will be asked to choose. Check the branch's changes first.";
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const EDITOR_ACTIONS: {
|
|
|
9
9
|
readonly refreshFromServer: "Refresh from server";
|
|
10
10
|
readonly submitForReview: "Submit for review";
|
|
11
11
|
readonly withdrawFromReview: "Withdraw from review";
|
|
12
|
+
readonly getLatestChanges: "Get latest changes";
|
|
12
13
|
readonly approve: "Approve";
|
|
13
14
|
readonly review: "Review";
|
|
14
15
|
readonly requestChanges: "Request changes";
|
|
@@ -28,7 +29,18 @@ export declare const EDITOR_ACTIONS: {
|
|
|
28
29
|
readonly redoAllChanges: "Redo all changes";
|
|
29
30
|
readonly restoreEntry: "Restore entry";
|
|
30
31
|
readonly deleteEntry: "Delete entry";
|
|
32
|
+
readonly useLatestVersion: "Use latest version";
|
|
33
|
+
readonly keepYourVersion: "Keep your version";
|
|
34
|
+
readonly useBoth: "Use both";
|
|
35
|
+
readonly markResolved: "Mark resolved";
|
|
36
|
+
readonly releaseHold: "Release hold";
|
|
37
|
+
readonly removeIt: "Remove it";
|
|
38
|
+
readonly keepIt: "Keep it";
|
|
39
|
+
readonly keepItDeleted: "Keep it deleted";
|
|
40
|
+
readonly chooseAgain: "Choose again";
|
|
31
41
|
};
|
|
42
|
+
/** Withdraw's confirm on a branch that conflicts with its base, which then gets the latest changes. */
|
|
43
|
+
export declare const WITHDRAW_AND_GET_LATEST = "Withdraw and get latest changes";
|
|
32
44
|
/**
|
|
33
45
|
* Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
|
|
34
46
|
* `one` and `many` name what the list holds.
|
package/dist/editor/copy.js
CHANGED
|
@@ -9,6 +9,7 @@ export const EDITOR_ACTIONS = {
|
|
|
9
9
|
refreshFromServer: 'Refresh from server',
|
|
10
10
|
submitForReview: 'Submit for review',
|
|
11
11
|
withdrawFromReview: 'Withdraw from review',
|
|
12
|
+
getLatestChanges: 'Get latest changes',
|
|
12
13
|
approve: 'Approve',
|
|
13
14
|
review: 'Review',
|
|
14
15
|
requestChanges: 'Request changes',
|
|
@@ -28,7 +29,18 @@ export const EDITOR_ACTIONS = {
|
|
|
28
29
|
redoAllChanges: 'Redo all changes',
|
|
29
30
|
restoreEntry: 'Restore entry',
|
|
30
31
|
deleteEntry: 'Delete entry',
|
|
32
|
+
useLatestVersion: 'Use latest version',
|
|
33
|
+
keepYourVersion: 'Keep your version',
|
|
34
|
+
useBoth: 'Use both',
|
|
35
|
+
markResolved: 'Mark resolved',
|
|
36
|
+
releaseHold: 'Release hold',
|
|
37
|
+
removeIt: 'Remove it',
|
|
38
|
+
keepIt: 'Keep it',
|
|
39
|
+
keepItDeleted: 'Keep it deleted',
|
|
40
|
+
chooseAgain: 'Choose again',
|
|
31
41
|
};
|
|
42
|
+
/** Withdraw's confirm on a branch that conflicts with its base, which then gets the latest changes. */
|
|
43
|
+
export const WITHDRAW_AND_GET_LATEST = 'Withdraw and get latest changes';
|
|
32
44
|
/**
|
|
33
45
|
* Why a list at its `maxItems` adds nothing more, as a disabled Add's Tooltip or an input's hint.
|
|
34
46
|
* `one` and `many` name what the list holds.
|
|
@@ -151,8 +151,9 @@ export interface EntryDiffInput {
|
|
|
151
151
|
* text's spacing, so one side read back from its parts would not be the text it was.
|
|
152
152
|
* Undefined once `deadline` (a `Date.now()`) passes: no line diff starts after it. A short word
|
|
153
153
|
* diff runs until the later `wordDeadline`.
|
|
154
|
-
* @internal Exported for tests.
|
|
155
154
|
*/
|
|
156
155
|
export declare function textDiff(before: string, after: string, long?: boolean, deadline?: number, wordDeadline?: number): TextDiff | undefined;
|
|
156
|
+
/** `value` of `field` as the drawer shows it whole, for a value a conflict compares whole. */
|
|
157
|
+
export declare function displayWhole(field: FieldConfig | undefined, value: unknown, referenceTitle?: (id: string) => string | undefined): DisplayValue;
|
|
157
158
|
/** The entry's changes between `before` and `after`, in schema order. */
|
|
158
159
|
export declare function computeEntryDiff({ fields, before, after, referenceTitle, marks, }: EntryDiffInput): EntryDiff;
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* so a reordered item reads as moved, not as one removed and one added.
|
|
6
6
|
*/
|
|
7
7
|
import { diffLines, diffWordsWithSpace } from 'diff';
|
|
8
|
-
import equal from 'fast-deep-equal';
|
|
9
8
|
import { ITEM_KEY } from '../item-keys.js';
|
|
9
|
+
import { isEmptyValue as isEmpty, normalizedValue as normalized, sameValue, } from '../merge/value-equality.js';
|
|
10
10
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
11
11
|
import { isCalendarDay, localMidnight } from '../utils/calendar-day.js';
|
|
12
12
|
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
@@ -40,25 +40,6 @@ const formValue = (fields, side) => {
|
|
|
40
40
|
return side.data;
|
|
41
41
|
return { ...side.data, [side.bodyFieldName ?? findBodyFieldName(fields)]: side.body };
|
|
42
42
|
};
|
|
43
|
-
const isEmpty = (value) => value === undefined ||
|
|
44
|
-
value === null ||
|
|
45
|
-
value === '' ||
|
|
46
|
-
(Array.isArray(value) && value.length === 0) ||
|
|
47
|
-
(isPlainRecord(value) && Object.keys(value).every((k) => k === ITEM_KEY || isEmpty(value[k])));
|
|
48
|
-
/** `value` without item keys and empty members, so equal content compares equal. */
|
|
49
|
-
const normalized = (value) => {
|
|
50
|
-
if (Array.isArray(value))
|
|
51
|
-
return value.map(normalized);
|
|
52
|
-
if (!isPlainRecord(value))
|
|
53
|
-
return value;
|
|
54
|
-
const out = {};
|
|
55
|
-
for (const [k, v] of Object.entries(value)) {
|
|
56
|
-
if (k !== ITEM_KEY && !isEmpty(v))
|
|
57
|
-
out[k] = normalized(v);
|
|
58
|
-
}
|
|
59
|
-
return out;
|
|
60
|
-
};
|
|
61
|
-
const sameValue = (a, b) => (isEmpty(a) && isEmpty(b)) || equal(normalized(a), normalized(b));
|
|
62
43
|
const at = (parent, label, key, segment) => ({
|
|
63
44
|
path: [...parent.path, label],
|
|
64
45
|
key: parent.key ? `${parent.key}.${key}` : key,
|
|
@@ -505,7 +486,6 @@ const splitLines = (value) => {
|
|
|
505
486
|
* text's spacing, so one side read back from its parts would not be the text it was.
|
|
506
487
|
* Undefined once `deadline` (a `Date.now()`) passes: no line diff starts after it. A short word
|
|
507
488
|
* diff runs until the later `wordDeadline`.
|
|
508
|
-
* @internal Exported for tests.
|
|
509
489
|
*/
|
|
510
490
|
export function textDiff(before, after, long = false, deadline = Date.now() + DIFF_TIMEOUT_MS, wordDeadline = Date.now() + SHORT_DIFF_TIMEOUT_MS) {
|
|
511
491
|
// jsdiff restarts its clock on each call, so each is given only what is left of the budget.
|
|
@@ -593,6 +573,10 @@ const withContext = (lines) => {
|
|
|
593
573
|
}
|
|
594
574
|
return out;
|
|
595
575
|
};
|
|
576
|
+
/** `value` of `field` as the drawer shows it whole, for a value a conflict compares whole. */
|
|
577
|
+
export function displayWhole(field, value, referenceTitle) {
|
|
578
|
+
return new Walker(referenceTitle, 0, 0).display(field, value);
|
|
579
|
+
}
|
|
596
580
|
/** The entry's changes between `before` and `after`, in schema order. */
|
|
597
581
|
export function computeEntryDiff({ fields, before, after, referenceTitle, marks = true, }) {
|
|
598
582
|
// One budget for the whole entry: see DIFF_TIMEOUT_MS and SHORT_DIFF_TIMEOUT_MS. Without
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BranchListItem } from '../../api/branch.js';
|
|
2
|
+
import type { OperatingMode } from '../../operating-mode/types.js';
|
|
3
|
+
import { type BaseUpdateWait } from '../base-update-wait.js';
|
|
4
|
+
export interface UseBaseUpdateOptions {
|
|
5
|
+
/** The open branch. */
|
|
6
|
+
branch: BranchListItem | undefined;
|
|
7
|
+
operatingMode: OperatingMode;
|
|
8
|
+
/** The user may take the branch's workflow actions, which asking for the latest changes is. */
|
|
9
|
+
permitted: boolean;
|
|
10
|
+
/** Entries with unsaved changes on the branch; asking waits until they are saved. */
|
|
11
|
+
unsavedCount: number;
|
|
12
|
+
/** Re-read the branch's entries and the open one, keeping drafts: the worker rewrote them. */
|
|
13
|
+
onUpdated: () => void;
|
|
14
|
+
/** Opens the branch's conflicts, offered by the toast of a merge that left some. */
|
|
15
|
+
onShowConflicts?: () => void;
|
|
16
|
+
}
|
|
17
|
+
export interface UseBaseUpdateReturn {
|
|
18
|
+
/** "Get latest changes" applies here: a worker answers, the branch is editing, the user may ask. */
|
|
19
|
+
available: boolean;
|
|
20
|
+
/** The open branch's wait; `waiting` makes its form read-only. */
|
|
21
|
+
wait: BaseUpdateWait;
|
|
22
|
+
/** The open branch's request itself is in flight. */
|
|
23
|
+
requesting: boolean;
|
|
24
|
+
/** Why it can't be asked now; undefined when it can. */
|
|
25
|
+
blockedReason?: string;
|
|
26
|
+
/** Asks for the open branch, unless unsaved changes hold it. */
|
|
27
|
+
request: () => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
|
|
31
|
+
* waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
|
|
32
|
+
* reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
|
|
33
|
+
* clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
|
|
34
|
+
* it lands after it.
|
|
35
|
+
*/
|
|
36
|
+
export declare function useBaseUpdate(options: UseBaseUpdateOptions): UseBaseUpdateReturn;
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { useSWRConfig } from 'swr';
|
|
5
|
+
import { notifications } from '@mantine/notifications';
|
|
6
|
+
import { clientOperatingStrategy } from '../../operating-mode/client.js';
|
|
7
|
+
import { isBaseUpdatePending } from '../../base-update.js';
|
|
8
|
+
import { useApiClient } from '../context/index.js';
|
|
9
|
+
import { getErrorMessage } from '../../utils/error.js';
|
|
10
|
+
import { BRANCHES_KEY } from './useBranchesData.js';
|
|
11
|
+
import { RetryToastMessage } from '../components/RetryToastMessage.js';
|
|
12
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
13
|
+
import { GAVE_UP_MESSAGE, REQUEST_FAILED_MESSAGE, SAVE_FIRST_REASON, UNANSWERED_MESSAGE, baseUpdatePollMs, baseUpdateResultMessage, baseUpdateWait, nextBaseUpdateWaitChange, } from '../base-update-wait.js';
|
|
14
|
+
import { serverNow } from '../server-clock.js';
|
|
15
|
+
import { UnansweredError, isGatewayFailure, isUnanswered } from '../unanswered.js';
|
|
16
|
+
const RESULT_TOAST_ID = 'canopy-base-update-result';
|
|
17
|
+
/** Writes the branch a request answered into the listing, so its wait shows at once. */
|
|
18
|
+
function withBranch(data, branch) {
|
|
19
|
+
if (!data)
|
|
20
|
+
return data;
|
|
21
|
+
return { ...data, branches: data.branches.map((b) => (b.name === branch.name ? branch : b)) };
|
|
22
|
+
}
|
|
23
|
+
/** Whether `a` is a later request time than `b`, an absent one being earliest. */
|
|
24
|
+
const laterThan = (a, b) => b === undefined || Date.parse(a) > Date.parse(b);
|
|
25
|
+
const sameBranch = (branch, record) => record?.name === branch.name && record.createdAt === branch.createdAt;
|
|
26
|
+
/** The listed branch, carrying this tab's newer request. */
|
|
27
|
+
function withAsked(branch, asked) {
|
|
28
|
+
if (!branch || !sameBranch(branch, asked))
|
|
29
|
+
return branch;
|
|
30
|
+
return laterThan(asked.requestedAt, branch.baseUpdateRequestedAt)
|
|
31
|
+
? { ...branch, baseUpdateRequestedAt: asked.requestedAt }
|
|
32
|
+
: branch;
|
|
33
|
+
}
|
|
34
|
+
/** The branch, carrying the answer this tab already reported when the listing hasn't caught up. */
|
|
35
|
+
function withSettled(branch, settled) {
|
|
36
|
+
if (!branch || !sameBranch(branch, settled) || !isBaseUpdatePending(branch))
|
|
37
|
+
return branch;
|
|
38
|
+
return laterThan(branch.baseUpdateRequestedAt ?? '', settled.requestedAt) &&
|
|
39
|
+
branch.baseUpdateRequestedAt !== undefined
|
|
40
|
+
? branch
|
|
41
|
+
: {
|
|
42
|
+
...branch,
|
|
43
|
+
baseUpdateRequestedAt: settled.requestedAt,
|
|
44
|
+
lastBaseUpdate: settled.lastBaseUpdate,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A toast in the one slot this hook uses, replacing what it showed before. `onAction` offers
|
|
49
|
+
* Retry, or `actionLabel`.
|
|
50
|
+
*/
|
|
51
|
+
function showResult(color, message, onAction, actionLabel) {
|
|
52
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
53
|
+
notifications.show({
|
|
54
|
+
id: RESULT_TOAST_ID,
|
|
55
|
+
color,
|
|
56
|
+
autoClose: color === 'green' ? undefined : false,
|
|
57
|
+
message: onAction ? (_jsx(RetryToastMessage, { message: message, onRetry: onAction, actionLabel: actionLabel })) : (message),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
|
|
62
|
+
* waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
|
|
63
|
+
* reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
|
|
64
|
+
* clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
|
|
65
|
+
* it lands after it.
|
|
66
|
+
*/
|
|
67
|
+
export function useBaseUpdate(options) {
|
|
68
|
+
const { operatingMode, permitted, unsavedCount, onUpdated } = options;
|
|
69
|
+
const apiClient = useApiClient();
|
|
70
|
+
const { mutate } = useSWRConfig();
|
|
71
|
+
const [requestingFor, setRequestingFor] = useState(null);
|
|
72
|
+
// The last request this tab made, by branch, which a listing that predates it can't undo.
|
|
73
|
+
const [asked, setAsked] = useState(null);
|
|
74
|
+
// The last answer this tab reported, by branch, which a listing that predates it can't undo:
|
|
75
|
+
// another host can serve the branch list from an older snapshot.
|
|
76
|
+
const [settled, setSettled] = useState(null);
|
|
77
|
+
const [, setTick] = useState(0);
|
|
78
|
+
const listed = options.branch;
|
|
79
|
+
const name = listed?.name;
|
|
80
|
+
const branch = withSettled(withAsked(listed, asked), settled);
|
|
81
|
+
const available = !!branch &&
|
|
82
|
+
clientOperatingStrategy(operatingMode).syncsBranchesWithBase() &&
|
|
83
|
+
branch.status === 'editing' &&
|
|
84
|
+
branch.isProtected === false &&
|
|
85
|
+
permitted;
|
|
86
|
+
const wait = baseUpdateWait(branch, serverNow());
|
|
87
|
+
const requestedAt = branch?.baseUpdateRequestedAt;
|
|
88
|
+
const answer = branch?.lastBaseUpdate;
|
|
89
|
+
const status = branch?.status;
|
|
90
|
+
const createdAt = branch?.createdAt;
|
|
91
|
+
// The wait changes on its own at the slow and give-up marks; nothing else would re-render then.
|
|
92
|
+
const changeAt = nextBaseUpdateWaitChange(branch, serverNow());
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (changeAt === undefined)
|
|
95
|
+
return;
|
|
96
|
+
const timer = setTimeout(() => setTick((n) => n + 1), Math.max(0, changeAt - serverNow()) + 50);
|
|
97
|
+
return () => clearTimeout(timer);
|
|
98
|
+
}, [changeAt]);
|
|
99
|
+
// Re-read the listing while the branch waits, on a timer of its own: SWR stops polling a key
|
|
100
|
+
// whose last fetch failed, and one failed read must not end the wait. Each read starts only once
|
|
101
|
+
// the last has settled, since SWR drops an in-flight read's result when another starts.
|
|
102
|
+
const pollMs = baseUpdatePollMs(wait);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (pollMs === 0)
|
|
105
|
+
return;
|
|
106
|
+
let stopped = false;
|
|
107
|
+
let timer;
|
|
108
|
+
const next = () => {
|
|
109
|
+
timer = setTimeout(async () => {
|
|
110
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'hidden') {
|
|
111
|
+
await mutate(BRANCHES_KEY).catch(() => undefined);
|
|
112
|
+
}
|
|
113
|
+
if (!stopped)
|
|
114
|
+
next();
|
|
115
|
+
}, pollMs);
|
|
116
|
+
};
|
|
117
|
+
next();
|
|
118
|
+
return () => {
|
|
119
|
+
stopped = true;
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
};
|
|
122
|
+
}, [pollMs, mutate]);
|
|
123
|
+
const latest = useRef({ name, unsavedCount, available });
|
|
124
|
+
latest.current = { name, unsavedCount, available };
|
|
125
|
+
const onUpdatedRef = useRef(onUpdated);
|
|
126
|
+
onUpdatedRef.current = onUpdated;
|
|
127
|
+
const onShowConflictsRef = useRef(options.onShowConflicts);
|
|
128
|
+
onShowConflictsRef.current = options.onShowConflicts;
|
|
129
|
+
const requestRef = useRef(async () => { });
|
|
130
|
+
// Stable, and reads only refs, so the toasts that carry it keep working.
|
|
131
|
+
const retry = useCallback(() => {
|
|
132
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
133
|
+
void requestRef.current();
|
|
134
|
+
}, []);
|
|
135
|
+
const report = useCallback((on, result) => {
|
|
136
|
+
setSettled({ ...on, lastBaseUpdate: result });
|
|
137
|
+
if (result.outcome !== 'failed')
|
|
138
|
+
onUpdatedRef.current();
|
|
139
|
+
if (result.outcome === 'conflicts') {
|
|
140
|
+
// The toast counts the choices left, which the branch's conflicts hold.
|
|
141
|
+
void apiClient.conflicts
|
|
142
|
+
.list({ branch: on.name })
|
|
143
|
+
.then((answer) => (answer.ok ? answer.data?.openCount : undefined))
|
|
144
|
+
.catch(() => undefined)
|
|
145
|
+
.then((count) => {
|
|
146
|
+
if (latest.current.name !== on.name)
|
|
147
|
+
return;
|
|
148
|
+
const { color, message } = baseUpdateResultMessage(result, count);
|
|
149
|
+
const show = () => {
|
|
150
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
151
|
+
onShowConflictsRef.current?.();
|
|
152
|
+
};
|
|
153
|
+
showResult(color, message, onShowConflictsRef.current && count !== 0 ? show : undefined, EDITOR_ACTIONS.showChanges);
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const { color, message } = baseUpdateResultMessage(result);
|
|
158
|
+
// Retry only for someone who may ask; another viewer just learns how it went.
|
|
159
|
+
const canRetry = result.outcome === 'failed' && latest.current.available;
|
|
160
|
+
showResult(color, message, canRetry ? retry : undefined);
|
|
161
|
+
}, [apiClient, retry]);
|
|
162
|
+
// A result is about the branch it came from: switching away closes it.
|
|
163
|
+
useEffect(() => () => {
|
|
164
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
165
|
+
}, [name]);
|
|
166
|
+
// A wait this tab saw, by branch and request, reports when its answer lands, even after the
|
|
167
|
+
// editor stopped waiting on it; giving up says so, once.
|
|
168
|
+
const watched = useRef(undefined);
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (!name || !requestedAt)
|
|
171
|
+
return;
|
|
172
|
+
const seen = watched.current;
|
|
173
|
+
const same = seen?.name === name && seen.requestedAt === requestedAt;
|
|
174
|
+
if (wait !== 'none') {
|
|
175
|
+
if (!same)
|
|
176
|
+
watched.current = { name, requestedAt, gaveUp: false };
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!seen || !same)
|
|
180
|
+
return;
|
|
181
|
+
if (answer &&
|
|
182
|
+
!isBaseUpdatePending({ baseUpdateRequestedAt: requestedAt, lastBaseUpdate: answer })) {
|
|
183
|
+
watched.current = undefined;
|
|
184
|
+
report({ name, createdAt: createdAt ?? '', requestedAt }, answer);
|
|
185
|
+
}
|
|
186
|
+
else if (!seen.gaveUp && status === 'editing') {
|
|
187
|
+
seen.gaveUp = true;
|
|
188
|
+
showResult('yellow', GAVE_UP_MESSAGE);
|
|
189
|
+
}
|
|
190
|
+
}, [name, createdAt, requestedAt, answer, wait, status, report]);
|
|
191
|
+
const request = useCallback(async () => {
|
|
192
|
+
const target = latest.current.name;
|
|
193
|
+
if (!target)
|
|
194
|
+
return;
|
|
195
|
+
if (latest.current.unsavedCount > 0) {
|
|
196
|
+
showResult('yellow', SAVE_FIRST_REASON);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// What lands after the user moved to another branch is about that one, not this.
|
|
200
|
+
const stillOpen = () => latest.current.name === target;
|
|
201
|
+
setRequestingFor(target);
|
|
202
|
+
try {
|
|
203
|
+
try {
|
|
204
|
+
const result = await apiClient.workflow.updateFromBase({ branch: target });
|
|
205
|
+
if (isGatewayFailure(result))
|
|
206
|
+
throw new UnansweredError();
|
|
207
|
+
if (result.ok && result.data) {
|
|
208
|
+
const { branch: answered, requestedAt: at } = result.data;
|
|
209
|
+
await mutate(BRANCHES_KEY, (data) => withBranch(data, answered), {
|
|
210
|
+
revalidate: false,
|
|
211
|
+
});
|
|
212
|
+
if (!stillOpen())
|
|
213
|
+
return;
|
|
214
|
+
const on = { name: target, createdAt: answered.createdAt, requestedAt: at };
|
|
215
|
+
setAsked(on);
|
|
216
|
+
if (!isBaseUpdatePending(answered) && answered.lastBaseUpdate) {
|
|
217
|
+
report(on, answered.lastBaseUpdate);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
watched.current = { name: target, requestedAt: at, gaveUp: false };
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// A refusal can mean the branch moved on (submitted meanwhile): re-read it, and offer Retry
|
|
225
|
+
// only if it can still ask.
|
|
226
|
+
const fresh = await mutate(BRANCHES_KEY);
|
|
227
|
+
if (!stillOpen())
|
|
228
|
+
return;
|
|
229
|
+
const now = fresh?.branches.find((b) => b.name === target);
|
|
230
|
+
const canRetry = now === undefined || now.status === 'editing';
|
|
231
|
+
showResult('red', result.error ?? REQUEST_FAILED_MESSAGE, canRetry ? retry : undefined);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (!isUnanswered(err))
|
|
235
|
+
throw err;
|
|
236
|
+
// It may have gone through, and a reload that shows the wait settles it.
|
|
237
|
+
const fresh = await mutate(BRANCHES_KEY);
|
|
238
|
+
if (!stillOpen())
|
|
239
|
+
return;
|
|
240
|
+
const reloaded = fresh?.branches.find((b) => b.name === target);
|
|
241
|
+
if (baseUpdateWait(reloaded, serverNow()) === 'none') {
|
|
242
|
+
showResult('yellow', UNANSWERED_MESSAGE, retry);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (!stillOpen())
|
|
248
|
+
return;
|
|
249
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
250
|
+
notifications.show({
|
|
251
|
+
id: RESULT_TOAST_ID,
|
|
252
|
+
color: 'red',
|
|
253
|
+
autoClose: false,
|
|
254
|
+
message: (_jsx(RetryToastMessage, { message: REQUEST_FAILED_MESSAGE, onRetry: retry, details: getErrorMessage(err) })),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
setRequestingFor((current) => (current === target ? null : current));
|
|
259
|
+
}
|
|
260
|
+
}, [apiClient, mutate, report, retry]);
|
|
261
|
+
requestRef.current = request;
|
|
262
|
+
return {
|
|
263
|
+
available,
|
|
264
|
+
wait,
|
|
265
|
+
requesting: !!name && requestingFor === name,
|
|
266
|
+
blockedReason: unsavedCount > 0 ? SAVE_FIRST_REASON : undefined,
|
|
267
|
+
request,
|
|
268
|
+
};
|
|
269
|
+
}
|