@reviewflow/ui 6.25.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,
@@ -40,7 +35,7 @@ import { ChatCircleDotsRegularIcon } from "alouette-icons/phosphor-icons/ChatCir
40
35
  import type { ReactNode } from "react";
41
36
  import { useState } from "react";
42
37
  import { usePullRequestDataSource } from "../dataSource.ts";
43
- import { prStatusAccents } from "../prStatus.ts";
38
+ import { usePrStatusAccent } from "../prStatus.ts";
44
39
  import { emojify } from "../services/emojify.ts";
45
40
  import { errorToMessage } from "../services/errorToMessage.ts";
46
41
  import { formatDataAge } from "../services/formatDataAge.ts";
@@ -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,6 +152,19 @@ 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
+
160
+ function StatusBadge({ status }: { status: PrStatus }): ReactNode {
161
+ return (
162
+ <CenteredBadge>
163
+ <Badge accent={usePrStatusAccent(status)}>{prStatusLabels[status]}</Badge>
164
+ </CenteredBadge>
165
+ );
166
+ }
167
+
159
168
  function MergeLine({
160
169
  status,
161
170
  author,
@@ -165,9 +174,7 @@ function MergeLine({
165
174
  }: MergeLineProps): ReactNode {
166
175
  return (
167
176
  <View className="flex-row flex-wrap items-center gap-xs">
168
- {status === null ? null : (
169
- <Badge accent={prStatusAccents[status]}>{prStatusLabels[status]}</Badge>
170
- )}
177
+ {status === null ? null : <StatusBadge status={status} />}
171
178
  <Text className="text-sm font-body-bold">
172
179
  {author ?? "unknown author"}
173
180
  </Text>
@@ -197,6 +204,7 @@ export function PullRequestSummaryHeader({
197
204
  }): ReactNode {
198
205
  return (
199
206
  <View className="gap-sm">
207
+ <RepoName ownerRepo={summary.ownerRepo} />
200
208
  <TitleText number={summary.number} title={summary.title} />
201
209
  <MergeLine
202
210
  status={reviewflowPrStatus(summary)}
@@ -208,16 +216,23 @@ export function PullRequestSummaryHeader({
208
216
  );
209
217
  }
210
218
 
219
+ interface HeaderProps extends EditableProps {
220
+ summary: PullRequestSummary | null;
221
+ }
222
+
211
223
  /**
212
224
  * The header: the title and its number, Edit and the way out beside
213
- * 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.
214
228
  */
215
- function Header({ detail, onEdited }: EditableProps): ReactNode {
229
+ function Header({ detail, summary, onEdited }: HeaderProps): ReactNode {
216
230
  const { pullRequest } = detail;
217
231
  const [editing, setEditing] = useState(false);
218
232
 
219
233
  return (
220
234
  <View className="gap-sm">
235
+ <RepoName ownerRepo={pullRequest.ownerRepo} />
221
236
  {editing ? (
222
237
  <TitleEditor
223
238
  detail={detail}
@@ -247,7 +262,10 @@ function Header({ detail, onEdited }: EditableProps): ReactNode {
247
262
  </View>
248
263
  )}
249
264
  <MergeLine
250
- status={prStatus(pullRequest)}
265
+ status={reviewflowPrStatus({
266
+ state: pullRequest.state,
267
+ reviewState: summary?.reviewState ?? null,
268
+ })}
251
269
  author={pullRequest.author?.login ?? null}
252
270
  baseRef={pullRequest.baseRef}
253
271
  headRef={pullRequest.headRef}
@@ -584,15 +602,17 @@ function ReviewerLine({
584
602
  <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
585
603
  {name}
586
604
  </Text>
587
- {state === null ? (
588
- <Badge accent="warning" size="sm">
589
- awaiting
590
- </Badge>
591
- ) : (
592
- <Badge accent={reviewStateAccent(state)} size="sm">
593
- {reviewStateLabel(state)}
594
- </Badge>
595
- )}
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>
596
616
  {onRerequest === undefined ? null : (
597
617
  <RerequestButton name={name} onRerequest={onRerequest} />
598
618
  )}
@@ -1161,6 +1181,8 @@ function PullRequestActions({ detail, onEdited }: EditableProps): ReactNode {
1161
1181
  }
1162
1182
 
1163
1183
  export interface PullRequestPanelProps extends CandidatesProps {
1184
+ /** reviewflow's copy, where the review status comes from; null without reviewflow */
1185
+ summary: PullRequestSummary | null;
1164
1186
  /** why the editors stay read-only, when GitHub refused what they offer */
1165
1187
  candidatesError: string | null;
1166
1188
  /** absent: the first tab, Review for a reviewer and Conversation for the author */
@@ -1181,6 +1203,7 @@ export interface PullRequestPanelProps extends CandidatesProps {
1181
1203
  */
1182
1204
  export function PullRequestPanel({
1183
1205
  detail,
1206
+ summary,
1184
1207
  candidates,
1185
1208
  candidatesError,
1186
1209
  onEdited,
@@ -1212,7 +1235,7 @@ export function PullRequestPanel({
1212
1235
  {`Reviewers, assignees and labels cannot be edited: ${candidatesError}`}
1213
1236
  </WarningMessage>
1214
1237
  )}
1215
- <Header detail={detail} onEdited={onEdited} />
1238
+ <Header detail={detail} summary={summary} onEdited={onEdited} />
1216
1239
  <ConflictWarning pullRequest={pullRequest} />
1217
1240
  <View className="flex-row flex-wrap items-center justify-between gap-sm">
1218
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
+ }