@reviewflow/ui 6.23.0 → 6.25.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 (39) hide show
  1. package/package.json +21 -6
  2. package/src/components/branch-diff/diff-font.ts +26 -0
  3. package/src/components/branch-diff/diff-themes.ts +53 -1
  4. package/src/components/branch-diff/reviewflow-diff-selection.ts +11 -0
  5. package/src/components/branch-diff/reviewflow-diff-themes.ts +2218 -0
  6. package/src/components/branch-diff/use-diff-settings.tsx +15 -3
  7. package/src/components/branch-diff/whitespace-button.tsx +2 -2
  8. package/src/components/diff-settings-section.tsx +189 -0
  9. package/src/components/diff-theme-preview.tsx +63 -0
  10. package/src/components/file-kind.ts +91 -0
  11. package/src/components/file-type-icon.tsx +70 -0
  12. package/src/components/pull-request-actions-review.tsx +2 -2
  13. package/src/components/pull-request-commits.tsx +2 -1
  14. package/src/components/pull-request-diff-annotations.tsx +8 -5
  15. package/src/components/pull-request-diff-impacts-summary.tsx +2 -1
  16. package/src/components/pull-request-diff-impacts.ts +1 -3
  17. package/src/components/pull-request-diff-jump.ts +59 -1
  18. package/src/components/pull-request-diff-overview-tree.ts +57 -0
  19. package/src/components/pull-request-diff-overview.tsx +489 -90
  20. package/src/components/pull-request-diff-tree-files.ts +64 -0
  21. package/src/components/pull-request-diff-tree.tsx +232 -0
  22. package/src/components/pull-request-diff-view.tsx +112 -28
  23. package/src/components/pull-request-diff-viewer.tsx +26 -6
  24. package/src/components/pull-request-files.tsx +6 -6
  25. package/src/components/pull-request-panel.tsx +23 -21
  26. package/src/components/pull-request-reactions.tsx +2 -2
  27. package/src/components/pull-request-revisions.tsx +2 -1
  28. package/src/components/pull-request-screen.tsx +37 -17
  29. package/src/components/pull-request-suggestion-actions.tsx +7 -6
  30. package/src/components/repository-attachment-banner.tsx +1 -1
  31. package/src/components/scroll-parent.ts +20 -0
  32. package/src/components/tooltip-icon-button.tsx +27 -0
  33. package/src/editor/code-editor.tsx +1 -0
  34. package/src/editor/code-token-colors.css +39 -0
  35. package/src/editor/codeHighlight.ts +2 -1
  36. package/src/editor/markdown-editor.css +1 -34
  37. package/src/editor/markdown-editor.tsx +1 -0
  38. package/src/index.ts +6 -6
  39. package/src/palette.ts +22 -0
@@ -4,16 +4,26 @@ import type {
4
4
  DiffOmittedFile,
5
5
  DiffReadingOrderResponse,
6
6
  } from "@reviewflow/sdk";
7
- import { isAttachablePath, subjectsOf } from "@reviewflow/sdk";
7
+ import { isAttachablePath, pluralOf, subjectsOf } from "@reviewflow/sdk";
8
+ import type { Accent } from "alouette";
8
9
  import { Icon, PressableBox, Text, View } from "alouette";
9
- import { FileRegularIcon } from "alouette-icons/phosphor-icons/File";
10
- import { FileTsRegularIcon } from "alouette-icons/phosphor-icons/FileTs";
10
+ import { CaretDownRegularIcon } from "alouette-icons/phosphor-icons/CaretDown";
11
+ import { CaretRightRegularIcon } from "alouette-icons/phosphor-icons/CaretRight";
11
12
  import { PackageDuotoneIcon } from "alouette-icons/phosphor-icons/Package";
12
- import { TestTubeRegularIcon } from "alouette-icons/phosphor-icons/TestTube";
13
13
  import type { ReactNode } from "react";
14
+ import { useEffect, useEffectEvent, useRef, useState } from "react";
15
+ import type { View as RNView } from "react-native";
16
+ import { FileTypeIcon } from "./file-type-icon.tsx";
14
17
  import { PullRequestDiffImpactsSummary } from "./pull-request-diff-impacts-summary.tsx";
15
18
  import type { ImpactsStatus } from "./pull-request-diff-impacts.ts";
16
- import { pluralOf } from "./pull-request-diff-impacts.ts";
19
+ import type { OverviewTreeRow } from "./pull-request-diff-overview-tree.ts";
20
+ import {
21
+ fileIdOf,
22
+ overviewTreeActionOf,
23
+ workspaceIdOf,
24
+ } from "./pull-request-diff-overview-tree.ts";
25
+ import type { DiffTreeFileStatus } from "./pull-request-diff-tree-files.ts";
26
+ import { revealWithinScrollParent } from "./scroll-parent.ts";
17
27
 
18
28
  /** Past this many names over the range, the rows carry their counts only. */
19
29
  export const maxOverviewNames = 50;
@@ -213,57 +223,74 @@ const labelOf = (
213
223
  return relativeTo(directory, path);
214
224
  };
215
225
 
216
- const typescriptFile = /\.[cm]?tsx?$/;
226
+ const splitLabel = (label: string): { name: string; directory: string } => {
227
+ const slash = label.lastIndexOf("/");
228
+ return {
229
+ name: label.slice(slash + 1),
230
+ directory: label.slice(0, Math.max(slash, 0)),
231
+ };
232
+ };
217
233
 
218
- function FileIcon({ path, test }: { path: string; test: boolean }): ReactNode {
219
- if (test) {
220
- return (
221
- <Icon icon={<TestTubeRegularIcon />} size={16} className="text-muted" />
222
- );
223
- }
224
- if (typescriptFile.test(path)) {
225
- return (
226
- <Icon icon={<FileTsRegularIcon />} size={16} className="text-muted" />
227
- );
228
- }
229
- return <Icon icon={<FileRegularIcon />} size={16} className="text-muted" />;
230
- }
234
+ /** The tree's git colors where the diff theme has none (`PullRequestDiffTree`). */
235
+ const statusAccents: Record<DiffTreeFileStatus, Accent> = {
236
+ added: "success",
237
+ modified: "info",
238
+ renamed: "warning",
239
+ deleted: "danger",
240
+ };
231
241
 
232
- interface OverviewFileProps {
242
+ const statusLetters: Record<DiffTreeFileStatus, string> = {
243
+ added: "A",
244
+ modified: "M",
245
+ renamed: "R",
246
+ deleted: "D",
247
+ };
248
+
249
+ const countsOf = (files: number, tests: number): string =>
250
+ tests === 0
251
+ ? pluralOf(files, "file")
252
+ : `${pluralOf(files, "file")} · ${pluralOf(tests, "test")}`;
253
+
254
+ interface OverviewChipProps {
233
255
  path: string;
234
- /** what is shown of the path: from its workspace, or its subject's directory */
235
256
  label: string;
236
257
  entry: boolean;
237
- test: boolean;
238
- indented: boolean;
258
+ status: DiffTreeFileStatus | null;
239
259
  /** null: not in the diff below (an omitted file), nothing to go to */
240
260
  onPress: (() => void) | null;
241
261
  }
242
262
 
243
- /** The file's name first, its directory under it, so the names line up. */
244
- function OverviewFile({
263
+ /** Inline, on a narrow screen: the file's name first, its directory under it, so the names line up. */
264
+ function OverviewChip({
245
265
  path,
246
266
  label,
247
267
  entry,
248
- test,
249
- indented,
268
+ status,
250
269
  onPress,
251
- }: OverviewFileProps): ReactNode {
252
- const slash = label.lastIndexOf("/");
253
- const directory = label.slice(0, Math.max(slash, 0));
270
+ }: OverviewChipProps): ReactNode {
271
+ const { name, directory } = splitLabel(label);
272
+ const test = isAttachablePath(path);
254
273
  const content = (
255
274
  <View className="flex-row items-start gap-xs">
256
275
  <View className="pt-[2px]">
257
- <FileIcon path={path} test={test} />
276
+ <FileTypeIcon path={path} test={test} size={16} />
258
277
  </View>
259
278
  <View className="min-w-0 shrink gap-[1px]">
260
279
  {/* one Text, so "entry" sits on the name's baseline */}
261
280
  <Text
262
- className={
263
- test ? "text-sm text-muted" : "font-body-bold text-sm text-sharp"
264
- }
281
+ accent={status === null ? undefined : statusAccents[status]}
282
+ className={((): string => {
283
+ if (status !== null) {
284
+ return test
285
+ ? "text-sm text-accent"
286
+ : "font-body-bold text-sm text-accent";
287
+ }
288
+ return test
289
+ ? "text-sm text-muted"
290
+ : "font-body-bold text-sm text-sharp";
291
+ })()}
265
292
  >
266
- {label.slice(slash + 1)}
293
+ {name}
267
294
  {entry ? (
268
295
  <Text className="font-body text-xs text-muted"> · entry</Text>
269
296
  ) : null}
@@ -274,16 +301,15 @@ function OverviewFile({
274
301
  </View>
275
302
  </View>
276
303
  );
277
- const frame = indented
278
- ? "ml-l rounded-sm px-xs py-xxs"
279
- : "rounded-sm px-xs py-xxs";
280
- if (onPress === null) return <View className={frame}>{content}</View>;
304
+ if (onPress === null) {
305
+ return <View className="rounded-sm px-xs py-xxs">{content}</View>;
306
+ }
281
307
  return (
282
308
  <PressableBox
283
309
  variant="soft"
284
310
  accent="neutral"
285
311
  aria-label={`Go to ${path}`}
286
- className={frame}
312
+ className="rounded-sm px-xs py-xxs"
287
313
  onPress={onPress}
288
314
  >
289
315
  {content}
@@ -291,6 +317,354 @@ function OverviewFile({
291
317
  );
292
318
  }
293
319
 
320
+ /** A vertical line under the icon of each ancestor row, as the tree draws them. */
321
+ function IndentGuides({ level }: { level: 2 | 3 }): ReactNode {
322
+ return (
323
+ <>
324
+ <View className="absolute inset-y-0 left-[15px] w-px bg-border-muted" />
325
+ {level === 3 ? (
326
+ <View className="absolute inset-y-0 left-[31px] w-px bg-border-muted" />
327
+ ) : null}
328
+ </>
329
+ );
330
+ }
331
+
332
+ interface TreeRowFocus {
333
+ /** the one row the Tab key reaches: the others are reached with the arrows */
334
+ tabbable: boolean;
335
+ onFocus: () => void;
336
+ rowRef: (node: RNView | null) => () => void;
337
+ }
338
+
339
+ interface TreeWorkspaceRowProps extends TreeRowFocus {
340
+ name: string;
341
+ files: number;
342
+ tests: number;
343
+ expanded: boolean;
344
+ onToggle: () => void;
345
+ }
346
+
347
+ /** Pinned at the top of the column while its files scroll under it. */
348
+ function TreeWorkspaceRow({
349
+ name,
350
+ files,
351
+ tests,
352
+ expanded,
353
+ onToggle,
354
+ tabbable,
355
+ onFocus,
356
+ rowRef,
357
+ }: TreeWorkspaceRowProps): ReactNode {
358
+ const label = name === "" ? "Root" : name;
359
+ const counts = countsOf(files, tests);
360
+ return (
361
+ <View className="bg-screen web:sticky mt-xs web:top-0 web:z-[1]">
362
+ <PressableBox
363
+ ref={rowRef}
364
+ variant="soft"
365
+ accent="neutral"
366
+ role="treeitem"
367
+ aria-level={1}
368
+ aria-expanded={expanded}
369
+ aria-label={`${label}, ${counts}`}
370
+ tabIndex={tabbable ? 0 : -1}
371
+ className="h-[30px] flex-row items-center gap-xxs rounded-sm px-xs"
372
+ onFocus={onFocus}
373
+ onPress={onToggle}
374
+ >
375
+ <Icon
376
+ icon={expanded ? <CaretDownRegularIcon /> : <CaretRightRegularIcon />}
377
+ size={12}
378
+ className="text-muted"
379
+ />
380
+ <Icon icon={<PackageDuotoneIcon />} size={16} className="text-accent" />
381
+ <Text
382
+ numberOfLines={1}
383
+ className="min-w-0 shrink font-body-bold text-sm text-sharp"
384
+ >
385
+ {label}
386
+ </Text>
387
+ <Text numberOfLines={1} className="ml-auto shrink-0 text-xs text-muted">
388
+ {counts}
389
+ </Text>
390
+ </PressableBox>
391
+ </View>
392
+ );
393
+ }
394
+
395
+ interface TreeFileRowProps extends TreeRowFocus {
396
+ path: string;
397
+ label: string;
398
+ level: 2 | 3;
399
+ entry: boolean;
400
+ status: DiffTreeFileStatus | null;
401
+ viewed: boolean;
402
+ /** the file the diff is scrolled to */
403
+ current: boolean;
404
+ /** null: not in the diff below (an omitted file), nothing to go to */
405
+ onPress: (() => void) | null;
406
+ }
407
+
408
+ /**
409
+ * One line, as a tree row: the name in its status's color, the directory
410
+ * after it, cut first when the column is narrow, then what is said of the
411
+ * file (entry, viewed, left out) and its status letter, at the right edge.
412
+ */
413
+ function TreeFileRow({
414
+ path,
415
+ label,
416
+ level,
417
+ entry,
418
+ status,
419
+ viewed,
420
+ current,
421
+ onPress,
422
+ tabbable,
423
+ onFocus,
424
+ rowRef,
425
+ }: TreeFileRowProps): ReactNode {
426
+ const { name, directory } = splitLabel(label);
427
+ const accent = status === null ? undefined : statusAccents[status];
428
+ const notes = [
429
+ entry ? "entry" : null,
430
+ onPress === null ? "left out" : null,
431
+ viewed && onPress !== null ? "viewed" : null,
432
+ ].filter((note) => note !== null);
433
+ const description = [status, ...notes].filter((part) => part !== null);
434
+ return (
435
+ <View
436
+ className={current ? "rounded-sm bg-interactive-soft-active" : undefined}
437
+ >
438
+ <PressableBox
439
+ ref={rowRef}
440
+ variant="soft"
441
+ accent="neutral"
442
+ role="treeitem"
443
+ aria-level={level}
444
+ aria-selected={current}
445
+ aria-label={[
446
+ onPress === null ? path : `Go to ${path}`,
447
+ ...description,
448
+ ].join(", ")}
449
+ disabled={onPress === null}
450
+ tabIndex={tabbable ? 0 : -1}
451
+ className={
452
+ level === 2
453
+ ? "h-[26px] flex-row items-center gap-xxs rounded-sm pl-[24px] pr-xs"
454
+ : "h-[26px] flex-row items-center gap-xxs rounded-sm pl-[40px] pr-xs"
455
+ }
456
+ onFocus={onFocus}
457
+ onPress={onPress ?? undefined}
458
+ >
459
+ <IndentGuides level={level} />
460
+ <FileTypeIcon path={path} test={isAttachablePath(path)} size={12} />
461
+ <Text numberOfLines={1} className="min-w-0 shrink text-xs">
462
+ <Text
463
+ accent={accent}
464
+ className={((): string => {
465
+ const weight = entry ? "font-body-bold" : "font-body";
466
+ if (accent !== undefined) return `${weight} text-accent`;
467
+ return onPress === null
468
+ ? `${weight} text-muted`
469
+ : `${weight} text-sharp`;
470
+ })()}
471
+ >
472
+ {name}
473
+ </Text>
474
+ {directory === "" ? null : (
475
+ <Text className="font-body text-xs text-muted">{` ${directory}`}</Text>
476
+ )}
477
+ </Text>
478
+ <View className="ml-auto shrink-0 flex-row items-center gap-xxs">
479
+ {notes.length === 0 ? null : (
480
+ <Text className="text-xs text-muted">{notes.join(" · ")}</Text>
481
+ )}
482
+ {status === null ? null : (
483
+ <Text
484
+ accent={accent}
485
+ className="w-[10px] text-center font-body-bold text-xs text-accent"
486
+ >
487
+ {statusLetters[status]}
488
+ </Text>
489
+ )}
490
+ </View>
491
+ </PressableBox>
492
+ </View>
493
+ );
494
+ }
495
+
496
+ const focusRow = (node: RNView | undefined): void => {
497
+ if (typeof HTMLElement !== "undefined" && node instanceof HTMLElement) {
498
+ node.focus();
499
+ }
500
+ };
501
+
502
+ /** Leaves room for the pinned workspace row above the row revealed. */
503
+ const pinnedRowHeight = 32;
504
+
505
+ interface OverviewTreeProps {
506
+ workspaces: readonly OverviewWorkspace[];
507
+ statuses: ReadonlyMap<string, DiffTreeFileStatus>;
508
+ viewedPaths: ReadonlySet<string>;
509
+ currentPath: string | null;
510
+ onFilePress: (path: string) => void;
511
+ }
512
+
513
+ /**
514
+ * Beside the diff: every file as a tree row, under its workspace, which folds;
515
+ * its tests a level below it. One row takes the Tab key and the arrows move
516
+ * between rows (`overviewTreeActionOf`); the file the diff is scrolled to is
517
+ * lit, and kept in view in the column.
518
+ */
519
+ function OverviewTree({
520
+ workspaces,
521
+ statuses,
522
+ viewedPaths,
523
+ currentPath,
524
+ onFilePress,
525
+ }: OverviewTreeProps): ReactNode {
526
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
527
+ const [focusedId, setFocusedId] = useState<string | null>(null);
528
+ const treeRef = useRef<RNView>(null);
529
+ const rowNodes = useRef(new Map<string, RNView>());
530
+
531
+ const rows: OverviewTreeRow[] = workspaces.flatMap(({ name, names }) => {
532
+ const id = workspaceIdOf(name);
533
+ const expanded = !collapsed.has(id);
534
+ return [
535
+ { id, kind: "workspace", level: 1, parent: null, expanded },
536
+ ...(expanded
537
+ ? names.map(({ path, subject }): OverviewTreeRow => ({
538
+ id: fileIdOf(path),
539
+ kind: "file",
540
+ level: subject === null ? 2 : 3,
541
+ parent: subject === null ? id : fileIdOf(subject),
542
+ expanded: false,
543
+ }))
544
+ : []),
545
+ ];
546
+ });
547
+ const listed = new Set(rows.map(({ id }) => id));
548
+ const currentId = currentPath === null ? null : fileIdOf(currentPath);
549
+ const tabbableId = ((): string | undefined => {
550
+ if (focusedId !== null && listed.has(focusedId)) return focusedId;
551
+ if (currentId !== null && listed.has(currentId)) return currentId;
552
+ return rows[0]?.id;
553
+ })();
554
+ const shownPaths = new Set(
555
+ workspaces.flatMap(({ names }) =>
556
+ names.filter(({ shown }) => shown).map(({ path }) => path),
557
+ ),
558
+ );
559
+
560
+ const toggle = (id: string): void => {
561
+ setCollapsed((previous) => {
562
+ const next = new Set(previous);
563
+ if (!next.delete(id)) next.add(id);
564
+ return next;
565
+ });
566
+ };
567
+ const press = (id: string): void => {
568
+ if (id.startsWith("workspace:")) {
569
+ toggle(id);
570
+ return;
571
+ }
572
+ const path = id.slice("file:".length);
573
+ if (shownPaths.has(path)) onFilePress(path);
574
+ };
575
+ const focusOf = (id: string): TreeRowFocus => ({
576
+ tabbable: id === tabbableId,
577
+ onFocus: () => {
578
+ setFocusedId(id);
579
+ },
580
+ rowRef: (node) => {
581
+ if (node !== null) rowNodes.current.set(id, node);
582
+ return () => {
583
+ if (rowNodes.current.get(id) === node) rowNodes.current.delete(id);
584
+ };
585
+ },
586
+ });
587
+
588
+ const onKeyDown = useEffectEvent((event: KeyboardEvent): void => {
589
+ if (tabbableId === undefined) return;
590
+ const action = overviewTreeActionOf(rows, tabbableId, event.key);
591
+ if (action === null) return;
592
+ event.preventDefault();
593
+ if (action.type === "focus") focusRow(rowNodes.current.get(action.id));
594
+ else if (action.type === "toggle") toggle(action.id);
595
+ else press(action.id);
596
+ });
597
+
598
+ // a DOM listener: react-native's View has no keyboard events to type it with
599
+ useEffect(() => {
600
+ const node = treeRef.current;
601
+ if (typeof HTMLElement === "undefined" || !(node instanceof HTMLElement)) {
602
+ return undefined;
603
+ }
604
+ const listener = (event: KeyboardEvent): void => {
605
+ onKeyDown(event);
606
+ };
607
+ node.addEventListener("keydown", listener);
608
+ return () => {
609
+ node.removeEventListener("keydown", listener);
610
+ };
611
+ }, []);
612
+
613
+ // follows the diff's scroll, which is not a press in this column
614
+ useEffect(() => {
615
+ if (currentId === null) return;
616
+ const node = rowNodes.current.get(currentId);
617
+ if (typeof HTMLElement !== "undefined" && node instanceof HTMLElement) {
618
+ revealWithinScrollParent(node, pinnedRowHeight);
619
+ }
620
+ }, [currentId]);
621
+
622
+ return (
623
+ <View ref={treeRef} role="tree" aria-label="Files in reading order">
624
+ {workspaces.map(({ name, files, tests, names }) => {
625
+ const id = workspaceIdOf(name);
626
+ const expanded = !collapsed.has(id);
627
+ return (
628
+ <View key={name}>
629
+ <TreeWorkspaceRow
630
+ name={name}
631
+ files={files}
632
+ tests={tests}
633
+ expanded={expanded}
634
+ onToggle={() => {
635
+ toggle(id);
636
+ }}
637
+ {...focusOf(id)}
638
+ />
639
+ {expanded
640
+ ? names.map(({ path, entry, shown, subject }) => (
641
+ <TreeFileRow
642
+ key={path}
643
+ path={path}
644
+ label={labelOf(name, path, subject)}
645
+ level={subject === null ? 2 : 3}
646
+ entry={entry}
647
+ status={shown ? (statuses.get(path) ?? null) : null}
648
+ viewed={viewedPaths.has(path)}
649
+ current={path === currentPath}
650
+ onPress={
651
+ shown
652
+ ? () => {
653
+ onFilePress(path);
654
+ }
655
+ : null
656
+ }
657
+ {...focusOf(fileIdOf(path))}
658
+ />
659
+ ))
660
+ : null}
661
+ </View>
662
+ );
663
+ })}
664
+ </View>
665
+ );
666
+ }
667
+
294
668
  const statusNotes: Record<OverviewReadingOrderStatus, string | null> = {
295
669
  ready: null,
296
670
  none: null,
@@ -298,6 +672,9 @@ const statusNotes: Record<OverviewReadingOrderStatus, string | null> = {
298
672
  failed: "The entry points could not be read: files are listed instead.",
299
673
  };
300
674
 
675
+ const noStatuses: ReadonlyMap<string, DiffTreeFileStatus> = new Map();
676
+ const noPaths: readonly string[] = [];
677
+
301
678
  export interface PullRequestDiffOverviewProps {
302
679
  /** `PullRequestDiffResponse.groups` of the range shown */
303
680
  groups: DiffGroup[];
@@ -311,11 +688,17 @@ export interface PullRequestDiffOverviewProps {
311
688
  impactsStatus?: ImpactsStatus;
312
689
  /** the reading order when the viewer orders the files by it, null in package order */
313
690
  displayOrder: DiffReadingOrderResponse | null;
691
+ /** each file's git status (`fileStatusesOf`); a file it lacks is not colored */
692
+ statuses?: ReadonlyMap<string, DiffTreeFileStatus>;
693
+ /** marked viewed: said on their row aside the diff */
694
+ viewedPaths?: readonly string[];
695
+ /** the file the diff is scrolled to, lit aside the diff */
696
+ currentPath?: string | null;
314
697
  /** brings the file into view in the diff below */
315
698
  onFilePress: (path: string) => void;
316
699
  /**
317
- * In a column beside the diff, which scrolls on its own: every file, one a
318
- * line, its tests indented below it, with no cap on their number.
700
+ * In a column beside the diff, which scrolls on its own: every file as a
701
+ * tree row, its tests a level below it, with no cap on their number.
319
702
  */
320
703
  aside: boolean;
321
704
  }
@@ -334,6 +717,9 @@ export function PullRequestDiffOverview({
334
717
  impacts = null,
335
718
  impactsStatus = "none",
336
719
  displayOrder,
720
+ statuses = noStatuses,
721
+ viewedPaths = noPaths,
722
+ currentPath = null,
337
723
  onFilePress,
338
724
  aside,
339
725
  }: PullRequestDiffOverviewProps): ReactNode {
@@ -353,55 +739,68 @@ export function PullRequestDiffOverview({
353
739
  const note = statusNotes[readingOrderStatus];
354
740
 
355
741
  return (
356
- <View className="gap-l py-xs" role="region" aria-label="Changes overview">
357
- <View className="flex-row items-baseline gap-xs">
358
- <Text className="font-heading-bold text-lg text-sharp">Changes</Text>
359
- <Text className="text-sm text-muted">
360
- {`in ${pluralOf(workspaces.length, "workspace")}`}
361
- </Text>
362
- </View>
363
- {workspaces.map(({ name, files, tests, names }) => (
364
- <View key={name} className="gap-sm">
365
- <View className="gap-xxs">
366
- <View className="flex-row items-center gap-xs">
367
- <Icon
368
- icon={<PackageDuotoneIcon />}
369
- size={20}
370
- className="text-accent"
371
- />
372
- <Text className="shrink font-heading-bold text-base text-sharp">
373
- {name === "" ? "Root" : name}
374
- </Text>
375
- </View>
376
- <Text className="pl-[28px] text-xs text-muted">
377
- {tests === 0
378
- ? pluralOf(files, "file")
379
- : `${pluralOf(files, "file")} · ${pluralOf(tests, "test")}`}
380
- </Text>
381
- </View>
382
- {showNames && names.length > 0 ? (
383
- <View className={aside ? "gap-xxs" : "flex-row flex-wrap gap-xs"}>
384
- {names.map(({ path, entry, shown, subject }) => (
385
- <OverviewFile
386
- key={path}
387
- path={path}
388
- label={labelOf(name, path, subject)}
389
- entry={entry}
390
- test={isAttachablePath(path)}
391
- indented={subject !== null}
392
- onPress={
393
- shown
394
- ? () => {
395
- onFilePress(path);
396
- }
397
- : null
398
- }
399
- />
400
- ))}
401
- </View>
402
- ) : null}
742
+ <View
743
+ className={aside ? "gap-m py-xs" : "gap-l py-xs"}
744
+ role="region"
745
+ aria-label="Changes overview"
746
+ >
747
+ <View>
748
+ <View className="flex-row items-baseline gap-xs">
749
+ <Text className="font-heading-bold text-lg text-sharp">Changes</Text>
750
+ <Text className="text-sm text-muted">
751
+ {`in ${pluralOf(workspaces.length, "workspace")}`}
752
+ </Text>
403
753
  </View>
404
- ))}
754
+ {aside ? (
755
+ <OverviewTree
756
+ workspaces={workspaces}
757
+ statuses={statuses}
758
+ viewedPaths={new Set(viewedPaths)}
759
+ currentPath={currentPath}
760
+ onFilePress={onFilePress}
761
+ />
762
+ ) : (
763
+ workspaces.map(({ name, files, tests, names }) => (
764
+ <View key={name} className="gap-sm">
765
+ <View className="gap-xxs">
766
+ <View className="flex-row items-center gap-xs">
767
+ <Icon
768
+ icon={<PackageDuotoneIcon />}
769
+ size={20}
770
+ className="text-accent"
771
+ />
772
+ <Text className="shrink font-heading-bold text-base text-sharp">
773
+ {name === "" ? "Root" : name}
774
+ </Text>
775
+ </View>
776
+ <Text className="pl-[28px] text-xs text-muted">
777
+ {countsOf(files, tests)}
778
+ </Text>
779
+ </View>
780
+ {showNames && names.length > 0 ? (
781
+ <View className="flex-row flex-wrap gap-xs">
782
+ {names.map(({ path, entry, shown }) => (
783
+ <OverviewChip
784
+ key={path}
785
+ path={path}
786
+ label={labelOf(name, path, null)}
787
+ entry={entry}
788
+ status={shown ? (statuses.get(path) ?? null) : null}
789
+ onPress={
790
+ shown
791
+ ? () => {
792
+ onFilePress(path);
793
+ }
794
+ : null
795
+ }
796
+ />
797
+ ))}
798
+ </View>
799
+ ) : null}
800
+ </View>
801
+ ))
802
+ )}
803
+ </View>
405
804
  {showNames ? null : (
406
805
  <Text className="text-xs text-muted">
407
806
  {`Names are left out past ${String(maxOverviewNames)}.`}