@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.
@@ -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
+ };