@reviewflow/ui 0.0.0-stage → 6.22.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 +101 -3
  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,1374 @@
1
+ import type {
2
+ DiffLineAnnotation,
3
+ FileDiffContentsLoader,
4
+ FileDiffMetadata,
5
+ SelectedLineRange,
6
+ } from "@pierre/diffs";
7
+ import { parsePatchFiles } from "@pierre/diffs";
8
+ import { FileDiff } from "@pierre/diffs/react";
9
+ import type {
10
+ DiffFileImpacts,
11
+ DiffGroup,
12
+ DiffImpactsResponse,
13
+ DiffReadingOrderResponse,
14
+ DiffWhitespace,
15
+ PullRequestDiffResponse,
16
+ PullRequestFile,
17
+ PullRequestFileViewed,
18
+ PullRequestRef,
19
+ PullRequestThread,
20
+ } from "@reviewflow/sdk";
21
+ import {
22
+ AccentScope,
23
+ Badge,
24
+ Box,
25
+ Button,
26
+ Checkbox,
27
+ Code,
28
+ Icon,
29
+ IconButton,
30
+ Popover,
31
+ PressableBox,
32
+ Text,
33
+ View,
34
+ } from "alouette";
35
+ import { CaretDownRegularIcon } from "alouette-icons/phosphor-icons/CaretDown";
36
+ import { CaretRightRegularIcon } from "alouette-icons/phosphor-icons/CaretRight";
37
+ import { ShareNetworkRegularIcon } from "alouette-icons/phosphor-icons/ShareNetwork";
38
+ import type { ComponentRef, ReactNode } from "react";
39
+ import {
40
+ memo,
41
+ useCallback,
42
+ useLayoutEffect,
43
+ useMemo,
44
+ useRef,
45
+ useState,
46
+ } from "react";
47
+ import type { PullRequestDataSource } from "../dataSource.ts";
48
+ import { usePullRequestDataSource } from "../dataSource.ts";
49
+ import type { PendingComment } from "../services/PullRequestReviewDraftProvider.tsx";
50
+ import { usePullRequestReviewDraft } from "../services/PullRequestReviewDraftProvider.tsx";
51
+ import type { DiffTheme } from "./branch-diff/diff-themes.ts";
52
+ import { diffThemeNames } from "./branch-diff/diff-themes.ts";
53
+ // metro picks the .web.tsx variant only for a specifier without extension
54
+ /* eslint-disable-next-line import-x/no-unresolved */ // oxlint-disable-next-line import/extensions
55
+ import { DiffWorkerPool } from "./branch-diff/diff-worker-pool";
56
+ import type { CommitFileAnchoring } from "./pull-request-commit-files.ts";
57
+ import type {
58
+ AnnotationGroup,
59
+ AnnotationItem,
60
+ EditPullRequest,
61
+ ReviewDraftActions,
62
+ } from "./pull-request-diff-annotations.tsx";
63
+ import {
64
+ ComposerAnnotation,
65
+ PendingAnnotation,
66
+ ThreadAnnotation,
67
+ } from "./pull-request-diff-annotations.tsx";
68
+ import type { PullRequestCollapse } from "./pull-request-diff-collapse.ts";
69
+ import type { PullRequestMovedOn } from "./pull-request-diff-data.ts";
70
+ import {
71
+ countImpacts,
72
+ maxImporterNames,
73
+ pluralOf,
74
+ usageLabel,
75
+ } from "./pull-request-diff-impacts.ts";
76
+ import type { DiffFileHighlight } from "./pull-request-diff-jump.ts";
77
+ import {
78
+ useDiffFileElements,
79
+ useDiffFileHighlight,
80
+ } from "./pull-request-diff-jump.ts";
81
+ import type { Composer } from "./pull-request-diff-lines.ts";
82
+ import {
83
+ anchorOfRange,
84
+ annotationSideOf,
85
+ isLineInPatch,
86
+ rightSideLines,
87
+ } from "./pull-request-diff-lines.ts";
88
+ import { revealTopOf } from "./scroll-parent.ts";
89
+
90
+ export type DiffStyle = "split" | "unified";
91
+
92
+ const fileKeyOf = (file: FileDiffMetadata): string => file.name;
93
+
94
+ interface FileReview {
95
+ viewed: PullRequestFileViewed;
96
+ onViewed: (viewed: boolean) => void;
97
+ threads: PullRequestThread[];
98
+ outdated: PullRequestThread[];
99
+ /** on lines changed since the diff's head: listed, not placed */
100
+ changedSince: PullRequestThread[];
101
+ /** on lines the patch does not carry once whitespace is hidden: listed, not placed */
102
+ hiddenThreads: PullRequestThread[];
103
+ hiddenPending: PendingComment[];
104
+ /** changed since the diff's head: a mark would be set on a version not shown */
105
+ viewedLocked: boolean;
106
+ pending: PendingComment[];
107
+ commentable: boolean;
108
+ /** the base of this diff is not the pull request's: only the new side's line numbers mean the same */
109
+ rightOnly: boolean;
110
+ /** the commit a new comment is anchored on */
111
+ commitId: string;
112
+ /** false on a single commit: no viewed mark, no pending review */
113
+ ofPullRequest: boolean;
114
+ /** false on the viewer's own pull request: no review to start */
115
+ reviewable: boolean;
116
+ /** on a commit read in its order; null otherwise */
117
+ anchoring: CommitFileAnchoring | null;
118
+ composer: Composer | null;
119
+ onOpenComposer: (composer: Composer) => void;
120
+ onCloseComposer: () => void;
121
+ onEdit: EditPullRequest;
122
+ draft: ReviewDraftActions;
123
+ }
124
+
125
+ const buildAnnotations = (
126
+ file: FileDiffMetadata,
127
+ review: FileReview | undefined,
128
+ ): DiffLineAnnotation<AnnotationGroup>[] => {
129
+ if (review === undefined) return [];
130
+ const groups = new Map<string, DiffLineAnnotation<AnnotationGroup>>();
131
+ const place = (
132
+ side: "LEFT" | "RIGHT",
133
+ lineNumber: number,
134
+ item: AnnotationItem,
135
+ ): void => {
136
+ const annotationSide = annotationSideOf(side);
137
+ const key = `${annotationSide}:${String(lineNumber)}`;
138
+ const existing = groups.get(key);
139
+ if (existing === undefined) {
140
+ groups.set(key, {
141
+ side: annotationSide,
142
+ lineNumber,
143
+ metadata: { items: [item] },
144
+ });
145
+ } else {
146
+ existing.metadata.items.push(item);
147
+ }
148
+ };
149
+
150
+ for (const thread of review.threads) {
151
+ if (thread.line !== null) {
152
+ place(thread.side, thread.line, { kind: "thread", thread });
153
+ }
154
+ }
155
+ for (const comment of review.pending) {
156
+ place(comment.side, comment.line, { kind: "pending", comment });
157
+ }
158
+ const { composer } = review;
159
+ if (composer?.fileKey === fileKeyOf(file)) {
160
+ place(composer.anchor.side, composer.anchor.line, {
161
+ kind: "composer",
162
+ anchor: composer.anchor,
163
+ suggestionLines: rightSideLines(file, composer.anchor),
164
+ });
165
+ }
166
+ return [...groups.values()];
167
+ };
168
+
169
+ const sameItem = (
170
+ a: AnnotationItem,
171
+ b: AnnotationItem | undefined,
172
+ ): boolean => {
173
+ if (a.kind === "thread") return b?.kind === "thread" && a.thread === b.thread;
174
+ if (a.kind === "pending") {
175
+ return b?.kind === "pending" && a.comment === b.comment;
176
+ }
177
+ return b?.kind === "composer" && a.anchor === b.anchor;
178
+ };
179
+
180
+ const sameAnnotations = (
181
+ a: readonly DiffLineAnnotation<AnnotationGroup>[],
182
+ b: readonly DiffLineAnnotation<AnnotationGroup>[],
183
+ ): boolean =>
184
+ a.length === b.length &&
185
+ a.every((group, index) => {
186
+ const other = b[index];
187
+ return (
188
+ other !== undefined &&
189
+ group.side === other.side &&
190
+ group.lineNumber === other.lineNumber &&
191
+ group.metadata.items.length === other.metadata.items.length &&
192
+ group.metadata.items.every((item, itemIndex) =>
193
+ sameItem(item, other.metadata.items[itemIndex]),
194
+ )
195
+ );
196
+ });
197
+
198
+ /**
199
+ * The previous annotations while they place the same threads, comments and
200
+ * composer: the per-file review is rebuilt at every render of the files, and
201
+ * pierre redraws a whole file for an annotations array it has not seen.
202
+ */
203
+ const useStableAnnotations = (
204
+ file: FileDiffMetadata,
205
+ next: DiffLineAnnotation<AnnotationGroup>[],
206
+ ): DiffLineAnnotation<AnnotationGroup>[] => {
207
+ const [stable, setStable] = useState({ file, annotations: next });
208
+ const reused =
209
+ stable.file === file && sameAnnotations(stable.annotations, next);
210
+ if (!reused) setStable({ file, annotations: next });
211
+ return reused ? stable.annotations : next;
212
+ };
213
+
214
+ /** Why a thread is listed above its file rather than placed on a line. */
215
+ type UnplacedKind = "changedSince" | "outdated" | "whitespace";
216
+
217
+ const commentsText = (count: number): string =>
218
+ `${String(count)} comment${count === 1 ? "" : "s"}`;
219
+
220
+ const unplacedLabel = (kind: UnplacedKind, count: number): string => {
221
+ if (kind === "changedSince") {
222
+ return `${commentsText(count)} on code changed since your review started`;
223
+ }
224
+ if (kind === "whitespace") {
225
+ return `${commentsText(count)} on lines hidden with whitespace changes`;
226
+ }
227
+ return `${String(count)} outdated comment${count === 1 ? "" : "s"}`;
228
+ };
229
+
230
+ const unplacedLineText = (
231
+ kind: UnplacedKind,
232
+ { path, line, originalLine }: PullRequestThread,
233
+ ): string => {
234
+ if (kind === "changedSince" && line !== null) {
235
+ return `On line ${String(line)} of the latest version`;
236
+ }
237
+ if (kind === "whitespace" && line !== null) {
238
+ return `On line ${String(line)} of ${path}`;
239
+ }
240
+ if (originalLine === null) return "Line no longer in the diff";
241
+ return `Was on line ${String(originalLine)}`;
242
+ };
243
+
244
+ interface UnplacedThreadsProps {
245
+ kind: UnplacedKind;
246
+ threads: PullRequestThread[];
247
+ onEdit: EditPullRequest;
248
+ }
249
+
250
+ function UnplacedThreads({
251
+ kind,
252
+ threads,
253
+ onEdit,
254
+ }: UnplacedThreadsProps): ReactNode {
255
+ const [open, setOpen] = useState(false);
256
+ if (threads.length === 0) return null;
257
+ const count = threads.reduce(
258
+ (total, thread) => total + thread.commentsTotal,
259
+ 0,
260
+ );
261
+ return (
262
+ <View className="gap-xs">
263
+ <View className="flex-row items-center gap-xs">
264
+ <Button
265
+ size="sm"
266
+ accent="neutral"
267
+ variant="soft"
268
+ text={unplacedLabel(kind, count)}
269
+ onPress={() => {
270
+ setOpen(!open);
271
+ }}
272
+ />
273
+ </View>
274
+ {open
275
+ ? threads.map((thread) => (
276
+ <View key={thread.id} className="gap-xxs">
277
+ <Text className="text-xs text-muted">
278
+ {unplacedLineText(kind, thread)}
279
+ </Text>
280
+ <ThreadAnnotation thread={thread} onEdit={onEdit} />
281
+ </View>
282
+ ))
283
+ : null}
284
+ </View>
285
+ );
286
+ }
287
+
288
+ interface HiddenByWhitespaceProps {
289
+ threads: PullRequestThread[];
290
+ pending: PendingComment[];
291
+ onEdit: EditPullRequest;
292
+ draft: ReviewDraftActions;
293
+ }
294
+
295
+ /**
296
+ * What hiding whitespace took the line of. A pending comment is always open:
297
+ * it goes out with the review, so it is never out of sight.
298
+ */
299
+ function HiddenByWhitespace({
300
+ threads,
301
+ pending,
302
+ onEdit,
303
+ draft,
304
+ }: HiddenByWhitespaceProps): ReactNode {
305
+ return (
306
+ <>
307
+ <UnplacedThreads kind="whitespace" threads={threads} onEdit={onEdit} />
308
+ {pending.map((comment) => (
309
+ <View key={comment.key} className="gap-xxs">
310
+ <Text className="text-xs text-muted">
311
+ {`Pending, on line ${String(comment.line)} of ${comment.path}, hidden with whitespace changes`}
312
+ </Text>
313
+ <PendingAnnotation comment={comment} draft={draft} />
314
+ </View>
315
+ ))}
316
+ </>
317
+ );
318
+ }
319
+
320
+ interface ViewedBadgeProps {
321
+ viewed: PullRequestFileViewed;
322
+ locked: boolean;
323
+ }
324
+
325
+ /** Also says why the viewed checkbox is disabled, which alouette's Checkbox cannot. */
326
+ interface HeaderBadgeProps {
327
+ accent: "info" | "warning";
328
+ children: string;
329
+ }
330
+
331
+ /** Wrapped: a Badge aligns itself to the start, so its own box is what the header row centers. */
332
+ function HeaderBadge({ accent, children }: HeaderBadgeProps): ReactNode {
333
+ return (
334
+ <View>
335
+ <Badge accent={accent} size="sm">
336
+ {children}
337
+ </Badge>
338
+ </View>
339
+ );
340
+ }
341
+
342
+ /** Why a file of a commit read in its order has fewer review gestures. */
343
+ function AnchoringBadge({
344
+ anchoring,
345
+ }: {
346
+ anchoring: CommitFileAnchoring | null;
347
+ }): ReactNode {
348
+ if (anchoring === "commit") {
349
+ return (
350
+ <HeaderBadge accent="info">Changed again in a later commit</HeaderBadge>
351
+ );
352
+ }
353
+ if (anchoring === "head") {
354
+ return (
355
+ <HeaderBadge accent="info">Also changed in an earlier commit</HeaderBadge>
356
+ );
357
+ }
358
+ return null;
359
+ }
360
+
361
+ function ViewedBadge({ viewed, locked }: ViewedBadgeProps): ReactNode {
362
+ if (locked) {
363
+ return (
364
+ <HeaderBadge accent="warning">
365
+ Changed since your review started
366
+ </HeaderBadge>
367
+ );
368
+ }
369
+ if (viewed === "dismissed") {
370
+ return <HeaderBadge accent="warning">Changed since viewed</HeaderBadge>;
371
+ }
372
+ return null;
373
+ }
374
+
375
+ type DiffSides = NonNullable<PullRequestDiffResponse["sides"]>;
376
+
377
+ /**
378
+ * What lets pierre unfold the unchanged lines between hunks: a patch carries
379
+ * only its hunks, and the whole of both sides is read on the first unfold.
380
+ */
381
+ const fileLoaderOf =
382
+ (
383
+ ref: PullRequestRef,
384
+ sides: DiffSides,
385
+ readFileAtSha: PullRequestDataSource["readFileAtSha"],
386
+ ): FileDiffContentsLoader =>
387
+ async (file) => {
388
+ const oldName = file.prevName ?? file.name;
389
+ const [oldContents, newContents] = await Promise.all([
390
+ file.type === "rename-pure"
391
+ ? null
392
+ : readFileAtSha(ref, sides.old, oldName),
393
+ readFileAtSha(ref, sides.new, file.name),
394
+ ]);
395
+ const newFile = { name: file.name, contents: newContents };
396
+ if (oldContents === null) return { oldFile: null, newFile };
397
+ return { oldFile: { name: oldName, contents: oldContents }, newFile };
398
+ };
399
+
400
+ /** Pierre's own rule pins a file header at `top: 0`, under the diff's toolbar. */
401
+ const stickyHeaderBelow = (top: number): string =>
402
+ `[data-diffs-header][data-sticky] { top: ${String(top)}px; }`;
403
+
404
+ /**
405
+ * alouette's global scrollbar-width and scrollbar-color are inherited through
406
+ * the shadow root, and a browser then ignores pierre's ::-webkit-scrollbar
407
+ * rules: the vertical gutter pierre zeroes would show as a strip beside the
408
+ * code, the horizontal one as alouette's opaque track under it. Firefox has no
409
+ * such rules and gets pierre's own scrollbar-color, so it is left alone.
410
+ */
411
+ /** The caret lined up with the unfold buttons below it, inset as pierre insets its separators. */
412
+ const headerInset =
413
+ "[data-diffs-header=default] { padding-inline: var(--diffs-gap-inline, var(--diffs-gap-fallback)); }";
414
+
415
+ /**
416
+ * alouette's `shadow-s`, the one its surfaces carry: Tailwind inlines
417
+ * `--shadow-s` into the utility, so only its colors exist as variables here.
418
+ */
419
+ const surfaceShadow =
420
+ "inset 0 1px 2px var(--color-shadow-highlight, #ffffff40), 0 1px 2px var(--color-dark-shadow, #00000040), 0 2px 4px var(--color-soft-shadow, #00000025)";
421
+
422
+ /** alouette's `rounded-sm`, the radius of its cards (`surface`). */
423
+ const fileRadius = "1rem";
424
+
425
+ /**
426
+ * Rounded and raised as an alouette card. `clip` rather than `hidden`: it
427
+ * makes no scroll container, which would stop the header sticking under the
428
+ * toolbar.
429
+ */
430
+ const roundedFile = `:host { border-radius: ${fileRadius}; overflow: clip; box-shadow: ${surfaceShadow}; }`;
431
+
432
+ const pierreScrollbars =
433
+ "@supports not (-moz-appearance: none) { [data-code] { scrollbar-width: auto; scrollbar-color: auto; } }";
434
+
435
+ /**
436
+ * Closing a file read down to its end would leave the reader on the files
437
+ * after it, some of them never seen: its top is brought back under the
438
+ * toolbar. Done before closing, since a file's top does not move when it
439
+ * closes, so it does not depend on when pierre applies the change.
440
+ */
441
+ const revealFileTop = (file: HTMLElement | null, stickyTop: number): void => {
442
+ if (file !== null) revealTopOf(file, stickyTop);
443
+ };
444
+
445
+ interface DiffFileProps {
446
+ file: FileDiffMetadata;
447
+ diffStyle: DiffStyle;
448
+ theme: DiffTheme;
449
+ themeType: "dark" | "light";
450
+ wordWrap: boolean;
451
+ stickyTop: number;
452
+ collapsed: boolean;
453
+ onToggleCollapsed: () => void;
454
+ loadFiles: FileDiffContentsLoader | undefined;
455
+ /** where the reading order starts a flow */
456
+ entry: boolean;
457
+ /** absent: not analyzed, or no touched export used outside the diff */
458
+ impacts?: DiffFileImpacts;
459
+ review?: FileReview;
460
+ }
461
+
462
+ interface FileImpactsListProps {
463
+ impacts: DiffFileImpacts;
464
+ }
465
+
466
+ /**
467
+ * Each touched export of the file and the files outside the diff that use it;
468
+ * past `maxImporterNames` files in all, each export's counts only.
469
+ */
470
+ function FileImpactsList({ impacts }: FileImpactsListProps): ReactNode {
471
+ const counts = countImpacts([impacts]);
472
+ const showNames = counts.files + counts.tests <= maxImporterNames;
473
+ return (
474
+ <View className="gap-sm p-xs">
475
+ <Text className="font-body-bold text-sm text-sharp">
476
+ {`${pluralOf(counts.exports, "touched export")} used outside the diff`}
477
+ </Text>
478
+ <View className="gap-sm">
479
+ {impacts.exports.map((impact) => (
480
+ <View key={impact.name} className="gap-xxs">
481
+ <View className="flex-row flex-wrap items-center gap-xs">
482
+ <Code className="text-sm">{impact.name}</Code>
483
+ {impact.removed ? (
484
+ <Badge size="sm" accent="warning">
485
+ removed
486
+ </Badge>
487
+ ) : null}
488
+ {showNames ? null : (
489
+ <Text className="text-xs text-muted">
490
+ {`used in ${usageLabel(countImpacts([{ path: impacts.path, exports: [impact] }]))}`}
491
+ </Text>
492
+ )}
493
+ </View>
494
+ {showNames
495
+ ? impact.importers.map(({ path, test }) => (
496
+ <Text key={path} className="pl-m text-xs">
497
+ {path}
498
+ {test ? (
499
+ <Text className="text-xs text-muted"> · test</Text>
500
+ ) : null}
501
+ </Text>
502
+ ))
503
+ : null}
504
+ </View>
505
+ ))}
506
+ {showNames ? null : (
507
+ <Text className="text-xs text-muted">
508
+ {`Files are left out past ${String(maxImporterNames)}.`}
509
+ </Text>
510
+ )}
511
+ </View>
512
+ </View>
513
+ );
514
+ }
515
+
516
+ interface FileHeaderMetadataProps {
517
+ entry: boolean;
518
+ /** what the touched exports of the file are used by; undefined: none known, or none touched */
519
+ impacts: DiffFileImpacts | undefined;
520
+ review: FileReview | undefined;
521
+ onViewedPress: (viewed: boolean) => void;
522
+ }
523
+
524
+ /**
525
+ * The header's "Used in" count, opening the touched exports and their users:
526
+ * pierre has no slot between the header and the diff, and above or below the
527
+ * file it reads as another file's.
528
+ */
529
+ function FileImpactsButton({
530
+ impacts,
531
+ }: {
532
+ impacts: DiffFileImpacts;
533
+ }): ReactNode {
534
+ const anchorRef = useRef<ComponentRef<typeof View>>(null);
535
+ const [open, setOpen] = useState(false);
536
+ const counts = countImpacts([impacts]);
537
+ return (
538
+ <>
539
+ <View ref={anchorRef}>
540
+ <PressableBox
541
+ variant="soft"
542
+ accent="info"
543
+ aria-haspopup="dialog"
544
+ aria-expanded={open}
545
+ className="flex-row items-center gap-xxs rounded-full border border-border-muted px-xs py-xxs"
546
+ onPress={() => {
547
+ setOpen(!open);
548
+ }}
549
+ >
550
+ <AccentScope accent="info">
551
+ <Icon
552
+ icon={<ShareNetworkRegularIcon />}
553
+ size={12}
554
+ className="text-accent"
555
+ />
556
+ <Text className="font-body-bold text-xs text-accent">
557
+ {`Used in ${usageLabel(counts)}`}
558
+ </Text>
559
+ </AccentScope>
560
+ </PressableBox>
561
+ </View>
562
+ <Popover
563
+ open={open}
564
+ anchorRef={anchorRef}
565
+ align="end"
566
+ width="content"
567
+ aria-label={`Users of ${impacts.path}`}
568
+ onClose={() => {
569
+ setOpen(false);
570
+ }}
571
+ >
572
+ {/* Popover takes no offset: the gap keeps the panel off the badge */}
573
+ <View className="pt-xs">
574
+ <Box className="surface-popover max-h-[60vh] max-w-[640px] overflow-y-auto">
575
+ <FileImpactsList impacts={impacts} />
576
+ </Box>
577
+ </View>
578
+ </Popover>
579
+ </>
580
+ );
581
+ }
582
+
583
+ function FileHeaderMetadata({
584
+ entry,
585
+ impacts,
586
+ review,
587
+ onViewedPress,
588
+ }: FileHeaderMetadataProps): ReactNode {
589
+ return (
590
+ <View className="flex-row items-center gap-xs">
591
+ {entry ? <HeaderBadge accent="info">Entry point</HeaderBadge> : null}
592
+ {impacts === undefined ? null : <FileImpactsButton impacts={impacts} />}
593
+ {impacts?.exports.some(({ removed }) => removed) === true ? (
594
+ <HeaderBadge accent="warning">Removed export</HeaderBadge>
595
+ ) : null}
596
+ <AnchoringBadge anchoring={review?.anchoring ?? null} />
597
+ {review?.ofPullRequest === true ? (
598
+ <>
599
+ <ViewedBadge viewed={review.viewed} locked={review.viewedLocked} />
600
+ <Checkbox
601
+ label="Viewed"
602
+ checked={review.viewed === "viewed"}
603
+ disabled={review.viewedLocked}
604
+ onValueChange={onViewedPress}
605
+ />
606
+ </>
607
+ ) : null}
608
+ </View>
609
+ );
610
+ }
611
+
612
+ /**
613
+ * The header of the file the reader just went to from the overview, outlined
614
+ * and tinted so the eye finds it, open or closed, then faded. The header only:
615
+ * an outline over the whole file would recolor the diff's change bars. Inset
616
+ * shadows, since they paint over pierre's opaque sticky ground and can
617
+ * transition, where a background image cannot.
618
+ */
619
+ const headerHighlight = (
620
+ highlight: DiffFileHighlight | null,
621
+ collapsed: boolean,
622
+ ): string => {
623
+ if (highlight === null) return "";
624
+ const shadows =
625
+ highlight.phase === "shown"
626
+ ? `inset 0 0 0 2px ${highlight.color}, inset 0 0 0 999px color-mix(in srgb, ${highlight.color} 14%, transparent)`
627
+ : "inset 0 0 0 2px transparent, inset 0 0 0 999px transparent";
628
+ // the file's own rounding (`roundedFile`): the whole file when closed, its top when open
629
+ const radius = collapsed ? fileRadius : `${fileRadius} ${fileRadius} 0 0`;
630
+ return `[data-diffs-header] { border-radius: ${radius}; box-shadow: ${shadows}; transition: box-shadow var(--motion-duration-collapse) ease-out; }`;
631
+ };
632
+
633
+ function DiffFile({
634
+ file,
635
+ diffStyle,
636
+ theme,
637
+ themeType,
638
+ wordWrap,
639
+ stickyTop,
640
+ collapsed,
641
+ onToggleCollapsed,
642
+ loadFiles,
643
+ entry,
644
+ impacts,
645
+ review,
646
+ }: DiffFileProps): ReactNode {
647
+ const fileKey = fileKeyOf(file);
648
+ const lineAnnotations = useStableAnnotations(
649
+ file,
650
+ buildAnnotations(file, review),
651
+ );
652
+ const commentable = review?.commentable === true;
653
+ // pierre compares its options with ===: a handler made at each render would
654
+ // redraw the file every time
655
+ const openComposerAt = useRef<(range: SelectedLineRange) => void>(() => {});
656
+ useLayoutEffect(() => {
657
+ openComposerAt.current = (range) => {
658
+ if (review === undefined) return;
659
+ const anchor = anchorOfRange(file.name, range);
660
+ const touchesOldSide =
661
+ anchor.side === "LEFT" || anchor.startSide === "LEFT";
662
+ if (review.rightOnly && touchesOldSide) return;
663
+ review.onOpenComposer({ fileKey, anchor, range });
664
+ };
665
+ });
666
+ const onGutterUtilityClick = useCallback((range: SelectedLineRange) => {
667
+ openComposerAt.current(range);
668
+ }, []);
669
+ const fileRef = useRef<ComponentRef<typeof View>>(null);
670
+ const fileElements = useDiffFileElements();
671
+ const highlight = useDiffFileHighlight(fileKey);
672
+ const setFileRef = useCallback(
673
+ (node: ComponentRef<typeof View>) => {
674
+ fileRef.current = node;
675
+ const unregister = fileElements?.register(
676
+ fileKey,
677
+ node as unknown as HTMLElement,
678
+ );
679
+ return () => {
680
+ fileRef.current = null;
681
+ unregister?.();
682
+ };
683
+ },
684
+ [fileElements, fileKey],
685
+ );
686
+ const revealBeforeClosing = (): void => {
687
+ if (collapsed) return;
688
+ revealFileTop(fileRef.current as unknown as HTMLElement | null, stickyTop);
689
+ };
690
+
691
+ return (
692
+ <View ref={setFileRef} className="gap-xs">
693
+ {review === undefined ? null : (
694
+ <>
695
+ <UnplacedThreads
696
+ kind="outdated"
697
+ threads={review.outdated}
698
+ onEdit={review.onEdit}
699
+ />
700
+ <UnplacedThreads
701
+ kind="changedSince"
702
+ threads={review.changedSince}
703
+ onEdit={review.onEdit}
704
+ />
705
+ <HiddenByWhitespace
706
+ threads={review.hiddenThreads}
707
+ pending={review.hiddenPending}
708
+ draft={review.draft}
709
+ onEdit={review.onEdit}
710
+ />
711
+ </>
712
+ )}
713
+ <FileDiff<AnnotationGroup>
714
+ fileDiff={file}
715
+ lineAnnotations={lineAnnotations}
716
+ selectedLines={
717
+ review?.composer?.fileKey === fileKey ? review.composer.range : null
718
+ }
719
+ options={{
720
+ diffStyle,
721
+ theme: diffThemeNames(theme),
722
+ themeType,
723
+ overflow: wordWrap ? "wrap" : "scroll",
724
+ collapsed,
725
+ stickyHeader: true,
726
+ unsafeCSS: `${stickyHeaderBelow(stickyTop)} ${headerInset} ${roundedFile} ${pierreScrollbars} ${headerHighlight(highlight, collapsed)}`,
727
+ loadDiffFiles: loadFiles,
728
+ enableGutterUtility: commentable,
729
+ onGutterUtilityClick: commentable ? onGutterUtilityClick : undefined,
730
+ }}
731
+ renderHeaderPrefix={() => (
732
+ <IconButton
733
+ icon={
734
+ collapsed ? <CaretRightRegularIcon /> : <CaretDownRegularIcon />
735
+ }
736
+ size="sm"
737
+ variant="soft"
738
+ accent="neutral"
739
+ aria-label={collapsed ? `Open ${file.name}` : `Close ${file.name}`}
740
+ aria-expanded={!collapsed}
741
+ onPress={() => {
742
+ revealBeforeClosing();
743
+ onToggleCollapsed();
744
+ }}
745
+ />
746
+ )}
747
+ renderHeaderMetadata={
748
+ !entry &&
749
+ impacts === undefined &&
750
+ review?.ofPullRequest !== true &&
751
+ (review?.anchoring ?? null) === null
752
+ ? undefined
753
+ : () => (
754
+ <FileHeaderMetadata
755
+ entry={entry}
756
+ impacts={impacts}
757
+ review={review}
758
+ onViewedPress={(viewed) => {
759
+ if (viewed) revealBeforeClosing();
760
+ review?.onViewed(viewed);
761
+ }}
762
+ />
763
+ )
764
+ }
765
+ renderAnnotation={({ metadata }) => (
766
+ <View>
767
+ {metadata.items.map((item) => {
768
+ if (review === undefined) return null;
769
+ if (item.kind === "thread") {
770
+ return (
771
+ <ThreadAnnotation
772
+ key={item.thread.id}
773
+ thread={item.thread}
774
+ onEdit={review.onEdit}
775
+ />
776
+ );
777
+ }
778
+ if (item.kind === "pending") {
779
+ return (
780
+ <PendingAnnotation
781
+ key={item.comment.key}
782
+ comment={item.comment}
783
+ draft={review.draft}
784
+ />
785
+ );
786
+ }
787
+ return (
788
+ <ComposerAnnotation
789
+ key="composer"
790
+ anchor={item.anchor}
791
+ suggestionLines={item.suggestionLines}
792
+ commitId={review.commitId}
793
+ draft={
794
+ review.ofPullRequest && review.reviewable
795
+ ? review.draft
796
+ : null
797
+ }
798
+ onEdit={review.onEdit}
799
+ onClose={review.onCloseComposer}
800
+ />
801
+ );
802
+ })}
803
+ </View>
804
+ )}
805
+ />
806
+ </View>
807
+ );
808
+ }
809
+
810
+ interface FileItem {
811
+ file: FileDiffMetadata;
812
+ entry: boolean;
813
+ /** its tests and stories, read below it and closed until opened */
814
+ attached: FileDiffMetadata[];
815
+ }
816
+
817
+ interface FileGroup {
818
+ name: string;
819
+ items: FileItem[];
820
+ }
821
+
822
+ /** How a file is shown: `attached` below the file it covers. */
823
+ interface FilePlacement {
824
+ entry: boolean;
825
+ attached: boolean;
826
+ }
827
+
828
+ const plainItem = (file: FileDiffMetadata): FileItem => ({
829
+ file,
830
+ entry: false,
831
+ attached: [],
832
+ });
833
+
834
+ /** The daemon's groups, in its order; a file it did not group goes under the root. */
835
+ const groupFiles = (
836
+ files: FileDiffMetadata[],
837
+ groups: DiffGroup[],
838
+ ): FileGroup[] => {
839
+ const groupOf = new Map<string, string>();
840
+ for (const { name, paths } of groups) {
841
+ for (const path of paths) groupOf.set(path, name);
842
+ }
843
+ const grouped = new Map<string, FileDiffMetadata[]>(
844
+ groups.map(({ name }) => [name, []]),
845
+ );
846
+ for (const file of files) {
847
+ const name = groupOf.get(file.name) ?? "";
848
+ const group = grouped.get(name);
849
+ if (group === undefined) grouped.set(name, [file]);
850
+ else group.push(file);
851
+ }
852
+ return [...grouped]
853
+ .filter(([, groupFilesList]) => groupFilesList.length > 0)
854
+ .map(([name, groupFilesList]) => ({
855
+ name,
856
+ items: groupFilesList.map(plainItem),
857
+ }));
858
+ };
859
+
860
+ /**
861
+ * The reading order's groups and files; a file of the patch it does not name
862
+ * (read on another range) goes after them, in its package's group.
863
+ */
864
+ const orderFiles = (
865
+ files: FileDiffMetadata[],
866
+ groups: DiffGroup[],
867
+ readingOrder: DiffReadingOrderResponse,
868
+ ): FileGroup[] => {
869
+ const byName = new Map(files.map((file) => [file.name, file]));
870
+ const placed = new Set<string>();
871
+ const take = (path: string): FileDiffMetadata[] => {
872
+ const file = byName.get(path);
873
+ if (file === undefined || placed.has(path)) return [];
874
+ placed.add(path);
875
+ return [file];
876
+ };
877
+ const ordered: FileGroup[] = readingOrder.groups.map(
878
+ ({ name, files: paths }) => ({
879
+ name,
880
+ items: paths.flatMap(({ path, entry, attached }) =>
881
+ take(path).map((file) => ({
882
+ file,
883
+ entry,
884
+ attached: attached.flatMap(take),
885
+ })),
886
+ ),
887
+ }),
888
+ );
889
+ for (const rest of groupFiles(
890
+ files.filter((file) => !placed.has(file.name)),
891
+ groups,
892
+ )) {
893
+ const group = ordered.find(({ name }) => name === rest.name);
894
+ if (group === undefined) ordered.push(rest);
895
+ else group.items.push(...rest.items);
896
+ }
897
+ return ordered.filter(({ items }) => items.length > 0);
898
+ };
899
+
900
+ const filesOf = ({ file, attached }: FileItem): FileDiffMetadata[] => [
901
+ file,
902
+ ...attached,
903
+ ];
904
+
905
+ interface FileGroupsProps {
906
+ groups: FileGroup[];
907
+ renderFile: (file: FileDiffMetadata, placement: FilePlacement) => ReactNode;
908
+ }
909
+
910
+ function FileGroups({ groups, renderFile }: FileGroupsProps): ReactNode {
911
+ return (
912
+ <View className="gap-xxl">
913
+ {groups.map((group) => {
914
+ const count = group.items.flatMap(filesOf).length;
915
+ return (
916
+ <View key={group.name} className="gap-sm">
917
+ <View className="flex-row items-baseline gap-sm">
918
+ <Text
919
+ role="heading"
920
+ aria-level={2}
921
+ className="font-heading-bold text-xl"
922
+ >
923
+ {group.name === "" ? "Root" : group.name}
924
+ </Text>
925
+ <Text className="text-sm text-muted">
926
+ {count === 1
927
+ ? "1 changed file"
928
+ : `${String(count)} changed files`}
929
+ </Text>
930
+ </View>
931
+ <View className="gap-m">
932
+ {group.items.map(({ file, entry, attached }) => (
933
+ <View key={fileKeyOf(file)} className="gap-xs">
934
+ {renderFile(file, { entry, attached: false })}
935
+ {attached.length === 0 ? null : (
936
+ <View className="ml-l gap-xs">
937
+ {attached.map((test) => (
938
+ <View key={fileKeyOf(test)}>
939
+ {renderFile(test, { entry: false, attached: true })}
940
+ </View>
941
+ ))}
942
+ </View>
943
+ )}
944
+ </View>
945
+ ))}
946
+ </View>
947
+ </View>
948
+ );
949
+ })}
950
+ </View>
951
+ );
952
+ }
953
+
954
+ export interface PullRequestDiffReview {
955
+ /** the head a comment is anchored on: the diff's, not the header's last read */
956
+ headSha: string;
957
+ /** the pull request's threads on the code, null while they are read */
958
+ threads: PullRequestThread[] | null;
959
+ /** the files with the viewer's viewed marks, null while they are read */
960
+ files: PullRequestFile[] | null;
961
+ /** the diff's head is the pull request's: a comment can be anchored on it */
962
+ commentable: boolean;
963
+ rightOnly: boolean;
964
+ /**
965
+ * Set on one commit's own diff: comments are anchored on it, alone (a
966
+ * review is sent on the head), on the new side only; the pull request's
967
+ * threads, pending comments and viewed marks are not shown.
968
+ */
969
+ commitOid?: string;
970
+ /**
971
+ * Set on one commit of the pull request read in its order: each of its
972
+ * paths with how its lines relate to the pull request's diff. A file is
973
+ * reviewed as on `commitOid`'s diff, on the new side only, or as on the
974
+ * pull request's diff.
975
+ */
976
+ commitFiles?: {
977
+ oid: string;
978
+ anchorings: ReadonlyMap<string, CommitFileAnchoring>;
979
+ };
980
+ /**
981
+ * false on the viewer's own pull request: GitHub lets its author comment
982
+ * but not approve or request changes, so a comment goes out alone and there
983
+ * is no review to start. Unset, reviewable.
984
+ */
985
+ reviewable?: boolean;
986
+ /** set while the diff shows a range the pull request has left */
987
+ movedOn?: PullRequestMovedOn | null;
988
+ onEdit: EditPullRequest;
989
+ /** resolves once GitHub answered and the screen read again; never rejects */
990
+ onViewed: (path: string, viewed: boolean) => Promise<void>;
991
+ }
992
+
993
+ const changedSinceShown = (
994
+ movedOn: PullRequestMovedOn | null | undefined,
995
+ path: string,
996
+ ): boolean =>
997
+ movedOn != null && (movedOn.paths === null || movedOn.paths.has(path));
998
+
999
+ /** Its line, read on the pull request's range, may name other code here. */
1000
+ const isDisplaced = (
1001
+ movedOn: PullRequestMovedOn | null | undefined,
1002
+ thread: PullRequestThread,
1003
+ ): boolean =>
1004
+ changedSinceShown(movedOn, thread.path) ||
1005
+ (movedOn?.baseMoved === true && thread.side === "LEFT");
1006
+
1007
+ const viewedStateOf = (
1008
+ pressed: boolean | undefined,
1009
+ known: PullRequestFileViewed | undefined,
1010
+ ): PullRequestFileViewed => {
1011
+ if (pressed === undefined) return known ?? "unviewed";
1012
+ return pressed ? "viewed" : "unviewed";
1013
+ };
1014
+
1015
+ interface ReviewedFilesProps {
1016
+ groups: FileGroup[];
1017
+ review: PullRequestDiffReview;
1018
+ whitespace: DiffWhitespace;
1019
+ diffStyle: DiffStyle;
1020
+ theme: DiffTheme;
1021
+ themeType: "dark" | "light";
1022
+ wordWrap: boolean;
1023
+ stickyTop: number;
1024
+ collapse: PullRequestCollapse;
1025
+ loadFiles: FileDiffContentsLoader | undefined;
1026
+ impactsOf: (path: string) => DiffFileImpacts | undefined;
1027
+ commitComposer: Composer | null;
1028
+ onCommitComposerChange: (composer: Composer | null) => void;
1029
+ }
1030
+
1031
+ function ReviewedFiles({
1032
+ groups,
1033
+ review,
1034
+ whitespace,
1035
+ diffStyle,
1036
+ theme,
1037
+ themeType,
1038
+ wordWrap,
1039
+ stickyTop,
1040
+ collapse,
1041
+ loadFiles,
1042
+ impactsOf,
1043
+ commitComposer,
1044
+ onCommitComposerChange,
1045
+ }: ReviewedFilesProps): ReactNode {
1046
+ const draft = usePullRequestReviewDraft();
1047
+ const anchoringOf = (path: string): CommitFileAnchoring => {
1048
+ if (review.commitOid !== undefined) return "commit";
1049
+ if (review.commitFiles === undefined) return "pullRequest";
1050
+ return review.commitFiles.anchorings.get(path) ?? "commit";
1051
+ };
1052
+ const isOfPullRequest = (path: string): boolean =>
1053
+ anchoringOf(path) !== "commit";
1054
+ const isRightOnly = (path: string): boolean =>
1055
+ review.rightOnly || anchoringOf(path) !== "pullRequest";
1056
+ const openComposerOf =
1057
+ (ofPullRequest: boolean) =>
1058
+ (next: Composer): void => {
1059
+ if (ofPullRequest) {
1060
+ onCommitComposerChange(null);
1061
+ draft.openComposer(next);
1062
+ return;
1063
+ }
1064
+ // one comment written at a time on a commit read in its order
1065
+ if (review.commitFiles !== undefined) draft.closeComposer();
1066
+ onCommitComposerChange(next);
1067
+ };
1068
+ const closeComposerOf = (ofPullRequest: boolean) =>
1069
+ ofPullRequest
1070
+ ? draft.closeComposer
1071
+ : () => {
1072
+ onCommitComposerChange(null);
1073
+ };
1074
+ const [optimistic, setOptimistic] = useState<Record<string, boolean>>({});
1075
+
1076
+ const threads = review.threads ?? [];
1077
+ const viewedOf = new Map(
1078
+ (review.files ?? []).map(({ path, viewed }) => [path, viewed]),
1079
+ );
1080
+
1081
+ const draftActions: ReviewDraftActions = {
1082
+ hasPending: draft.comments.length > 0,
1083
+ add: draft.add,
1084
+ update: draft.update,
1085
+ remove: draft.remove,
1086
+ };
1087
+
1088
+ const onSide = ({
1089
+ path,
1090
+ side,
1091
+ }: {
1092
+ path: string;
1093
+ side: "LEFT" | "RIGHT";
1094
+ }): boolean => !isRightOnly(path) || side === "RIGHT";
1095
+ const pendingOfPullRequest = draft.comments.filter(
1096
+ (comment) => isOfPullRequest(comment.path) && onSide(comment),
1097
+ );
1098
+ /**
1099
+ * `git diff -w` drops a file whose every change is whitespace, so what is
1100
+ * anchored in it has no file to be listed above.
1101
+ */
1102
+ const shownPaths = new Set(
1103
+ groups.flatMap(({ items }) =>
1104
+ items.flatMap(filesOf).map(({ name }) => name),
1105
+ ),
1106
+ );
1107
+ const inHiddenFile = ({ path }: { path: string }): boolean =>
1108
+ whitespace === "hide" && !shownPaths.has(path);
1109
+ const isHiddenLine = (
1110
+ file: FileDiffMetadata,
1111
+ { side, line }: { side: "LEFT" | "RIGHT"; line: number | null },
1112
+ ): boolean =>
1113
+ whitespace === "hide" && line !== null && !isLineInPatch(file, side, line);
1114
+
1115
+ const markViewed = (path: string, viewed: boolean): void => {
1116
+ collapse.set(path, viewed);
1117
+ setOptimistic((previous) => ({ ...previous, [path]: viewed }));
1118
+ review
1119
+ .onViewed(path, viewed)
1120
+ .then(() => {
1121
+ setOptimistic((previous) => {
1122
+ const { [path]: _settled, ...rest } = previous;
1123
+ return rest;
1124
+ });
1125
+ })
1126
+ .catch(() => {
1127
+ // onViewed reports its own failure and does not reject
1128
+ });
1129
+ };
1130
+
1131
+ return (
1132
+ <View className="gap-m">
1133
+ <HiddenByWhitespace
1134
+ threads={threads.filter(
1135
+ (thread) =>
1136
+ isOfPullRequest(thread.path) &&
1137
+ !thread.outdated &&
1138
+ thread.line !== null &&
1139
+ onSide(thread) &&
1140
+ inHiddenFile(thread),
1141
+ )}
1142
+ pending={pendingOfPullRequest.filter(inHiddenFile)}
1143
+ draft={draftActions}
1144
+ onEdit={review.onEdit}
1145
+ />
1146
+ <FileGroups
1147
+ groups={groups}
1148
+ renderFile={(file, { entry, attached }) => {
1149
+ const path = file.name;
1150
+ const anchoring = anchoringOf(path);
1151
+ const ofPullRequest = anchoring !== "commit";
1152
+ const rightOnly = isRightOnly(path);
1153
+ const pressed = optimistic[path];
1154
+ const viewed = ofPullRequest
1155
+ ? viewedStateOf(pressed, viewedOf.get(path))
1156
+ : "unviewed";
1157
+ const closedByDefault = attached || viewed === "viewed";
1158
+ const ofFile = threads.filter((thread) => thread.path === path);
1159
+ const onLine = ofFile.filter(
1160
+ (thread) =>
1161
+ ofPullRequest &&
1162
+ !thread.outdated &&
1163
+ thread.line !== null &&
1164
+ onSide(thread),
1165
+ );
1166
+ const settled = onLine.filter(
1167
+ (thread) => !isDisplaced(review.movedOn, thread),
1168
+ );
1169
+ const pendingOfFile = pendingOfPullRequest.filter(
1170
+ (comment) => comment.path === path,
1171
+ );
1172
+ return (
1173
+ <DiffFile
1174
+ file={file}
1175
+ diffStyle={diffStyle}
1176
+ theme={theme}
1177
+ themeType={themeType}
1178
+ wordWrap={wordWrap}
1179
+ stickyTop={stickyTop}
1180
+ loadFiles={loadFiles}
1181
+ entry={entry}
1182
+ impacts={impactsOf(path)}
1183
+ collapsed={collapse.isCollapsed(path, closedByDefault)}
1184
+ review={{
1185
+ viewed,
1186
+ onViewed: (next) => {
1187
+ markViewed(path, next);
1188
+ },
1189
+ threads: settled.filter(
1190
+ (thread) => !isHiddenLine(file, thread),
1191
+ ),
1192
+ outdated: rightOnly
1193
+ ? []
1194
+ : ofFile.filter((thread) => thread.outdated),
1195
+ changedSince: onLine.filter((thread) =>
1196
+ isDisplaced(review.movedOn, thread),
1197
+ ),
1198
+ hiddenThreads: settled.filter((thread) =>
1199
+ isHiddenLine(file, thread),
1200
+ ),
1201
+ hiddenPending: pendingOfFile.filter((comment) =>
1202
+ isHiddenLine(file, comment),
1203
+ ),
1204
+ viewedLocked:
1205
+ ofPullRequest && changedSinceShown(review.movedOn, path),
1206
+ pending: pendingOfFile.filter(
1207
+ (comment) => !isHiddenLine(file, comment),
1208
+ ),
1209
+ commentable: review.commentable,
1210
+ rightOnly,
1211
+ commitId: ofPullRequest
1212
+ ? review.headSha
1213
+ : (review.commitOid ??
1214
+ review.commitFiles?.oid ??
1215
+ review.headSha),
1216
+ ofPullRequest,
1217
+ reviewable: review.reviewable !== false,
1218
+ anchoring: review.commitFiles === undefined ? null : anchoring,
1219
+ composer: ofPullRequest ? draft.composer : commitComposer,
1220
+ onOpenComposer: openComposerOf(ofPullRequest),
1221
+ onCloseComposer: closeComposerOf(ofPullRequest),
1222
+ onEdit: review.onEdit,
1223
+ draft: draftActions,
1224
+ }}
1225
+ onToggleCollapsed={() => {
1226
+ collapse.set(
1227
+ path,
1228
+ !collapse.isCollapsed(path, closedByDefault),
1229
+ );
1230
+ }}
1231
+ />
1232
+ );
1233
+ }}
1234
+ />
1235
+ </View>
1236
+ );
1237
+ }
1238
+
1239
+ interface PullRequestDiffViewerProps {
1240
+ pullRequest: PullRequestRef;
1241
+ patch: string;
1242
+ groups: DiffGroup[];
1243
+ /** null: the files by package, in patch order */
1244
+ readingOrder: DiffReadingOrderResponse | null;
1245
+ /** null: not read, still reading or failed: no file says anything of its usage */
1246
+ impacts: DiffImpactsResponse | null;
1247
+ sides: PullRequestDiffResponse["sides"];
1248
+ /** what the patch did with whitespace, which decides where a thread can be placed */
1249
+ whitespace: DiffWhitespace;
1250
+ diffStyle: DiffStyle;
1251
+ theme: DiffTheme;
1252
+ themeType: "dark" | "light";
1253
+ wordWrap: boolean;
1254
+ /** where a file header sticks: the height of the toolbar stuck above it */
1255
+ stickyTop: number;
1256
+ collapse: PullRequestCollapse;
1257
+ /** absent: a read-only diff, a commit's own */
1258
+ review?: PullRequestDiffReview;
1259
+ /** held by the view, whose toolbar must know a comment is being written */
1260
+ commitComposer: Composer | null;
1261
+ onCommitComposerChange: (composer: Composer | null) => void;
1262
+ }
1263
+
1264
+ /**
1265
+ * The only module here importing `@pierre/diffs`, loaded lazily (shiki has no
1266
+ * place in the bundle every board loads). A line gets its annotations through
1267
+ * `lineAnnotations` (one per side and line, so threads, pending comments and the
1268
+ * composer anchored together share it), rendered in light DOM slots by
1269
+ * `renderAnnotation`. Commenting starts from pierre's gutter utility, whose
1270
+ * drag selects a range: `onGutterUtilityClick` receives it.
1271
+ *
1272
+ * Memoized, so a toolbar press that changes none of its props does not render
1273
+ * every file again before the press shows.
1274
+ */
1275
+ function PullRequestDiffViewer({
1276
+ pullRequest,
1277
+ patch,
1278
+ groups,
1279
+ readingOrder,
1280
+ impacts,
1281
+ sides,
1282
+ whitespace,
1283
+ diffStyle,
1284
+ theme,
1285
+ themeType,
1286
+ wordWrap,
1287
+ stickyTop,
1288
+ collapse,
1289
+ review,
1290
+ commitComposer,
1291
+ onCommitComposerChange,
1292
+ }: PullRequestDiffViewerProps): ReactNode {
1293
+ const files = useMemo(
1294
+ () => parsePatchFiles(patch).flatMap((parsed) => parsed.files),
1295
+ [patch],
1296
+ );
1297
+ const fileGroups = useMemo(
1298
+ () =>
1299
+ readingOrder === null
1300
+ ? groupFiles(files, groups)
1301
+ : orderFiles(files, groups, readingOrder),
1302
+ [files, groups, readingOrder],
1303
+ );
1304
+ const impactsByPath = useMemo(
1305
+ () =>
1306
+ new Map(
1307
+ impacts === null || impacts.tooLarge
1308
+ ? []
1309
+ : impacts.files
1310
+ .filter(({ exports }) => exports.length > 0)
1311
+ .map((file) => [file.path, file]),
1312
+ ),
1313
+ [impacts],
1314
+ );
1315
+ const impactsOf = (path: string): DiffFileImpacts | undefined =>
1316
+ impactsByPath.get(path);
1317
+ const { ownerRepo, number } = pullRequest;
1318
+ const { readFileAtSha } = usePullRequestDataSource();
1319
+ const loadFiles = useMemo(
1320
+ () =>
1321
+ sides === null
1322
+ ? undefined
1323
+ : fileLoaderOf({ ownerRepo, number }, sides, readFileAtSha),
1324
+ [ownerRepo, number, sides, readFileAtSha],
1325
+ );
1326
+
1327
+ return (
1328
+ <DiffWorkerPool theme={theme}>
1329
+ {review === undefined ? (
1330
+ <FileGroups
1331
+ groups={fileGroups}
1332
+ renderFile={(file, { entry, attached }) => (
1333
+ <DiffFile
1334
+ file={file}
1335
+ diffStyle={diffStyle}
1336
+ theme={theme}
1337
+ themeType={themeType}
1338
+ wordWrap={wordWrap}
1339
+ stickyTop={stickyTop}
1340
+ loadFiles={loadFiles}
1341
+ entry={entry}
1342
+ impacts={impactsOf(file.name)}
1343
+ collapsed={collapse.isCollapsed(file.name, attached)}
1344
+ onToggleCollapsed={() => {
1345
+ collapse.set(
1346
+ file.name,
1347
+ !collapse.isCollapsed(file.name, attached),
1348
+ );
1349
+ }}
1350
+ />
1351
+ )}
1352
+ />
1353
+ ) : (
1354
+ <ReviewedFiles
1355
+ groups={fileGroups}
1356
+ review={review}
1357
+ whitespace={whitespace}
1358
+ diffStyle={diffStyle}
1359
+ theme={theme}
1360
+ themeType={themeType}
1361
+ wordWrap={wordWrap}
1362
+ stickyTop={stickyTop}
1363
+ collapse={collapse}
1364
+ loadFiles={loadFiles}
1365
+ impactsOf={impactsOf}
1366
+ commitComposer={commitComposer}
1367
+ onCommitComposerChange={onCommitComposerChange}
1368
+ />
1369
+ )}
1370
+ </DiffWorkerPool>
1371
+ );
1372
+ }
1373
+
1374
+ export default memo(PullRequestDiffViewer);