@reviewflow/ui 6.26.0 → 6.27.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.
@@ -7,12 +7,7 @@ import type {
7
7
  PullRequestReviewState,
8
8
  PullRequestSummary,
9
9
  } from "@reviewflow/sdk";
10
- import {
11
- pluralOf,
12
- prStatus,
13
- prStatusLabels,
14
- reviewflowPrStatus,
15
- } from "@reviewflow/sdk";
10
+ import { pluralOf, prStatusLabels, reviewflowPrStatus } from "@reviewflow/sdk";
16
11
  import type { Accent } from "alouette";
17
12
  import {
18
13
  AccentScope,
@@ -59,6 +54,7 @@ import {
59
54
  PullRequestPartPending,
60
55
  PullRequestPartReading,
61
56
  } from "./pull-request-part-notice.tsx";
57
+ import { RepoName } from "./pull-request-repo-name.tsx";
62
58
  import { PullRequestReview } from "./pull-request-review.tsx";
63
59
  import { PullRequestTimeline } from "./pull-request-timeline.tsx";
64
60
 
@@ -156,9 +152,16 @@ interface MergeLineProps {
156
152
  children?: ReactNode;
157
153
  }
158
154
 
155
+ /** Badge aligns itself to the start: in a row, that is the top, not the center. */
156
+ function CenteredBadge({ children }: { children: ReactNode }): ReactNode {
157
+ return <View className="justify-center">{children}</View>;
158
+ }
159
+
159
160
  function StatusBadge({ status }: { status: PrStatus }): ReactNode {
160
161
  return (
161
- <Badge accent={usePrStatusAccent(status)}>{prStatusLabels[status]}</Badge>
162
+ <CenteredBadge>
163
+ <Badge accent={usePrStatusAccent(status)}>{prStatusLabels[status]}</Badge>
164
+ </CenteredBadge>
162
165
  );
163
166
  }
164
167
 
@@ -201,6 +204,7 @@ export function PullRequestSummaryHeader({
201
204
  }): ReactNode {
202
205
  return (
203
206
  <View className="gap-sm">
207
+ <RepoName ownerRepo={summary.ownerRepo} />
204
208
  <TitleText number={summary.number} title={summary.title} />
205
209
  <MergeLine
206
210
  status={reviewflowPrStatus(summary)}
@@ -212,16 +216,23 @@ export function PullRequestSummaryHeader({
212
216
  );
213
217
  }
214
218
 
219
+ interface HeaderProps extends EditableProps {
220
+ summary: PullRequestSummary | null;
221
+ }
222
+
215
223
  /**
216
224
  * The header: the title and its number, Edit and the way out beside
217
- * them, and one line saying whose branch goes where.
225
+ * them, and one line saying whose branch goes where. The review status is
226
+ * reviewflow's, never recomputed from GitHub's: without reviewflow's
227
+ * summary, an open pull request shows none.
218
228
  */
219
- function Header({ detail, onEdited }: EditableProps): ReactNode {
229
+ function Header({ detail, summary, onEdited }: HeaderProps): ReactNode {
220
230
  const { pullRequest } = detail;
221
231
  const [editing, setEditing] = useState(false);
222
232
 
223
233
  return (
224
234
  <View className="gap-sm">
235
+ <RepoName ownerRepo={pullRequest.ownerRepo} />
225
236
  {editing ? (
226
237
  <TitleEditor
227
238
  detail={detail}
@@ -251,7 +262,10 @@ function Header({ detail, onEdited }: EditableProps): ReactNode {
251
262
  </View>
252
263
  )}
253
264
  <MergeLine
254
- status={prStatus(pullRequest)}
265
+ status={reviewflowPrStatus({
266
+ state: pullRequest.state,
267
+ reviewState: summary?.reviewState ?? null,
268
+ })}
255
269
  author={pullRequest.author?.login ?? null}
256
270
  baseRef={pullRequest.baseRef}
257
271
  headRef={pullRequest.headRef}
@@ -588,15 +602,17 @@ function ReviewerLine({
588
602
  <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
589
603
  {name}
590
604
  </Text>
591
- {state === null ? (
592
- <Badge accent="warning" size="sm">
593
- awaiting
594
- </Badge>
595
- ) : (
596
- <Badge accent={reviewStateAccent(state)} size="sm">
597
- {reviewStateLabel(state)}
598
- </Badge>
599
- )}
605
+ <CenteredBadge>
606
+ {state === null ? (
607
+ <Badge accent="warning" size="sm">
608
+ awaiting
609
+ </Badge>
610
+ ) : (
611
+ <Badge accent={reviewStateAccent(state)} size="sm">
612
+ {reviewStateLabel(state)}
613
+ </Badge>
614
+ )}
615
+ </CenteredBadge>
600
616
  {onRerequest === undefined ? null : (
601
617
  <RerequestButton name={name} onRerequest={onRerequest} />
602
618
  )}
@@ -1165,6 +1181,8 @@ function PullRequestActions({ detail, onEdited }: EditableProps): ReactNode {
1165
1181
  }
1166
1182
 
1167
1183
  export interface PullRequestPanelProps extends CandidatesProps {
1184
+ /** reviewflow's copy, where the review status comes from; null without reviewflow */
1185
+ summary: PullRequestSummary | null;
1168
1186
  /** why the editors stay read-only, when GitHub refused what they offer */
1169
1187
  candidatesError: string | null;
1170
1188
  /** absent: the first tab, Review for a reviewer and Conversation for the author */
@@ -1185,6 +1203,7 @@ export interface PullRequestPanelProps extends CandidatesProps {
1185
1203
  */
1186
1204
  export function PullRequestPanel({
1187
1205
  detail,
1206
+ summary,
1188
1207
  candidates,
1189
1208
  candidatesError,
1190
1209
  onEdited,
@@ -1216,7 +1235,7 @@ export function PullRequestPanel({
1216
1235
  {`Reviewers, assignees and labels cannot be edited: ${candidatesError}`}
1217
1236
  </WarningMessage>
1218
1237
  )}
1219
- <Header detail={detail} onEdited={onEdited} />
1238
+ <Header detail={detail} summary={summary} onEdited={onEdited} />
1220
1239
  <ConflictWarning pullRequest={pullRequest} />
1221
1240
  <View className="flex-row flex-wrap items-center justify-between gap-sm">
1222
1241
  <Tabs
@@ -0,0 +1,27 @@
1
+ import { Text, View } from "alouette";
2
+ import type { ReactNode } from "react";
3
+ import { createContext, use } from "react";
4
+
5
+ /**
6
+ * How an app links a repository's name to its own screen of it, the router
7
+ * being the app's; null keeps the name as plain text, e.g. for a repository
8
+ * the app has no screen of.
9
+ */
10
+ export type RenderRepoLink = (ownerRepo: string) => ReactNode;
11
+
12
+ export const RepoLinkContext = createContext<RenderRepoLink | null>(null);
13
+
14
+ export interface RepoNameProps {
15
+ ownerRepo: string;
16
+ }
17
+
18
+ /** The repository above a pull request's title, a link when the app gives one. */
19
+ export function RepoName({ ownerRepo }: RepoNameProps): ReactNode {
20
+ const renderRepoLink = use(RepoLinkContext);
21
+ const link = renderRepoLink?.(ownerRepo) ?? null;
22
+ if (link === null) {
23
+ return <Text className="text-sm text-muted">{ownerRepo}</Text>;
24
+ }
25
+ // a link stretches in a column: the row keeps it as wide as its name
26
+ return <View className="flex-row">{link}</View>;
27
+ }
@@ -40,6 +40,8 @@ import {
40
40
  PullRequestPartPending,
41
41
  usePartReadingCount,
42
42
  } from "./pull-request-part-notice.tsx";
43
+ import type { RenderRepoLink } from "./pull-request-repo-name.tsx";
44
+ import { RepoLinkContext } from "./pull-request-repo-name.tsx";
43
45
  import { RepositoryAttachmentBanner } from "./repository-attachment-banner.tsx";
44
46
 
45
47
  /** How old what the header shows is: GitHub's answer, or the last read shown meanwhile. */
@@ -184,6 +186,7 @@ function PullRequestBody({
184
186
  <PartReadingScope value={partsReadInTabs.report}>
185
187
  <PullRequestPanel
186
188
  detail={detail}
189
+ summary={summary}
187
190
  candidates={candidates}
188
191
  candidatesError={candidatesError}
189
192
  tab={tab}
@@ -236,6 +239,8 @@ export interface PullRequestScreenProps {
236
239
  ref: PullRequestRef,
237
240
  detail: PullRequestDetailResponse | null,
238
241
  ) => ReactNode;
242
+ /** links the repository's name above the title; absent or null: plain text */
243
+ renderRepoLink?: RenderRepoLink;
239
244
  /** what the app replaces, see {@link PullRequestSlots} */
240
245
  slots?: PullRequestSlots;
241
246
  /**
@@ -285,35 +290,38 @@ export function PullRequestScreen({
285
290
  initialWhitespace,
286
291
  diffFileOrder,
287
292
  onDiffFileOrderChange,
293
+ renderRepoLink,
288
294
  ...body
289
295
  }: PullRequestScreenProps): ReactNode {
290
296
  return (
291
- <PullRequestSlotsProvider value={slots}>
292
- <DiffSettingsProvider
293
- theme={diffTheme}
294
- font={diffFont}
295
- customFont={diffCustomFont}
296
- wordWrap={diffWordWrap}
297
- initialWhitespace={initialWhitespace}
298
- fileOrder={diffFileOrder}
299
- onFileOrderChange={onDiffFileOrderChange}
300
- >
301
- <View className="w-full px-l pt-l pb-xl web:@container">
302
- <View className="mx-auto w-full max-w-[1200px] gap-m">
303
- <PullRequestDraftsProvider
304
- store={draftStore}
305
- pullRequest={{ ownerRepo, number }}
306
- >
307
- <PullRequestBody
308
- key={`${ownerRepo}#${String(number)}`}
309
- ownerRepo={ownerRepo}
310
- number={number}
311
- {...body}
312
- />
313
- </PullRequestDraftsProvider>
297
+ <RepoLinkContext value={renderRepoLink ?? null}>
298
+ <PullRequestSlotsProvider value={slots}>
299
+ <DiffSettingsProvider
300
+ theme={diffTheme}
301
+ font={diffFont}
302
+ customFont={diffCustomFont}
303
+ wordWrap={diffWordWrap}
304
+ initialWhitespace={initialWhitespace}
305
+ fileOrder={diffFileOrder}
306
+ onFileOrderChange={onDiffFileOrderChange}
307
+ >
308
+ <View className="w-full px-l pt-l pb-xl web:@container">
309
+ <View className="mx-auto w-full max-w-[1200px] gap-m">
310
+ <PullRequestDraftsProvider
311
+ store={draftStore}
312
+ pullRequest={{ ownerRepo, number }}
313
+ >
314
+ <PullRequestBody
315
+ key={`${ownerRepo}#${String(number)}`}
316
+ ownerRepo={ownerRepo}
317
+ number={number}
318
+ {...body}
319
+ />
320
+ </PullRequestDraftsProvider>
321
+ </View>
314
322
  </View>
315
- </View>
316
- </DiffSettingsProvider>
317
- </PullRequestSlotsProvider>
323
+ </DiffSettingsProvider>
324
+ </PullRequestSlotsProvider>
325
+ </RepoLinkContext>
318
326
  );
319
327
  }
@@ -0,0 +1,233 @@
1
+ import type {
2
+ WorktreeRemovalResult,
3
+ WorktreeRemovalStatus,
4
+ } from "@reviewflow/sdk";
5
+ import { worktreeRemovalRefusals } from "@reviewflow/sdk";
6
+ import {
7
+ Button,
8
+ Checkbox,
9
+ Code,
10
+ ConfirmationMessage,
11
+ Text,
12
+ View,
13
+ WarningAlertDialog,
14
+ WarningMessage,
15
+ } from "alouette";
16
+ import { TrashRegularIcon } from "alouette-icons/phosphor-icons/Trash";
17
+ import type { ReactNode } from "react";
18
+ import { useRef, useState } from "react";
19
+ import { errorToMessage } from "../services/errorToMessage.ts";
20
+
21
+ const visibleIgnored = 10;
22
+
23
+ export interface IgnoredFilesProps {
24
+ paths: string[];
25
+ }
26
+
27
+ /**
28
+ * Not a guard: an ignored file is usually disposable, so the reader decides.
29
+ * Each path is prefixed `./` because a bare `~/…` (an agent writing to an
30
+ * unexpanded home path) reads as the home directory.
31
+ */
32
+ export function IgnoredFiles({ paths }: IgnoredFilesProps): ReactNode {
33
+ if (paths.length === 0) return null;
34
+ const hidden = paths.length - visibleIgnored;
35
+ return (
36
+ <View className="gap-xxs">
37
+ <WarningMessage variant="flat">
38
+ Lost with the worktree: ignored by git, so not counted as uncommitted,
39
+ real files and not symlinks, and the main checkout has no copy. Move
40
+ them out first if you need them.
41
+ </WarningMessage>
42
+ {paths.slice(0, visibleIgnored).map((path) => (
43
+ <Code key={path}>{`./${path}`}</Code>
44
+ ))}
45
+ {hidden > 0 ? (
46
+ <Text className="text-sm text-muted">{`and ${String(hidden)} more`}</Text>
47
+ ) : null}
48
+ </View>
49
+ );
50
+ }
51
+
52
+ /** What a confirmation reads before it offers the removal. */
53
+ export interface WorktreeRemovalCheck {
54
+ status: WorktreeRemovalStatus;
55
+ /** the branch's work is demonstrably spent (a merged pull request): the branch box starts checked */
56
+ branchSpent: boolean;
57
+ /** under the refusals, e.g. why no pull request could prove a commit pushed */
58
+ notice: string | null;
59
+ }
60
+
61
+ export interface WorktreeRemovalMessagesProps {
62
+ check: WorktreeRemovalCheck;
63
+ }
64
+
65
+ /** Every refusal, or the all-clear; the notice; then the ignored files the removal takes. */
66
+ export function WorktreeRemovalMessages({
67
+ check,
68
+ }: WorktreeRemovalMessagesProps): ReactNode {
69
+ const refusals = worktreeRemovalRefusals(check.status);
70
+ return (
71
+ <>
72
+ {refusals.map((refusal) => (
73
+ <WarningMessage key={refusal} variant="flat">
74
+ {refusal}
75
+ </WarningMessage>
76
+ ))}
77
+ {refusals.length === 0 ? (
78
+ <ConfirmationMessage variant="flat">
79
+ No uncommitted file, every commit is on a remote branch or in a pull
80
+ request, and nothing runs inside.
81
+ </ConfirmationMessage>
82
+ ) : null}
83
+ {check.notice === null ? null : (
84
+ <Text className="text-sm text-muted">{check.notice}</Text>
85
+ )}
86
+ <IgnoredFiles paths={check.status.localOnlyIgnored} />
87
+ </>
88
+ );
89
+ }
90
+
91
+ interface CheckState {
92
+ check: WorktreeRemovalCheck | null;
93
+ error: string | null;
94
+ }
95
+
96
+ const unread: CheckState = { check: null, error: null };
97
+
98
+ const branchDetails =
99
+ "Only the local branch: git refuses while it is unmerged, and the remote is never touched.";
100
+
101
+ export interface RemoveWorktreeButtonProps {
102
+ /** named before git answers anything */
103
+ path: string;
104
+ /** read on every opening: an answer kept from the last one could let a changed worktree through */
105
+ readCheck: () => Promise<WorktreeRemovalCheck>;
106
+ /** runs the removal; the server reads every refusal again first */
107
+ onRemove: (deleteBranch: boolean) => Promise<WorktreeRemovalResult>;
108
+ /** after the removal, the branch's refusal included */
109
+ onRemoved: (result: WorktreeRemovalResult) => void;
110
+ /** why the button cannot be pressed at all, e.g. a removal already running */
111
+ disabledReason?: string | null;
112
+ }
113
+
114
+ /**
115
+ * Removes a worktree after a confirmation that reads what it would lose.
116
+ * Remove stays disabled until that read proves no uncommitted file, no commit
117
+ * only here, no lock and no process inside; an unread or failed read disables
118
+ * it too, since nothing is proven. A `WarningAlertDialog`: one of the buttons
119
+ * deletes files, and the icon says so before the copy is read.
120
+ */
121
+ export function RemoveWorktreeButton({
122
+ path,
123
+ readCheck,
124
+ onRemove,
125
+ onRemoved,
126
+ disabledReason = null,
127
+ }: RemoveWorktreeButtonProps): ReactNode {
128
+ const [confirming, setConfirming] = useState(false);
129
+ const [read, setRead] = useState<CheckState>(unread);
130
+ const [pickedDeleteBranch, setPickedDeleteBranch] = useState<boolean | null>(
131
+ null,
132
+ );
133
+ // an answer to an earlier opening must not land in this one
134
+ const opening = useRef(0);
135
+ const { check } = read;
136
+ const branch = check?.status.branch ?? null;
137
+ const deleteBranch =
138
+ pickedDeleteBranch ?? (branch !== null && check?.branchSpent === true);
139
+ const removable =
140
+ check !== null && worktreeRemovalRefusals(check.status).length === 0;
141
+
142
+ const open = (): void => {
143
+ opening.current += 1;
144
+ const current = opening.current;
145
+ setConfirming(true);
146
+ setRead(unread);
147
+ setPickedDeleteBranch(null);
148
+ readCheck().then(
149
+ (answer) => {
150
+ if (opening.current === current) {
151
+ setRead({ check: answer, error: null });
152
+ }
153
+ },
154
+ (error: unknown) => {
155
+ if (opening.current === current) {
156
+ setRead({ check: null, error: errorToMessage(error) });
157
+ }
158
+ },
159
+ );
160
+ };
161
+
162
+ const close = (): void => {
163
+ opening.current += 1;
164
+ setConfirming(false);
165
+ setRead(unread);
166
+ };
167
+
168
+ const body = ((): ReactNode => {
169
+ if (read.error !== null) {
170
+ return (
171
+ <WarningMessage variant="flat">
172
+ {`Could not read the worktree: ${read.error}. Remove stays disabled until it can be.`}
173
+ </WarningMessage>
174
+ );
175
+ }
176
+ if (check === null) {
177
+ return (
178
+ <Text className="text-sm text-muted">
179
+ Checking for uncommitted files, unpushed commits and processes inside…
180
+ </Text>
181
+ );
182
+ }
183
+ return <WorktreeRemovalMessages check={check} />;
184
+ })();
185
+
186
+ return (
187
+ <>
188
+ <Button
189
+ text="Remove"
190
+ icon={<TrashRegularIcon />}
191
+ size="sm"
192
+ variant="tonal"
193
+ accent="danger"
194
+ aria-label={`Remove the worktree at ${path}`}
195
+ disabledReason={disabledReason}
196
+ onPress={open}
197
+ />
198
+ <WarningAlertDialog
199
+ visible={confirming}
200
+ title="Remove this worktree?"
201
+ confirmText="Remove"
202
+ primaryAction="cancel"
203
+ confirmDisabled={!removable}
204
+ errorToMessage={errorToMessage}
205
+ content={
206
+ <View className="gap-sm">
207
+ <Code>{path}</Code>
208
+ {body}
209
+ {branch === null || check === null ? null : (
210
+ <View className="gap-xxs">
211
+ <Checkbox
212
+ label={`Also delete the branch ${branch}`}
213
+ checked={deleteBranch}
214
+ onValueChange={setPickedDeleteBranch}
215
+ />
216
+ <Text className="text-sm text-muted">{branchDetails}</Text>
217
+ </View>
218
+ )}
219
+ </View>
220
+ }
221
+ onConfirm={async () => {
222
+ const result = await onRemove(deleteBranch);
223
+ close();
224
+ onRemoved(result);
225
+ }}
226
+ onCancel={close}
227
+ >
228
+ Its directory is deleted from disk. git refuses on its own if a file
229
+ changed since this check.
230
+ </WarningAlertDialog>
231
+ </>
232
+ );
233
+ }