@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.
- package/package.json +6 -2
- package/src/board/pull-request-row.tsx +57 -18
- package/src/components/branch-diff/diff-change-mark.tsx +86 -0
- package/src/components/branch-diff/use-collapsed-files.ts +3 -0
- package/src/components/checkout-sync-buttons.tsx +76 -0
- package/src/components/checkout-view.tsx +789 -0
- package/src/components/commit-list.tsx +172 -0
- package/src/components/diff-viewer.tsx +1 -0
- package/src/components/omitted-files.tsx +32 -0
- package/src/components/pull-request-commits.tsx +4 -91
- package/src/components/pull-request-diff-view.tsx +1 -25
- package/src/components/pull-request-diff-viewer.tsx +145 -37
- package/src/components/pull-request-panel.tsx +39 -20
- package/src/components/pull-request-repo-name.tsx +27 -0
- package/src/components/pull-request-screen.tsx +34 -26
- package/src/components/remove-worktree-button.tsx +233 -0
- package/src/components/repo-overview.tsx +615 -0
- package/src/index.ts +44 -0
- package/src/services/checkoutSummary.ts +32 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CheckoutBranch,
|
|
3
|
+
CheckoutBranchesResponse,
|
|
4
|
+
CheckoutWorktree,
|
|
5
|
+
OpenPullRequest,
|
|
6
|
+
OpenPullRequestsResponse,
|
|
7
|
+
} from "@reviewflow/sdk";
|
|
8
|
+
import {
|
|
9
|
+
formatRelativeDate,
|
|
10
|
+
pluralOf,
|
|
11
|
+
pullRefusal,
|
|
12
|
+
pullRequestOfBranch,
|
|
13
|
+
pullRequestsWithoutBranch,
|
|
14
|
+
} from "@reviewflow/sdk";
|
|
15
|
+
import {
|
|
16
|
+
ActionButton,
|
|
17
|
+
Badge,
|
|
18
|
+
Box,
|
|
19
|
+
Button,
|
|
20
|
+
Code,
|
|
21
|
+
EmptyState,
|
|
22
|
+
Icon,
|
|
23
|
+
IndeterminateCircularProgress,
|
|
24
|
+
PressableListItem,
|
|
25
|
+
SectionTitle,
|
|
26
|
+
Text,
|
|
27
|
+
View,
|
|
28
|
+
WarningMessage,
|
|
29
|
+
} from "alouette";
|
|
30
|
+
import { ArrowsClockwiseRegularIcon } from "alouette-icons/phosphor-icons/ArrowsClockwise";
|
|
31
|
+
import { CloudArrowDownRegularIcon } from "alouette-icons/phosphor-icons/CloudArrowDown";
|
|
32
|
+
import { FolderSimpleRegularIcon } from "alouette-icons/phosphor-icons/FolderSimple";
|
|
33
|
+
import { GitBranchRegularIcon } from "alouette-icons/phosphor-icons/GitBranch";
|
|
34
|
+
import { GitPullRequestRegularIcon } from "alouette-icons/phosphor-icons/GitPullRequest";
|
|
35
|
+
import type { ReactNode } from "react";
|
|
36
|
+
import { useState } from "react";
|
|
37
|
+
import {
|
|
38
|
+
PullRequestMetaSeparator,
|
|
39
|
+
PullRequestRowFrame,
|
|
40
|
+
} from "../board/pull-request-row.tsx";
|
|
41
|
+
import {
|
|
42
|
+
branchTrackParts,
|
|
43
|
+
checkoutSummaryParts,
|
|
44
|
+
} from "../services/checkoutSummary.ts";
|
|
45
|
+
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
46
|
+
import type { CheckoutRead } from "./checkout-view.tsx";
|
|
47
|
+
import { TooltipIconButton } from "./tooltip-icon-button.tsx";
|
|
48
|
+
|
|
49
|
+
/** Past this many, the older branches wait behind a press. */
|
|
50
|
+
const shownBranches = 20;
|
|
51
|
+
|
|
52
|
+
const joinParts = (parts: string[], whenEmpty: string): string =>
|
|
53
|
+
parts.length === 0 ? whenEmpty : parts.join(" · ");
|
|
54
|
+
|
|
55
|
+
function Loading(): ReactNode {
|
|
56
|
+
return (
|
|
57
|
+
<View className="items-center py-l">
|
|
58
|
+
<IndeterminateCircularProgress loading />
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface WorktreeRowProps {
|
|
64
|
+
worktree: CheckoutWorktree;
|
|
65
|
+
onOpen: (worktree: CheckoutWorktree) => void;
|
|
66
|
+
/** at the row's bottom, e.g. a removal; each takes its own press */
|
|
67
|
+
actions: ReactNode;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function WorktreeSummary({ worktree }: Pick<WorktreeRowProps, "worktree">) {
|
|
71
|
+
if (worktree.status === null) {
|
|
72
|
+
return <Text className="text-sm text-muted">status unreadable</Text>;
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<Text className="text-sm text-muted">
|
|
76
|
+
{joinParts(checkoutSummaryParts(worktree.status), "clean, up to date")}
|
|
77
|
+
</Text>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function WorktreeBadges({ worktree }: Pick<WorktreeRowProps, "worktree">) {
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{worktree.main ? (
|
|
85
|
+
<Badge accent="neutral" variant="outlined">
|
|
86
|
+
main checkout
|
|
87
|
+
</Badge>
|
|
88
|
+
) : null}
|
|
89
|
+
{worktree.lock === null ? null : (
|
|
90
|
+
<Badge accent="warning" variant="outlined">
|
|
91
|
+
locked
|
|
92
|
+
</Badge>
|
|
93
|
+
)}
|
|
94
|
+
{worktree.prunable ? (
|
|
95
|
+
<Badge accent="warning" variant="outlined">
|
|
96
|
+
directory gone
|
|
97
|
+
</Badge>
|
|
98
|
+
) : null}
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function WorktreeContent({ worktree }: Pick<WorktreeRowProps, "worktree">) {
|
|
104
|
+
return (
|
|
105
|
+
<View className="gap-xxs">
|
|
106
|
+
<View className="flex-row flex-wrap items-center gap-sm">
|
|
107
|
+
<View className="flex-row items-center gap-xxs">
|
|
108
|
+
<Icon icon={<GitBranchRegularIcon />} size={14} />
|
|
109
|
+
<Code className="text-sm">{worktree.branch ?? "detached HEAD"}</Code>
|
|
110
|
+
</View>
|
|
111
|
+
<WorktreeBadges worktree={worktree} />
|
|
112
|
+
{worktree.prunable ? null : <WorktreeSummary worktree={worktree} />}
|
|
113
|
+
</View>
|
|
114
|
+
<Text className="font-mono text-xs text-muted">{worktree.path}</Text>
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The row opens the checkout; its actions sit at the bottom and take their own
|
|
121
|
+
* press. A worktree whose directory is gone opens nothing: git would prune it.
|
|
122
|
+
*/
|
|
123
|
+
function WorktreeRow({
|
|
124
|
+
worktree,
|
|
125
|
+
onOpen,
|
|
126
|
+
actions,
|
|
127
|
+
}: WorktreeRowProps): ReactNode {
|
|
128
|
+
if (worktree.prunable) {
|
|
129
|
+
return (
|
|
130
|
+
<Box className="surface px-m py-sm">
|
|
131
|
+
<WorktreeContent worktree={worktree} />
|
|
132
|
+
</Box>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
return (
|
|
136
|
+
<PressableListItem
|
|
137
|
+
role="link"
|
|
138
|
+
aria-label={`Open the checkout at ${worktree.path}`}
|
|
139
|
+
actions={
|
|
140
|
+
actions === null || actions === undefined ? undefined : (
|
|
141
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
142
|
+
{actions}
|
|
143
|
+
</View>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
onPress={() => {
|
|
147
|
+
onOpen(worktree);
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
<WorktreeContent worktree={worktree} />
|
|
151
|
+
</PressableListItem>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface PullRequestButtonProps {
|
|
156
|
+
pullRequest: OpenPullRequest;
|
|
157
|
+
onOpen: (number: number) => void;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function PullRequestButton({
|
|
161
|
+
pullRequest,
|
|
162
|
+
onOpen,
|
|
163
|
+
}: PullRequestButtonProps): ReactNode {
|
|
164
|
+
return (
|
|
165
|
+
<Button
|
|
166
|
+
text={`#${String(pullRequest.number)}`}
|
|
167
|
+
icon={<GitPullRequestRegularIcon />}
|
|
168
|
+
accent={pullRequest.isDraft ? "neutral" : "success"}
|
|
169
|
+
variant="tonal"
|
|
170
|
+
size="sm"
|
|
171
|
+
role="link"
|
|
172
|
+
aria-label={`Open pull request #${String(pullRequest.number)}: ${pullRequest.title}`}
|
|
173
|
+
onPress={() => {
|
|
174
|
+
onOpen(pullRequest.number);
|
|
175
|
+
}}
|
|
176
|
+
/>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface BranchStateBadgesProps {
|
|
181
|
+
branch: CheckoutBranch;
|
|
182
|
+
worktree: CheckoutWorktree | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function BranchStateBadges({
|
|
186
|
+
branch,
|
|
187
|
+
worktree,
|
|
188
|
+
}: BranchStateBadgesProps): ReactNode {
|
|
189
|
+
return (
|
|
190
|
+
<>
|
|
191
|
+
{worktree === null ? null : (
|
|
192
|
+
<Badge accent="brand" variant="outlined">
|
|
193
|
+
{worktree.main ? "current" : "checked out"}
|
|
194
|
+
</Badge>
|
|
195
|
+
)}
|
|
196
|
+
{branch.merged ? (
|
|
197
|
+
<Badge accent="success" variant="outlined">
|
|
198
|
+
merged
|
|
199
|
+
</Badge>
|
|
200
|
+
) : null}
|
|
201
|
+
{branch.upstreamGone ? (
|
|
202
|
+
<Badge accent="warning" variant="outlined">
|
|
203
|
+
upstream gone
|
|
204
|
+
</Badge>
|
|
205
|
+
) : null}
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface BranchRowProps {
|
|
211
|
+
branch: CheckoutBranch;
|
|
212
|
+
pullRequest: OpenPullRequest | null;
|
|
213
|
+
/** the worktree it is checked out in, openable; null when none */
|
|
214
|
+
worktree: CheckoutWorktree | null;
|
|
215
|
+
onOpenWorktree: (worktree: CheckoutWorktree) => void;
|
|
216
|
+
onOpenBranch: (branch: string) => void;
|
|
217
|
+
onPullWorktree: (worktree: CheckoutWorktree) => Promise<void>;
|
|
218
|
+
onOpenPullRequest: (number: number) => void;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Offered on the worktree's status by the rule the server applies before
|
|
223
|
+
* pulling, so the button never meets a refusal of its own terms.
|
|
224
|
+
*/
|
|
225
|
+
const isPullable = (worktree: CheckoutWorktree | null): boolean =>
|
|
226
|
+
worktree?.status != null && pullRefusal(worktree.status) === null;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The row opens the branch: its checkout when a worktree has it out, else the
|
|
230
|
+
* branch read from the listed checkout. Its pull and its pull request sit at
|
|
231
|
+
* the bottom and take their own press.
|
|
232
|
+
*/
|
|
233
|
+
function BranchRow({
|
|
234
|
+
branch,
|
|
235
|
+
pullRequest,
|
|
236
|
+
worktree,
|
|
237
|
+
onOpenWorktree,
|
|
238
|
+
onOpenBranch,
|
|
239
|
+
onPullWorktree,
|
|
240
|
+
onOpenPullRequest,
|
|
241
|
+
}: BranchRowProps): ReactNode {
|
|
242
|
+
const track = branchTrackParts(branch);
|
|
243
|
+
const pullable = worktree !== null && isPullable(worktree);
|
|
244
|
+
const hasActions = pullable || pullRequest !== null;
|
|
245
|
+
return (
|
|
246
|
+
<PressableListItem
|
|
247
|
+
role="link"
|
|
248
|
+
aria-label={
|
|
249
|
+
worktree === null
|
|
250
|
+
? `Open the branch ${branch.name}`
|
|
251
|
+
: `Open the checkout of ${branch.name} at ${worktree.path}`
|
|
252
|
+
}
|
|
253
|
+
actions={
|
|
254
|
+
hasActions ? (
|
|
255
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
256
|
+
{pullable ? (
|
|
257
|
+
<ActionButton
|
|
258
|
+
text={`Pull ${String(worktree.status?.behind)}`}
|
|
259
|
+
icon={<CloudArrowDownRegularIcon />}
|
|
260
|
+
accent="brand"
|
|
261
|
+
variant="tonal"
|
|
262
|
+
size="sm"
|
|
263
|
+
aria-label={`Pull ${branch.name}`}
|
|
264
|
+
errorMessageVariant="flat"
|
|
265
|
+
errorToMessage={errorToMessage}
|
|
266
|
+
onPress={() => onPullWorktree(worktree)}
|
|
267
|
+
/>
|
|
268
|
+
) : null}
|
|
269
|
+
{pullRequest === null ? null : (
|
|
270
|
+
<PullRequestButton
|
|
271
|
+
pullRequest={pullRequest}
|
|
272
|
+
onOpen={onOpenPullRequest}
|
|
273
|
+
/>
|
|
274
|
+
)}
|
|
275
|
+
</View>
|
|
276
|
+
) : undefined
|
|
277
|
+
}
|
|
278
|
+
onPress={() => {
|
|
279
|
+
if (worktree === null) onOpenBranch(branch.name);
|
|
280
|
+
else onOpenWorktree(worktree);
|
|
281
|
+
}}
|
|
282
|
+
>
|
|
283
|
+
<View className="min-w-0 gap-xxs">
|
|
284
|
+
<View className="flex-row flex-wrap items-center gap-sm">
|
|
285
|
+
<Code className="text-sm">{branch.name}</Code>
|
|
286
|
+
<BranchStateBadges branch={branch} worktree={worktree} />
|
|
287
|
+
{track.length === 0 ? null : (
|
|
288
|
+
<Text className="text-xs text-muted">{track.join(" · ")}</Text>
|
|
289
|
+
)}
|
|
290
|
+
</View>
|
|
291
|
+
<Text className="text-xs text-muted" numberOfLines={1}>
|
|
292
|
+
{`${branch.lastCommit.subject} · ${formatRelativeDate(branch.lastCommit.committedAt)}`}
|
|
293
|
+
</Text>
|
|
294
|
+
</View>
|
|
295
|
+
</PressableListItem>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface BranchesSectionProps {
|
|
300
|
+
ownerRepo: string;
|
|
301
|
+
overview: CheckoutBranchesResponse;
|
|
302
|
+
pullRequests: OpenPullRequest[];
|
|
303
|
+
onOpenWorktree: (worktree: CheckoutWorktree) => void;
|
|
304
|
+
onOpenBranch: (branch: string) => void;
|
|
305
|
+
onPullWorktree: (worktree: CheckoutWorktree) => Promise<void>;
|
|
306
|
+
onOpenPullRequest: (number: number) => void;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function BranchesSection({
|
|
310
|
+
ownerRepo,
|
|
311
|
+
overview,
|
|
312
|
+
pullRequests,
|
|
313
|
+
onOpenWorktree,
|
|
314
|
+
onOpenBranch,
|
|
315
|
+
onPullWorktree,
|
|
316
|
+
onOpenPullRequest,
|
|
317
|
+
}: BranchesSectionProps): ReactNode {
|
|
318
|
+
const [showsAll, setShowsAll] = useState(false);
|
|
319
|
+
const { branches, worktrees } = overview;
|
|
320
|
+
const shown = showsAll ? branches : branches.slice(0, shownBranches);
|
|
321
|
+
const openableWorktree = (path: string | null): CheckoutWorktree | null =>
|
|
322
|
+
worktrees.find(
|
|
323
|
+
(worktree) => worktree.path === path && !worktree.prunable,
|
|
324
|
+
) ?? null;
|
|
325
|
+
|
|
326
|
+
return (
|
|
327
|
+
<View className="gap-sm">
|
|
328
|
+
<SectionTitle icon={<GitBranchRegularIcon />}>
|
|
329
|
+
{`Local branches (${String(branches.length)})`}
|
|
330
|
+
</SectionTitle>
|
|
331
|
+
{branches.length === 0 ? (
|
|
332
|
+
<Text className="text-sm text-muted">No local branch.</Text>
|
|
333
|
+
) : (
|
|
334
|
+
<View className="gap-xs">
|
|
335
|
+
{shown.map((branch) => (
|
|
336
|
+
<BranchRow
|
|
337
|
+
key={branch.name}
|
|
338
|
+
branch={branch}
|
|
339
|
+
pullRequest={pullRequestOfBranch(
|
|
340
|
+
pullRequests,
|
|
341
|
+
ownerRepo,
|
|
342
|
+
branch.name,
|
|
343
|
+
)}
|
|
344
|
+
worktree={openableWorktree(branch.worktreePath)}
|
|
345
|
+
onOpenWorktree={onOpenWorktree}
|
|
346
|
+
onOpenBranch={onOpenBranch}
|
|
347
|
+
onPullWorktree={onPullWorktree}
|
|
348
|
+
onOpenPullRequest={onOpenPullRequest}
|
|
349
|
+
/>
|
|
350
|
+
))}
|
|
351
|
+
</View>
|
|
352
|
+
)}
|
|
353
|
+
{shown.length < branches.length ? (
|
|
354
|
+
<View className="flex-row">
|
|
355
|
+
<Button
|
|
356
|
+
text={`Show the ${pluralOf(branches.length - shown.length, "older branch", "older branches")}`}
|
|
357
|
+
accent="neutral"
|
|
358
|
+
variant="soft"
|
|
359
|
+
size="sm"
|
|
360
|
+
onPress={() => {
|
|
361
|
+
setShowsAll(true);
|
|
362
|
+
}}
|
|
363
|
+
/>
|
|
364
|
+
</View>
|
|
365
|
+
) : null}
|
|
366
|
+
</View>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface PullRequestRowProps {
|
|
371
|
+
ownerRepo: string;
|
|
372
|
+
pullRequest: OpenPullRequest;
|
|
373
|
+
onOpen: (number: number) => void;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** A fork's head is named with its owner, as GitHub does. */
|
|
377
|
+
const headLabel = (ownerRepo: string, pullRequest: OpenPullRequest): string => {
|
|
378
|
+
const headOwner = pullRequest.headOwnerRepo?.split("/")[0] ?? null;
|
|
379
|
+
const owner = ownerRepo.split("/")[0] ?? "";
|
|
380
|
+
if (headOwner === null || headOwner.toLowerCase() === owner.toLowerCase()) {
|
|
381
|
+
return pullRequest.headRefName;
|
|
382
|
+
}
|
|
383
|
+
return `${headOwner}:${pullRequest.headRefName}`;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/** The board's row, without the repository its screen already names. */
|
|
387
|
+
function PullRequestRow({
|
|
388
|
+
ownerRepo,
|
|
389
|
+
pullRequest,
|
|
390
|
+
onOpen,
|
|
391
|
+
}: PullRequestRowProps): ReactNode {
|
|
392
|
+
return (
|
|
393
|
+
<PullRequestRowFrame
|
|
394
|
+
ariaLabel={`Open pull request #${String(pullRequest.number)}: ${pullRequest.title}`}
|
|
395
|
+
meta={
|
|
396
|
+
<>
|
|
397
|
+
<Text className="font-mono text-xs text-muted">
|
|
398
|
+
{`#${String(pullRequest.number)}`}
|
|
399
|
+
</Text>
|
|
400
|
+
<PullRequestMetaSeparator />
|
|
401
|
+
<Text className="font-mono text-xs text-muted">
|
|
402
|
+
{headLabel(ownerRepo, pullRequest)}
|
|
403
|
+
</Text>
|
|
404
|
+
<PullRequestMetaSeparator />
|
|
405
|
+
<Text className="text-xs text-muted">
|
|
406
|
+
{pullRequest.author ?? "ghost"}
|
|
407
|
+
</Text>
|
|
408
|
+
<PullRequestMetaSeparator />
|
|
409
|
+
<Text className="text-xs text-muted">
|
|
410
|
+
{`updated ${formatRelativeDate(pullRequest.updatedAt)}`}
|
|
411
|
+
</Text>
|
|
412
|
+
</>
|
|
413
|
+
}
|
|
414
|
+
title={pullRequest.title}
|
|
415
|
+
titleAside={
|
|
416
|
+
pullRequest.isDraft ? (
|
|
417
|
+
<Badge accent="neutral" variant="outlined">
|
|
418
|
+
draft
|
|
419
|
+
</Badge>
|
|
420
|
+
) : null
|
|
421
|
+
}
|
|
422
|
+
onPress={() => {
|
|
423
|
+
onOpen(pullRequest.number);
|
|
424
|
+
}}
|
|
425
|
+
/>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interface PullRequestsSectionProps {
|
|
430
|
+
ownerRepo: string;
|
|
431
|
+
pulls: CheckoutRead<OpenPullRequestsResponse>;
|
|
432
|
+
branches: CheckoutBranch[];
|
|
433
|
+
onOpenPullRequest: (number: number) => void;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The open pull requests no local branch carries: the way to the others is their branch's row. */
|
|
437
|
+
function PullRequestsSection({
|
|
438
|
+
ownerRepo,
|
|
439
|
+
pulls,
|
|
440
|
+
branches,
|
|
441
|
+
onOpenPullRequest,
|
|
442
|
+
}: PullRequestsSectionProps): ReactNode {
|
|
443
|
+
const body = ((): ReactNode => {
|
|
444
|
+
if (pulls.value === null) {
|
|
445
|
+
if (pulls.error === null) return <Loading />;
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
const without = pullRequestsWithoutBranch(
|
|
449
|
+
pulls.value.pullRequests,
|
|
450
|
+
ownerRepo,
|
|
451
|
+
branches,
|
|
452
|
+
);
|
|
453
|
+
const unlisted = pulls.value.totalCount - pulls.value.pullRequests.length;
|
|
454
|
+
return (
|
|
455
|
+
<>
|
|
456
|
+
{without.length === 0 ? (
|
|
457
|
+
<Text className="text-sm text-muted">
|
|
458
|
+
Every open pull request has its branch here.
|
|
459
|
+
</Text>
|
|
460
|
+
) : (
|
|
461
|
+
<View className="gap-xs">
|
|
462
|
+
{without.map((pullRequest) => (
|
|
463
|
+
<PullRequestRow
|
|
464
|
+
key={pullRequest.number}
|
|
465
|
+
ownerRepo={ownerRepo}
|
|
466
|
+
pullRequest={pullRequest}
|
|
467
|
+
onOpen={onOpenPullRequest}
|
|
468
|
+
/>
|
|
469
|
+
))}
|
|
470
|
+
</View>
|
|
471
|
+
)}
|
|
472
|
+
{unlisted > 0 ? (
|
|
473
|
+
<Text className="text-xs text-muted">
|
|
474
|
+
{`${pluralOf(unlisted, "older pull request")} not listed`}
|
|
475
|
+
</Text>
|
|
476
|
+
) : null}
|
|
477
|
+
</>
|
|
478
|
+
);
|
|
479
|
+
})();
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<View className="gap-sm">
|
|
483
|
+
<SectionTitle icon={<GitPullRequestRegularIcon />}>
|
|
484
|
+
Open pull requests without a local branch
|
|
485
|
+
</SectionTitle>
|
|
486
|
+
{pulls.error === null ? null : (
|
|
487
|
+
<WarningMessage>
|
|
488
|
+
{`Could not read the open pull requests: ${pulls.error}`}
|
|
489
|
+
</WarningMessage>
|
|
490
|
+
)}
|
|
491
|
+
{body}
|
|
492
|
+
</View>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export interface RepoOverviewProps {
|
|
497
|
+
ownerRepo: string;
|
|
498
|
+
/** the local branches and the worktrees, from git */
|
|
499
|
+
branches: CheckoutRead<CheckoutBranchesResponse>;
|
|
500
|
+
/** the open pull requests, from GitHub: a failure only loses the pull request buttons */
|
|
501
|
+
pulls: CheckoutRead<OpenPullRequestsResponse>;
|
|
502
|
+
onReload: () => void;
|
|
503
|
+
onOpenWorktree: (worktree: CheckoutWorktree) => void;
|
|
504
|
+
/** a local branch no worktree has out, read from the listed checkout */
|
|
505
|
+
onOpenBranch: (branch: string) => void;
|
|
506
|
+
/** a fast-forward pull in that worktree; a rejection is shown under its button */
|
|
507
|
+
onPullWorktree: (worktree: CheckoutWorktree) => Promise<void>;
|
|
508
|
+
onOpenPullRequest: (number: number) => void;
|
|
509
|
+
/** before the title, e.g. a back button */
|
|
510
|
+
headerStart?: ReactNode;
|
|
511
|
+
/** under the title, e.g. what the last removal left behind */
|
|
512
|
+
notices?: ReactNode;
|
|
513
|
+
/** at the bottom of a worktree's row, e.g. its removal; each takes its own press */
|
|
514
|
+
renderWorktreeActions?: (worktree: CheckoutWorktree) => ReactNode;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* A repository as the disk has it: its worktrees, each opening its checkout;
|
|
519
|
+
* its local branches, with the open pull request of each and the worktree it
|
|
520
|
+
* is checked out in; and the open pull requests no local branch carries.
|
|
521
|
+
* Read-only: what changes the disk comes from the caller, through
|
|
522
|
+
* `onPullWorktree` and `renderWorktreeActions`.
|
|
523
|
+
*/
|
|
524
|
+
export function RepoOverview({
|
|
525
|
+
ownerRepo,
|
|
526
|
+
branches,
|
|
527
|
+
pulls,
|
|
528
|
+
onReload,
|
|
529
|
+
onOpenWorktree,
|
|
530
|
+
onOpenBranch,
|
|
531
|
+
onPullWorktree,
|
|
532
|
+
onOpenPullRequest,
|
|
533
|
+
headerStart,
|
|
534
|
+
notices,
|
|
535
|
+
renderWorktreeActions,
|
|
536
|
+
}: RepoOverviewProps): ReactNode {
|
|
537
|
+
const overview = branches.value;
|
|
538
|
+
const pullRequests = pulls.value?.pullRequests ?? [];
|
|
539
|
+
|
|
540
|
+
const body = ((): ReactNode => {
|
|
541
|
+
if (overview === null) {
|
|
542
|
+
if (branches.error !== null) return null;
|
|
543
|
+
return <Loading />;
|
|
544
|
+
}
|
|
545
|
+
return (
|
|
546
|
+
<View className="gap-xl">
|
|
547
|
+
<View className="gap-sm">
|
|
548
|
+
<SectionTitle icon={<FolderSimpleRegularIcon />}>
|
|
549
|
+
{`Worktrees (${String(overview.worktrees.length)})`}
|
|
550
|
+
</SectionTitle>
|
|
551
|
+
{overview.worktrees.length === 0 ? (
|
|
552
|
+
<EmptyState icon={<FolderSimpleRegularIcon />} title="No worktree">
|
|
553
|
+
This repository is bare: no checkout has its files.
|
|
554
|
+
</EmptyState>
|
|
555
|
+
) : (
|
|
556
|
+
<View className="gap-xs">
|
|
557
|
+
{overview.worktrees.map((worktree) => (
|
|
558
|
+
<WorktreeRow
|
|
559
|
+
key={worktree.path}
|
|
560
|
+
worktree={worktree}
|
|
561
|
+
actions={renderWorktreeActions?.(worktree)}
|
|
562
|
+
onOpen={onOpenWorktree}
|
|
563
|
+
/>
|
|
564
|
+
))}
|
|
565
|
+
</View>
|
|
566
|
+
)}
|
|
567
|
+
</View>
|
|
568
|
+
<BranchesSection
|
|
569
|
+
ownerRepo={ownerRepo}
|
|
570
|
+
overview={overview}
|
|
571
|
+
pullRequests={pullRequests}
|
|
572
|
+
onOpenWorktree={onOpenWorktree}
|
|
573
|
+
onOpenBranch={onOpenBranch}
|
|
574
|
+
onPullWorktree={onPullWorktree}
|
|
575
|
+
onOpenPullRequest={onOpenPullRequest}
|
|
576
|
+
/>
|
|
577
|
+
<PullRequestsSection
|
|
578
|
+
ownerRepo={ownerRepo}
|
|
579
|
+
pulls={pulls}
|
|
580
|
+
branches={overview.branches}
|
|
581
|
+
onOpenPullRequest={onOpenPullRequest}
|
|
582
|
+
/>
|
|
583
|
+
</View>
|
|
584
|
+
);
|
|
585
|
+
})();
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<View className="gap-l">
|
|
589
|
+
<View className="flex-row flex-wrap items-center gap-m">
|
|
590
|
+
{headerStart}
|
|
591
|
+
<Text
|
|
592
|
+
role="heading"
|
|
593
|
+
aria-level={1}
|
|
594
|
+
className="min-w-0 flex-1 font-heading-extrabold text-3xl xl:text-4xl"
|
|
595
|
+
>
|
|
596
|
+
{ownerRepo}
|
|
597
|
+
</Text>
|
|
598
|
+
<TooltipIconButton
|
|
599
|
+
icon={<ArrowsClockwiseRegularIcon />}
|
|
600
|
+
size="sm"
|
|
601
|
+
variant="soft"
|
|
602
|
+
accent="neutral"
|
|
603
|
+
aria-label="Read the repository again"
|
|
604
|
+
disabledReason={branches.loading || pulls.loading ? "Reading…" : null}
|
|
605
|
+
onPress={onReload}
|
|
606
|
+
/>
|
|
607
|
+
</View>
|
|
608
|
+
{notices}
|
|
609
|
+
{branches.error === null ? null : (
|
|
610
|
+
<WarningMessage>{branches.error}</WarningMessage>
|
|
611
|
+
)}
|
|
612
|
+
{body}
|
|
613
|
+
</View>
|
|
614
|
+
);
|
|
615
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type { MarkdownEditorProps, PullRequestSlots } from "./slots.tsx";
|
|
|
18
18
|
export { PullRequestSlotsProvider, usePullRequestSlots } from "./slots.tsx";
|
|
19
19
|
export type { PullRequestScreenProps } from "./components/pull-request-screen.tsx";
|
|
20
20
|
export { PullRequestScreen } from "./components/pull-request-screen.tsx";
|
|
21
|
+
export type { RenderRepoLink } from "./components/pull-request-repo-name.tsx";
|
|
21
22
|
export type { PullRequestTab } from "./components/pull-request-panel.tsx";
|
|
22
23
|
export {
|
|
23
24
|
PullRequestPanel,
|
|
@@ -28,6 +29,49 @@ export { PullRequestFiles } from "./components/pull-request-files.tsx";
|
|
|
28
29
|
export { PullRequestReview } from "./components/pull-request-review.tsx";
|
|
29
30
|
export { PullRequestCommits } from "./components/pull-request-commits.tsx";
|
|
30
31
|
export { DiffRefused } from "./components/pull-request-diff-view.tsx";
|
|
32
|
+
export { OmittedFiles } from "./components/omitted-files.tsx";
|
|
33
|
+
export type {
|
|
34
|
+
CheckoutHistory,
|
|
35
|
+
CheckoutRead,
|
|
36
|
+
CheckoutTab,
|
|
37
|
+
CheckoutViewProps,
|
|
38
|
+
} from "./components/checkout-view.tsx";
|
|
39
|
+
export { CheckoutView } from "./components/checkout-view.tsx";
|
|
40
|
+
export type { RepoOverviewProps } from "./components/repo-overview.tsx";
|
|
41
|
+
export { RepoOverview } from "./components/repo-overview.tsx";
|
|
42
|
+
export type {
|
|
43
|
+
IgnoredFilesProps,
|
|
44
|
+
RemoveWorktreeButtonProps,
|
|
45
|
+
WorktreeRemovalCheck,
|
|
46
|
+
WorktreeRemovalMessagesProps,
|
|
47
|
+
} from "./components/remove-worktree-button.tsx";
|
|
48
|
+
export {
|
|
49
|
+
IgnoredFiles,
|
|
50
|
+
RemoveWorktreeButton,
|
|
51
|
+
WorktreeRemovalMessages,
|
|
52
|
+
} from "./components/remove-worktree-button.tsx";
|
|
53
|
+
export {
|
|
54
|
+
branchTrackParts,
|
|
55
|
+
checkoutSummaryParts,
|
|
56
|
+
} from "./services/checkoutSummary.ts";
|
|
57
|
+
export type {
|
|
58
|
+
CommitListProps,
|
|
59
|
+
CommitSteps,
|
|
60
|
+
} from "./components/commit-list.tsx";
|
|
61
|
+
export { CommitHeader, CommitList } from "./components/commit-list.tsx";
|
|
62
|
+
export type { CheckoutSyncButtonsProps } from "./components/checkout-sync-buttons.tsx";
|
|
63
|
+
export { CheckoutSyncButtons } from "./components/checkout-sync-buttons.tsx";
|
|
64
|
+
// DiffViewer is the `@reviewflow/ui/diff-viewer` subpath (default export), to be loaded lazily:
|
|
65
|
+
// it imports @pierre/diffs. Only its types come from here.
|
|
66
|
+
export type {
|
|
67
|
+
DiffStyle,
|
|
68
|
+
DiffViewerProps,
|
|
69
|
+
} from "./components/pull-request-diff-viewer.tsx";
|
|
70
|
+
export type { PullRequestCollapse } from "./components/pull-request-diff-collapse.ts";
|
|
71
|
+
export { usePullRequestCollapse } from "./components/pull-request-diff-collapse.ts";
|
|
72
|
+
export { diffFontFamily } from "./components/branch-diff/diff-font.ts";
|
|
73
|
+
export { linkInRow } from "./components/link-in-row.ts";
|
|
74
|
+
export { scrollParentOf } from "./components/scroll-parent.ts";
|
|
31
75
|
export { MarkdownText } from "./components/markdown-text.tsx";
|
|
32
76
|
export {
|
|
33
77
|
DiffSettingsProvider,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CheckoutBranch, CheckoutStatus } from "@reviewflow/sdk";
|
|
2
|
+
|
|
3
|
+
const trackParts = (ahead: number | null, behind: number | null): string[] => [
|
|
4
|
+
...(ahead === null || ahead === 0 ? [] : [`${String(ahead)} to push`]),
|
|
5
|
+
...(behind === null || behind === 0 ? [] : [`${String(behind)} to pull`]),
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a checkout holds that its remote does not, and the reverse, as short
|
|
10
|
+
* phrases; empty when it is clean and in sync. Push and pull are as of the
|
|
11
|
+
* last fetch.
|
|
12
|
+
*/
|
|
13
|
+
export const checkoutSummaryParts = (status: CheckoutStatus): string[] => {
|
|
14
|
+
const uncommitted = status.modified + status.untracked;
|
|
15
|
+
const upstreamParts = ((): string[] => {
|
|
16
|
+
if (status.branch === null) return [];
|
|
17
|
+
if (status.upstream === null) return ["not published"];
|
|
18
|
+
if (status.ahead === null) return ["upstream gone"];
|
|
19
|
+
return trackParts(status.ahead, status.behind);
|
|
20
|
+
})();
|
|
21
|
+
return [
|
|
22
|
+
...(uncommitted === 0 ? [] : [`${String(uncommitted)} uncommitted`]),
|
|
23
|
+
...upstreamParts,
|
|
24
|
+
];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Where a branch stands against the remote branch it tracks; gone is a badge of its own. */
|
|
28
|
+
export const branchTrackParts = (branch: CheckoutBranch): string[] => {
|
|
29
|
+
if (branch.upstreamGone) return [];
|
|
30
|
+
if (branch.upstream === null) return ["not published"];
|
|
31
|
+
return trackParts(branch.ahead, branch.behind);
|
|
32
|
+
};
|