canopycms 0.5.0 → 0.7.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 (107) hide show
  1. package/CHANGELOG.md +185 -46
  2. package/dist/api/branch-review.d.ts +11 -1
  3. package/dist/api/branch-review.js +96 -18
  4. package/dist/api/branch-status.d.ts +26 -2
  5. package/dist/api/branch-status.js +38 -2
  6. package/dist/api/branch-withdraw.js +4 -7
  7. package/dist/api/branch.d.ts +2 -0
  8. package/dist/api/client.d.ts +3 -3
  9. package/dist/api/client.js +4 -4
  10. package/dist/api/comments.d.ts +2 -2
  11. package/dist/api/comments.js +4 -0
  12. package/dist/api/content.d.ts +2 -2
  13. package/dist/api/github-sync.d.ts +4 -0
  14. package/dist/api/github-sync.js +38 -6
  15. package/dist/api/pr-comment-sync.d.ts +18 -0
  16. package/dist/api/pr-comment-sync.js +77 -0
  17. package/dist/api/request-deadline.d.ts +15 -7
  18. package/dist/api/request-deadline.js +15 -7
  19. package/dist/api/schema.d.ts +16 -16
  20. package/dist/authorization/review-role.d.ts +33 -0
  21. package/dist/authorization/review-role.js +17 -0
  22. package/dist/branch-metadata-file.d.ts +3 -0
  23. package/dist/branch-metadata-file.js +1 -0
  24. package/dist/cli/cli.js +704 -131
  25. package/dist/cli/generate-ai-content.js +524 -59
  26. package/dist/cli/github-app-output.d.ts +34 -0
  27. package/dist/cli/github-app-output.js +85 -0
  28. package/dist/cli/init-github-app.d.ts +18 -0
  29. package/dist/cli/init-github-app.js +66 -58
  30. package/dist/cli/init.js +15 -2
  31. package/dist/cli/template-files/cms-stack.ts.template +3 -0
  32. package/dist/cli/templates.js +5 -1
  33. package/dist/comment-store.d.ts +5 -0
  34. package/dist/comment-store.js +24 -0
  35. package/dist/config/helpers.js +1 -3
  36. package/dist/config/schemas/collection.d.ts +2 -2
  37. package/dist/config/schemas/config.d.ts +3 -0
  38. package/dist/config/schemas/config.js +2 -1
  39. package/dist/config/schemas/url.d.ts +6 -0
  40. package/dist/config/schemas/url.js +16 -0
  41. package/dist/config/types.d.ts +12 -1
  42. package/dist/content-id-index.d.ts +9 -0
  43. package/dist/content-id-index.js +31 -0
  44. package/dist/editor/BranchManager.d.ts +3 -0
  45. package/dist/editor/BranchManager.js +75 -44
  46. package/dist/editor/CanopyEditor.js +2 -2
  47. package/dist/editor/CanopyEditorPage.js +3 -4
  48. package/dist/editor/Editor.js +47 -8
  49. package/dist/editor/branch-lock.d.ts +7 -0
  50. package/dist/editor/branch-lock.js +9 -2
  51. package/dist/editor/client-reference-resolver.d.ts +5 -3
  52. package/dist/editor/client-reference-resolver.js +10 -5
  53. package/dist/editor/components/BranchesLoadFailedNotice.d.ts +13 -0
  54. package/dist/editor/components/BranchesLoadFailedNotice.js +12 -0
  55. package/dist/editor/components/EditorHeader.d.ts +10 -4
  56. package/dist/editor/components/EditorHeader.js +108 -28
  57. package/dist/editor/components/EntryHeader.d.ts +5 -1
  58. package/dist/editor/components/EntryHeader.js +3 -1
  59. package/dist/editor/components/ReasonedMenuItem.d.ts +8 -0
  60. package/dist/editor/components/ReasonedMenuItem.js +2 -2
  61. package/dist/editor/components/ReviewDialogs.d.ts +19 -0
  62. package/dist/editor/components/ReviewDialogs.js +41 -0
  63. package/dist/editor/copy.d.ts +4 -0
  64. package/dist/editor/copy.js +4 -0
  65. package/dist/editor/fields/MarkdownField.js +1 -1
  66. package/dist/editor/hooks/create-branch-request.d.ts +6 -2
  67. package/dist/editor/hooks/create-branch-request.js +7 -2
  68. package/dist/editor/hooks/useBranchManager.d.ts +7 -2
  69. package/dist/editor/hooks/useBranchManager.js +252 -37
  70. package/dist/editor-url.d.ts +20 -0
  71. package/dist/editor-url.js +71 -0
  72. package/dist/git-manager.js +10 -6
  73. package/dist/github-service.d.ts +2 -2
  74. package/dist/github-service.js +4 -2
  75. package/dist/pr-comments.d.ts +33 -0
  76. package/dist/pr-comments.js +41 -0
  77. package/dist/schema/schema-store.d.ts +8 -8
  78. package/dist/services.d.ts +2 -17
  79. package/dist/services.js +38 -1
  80. package/dist/settings-workspace.d.ts +21 -7
  81. package/dist/settings-workspace.js +91 -16
  82. package/dist/submission-attribution.d.ts +38 -1
  83. package/dist/submission-attribution.js +197 -18
  84. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  85. package/dist/types.d.ts +6 -0
  86. package/dist/utils/content-serialize.js +7 -6
  87. package/dist/utils/markdown-body-splice.d.ts +15 -10
  88. package/dist/utils/markdown-body-splice.js +71 -28
  89. package/dist/utils/markdown-export-options.d.ts +13 -0
  90. package/dist/utils/markdown-export-options.js +13 -0
  91. package/dist/utils/markdown-restyle.d.ts +58 -0
  92. package/dist/utils/markdown-restyle.js +482 -0
  93. package/dist/version.d.ts +1 -1
  94. package/dist/version.js +1 -1
  95. package/dist/worker/bundle-header.d.ts +13 -9
  96. package/dist/worker/bundle-header.js +17 -11
  97. package/dist/worker/git-process.d.ts +2 -2
  98. package/dist/worker/github-gateway-http.js +5 -4
  99. package/dist/worker/github-gateway-wire.d.ts +3 -0
  100. package/dist/worker/github-gateway-wire.js +1 -0
  101. package/dist/worker/github-gateway.d.ts +1 -1
  102. package/dist/worker/github-gateway.js +2 -2
  103. package/dist/worker/github-transfer.js +72 -2
  104. package/dist/worker/task-runner.js +53 -3
  105. package/package.json +2 -2
  106. package/dist/editor/fields/markdown-export-options.d.ts +0 -2
  107. package/dist/editor/fields/markdown-export-options.js +0 -8
@@ -0,0 +1,13 @@
1
+ export interface BranchesLoadFailedNoticeProps {
2
+ /** Loads the branch list again. Without it the notice is text alone. */
3
+ onRetry?: () => void;
4
+ /** A retry is in flight. */
5
+ retrying?: boolean;
6
+ align?: 'flex-start' | 'center';
7
+ 'data-testid'?: string;
8
+ }
9
+ /**
10
+ * The error state of an editor no branch is pinned in, while the branch list it takes its
11
+ * branch from has failed to load. It outlasts the dismissible toast for the same failure.
12
+ */
13
+ export declare function BranchesLoadFailedNotice({ onRetry, retrying, align, 'data-testid': testId, }: BranchesLoadFailedNoticeProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, Stack, Text } from '@mantine/core';
3
+ /**
4
+ * The error state of an editor no branch is pinned in, while the branch list it takes its
5
+ * branch from has failed to load. It outlasts the dismissible toast for the same failure.
6
+ */
7
+ export function BranchesLoadFailedNotice({ onRetry, retrying = false, align = 'flex-start', 'data-testid': testId, }) {
8
+ return (_jsxs(Stack, { gap: 4, py: "sm", align: align, "data-testid": testId, children: [_jsx(Text, { size: "sm", c: "var(--canopy-danger-text)", children: "Couldn't load the branch list, so no branch can open." }), onRetry && (_jsx(Button, { size: "compact-sm", variant: "subtle", "aria-disabled": retrying || undefined, style: { cursor: retrying ? 'default' : undefined }, onClick: () => {
9
+ if (!retrying)
10
+ onRetry();
11
+ }, children: retrying ? 'Retrying…' : 'Retry' }))] }));
12
+ }
@@ -1,5 +1,5 @@
1
1
  import { type Ref } from 'react';
2
- import type { BranchStatus } from '../../types.js';
2
+ import type { BranchStatus, PullRequestState } from '../../types.js';
3
3
  import type { SaveAllProgress } from '../hooks/save-all.js';
4
4
  export interface EditorHeaderProps {
5
5
  branchName: string;
@@ -27,6 +27,10 @@ export interface EditorHeaderProps {
27
27
  onSubmit: (returnFocusTo?: () => HTMLElement | null) => void;
28
28
  /** Opens the withdraw dialog, which returns focus to `returnFocusTo()` when given. */
29
29
  onWithdraw: (returnFocusTo?: () => HTMLElement | null) => void;
30
+ /** Opens the approve dialog, which returns focus to `returnFocusTo()` when given. */
31
+ onApprove?: (returnFocusTo?: () => HTMLElement | null) => void;
32
+ /** Opens the request-changes dialog, which returns focus to `returnFocusTo()` when given. */
33
+ onRequestChanges?: (returnFocusTo?: () => HTMLElement | null) => void;
30
34
  /** Writing drafts to localStorage failed, so they live only in this tab. */
31
35
  draftStorageFailed?: boolean;
32
36
  /** Current branch status (undefined if unknown). */
@@ -37,6 +41,8 @@ export interface EditorHeaderProps {
37
41
  groups?: string[];
38
42
  };
39
43
  branchCreatedBy?: string;
44
+ /** The PR's state as last observed; a closed one blocks Approve and Request changes. */
45
+ branchPullRequestState?: PullRequestState;
40
46
  branchAccess?: {
41
47
  allowedUsers?: string[];
42
48
  allowedGroups?: string[];
@@ -63,7 +69,7 @@ export interface EditorHeaderProps {
63
69
  }
64
70
  /**
65
71
  * The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
66
- * warning, Save all, the way forward and Submit or Withdraw. The open entry's own controls are in
67
- * `EntryHeader`.
72
+ * warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
73
+ * submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
68
74
  */
69
- export declare function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch, onSaveAll, saveAllProgress, singleSaving, entryListFailed, onSubmit, onWithdraw, draftStorageFailed, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, branchServerProtected, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
75
+ export declare function EditorHeader({ branchName, 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, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EditorHeaderProps): import("react/jsx-runtime").JSX.Element;
@@ -6,6 +6,7 @@ import { IconChevronDown, IconLock } 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 { EDITOR_ACTIONS } from '../copy.js';
9
+ import { reviewRoleOf } from '../../authorization/review-role.js';
9
10
  import { SYSTEM_USER_ID } from '../../system-users.js';
10
11
  import { TopBar } from '../shell/TopBar.js';
11
12
  import { BranchPill } from './BranchPill.js';
@@ -15,18 +16,18 @@ import { DraftStorageWarning, HIDDEN_STYLE, SaveAllButton } from './SaveAllButto
15
16
  import { useFocusHandOff, useMenuItemFocusReturn } from '../utils/focus-return.js';
16
17
  /**
17
18
  * The branch bar: the pill, the branch's status and its unsaved count, then the draft-storage
18
- * warning, Save all, the way forward and Submit or Withdraw. The open entry's own controls are in
19
- * `EntryHeader`.
19
+ * warning, Save all, the way forward and Submit or Withdraw, or to a reviewer of someone else's
20
+ * submission Approve and Request changes. The open entry's own controls are in `EntryHeader`.
20
21
  */
21
- export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, branchToggleRef, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onNewBranch = onBranchManagerOpen, onSaveAll, saveAllProgress, singleSaving = false, entryListFailed = false, onSubmit, onWithdraw, draftStorageFailed = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchServerProtected, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
22
- const lock = { branchStatus, branchReadOnly, branchWriteBlocked, branchPending, branchMissing };
23
- const branchLockReason = describeBranchLock(lock);
24
- const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
25
- const theme = useMantineTheme();
26
- // Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
27
- const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
28
- const narrow = useMediaQuery(belowMd, false, { getInitialValueInEffect: false }) === true;
29
- const editable = !!branchName && !branchLoading && !branchLockReason;
22
+ export function EditorHeader({ branchName, 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, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
23
+ const reviewRole = reviewRoleOf({
24
+ status: branchStatus,
25
+ createdBy: branchCreatedBy,
26
+ users: branchAccess?.allowedUsers,
27
+ groups: branchAccess?.allowedGroups,
28
+ isProtected: branchIsProtected,
29
+ pullRequestState: branchPullRequestState,
30
+ }, userContext);
30
31
  const workflow = workflowAction({
31
32
  branchName,
32
33
  busy,
@@ -35,13 +36,31 @@ export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, b
35
36
  branchCreatedBy,
36
37
  branchAccess,
37
38
  branchIsProtected,
39
+ reviewRole,
38
40
  savingAll: !!saveAllProgress,
39
41
  });
42
+ const lock = {
43
+ branchStatus,
44
+ branchReadOnly,
45
+ branchWriteBlocked,
46
+ branchPending,
47
+ branchMissing,
48
+ reviewing: workflow?.kind === 'review',
49
+ pullRequestClosed: reviewRole === 'reviewing-closed',
50
+ };
51
+ const branchLockReason = describeBranchLock(lock);
52
+ const status = branchBarStatus({ ...lock, isProtected: branchServerProtected });
53
+ const theme = useMantineTheme();
54
+ // Mantine's own below-`md` query, the one `visibleFrom="md"` hides under.
55
+ const belowMd = `(max-width: ${em(getBreakpointValue('md', theme.breakpoints) - 0.1)})`;
56
+ const narrow = useMediaQuery(belowMd, false, { getInitialValueInEffect: false }) === true;
57
+ const editable = !!branchName && !branchLoading && !branchLockReason;
40
58
  // Below `md` one split button holds the bar's actions: Save all on an editable branch, else the
41
59
  // row's way forward, with the workflow action in its caret. Withdraw leads only for a user who
42
60
  // may withdraw; anyone else gets Switch branch…, the blocked Withdraw and its reason in the
43
- // caret. A protected branch keeps New branch… either way. Wide, a Withdraw the user can't take
44
- // shows blocked, with no Switch branch… beside it: that stays in the pill's menu.
61
+ // caret. A reviewer's Approve leads the same way, with Request changes in its own caret. A
62
+ // protected branch keeps New branch… either way. Wide, a Withdraw the user can't take shows
63
+ // blocked, with no Switch branch… beside it: that stays in the pill's menu.
45
64
  const split = narrow && editable;
46
65
  const workflowLeads = status.action === 'workflow' && !!workflow?.permitted;
47
66
  const barAction = status.action === 'workflow'
@@ -50,16 +69,19 @@ export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, b
50
69
  : undefined
51
70
  : status.action;
52
71
  // Controls come and go as a submit or withdraw changes the lock, and focus follows them.
53
- const [[wayRef, saveAllRef, workflowRef], focusTarget] = useFocusHandOff(3);
72
+ const [[wayRef, saveAllRef, workflowRef, secondaryRef], focusTarget] = useFocusHandOff(4);
54
73
  const workflowControl = {
55
74
  onSubmit,
56
75
  onWithdraw,
76
+ onApprove,
77
+ onRequestChanges,
57
78
  controlRef: workflowRef,
79
+ secondaryRef,
58
80
  // A withdraw that leaves no workflow action returns focus to the way forward, else Save all.
59
81
  returnFocusTo: () => focusTarget(2),
60
82
  };
61
83
  const caretOnWay = narrow && !!barAction && !!workflow;
62
- return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, !!workflow?.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
84
+ return (_jsx(TopBar, { leading: _jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", style: { rowGap: 4 }, children: [_jsx(BranchPill, { branchName: branchName, loading: busy, unsavedCount: modifiedCount, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, pillRef: branchToggleRef }), _jsx(BranchStatusLabel, { status: branchName ? status : { value: 'none', readOnly: false }, reason: branchLockReason }), modifiedCount > 0 && (_jsx(Tooltip, { label: unsavedTooltip(branchName ? status : undefined, modifiedCount, workflow?.kind === 'withdraw' && workflow.permitted), events: TOOLTIP_EVENTS, multiline: true, maw: 320, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", style: { flexShrink: 0 }, leftSection: _jsx(UnsavedMark, { "aria-hidden": "true" }), onClick: onShowUnsaved, "data-testid": "branch-unsaved-count", children: `${modifiedCount} unsaved` }) }))] }), trailing: _jsxs(_Fragment, { children: [_jsx(DraftStorageWarning, { shown: draftStorageFailed && modifiedCount > 0 }), (!narrow || editable) && (_jsx(SaveAllButton
63
85
  // Its reserved width follows this branch's counts, not the session's largest.
64
86
  , { onSaveAll: onSaveAll, unsavedCount: modifiedCount, blockedReason: branchLockReason ??
65
87
  (!branchName
@@ -70,8 +92,11 @@ export function EditorHeader({ branchName, busy, modifiedCount, onShowUnsaved, b
70
92
  ? onNewBranch
71
93
  : barAction === 'refreshBranch'
72
94
  ? onBranchReloadData
73
- : onBranchManagerOpen, caret: caretOnWay && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined })), !split && !caretOnWay && workflow && (_jsx(WorkflowButton, { ...workflowControl, action: workflow }))] }) }));
95
+ : onBranchManagerOpen, caret: caretOnWay && workflow ? (_jsx(WorkflowCaret, { ...workflowControl, action: workflow })) : undefined })), !split && !caretOnWay && workflow && (_jsx(WorkflowButton, { ...workflowControl, action: workflow, narrow: narrow }))] }) }));
74
96
  }
97
+ const noopAction = () => { };
98
+ /** Why a workflow control waits when the bar is busy and nothing else holds it. */
99
+ const BUSY_REASON = 'Wait for the current save or load to finish.';
75
100
  const TOOLTIP_EVENTS = { hover: true, focus: true, touch: false };
76
101
  /**
77
102
  * On a locked branch, what would let its drafts be saved. A new branch starts without them, so a
@@ -111,13 +136,16 @@ function BranchWayButton({ action, onClick, caret, buttonRef, }) {
111
136
  // Grouped even alone, as Save all is, so a caret coming or going never remounts the button.
112
137
  _jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => (_jsx("div", { ...props, role: caret ? 'group' : undefined, "aria-label": caret ? 'Branch controls' : undefined })), children: [_jsx(Button, { ref: buttonRef, variant: "light", size: "sm", onClick: () => onClick(), "data-testid": `${action.replace('Branch', '')}-branch-button`, children: WAY_LABEL[action] }), caret] }));
113
138
  }
114
- /** Submit for review, or Withdraw from review once submitted or approved; null when neither shows. */
115
- function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, savingAll, }) {
139
+ /**
140
+ * Submit for review, or Withdraw from review once submitted or approved, or Approve and Request
141
+ * changes to a reviewer of someone else's submission; null when none shows.
142
+ */
143
+ function workflowAction({ branchName, busy, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected, reviewRole, savingAll, }) {
116
144
  const isSubmitted = branchStatus === 'submitted';
117
145
  const isEditing = branchStatus === 'editing';
118
- // 'approved' withdraws too -- it is that status's only
119
- // non-destructive exit now that the submit status gate refuses a
120
- // non-editing branch (api/branch-status.ts, api/branch-withdraw.ts).
146
+ // 'approved' withdraws too -- it is the author's way back from
147
+ // either status, since the editor offers an author no Request changes
148
+ // and the submit gate refuses a non-editing branch (api/branch-withdraw.ts).
121
149
  // This button is a two-state toggle, so every withdrawable status
122
150
  // has to drive the withdraw side of it, not just 'submitted'.
123
151
  const isWithdrawable = isSubmitted || branchStatus === 'approved';
@@ -127,6 +155,15 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
127
155
  // wrongly stuck in 'submitted'.
128
156
  if (branchIsProtected && !isWithdrawable)
129
157
  return null;
158
+ if (reviewRole === 'reviewing') {
159
+ return {
160
+ kind: 'review',
161
+ offersApprove: isSubmitted,
162
+ blocked: !branchName || busy || savingAll,
163
+ permitted: true,
164
+ reason: savingAll ? 'Wait for Save all to finish.' : busy ? BUSY_REASON : '',
165
+ };
166
+ }
130
167
  // Who may submit/withdraw: creator, ACL access, the system branch, or an
131
168
  // admin/reviewer (alone on a locked-down branch, and even without ACL access
132
169
  // on a protected base branch stuck in 'submitted'). Mirrors the server's
@@ -167,7 +204,42 @@ function workflowAction({ branchName, busy, branchStatus, userContext, branchCre
167
204
  : '',
168
205
  };
169
206
  }
170
- function WorkflowButton({ action, onSubmit, onWithdraw, controlRef, returnFocusTo, }) {
207
+ function WorkflowButton({ narrow, ...props }) {
208
+ return props.action.kind === 'review' ? (_jsx(ReviewButtons, { ...props, narrow: narrow })) : (_jsx(SubmitOrWithdrawButton, { ...props }));
209
+ }
210
+ /**
211
+ * Approve (leading) and Request changes. Below `md`, Approve is a split button whose caret holds
212
+ * Request changes; with nothing to approve, Request changes stands alone.
213
+ */
214
+ function ReviewButtons({ action, onApprove, onRequestChanges, controlRef, secondaryRef, returnFocusTo, narrow, ...rest }) {
215
+ // The bar's hand-off slot goes to Approve while there is one, in either layout, so focus on it
216
+ // moves on when it goes; then Request changes, the one control left, takes the slot. Beside
217
+ // Approve, Request changes (or the caret that holds it) has a slot of its own, so focus on it
218
+ // moves on when the branch leaves review.
219
+ const approveEl = useRef(null);
220
+ const requestChangesEl = useRef(null);
221
+ const approveRef = useMergedRef(approveEl, action.offersApprove ? controlRef : undefined);
222
+ const requestChangesRef = useMergedRef(requestChangesEl, action.offersApprove ? (narrow ? undefined : secondaryRef) : controlRef);
223
+ // Each returns focus to its own button while that is mounted, else to the control left.
224
+ const approveReturn = () => (approveEl.current?.isConnected ? approveEl.current : returnFocusTo());
225
+ const requestChangesReturn = () => requestChangesEl.current?.isConnected ? requestChangesEl.current : returnFocusTo();
226
+ const approve = (_jsx(ReviewButton, { buttonRef: approveRef, action: action, color: "brand", testId: "approve-button", label: EDITOR_ACTIONS.approve, onClick: () => onApprove(approveReturn) }));
227
+ const requestChanges = (_jsx(ReviewButton, { buttonRef: requestChangesRef, action: action, color: "orange", testId: "request-changes-button", label: EDITOR_ACTIONS.requestChanges, onClick: () => onRequestChanges(requestChangesReturn) }));
228
+ if (!action.offersApprove)
229
+ return requestChanges;
230
+ if (!narrow) {
231
+ return (_jsxs(Group, { gap: 8, wrap: "nowrap", style: { flexShrink: 0 }, children: [requestChanges, approve] }));
232
+ }
233
+ return (_jsxs(Button.Group, { style: { flexShrink: 0 }, renderRoot: (props) => _jsx("div", { ...props, role: "group", "aria-label": "Review controls" }), children: [approve, _jsx(WorkflowCaret, { ...rest, action: action, onApprove: onApprove, onRequestChanges: onRequestChanges, returnFocusTo: returnFocusTo, includeApprove: false, color: "brand", controlRef: secondaryRef })] }));
234
+ }
235
+ /** One review button, blocked as a whole with the action's reason. */
236
+ function ReviewButton({ action, buttonRef, color, testId, label, onClick, }) {
237
+ return (_jsx(Tooltip, { label: action.reason, disabled: !action.reason, multiline: true, maw: 320, children: _jsx(Button, { ref: buttonRef, size: "sm", variant: "light", color: color, style: { flexShrink: 0 }, onClick: () => {
238
+ if (!action.blocked)
239
+ onClick();
240
+ }, "data-disabled": action.blocked ? true : undefined, "aria-disabled": action.blocked ? true : undefined, "data-testid": testId, children: label }) }));
241
+ }
242
+ function SubmitOrWithdrawButton({ action, onSubmit, onWithdraw, controlRef, returnFocusTo, }) {
171
243
  const withdraw = action.kind === 'withdraw';
172
244
  return (_jsx(Tooltip, { label: action.reason, disabled: !action.reason, multiline: true, maw: 320, children: _jsx(Button, { ref: controlRef, size: "sm",
173
245
  // The entry's Save is the one filled primary; the rest take their colour's light variant.
@@ -180,20 +252,28 @@ function WorkflowButton({ action, onSubmit, onWithdraw, controlRef, returnFocusT
180
252
  onSubmit(returnFocusTo);
181
253
  }, "data-disabled": action.blocked ? true : undefined, "aria-disabled": action.blocked ? true : undefined, "data-testid": withdraw ? 'withdraw-button' : 'submit-button', children: withdraw ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview }) }));
182
254
  }
183
- /** Save all's caret below `md`, holding the workflow action. */
184
- function WorkflowCaret({ action, onSubmit, onWithdraw, controlRef, returnFocusTo, }) {
255
+ /**
256
+ * The workflow action's caret below `md`, on Save all or the way forward or, for a review, beside
257
+ * Approve. `includeApprove` is false there, where Approve is already a button.
258
+ */
259
+ function WorkflowCaret({ action, onSubmit, onWithdraw, onApprove, onRequestChanges, controlRef, returnFocusTo, includeApprove = true, color, }) {
185
260
  const caret = useRef(null);
186
261
  const caretRef = useMergedRef(caret, controlRef);
187
262
  const menuFocus = useMenuItemFocusReturn(caret);
188
263
  const withdraw = action.kind === 'withdraw';
264
+ // Beside Approve it holds only Request changes, and a dialog it opens returns to the caret.
265
+ const reviewCaret = action.kind === 'review' && !includeApprove;
266
+ const caretLabel = reviewCaret ? 'More review actions' : 'Branch actions';
267
+ const returnToCaret = reviewCaret
268
+ ? () => (caret.current?.isConnected ? caret.current : returnFocusTo())
269
+ : returnFocusTo;
270
+ const blockedReason = action.blocked ? action.reason || BUSY_REASON : undefined;
189
271
  return (
190
272
  // Bounded, so a blocked item's reason wraps on a phone rather than run off the screen.
191
- _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, ...menuFocus.menu, children: [_jsx(Tooltip, { label: "Branch actions", events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caretRef, variant: "light", size: "sm", px: 8, "aria-label": "Branch actions", "data-testid": "branch-actions-button", style: { borderInlineStart: '1px solid var(--canopy-input-border)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: _jsx(ReasonedMenuItem, { "data-testid": withdraw ? 'withdraw-menu-item' : 'submit-menu-item', blockedReason: action.blocked
192
- ? action.reason || 'Wait for the current save or load to finish.'
193
- : undefined, onClick: () => {
273
+ _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, ...menuFocus.menu, children: [_jsx(Tooltip, { label: caretLabel, events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caretRef, variant: "light", color: color, size: "sm", px: 8, "aria-label": caretLabel, "data-testid": "branch-actions-button", style: { borderInlineStart: '1px solid var(--canopy-input-border)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: action.kind === 'review' ? (_jsxs(_Fragment, { children: [action.offersApprove && includeApprove && (_jsx(ReasonedMenuItem, { "data-testid": "approve-menu-item", blockedReason: blockedReason, onClick: () => onApprove(returnToCaret), children: EDITOR_ACTIONS.approve })), _jsx(ReasonedMenuItem, { "data-testid": "request-changes-menu-item", blockedReason: blockedReason, onClick: () => onRequestChanges(returnToCaret), children: EDITOR_ACTIONS.requestChanges })] })) : (_jsx(ReasonedMenuItem, { "data-testid": withdraw ? 'withdraw-menu-item' : 'submit-menu-item', blockedReason: blockedReason, onClick: () => {
194
274
  if (withdraw)
195
275
  onWithdraw(returnFocusTo);
196
276
  else
197
277
  onSubmit(returnFocusTo);
198
- }, children: withdraw ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview }) })] }));
278
+ }, children: withdraw ? EDITOR_ACTIONS.withdrawFromReview : EDITOR_ACTIONS.submitForReview })) })] }));
199
279
  }
@@ -55,6 +55,10 @@ export interface EntryHeaderProps {
55
55
  branchPending?: boolean;
56
56
  /** The branch list answered without this branch. */
57
57
  branchMissing?: boolean;
58
+ /** The user reviews this branch rather than owns it; the lock reason then names Approve. */
59
+ branchReviewing?: boolean;
60
+ /** The user would review this branch but its pull request was closed; the lock reason says so. */
61
+ branchPullRequestClosed?: boolean;
58
62
  }
59
63
  /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
60
- export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
64
+ export declare function EntryHeader({ currentEntry, entryLoading, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving, savedSignal, savedHere, hasUnsavedChanges, entryHasDraft, draftStorageFailed, entryReadOnly, entryUnavailable, branchStatus, branchReadOnly, branchWriteBlocked, branchLoading, branchPending, branchMissing, branchReviewing, branchPullRequestClosed, }: EntryHeaderProps): import("react/jsx-runtime").JSX.Element;
@@ -6,13 +6,15 @@ import { describeBranchLock } from '../branch-lock.js';
6
6
  import { EntryBreadcrumb } from './EntryBreadcrumb.js';
7
7
  import { EntrySaveButton } from './EntrySaveButton.js';
8
8
  /** The open entry's breadcrumb, menu, unsaved dot and "Read-only" chip, then Save, over the panes. */
9
- export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, }) {
9
+ export function EntryHeader({ currentEntry, entryLoading = false, branchName, busy, breadcrumbSegments, onCollectionOpen, onEntryReveal, navigatorToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onSave, saveAllRunning, saving = false, savedSignal, savedHere = false, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, branchPending = false, branchMissing = false, branchReviewing = false, branchPullRequestClosed = false, }) {
10
10
  const branchLockReason = describeBranchLock({
11
11
  branchStatus,
12
12
  branchReadOnly,
13
13
  branchWriteBlocked,
14
14
  branchPending,
15
15
  branchMissing,
16
+ reviewing: branchReviewing,
17
+ pullRequestClosed: branchPullRequestClosed,
16
18
  });
17
19
  const entryLockReason = entryReadOnly
18
20
  ? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
@@ -3,8 +3,16 @@ import { type MenuItemProps } from '@mantine/core';
3
3
  export interface ReasonedMenuItemProps extends MenuItemProps {
4
4
  /** Why the item can't act now. Set, the item is blocked and shows this beneath its label. */
5
5
  blockedReason?: string;
6
+ /** False when a neighbouring item already says why, so the reason is not printed twice. */
7
+ showReason?: boolean;
8
+ /** The reason's element id, which another blocked item that shows no reason can point to. */
9
+ reasonId?: string;
10
+ /** The id of the element that gives this item's reason, when it is not printed beneath it. */
11
+ reasonDescribedBy?: string;
6
12
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
7
13
  'data-testid'?: string;
14
+ /** The caller's own description, kept unless a shared reason points elsewhere. */
15
+ 'aria-describedby'?: string;
8
16
  children: React.ReactNode;
9
17
  }
10
18
  /**
@@ -6,10 +6,10 @@ import { Menu, Text } from '@mantine/core';
6
6
  * Menu drops a `disabled` or `data-disabled` item from its arrow-key order, hiding the reason from
7
7
  * a keyboard user; a blocked item is `aria-disabled` instead and ignores its click.
8
8
  */
9
- export const ReasonedMenuItem = ({ blockedReason, onClick, children, color, style, ...rest }) => (_jsxs(Menu.Item, { ...rest, color: blockedReason ? undefined : color, c: blockedReason ? 'dimmed' : rest.c, "aria-disabled": blockedReason ? true : undefined, closeMenuOnClick: blockedReason ? false : undefined, style: blockedReason ? { ...style, cursor: 'not-allowed' } : style, onClick: (event) => {
9
+ export const ReasonedMenuItem = ({ blockedReason, showReason = true, reasonId, reasonDescribedBy, onClick, children, color, style, ...rest }) => (_jsxs(Menu.Item, { ...rest, color: blockedReason ? undefined : color, c: blockedReason ? 'dimmed' : rest.c, "aria-disabled": blockedReason ? true : undefined, "aria-describedby": (blockedReason && reasonDescribedBy) || rest['aria-describedby'], closeMenuOnClick: blockedReason ? false : undefined, style: blockedReason ? { ...style, cursor: 'not-allowed' } : style, onClick: (event) => {
10
10
  if (blockedReason) {
11
11
  event.stopPropagation();
12
12
  return;
13
13
  }
14
14
  onClick?.(event);
15
- }, children: [children, blockedReason && (_jsx(Text, { size: "xs", c: "dimmed", component: "span", display: "block", style: { whiteSpace: 'normal' }, children: blockedReason }))] }));
15
+ }, children: [children, blockedReason && showReason && (_jsx(Text, { id: reasonId, size: "xs", c: "dimmed", component: "span", display: "block", style: { whiteSpace: 'normal' }, children: blockedReason }))] }));
@@ -0,0 +1,19 @@
1
+ /** What the Approve dialog says; the pull request clause needs a pull request to be true. */
2
+ export declare function approveMessage(branchName: string, hasPullRequest: boolean): string;
3
+ /** The Request changes dialog's body: what happens, and an optional note for the editor. */
4
+ export declare function RequestChangesForm({ branchName, hasPullRequest, initialNote, onNoteChange, }: {
5
+ branchName: string;
6
+ hasPullRequest: boolean;
7
+ /** A note typed before, such as one a failed request left behind. */
8
+ initialNote?: string;
9
+ onNoteChange: (note: string) => void;
10
+ }): import("react/jsx-runtime").JSX.Element;
11
+ /**
12
+ * A notification body that holds a note the reviewer typed, which is not saved: the message, the
13
+ * note quoted, and a button to copy it. `hint` says what to do with the copy.
14
+ */
15
+ export declare function NoteNotSavedMessage({ message, note, hint, }: {
16
+ message: string;
17
+ note: string;
18
+ hint?: string;
19
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useRef, useState } from 'react';
4
+ import { Button, CopyButton, Stack, Text, Textarea, VisuallyHidden } from '@mantine/core';
5
+ import { REQUEST_CHANGES_NOTE_MAX_LENGTH } from '../../authorization/review-role.js';
6
+ /** The note counter appears once a note is this long, and is announced in the last 100. */
7
+ const NOTE_COUNT_FROM = 1800;
8
+ const NOTE_ANNOUNCE_FROM = REQUEST_CHANGES_NOTE_MAX_LENGTH - 100;
9
+ /** What the Approve dialog says; the pull request clause needs a pull request to be true. */
10
+ export function approveMessage(branchName, hasPullRequest) {
11
+ return hasPullRequest
12
+ ? `Approve "${branchName}"? Its pull request stays open on GitHub for someone with access to merge it.`
13
+ : `Approve "${branchName}"? It stays locked until it is withdrawn or changes are requested.`;
14
+ }
15
+ /** The Request changes dialog's body: what happens, and an optional note for the editor. */
16
+ export function RequestChangesForm({ branchName, hasPullRequest, initialNote = '', onNoteChange, }) {
17
+ const [note, setNote] = useState(initialNote);
18
+ // The first focus, which the dialog's focus trap gives, puts the caret after a prefilled note so
19
+ // typing carries on from it; later focus keeps wherever the caret was left.
20
+ const caretPlaced = useRef(false);
21
+ return (_jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", children: `Send "${branchName}" back to its editor? It goes back to editing${hasPullRequest ? ', and its pull request becomes a draft' : ''}.` }), _jsxs(Stack, { gap: 4, children: [_jsx(Textarea, { label: "Note to the editor", description: "Saved as a comment on the branch.", value: note, onChange: (event) => {
22
+ setNote(event.currentTarget.value);
23
+ onNoteChange(event.currentTarget.value);
24
+ }, maxLength: REQUEST_CHANGES_NOTE_MAX_LENGTH, autosize: true, minRows: 3, maxRows: 8, onFocus: (event) => {
25
+ if (caretPlaced.current)
26
+ return;
27
+ caretPlaced.current = true;
28
+ const end = event.currentTarget.value.length;
29
+ event.currentTarget.setSelectionRange(end, end);
30
+ }, "data-autofocus": true, "data-testid": "request-changes-note" }), note.length > NOTE_COUNT_FROM && (_jsx(Text, { size: "xs", c: "dimmed", ta: "end", "aria-live": note.length >= NOTE_ANNOUNCE_FROM ? 'polite' : undefined, "data-testid": "request-changes-note-count", children: `${note.length.toLocaleString('en-US')} / ${REQUEST_CHANGES_NOTE_MAX_LENGTH.toLocaleString('en-US')}` }))] })] }));
31
+ }
32
+ /**
33
+ * A notification body that holds a note the reviewer typed, which is not saved: the message, the
34
+ * note quoted, and a button to copy it. `hint` says what to do with the copy.
35
+ */
36
+ export function NoteNotSavedMessage({ message, note, hint = 'Copy it into a comment on the branch.', }) {
37
+ return (_jsxs(Stack, { gap: 6, children: [_jsx(Text, { size: "sm", children: `${message} ${hint}` }), _jsx(Text, { component: "blockquote", size: "sm", m: 0, ps: "sm", lineClamp: 6, style: {
38
+ whiteSpace: 'pre-wrap',
39
+ borderInlineStart: '2px solid var(--canopy-input-border)',
40
+ }, "data-testid": "note-not-saved-text", children: note }), _jsx(CopyButton, { value: note, children: ({ copied, copy }) => (_jsxs(_Fragment, { children: [_jsx(Button, { size: "compact-sm", variant: "light", style: { alignSelf: 'flex-start' }, onClick: copy, "data-testid": "copy-note-button", children: copied ? 'Copied' : 'Copy note' }), _jsx(VisuallyHidden, { role: "status", "aria-live": "polite", children: copied ? 'Note copied' : '' })] })) })] }));
41
+ }
@@ -9,6 +9,8 @@ export declare const EDITOR_ACTIONS: {
9
9
  readonly refreshFromServer: "Refresh from server";
10
10
  readonly submitForReview: "Submit for review";
11
11
  readonly withdrawFromReview: "Withdraw from review";
12
+ readonly approve: "Approve";
13
+ readonly review: "Review";
12
14
  readonly requestChanges: "Request changes";
13
15
  readonly switchBranch: "Switch branch";
14
16
  readonly newBranch: "New branch";
@@ -24,3 +26,5 @@ export declare const EDITOR_ACTIONS: {
24
26
  * `one` and `many` name what the list holds.
25
27
  */
26
28
  export declare const listFullReason: (maxItems: number, one?: string, many?: string) => string;
29
+ /** Why Approve and Request changes can't act: the pull request they act on is gone. */
30
+ export declare const CLOSED_PULL_REQUEST_REASON = "The pull request was closed on GitHub. Use \"Withdraw from review\", then submit it again to open a new one.";
@@ -9,6 +9,8 @@ export const EDITOR_ACTIONS = {
9
9
  refreshFromServer: 'Refresh from server',
10
10
  submitForReview: 'Submit for review',
11
11
  withdrawFromReview: 'Withdraw from review',
12
+ approve: 'Approve',
13
+ review: 'Review',
12
14
  requestChanges: 'Request changes',
13
15
  switchBranch: 'Switch branch',
14
16
  newBranch: 'New branch',
@@ -24,3 +26,5 @@ export const EDITOR_ACTIONS = {
24
26
  * `one` and `many` name what the list holds.
25
27
  */
26
28
  export const listFullReason = (maxItems, one = 'item', many = 'items') => `This list holds at most ${maxItems} ${maxItems === 1 ? one : many}. Remove one to add another.`;
29
+ /** Why Approve and Request changes can't act: the pull request they act on is gone. */
30
+ export const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Use "Withdraw from review", then submit it again to open a new one.';
@@ -3,7 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import React, { Suspense, useId, useRef, useCallback, useEffect, useReducer, useState } from 'react';
4
4
  import { Alert, Box, Button, Text, Textarea, useComputedColorScheme } from '@mantine/core';
5
5
  import { InsertEntryLink } from './entry-link/index.js';
6
- import { MARKDOWN_EXPORT_OPTIONS } from './markdown-export-options.js';
6
+ import { MARKDOWN_EXPORT_OPTIONS } from '../../utils/markdown-export-options.js';
7
7
  import { createMarkdownFidelityPlugin } from './markdown-fidelity-visitors.js';
8
8
  import { MdxImageDialog } from './MdxImageDialog.js';
9
9
  import { createMdxJsxPlugins } from './mdx-jsx-support.js';
@@ -1,7 +1,11 @@
1
1
  import type { BranchListItem, CreateBranchBody } from '../../api/branch.js';
2
2
  import { type CanopyApiClient } from '../../api/client.js';
3
- /** @internal Exported for tests. Past the 60 s a Lambda behind CloudFront gets, so its 504 lands first. */
4
- export declare const CREATE_BRANCH_DEADLINE_MS = 90000;
3
+ /**
4
+ * @internal Exported for tests. Past the gateway's limit, so its 504 lands first, and under
5
+ * `REQUEST_TIMEOUT_MS.long`. The limit comes from `request-deadline` directly: a test that mocks
6
+ * `api/client` would otherwise lose it at module load.
7
+ */
8
+ export declare const CREATE_BRANCH_DEADLINE_MS: number;
5
9
  /** @internal Exported for tests. */
6
10
  export declare const CREATE_TIMED_OUT_MESSAGE = "Creating the branch didn't finish (the server timed out). It's safe to try again.";
7
11
  export type CreateBranchOutcome = {
@@ -1,8 +1,13 @@
1
1
  import { isCreatorsRecentBranch } from '../../api/branch-create-window.js';
2
2
  import { isApiTimeoutError, isNonApiResponse } from '../../api/client.js';
3
+ import { GATEWAY_MAX_TIMEOUT_MS } from '../../api/request-deadline.js';
3
4
  import { sanitizeBranchName } from '../../paths/branch-name.js';
4
- /** @internal Exported for tests. Past the 60 s a Lambda behind CloudFront gets, so its 504 lands first. */
5
- export const CREATE_BRANCH_DEADLINE_MS = 90_000;
5
+ /**
6
+ * @internal Exported for tests. Past the gateway's limit, so its 504 lands first, and under
7
+ * `REQUEST_TIMEOUT_MS.long`. The limit comes from `request-deadline` directly: a test that mocks
8
+ * `api/client` would otherwise lose it at module load.
9
+ */
10
+ export const CREATE_BRANCH_DEADLINE_MS = GATEWAY_MAX_TIMEOUT_MS + 30_000;
6
11
  /** @internal Exported for tests. */
7
12
  export const CREATE_TIMED_OUT_MESSAGE = "Creating the branch didn't finish (the server timed out). It's safe to try again.";
8
13
  /**
@@ -5,7 +5,7 @@ import type { BranchListItem } from '../../api/branch.js';
5
5
  import type { UnsavedSummary } from './useDraftManager.js';
6
6
  import type { StampedEntry } from '../../stamp-on-publish.js';
7
7
  /** @internal Exported for tests. */
8
- export declare const branchActionOutcomeUnknown: (action: string) => string;
8
+ export declare const branchActionOutcomeUnknown: (action: string, check?: string) => string;
9
9
  /** What the withdraw and delete dialogs say about the branch's pull request. */
10
10
  type BranchPullRequest = Pick<BranchListItem, 'pullRequestNumber' | 'pullRequestState'>;
11
11
  /**
@@ -81,6 +81,8 @@ export interface UseBranchManagerOptions {
81
81
  getUnsaved?: () => Promise<UnsavedSummary>;
82
82
  /** The entries a submit of the open branch stamped (useDraftManager's `applySubmitStamps`). */
83
83
  onStamped?: (stamped: StampedEntry[]) => void;
84
+ /** A request for changes went through; the branch may now carry the reviewer's note as a comment. */
85
+ onChangesRequested?: (branchName: string) => void;
84
86
  }
85
87
  export interface UseBranchManagerReturn {
86
88
  branchName: string;
@@ -98,7 +100,10 @@ export interface UseBranchManagerReturn {
98
100
  handleSubmit: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
99
101
  /** Confirms first; the dialog returns focus to `returnFocusTo()` when given, else its opener. */
100
102
  handleWithdraw: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
101
- handleRequestChanges: (branchName: string) => Promise<void>;
103
+ /** Confirms first; the dialog returns focus to `returnFocusTo()` when given, else its opener. */
104
+ handleApprove: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
105
+ /** Confirms first, taking an optional note; focus returns as for `handleApprove`. */
106
+ handleRequestChanges: (branchName: string, returnFocusTo?: () => HTMLElement | null) => Promise<void>;
102
107
  handleDelete: (branchName: string) => Promise<void>;
103
108
  handleReloadBranchData: () => Promise<void>;
104
109
  loadBranches: () => Promise<void>;