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.
Files changed (93) hide show
  1. package/CHANGELOG.md +211 -49
  2. package/dist/api/branch-review.d.ts +20 -2
  3. package/dist/api/branch-review.js +112 -23
  4. package/dist/api/branch-status.d.ts +11 -2
  5. package/dist/api/branch-status.js +3 -2
  6. package/dist/api/branch-update-from-base.js +57 -6
  7. package/dist/api/branch.d.ts +5 -0
  8. package/dist/api/branch.js +27 -2
  9. package/dist/api/client.d.ts +2 -2
  10. package/dist/api/client.js +2 -2
  11. package/dist/api/content.js +1 -1
  12. package/dist/api/merge-conflicts.d.ts +10 -1
  13. package/dist/api/merge-conflicts.js +42 -7
  14. package/dist/api/types.d.ts +3 -2
  15. package/dist/base-update.d.ts +6 -0
  16. package/dist/branch-metadata-file.d.ts +15 -0
  17. package/dist/branch-metadata-file.js +5 -1
  18. package/dist/cli/cli.js +238 -108
  19. package/dist/cli/generate-ai-content.js +20 -6
  20. package/dist/cli/github-app-manifest.d.ts +4 -1
  21. package/dist/cli/github-app-manifest.js +5 -1
  22. package/dist/cli/init.js +7 -1
  23. package/dist/editor/Editor.js +39 -16
  24. package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
  25. package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
  26. package/dist/editor/admin/SystemHealthPanel.js +4 -3
  27. package/dist/editor/base-update-wait.d.ts +4 -0
  28. package/dist/editor/base-update-wait.js +7 -0
  29. package/dist/editor/components/ConflictsGroup.d.ts +13 -1
  30. package/dist/editor/components/ConflictsGroup.js +52 -12
  31. package/dist/editor/components/EditorHeader.d.ts +3 -1
  32. package/dist/editor/components/EditorHeader.js +11 -7
  33. package/dist/editor/components/EntryChangesDrawer.d.ts +1 -1
  34. package/dist/editor/components/EntryChangesDrawer.js +58 -6
  35. package/dist/editor/conflict-cards.d.ts +4 -1
  36. package/dist/editor/conflict-cards.js +14 -9
  37. package/dist/editor/hooks/useBaseUpdate.d.ts +2 -0
  38. package/dist/editor/hooks/useBaseUpdate.js +12 -2
  39. package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
  40. package/dist/editor/hooks/useBranchChangesData.js +13 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  42. package/dist/editor/hooks/useBranchManager.js +80 -6
  43. package/dist/editor/hooks/useDraftManager.d.ts +8 -1
  44. package/dist/editor/hooks/useDraftManager.js +16 -1
  45. package/dist/editor/hooks/useEntryManager.d.ts +8 -3
  46. package/dist/editor/hooks/useEntryManager.js +6 -5
  47. package/dist/git-manager.d.ts +1 -0
  48. package/dist/git-manager.js +8 -3
  49. package/dist/merge/entry-merge.js +4 -3
  50. package/dist/merge/list-alignment.d.ts +9 -6
  51. package/dist/merge/list-alignment.js +65 -16
  52. package/dist/merge/rekey.d.ts +11 -0
  53. package/dist/merge/rekey.js +75 -0
  54. package/dist/operating-mode/client-safe-strategy.js +1 -1
  55. package/dist/operating-mode/client-unsafe-strategy.js +6 -0
  56. package/dist/operating-mode/types.d.ts +10 -2
  57. package/dist/services.d.ts +12 -4
  58. package/dist/services.js +56 -0
  59. package/dist/types.d.ts +35 -3
  60. package/dist/utils/git.d.ts +7 -0
  61. package/dist/utils/git.js +35 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/worker/branch-merge.d.ts +27 -3
  65. package/dist/worker/branch-merge.js +106 -5
  66. package/dist/worker/branch-policy.d.ts +18 -2
  67. package/dist/worker/branch-policy.js +36 -0
  68. package/dist/worker/cms-worker.d.ts +21 -0
  69. package/dist/worker/cms-worker.js +29 -4
  70. package/dist/worker/conflicts-status.d.ts +20 -0
  71. package/dist/worker/conflicts-status.js +204 -0
  72. package/dist/worker/field-merge.d.ts +17 -2
  73. package/dist/worker/field-merge.js +95 -2
  74. package/dist/worker/git-sync.d.ts +3 -2
  75. package/dist/worker/git-sync.js +9 -0
  76. package/dist/worker/github-commits.d.ts +3 -3
  77. package/dist/worker/github-commits.js +192 -40
  78. package/dist/worker/github-errors.d.ts +15 -0
  79. package/dist/worker/github-errors.js +47 -0
  80. package/dist/worker/github-gateway-http.js +7 -0
  81. package/dist/worker/github-gateway-wire.d.ts +30 -10
  82. package/dist/worker/github-gateway-wire.js +14 -2
  83. package/dist/worker/github-gateway.d.ts +17 -0
  84. package/dist/worker/github-gateway.js +37 -1
  85. package/dist/worker/history-rewrite.d.ts +40 -8
  86. package/dist/worker/history-rewrite.js +51 -23
  87. package/dist/worker/publish-decision.d.ts +37 -0
  88. package/dist/worker/publish-decision.js +16 -0
  89. package/dist/worker/rebase.d.ts +25 -5
  90. package/dist/worker/rebase.js +225 -81
  91. package/dist/worker/task-runner.js +16 -39
  92. package/dist/worker/worker-context.d.ts +6 -0
  93. 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
- { choice: 'restore', label: EDITOR_ACTIONS.restoreEntry },
106
- { choice: 'keep', label: EDITOR_ACTIONS.keepItDeleted },
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: [{ choice: 'keep', label: EDITOR_ACTIONS.keepItDeleted }],
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
- setWorking(undefined);
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
- /** The drawer's body: the entry's conflicts, if any, above its changes. */
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
- const latestPlaceable = !!latest &&
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
- // Your saved value where a save since the merge changed it, which is what keeping yours keeps:
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
- ...(!latestPlaceable && { latestUnavailable: true }),
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
- ...(!latestPlaceable && { latestUnavailable: true }),
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) || !isBaseUpdatePending(branch))
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
- showResult('red', result.error ?? REQUEST_FAILED_MESSAGE, canRetry ? retry : undefined);
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.