@reviewflow/ui 0.0.0-stage → 6.21.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 (92) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +95 -2
  3. package/package.json +102 -4
  4. package/src/board/bucket-section.tsx +94 -0
  5. package/src/board/dataSource.ts +44 -0
  6. package/src/board/facts.ts +183 -0
  7. package/src/board/index.ts +12 -0
  8. package/src/board/pull-request-board.tsx +239 -0
  9. package/src/board/pull-request-row.tsx +181 -0
  10. package/src/components/branch-diff/diff-themes.ts +6 -0
  11. package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
  12. package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
  13. package/src/components/branch-diff/diff-worker.ts +7 -0
  14. package/src/components/branch-diff/use-collapsed-files.ts +49 -0
  15. package/src/components/branch-diff/use-diff-settings.tsx +97 -0
  16. package/src/components/branch-diff/whitespace-button.tsx +32 -0
  17. package/src/components/copyable-code-block.tsx +101 -0
  18. package/src/components/label-pill.tsx +84 -0
  19. package/src/components/link-in-row.ts +6 -0
  20. package/src/components/markdown-suggestion.tsx +60 -0
  21. package/src/components/markdown-text.tsx +1067 -0
  22. package/src/components/pull-request-actions-merge.tsx +341 -0
  23. package/src/components/pull-request-actions-review.tsx +218 -0
  24. package/src/components/pull-request-checks.tsx +86 -0
  25. package/src/components/pull-request-commit-files.ts +143 -0
  26. package/src/components/pull-request-commits.tsx +221 -0
  27. package/src/components/pull-request-conversation-card.tsx +220 -0
  28. package/src/components/pull-request-conversation-edit.tsx +147 -0
  29. package/src/components/pull-request-conversation-event.tsx +267 -0
  30. package/src/components/pull-request-conversation-person.tsx +28 -0
  31. package/src/components/pull-request-conversation-thread.tsx +158 -0
  32. package/src/components/pull-request-diff-annotations.tsx +443 -0
  33. package/src/components/pull-request-diff-collapse.ts +43 -0
  34. package/src/components/pull-request-diff-data.ts +174 -0
  35. package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
  36. package/src/components/pull-request-diff-impacts.ts +45 -0
  37. package/src/components/pull-request-diff-jump.ts +87 -0
  38. package/src/components/pull-request-diff-lines.ts +103 -0
  39. package/src/components/pull-request-diff-overview.tsx +416 -0
  40. package/src/components/pull-request-diff-view.tsx +438 -0
  41. package/src/components/pull-request-diff-viewer.tsx +1374 -0
  42. package/src/components/pull-request-files.tsx +533 -0
  43. package/src/components/pull-request-markdown-input.tsx +91 -0
  44. package/src/components/pull-request-panel.tsx +1270 -0
  45. package/src/components/pull-request-part-notice.tsx +82 -0
  46. package/src/components/pull-request-reactions.tsx +178 -0
  47. package/src/components/pull-request-review.tsx +164 -0
  48. package/src/components/pull-request-revisions.tsx +273 -0
  49. package/src/components/pull-request-screen.tsx +299 -0
  50. package/src/components/pull-request-suggestion-actions.tsx +201 -0
  51. package/src/components/pull-request-timeline.tsx +270 -0
  52. package/src/components/repository-attachment-banner.tsx +42 -0
  53. package/src/components/scroll-parent.ts +50 -0
  54. package/src/css.d.ts +1 -0
  55. package/src/dataSource.ts +242 -0
  56. package/src/editor/alerts.ts +105 -0
  57. package/src/editor/code-editor.native.tsx +25 -0
  58. package/src/editor/code-editor.tsx +112 -0
  59. package/src/editor/codeEditorExtensions.ts +65 -0
  60. package/src/editor/codeHighlight.ts +71 -0
  61. package/src/editor/commands.ts +175 -0
  62. package/src/editor/decorations.ts +425 -0
  63. package/src/editor/editable-file.tsx +224 -0
  64. package/src/editor/emojiCompletion.ts +110 -0
  65. package/src/editor/extensions.ts +149 -0
  66. package/src/editor/frontmatter.ts +76 -0
  67. package/src/editor/inlineFormat.ts +114 -0
  68. package/src/editor/lineTypes.ts +55 -0
  69. package/src/editor/markdown-editor.css +511 -0
  70. package/src/editor/markdown-editor.native.tsx +29 -0
  71. package/src/editor/markdown-editor.tsx +295 -0
  72. package/src/editor/nodeClasses.ts +69 -0
  73. package/src/index.ts +75 -0
  74. package/src/markdown-github/flatten.ts +49 -0
  75. package/src/markdown-github/html-tokens.ts +93 -0
  76. package/src/markdown-github/pair-html.ts +354 -0
  77. package/src/markdown-github/safe-url.ts +15 -0
  78. package/src/markdown-github/table-columns.ts +23 -0
  79. package/src/markdown-github/toggle-task.ts +164 -0
  80. package/src/nativewind-env.d.ts +1 -0
  81. package/src/prStatus.ts +90 -0
  82. package/src/services/PullRequestDraftsProvider.tsx +179 -0
  83. package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
  84. package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
  85. package/src/services/emojify.ts +13 -0
  86. package/src/services/errorToMessage.ts +6 -0
  87. package/src/services/expiringDraftStore.ts +102 -0
  88. package/src/services/formatDataAge.ts +12 -0
  89. package/src/services/formatRelativeDate.ts +56 -0
  90. package/src/services/githubImages.tsx +33 -0
  91. package/src/services/isViewerAuthor.ts +7 -0
  92. package/src/slots.tsx +34 -0
@@ -0,0 +1,1270 @@
1
+ import type {
2
+ PullRequestCandidatesResponse,
3
+ PullRequestDetail,
4
+ PullRequestDetailResponse,
5
+ PullRequestEdit,
6
+ PullRequestReviewState,
7
+ PullRequestSummary,
8
+ } from "@reviewflow/sdk";
9
+ import type { Accent } from "alouette";
10
+ import {
11
+ AccentScope,
12
+ ActionButton,
13
+ Badge,
14
+ Box,
15
+ Button,
16
+ Checkbox,
17
+ CheckboxGroup,
18
+ Code,
19
+ ErrorMessage,
20
+ FormEditableSection,
21
+ FormField,
22
+ Icon,
23
+ IconButton,
24
+ InputText,
25
+ Tab,
26
+ Tabs,
27
+ Text,
28
+ View,
29
+ WarningMessage,
30
+ } from "alouette";
31
+ import { ArrowClockwiseRegularIcon } from "alouette-icons/phosphor-icons/ArrowClockwise";
32
+ import { ChatCircleDotsRegularIcon } from "alouette-icons/phosphor-icons/ChatCircleDots";
33
+ import type { ReactNode } from "react";
34
+ import { useState } from "react";
35
+ import { usePullRequestDataSource } from "../dataSource.ts";
36
+ import type { PrStatus } from "../prStatus.ts";
37
+ import {
38
+ prStatus,
39
+ prStatusAccents,
40
+ prStatusLabels,
41
+ reviewflowPrStatus,
42
+ } from "../prStatus.ts";
43
+ import { emojify } from "../services/emojify.ts";
44
+ import { errorToMessage } from "../services/errorToMessage.ts";
45
+ import { formatDataAge } from "../services/formatDataAge.ts";
46
+ import { LabelPill } from "./label-pill.tsx";
47
+ import {
48
+ PullRequestMergeButton,
49
+ PullRequestMergeStatus,
50
+ PullRequestReopen,
51
+ } from "./pull-request-actions-merge.tsx";
52
+ import { CheckLine } from "./pull-request-checks.tsx";
53
+ import { PullRequestCommits } from "./pull-request-commits.tsx";
54
+ import { PersonAvatar } from "./pull-request-conversation-person.tsx";
55
+ import { PullRequestFiles } from "./pull-request-files.tsx";
56
+ import {
57
+ PullRequestPartError,
58
+ PullRequestPartPending,
59
+ PullRequestPartReading,
60
+ } from "./pull-request-part-notice.tsx";
61
+ import { PullRequestReview } from "./pull-request-review.tsx";
62
+ import { PullRequestTimeline } from "./pull-request-timeline.tsx";
63
+
64
+ /**
65
+ * What the action components take: one edit written through the data source,
66
+ * then GitHub's state read again.
67
+ */
68
+ const useEditAndRefresh = ({
69
+ detail,
70
+ onEdited,
71
+ }: EditableProps): ((edit: PullRequestEdit) => Promise<void>) => {
72
+ const { edit: writeEdit } = usePullRequestDataSource();
73
+ return async (edit) => {
74
+ await writeEdit(detail.pullRequest, edit);
75
+ await onEdited(edit);
76
+ };
77
+ };
78
+
79
+ interface EditableProps {
80
+ detail: PullRequestDetailResponse;
81
+ /** reads the pull request again once GitHub took the change */
82
+ onEdited: (edit: PullRequestEdit) => Promise<void>;
83
+ }
84
+
85
+ /* --- header --- */
86
+
87
+ interface TitleEditorProps extends EditableProps {
88
+ initial: string;
89
+ onDone: () => void;
90
+ }
91
+
92
+ function TitleEditor({
93
+ detail,
94
+ onEdited,
95
+ initial,
96
+ onDone,
97
+ }: TitleEditorProps): ReactNode {
98
+ const editAndRefresh = useEditAndRefresh({ detail, onEdited });
99
+ const [title, setTitle] = useState(initial);
100
+ return (
101
+ <View className="flex-row flex-wrap items-center gap-xs">
102
+ <InputText
103
+ aria-label="Title"
104
+ className="min-w-0 flex-1"
105
+ value={title}
106
+ onChangeText={setTitle}
107
+ />
108
+ <Button
109
+ text="Cancel"
110
+ accent="neutral"
111
+ variant="soft"
112
+ size="sm"
113
+ onPress={onDone}
114
+ />
115
+ <ActionButton
116
+ text="Save"
117
+ accent="brand"
118
+ variant="tonal"
119
+ size="sm"
120
+ disabled={title.trim() === ""}
121
+ errorToMessage={errorToMessage}
122
+ onPress={async () => {
123
+ const edit: PullRequestEdit = { kind: "title", title };
124
+ await editAndRefresh(edit);
125
+ onDone();
126
+ }}
127
+ />
128
+ </View>
129
+ );
130
+ }
131
+
132
+ interface TitleTextProps {
133
+ number: number;
134
+ title: string;
135
+ }
136
+
137
+ function TitleText({ number, title }: TitleTextProps): ReactNode {
138
+ return (
139
+ <Text className="min-w-0 flex-1 font-heading-bold text-xl">
140
+ <Text className="font-heading text-2xl text-muted">
141
+ {`#${String(number)} `}
142
+ </Text>
143
+ {emojify(title)}
144
+ </Text>
145
+ );
146
+ }
147
+
148
+ interface MergeLineProps {
149
+ /** null when the source does not know enough to say */
150
+ status: PrStatus | null;
151
+ author: string | null;
152
+ baseRef: string | null;
153
+ headRef: string | null;
154
+ /** what follows the branches: sizes and age, which only GitHub's read has */
155
+ children?: ReactNode;
156
+ }
157
+
158
+ function MergeLine({
159
+ status,
160
+ author,
161
+ baseRef,
162
+ headRef,
163
+ children,
164
+ }: MergeLineProps): ReactNode {
165
+ return (
166
+ <View className="flex-row flex-wrap items-center gap-xs">
167
+ {status === null ? null : (
168
+ <Badge accent={prStatusAccents[status]}>{prStatusLabels[status]}</Badge>
169
+ )}
170
+ <Text className="text-sm font-body-bold">
171
+ {author ?? "unknown author"}
172
+ </Text>
173
+ {baseRef === null || headRef === null ? null : (
174
+ <>
175
+ <Text className="text-xs text-muted">
176
+ {status === "merged" ? "merged into" : "wants to merge into"}
177
+ </Text>
178
+ <Code className="text-sm">{baseRef}</Code>
179
+ <Text className="text-xs text-muted">from</Text>
180
+ <Code className="text-sm">{headRef}</Code>
181
+ </>
182
+ )}
183
+ {children}
184
+ </View>
185
+ );
186
+ }
187
+
188
+ /**
189
+ * The header from a summary the data source holds, while GitHub is read: the
190
+ * same title and branch line, without what only GitHub's answer carries.
191
+ */
192
+ export function PullRequestSummaryHeader({
193
+ summary,
194
+ }: {
195
+ summary: PullRequestSummary;
196
+ }): ReactNode {
197
+ return (
198
+ <View className="gap-sm">
199
+ <TitleText number={summary.number} title={summary.title} />
200
+ <MergeLine
201
+ status={reviewflowPrStatus(summary)}
202
+ author={summary.author}
203
+ baseRef={summary.baseRef}
204
+ headRef={summary.headRef}
205
+ />
206
+ </View>
207
+ );
208
+ }
209
+
210
+ /**
211
+ * The header: the title and its number, Edit and the way out beside
212
+ * them, and one line saying whose branch goes where.
213
+ */
214
+ function Header({ detail, onEdited }: EditableProps): ReactNode {
215
+ const { pullRequest } = detail;
216
+ const [editing, setEditing] = useState(false);
217
+
218
+ return (
219
+ <View className="gap-sm">
220
+ {editing ? (
221
+ <TitleEditor
222
+ detail={detail}
223
+ initial={pullRequest.title}
224
+ onEdited={onEdited}
225
+ onDone={() => {
226
+ setEditing(false);
227
+ }}
228
+ />
229
+ ) : (
230
+ <View className="flex-row flex-wrap items-start gap-m">
231
+ <TitleText number={pullRequest.number} title={pullRequest.title} />
232
+ <View className="flex-row items-center gap-xs">
233
+ {pullRequest.viewerCanUpdate ? (
234
+ <Button
235
+ text="Edit"
236
+ accent="neutral"
237
+ variant="tonal"
238
+ size="sm"
239
+ aria-label="Edit the title"
240
+ onPress={() => {
241
+ setEditing(true);
242
+ }}
243
+ />
244
+ ) : null}
245
+ </View>
246
+ </View>
247
+ )}
248
+ <MergeLine
249
+ status={prStatus(pullRequest)}
250
+ author={pullRequest.author?.login ?? null}
251
+ baseRef={pullRequest.baseRef}
252
+ headRef={pullRequest.headRef}
253
+ >
254
+ <Text className="text-sm text-muted">·</Text>
255
+ <View className="flex-row gap-xxs">
256
+ <AccentScope accent="success">
257
+ <Text className="text-sm font-body-bold text-accent">
258
+ {`+${String(pullRequest.additions)}`}
259
+ </Text>
260
+ </AccentScope>
261
+ <AccentScope accent="danger">
262
+ <Text className="text-sm font-body-bold text-accent">
263
+ {`−${String(pullRequest.deletions)}`}
264
+ </Text>
265
+ </AccentScope>
266
+ </View>
267
+ <Text className="text-xs text-muted">
268
+ {`· updated ${formatDataAge(pullRequest.updatedAt)}`}
269
+ </Text>
270
+ </MergeLine>
271
+ </View>
272
+ );
273
+ }
274
+
275
+ /* --- conversation --- */
276
+
277
+ interface SurfaceProps {
278
+ title: string;
279
+ children: ReactNode;
280
+ }
281
+
282
+ function Surface({ title, children }: SurfaceProps): ReactNode {
283
+ return (
284
+ <Box className="surface min-w-0 gap-m p-m">
285
+ <Text className="font-heading-bold text-lg">{title}</Text>
286
+ {children}
287
+ </Box>
288
+ );
289
+ }
290
+
291
+ /**
292
+ * The passing checks are a count: what needs reading is what failed or has
293
+ * not finished, and a list of forty green lines buries it.
294
+ */
295
+ function Checks({
296
+ pullRequest,
297
+ }: {
298
+ pullRequest: PullRequestDetail;
299
+ }): ReactNode {
300
+ const { value, error } = usePullRequestDataSource().usePart(
301
+ pullRequest,
302
+ "checks",
303
+ );
304
+ if (value === null) {
305
+ return <PullRequestPartPending error={error} what="the checks" />;
306
+ }
307
+ const { checks, checksTotal } = value;
308
+ if (checksTotal === 0) {
309
+ return <Text className="text-sm text-muted">no checks</Text>;
310
+ }
311
+ const unsettled = checks.filter((check) => check.outcome !== "passed");
312
+ const passed = checks.length - unsettled.length;
313
+ const unlisted = checksTotal - checks.length;
314
+
315
+ return (
316
+ <View className="gap-xs">
317
+ {unsettled.map((check) => (
318
+ <CheckLine key={check.name} check={check} />
319
+ ))}
320
+ <Text className="text-sm text-muted">
321
+ {`${String(passed)} passing${unlisted > 0 ? `, ${String(unlisted)} more not listed` : ""}`}
322
+ </Text>
323
+ </View>
324
+ );
325
+ }
326
+
327
+ /** Above every tab: a conflict is the one thing that blocks the merge whatever is being read. */
328
+ function ConflictWarning({
329
+ pullRequest,
330
+ }: {
331
+ pullRequest: PullRequestDetail;
332
+ }): ReactNode {
333
+ if (pullRequest.mergeable !== "conflicting") return null;
334
+ return (
335
+ <WarningMessage>
336
+ {`This branch has conflicts with ${pullRequest.baseRef} that must be resolved.`}
337
+ </WarningMessage>
338
+ );
339
+ }
340
+
341
+ /**
342
+ * Counted from the threads GitHub listed, the ones the conversation shows and
343
+ * resolves: reviewflow's own count would lag a resolve made here until its
344
+ * webhook lands. Past the 100 threads read, it undercounts.
345
+ */
346
+ function UnresolvedThreads({
347
+ pullRequest,
348
+ }: {
349
+ pullRequest: PullRequestDetail;
350
+ }): ReactNode {
351
+ const { value } = usePullRequestDataSource().usePart(pullRequest, "timeline");
352
+ const unresolved = (value?.timeline ?? []).filter(
353
+ (item) => item.kind === "thread" && !item.resolved,
354
+ ).length;
355
+ if (unresolved === 0) return null;
356
+ return (
357
+ <View className="flex-row items-start gap-xs">
358
+ <AccentScope accent="warning">
359
+ <View className="h-[20px] justify-center">
360
+ <Icon
361
+ icon={<ChatCircleDotsRegularIcon />}
362
+ size={16}
363
+ className="text-accent"
364
+ />
365
+ </View>
366
+ </AccentScope>
367
+ <Text className="min-w-0 flex-1 text-sm">
368
+ {`${String(unresolved)} unresolved ${unresolved === 1 ? "thread" : "threads"}`}
369
+ </Text>
370
+ </View>
371
+ );
372
+ }
373
+
374
+ /**
375
+ * GitHub's merge box, minus its buttons, at the top of the sidebar: what
376
+ * stands between the pull request and a review. On a draft that is the
377
+ * operator's own move, so it leads.
378
+ */
379
+ function Status({ detail, onEdited }: EditableProps): ReactNode {
380
+ const { pullRequest } = detail;
381
+ const editAndRefresh = useEditAndRefresh({ detail, onEdited });
382
+ if (pullRequest.state === "merged") return null;
383
+ if (pullRequest.state === "closed") {
384
+ return (
385
+ <View className="gap-sm">
386
+ <Text className="font-heading-bold text-xl">Status</Text>
387
+ <Text className="text-sm">This pull request is closed.</Text>
388
+ </View>
389
+ );
390
+ }
391
+
392
+ return (
393
+ <View className="gap-sm">
394
+ <Text className="font-heading-bold text-xl">Status</Text>
395
+ {pullRequest.state === "draft" && pullRequest.viewerCanUpdate ? (
396
+ <View className="items-start gap-xs">
397
+ <Text className="text-sm">
398
+ This pull request is still a work in progress.
399
+ </Text>
400
+ <ActionButton
401
+ text="Ready for review"
402
+ accent="brand"
403
+ variant="tonal"
404
+ size="sm"
405
+ errorToMessage={errorToMessage}
406
+ onPress={async () => {
407
+ const edit: PullRequestEdit = { kind: "draft", draft: false };
408
+ await editAndRefresh(edit);
409
+ }}
410
+ />
411
+ </View>
412
+ ) : null}
413
+ {pullRequest.state === "draft" && !pullRequest.viewerCanUpdate ? (
414
+ <Text className="text-sm">
415
+ This pull request is still a work in progress.
416
+ </Text>
417
+ ) : null}
418
+ <Checks pullRequest={pullRequest} />
419
+ <UnresolvedThreads pullRequest={pullRequest} />
420
+ <PullRequestMergeStatus detail={detail} onEdit={editAndRefresh} />
421
+ </View>
422
+ );
423
+ }
424
+
425
+ /** Every check of the head commit, failures first, the passing ones too. */
426
+ function ChecksTab({
427
+ pullRequest,
428
+ }: {
429
+ pullRequest: PullRequestDetail;
430
+ }): ReactNode {
431
+ const { value, error, reading } = usePullRequestDataSource().usePart(
432
+ pullRequest,
433
+ "checks",
434
+ );
435
+ if (value === null) {
436
+ return <PullRequestPartPending error={error} what="the checks" />;
437
+ }
438
+ const { checks, checksTotal } = value;
439
+ const unlisted = checksTotal - checks.length;
440
+ return (
441
+ <Surface title={`Checks (${String(checksTotal)})`}>
442
+ <PullRequestPartReading reading={reading} />
443
+ <PullRequestPartError error={error} />
444
+ {checksTotal === 0 ? (
445
+ <Text className="text-sm text-muted">no checks</Text>
446
+ ) : (
447
+ <View className="gap-xs">
448
+ {checks.map((check) => (
449
+ <CheckLine key={check.name} check={check} />
450
+ ))}
451
+ {unlisted > 0 ? (
452
+ <Text className="text-sm text-muted">
453
+ {`${String(unlisted)} more not listed`}
454
+ </Text>
455
+ ) : null}
456
+ </View>
457
+ )}
458
+ </Surface>
459
+ );
460
+ }
461
+
462
+ /* --- sidebar --- */
463
+
464
+ interface CandidatesProps extends EditableProps {
465
+ /** what the editors offer; null until GitHub answered, the editors read-only */
466
+ candidates: PullRequestCandidatesResponse | null;
467
+ }
468
+
469
+ const reviewStateLabel = (state: PullRequestReviewState): string => {
470
+ switch (state) {
471
+ case "approved":
472
+ return "approved";
473
+ case "changesRequested":
474
+ return "changes requested";
475
+ case "commented":
476
+ return "commented";
477
+ case "dismissed":
478
+ return "dismissed";
479
+ case "pending":
480
+ return "pending";
481
+ default: {
482
+ const unlabelled: never = state;
483
+ return unlabelled;
484
+ }
485
+ }
486
+ };
487
+
488
+ const reviewStateAccent = (state: PullRequestReviewState): Accent => {
489
+ if (state === "approved") return "success";
490
+ if (state === "changesRequested") return "danger";
491
+ return "info";
492
+ };
493
+
494
+ const userValue = (login: string): string => `user:${login}`;
495
+ const teamValue = (slug: string): string => `team:${slug}`;
496
+
497
+ const requestedValues = (pullRequest: PullRequestDetail): string[] =>
498
+ pullRequest.requestedReviewers.map((reviewer) =>
499
+ reviewer.kind === "user"
500
+ ? userValue(reviewer.login)
501
+ : teamValue(reviewer.slug),
502
+ );
503
+
504
+ /** What the selection changed, in the request's terms. */
505
+ const reviewersEdit = (
506
+ pullRequest: PullRequestDetail,
507
+ selected: string[],
508
+ ): PullRequestEdit => {
509
+ const before = new Set(requestedValues(pullRequest));
510
+ const after = new Set(selected);
511
+ const added = selected.filter((value) => !before.has(value));
512
+ const removed = [...before].filter((value) => !after.has(value));
513
+ const names = (values: string[], prefix: string): string[] =>
514
+ values
515
+ .filter((value) => value.startsWith(prefix))
516
+ .map((value) => value.slice(prefix.length));
517
+
518
+ return {
519
+ kind: "reviewers",
520
+ add: names(added, "user:"),
521
+ remove: names(removed, "user:"),
522
+ addTeams: names(added, "team:"),
523
+ removeTeams: names(removed, "team:"),
524
+ };
525
+ };
526
+
527
+ interface ReviewerLineProps {
528
+ name: string;
529
+ avatarUrl: string | null;
530
+ /** null: asked, not answered yet — or asked again since */
531
+ state: PullRequestReviewState | null;
532
+ /** asks the reviewer again; absent where the gesture is not offered */
533
+ onRerequest?: () => Promise<void>;
534
+ }
535
+
536
+ function RerequestButton({
537
+ name,
538
+ onRerequest,
539
+ }: {
540
+ name: string;
541
+ onRerequest: () => Promise<void>;
542
+ }): ReactNode {
543
+ const [error, setError] = useState<string | null>(null);
544
+ const [pending, setPending] = useState(false);
545
+ return (
546
+ <>
547
+ <IconButton
548
+ icon={<ArrowClockwiseRegularIcon />}
549
+ aria-label={`Re-request a review from ${name}`}
550
+ variant="soft"
551
+ size="sm"
552
+ disabled={pending}
553
+ onPress={() => {
554
+ setPending(true);
555
+ setError(null);
556
+ onRerequest()
557
+ .catch((error_: unknown) => {
558
+ setError(errorToMessage(error_));
559
+ })
560
+ .finally(() => {
561
+ setPending(false);
562
+ });
563
+ }}
564
+ />
565
+ {error === null ? null : (
566
+ <View className="basis-full">
567
+ <ErrorMessage>{error}</ErrorMessage>
568
+ </View>
569
+ )}
570
+ </>
571
+ );
572
+ }
573
+
574
+ function ReviewerLine({
575
+ name,
576
+ avatarUrl,
577
+ state,
578
+ onRerequest,
579
+ }: ReviewerLineProps): ReactNode {
580
+ return (
581
+ <View className="flex-row flex-wrap items-center gap-xs">
582
+ <PersonAvatar person={{ login: name, avatarUrl }} />
583
+ <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
584
+ {name}
585
+ </Text>
586
+ {state === null ? (
587
+ <Badge accent="warning" size="sm">
588
+ awaiting
589
+ </Badge>
590
+ ) : (
591
+ <Badge accent={reviewStateAccent(state)} size="sm">
592
+ {reviewStateLabel(state)}
593
+ </Badge>
594
+ )}
595
+ {onRerequest === undefined ? null : (
596
+ <RerequestButton name={name} onRerequest={onRerequest} />
597
+ )}
598
+ </View>
599
+ );
600
+ }
601
+
602
+ /**
603
+ * GitHub's reviewers list: each person once — awaiting when asked (again),
604
+ * else their latest review — teams as asked.
605
+ */
606
+ function ReviewersSummary({
607
+ detail,
608
+ candidates,
609
+ onEdit,
610
+ }: {
611
+ detail: PullRequestDetailResponse;
612
+ candidates: PullRequestCandidatesResponse | null;
613
+ onEdit: (edit: PullRequestEdit) => Promise<void>;
614
+ }): ReactNode {
615
+ const { pullRequest, viewer } = detail;
616
+ // a review carries only a login: its avatar is the candidate lists'
617
+ const avatarOf = (login: string): string | null =>
618
+ [
619
+ ...(candidates?.suggestedReviewers ?? []),
620
+ ...(candidates?.reviewerCandidates ?? []),
621
+ ...(candidates?.assigneeCandidates ?? []),
622
+ ].find((person) => person.login === login)?.avatarUrl ?? null;
623
+ const requestedLogins = new Set(
624
+ pullRequest.requestedReviewers.flatMap((reviewer) =>
625
+ reviewer.kind === "user" ? [reviewer.login] : [],
626
+ ),
627
+ );
628
+ const answered = pullRequest.reviews.filter(
629
+ (review) => !requestedLogins.has(review.author),
630
+ );
631
+ if (pullRequest.requestedReviewers.length === 0 && answered.length === 0) {
632
+ return <Text className="text-sm text-muted">No reviews</Text>;
633
+ }
634
+
635
+ return (
636
+ <View className="gap-xxs">
637
+ {answered.map((review) => (
638
+ <ReviewerLine
639
+ key={review.author}
640
+ name={review.author}
641
+ avatarUrl={avatarOf(review.author)}
642
+ state={review.state}
643
+ onRerequest={
644
+ pullRequest.viewerCanUpdate &&
645
+ review.author !== viewer &&
646
+ pullRequest.state !== "merged"
647
+ ? async () => {
648
+ await onEdit({
649
+ kind: "reviewers",
650
+ add: [review.author],
651
+ remove: [],
652
+ addTeams: [],
653
+ removeTeams: [],
654
+ });
655
+ }
656
+ : undefined
657
+ }
658
+ />
659
+ ))}
660
+ {pullRequest.requestedReviewers.map((reviewer) =>
661
+ reviewer.kind === "user" ? (
662
+ <ReviewerLine
663
+ key={userValue(reviewer.login)}
664
+ name={reviewer.login}
665
+ avatarUrl={reviewer.avatarUrl}
666
+ state={null}
667
+ />
668
+ ) : (
669
+ <ReviewerLine
670
+ key={teamValue(reviewer.slug)}
671
+ name={reviewer.name}
672
+ avatarUrl={null}
673
+ state={null}
674
+ />
675
+ ),
676
+ )}
677
+ </View>
678
+ );
679
+ }
680
+
681
+ interface ReviewersValues {
682
+ members: string[];
683
+ teams: string[];
684
+ }
685
+
686
+ /** Past this many people, the groups are tabs rather than stacked. */
687
+ const maxStackedReviewers = 5;
688
+
689
+ type ReviewerGroup = "members" | "teams";
690
+
691
+ const isReviewerGroup = (value: string): value is ReviewerGroup =>
692
+ value === "members" || value === "teams";
693
+
694
+ interface ReviewerGroupsProps {
695
+ tabbed: boolean;
696
+ /** each group's field, null when it offers no one */
697
+ members: ReactNode;
698
+ teams: ReactNode;
699
+ }
700
+
701
+ function ReviewerGroups({
702
+ tabbed,
703
+ members,
704
+ teams,
705
+ }: ReviewerGroupsProps): ReactNode {
706
+ const [tab, setTab] = useState<ReviewerGroup>(() =>
707
+ members === null ? "teams" : "members",
708
+ );
709
+
710
+ if (!tabbed) {
711
+ return (
712
+ <View className="gap-m">
713
+ {members}
714
+ {teams}
715
+ </View>
716
+ );
717
+ }
718
+
719
+ return (
720
+ <View className="gap-m">
721
+ <Tabs
722
+ aria-label="Reviewer groups"
723
+ value={tab}
724
+ onValueChange={(next) => {
725
+ if (isReviewerGroup(next)) setTab(next);
726
+ }}
727
+ >
728
+ {members === null ? null : <Tab value="members" label="Members" />}
729
+ {teams === null ? null : <Tab value="teams" label="Teams" />}
730
+ </Tabs>
731
+ {tab === "members" ? members : teams}
732
+ </View>
733
+ );
734
+ }
735
+
736
+ interface MemberCheckboxesProps {
737
+ title: string | null;
738
+ logins: string[];
739
+ }
740
+
741
+ function MemberCheckboxes({ title, logins }: MemberCheckboxesProps): ReactNode {
742
+ if (logins.length === 0) return null;
743
+ return (
744
+ <View className="gap-xxs">
745
+ {title === null ? null : (
746
+ <Text className="font-body-bold text-xs text-muted">{title}</Text>
747
+ )}
748
+ {logins.map((login) => (
749
+ <Checkbox key={login} value={userValue(login)} label={login} />
750
+ ))}
751
+ </View>
752
+ );
753
+ }
754
+
755
+ function ReadOnlySection({
756
+ title,
757
+ children,
758
+ }: {
759
+ title: string;
760
+ children: ReactNode;
761
+ }): ReactNode {
762
+ return (
763
+ <View className="gap-sm">
764
+ <Text className="font-heading-bold text-xl">{title}</Text>
765
+ {children}
766
+ </View>
767
+ );
768
+ }
769
+
770
+ /**
771
+ * Members: GitHub's suggestions grouped first, then anyone else already asked,
772
+ * then the repository's assignable users; those already asked lead each group.
773
+ * Teams: those already asked, then the rest.
774
+ */
775
+ function Reviewers({
776
+ detail,
777
+ candidates,
778
+ onEdited,
779
+ }: CandidatesProps): ReactNode {
780
+ const { pullRequest } = detail;
781
+ const editAndRefresh = useEditAndRefresh({ detail, onEdited });
782
+ const suggestedLogins = (candidates?.suggestedReviewers ?? []).map(
783
+ ({ login }) => login,
784
+ );
785
+ const reviewerCandidates = candidates?.reviewerCandidates ?? [];
786
+ const teamCandidates = candidates?.teamCandidates ?? [];
787
+ const requestedLogins = pullRequest.requestedReviewers.flatMap((reviewer) =>
788
+ reviewer.kind === "user" ? [reviewer.login] : [],
789
+ );
790
+ const requestedTeams = pullRequest.requestedReviewers.flatMap((reviewer) =>
791
+ reviewer.kind === "team" ? [reviewer] : [],
792
+ );
793
+ const requestedSuggested = requestedLogins.filter((login) =>
794
+ suggestedLogins.includes(login),
795
+ );
796
+ const requestedMembers = requestedLogins.filter(
797
+ (login) => !suggestedLogins.includes(login),
798
+ );
799
+ const teams = [
800
+ ...requestedTeams,
801
+ ...teamCandidates.filter(
802
+ ({ slug }) => !requestedTeams.some((team) => team.slug === slug),
803
+ ),
804
+ ];
805
+ const suggested = [
806
+ ...requestedSuggested,
807
+ ...suggestedLogins.filter((login) => !requestedLogins.includes(login)),
808
+ ];
809
+ const otherMembers = [
810
+ ...requestedMembers,
811
+ ...reviewerCandidates
812
+ .map(({ login }) => login)
813
+ .filter(
814
+ (login) =>
815
+ !requestedLogins.includes(login) && !suggestedLogins.includes(login),
816
+ ),
817
+ ];
818
+ const memberCount = suggested.length + otherMembers.length;
819
+
820
+ if (!pullRequest.viewerCanUpdate || candidates === null) {
821
+ return (
822
+ <ReadOnlySection title="Reviewers">
823
+ <ReviewersSummary
824
+ detail={detail}
825
+ candidates={candidates}
826
+ onEdit={editAndRefresh}
827
+ />
828
+ </ReadOnlySection>
829
+ );
830
+ }
831
+
832
+ return (
833
+ <FormEditableSection<ReviewersValues>
834
+ title="Reviewers"
835
+ editAriaLabel="Edit the requested reviewers"
836
+ cancelLabel="Cancel"
837
+ submitLabel="Save"
838
+ submitErrorToMessage={errorToMessage}
839
+ defaultValues={{
840
+ members: requestedLogins.map(userValue),
841
+ teams: requestedTeams.map(({ slug }) => teamValue(slug)),
842
+ }}
843
+ render={({ control }) => (
844
+ <ReviewerGroups
845
+ tabbed={memberCount > maxStackedReviewers}
846
+ members={
847
+ memberCount === 0 ? null : (
848
+ <FormField
849
+ control={control}
850
+ name="members"
851
+ label="Members"
852
+ render={({ field, labelId }) => (
853
+ <CheckboxGroup
854
+ values={field.value}
855
+ aria-labelledby={labelId}
856
+ onValuesChange={field.onChange}
857
+ >
858
+ <View className="gap-sm">
859
+ <MemberCheckboxes title="Suggested" logins={suggested} />
860
+ <MemberCheckboxes
861
+ title={suggested.length === 0 ? null : "Others"}
862
+ logins={otherMembers}
863
+ />
864
+ </View>
865
+ </CheckboxGroup>
866
+ )}
867
+ />
868
+ )
869
+ }
870
+ teams={
871
+ teams.length === 0 ? null : (
872
+ <FormField
873
+ control={control}
874
+ name="teams"
875
+ label="Teams"
876
+ render={({ field, labelId }) => (
877
+ <CheckboxGroup
878
+ values={field.value}
879
+ aria-labelledby={labelId}
880
+ onValuesChange={field.onChange}
881
+ >
882
+ <View className="gap-xxs">
883
+ {teams.map((team) => (
884
+ <Checkbox
885
+ key={team.slug}
886
+ value={teamValue(team.slug)}
887
+ label={team.name}
888
+ />
889
+ ))}
890
+ </View>
891
+ </CheckboxGroup>
892
+ )}
893
+ />
894
+ )
895
+ }
896
+ />
897
+ )}
898
+ onSubmit={async ({ members: selectedMembers, teams: selectedTeams }) => {
899
+ const edit = reviewersEdit(pullRequest, [
900
+ ...selectedMembers,
901
+ ...selectedTeams,
902
+ ]);
903
+ await editAndRefresh(edit);
904
+ }}
905
+ >
906
+ <ReviewersSummary
907
+ detail={detail}
908
+ candidates={candidates}
909
+ onEdit={editAndRefresh}
910
+ />
911
+ </FormEditableSection>
912
+ );
913
+ }
914
+
915
+ /** Where GitHub puts it: under the reviewers, the rare way back. */
916
+ function ConvertToDraft({ detail, onEdited }: EditableProps): ReactNode {
917
+ const { pullRequest } = detail;
918
+ const editAndRefresh = useEditAndRefresh({ detail, onEdited });
919
+ if (pullRequest.state !== "open" || !pullRequest.viewerCanUpdate) return null;
920
+ return (
921
+ <View className="flex-row flex-wrap items-center gap-xs">
922
+ <Text className="text-xs text-muted">Still in progress?</Text>
923
+ <ActionButton
924
+ text="Convert to draft"
925
+ accent="neutral"
926
+ variant="tonal"
927
+ size="sm"
928
+ errorToMessage={errorToMessage}
929
+ onPress={async () => {
930
+ const edit: PullRequestEdit = { kind: "draft", draft: true };
931
+ await editAndRefresh(edit);
932
+ }}
933
+ />
934
+ </View>
935
+ );
936
+ }
937
+
938
+ interface AssigneesValues {
939
+ assignees: string[];
940
+ }
941
+
942
+ function AssigneesSummary({
943
+ pullRequest,
944
+ }: {
945
+ pullRequest: PullRequestDetail;
946
+ }): ReactNode {
947
+ if (pullRequest.assignees.length === 0) {
948
+ return <Text className="text-sm text-muted">No one assigned</Text>;
949
+ }
950
+ return (
951
+ <View className="gap-xs">
952
+ {pullRequest.assignees.map((assignee) => (
953
+ <View key={assignee.login} className="flex-row items-center gap-xs">
954
+ <PersonAvatar person={assignee} />
955
+ <Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
956
+ {assignee.login}
957
+ </Text>
958
+ </View>
959
+ ))}
960
+ </View>
961
+ );
962
+ }
963
+
964
+ /** Current assignees first, then the repository's assignable users. */
965
+ function Assignees({
966
+ detail,
967
+ candidates,
968
+ onEdited,
969
+ }: CandidatesProps): ReactNode {
970
+ const { pullRequest } = detail;
971
+ const editAndRefresh = useEditAndRefresh({ detail, onEdited });
972
+ const current = pullRequest.assignees.map(({ login }) => login);
973
+ const offered = [
974
+ ...new Set([
975
+ ...current,
976
+ ...(candidates?.assigneeCandidates ?? []).map(({ login }) => login),
977
+ ]),
978
+ ];
979
+
980
+ if (!pullRequest.viewerCanUpdate || candidates === null) {
981
+ return (
982
+ <ReadOnlySection title="Assignees">
983
+ <AssigneesSummary pullRequest={pullRequest} />
984
+ </ReadOnlySection>
985
+ );
986
+ }
987
+
988
+ return (
989
+ <FormEditableSection<AssigneesValues>
990
+ title="Assignees"
991
+ editAriaLabel="Edit the pull request's assignees"
992
+ cancelLabel="Cancel"
993
+ submitLabel="Save"
994
+ submitErrorToMessage={errorToMessage}
995
+ defaultValues={{ assignees: current }}
996
+ render={({ control }) => (
997
+ <FormField
998
+ control={control}
999
+ name="assignees"
1000
+ label="Assign to"
1001
+ render={({ field, labelId }) => (
1002
+ <CheckboxGroup
1003
+ values={field.value}
1004
+ aria-labelledby={labelId}
1005
+ onValuesChange={field.onChange}
1006
+ >
1007
+ <View className="gap-xxs">
1008
+ {offered.map((login) => (
1009
+ <Checkbox key={login} value={login} label={login} />
1010
+ ))}
1011
+ </View>
1012
+ </CheckboxGroup>
1013
+ )}
1014
+ />
1015
+ )}
1016
+ onSubmit={async ({ assignees }) => {
1017
+ const before = new Set(current);
1018
+ const after = new Set(assignees);
1019
+ const edit: PullRequestEdit = {
1020
+ kind: "assignees",
1021
+ add: assignees.filter((login) => !before.has(login)),
1022
+ remove: current.filter((login) => !after.has(login)),
1023
+ };
1024
+ await editAndRefresh(edit);
1025
+ }}
1026
+ >
1027
+ <AssigneesSummary pullRequest={pullRequest} />
1028
+ </FormEditableSection>
1029
+ );
1030
+ }
1031
+
1032
+ interface LabelsValues {
1033
+ labels: string[];
1034
+ }
1035
+
1036
+ function Labels({ detail, candidates, onEdited }: CandidatesProps): ReactNode {
1037
+ const { pullRequest } = detail;
1038
+ const editAndRefresh = useEditAndRefresh({ detail, onEdited });
1039
+ const current = pullRequest.labels.map(({ name }) => name);
1040
+ const offered = [
1041
+ ...new Set([
1042
+ ...current,
1043
+ ...(candidates?.repoLabels ?? []).map(({ name }) => name),
1044
+ ]),
1045
+ ];
1046
+ const summary =
1047
+ pullRequest.labels.length === 0 ? (
1048
+ <Text className="text-sm text-muted">None yet</Text>
1049
+ ) : (
1050
+ <View className="flex-row flex-wrap gap-xxs">
1051
+ {pullRequest.labels.map((label) => (
1052
+ <LabelPill key={label.name} label={label} />
1053
+ ))}
1054
+ </View>
1055
+ );
1056
+
1057
+ if (!pullRequest.viewerCanUpdate || candidates === null) {
1058
+ return <ReadOnlySection title="Labels">{summary}</ReadOnlySection>;
1059
+ }
1060
+
1061
+ return (
1062
+ <FormEditableSection<LabelsValues>
1063
+ title="Labels"
1064
+ editAriaLabel="Edit the pull request's labels"
1065
+ cancelLabel="Cancel"
1066
+ submitLabel="Save"
1067
+ submitErrorToMessage={errorToMessage}
1068
+ defaultValues={{ labels: current }}
1069
+ render={({ control }) => (
1070
+ <FormField
1071
+ control={control}
1072
+ name="labels"
1073
+ label="Apply labels"
1074
+ render={({ field, labelId }) => (
1075
+ <CheckboxGroup
1076
+ values={field.value}
1077
+ aria-labelledby={labelId}
1078
+ onValuesChange={field.onChange}
1079
+ >
1080
+ <View className="gap-xxs">
1081
+ {offered.map((name) => (
1082
+ <Checkbox key={name} value={name} label={emojify(name)} />
1083
+ ))}
1084
+ </View>
1085
+ </CheckboxGroup>
1086
+ )}
1087
+ />
1088
+ )}
1089
+ onSubmit={async ({ labels }) => {
1090
+ const before = new Set(current);
1091
+ const after = new Set(labels);
1092
+ const edit: PullRequestEdit = {
1093
+ kind: "labels",
1094
+ add: labels.filter((name) => !before.has(name)),
1095
+ remove: current.filter((name) => !after.has(name)),
1096
+ };
1097
+ await editAndRefresh(edit);
1098
+ }}
1099
+ >
1100
+ {summary}
1101
+ </FormEditableSection>
1102
+ );
1103
+ }
1104
+
1105
+ /** The status, then GitHub's sidebar order: reviewers, assignees, labels. */
1106
+ function Sidebar({ detail, candidates, onEdited }: CandidatesProps): ReactNode {
1107
+ return (
1108
+ <View className="gap-xxl md:w-[296px] md:shrink-0">
1109
+ <Status detail={detail} onEdited={onEdited} />
1110
+
1111
+ <View className="gap-xs">
1112
+ <Reviewers
1113
+ detail={detail}
1114
+ candidates={candidates}
1115
+ onEdited={onEdited}
1116
+ />
1117
+ <ConvertToDraft detail={detail} onEdited={onEdited} />
1118
+ </View>
1119
+
1120
+ <Assignees detail={detail} candidates={candidates} onEdited={onEdited} />
1121
+
1122
+ <Labels detail={detail} candidates={candidates} onEdited={onEdited} />
1123
+ </View>
1124
+ );
1125
+ }
1126
+
1127
+ export type PullRequestTab =
1128
+ | "checks"
1129
+ | "commits"
1130
+ | "files"
1131
+ | "review"
1132
+ | "timeline";
1133
+
1134
+ export const isPullRequestTab = (value: unknown): value is PullRequestTab =>
1135
+ value === "checks" ||
1136
+ value === "commits" ||
1137
+ value === "timeline" ||
1138
+ value === "files" ||
1139
+ value === "review";
1140
+
1141
+ /** The timeline opens on the description, as GitHub's first comment. */
1142
+ function TimelineTab({
1143
+ detail,
1144
+ candidates,
1145
+ onEdited,
1146
+ }: CandidatesProps): ReactNode {
1147
+ return (
1148
+ <View className="flex-col gap-l md:flex-row md:items-start">
1149
+ <View className="min-w-0 gap-m md:flex-1">
1150
+ <PullRequestTimeline detail={detail} onEdited={onEdited} />
1151
+ </View>
1152
+ <Sidebar detail={detail} candidates={candidates} onEdited={onEdited} />
1153
+ </View>
1154
+ );
1155
+ }
1156
+
1157
+ /** Beside the tabs, so the merge and the reopen are in reach from every tab. */
1158
+ function PullRequestActions({ detail, onEdited }: EditableProps): ReactNode {
1159
+ const onEdit = useEditAndRefresh({ detail, onEdited });
1160
+ return (
1161
+ <View className="flex-row flex-wrap items-center gap-xs">
1162
+ <PullRequestReopen detail={detail} onEdit={onEdit} />
1163
+ <PullRequestMergeButton detail={detail} onEdit={onEdit} />
1164
+ </View>
1165
+ );
1166
+ }
1167
+
1168
+ export interface PullRequestPanelProps extends CandidatesProps {
1169
+ /** why the editors stay read-only, when GitHub refused what they offer */
1170
+ candidatesError: string | null;
1171
+ tab: PullRequestTab;
1172
+ onTabChange: (tab: PullRequestTab) => void;
1173
+ /** the commit whose diff is open on the Commits tab; absent: the tab holds it */
1174
+ openCommit?: string | null;
1175
+ onOpenCommitChange?: (oid: string | null) => void;
1176
+ }
1177
+
1178
+ /**
1179
+ * One pull request as GitHub reports it now, laid out as GitHub's own page —
1180
+ * the header, then Timeline (the description, a status box and a narrow
1181
+ * sidebar), Checks and Files changed — with the edits the operator makes
1182
+ * before asking for a review. Every edit is GitHub's to refuse, and the panel
1183
+ * re-reads the pull request after it, so nothing on screen is the request
1184
+ * echoed back.
1185
+ */
1186
+ export function PullRequestPanel({
1187
+ detail,
1188
+ candidates,
1189
+ candidatesError,
1190
+ onEdited,
1191
+ tab,
1192
+ onTabChange,
1193
+ openCommit,
1194
+ onOpenCommitChange,
1195
+ }: PullRequestPanelProps): ReactNode {
1196
+ const { pullRequest, errors } = detail;
1197
+ const checks = usePullRequestDataSource().usePart(
1198
+ pullRequest,
1199
+ "checks",
1200
+ ).value;
1201
+ return (
1202
+ <View className="gap-l">
1203
+ {[...new Set([...errors, ...(candidates?.errors ?? [])])].map((error) => (
1204
+ <WarningMessage key={error}>{error}</WarningMessage>
1205
+ ))}
1206
+ {candidatesError === null ? null : (
1207
+ <WarningMessage>
1208
+ {`Reviewers, assignees and labels cannot be edited: ${candidatesError}`}
1209
+ </WarningMessage>
1210
+ )}
1211
+ <Header detail={detail} onEdited={onEdited} />
1212
+ <ConflictWarning pullRequest={pullRequest} />
1213
+ <View className="flex-row flex-wrap items-center justify-between gap-sm">
1214
+ <Tabs
1215
+ aria-label="Pull request sections"
1216
+ value={tab}
1217
+ onValueChange={(next) => {
1218
+ if (isPullRequestTab(next)) onTabChange(next);
1219
+ }}
1220
+ >
1221
+ <Tab value="timeline" label="Conversation" />
1222
+ <Tab
1223
+ value="commits"
1224
+ label={`Commits (${String(pullRequest.commitsTotal)})`}
1225
+ />
1226
+ <Tab
1227
+ value="checks"
1228
+ label={
1229
+ checks === null
1230
+ ? "Checks"
1231
+ : `Checks (${String(checks.checksTotal)})`
1232
+ }
1233
+ />
1234
+ <Tab
1235
+ value="files"
1236
+ label={`Files changed (${String(pullRequest.changedFiles)})`}
1237
+ />
1238
+ <Tab value="review" label="Review" />
1239
+ </Tabs>
1240
+ <PullRequestActions detail={detail} onEdited={onEdited} />
1241
+ </View>
1242
+ {(() => {
1243
+ if (tab === "commits") {
1244
+ return (
1245
+ <PullRequestCommits
1246
+ detail={detail}
1247
+ openCommit={openCommit}
1248
+ onEdited={onEdited}
1249
+ onOpenCommitChange={onOpenCommitChange}
1250
+ />
1251
+ );
1252
+ }
1253
+ if (tab === "checks") return <ChecksTab pullRequest={pullRequest} />;
1254
+ if (tab === "files") {
1255
+ return <PullRequestFiles detail={detail} onEdited={onEdited} />;
1256
+ }
1257
+ if (tab === "review") {
1258
+ return <PullRequestReview detail={detail} onEdited={onEdited} />;
1259
+ }
1260
+ return (
1261
+ <TimelineTab
1262
+ detail={detail}
1263
+ candidates={candidates}
1264
+ onEdited={onEdited}
1265
+ />
1266
+ );
1267
+ })()}
1268
+ </View>
1269
+ );
1270
+ }