@reviewflow/ui 6.24.0 → 6.26.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 (33) 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 +12 -6
  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
  32. package/src/prStatus.ts +8 -1
  33. package/src/slots.tsx +8 -0
@@ -1,6 +1,8 @@
1
- import type { DiffWhitespace } from "@reviewflow/sdk";
1
+ import type { DiffFont, DiffWhitespace } from "@reviewflow/sdk";
2
+ import { defaultDiffFont, defaultDiffTheme } from "@reviewflow/sdk";
2
3
  import type { ReactNode } from "react";
3
4
  import { createContext, use, useMemo, useState } from "react";
5
+ import { diffFontFamily } from "./diff-font.ts";
4
6
  import type { DiffTheme } from "./diff-themes.ts";
5
7
 
6
8
  /**
@@ -16,6 +18,8 @@ export const isDiffFileOrder = (value: string): value is DiffFileOrder =>
16
18
  export interface DiffSettings {
17
19
  wordWrap: boolean;
18
20
  theme: DiffTheme;
21
+ /** the code's CSS `font-family`; null keeps pierre's own */
22
+ fontFamily: string | null;
19
23
  whitespace: DiffWhitespace;
20
24
  setWhitespace: (whitespace: DiffWhitespace) => void;
21
25
  fileOrder: DiffFileOrder;
@@ -24,7 +28,8 @@ export interface DiffSettings {
24
28
 
25
29
  const defaults: DiffSettings = {
26
30
  wordWrap: true,
27
- theme: "github",
31
+ theme: defaultDiffTheme,
32
+ fontFamily: diffFontFamily(defaultDiffFont, ""),
28
33
  whitespace: "show",
29
34
  setWhitespace: () => undefined,
30
35
  fileOrder: "packages",
@@ -35,6 +40,9 @@ const DiffSettingsContext = createContext<DiffSettings>(defaults);
35
40
 
36
41
  interface DiffSettingsProviderProps {
37
42
  theme?: DiffTheme;
43
+ font?: DiffFont;
44
+ /** the CSS `font-family` of the `custom` font */
45
+ customFont?: string;
38
46
  wordWrap?: boolean;
39
47
  /** the whitespace the diff starts with; the toolbar toggle then owns it */
40
48
  initialWhitespace?: DiffWhitespace;
@@ -52,6 +60,8 @@ interface DiffSettingsProviderProps {
52
60
  */
53
61
  export function DiffSettingsProvider({
54
62
  theme = defaults.theme,
63
+ font = defaultDiffFont,
64
+ customFont = "",
55
65
  wordWrap = defaults.wordWrap,
56
66
  initialWhitespace = defaults.whitespace,
57
67
  fileOrder: storedFileOrder,
@@ -61,9 +71,11 @@ export function DiffSettingsProvider({
61
71
  const [whitespace, setWhitespace] = useState(initialWhitespace);
62
72
  const [ownFileOrder, setOwnFileOrder] = useState(defaults.fileOrder);
63
73
  const fileOrder = storedFileOrder ?? ownFileOrder;
74
+ const fontFamily = diffFontFamily(font, customFont);
64
75
  const value = useMemo(
65
76
  () => ({
66
77
  theme,
78
+ fontFamily,
67
79
  wordWrap,
68
80
  whitespace,
69
81
  setWhitespace,
@@ -73,7 +85,7 @@ export function DiffSettingsProvider({
73
85
  onFileOrderChange?.(next);
74
86
  },
75
87
  }),
76
- [theme, wordWrap, whitespace, fileOrder, onFileOrderChange],
88
+ [theme, fontFamily, wordWrap, whitespace, fileOrder, onFileOrderChange],
77
89
  );
78
90
  return <DiffSettingsContext value={value}>{children}</DiffSettingsContext>;
79
91
  }
@@ -1,7 +1,7 @@
1
1
  import type { DiffWhitespace } from "@reviewflow/sdk";
2
- import { IconButton } from "alouette";
3
2
  import { ParagraphRegularIcon } from "alouette-icons/phosphor-icons/Paragraph";
4
3
  import type { ReactNode } from "react";
4
+ import { TooltipIconButton } from "../tooltip-icon-button.tsx";
5
5
 
6
6
  interface WhitespaceButtonProps {
7
7
  whitespace: DiffWhitespace;
@@ -16,7 +16,7 @@ export function WhitespaceButton({
16
16
  onToggle,
17
17
  }: WhitespaceButtonProps): ReactNode {
18
18
  return (
19
- <IconButton
19
+ <TooltipIconButton
20
20
  icon={<ParagraphRegularIcon />}
21
21
  size="sm"
22
22
  accent={whitespace === "hide" ? "brand" : "neutral"}
@@ -0,0 +1,189 @@
1
+ import type { DiffFont, DiffTheme } from "@reviewflow/sdk";
2
+ import {
3
+ diffFontOptions,
4
+ diffThemeOptions,
5
+ isDiffFont,
6
+ isDiffTheme,
7
+ } from "@reviewflow/sdk";
8
+ import {
9
+ Box,
10
+ ErrorMessage,
11
+ FormItem,
12
+ IndeterminateCircularProgress,
13
+ InputText,
14
+ Select,
15
+ Switch,
16
+ Text,
17
+ View,
18
+ useCurrentMode,
19
+ } from "alouette";
20
+ import type { ReactNode } from "react";
21
+ import { Suspense, lazy, useState } from "react";
22
+ import { errorToMessage } from "../services/errorToMessage.ts";
23
+ import { diffFontFamily } from "./branch-diff/diff-font.ts";
24
+
25
+ const DiffThemePreview = lazy(() => import("./diff-theme-preview.tsx"));
26
+
27
+ export interface DiffDisplaySettings {
28
+ wordWrap: boolean;
29
+ hideWhitespace: boolean;
30
+ theme: DiffTheme;
31
+ font: DiffFont;
32
+ /** the CSS `font-family` of the `custom` font */
33
+ customFont: string;
34
+ }
35
+
36
+ interface DiffSettingsSectionProps {
37
+ settings: DiffDisplaySettings;
38
+ /** saves one change, wherever the app keeps them; a rejection is shown in the section */
39
+ onChange: (change: Partial<DiffDisplaySettings>) => Promise<void>;
40
+ }
41
+
42
+ /**
43
+ * How the diffs render, for an app's settings screen. Saved on the press; the
44
+ * preview follows the picked theme at once rather than waiting for the save.
45
+ */
46
+ export function DiffSettingsSection({
47
+ settings,
48
+ onChange,
49
+ }: DiffSettingsSectionProps): ReactNode {
50
+ const mode = useCurrentMode();
51
+ const [error, setError] = useState<string | null>(null);
52
+ const [pickedTheme, setPickedTheme] = useState<DiffTheme | null>(null);
53
+ const [pickedFont, setPickedFont] = useState<DiffFont | null>(null);
54
+ const [typedCustomFont, setTypedCustomFont] = useState<string | null>(null);
55
+
56
+ const save = (
57
+ change: Partial<DiffDisplaySettings>,
58
+ onSettled?: () => void,
59
+ ): void => {
60
+ setError(null);
61
+ onChange(change)
62
+ .catch((changeError: unknown) => {
63
+ setError(errorToMessage(changeError));
64
+ })
65
+ .finally(onSettled);
66
+ };
67
+
68
+ const previewTheme = pickedTheme ?? settings.theme;
69
+ const previewFont = pickedFont ?? settings.font;
70
+ const previewCustomFont = typedCustomFont ?? settings.customFont;
71
+
72
+ return (
73
+ <Box className="surface gap-m p-m">
74
+ <Text className="font-heading-bold text-lg">Diff</Text>
75
+
76
+ <FormItem
77
+ label="Wrap long lines"
78
+ details="On, a line longer than its column wraps. Off, it stays on one line and the file scrolls sideways."
79
+ render={({ labelId, describedBy }) => (
80
+ <Switch
81
+ aria-labelledby={labelId}
82
+ aria-describedby={describedBy}
83
+ checked={settings.wordWrap}
84
+ onValueChange={(wordWrap) => {
85
+ save({ wordWrap });
86
+ }}
87
+ />
88
+ )}
89
+ />
90
+
91
+ <FormItem
92
+ label="Hide whitespace changes"
93
+ details="On, a line whose only change is whitespace is not shown as changed, and a file with nothing else is listed as left out. The diff's toolbar switches it too. A pull request read from GitHub's API shows whitespace whatever this says."
94
+ render={({ labelId, describedBy }) => (
95
+ <Switch
96
+ aria-labelledby={labelId}
97
+ aria-describedby={describedBy}
98
+ checked={settings.hideWhitespace}
99
+ onValueChange={(hideWhitespace) => {
100
+ save({ hideWhitespace });
101
+ }}
102
+ />
103
+ )}
104
+ />
105
+
106
+ <FormItem
107
+ label="Theme"
108
+ details="The syntax colours of the diffs. Each theme has a light and a dark variant, following the app's mode."
109
+ render={({ labelId, describedBy }) => (
110
+ <Select
111
+ aria-labelledby={labelId}
112
+ aria-describedby={describedBy}
113
+ options={diffThemeOptions}
114
+ value={previewTheme}
115
+ onValueChange={(theme) => {
116
+ if (!isDiffTheme(theme)) return;
117
+ setPickedTheme(theme);
118
+ save({ theme }, () => {
119
+ setPickedTheme((current) =>
120
+ current === theme ? null : current,
121
+ );
122
+ });
123
+ }}
124
+ />
125
+ )}
126
+ />
127
+
128
+ <FormItem
129
+ label="Font"
130
+ details="The font of the diffs' code: the app's own mono, the system mono font the diff library ships with, or one installed on this machine."
131
+ render={({ labelId, describedBy }) => (
132
+ <Select
133
+ aria-labelledby={labelId}
134
+ aria-describedby={describedBy}
135
+ options={diffFontOptions}
136
+ value={previewFont}
137
+ onValueChange={(font) => {
138
+ if (!isDiffFont(font)) return;
139
+ setPickedFont(font);
140
+ save({ font }, () => {
141
+ setPickedFont((current) => (current === font ? null : current));
142
+ });
143
+ }}
144
+ />
145
+ )}
146
+ />
147
+
148
+ {previewFont === "custom" ? (
149
+ <FormItem
150
+ label="Custom font"
151
+ details="A CSS font-family, as installed on this machine: JetBrains Mono, or a list such as “Fira Code”, Menlo. The app's mono stands in for a font this machine does not have."
152
+ render={({ labelId, describedBy }) => (
153
+ <InputText
154
+ value={previewCustomFont}
155
+ aria-labelledby={labelId}
156
+ aria-describedby={describedBy}
157
+ autoComplete="off"
158
+ placeholder="JetBrains Mono"
159
+ onChangeText={(customFont) => {
160
+ setTypedCustomFont(customFont);
161
+ save({ customFont }, () => {
162
+ setTypedCustomFont((current) =>
163
+ current === customFont ? null : current,
164
+ );
165
+ });
166
+ }}
167
+ />
168
+ )}
169
+ />
170
+ ) : null}
171
+
172
+ <Suspense
173
+ fallback={
174
+ <View className="items-center py-m">
175
+ <IndeterminateCircularProgress loading />
176
+ </View>
177
+ }
178
+ >
179
+ <DiffThemePreview
180
+ theme={previewTheme}
181
+ fontFamily={diffFontFamily(previewFont, previewCustomFont)}
182
+ themeType={mode}
183
+ />
184
+ </Suspense>
185
+
186
+ {error === null ? null : <ErrorMessage>{error}</ErrorMessage>}
187
+ </Box>
188
+ );
189
+ }
@@ -0,0 +1,63 @@
1
+ import { parseDiffFromFile } from "@pierre/diffs";
2
+ import { FileDiff } from "@pierre/diffs/react";
3
+ import type { DiffTheme } from "@reviewflow/sdk";
4
+ import type { ReactNode } from "react";
5
+ import { diffFontCSS } from "./branch-diff/diff-font.ts";
6
+ import {
7
+ changeBackgroundCSS,
8
+ diffThemeNames,
9
+ } from "./branch-diff/diff-themes.ts";
10
+
11
+ const before = `import { readFile } from "node:fs/promises";
12
+
13
+ /** Reads the settings, or null when there are none. */
14
+ export async function loadSettings(path: string) {
15
+ const contents = await readFile(path, "utf8");
16
+ return JSON.parse(contents);
17
+ }
18
+ `;
19
+
20
+ const after = `import { readFile } from "node:fs/promises";
21
+
22
+ const defaultTheme = "github";
23
+
24
+ /** Reads the settings, or the defaults when there are none. */
25
+ export async function loadSettings(path: string): Promise<Settings> {
26
+ const contents = await readFile(path, "utf8").catch(() => "{}");
27
+ return { theme: defaultTheme, ...JSON.parse(contents) };
28
+ }
29
+ `;
30
+
31
+ const sample = parseDiffFromFile(
32
+ { name: "settings.ts", contents: before },
33
+ { name: "settings.ts", contents: after },
34
+ );
35
+
36
+ interface DiffThemePreviewProps {
37
+ theme: DiffTheme;
38
+ fontFamily: string | null;
39
+ themeType: "dark" | "light";
40
+ }
41
+
42
+ /**
43
+ * A fixed diff rendered in the picked theme and font, in the app's current
44
+ * mode. Its own lazy chunk, like the diff viewers: it carries shiki.
45
+ */
46
+ export default function DiffThemePreview({
47
+ theme,
48
+ fontFamily,
49
+ themeType,
50
+ }: DiffThemePreviewProps): ReactNode {
51
+ return (
52
+ <FileDiff
53
+ fileDiff={sample}
54
+ options={{
55
+ diffStyle: "unified",
56
+ theme: diffThemeNames(theme),
57
+ themeType,
58
+ overflow: "wrap",
59
+ unsafeCSS: `${diffFontCSS(fontFamily)} ${changeBackgroundCSS(theme)}`,
60
+ }}
61
+ />
62
+ );
63
+ }
@@ -0,0 +1,91 @@
1
+ export type FileKind =
2
+ | "config"
3
+ | "css"
4
+ | "file"
5
+ | "html"
6
+ | "image"
7
+ | "javascript"
8
+ | "json"
9
+ | "jsx"
10
+ | "lock"
11
+ | "markdown"
12
+ | "package"
13
+ | "python"
14
+ | "rust"
15
+ | "shell"
16
+ | "sql"
17
+ | "svg"
18
+ | "text"
19
+ | "tsx"
20
+ | "typescript"
21
+ | "vue";
22
+
23
+ const lockFiles = new Set([
24
+ "bun.lock",
25
+ "bun.lockb",
26
+ "composer.lock",
27
+ "Gemfile.lock",
28
+ "package-lock.json",
29
+ "pnpm-lock.yaml",
30
+ "poetry.lock",
31
+ "yarn.lock",
32
+ ]);
33
+
34
+ const kindByExtension: Readonly<Record<string, FileKind>> = {
35
+ ts: "typescript",
36
+ mts: "typescript",
37
+ cts: "typescript",
38
+ tsx: "tsx",
39
+ js: "javascript",
40
+ mjs: "javascript",
41
+ cjs: "javascript",
42
+ jsx: "jsx",
43
+ json: "json",
44
+ jsonc: "json",
45
+ json5: "json",
46
+ md: "markdown",
47
+ mdx: "markdown",
48
+ css: "css",
49
+ scss: "css",
50
+ sass: "css",
51
+ less: "css",
52
+ html: "html",
53
+ htm: "html",
54
+ png: "image",
55
+ jpg: "image",
56
+ jpeg: "image",
57
+ gif: "image",
58
+ webp: "image",
59
+ avif: "image",
60
+ ico: "image",
61
+ svg: "svg",
62
+ yaml: "config",
63
+ yml: "config",
64
+ toml: "config",
65
+ ini: "config",
66
+ env: "config",
67
+ sh: "shell",
68
+ bash: "shell",
69
+ zsh: "shell",
70
+ fish: "shell",
71
+ sql: "sql",
72
+ py: "python",
73
+ rs: "rust",
74
+ vue: "vue",
75
+ txt: "text",
76
+ log: "text",
77
+ };
78
+
79
+ /** What a file is, from its name, for the icon standing for it. */
80
+ export const fileKindOf = (path: string): FileKind => {
81
+ const name = path.slice(path.lastIndexOf("/") + 1);
82
+ if (lockFiles.has(name) || name.endsWith(".lock")) return "lock";
83
+ if (name === "package.json") return "package";
84
+ const dot = name.lastIndexOf(".");
85
+ const known =
86
+ dot > 0 ? kindByExtension[name.slice(dot + 1).toLowerCase()] : undefined;
87
+ if (known !== undefined) return known;
88
+ // a dotfile without a known extension (.gitignore, .npmrc, .env)
89
+ if (name.startsWith(".")) return "config";
90
+ return "file";
91
+ };
@@ -0,0 +1,70 @@
1
+ import type { SVGIconElement } from "alouette";
2
+ import { Icon } from "alouette";
3
+ import { BracketsCurlyRegularIcon } from "alouette-icons/phosphor-icons/BracketsCurly";
4
+ import { FileRegularIcon } from "alouette-icons/phosphor-icons/File";
5
+ import { FileCssRegularIcon } from "alouette-icons/phosphor-icons/FileCss";
6
+ import { FileHtmlRegularIcon } from "alouette-icons/phosphor-icons/FileHtml";
7
+ import { FileImageRegularIcon } from "alouette-icons/phosphor-icons/FileImage";
8
+ import { FileJsRegularIcon } from "alouette-icons/phosphor-icons/FileJs";
9
+ import { FileJsxRegularIcon } from "alouette-icons/phosphor-icons/FileJsx";
10
+ import { FileLockRegularIcon } from "alouette-icons/phosphor-icons/FileLock";
11
+ import { FileMdRegularIcon } from "alouette-icons/phosphor-icons/FileMd";
12
+ import { FilePyRegularIcon } from "alouette-icons/phosphor-icons/FilePy";
13
+ import { FileRsRegularIcon } from "alouette-icons/phosphor-icons/FileRs";
14
+ import { FileSqlRegularIcon } from "alouette-icons/phosphor-icons/FileSql";
15
+ import { FileSvgRegularIcon } from "alouette-icons/phosphor-icons/FileSvg";
16
+ import { FileTsRegularIcon } from "alouette-icons/phosphor-icons/FileTs";
17
+ import { FileTsxRegularIcon } from "alouette-icons/phosphor-icons/FileTsx";
18
+ import { FileTxtRegularIcon } from "alouette-icons/phosphor-icons/FileTxt";
19
+ import { FileVueRegularIcon } from "alouette-icons/phosphor-icons/FileVue";
20
+ import { GearSixRegularIcon } from "alouette-icons/phosphor-icons/GearSix";
21
+ import { PackageRegularIcon } from "alouette-icons/phosphor-icons/Package";
22
+ import { TerminalRegularIcon } from "alouette-icons/phosphor-icons/Terminal";
23
+ import { TestTubeRegularIcon } from "alouette-icons/phosphor-icons/TestTube";
24
+ import type { ReactNode } from "react";
25
+ import type { FileKind } from "./file-kind.ts";
26
+ import { fileKindOf } from "./file-kind.ts";
27
+
28
+ const glyphOf = (kind: FileKind): SVGIconElement => {
29
+ if (kind === "typescript") return <FileTsRegularIcon />;
30
+ if (kind === "tsx") return <FileTsxRegularIcon />;
31
+ if (kind === "javascript") return <FileJsRegularIcon />;
32
+ if (kind === "jsx") return <FileJsxRegularIcon />;
33
+ if (kind === "json") return <BracketsCurlyRegularIcon />;
34
+ if (kind === "package") return <PackageRegularIcon />;
35
+ if (kind === "lock") return <FileLockRegularIcon />;
36
+ if (kind === "markdown") return <FileMdRegularIcon />;
37
+ if (kind === "css") return <FileCssRegularIcon />;
38
+ if (kind === "html") return <FileHtmlRegularIcon />;
39
+ if (kind === "image") return <FileImageRegularIcon />;
40
+ if (kind === "svg") return <FileSvgRegularIcon />;
41
+ if (kind === "config") return <GearSixRegularIcon />;
42
+ if (kind === "shell") return <TerminalRegularIcon />;
43
+ if (kind === "sql") return <FileSqlRegularIcon />;
44
+ if (kind === "python") return <FilePyRegularIcon />;
45
+ if (kind === "rust") return <FileRsRegularIcon />;
46
+ if (kind === "vue") return <FileVueRegularIcon />;
47
+ if (kind === "text") return <FileTxtRegularIcon />;
48
+ return <FileRegularIcon />;
49
+ };
50
+
51
+ interface FileTypeIconProps {
52
+ path: string;
53
+ /** a test or a story, whatever its extension */
54
+ test: boolean;
55
+ size: number;
56
+ }
57
+
58
+ export function FileTypeIcon({
59
+ path,
60
+ test,
61
+ size,
62
+ }: FileTypeIconProps): ReactNode {
63
+ return (
64
+ <Icon
65
+ icon={test ? <TestTubeRegularIcon /> : glyphOf(fileKindOf(path))}
66
+ size={size}
67
+ className="text-muted"
68
+ />
69
+ );
70
+ }
@@ -51,11 +51,14 @@ export interface ReviewDraftActions {
51
51
  remove: (key: string) => void;
52
52
  }
53
53
 
54
- /** Flat: it sits inside the file's card, which is already raised. */
54
+ /**
55
+ * Raised off the code on its own highlight ground, not the line's: a selected
56
+ * line's tint would show through, and a hairline left it reading as code.
57
+ */
55
58
  function Frame({ children }: { children: ReactNode }): ReactNode {
56
59
  return (
57
60
  <View className="p-xs">
58
- <View className="min-w-0 gap-sm rounded-sm border border-border-muted p-m">
61
+ <View className="min-w-0 gap-sm rounded-sm bg-highlight p-m shadow-s">
59
62
  {children}
60
63
  </View>
61
64
  </View>
@@ -1,5 +1,12 @@
1
1
  import { animationDurationsMs } from "alouette";
2
- import { createContext, use, useSyncExternalStore } from "react";
2
+ import type { RefObject } from "react";
3
+ import {
4
+ createContext,
5
+ use,
6
+ useEffect,
7
+ useState,
8
+ useSyncExternalStore,
9
+ } from "react";
3
10
 
4
11
  /** How long a file gone to stays outlined before the outline fades. */
5
12
  const highlightHoldMs = 1500;
@@ -20,6 +27,8 @@ export interface DiffFileElements {
20
27
  /** returns the unregistration */
21
28
  register: (path: string, element: HTMLElement) => () => void;
22
29
  elementOf: (path: string) => HTMLElement | undefined;
30
+ /** the file whose element spans the viewport's `y`; null in a gap or past the files */
31
+ fileAt: (y: number) => string | null;
23
32
  /** outlines the file's header in `color`, replacing any outline still showing */
24
33
  highlight: (path: string, color: string) => void;
25
34
  subscribe: (listener: () => void) => () => void;
@@ -43,6 +52,13 @@ export const createDiffFileElements = (): DiffFileElements => {
43
52
  };
44
53
  },
45
54
  elementOf: (path) => elements.get(path),
55
+ fileAt: (y) => {
56
+ for (const [path, element] of elements) {
57
+ const { top, bottom } = element.getBoundingClientRect();
58
+ if (top <= y && y < bottom) return path;
59
+ }
60
+ return null;
61
+ },
46
62
  highlight: (path, color) => {
47
63
  for (const timer of timers) clearTimeout(timer);
48
64
  setHighlighted({ path, phase: "shown", color });
@@ -73,6 +89,48 @@ export const DiffFileElementsContext = createContext<DiffFileElements | null>(
73
89
  export const useDiffFileElements = (): DiffFileElements | null =>
74
90
  use(DiffFileElementsContext);
75
91
 
92
+ /**
93
+ * The file read at the moment: the one under the bottom edge of `bar`, the
94
+ * sticky toolbar the diff scrolls beneath. Measured once a frame while
95
+ * anything scrolls, on the web only; it keeps the last file found while the
96
+ * edge is over a gap between files, and is null until one is.
97
+ */
98
+ export const useFileInView = (
99
+ fileElements: DiffFileElements,
100
+ bar: RefObject<unknown>,
101
+ enabled: boolean,
102
+ ): string | null => {
103
+ const [path, setPath] = useState<string | null>(null);
104
+ useEffect(() => {
105
+ if (!enabled || typeof document === "undefined") return undefined;
106
+ let frame: number | null = null;
107
+ const measure = (): void => {
108
+ frame = null;
109
+ const element = bar.current;
110
+ if (!(element instanceof HTMLElement)) return;
111
+ const found = fileElements.fileAt(
112
+ element.getBoundingClientRect().bottom + 1,
113
+ );
114
+ if (found !== null) setPath(found);
115
+ };
116
+ const schedule = (): void => {
117
+ frame ??= requestAnimationFrame(measure);
118
+ };
119
+ schedule();
120
+ document.addEventListener("scroll", schedule, {
121
+ capture: true,
122
+ passive: true,
123
+ });
124
+ window.addEventListener("resize", schedule);
125
+ return () => {
126
+ if (frame !== null) cancelAnimationFrame(frame);
127
+ document.removeEventListener("scroll", schedule, { capture: true });
128
+ window.removeEventListener("resize", schedule);
129
+ };
130
+ }, [fileElements, bar, enabled]);
131
+ return enabled ? path : null;
132
+ };
133
+
76
134
  const noSubscription = (): (() => void) => () => {};
77
135
 
78
136
  /** Re-renders only the file whose highlight changes, not every file of the diff. */
@@ -0,0 +1,57 @@
1
+ export interface OverviewTreeRow {
2
+ /** `workspaceIdOf` or `fileIdOf`: a workspace and a file may share a path */
3
+ id: string;
4
+ kind: "file" | "workspace";
5
+ /** 1 for a workspace, 2 for its files, 3 for a test below its subject */
6
+ level: 1 | 2 | 3;
7
+ parent: string | null;
8
+ /** a workspace's rows are listed; false for a file */
9
+ expanded: boolean;
10
+ }
11
+
12
+ export const workspaceIdOf = (name: string): string => `workspace:${name}`;
13
+ export const fileIdOf = (path: string): string => `file:${path}`;
14
+
15
+ export type OverviewTreeAction =
16
+ | { type: "focus"; id: string }
17
+ | { type: "press"; id: string }
18
+ | { type: "toggle"; id: string };
19
+
20
+ /**
21
+ * What a key does on the focused row, as the WAI-ARIA tree pattern has it:
22
+ * up and down move a row, home and end to either end, right opens a closed
23
+ * workspace or enters an open one, left closes an open workspace or goes up to
24
+ * the parent, space presses the row. Enter is left to the pressable, which
25
+ * presses on it already. Null for any other key.
26
+ */
27
+ export const overviewTreeActionOf = (
28
+ rows: readonly OverviewTreeRow[],
29
+ focusedId: string,
30
+ key: string,
31
+ ): OverviewTreeAction | null => {
32
+ const index = rows.findIndex(({ id }) => id === focusedId);
33
+ const row = rows[index];
34
+ if (row === undefined) return null;
35
+ const focus = (
36
+ target: OverviewTreeRow | undefined,
37
+ ): OverviewTreeAction | null =>
38
+ target === undefined ? null : { type: "focus", id: target.id };
39
+ if (key === "ArrowDown") return focus(rows[index + 1]);
40
+ if (key === "ArrowUp") return focus(rows[index - 1]);
41
+ if (key === "Home") return focus(rows[0]);
42
+ if (key === "End") return focus(rows.at(-1));
43
+ if (key === " ") return { type: "press", id: row.id };
44
+ if (key === "ArrowRight") {
45
+ if (row.kind === "file") return null;
46
+ if (!row.expanded) return { type: "toggle", id: row.id };
47
+ const child = rows[index + 1];
48
+ return child?.parent === row.id ? focus(child) : null;
49
+ }
50
+ if (key === "ArrowLeft") {
51
+ if (row.kind === "workspace" && row.expanded) {
52
+ return { type: "toggle", id: row.id };
53
+ }
54
+ return row.parent === null ? null : { type: "focus", id: row.parent };
55
+ }
56
+ return null;
57
+ };