@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.
- package/package.json +17 -2
- 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-diff-annotations.tsx +5 -2
- 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 +488 -88
- 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 +111 -27
- package/src/components/pull-request-diff-viewer.tsx +23 -2
- package/src/components/pull-request-files.tsx +4 -4
- package/src/components/pull-request-panel.tsx +4 -2
- package/src/components/pull-request-screen.tsx +16 -4
- 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
|
@@ -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 {
|
|
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";
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
243
|
-
function
|
|
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
|
-
|
|
248
|
-
indented,
|
|
268
|
+
status,
|
|
249
269
|
onPress,
|
|
250
|
-
}:
|
|
251
|
-
const
|
|
252
|
-
const
|
|
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
|
-
<
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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=
|
|
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
|
|
317
|
-
*
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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)}.`}
|