@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.
- package/package.json +21 -6
- package/src/components/branch-diff/diff-font.ts +26 -0
- package/src/components/branch-diff/diff-themes.ts +53 -1
- package/src/components/branch-diff/reviewflow-diff-selection.ts +11 -0
- package/src/components/branch-diff/reviewflow-diff-themes.ts +2218 -0
- package/src/components/branch-diff/use-diff-settings.tsx +15 -3
- package/src/components/branch-diff/whitespace-button.tsx +2 -2
- package/src/components/diff-settings-section.tsx +189 -0
- package/src/components/diff-theme-preview.tsx +63 -0
- package/src/components/file-kind.ts +91 -0
- package/src/components/file-type-icon.tsx +70 -0
- package/src/components/pull-request-actions-review.tsx +2 -2
- package/src/components/pull-request-commits.tsx +2 -1
- package/src/components/pull-request-diff-annotations.tsx +8 -5
- package/src/components/pull-request-diff-impacts-summary.tsx +2 -1
- package/src/components/pull-request-diff-impacts.ts +1 -3
- package/src/components/pull-request-diff-jump.ts +59 -1
- package/src/components/pull-request-diff-overview-tree.ts +57 -0
- package/src/components/pull-request-diff-overview.tsx +489 -90
- package/src/components/pull-request-diff-tree-files.ts +64 -0
- package/src/components/pull-request-diff-tree.tsx +232 -0
- package/src/components/pull-request-diff-view.tsx +112 -28
- package/src/components/pull-request-diff-viewer.tsx +26 -6
- package/src/components/pull-request-files.tsx +6 -6
- package/src/components/pull-request-panel.tsx +23 -21
- package/src/components/pull-request-reactions.tsx +2 -2
- package/src/components/pull-request-revisions.tsx +2 -1
- package/src/components/pull-request-screen.tsx +37 -17
- package/src/components/pull-request-suggestion-actions.tsx +7 -6
- package/src/components/repository-attachment-banner.tsx +1 -1
- package/src/components/scroll-parent.ts +20 -0
- package/src/components/tooltip-icon-button.tsx +27 -0
- package/src/editor/code-editor.tsx +1 -0
- package/src/editor/code-token-colors.css +39 -0
- package/src/editor/codeHighlight.ts +2 -1
- package/src/editor/markdown-editor.css +1 -34
- package/src/editor/markdown-editor.tsx +1 -0
- package/src/index.ts +6 -6
- 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 {
|
|
10
|
-
import {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
244
|
-
function
|
|
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
|
-
|
|
249
|
-
indented,
|
|
268
|
+
status,
|
|
250
269
|
onPress,
|
|
251
|
-
}:
|
|
252
|
-
const
|
|
253
|
-
const
|
|
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
|
-
<
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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=
|
|
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
|
|
318
|
-
*
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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)}.`}
|