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,38 @@
|
|
|
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
|
+
/** The base has changes the branch's saved work keeps the automatic sync from bringing in. */
|
|
29
|
+
latestChangesWaiting: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
|
|
33
|
+
* waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
|
|
34
|
+
* reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
|
|
35
|
+
* clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
|
|
36
|
+
* it lands after it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function useBaseUpdate(options: UseBaseUpdateOptions): UseBaseUpdateReturn;
|
|
@@ -0,0 +1,279 @@
|
|
|
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))
|
|
37
|
+
return branch;
|
|
38
|
+
// Read before the answer, so what it says was waiting has come in.
|
|
39
|
+
const cameIn = settled.lastBaseUpdate.outcome !== 'failed' ? { latestChangesWaiting: undefined } : {};
|
|
40
|
+
if (!isBaseUpdatePending(branch)) {
|
|
41
|
+
return laterThan(settled.requestedAt, branch.baseUpdateRequestedAt)
|
|
42
|
+
? { ...branch, ...cameIn }
|
|
43
|
+
: branch;
|
|
44
|
+
}
|
|
45
|
+
return laterThan(branch.baseUpdateRequestedAt ?? '', settled.requestedAt) &&
|
|
46
|
+
branch.baseUpdateRequestedAt !== undefined
|
|
47
|
+
? branch
|
|
48
|
+
: {
|
|
49
|
+
...branch,
|
|
50
|
+
baseUpdateRequestedAt: settled.requestedAt,
|
|
51
|
+
lastBaseUpdate: settled.lastBaseUpdate,
|
|
52
|
+
...cameIn,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A toast in the one slot this hook uses, replacing what it showed before. `onAction` offers
|
|
57
|
+
* Retry, or `actionLabel`.
|
|
58
|
+
*/
|
|
59
|
+
function showResult(color, message, onAction, actionLabel) {
|
|
60
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
61
|
+
notifications.show({
|
|
62
|
+
id: RESULT_TOAST_ID,
|
|
63
|
+
color,
|
|
64
|
+
autoClose: color === 'green' ? undefined : false,
|
|
65
|
+
message: onAction ? (_jsx(RetryToastMessage, { message: message, onRetry: onAction, actionLabel: actionLabel })) : (message),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
|
|
70
|
+
* waits, and reports the worker's answer in a toast once it lands. Only a wait this tab saw end
|
|
71
|
+
* reports, so a reload onto an answered request stays quiet. The wait is timed by the server's
|
|
72
|
+
* clock (server-clock.ts), and a request this tab made counts even when a listing fetched before
|
|
73
|
+
* it lands after it.
|
|
74
|
+
*/
|
|
75
|
+
export function useBaseUpdate(options) {
|
|
76
|
+
const { operatingMode, permitted, unsavedCount, onUpdated } = options;
|
|
77
|
+
const apiClient = useApiClient();
|
|
78
|
+
const { mutate } = useSWRConfig();
|
|
79
|
+
const [requestingFor, setRequestingFor] = useState(null);
|
|
80
|
+
// The last request this tab made, by branch, which a listing that predates it can't undo.
|
|
81
|
+
const [asked, setAsked] = useState(null);
|
|
82
|
+
// The last answer this tab reported, by branch, which a listing that predates it can't undo:
|
|
83
|
+
// another host can serve the branch list from an older snapshot.
|
|
84
|
+
const [settled, setSettled] = useState(null);
|
|
85
|
+
const [, setTick] = useState(0);
|
|
86
|
+
const listed = options.branch;
|
|
87
|
+
const name = listed?.name;
|
|
88
|
+
const branch = withSettled(withAsked(listed, asked), settled);
|
|
89
|
+
const available = !!branch &&
|
|
90
|
+
clientOperatingStrategy(operatingMode).syncsBranchesWithBase() &&
|
|
91
|
+
branch.status === 'editing' &&
|
|
92
|
+
branch.isProtected === false &&
|
|
93
|
+
permitted;
|
|
94
|
+
const wait = baseUpdateWait(branch, serverNow());
|
|
95
|
+
const requestedAt = branch?.baseUpdateRequestedAt;
|
|
96
|
+
const answer = branch?.lastBaseUpdate;
|
|
97
|
+
const status = branch?.status;
|
|
98
|
+
const createdAt = branch?.createdAt;
|
|
99
|
+
// The wait changes on its own at the slow and give-up marks; nothing else would re-render then.
|
|
100
|
+
const changeAt = nextBaseUpdateWaitChange(branch, serverNow());
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (changeAt === undefined)
|
|
103
|
+
return;
|
|
104
|
+
const timer = setTimeout(() => setTick((n) => n + 1), Math.max(0, changeAt - serverNow()) + 50);
|
|
105
|
+
return () => clearTimeout(timer);
|
|
106
|
+
}, [changeAt]);
|
|
107
|
+
// Re-read the listing while the branch waits, on a timer of its own: SWR stops polling a key
|
|
108
|
+
// whose last fetch failed, and one failed read must not end the wait. Each read starts only once
|
|
109
|
+
// the last has settled, since SWR drops an in-flight read's result when another starts.
|
|
110
|
+
const pollMs = baseUpdatePollMs(wait);
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (pollMs === 0)
|
|
113
|
+
return;
|
|
114
|
+
let stopped = false;
|
|
115
|
+
let timer;
|
|
116
|
+
const next = () => {
|
|
117
|
+
timer = setTimeout(async () => {
|
|
118
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'hidden') {
|
|
119
|
+
await mutate(BRANCHES_KEY).catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
if (!stopped)
|
|
122
|
+
next();
|
|
123
|
+
}, pollMs);
|
|
124
|
+
};
|
|
125
|
+
next();
|
|
126
|
+
return () => {
|
|
127
|
+
stopped = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
};
|
|
130
|
+
}, [pollMs, mutate]);
|
|
131
|
+
const latest = useRef({ name, unsavedCount, available });
|
|
132
|
+
latest.current = { name, unsavedCount, available };
|
|
133
|
+
const onUpdatedRef = useRef(onUpdated);
|
|
134
|
+
onUpdatedRef.current = onUpdated;
|
|
135
|
+
const onShowConflictsRef = useRef(options.onShowConflicts);
|
|
136
|
+
onShowConflictsRef.current = options.onShowConflicts;
|
|
137
|
+
const requestRef = useRef(async () => { });
|
|
138
|
+
// Stable, and reads only refs, so the toasts that carry it keep working.
|
|
139
|
+
const retry = useCallback(() => {
|
|
140
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
141
|
+
void requestRef.current();
|
|
142
|
+
}, []);
|
|
143
|
+
const report = useCallback((on, result) => {
|
|
144
|
+
setSettled({ ...on, lastBaseUpdate: result });
|
|
145
|
+
if (result.outcome !== 'failed')
|
|
146
|
+
onUpdatedRef.current();
|
|
147
|
+
if (result.outcome === 'conflicts') {
|
|
148
|
+
// The toast counts the choices left, which the branch's conflicts hold.
|
|
149
|
+
void apiClient.conflicts
|
|
150
|
+
.list({ branch: on.name })
|
|
151
|
+
.then((answer) => (answer.ok ? answer.data?.openCount : undefined))
|
|
152
|
+
.catch(() => undefined)
|
|
153
|
+
.then((count) => {
|
|
154
|
+
if (latest.current.name !== on.name)
|
|
155
|
+
return;
|
|
156
|
+
const { color, message } = baseUpdateResultMessage(result, count);
|
|
157
|
+
const show = () => {
|
|
158
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
159
|
+
onShowConflictsRef.current?.();
|
|
160
|
+
};
|
|
161
|
+
showResult(color, message, onShowConflictsRef.current && count !== 0 ? show : undefined, EDITOR_ACTIONS.showChanges);
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const { color, message } = baseUpdateResultMessage(result);
|
|
166
|
+
// Retry only for someone who may ask; another viewer just learns how it went.
|
|
167
|
+
const canRetry = result.outcome === 'failed' && latest.current.available;
|
|
168
|
+
showResult(color, message, canRetry ? retry : undefined);
|
|
169
|
+
}, [apiClient, retry]);
|
|
170
|
+
// A result is about the branch it came from: switching away closes it.
|
|
171
|
+
useEffect(() => () => {
|
|
172
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
173
|
+
}, [name]);
|
|
174
|
+
// A wait this tab saw, by branch and request, reports when its answer lands, even after the
|
|
175
|
+
// editor stopped waiting on it; giving up says so, once.
|
|
176
|
+
const watched = useRef(undefined);
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!name || !requestedAt)
|
|
179
|
+
return;
|
|
180
|
+
const seen = watched.current;
|
|
181
|
+
const same = seen?.name === name && seen.requestedAt === requestedAt;
|
|
182
|
+
if (wait !== 'none') {
|
|
183
|
+
if (!same)
|
|
184
|
+
watched.current = { name, requestedAt, gaveUp: false };
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!seen || !same)
|
|
188
|
+
return;
|
|
189
|
+
if (answer &&
|
|
190
|
+
!isBaseUpdatePending({ baseUpdateRequestedAt: requestedAt, lastBaseUpdate: answer })) {
|
|
191
|
+
watched.current = undefined;
|
|
192
|
+
report({ name, createdAt: createdAt ?? '', requestedAt }, answer);
|
|
193
|
+
}
|
|
194
|
+
else if (!seen.gaveUp && status === 'editing') {
|
|
195
|
+
seen.gaveUp = true;
|
|
196
|
+
showResult('yellow', GAVE_UP_MESSAGE);
|
|
197
|
+
}
|
|
198
|
+
}, [name, createdAt, requestedAt, answer, wait, status, report]);
|
|
199
|
+
const request = useCallback(async () => {
|
|
200
|
+
const target = latest.current.name;
|
|
201
|
+
if (!target)
|
|
202
|
+
return;
|
|
203
|
+
if (latest.current.unsavedCount > 0) {
|
|
204
|
+
showResult('yellow', SAVE_FIRST_REASON);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
// What lands after the user moved to another branch is about that one, not this.
|
|
208
|
+
const stillOpen = () => latest.current.name === target;
|
|
209
|
+
setRequestingFor(target);
|
|
210
|
+
try {
|
|
211
|
+
try {
|
|
212
|
+
const result = await apiClient.workflow.updateFromBase({ branch: target });
|
|
213
|
+
if (isGatewayFailure(result))
|
|
214
|
+
throw new UnansweredError();
|
|
215
|
+
if (result.ok && result.data) {
|
|
216
|
+
const { branch: answered, requestedAt: at } = result.data;
|
|
217
|
+
await mutate(BRANCHES_KEY, (data) => withBranch(data, answered), {
|
|
218
|
+
revalidate: false,
|
|
219
|
+
});
|
|
220
|
+
if (!stillOpen())
|
|
221
|
+
return;
|
|
222
|
+
const on = { name: target, createdAt: answered.createdAt, requestedAt: at };
|
|
223
|
+
setAsked(on);
|
|
224
|
+
if (!isBaseUpdatePending(answered) && answered.lastBaseUpdate) {
|
|
225
|
+
report(on, answered.lastBaseUpdate);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
watched.current = { name: target, requestedAt: at, gaveUp: false };
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// A refusal can mean the branch moved on (submitted meanwhile): re-read it, and offer Retry
|
|
233
|
+
// only if it can still ask.
|
|
234
|
+
const fresh = await mutate(BRANCHES_KEY);
|
|
235
|
+
if (!stillOpen())
|
|
236
|
+
return;
|
|
237
|
+
const now = fresh?.branches.find((b) => b.name === target);
|
|
238
|
+
const canRetry = now === undefined || now.status === 'editing';
|
|
239
|
+
// A 409 is a conflict with the branch's state, which the guidelines colour yellow.
|
|
240
|
+
showResult(result.status === 409 ? 'yellow' : 'red', result.error ?? REQUEST_FAILED_MESSAGE, canRetry ? retry : undefined);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
if (!isUnanswered(err))
|
|
244
|
+
throw err;
|
|
245
|
+
// It may have gone through, and a reload that shows the wait settles it.
|
|
246
|
+
const fresh = await mutate(BRANCHES_KEY);
|
|
247
|
+
if (!stillOpen())
|
|
248
|
+
return;
|
|
249
|
+
const reloaded = fresh?.branches.find((b) => b.name === target);
|
|
250
|
+
if (baseUpdateWait(reloaded, serverNow()) === 'none') {
|
|
251
|
+
showResult('yellow', UNANSWERED_MESSAGE, retry);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
if (!stillOpen())
|
|
257
|
+
return;
|
|
258
|
+
notifications.hide(RESULT_TOAST_ID);
|
|
259
|
+
notifications.show({
|
|
260
|
+
id: RESULT_TOAST_ID,
|
|
261
|
+
color: 'red',
|
|
262
|
+
autoClose: false,
|
|
263
|
+
message: (_jsx(RetryToastMessage, { message: REQUEST_FAILED_MESSAGE, onRetry: retry, details: getErrorMessage(err) })),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
setRequestingFor((current) => (current === target ? null : current));
|
|
268
|
+
}
|
|
269
|
+
}, [apiClient, mutate, report, retry]);
|
|
270
|
+
requestRef.current = request;
|
|
271
|
+
return {
|
|
272
|
+
available,
|
|
273
|
+
wait,
|
|
274
|
+
requesting: !!name && requestingFor === name,
|
|
275
|
+
blockedReason: unsavedCount > 0 ? SAVE_FIRST_REASON : undefined,
|
|
276
|
+
request,
|
|
277
|
+
latestChangesWaiting: available && !!branch?.latestChangesWaiting,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -45,7 +45,13 @@ interface ChangesState<T, E> {
|
|
|
45
45
|
*/
|
|
46
46
|
reload: () => Promise<void>;
|
|
47
47
|
}
|
|
48
|
-
export type BranchChangesState = ChangesState<BranchChangesData, BranchChangesError
|
|
48
|
+
export type BranchChangesState = ChangesState<BranchChangesData, BranchChangesError> & {
|
|
49
|
+
/**
|
|
50
|
+
* Reads the list afresh, shown or not, rejecting when the read fails, and empties every entry
|
|
51
|
+
* change of the branch, so none shows a comparison read before this.
|
|
52
|
+
*/
|
|
53
|
+
reread: () => Promise<void>;
|
|
54
|
+
};
|
|
49
55
|
/**
|
|
50
56
|
* The branch's changes. `reload` also revalidates every entry change of the branch that is
|
|
51
57
|
* shown, so one save refreshes the list and an open "Show changes" alike.
|
|
@@ -98,12 +98,25 @@ export function useBranchChangesData(apiClient, branch, enabled) {
|
|
|
98
98
|
mutateKeys((k) => typeof k === 'string' && k.startsWith(entryPrefix)),
|
|
99
99
|
]);
|
|
100
100
|
}, [mutate, mutateKeys, branch]);
|
|
101
|
+
const reread = useCallback(async () => {
|
|
102
|
+
if (!branch)
|
|
103
|
+
return;
|
|
104
|
+
const entryPrefix = `${branchChangesKey(branch)}:`;
|
|
105
|
+
const [fresh] = await Promise.all([
|
|
106
|
+
fetchBranchChanges(apiClient, branch),
|
|
107
|
+
mutateKeys((k) => typeof k === 'string' && k.startsWith(entryPrefix), undefined, {
|
|
108
|
+
revalidate: true,
|
|
109
|
+
}),
|
|
110
|
+
]);
|
|
111
|
+
await mutateKeys(branchChangesKey(branch), fresh, { revalidate: false });
|
|
112
|
+
}, [apiClient, mutateKeys, branch]);
|
|
101
113
|
return {
|
|
102
114
|
data: swr.data,
|
|
103
115
|
error: key && swr.error !== undefined ? toError(swr.error) : undefined,
|
|
104
116
|
isLoading: swr.isLoading,
|
|
105
117
|
isValidating: swr.isValidating,
|
|
106
118
|
reload,
|
|
119
|
+
reread,
|
|
107
120
|
};
|
|
108
121
|
}
|
|
109
122
|
/** One entry's change on the branch, fetched while `target` is set (the drawer is open). */
|
|
@@ -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,13 @@ 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
|
+
/** A branch is being pinned for its reviews: read its content again if it is open, settling once read. */
|
|
96
|
+
onReviewPinned?: (branchName: string) => Promise<void> | void;
|
|
97
|
+
/**
|
|
98
|
+
* How to ask for the latest changes on `branchName` once it is withdrawn, or undefined where
|
|
99
|
+
* that can't apply. With it, withdrawing a branch that conflicts with its base asks right after.
|
|
100
|
+
*/
|
|
101
|
+
latestChangesAfterWithdraw?: (branchName: string) => (() => Promise<void>) | undefined;
|
|
95
102
|
}
|
|
96
103
|
export interface UseBranchManagerReturn {
|
|
97
104
|
/**
|
|
@@ -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';
|
|
@@ -32,6 +33,13 @@ class StaleStatusError extends Error {
|
|
|
32
33
|
this.name = 'StaleStatusError';
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
/** The API's `BRANCH_MOVED`: the branch's head isn't the one the reviewer's page was pinned to. */
|
|
37
|
+
class BranchMovedError extends Error {
|
|
38
|
+
constructor() {
|
|
39
|
+
super(...arguments);
|
|
40
|
+
this.name = 'BranchMovedError';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
35
43
|
/** An answer that refused the action, with its HTTP status: a 4xx is a refusal no retry passes. */
|
|
36
44
|
class RefusedError extends Error {
|
|
37
45
|
constructor(message, status) {
|
|
@@ -50,7 +58,13 @@ function actionError(result, fallback, staleIsConflict = false) {
|
|
|
50
58
|
const message = result.error || fallback;
|
|
51
59
|
if (result.ok)
|
|
52
60
|
return new Error(message);
|
|
53
|
-
|
|
61
|
+
if (result.code === 'BRANCH_MOVED')
|
|
62
|
+
return new BranchMovedError(message);
|
|
63
|
+
// Open conflicts refuse the action where the branch still is, so they are no stale status.
|
|
64
|
+
return staleIsConflict &&
|
|
65
|
+
result.status === 409 &&
|
|
66
|
+
result.code !== 'OPEN_CONFLICTS' &&
|
|
67
|
+
!isNonApiResponse(result)
|
|
54
68
|
? new StaleStatusError(message)
|
|
55
69
|
: new RefusedError(message, result.status);
|
|
56
70
|
}
|
|
@@ -142,16 +156,22 @@ export function withdrawPullRequestBullet(pr) {
|
|
|
142
156
|
return `Leave the merged ${label} as it is`;
|
|
143
157
|
return `Convert ${label} to a draft`;
|
|
144
158
|
}
|
|
145
|
-
const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo
|
|
159
|
+
const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss, returnFocusTo,
|
|
160
|
+
/** The branch conflicts with its base, so withdrawing also gets the latest changes. */
|
|
161
|
+
thenGetLatest = false) => {
|
|
146
162
|
const bullets = [
|
|
147
163
|
withdrawPullRequestBullet(pr),
|
|
148
164
|
'Change the branch status back to “editing”',
|
|
149
165
|
'Remove from review queue',
|
|
166
|
+
thenGetLatest ? 'Bring in the latest changes made on the site' : undefined,
|
|
150
167
|
].filter((b) => b !== undefined);
|
|
151
168
|
openConfirm({
|
|
152
169
|
title: EDITOR_ACTIONS.withdrawFromReview,
|
|
153
170
|
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: {
|
|
171
|
+
labels: {
|
|
172
|
+
confirm: thenGetLatest ? WITHDRAW_AND_GET_LATEST : EDITOR_ACTIONS.withdrawFromReview,
|
|
173
|
+
cancel: 'Cancel',
|
|
174
|
+
},
|
|
155
175
|
confirmProps: { color: 'orange', 'data-testid': 'confirm-withdraw-branch' },
|
|
156
176
|
...confirmModalHandlers(onConfirm, onDismiss),
|
|
157
177
|
...focusReturnProps(returnFocusTo),
|
|
@@ -467,6 +487,52 @@ export function useBranchManager(options) {
|
|
|
467
487
|
// these at confirm time rather than from that render's closure.
|
|
468
488
|
const latest = useRef({ branchName, resolved, branches, branchesData });
|
|
469
489
|
latest.current = { branchName, resolved, branches, branchesData };
|
|
490
|
+
// The head a review sends, never newer than what the page shows of the branch. One not shown
|
|
491
|
+
// follows each listing; opening one, a head listed for the open one while its pin is null, and a
|
|
492
|
+
// refusal's recovery pin it from a listing already fetched once its content is read again.
|
|
493
|
+
const reviewPins = useRef(new Map());
|
|
494
|
+
const shown = useRef(new Set());
|
|
495
|
+
const pinning = useRef(new Map());
|
|
496
|
+
const lastOpen = useRef(undefined);
|
|
497
|
+
const pinThenReread = async (branch, data) => {
|
|
498
|
+
const headSha = data?.branches.find((b) => b.name === branch)?.headSha;
|
|
499
|
+
const claim = Symbol(branch);
|
|
500
|
+
pinning.current.set(branch, claim);
|
|
501
|
+
reviewPins.current.set(branch, null);
|
|
502
|
+
try {
|
|
503
|
+
if (headSha)
|
|
504
|
+
await options.onReviewPinned?.(branch);
|
|
505
|
+
// A failed read leaves it null, for the next listing to try again.
|
|
506
|
+
if (pinning.current.get(branch) === claim)
|
|
507
|
+
reviewPins.current.set(branch, headSha ?? null);
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
if (pinning.current.get(branch) === claim)
|
|
511
|
+
pinning.current.delete(branch);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
const listed = branchesData?.branches ?? [];
|
|
516
|
+
for (const b of listed) {
|
|
517
|
+
if (!shown.current.has(b.name))
|
|
518
|
+
reviewPins.current.set(b.name, b.headSha ?? null);
|
|
519
|
+
}
|
|
520
|
+
const open = currentBranchName && listed.find((b) => b.name === currentBranchName);
|
|
521
|
+
if (!open)
|
|
522
|
+
return;
|
|
523
|
+
const firstHead = open.headSha && reviewPins.current.get(open.name) === null && !pinning.current.has(open.name);
|
|
524
|
+
// Coming back to a branch opens it again: its entries are read afresh, so its pin is too.
|
|
525
|
+
const reopened = lastOpen.current !== open.name;
|
|
526
|
+
lastOpen.current = open.name;
|
|
527
|
+
if (!shown.current.has(open.name) || firstHead || reopened) {
|
|
528
|
+
shown.current.add(open.name);
|
|
529
|
+
pinThenReread(open.name, branchesData).catch(console.error);
|
|
530
|
+
}
|
|
531
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `pinThenReread` reads only refs and options
|
|
532
|
+
}, [branchesData, currentBranchName]);
|
|
533
|
+
const reviewedHead = (branch) => ({
|
|
534
|
+
headSha: reviewPins.current.get(branch) ?? undefined,
|
|
535
|
+
});
|
|
470
536
|
// A card, deep link or URL can carry a short name, an exact form or the raw form of a listed
|
|
471
537
|
// name; the actions resolve it once, the way an arriving name resolves, then key, look up and
|
|
472
538
|
// send the listed name.
|
|
@@ -577,6 +643,11 @@ export function useBranchManager(options) {
|
|
|
577
643
|
}
|
|
578
644
|
};
|
|
579
645
|
const reviewFailureId = (kind, branch) => `canopy-${kind}-failed-${branch}`;
|
|
646
|
+
/** A verdict that went through answers either kind's refusal. */
|
|
647
|
+
const hideReviewFailures = (branch) => {
|
|
648
|
+
notifications.hide(reviewFailureId('approve', branch));
|
|
649
|
+
notifications.hide(reviewFailureId('request-changes', branch));
|
|
650
|
+
};
|
|
580
651
|
const submitRefusedId = (branch) => `canopy-submit-refused-${branch}`;
|
|
581
652
|
const deleteRefusedId = (branch) => `canopy-delete-refused-${branch}`;
|
|
582
653
|
/**
|
|
@@ -586,6 +657,22 @@ export function useBranchManager(options) {
|
|
|
586
657
|
* submit didn't finish (see `showLeftReview`, which shows the server's reason either way).
|
|
587
658
|
*/
|
|
588
659
|
const reportReviewFailure = async (err, kind, branch, details) => {
|
|
660
|
+
if (err instanceof BranchMovedError) {
|
|
661
|
+
// A listing that fails, or leaves the branch out, pins none for now.
|
|
662
|
+
void pinThenReread(branch, await reloadBranches()).catch(console.error);
|
|
663
|
+
const id = reviewFailureId(kind, branch);
|
|
664
|
+
notifications.hide(id);
|
|
665
|
+
notifications.show({
|
|
666
|
+
id,
|
|
667
|
+
message: details.note
|
|
668
|
+
? `${err.message} Your note is kept for when you request changes again.`
|
|
669
|
+
: err.message,
|
|
670
|
+
color: 'yellow',
|
|
671
|
+
autoClose: false,
|
|
672
|
+
withCloseButton: true,
|
|
673
|
+
});
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
589
676
|
if (err instanceof StaleStatusError) {
|
|
590
677
|
showLeftReview(branch, err.message, details.note);
|
|
591
678
|
// What the branch is now decides whether a Request changes note is still wanted.
|
|
@@ -609,8 +696,9 @@ export function useBranchManager(options) {
|
|
|
609
696
|
notifications.show({
|
|
610
697
|
id,
|
|
611
698
|
message: details.note ? (_jsx(NoteNotSavedMessage, { message: message, note: details.note, hint: "Copy it to keep it." })) : (message),
|
|
612
|
-
|
|
613
|
-
|
|
699
|
+
// A 409, such as open conflicts, loses nothing and says what to do: yellow, until read.
|
|
700
|
+
color: err.status === 409 ? 'yellow' : 'red',
|
|
701
|
+
...(details.note || err.status === 409 ? { autoClose: false, withCloseButton: true } : {}),
|
|
614
702
|
});
|
|
615
703
|
await loadBranches();
|
|
616
704
|
return;
|
|
@@ -719,6 +807,12 @@ export function useBranchManager(options) {
|
|
|
719
807
|
const handleWithdraw = (requested, returnFocusTo) => {
|
|
720
808
|
const branchNameToWithdraw = listedName(requested);
|
|
721
809
|
return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
|
|
810
|
+
// The conflict that held it in review is what the latest changes settle.
|
|
811
|
+
const requestLatest = clientOperatingStrategy(options.operatingMode).syncsBranchesWithBase() &&
|
|
812
|
+
listedBranch(branchNameToWithdraw)?.hold?.kind === 'conflicts'
|
|
813
|
+
? options.latestChangesAfterWithdraw?.(branchNameToWithdraw)
|
|
814
|
+
: undefined;
|
|
815
|
+
const thenGetLatest = !!requestLatest;
|
|
722
816
|
showWithdrawConfirmation(shownName(branchNameToWithdraw), pullRequestOf(branchNameToWithdraw), async () => {
|
|
723
817
|
options.setBusy(true);
|
|
724
818
|
try {
|
|
@@ -731,6 +825,9 @@ export function useBranchManager(options) {
|
|
|
731
825
|
updateCreatedBranch(branchNameToWithdraw, result.data?.branch);
|
|
732
826
|
await loadBranches();
|
|
733
827
|
resolve();
|
|
828
|
+
// Reports its own outcome, so a failure here never reads as a failed withdraw.
|
|
829
|
+
if (requestLatest)
|
|
830
|
+
void requestLatest();
|
|
734
831
|
}
|
|
735
832
|
catch (err) {
|
|
736
833
|
await reportActionFailure(err, 'Failed to withdraw branch', EDITOR_ACTIONS.withdrawFromReview);
|
|
@@ -739,7 +836,7 @@ export function useBranchManager(options) {
|
|
|
739
836
|
finally {
|
|
740
837
|
options.setBusy(false);
|
|
741
838
|
}
|
|
742
|
-
}, () => resolve(), returnFocusTo);
|
|
839
|
+
}, () => resolve(), returnFocusTo, thenGetLatest);
|
|
743
840
|
}));
|
|
744
841
|
};
|
|
745
842
|
const handleArchive = (requested) => {
|
|
@@ -787,12 +884,10 @@ export function useBranchManager(options) {
|
|
|
787
884
|
showApproveConfirmation(shownName(branchNameToApprove), pullRequestOf(branchNameToApprove), async () => {
|
|
788
885
|
options.setBusy(true);
|
|
789
886
|
try {
|
|
790
|
-
const result = await apiClient.workflow.approve({
|
|
791
|
-
branch: sendName(branchNameToApprove),
|
|
792
|
-
});
|
|
887
|
+
const result = await apiClient.workflow.approve({ branch: sendName(branchNameToApprove) }, reviewedHead(branchNameToApprove));
|
|
793
888
|
if (!result.ok)
|
|
794
889
|
throw actionError(result, 'Failed to approve branch', true);
|
|
795
|
-
|
|
890
|
+
hideReviewFailures(branchNameToApprove);
|
|
796
891
|
notifications.show({ message: 'Branch approved', color: 'green' });
|
|
797
892
|
updateCreatedBranch(branchNameToApprove, result.data?.branch);
|
|
798
893
|
await loadBranches();
|
|
@@ -825,11 +920,11 @@ export function useBranchManager(options) {
|
|
|
825
920
|
}, async (note) => {
|
|
826
921
|
options.setBusy(true);
|
|
827
922
|
try {
|
|
828
|
-
const result = await apiClient.workflow.requestChanges({ branch: sendName(branchNameForChanges) }, { note: note.trim() || undefined });
|
|
923
|
+
const result = await apiClient.workflow.requestChanges({ branch: sendName(branchNameForChanges) }, { note: note.trim() || undefined, ...reviewedHead(branchNameForChanges) });
|
|
829
924
|
if (!result.ok)
|
|
830
925
|
throw actionError(result, 'Failed to request changes', true);
|
|
831
926
|
noteDrafts.current.delete(branchNameForChanges);
|
|
832
|
-
|
|
927
|
+
hideReviewFailures(branchNameForChanges);
|
|
833
928
|
notifications.show({ message: 'Changes requested', color: 'blue' });
|
|
834
929
|
// The note is saved apart from the status change, so it can fail on its own;
|
|
835
930
|
// the warning stays up, with the note to copy, because the words are not saved.
|
|
@@ -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;
|