@reviewflow/ui 6.24.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 (31) hide show
  1. package/package.json +17 -2
  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-diff-annotations.tsx +5 -2
  13. package/src/components/pull-request-diff-jump.ts +59 -1
  14. package/src/components/pull-request-diff-overview-tree.ts +57 -0
  15. package/src/components/pull-request-diff-overview.tsx +488 -88
  16. package/src/components/pull-request-diff-tree-files.ts +64 -0
  17. package/src/components/pull-request-diff-tree.tsx +232 -0
  18. package/src/components/pull-request-diff-view.tsx +111 -27
  19. package/src/components/pull-request-diff-viewer.tsx +23 -2
  20. package/src/components/pull-request-files.tsx +4 -4
  21. package/src/components/pull-request-panel.tsx +4 -2
  22. package/src/components/pull-request-screen.tsx +16 -4
  23. package/src/components/scroll-parent.ts +20 -0
  24. package/src/components/tooltip-icon-button.tsx +27 -0
  25. package/src/editor/code-editor.tsx +1 -0
  26. package/src/editor/code-token-colors.css +39 -0
  27. package/src/editor/codeHighlight.ts +2 -1
  28. package/src/editor/markdown-editor.css +1 -34
  29. package/src/editor/markdown-editor.tsx +1 -0
  30. package/src/index.ts +6 -6
  31. package/src/palette.ts +22 -0
@@ -5,14 +5,25 @@ import type {
5
5
  DiffReadingOrderResponse,
6
6
  } from "@reviewflow/sdk";
7
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";
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";
16
27
 
17
28
  /** Past this many names over the range, the rows carry their counts only. */
18
29
  export const maxOverviewNames = 50;
@@ -212,57 +223,74 @@ const labelOf = (
212
223
  return relativeTo(directory, path);
213
224
  };
214
225
 
215
- 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
+ };
216
233
 
217
- function FileIcon({ path, test }: { path: string; test: boolean }): ReactNode {
218
- if (test) {
219
- return (
220
- <Icon icon={<TestTubeRegularIcon />} size={16} className="text-muted" />
221
- );
222
- }
223
- if (typescriptFile.test(path)) {
224
- return (
225
- <Icon icon={<FileTsRegularIcon />} size={16} className="text-muted" />
226
- );
227
- }
228
- return <Icon icon={<FileRegularIcon />} size={16} className="text-muted" />;
229
- }
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
+ };
230
241
 
231
- 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 {
232
255
  path: string;
233
- /** what is shown of the path: from its workspace, or its subject's directory */
234
256
  label: string;
235
257
  entry: boolean;
236
- test: boolean;
237
- indented: boolean;
258
+ status: DiffTreeFileStatus | null;
238
259
  /** null: not in the diff below (an omitted file), nothing to go to */
239
260
  onPress: (() => void) | null;
240
261
  }
241
262
 
242
- /** The file's name first, its directory under it, so the names line up. */
243
- 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({
244
265
  path,
245
266
  label,
246
267
  entry,
247
- test,
248
- indented,
268
+ status,
249
269
  onPress,
250
- }: OverviewFileProps): ReactNode {
251
- const slash = label.lastIndexOf("/");
252
- const directory = label.slice(0, Math.max(slash, 0));
270
+ }: OverviewChipProps): ReactNode {
271
+ const { name, directory } = splitLabel(label);
272
+ const test = isAttachablePath(path);
253
273
  const content = (
254
274
  <View className="flex-row items-start gap-xs">
255
275
  <View className="pt-[2px]">
256
- <FileIcon path={path} test={test} />
276
+ <FileTypeIcon path={path} test={test} size={16} />
257
277
  </View>
258
278
  <View className="min-w-0 shrink gap-[1px]">
259
279
  {/* one Text, so "entry" sits on the name's baseline */}
260
280
  <Text
261
- className={
262
- test ? "text-sm text-muted" : "font-body-bold text-sm text-sharp"
263
- }
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
+ })()}
264
292
  >
265
- {label.slice(slash + 1)}
293
+ {name}
266
294
  {entry ? (
267
295
  <Text className="font-body text-xs text-muted"> · entry</Text>
268
296
  ) : null}
@@ -273,16 +301,15 @@ function OverviewFile({
273
301
  </View>
274
302
  </View>
275
303
  );
276
- const frame = indented
277
- ? "ml-l rounded-sm px-xs py-xxs"
278
- : "rounded-sm px-xs py-xxs";
279
- 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
+ }
280
307
  return (
281
308
  <PressableBox
282
309
  variant="soft"
283
310
  accent="neutral"
284
311
  aria-label={`Go to ${path}`}
285
- className={frame}
312
+ className="rounded-sm px-xs py-xxs"
286
313
  onPress={onPress}
287
314
  >
288
315
  {content}
@@ -290,6 +317,354 @@ function OverviewFile({
290
317
  );
291
318
  }
292
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
+
293
668
  const statusNotes: Record<OverviewReadingOrderStatus, string | null> = {
294
669
  ready: null,
295
670
  none: null,
@@ -297,6 +672,9 @@ const statusNotes: Record<OverviewReadingOrderStatus, string | null> = {
297
672
  failed: "The entry points could not be read: files are listed instead.",
298
673
  };
299
674
 
675
+ const noStatuses: ReadonlyMap<string, DiffTreeFileStatus> = new Map();
676
+ const noPaths: readonly string[] = [];
677
+
300
678
  export interface PullRequestDiffOverviewProps {
301
679
  /** `PullRequestDiffResponse.groups` of the range shown */
302
680
  groups: DiffGroup[];
@@ -310,11 +688,17 @@ export interface PullRequestDiffOverviewProps {
310
688
  impactsStatus?: ImpactsStatus;
311
689
  /** the reading order when the viewer orders the files by it, null in package order */
312
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;
313
697
  /** brings the file into view in the diff below */
314
698
  onFilePress: (path: string) => void;
315
699
  /**
316
- * In a column beside the diff, which scrolls on its own: every file, one a
317
- * 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.
318
702
  */
319
703
  aside: boolean;
320
704
  }
@@ -333,6 +717,9 @@ export function PullRequestDiffOverview({
333
717
  impacts = null,
334
718
  impactsStatus = "none",
335
719
  displayOrder,
720
+ statuses = noStatuses,
721
+ viewedPaths = noPaths,
722
+ currentPath = null,
336
723
  onFilePress,
337
724
  aside,
338
725
  }: PullRequestDiffOverviewProps): ReactNode {
@@ -352,55 +739,68 @@ export function PullRequestDiffOverview({
352
739
  const note = statusNotes[readingOrderStatus];
353
740
 
354
741
  return (
355
- <View className="gap-l py-xs" role="region" aria-label="Changes overview">
356
- <View className="flex-row items-baseline gap-xs">
357
- <Text className="font-heading-bold text-lg text-sharp">Changes</Text>
358
- <Text className="text-sm text-muted">
359
- {`in ${pluralOf(workspaces.length, "workspace")}`}
360
- </Text>
361
- </View>
362
- {workspaces.map(({ name, files, tests, names }) => (
363
- <View key={name} className="gap-sm">
364
- <View className="gap-xxs">
365
- <View className="flex-row items-center gap-xs">
366
- <Icon
367
- icon={<PackageDuotoneIcon />}
368
- size={20}
369
- className="text-accent"
370
- />
371
- <Text className="shrink font-heading-bold text-base text-sharp">
372
- {name === "" ? "Root" : name}
373
- </Text>
374
- </View>
375
- <Text className="pl-[28px] text-xs text-muted">
376
- {tests === 0
377
- ? pluralOf(files, "file")
378
- : `${pluralOf(files, "file")} · ${pluralOf(tests, "test")}`}
379
- </Text>
380
- </View>
381
- {showNames && names.length > 0 ? (
382
- <View className={aside ? "gap-xxs" : "flex-row flex-wrap gap-xs"}>
383
- {names.map(({ path, entry, shown, subject }) => (
384
- <OverviewFile
385
- key={path}
386
- path={path}
387
- label={labelOf(name, path, subject)}
388
- entry={entry}
389
- test={isAttachablePath(path)}
390
- indented={subject !== null}
391
- onPress={
392
- shown
393
- ? () => {
394
- onFilePress(path);
395
- }
396
- : null
397
- }
398
- />
399
- ))}
400
- </View>
401
- ) : 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>
402
753
  </View>
403
- ))}
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>
404
804
  {showNames ? null : (
405
805
  <Text className="text-xs text-muted">
406
806
  {`Names are left out past ${String(maxOverviewNames)}.`}