canopycms 0.12.1 → 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 +211 -49
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +112 -23
- package/dist/api/branch-status.d.ts +11 -2
- package/dist/api/branch-status.js +3 -2
- package/dist/api/branch-update-from-base.js +57 -6
- package/dist/api/branch.d.ts +5 -0
- package/dist/api/branch.js +27 -2
- package/dist/api/client.d.ts +2 -2
- package/dist/api/client.js +2 -2
- package/dist/api/content.js +1 -1
- package/dist/api/merge-conflicts.d.ts +10 -1
- package/dist/api/merge-conflicts.js +42 -7
- package/dist/api/types.d.ts +3 -2
- package/dist/base-update.d.ts +6 -0
- package/dist/branch-metadata-file.d.ts +15 -0
- package/dist/branch-metadata-file.js +5 -1
- package/dist/cli/cli.js +238 -108
- package/dist/cli/generate-ai-content.js +20 -6
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +7 -1
- package/dist/editor/Editor.js +39 -16
- 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 +4 -0
- package/dist/editor/base-update-wait.js +7 -0
- package/dist/editor/components/ConflictsGroup.d.ts +13 -1
- package/dist/editor/components/ConflictsGroup.js +52 -12
- package/dist/editor/components/EditorHeader.d.ts +3 -1
- package/dist/editor/components/EditorHeader.js +11 -7
- package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
- package/dist/editor/components/EntryChangesDrawer.js +58 -6
- package/dist/editor/conflict-cards.d.ts +4 -1
- package/dist/editor/conflict-cards.js +14 -9
- package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
- package/dist/editor/hooks/useBaseUpdate.js +12 -2
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +80 -6
- package/dist/editor/hooks/useDraftManager.d.ts +8 -1
- package/dist/editor/hooks/useDraftManager.js +16 -1
- package/dist/editor/hooks/useEntryManager.d.ts +8 -3
- package/dist/editor/hooks/useEntryManager.js +6 -5
- package/dist/git-manager.d.ts +1 -0
- package/dist/git-manager.js +8 -3
- package/dist/merge/entry-merge.js +4 -3
- package/dist/merge/list-alignment.d.ts +9 -6
- package/dist/merge/list-alignment.js +65 -16
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/operating-mode/client-safe-strategy.js +1 -1
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +10 -2
- package/dist/services.d.ts +12 -4
- package/dist/services.js +56 -0
- package/dist/types.d.ts +35 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +27 -3
- package/dist/worker/branch-merge.js +106 -5
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +21 -0
- package/dist/worker/cms-worker.js +29 -4
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +17 -2
- package/dist/worker/field-merge.js +95 -2
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +9 -0
- package/dist/worker/github-commits.d.ts +3 -3
- package/dist/worker/github-commits.js +192 -40
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +7 -0
- package/dist/worker/github-gateway-wire.d.ts +30 -10
- package/dist/worker/github-gateway-wire.js +14 -2
- package/dist/worker/github-gateway.d.ts +17 -0
- package/dist/worker/github-gateway.js +37 -1
- 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 +25 -5
- package/dist/worker/rebase.js +225 -81
- package/dist/worker/task-runner.js +16 -39
- package/dist/worker/worker-context.d.ts +6 -0
- package/package.json +1 -1
|
@@ -90,33 +90,52 @@ export function conflictHeading(card) {
|
|
|
90
90
|
}
|
|
91
91
|
return `${where}${part} was changed by you and by someone else`;
|
|
92
92
|
}
|
|
93
|
+
const KEPT_YOURS = 'Conflict cleared. Your version of the entry stays.';
|
|
94
|
+
/** @internal Exported for tests: how long a settled entry card waits for the reload to take it. */
|
|
95
|
+
export const SETTLED_HOLD_MS = 10_000;
|
|
93
96
|
/** What an entry-level conflict is, and the choices that settle it, in order. */
|
|
94
97
|
const ENTRY_KINDS = {
|
|
95
98
|
'deleted-on-base': {
|
|
96
99
|
heading: 'This entry was deleted in the latest version, but you changed it',
|
|
97
100
|
choices: [
|
|
98
|
-
{ choice: 'keep', label: EDITOR_ACTIONS.keepIt },
|
|
101
|
+
{ choice: 'keep', label: EDITOR_ACTIONS.keepIt, done: KEPT_YOURS },
|
|
99
102
|
{ choice: 'delete', label: EDITOR_ACTIONS.deleteEntry, danger: true },
|
|
100
103
|
],
|
|
101
104
|
},
|
|
102
105
|
'deleted-on-branch': {
|
|
103
106
|
heading: 'You deleted this entry, but it was changed in the latest version',
|
|
104
107
|
choices: [
|
|
105
|
-
{
|
|
106
|
-
|
|
108
|
+
{
|
|
109
|
+
choice: 'restore',
|
|
110
|
+
label: EDITOR_ACTIONS.restoreEntry,
|
|
111
|
+
done: 'Entry restored as the latest version has it.',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
choice: 'keep',
|
|
115
|
+
label: EDITOR_ACTIONS.keepItDeleted,
|
|
116
|
+
entryGone: true,
|
|
117
|
+
done: 'Conflict cleared. The entry stays deleted.',
|
|
118
|
+
},
|
|
107
119
|
],
|
|
108
120
|
},
|
|
109
121
|
// Keeping it deleted clears the record: `keep` resolves a `fields` record whole (api/merge-conflicts.ts).
|
|
110
122
|
'fields-on-deleted': {
|
|
111
123
|
heading: 'You deleted this entry, but fields in it were changed by you and by someone else',
|
|
112
124
|
body: `Keeping it deleted drops the latest version's changes to it. You can still use "${EDITOR_ACTIONS.restoreEntry}" afterwards, to bring it back as the latest version has it.`,
|
|
113
|
-
choices: [
|
|
125
|
+
choices: [
|
|
126
|
+
{
|
|
127
|
+
choice: 'keep',
|
|
128
|
+
label: EDITOR_ACTIONS.keepItDeleted,
|
|
129
|
+
entryGone: true,
|
|
130
|
+
done: 'Conflicts cleared. The entry stays deleted.',
|
|
131
|
+
},
|
|
132
|
+
],
|
|
114
133
|
},
|
|
115
134
|
unreadable: {
|
|
116
135
|
heading: "This entry was changed by you and by someone else in a way that can't be compared field by field",
|
|
117
136
|
choices: [
|
|
118
137
|
{ choice: 'use-latest', label: EDITOR_ACTIONS.useLatestVersion },
|
|
119
|
-
{ choice: 'keep', label: EDITOR_ACTIONS.keepYourVersion },
|
|
138
|
+
{ choice: 'keep', label: EDITOR_ACTIONS.keepYourVersion, done: KEPT_YOURS },
|
|
120
139
|
],
|
|
121
140
|
},
|
|
122
141
|
};
|
|
@@ -222,7 +241,7 @@ const FieldCardActions = ({ card, actions, label, }) => {
|
|
|
222
241
|
: held
|
|
223
242
|
? btn(verb, pick(how), testId)
|
|
224
243
|
: null;
|
|
225
|
-
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [side(EDITOR_ACTIONS.removeIt, removeIs, 'conflict-remove-it'), side(EDITOR_ACTIONS.keepIt, keepIs, 'conflict-keep-it'), !held &&
|
|
244
|
+
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [side(EDITOR_ACTIONS.removeIt, removeIs, 'conflict-remove-it'), side(EDITOR_ACTIONS.keepIt, keepIs, 'conflict-keep-it'), (!held || actions.markable) &&
|
|
226
245
|
btn(EDITOR_ACTIONS.markResolved, () => actions.choose({ how: 'edited' }), 'conflict-mark-resolved'), copy !== undefined && (_jsx(CopyButton, { value: copy, children: ({ copied, copy: doCopy }) => (_jsx(Button, { size: "compact-sm", variant: "subtle", onClick: doCopy, "aria-label": copied ? `Copied: ${label}` : `Copy your version: ${label}`, "data-testid": "conflict-copy", children: copied ? 'Copied' : 'Copy' })) }))] }));
|
|
227
246
|
}
|
|
228
247
|
return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", children: [btn(EDITOR_ACTIONS.useLatestVersion, () => actions.choose({ how: 'latest' }), 'conflict-use-latest', latestTakes), held
|
|
@@ -241,27 +260,47 @@ const FieldCard = ({ card, adminBaseBranch, actions }) => {
|
|
|
241
260
|
const heading = conflictHeading(card);
|
|
242
261
|
return (_jsx(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", style: CARD_ACCENT, "data-testid": "conflict-card", "data-conflict-kind": card.type === 'value' ? (card.item ? 'item' : card.conflict) : card.type, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(IconAlertTriangle, { size: 16, "aria-hidden": true, style: { flexShrink: 0, marginTop: 2 } }), _jsx(Text, { component: "h4", size: "sm", fw: 500, m: 0, miw: 0, style: { overflowWrap: 'anywhere' }, children: heading })] }), card.type === 'location' ? (_jsxs(Text, { size: "sm", children: ["It keeps your URL. To use another, choose \"", EDITOR_ACTIONS.changeUrl, "\u2026\" in the entry's menu."] })) : (_jsx(Versions, { card: card, adminBaseBranch: adminBaseBranch })), actions && _jsx(FieldCardActions, { card: card, actions: actions, label: heading })] }) }));
|
|
243
262
|
};
|
|
244
|
-
const EntryCard = ({ kind, reason, unsavedEdits, onResolve }) => {
|
|
263
|
+
const EntryCard = ({ kind, reason, unsavedEdits, onResolve, onDone }) => {
|
|
245
264
|
const [working, setWorking] = useState();
|
|
265
|
+
const [settled, setSettled] = useState(false);
|
|
246
266
|
const [refusal, setRefusal] = useState();
|
|
247
267
|
const { heading, body, choices } = ENTRY_KINDS[kind];
|
|
268
|
+
// A choice settled in place keeps its button working until the reload takes the card away, or,
|
|
269
|
+
// should that reload fail, for SETTLED_HOLD_MS, after which the choices come back.
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
if (!settled)
|
|
272
|
+
return;
|
|
273
|
+
const timer = setTimeout(() => {
|
|
274
|
+
setSettled(false);
|
|
275
|
+
setWorking(undefined);
|
|
276
|
+
}, SETTLED_HOLD_MS);
|
|
277
|
+
return () => clearTimeout(timer);
|
|
278
|
+
}, [settled]);
|
|
248
279
|
const resolve = async (choice) => {
|
|
249
280
|
if (!onResolve || working)
|
|
250
281
|
return;
|
|
282
|
+
const made = choices.find((c) => c.choice === choice);
|
|
251
283
|
setWorking(choice);
|
|
252
284
|
setRefusal(undefined);
|
|
285
|
+
let done = false;
|
|
253
286
|
try {
|
|
254
|
-
const refused = await onResolve(choice);
|
|
287
|
+
const refused = await onResolve(choice, made?.entryGone ? { entryGone: true } : {});
|
|
255
288
|
setRefusal(refused && { ...refused, choice });
|
|
289
|
+
done = !refused && !!made?.done;
|
|
290
|
+
if (done)
|
|
291
|
+
onDone?.({ message: made.done, entryGone: !!made.entryGone });
|
|
256
292
|
}
|
|
257
293
|
finally {
|
|
258
|
-
|
|
294
|
+
if (done)
|
|
295
|
+
setSettled(true);
|
|
296
|
+
else
|
|
297
|
+
setWorking(undefined);
|
|
259
298
|
}
|
|
260
299
|
};
|
|
261
|
-
return (_jsx(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", style: CARD_ACCENT, "data-testid": "conflict-card", "data-conflict-kind": kind, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(IconAlertTriangle, { size: 16, "aria-hidden": true, style: { flexShrink: 0, marginTop: 2 } }), _jsx(Text, { component: "h4", size: "sm", fw: 500, m: 0, children: heading })] }), body && (_jsx(Text, { size: "sm", "data-testid": "conflict-entry-body", children: body })), onResolve && (_jsx(Group, { gap: "xs", wrap: "wrap", align: "center", children: choices.map(({ choice, label, danger }) => (_jsx(ReasonedButton, { reason: reason ?? (choice !== 'keep' && unsavedEdits ? SAVE_FIRST_REASON : undefined), onClick: () => void resolve(choice), loading: working === choice, danger: danger, "data-testid": `conflict-entry-${choice}`, children: label }, choice))) })), refusal && (_jsx(Alert, { variant: "light", color: refusal.tone === 'error' ? 'red' : 'yellow', icon: refusal.tone === 'error' ? (_jsx(IconAlertCircle, { size: 16 })) : (_jsx(IconAlertTriangle, { size: 16 })), "data-testid": "conflict-entry-refused", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: refusal.message }), refusal.tone === 'error' && (_jsx(Button, { size: "compact-sm", variant: "subtle", loading: working === refusal.choice, onClick: () => void resolve(refusal.choice), children: "Retry" }))] }) }))] }) }));
|
|
300
|
+
return (_jsx(Paper, { withBorder: true, p: "sm", radius: "sm", component: "li", style: CARD_ACCENT, "data-testid": "conflict-card", "data-conflict-kind": kind, "data-entry-card": true, children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", align: "flex-start", children: [_jsx(IconAlertTriangle, { size: 16, "aria-hidden": true, style: { flexShrink: 0, marginTop: 2 } }), _jsx(Text, { component: "h4", size: "sm", fw: 500, m: 0, children: heading })] }), body && (_jsx(Text, { size: "sm", "data-testid": "conflict-entry-body", children: body })), onResolve && (_jsx(Group, { gap: "xs", wrap: "wrap", align: "center", children: choices.map(({ choice, label, danger }) => (_jsx(ReasonedButton, { reason: reason ?? (choice !== 'keep' && unsavedEdits ? SAVE_FIRST_REASON : undefined), onClick: () => void resolve(choice), loading: working === choice, danger: danger, "data-testid": `conflict-entry-${choice}`, children: label }, choice))) })), refusal && (_jsx(Alert, { variant: "light", color: refusal.tone === 'error' ? 'red' : 'yellow', icon: refusal.tone === 'error' ? (_jsx(IconAlertCircle, { size: 16 })) : (_jsx(IconAlertTriangle, { size: 16 })), "data-testid": "conflict-entry-refused", children: _jsxs(Stack, { gap: 4, align: "flex-start", children: [_jsx(Text, { size: "sm", children: refusal.message }), refusal.tone === 'error' && (_jsx(Button, { size: "compact-sm", variant: "subtle", loading: working === refusal.choice, onClick: () => void resolve(refusal.choice), children: "Retry" }))] }) }))] }) }));
|
|
262
301
|
};
|
|
263
302
|
/** The "Conflicts" group: a heading, a count heard as it changes, and the cards. */
|
|
264
|
-
export const ConflictsGroup = ({ data, conflicts, adminBaseBranch, choices, form, onChoose, onEdit, onResolveEntry, noEditReason, lockedReason, unsavedEdits, entryDeleted, }) => {
|
|
303
|
+
export const ConflictsGroup = ({ data, conflicts, adminBaseBranch, choices, form, onChoose, onEdit, onResolveEntry, onEntryDone, noEditReason, lockedReason, unsavedEdits, entryDeleted, }) => {
|
|
265
304
|
const headingId = useId();
|
|
266
305
|
const editable = !noEditReason;
|
|
267
306
|
const entryKind = data.kind === 'fields' ? undefined : data.kind;
|
|
@@ -316,6 +355,7 @@ export const ConflictsGroup = ({ data, conflicts, adminBaseBranch, choices, form
|
|
|
316
355
|
return {
|
|
317
356
|
reason: lockedReason,
|
|
318
357
|
latestReason,
|
|
358
|
+
markable: field.otherItemHere,
|
|
319
359
|
choice,
|
|
320
360
|
held,
|
|
321
361
|
choose: (next) => {
|
|
@@ -357,7 +397,7 @@ export const ConflictsGroup = ({ data, conflicts, adminBaseBranch, choices, form
|
|
|
357
397
|
? 'Changed by you and by someone else. Choose what to keep.'
|
|
358
398
|
: `${plural(conflicts.fields.length, 'field', 'fields')} changed by you and by someone else. Choose what to keep, then save.` }), _jsx(VisuallyHidden, { role: "status", "data-testid": "conflicts-left", children: open === 0
|
|
359
399
|
? 'All chosen. Save to keep your choices.'
|
|
360
|
-
: `${plural(open, 'field', 'fields')} left to choose.` }), conflicts.incomplete && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "conflicts-incomplete", children: "Some versions couldn't be read, so some values may not show." })), noEditReason && (_jsx(Text, { size: "sm", "data-testid": "conflicts-no-edit", children: noEditReason })), editable && !form && !entryKind && !deletedFields && (_jsx(Text, { size: "sm", "data-testid": "conflicts-open-to-choose", children: OPEN_TO_CHOOSE })), editable && lockedReason && (_jsx(Text, { size: "sm", "data-testid": "conflicts-locked", children: lockedReason })), _jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: entryKind ? (_jsx(EntryCard, { kind: entryKind, reason: lockedReason, unsavedEdits: unsavedEdits, onResolve: editable ? onResolveEntry : undefined })) : (_jsxs(_Fragment, { children: [deletedFields && (_jsx(EntryCard, { kind: "fields-on-deleted", reason: lockedReason, onResolve: editable ? onResolveEntry : undefined })), conflicts.fields.flatMap((field) => {
|
|
400
|
+
: `${plural(open, 'field', 'fields')} left to choose.` }), conflicts.incomplete && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "conflicts-incomplete", children: "Some versions couldn't be read, so some values may not show." })), noEditReason && (_jsx(Text, { size: "sm", "data-testid": "conflicts-no-edit", children: noEditReason })), editable && !form && !entryKind && !deletedFields && (_jsx(Text, { size: "sm", "data-testid": "conflicts-open-to-choose", children: OPEN_TO_CHOOSE })), editable && lockedReason && (_jsx(Text, { size: "sm", "data-testid": "conflicts-locked", children: lockedReason })), _jsx(Stack, { component: "ul", gap: "sm", p: 0, m: 0, style: { listStyle: 'none' }, children: entryKind ? (_jsx(EntryCard, { kind: entryKind, reason: lockedReason, unsavedEdits: unsavedEdits, onResolve: editable ? onResolveEntry : undefined, onDone: onEntryDone })) : (_jsxs(_Fragment, { children: [deletedFields && (_jsx(EntryCard, { kind: "fields-on-deleted", reason: lockedReason, onResolve: editable ? onResolveEntry : undefined, onDone: onEntryDone })), conflicts.fields.flatMap((field) => {
|
|
361
401
|
const actions = actionsFor(field);
|
|
362
402
|
return field.cards.map((card) => (_jsx(FieldCard, { card: card, adminBaseBranch: adminBaseBranch, actions: actions }, card.id)));
|
|
363
403
|
})] })) })] }));
|
|
@@ -87,10 +87,12 @@ export interface EditorHeaderProps {
|
|
|
87
87
|
};
|
|
88
88
|
/** The open branch's wait for the latest changes, whoever asked: every viewer who sees it waits. */
|
|
89
89
|
latestWait?: BaseUpdateWait;
|
|
90
|
+
/** The base has changes the open branch's saved work keeps the automatic sync from bringing in. */
|
|
91
|
+
latestChangesWaiting?: boolean;
|
|
90
92
|
}
|
|
91
93
|
/**
|
|
92
94
|
* The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
|
|
93
95
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
94
96
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
95
97
|
*/
|
|
96
|
-
export declare function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch, onSaveAll, saveAllProgress, singleSaving, entryListFailed, onSubmit, onWithdraw, onApprove, onRequestChanges, draftStorageFailed, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected, branchServerProtected, branchReadOnly, branchHold, branchApproveBlocked, branchWriteBlocked, branchLoading, branchPending, branchMissing, baseUpdate, latestWait, openConflicts, conflictRecordHeld, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
98
|
+
export declare function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch, onSaveAll, saveAllProgress, singleSaving, entryListFailed, onSubmit, onWithdraw, onApprove, onRequestChanges, draftStorageFailed, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected, branchServerProtected, branchReadOnly, branchHold, branchApproveBlocked, branchWriteBlocked, branchLoading, branchPending, branchMissing, baseUpdate, latestWait, latestChangesWaiting, openConflicts, conflictRecordHeld, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { useLayoutEffect, useRef } from 'react';
|
|
3
3
|
import { Badge, Box, Button, Group, Loader, Menu, Text, Tooltip, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
4
4
|
import { useMediaQuery, useMergedRef } from '@mantine/hooks';
|
|
5
|
-
import { IconChevronDown, IconLock } from '@tabler/icons-react';
|
|
5
|
+
import { IconChevronDown, IconLock, IconRefresh } from '@tabler/icons-react';
|
|
6
6
|
import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
|
|
7
7
|
import { branchBarStatus, describeBranchLock } from '../branch-lock.js';
|
|
8
8
|
import { HOLD_BADGE, branchHoldMessage } from '../branch-status.js';
|
|
@@ -18,14 +18,14 @@ import { ReasonedMenuItem } from './ReasonedMenuItem.js';
|
|
|
18
18
|
import { UnsavedMark } from './UnsavedMark.js';
|
|
19
19
|
import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButton.js';
|
|
20
20
|
import { GetLatestChangesButton } from './GetLatestChangesButton.js';
|
|
21
|
-
import { GETTING_LATEST_CHANGES, WAITING_NOTICE, WAITING_SUBMIT_REASON, } from '../base-update-wait.js';
|
|
21
|
+
import { GETTING_LATEST_CHANGES, LATEST_CHANGES_WAITING, latestChangesWaitingDetail, WAITING_NOTICE, WAITING_SUBMIT_REASON, } from '../base-update-wait.js';
|
|
22
22
|
import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.js';
|
|
23
23
|
/**
|
|
24
24
|
* The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
|
|
25
25
|
* warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
|
|
26
26
|
* submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
|
|
27
27
|
*/
|
|
28
|
-
export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, baseUpdate, latestWait = 'none', openConflicts = 0, conflictRecordHeld, }) {
|
|
28
|
+
export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, onApprove = noopAction, onRequestChanges = noopAction, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchPullRequestState, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchHold, branchApproveBlocked = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, baseUpdate, latestWait = 'none', latestChangesWaiting = false, openConflicts = 0, conflictRecordHeld, }) {
|
|
29
29
|
const reviewRole = reviewRoleOf({
|
|
30
30
|
status: branchStatus,
|
|
31
31
|
createdBy: branchCreatedBy,
|
|
@@ -129,6 +129,10 @@ export function EditorHeader({ branchName, branchLabel, busy, modifiedCount, onS
|
|
|
129
129
|
return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchLabel ?? branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef,
|
|
130
130
|
// Below `md` the bar has no room for it, so the branch's menu offers it.
|
|
131
131
|
getLatest: narrow ? baseUpdate : undefined }), _jsxs(Group, { gap: "xs", wrap: "nowrap", style: { flexShrink: 0 }, children: [_jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason, holdMessage: holdMessage, holdCount: holdCount, working: latestWait !== 'none' ? GETTING_LATEST_CHANGES : undefined,
|
|
132
|
+
// A hold's way forward comes first.
|
|
133
|
+
latestChangesWaiting: latestChangesWaiting && latestWait === 'none' && !holdMessage
|
|
134
|
+
? latestChangesWaitingDetail(narrow, !!baseUpdate?.blockedReason)
|
|
135
|
+
: undefined, compact: narrow,
|
|
132
136
|
// The bar's button carries the spinner where it shows.
|
|
133
137
|
workingSpinner: narrow || !baseUpdate }), releaseHold && (_jsx(ReleaseHoldButton, { branchLabel: branchLabel ?? branchName, onRelease: async () => {
|
|
134
138
|
// Marked while in flight: the re-read that clears the hold can land first.
|
|
@@ -178,18 +182,18 @@ function unsavedTooltip(status, count, mayWithdraw) {
|
|
|
178
182
|
* The branch's state beside the pill, heard in words with its reason, but not while it loads. A
|
|
179
183
|
* hold follows it whatever the lock, since a lock doesn't say that the branch is stuck.
|
|
180
184
|
*/
|
|
181
|
-
function BranchStatusLabel({ status, reason, holdMessage, holdCount, working, workingSpinner = true, }) {
|
|
185
|
+
function BranchStatusLabel({ status, reason, holdMessage, holdCount, working, workingSpinner = true, latestChangesWaiting, compact = false, }) {
|
|
182
186
|
const spoken = [
|
|
183
187
|
status.label && status.value !== 'loading'
|
|
184
188
|
? [status.label.replace(' · ', ', '), reason].filter(Boolean).join('. ')
|
|
185
189
|
: '',
|
|
186
190
|
working,
|
|
191
|
+
// The badge sits outside this region, so a detail that follows the unsaved count stays quiet.
|
|
192
|
+
latestChangesWaiting && LATEST_CHANGES_WAITING,
|
|
187
193
|
]
|
|
188
194
|
.filter(Boolean)
|
|
189
195
|
.join('. ');
|
|
190
|
-
return (
|
|
191
|
-
// Always mounted, so a lock that arrives is announced.
|
|
192
|
-
_jsxs(Box, { role: "status", style: status.label || holdMessage || working ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [working && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-working-status", children: [workingSpinner && _jsx(Loader, { size: 12 }), _jsx(Text, { span: true, size: "sm", children: working })] })), status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: holdCount === undefined ? HOLD_BADGE.label : `${HOLD_BADGE.label} (${holdCount})` }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }));
|
|
196
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Box, { role: "status", style: status.label || holdMessage || working ? { flexShrink: 0 } : HIDDEN_STYLE, children: [_jsxs(Group, { gap: "xs", wrap: "nowrap", children: [working && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-working-status", children: [workingSpinner && _jsx(Loader, { size: 12 }), _jsx(Text, { span: true, size: "sm", children: working })] })), status.label && (_jsxs(Group, { gap: 4, wrap: "nowrap", "aria-hidden": "true", "data-testid": "branch-status", "data-status": status.value, children: [status.readOnly && _jsx(IconLock, { size: 14 }), _jsx(Text, { span: true, size: "sm", children: status.label })] })), holdMessage && (_jsx(TooltipDetail, { detail: holdMessage, children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": "branch-hold-status", children: holdCount === undefined ? HOLD_BADGE.label : `${HOLD_BADGE.label} (${holdCount})` }) }))] }), _jsx(VisuallyHidden, { children: spoken })] }), latestChangesWaiting && (_jsx(TooltipDetail, { detail: latestChangesWaiting, name: compact ? LATEST_CHANGES_WAITING : undefined, children: _jsx(Badge, { color: "blue", variant: "light", circle: compact, "data-testid": "branch-latest-waiting", children: compact ? (_jsx(IconRefresh, { size: 12, style: { display: 'block' }, "aria-hidden": "true" })) : (LATEST_CHANGES_WAITING) }) }))] }));
|
|
193
197
|
}
|
|
194
198
|
const WAY_LABEL = {
|
|
195
199
|
newBranch: `${EDITOR_ACTIONS.newBranch}…`,
|
|
@@ -91,7 +91,7 @@ export interface EntryChangesUndo {
|
|
|
91
91
|
threadsWithin?: (path: CanopyPathSegment[]) => number;
|
|
92
92
|
}
|
|
93
93
|
type EntryChangesViewProps = Omit<EntryChangesDrawerProps, 'opened' | 'onClose' | 'onExited' | 'returnFocusTo'>;
|
|
94
|
-
/**
|
|
94
|
+
/** Conflicts, what a choice settled, then the changes, which keep their place as conflicts go. */
|
|
95
95
|
export declare const EntryChangesView: React.FC<EntryChangesViewProps>;
|
|
96
96
|
/**
|
|
97
97
|
* A modal right-hand drawer, as the Branches, Comments and Media drawers are: it traps focus,
|
|
@@ -8,7 +8,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
8
8
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { Alert, Badge, Box, Button, Group, Paper, SimpleGrid, Stack, Text, VisuallyHidden, em, getBreakpointValue, useMantineTheme, } from '@mantine/core';
|
|
10
10
|
import { useMediaQuery } from '@mantine/hooks';
|
|
11
|
-
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconInfoCircle, IconMinus, IconPencil, IconPlus, } from '@tabler/icons-react';
|
|
11
|
+
import { IconAlertCircle, IconAlertTriangle, IconExternalLink, IconArrowsMove, IconCircleCheck, IconInfoCircle, IconMinus, IconPencil, IconPlus, } from '@tabler/icons-react';
|
|
12
12
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
13
13
|
import { computeEntryDiff } from '../entry-diff.js';
|
|
14
14
|
import { buildEntryConflicts } from '../conflict-cards.js';
|
|
@@ -18,7 +18,7 @@ import { movedThreadsNote } from '../undo-removal.js';
|
|
|
18
18
|
import { canSetBack, canopyPathOf, formValueOfSide, holdsAs, setAllBack, setChangeBack, sideOfFormValue, } from '../undo-change.js';
|
|
19
19
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
20
20
|
import { DateValue, MarkedText, ReasonedButton, TEXT_STYLE, Value, ordinal, plural, } from './change-parts.js';
|
|
21
|
-
import { ConflictsGroup } from './ConflictsGroup.js';
|
|
21
|
+
import { ConflictsGroup, SETTLED_HOLD_MS, } from './ConflictsGroup.js';
|
|
22
22
|
import { LoadingCards, LoadingSummary } from './EntryChangesSkeleton.js';
|
|
23
23
|
const KIND_BADGES = {
|
|
24
24
|
added: { label: 'Added', icon: _jsx(IconPlus, { size: 10, "aria-hidden": true }) },
|
|
@@ -174,7 +174,7 @@ const changeSummary = (changes, base) => {
|
|
|
174
174
|
return `${parts.join(', ')} since ${base}`;
|
|
175
175
|
};
|
|
176
176
|
/** The conflicts' place: loading, their failure with Retry, or the group; nothing for none. */
|
|
177
|
-
const ConflictsSection = ({ conflicts, subject, referenceTitle, undo, noEditReason, lockedReason }) => {
|
|
177
|
+
const ConflictsSection = ({ conflicts, subject, referenceTitle, undo, noEditReason, lockedReason, onEntryDone }) => {
|
|
178
178
|
const { data, error, isValidating, reload } = conflicts.state;
|
|
179
179
|
const bodyField = useMemo(() => {
|
|
180
180
|
const side = data && Object.values(data.versions).find((v) => 'body' in v && v.body !== undefined);
|
|
@@ -194,10 +194,62 @@ const ConflictsSection = ({ conflicts, subject, referenceTitle, undo, noEditReas
|
|
|
194
194
|
return _jsx(LoadingCards, {});
|
|
195
195
|
return (_jsx(ConflictsGroup, { data: data, conflicts: built, adminBaseBranch: conflicts.adminBaseBranch, choices: conflicts.choices, form: !noEditReason && undo?.saved
|
|
196
196
|
? { value: undo.value, saved: undo.saved, update: undo.update, bodyField }
|
|
197
|
-
: undefined, onChoose: conflicts.onChoose, onEdit: conflicts.onEdit, onResolveEntry: conflicts.onResolveEntry, noEditReason: noEditReason, lockedReason: lockedReason, unsavedEdits: conflicts.unsavedEdits ?? subject.unsavedEdits, entryDeleted: subject.status === 'deleted' }));
|
|
197
|
+
: undefined, onChoose: conflicts.onChoose, onEdit: conflicts.onEdit, onResolveEntry: conflicts.onResolveEntry, onEntryDone: onEntryDone, noEditReason: noEditReason, lockedReason: lockedReason, unsavedEdits: conflicts.unsavedEdits ?? subject.unsavedEdits, entryDeleted: subject.status === 'deleted' }));
|
|
198
|
+
};
|
|
199
|
+
/** Focus once a choice's card is gone: "Restore entry" if it is offered, else the drawer's title. */
|
|
200
|
+
const focusAfterEntryChoice = (root, entryGone) => {
|
|
201
|
+
const restore = entryGone
|
|
202
|
+
? root?.querySelector('[data-testid="entry-changes-restore"]')
|
|
203
|
+
: null;
|
|
204
|
+
if (restore)
|
|
205
|
+
return restore.focus();
|
|
206
|
+
const titleId = root?.closest('[role="dialog"]')?.getAttribute('aria-labelledby');
|
|
207
|
+
const title = titleId ? document.getElementById(titleId) : null;
|
|
208
|
+
if (!title)
|
|
209
|
+
return;
|
|
210
|
+
title.tabIndex = -1;
|
|
211
|
+
title.focus();
|
|
212
|
+
};
|
|
213
|
+
/** Conflicts, what a choice settled, then the changes, which keep their place as conflicts go. */
|
|
214
|
+
export const EntryChangesView = (props) => {
|
|
215
|
+
const rootRef = useRef(null);
|
|
216
|
+
const [said, setSaid] = useState();
|
|
217
|
+
const entryDone = said?.path === props.subject.logicalPath ? said : undefined;
|
|
218
|
+
// Focus waits for the card to go: only then may "Restore entry" be offered in its place.
|
|
219
|
+
const focusAfter = useRef();
|
|
220
|
+
const onEntryDone = (done) => {
|
|
221
|
+
setSaid((last) => ({
|
|
222
|
+
id: (last?.id ?? 0) + 1,
|
|
223
|
+
message: done.message,
|
|
224
|
+
path: props.subject.logicalPath,
|
|
225
|
+
}));
|
|
226
|
+
focusAfter.current = {
|
|
227
|
+
entryGone: done.entryGone,
|
|
228
|
+
path: props.subject.logicalPath,
|
|
229
|
+
at: Date.now(),
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
const root = rootRef.current;
|
|
234
|
+
const pending = focusAfter.current;
|
|
235
|
+
if (!root || !pending)
|
|
236
|
+
return;
|
|
237
|
+
// No longer than the card holds its choice: a card gone later went for another reason.
|
|
238
|
+
if (pending.path !== props.subject.logicalPath || Date.now() - pending.at > SETTLED_HOLD_MS) {
|
|
239
|
+
focusAfter.current = undefined;
|
|
240
|
+
}
|
|
241
|
+
if (!focusAfter.current || root.querySelector('[data-entry-card]'))
|
|
242
|
+
return;
|
|
243
|
+
focusAfter.current = undefined;
|
|
244
|
+
// Focus anywhere but the page or the drawer itself was put there since, and stays.
|
|
245
|
+
const dialog = root.closest('[role="dialog"]');
|
|
246
|
+
const active = document.activeElement;
|
|
247
|
+
if (active && active !== document.body && active !== dialog)
|
|
248
|
+
return;
|
|
249
|
+
focusAfterEntryChoice(root, pending.entryGone);
|
|
250
|
+
});
|
|
251
|
+
return (_jsxs(Stack, { gap: "lg", ref: rootRef, children: [props.conflicts && (_jsx(ConflictsSection, { conflicts: props.conflicts, subject: props.subject, referenceTitle: props.referenceTitle, undo: props.undo, noEditReason: props.noEditReason, lockedReason: props.lockedReason, onEntryDone: onEntryDone })), _jsxs("div", { children: [(props.conflicts || entryDone) && (_jsx(Group, { gap: 6, wrap: "nowrap", role: "status", mb: entryDone ? 'md' : 0, "data-testid": "entry-choice-done", children: entryDone && (_jsxs(React.Fragment, { children: [_jsx(IconCircleCheck, { size: 16, color: "var(--mantine-color-green-filled)", "aria-hidden": true }), _jsx(Text, { size: "sm", children: entryDone.message })] }, entryDone.id)) })), _jsx(EntryChangesBody, { ...props })] })] }));
|
|
198
252
|
};
|
|
199
|
-
/** The drawer's body: the entry's conflicts, if any, above its changes. */
|
|
200
|
-
export const EntryChangesView = (props) => props.conflicts ? (_jsxs(Stack, { gap: "lg", children: [_jsx(ConflictsSection, { conflicts: props.conflicts, subject: props.subject, referenceTitle: props.referenceTitle, undo: props.undo, noEditReason: props.noEditReason, lockedReason: props.lockedReason }), _jsx(EntryChangesBody, { ...props })] })) : (_jsx(EntryChangesBody, { ...props }));
|
|
201
253
|
/** The changes: their states, then the summary and one card per change. */
|
|
202
254
|
const EntryChangesBody = ({ base, subject, state, referenceTitle, onOpenEntry, pullRequestUrl, undo, noEditReason, lockedReason, onDeleteEntry, onRestoreEntry, }) => {
|
|
203
255
|
const { data, error } = state;
|
|
@@ -65,9 +65,12 @@ export interface FieldConflict {
|
|
|
65
65
|
* a removal on its side took out, so its value there can be found and placed. It is keyed as the
|
|
66
66
|
* merge named the place along the base's own path ({@link alignedTo}), so this fails only where
|
|
67
67
|
* the record gives no such path, one that runs another way, or one whose merged key another of
|
|
68
|
-
* its items holds
|
|
68
|
+
* its items holds; and where your saved value holds another item at the key of one your version
|
|
69
|
+
* lacks.
|
|
69
70
|
*/
|
|
70
71
|
latestPlaceable: boolean;
|
|
72
|
+
/** Your saved value holds another item at the key of the one your version lacks here. */
|
|
73
|
+
otherItemHere?: true;
|
|
71
74
|
/**
|
|
72
75
|
* Long text found again by region from its three versions: the text the merge left, and its
|
|
73
76
|
* regions, each holding your lines there. Absent when the text is one whole value.
|
|
@@ -149,17 +149,27 @@ export function buildEntryConflicts(data, fields, bodyField, referenceTitle, sav
|
|
|
149
149
|
if (Object.values(versions).some((v) => v === undefined))
|
|
150
150
|
incomplete = true;
|
|
151
151
|
const latest = versions.latest ? formValueOfSide(versions.latest) : undefined;
|
|
152
|
-
|
|
152
|
+
// Your saved value where a save since the merge changed it, which is what keeping yours keeps:
|
|
153
|
+
// the branch's version is only how it read at the merge.
|
|
154
|
+
const savedHere = saved && valueAtChange(saved, { dataPath: field.dataPath }, bodyField);
|
|
155
|
+
const branchHere = versions.yours &&
|
|
156
|
+
valueAtChange(formValueOfSide(versions.yours), { dataPath: field.dataPath }, bodyField);
|
|
157
|
+
// A save can give a look-alike the key of an item your version lacks once it is gone: a choice
|
|
158
|
+
// here would land on that other item.
|
|
159
|
+
const otherItemHere = field.item && !!versions.yours && branchHere === undefined && savedHere !== undefined;
|
|
160
|
+
const latestFound = !!latest &&
|
|
153
161
|
field.dataPath.every((segment, i) => !('item' in segment) ||
|
|
154
162
|
(field.item && field.kind === 'removed-on-base' && i === field.dataPath.length - 1) ||
|
|
155
163
|
valueAtChange(latest, { dataPath: field.dataPath.slice(0, i + 1) }, bodyField) !==
|
|
156
164
|
undefined);
|
|
165
|
+
const latestPlaceable = latestFound && !otherItemHere;
|
|
157
166
|
const common = {
|
|
158
167
|
key: field.key,
|
|
159
168
|
mergeSha: field.mergeSha,
|
|
160
169
|
dataPath: field.dataPath,
|
|
161
170
|
item: !!field.item,
|
|
162
171
|
latestPlaceable,
|
|
172
|
+
...(otherItemHere && { otherItemHere: true }),
|
|
163
173
|
...(latest && { latest }),
|
|
164
174
|
};
|
|
165
175
|
const card = { key: field.key, mergeSha: field.mergeSha };
|
|
@@ -174,12 +184,7 @@ export function buildEntryConflicts(data, fields, bodyField, referenceTitle, sav
|
|
|
174
184
|
const named = toLatest.get(field.drawerKey) ?? toYours.get(field.drawerKey);
|
|
175
185
|
const along = fieldAlong(fields, field.dataPath);
|
|
176
186
|
const path = named?.path ?? between.get(field.drawerKey)?.path ?? along.path;
|
|
177
|
-
|
|
178
|
-
// the branch's version is only how it read at the merge.
|
|
179
|
-
const savedHere = saved && valueAtChange(saved, { dataPath: field.dataPath }, bodyField);
|
|
180
|
-
const branchHere = versions.yours &&
|
|
181
|
-
valueAtChange(formValueOfSide(versions.yours), { dataPath: field.dataPath }, bodyField);
|
|
182
|
-
const savedYours = saved && !equal(savedHere, branchHere)
|
|
187
|
+
const savedYours = saved && !otherItemHere && !equal(savedHere, branchHere)
|
|
183
188
|
? displayWhole(along.field, savedHere, referenceTitle)
|
|
184
189
|
: undefined;
|
|
185
190
|
const savedMarks = (before) => savedYours && before.kind === 'text' && savedYours.kind === 'text'
|
|
@@ -245,7 +250,7 @@ export function buildEntryConflicts(data, fields, bodyField, referenceTitle, sav
|
|
|
245
250
|
path,
|
|
246
251
|
before,
|
|
247
252
|
latest: latestValue,
|
|
248
|
-
...(!
|
|
253
|
+
...(!latestFound && { latestUnavailable: true }),
|
|
249
254
|
yours: savedYours ?? yoursValue,
|
|
250
255
|
...(latestText && { latestText }),
|
|
251
256
|
...(yoursText && { yoursText }),
|
|
@@ -267,7 +272,7 @@ export function buildEntryConflicts(data, fields, bodyField, referenceTitle, sav
|
|
|
267
272
|
path,
|
|
268
273
|
before,
|
|
269
274
|
latest: fromLatest ? (fromLatest.after ?? EMPTY) : (across?.before ?? EMPTY),
|
|
270
|
-
...(!
|
|
275
|
+
...(!latestFound && { latestUnavailable: true }),
|
|
271
276
|
yours: savedYours ?? (fromYours ? (fromYours.after ?? EMPTY) : (across?.after ?? EMPTY)),
|
|
272
277
|
...(fromLatest?.text && { latestText: fromLatest.text }),
|
|
273
278
|
...(yoursText && { yoursText }),
|
|
@@ -25,6 +25,8 @@ export interface UseBaseUpdateReturn {
|
|
|
25
25
|
blockedReason?: string;
|
|
26
26
|
/** Asks for the open branch, unless unsaved changes hold it. */
|
|
27
27
|
request: () => Promise<void>;
|
|
28
|
+
/** The base has changes the branch's saved work keeps the automatic sync from bringing in. */
|
|
29
|
+
latestChangesWaiting: boolean;
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
32
|
* "Get latest changes" for the open branch: asks, then re-reads the branch list while the branch
|
|
@@ -33,8 +33,15 @@ function withAsked(branch, asked) {
|
|
|
33
33
|
}
|
|
34
34
|
/** The branch, carrying the answer this tab already reported when the listing hasn't caught up. */
|
|
35
35
|
function withSettled(branch, settled) {
|
|
36
|
-
if (!branch || !sameBranch(branch, settled)
|
|
36
|
+
if (!branch || !sameBranch(branch, settled))
|
|
37
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
|
+
}
|
|
38
45
|
return laterThan(branch.baseUpdateRequestedAt ?? '', settled.requestedAt) &&
|
|
39
46
|
branch.baseUpdateRequestedAt !== undefined
|
|
40
47
|
? branch
|
|
@@ -42,6 +49,7 @@ function withSettled(branch, settled) {
|
|
|
42
49
|
...branch,
|
|
43
50
|
baseUpdateRequestedAt: settled.requestedAt,
|
|
44
51
|
lastBaseUpdate: settled.lastBaseUpdate,
|
|
52
|
+
...cameIn,
|
|
45
53
|
};
|
|
46
54
|
}
|
|
47
55
|
/**
|
|
@@ -228,7 +236,8 @@ export function useBaseUpdate(options) {
|
|
|
228
236
|
return;
|
|
229
237
|
const now = fresh?.branches.find((b) => b.name === target);
|
|
230
238
|
const canRetry = now === undefined || now.status === 'editing';
|
|
231
|
-
|
|
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);
|
|
232
241
|
}
|
|
233
242
|
catch (err) {
|
|
234
243
|
if (!isUnanswered(err))
|
|
@@ -265,5 +274,6 @@ export function useBaseUpdate(options) {
|
|
|
265
274
|
requesting: !!name && requestingFor === name,
|
|
266
275
|
blockedReason: unsavedCount > 0 ? SAVE_FIRST_REASON : undefined,
|
|
267
276
|
request,
|
|
277
|
+
latestChangesWaiting: available && !!branch?.latestChangesWaiting,
|
|
268
278
|
};
|
|
269
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). */
|
|
@@ -92,6 +92,8 @@ 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;
|
|
95
97
|
/**
|
|
96
98
|
* How to ask for the latest changes on `branchName` once it is withdrawn, or undefined where
|
|
97
99
|
* that can't apply. With it, withdrawing a branch that conflicts with its base asks right after.
|