authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 08:15:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 08:15:51-07:00
logb3e1d88590489da2bb63b95c18a57e6f400b8b7f
tree4aea3286a686cfe9eb3e344c6b1935fe204af243
parentc163d502dd32b993c03d8c20a5fae55b159bd8dc
signature Signed by PGP key B5690EEEBB952194

perf(web): defer diff workers until a code view opens (#9692)


11 files changed, 802 insertions(+), 274 deletions(-)

apps/web/src/components/ChatView.tsx+1-10
......@@ -179,7 +179,6 @@ import {
179179 deriveAgentPanelModel,
180180 foldSubagentActivities,
181181} from "@t3tools/client-runtime/state/subagentRuntime";
182import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
183182import { BranchToolbar } from "./BranchToolbar";
184183import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
185184import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
......@@ -1351,7 +1350,7 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
13511350 return current.messageId === null ? current : { ...current, messageId: null };
13521351}
13531352
1354function ChatViewContent(props: ChatViewProps) {
1353export default function ChatView(props: ChatViewProps) {
13551354 const {
13561355 environmentId,
13571356 threadId,
......@@ -8226,11 +8225,3 @@ function ChatViewContent(props: ChatViewProps) {
82268225 </div>
82278226 );
82288227}
8229
8230export default function ChatView(props: ChatViewProps) {
8231 return (
8232 <DiffWorkerPoolProvider>
8233 <ChatViewContent {...props} />
8234 </DiffWorkerPoolProvider>
8235 );
8236}
apps/web/src/components/DiffPanel.tsx+13-14
......@@ -24,6 +24,7 @@ import {
2424} from "lucide-react";
2525import * as Schema from "effect/Schema";
2626import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
2728import { useOpenInPreferredEditor } from "../editorPreferences";
2829import { type DraftId } from "../composerDraftStore";
2930import { openDiffFilePrimaryAction } from "../diffFileActions";
......@@ -129,7 +130,7 @@ export default function DiffPanel({
129130 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
130131 }));
131132 const [codeViewRevision, setCodeViewRevision] = useState(0);
132 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
133 const [codeView, setCodeView] = useState<AnnotatableCodeViewHandle | null>(null);
133134
134135 const routeThreadRef = useParams({
135136 strict: false,
......@@ -448,17 +449,15 @@ export default function DiffPanel({
448449 : null;
449450
450451 useEffect(() => {
451 if (!selectedDiffFileKey) return;
452 codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
453 }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
452 if (!selectedDiffFileKey || !codeView?.getInstance()) return;
453 codeView.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
454 }, [codeView, codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
454455
455 // Held as state so the scroll runs after a collapsed file has been drawn open again; scrolling
456 // in the same tick would land on the folded header's position.
457 const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null);
458 useEffect(() => {
459 if (treeReveal === null) return;
460 codeViewRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" });
461 }, [treeReveal]);
456 const treeRevealScope = useMemo(
457 () => ({ collapseScopeKey, diffSelection }),
458 [collapseScopeKey, diffSelection],
459 );
460 const requestTreeReveal = useCodeViewFileReveal(codeView, treeRevealScope);
462461 const revealDiffFile = useCallback(
463462 (filePath: string) => {
464463 const file = codeViewFiles.find((candidate) => candidate.filePath === filePath);
......@@ -470,9 +469,9 @@ export default function DiffPanel({
470469 return { scopeKey: collapseScopeKey, fileKeys: next };
471470 });
472471 }
473 setTreeReveal((current) => ({ fileKey: file.fileKey, id: (current?.id ?? 0) + 1 }));
472 requestTreeReveal(file.fileKey);
474473 },
475 [codeViewFiles, collapseScopeKey],
474 [codeViewFiles, collapseScopeKey, requestTreeReveal],
476475 );
477476
478477 const openDiffFile = useCallback(
......@@ -972,7 +971,7 @@ export default function DiffPanel({
972971 >
973972 <AnnotatableCodeView
974973 key={collapseScopeKey ?? reviewSectionId}
975 viewerRef={codeViewRef}
974 viewerRef={setCodeView}
976975 codeViewKey={codeViewMountKey}
977976 className="h-full min-h-0 overflow-auto"
978977 files={codeViewFiles}
apps/web/src/components/DiffWorkerPoolProvider.tsx+35-2
......@@ -1,7 +1,7 @@
11import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";
22import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";
33import * as Schema from "effect/Schema";
4import { useEffect, useMemo, type ReactNode } from "react";
4import { useEffect, useMemo, useState, type ReactNode } from "react";
55import { useTheme } from "../hooks/useTheme";
66import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
77import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting";
......@@ -46,6 +46,39 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {
4646 return null;
4747}
4848
49// Plain-text views do not queue a highlight task that could retry a blank first render.
50function DiffWorkerReady({ children }: { children?: ReactNode }) {
51 const workerPool = useWorkerPool();
52 const [ready, setReady] = useState(
53 () => !workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool(),
54 );
55
56 useEffect(() => {
57 if (ready || !workerPool) return;
58
59 let mounted = true;
60 const finish = () => {
61 if (mounted) setReady(true);
62 };
63 // Failed pools use Pierre's existing main-thread highlighter.
64 void workerPool.initialize().then(finish, finish);
65 return () => {
66 mounted = false;
67 };
68 }, [ready, workerPool]);
69
70 return ready ? (
71 children
72 ) : (
73 <div
74 role="status"
75 className="flex min-h-0 flex-1 items-center justify-center p-4 text-xs text-muted-foreground"
76 >
77 Loading code...
78 </div>
79 );
80}
81
4982export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
5083 const { resolvedTheme } = useTheme();
5184 const diffThemeName = resolveDiffThemeName(resolvedTheme);
......@@ -80,7 +113,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
80113 }}
81114 >
82115 <DiffWorkerThemeSync themeName={diffThemeName} />
83 {children}
116 <DiffWorkerReady>{children}</DiffWorkerReady>
84117 </WorkerPoolContextProvider>
85118 );
86119}
apps/web/src/components/chat/MessagesTimeline.test.tsx+4
......@@ -122,6 +122,10 @@ vi.mock("@pierre/diffs/react", () => {
122122 return { FileDiff: MockFileDiff };
123123});
124124
125vi.mock("../DiffWorkerPoolProvider", () => ({
126 DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children,
127}));
128
125129function matchMedia() {
126130 return {
127131 matches: false,
apps/web/src/components/chat/MessagesTimeline.tsx+17-13
......@@ -50,6 +50,7 @@ import {
5050 type MaintainScrollAtEndOptions,
5151} from "@legendapp/list/react";
5252import { FileDiff } from "@pierre/diffs/react";
53import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
5354import {
5455 deriveTimelineEntries,
5556 workEntryDisplayIndicatesToolFailure,
......@@ -2530,19 +2531,22 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
25302531 className="text-message-foreground"
25312532 />
25322533 )}
2533 {renderablePatch?.kind === "files" &&
2534 renderablePatch.files.map((fileDiff) => (
2535 <FileDiff
2536 key={resolveFileDiffPath(fileDiff)}
2537 fileDiff={fileDiff}
2538 options={{
2539 collapsed: false,
2540 diffStyle: "unified",
2541 theme: resolveDiffThemeName(ctx.resolvedTheme),
2542 preferredHighlighter: PREFERRED_HIGHLIGHTER,
2543 }}
2544 />
2545 ))}
2534 {renderablePatch?.kind === "files" && (
2535 <DiffWorkerPoolProvider>
2536 {renderablePatch.files.map((fileDiff) => (
2537 <FileDiff
2538 key={resolveFileDiffPath(fileDiff)}
2539 fileDiff={fileDiff}
2540 options={{
2541 collapsed: false,
2542 diffStyle: "unified",
2543 theme: resolveDiffThemeName(ctx.resolvedTheme),
2544 preferredHighlighter: PREFERRED_HIGHLIGHTER,
2545 }}
2546 />
2547 ))}
2548 </DiffWorkerPoolProvider>
2549 )}
25462550 {renderablePatch?.kind === "raw" && (
25472551 <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs">
25482552 {renderablePatch.text}
apps/web/src/components/diffs/AnnotatableCodeView.test.tsx+5
......@@ -1,3 +1,4 @@
1import type { ReactNode } from "react";
12import { renderToStaticMarkup } from "react-dom/server";
23import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
34
......@@ -12,6 +13,10 @@ vi.mock("@pierre/diffs/react", () => ({
1213 },
1314}));
1415
16vi.mock("../DiffWorkerPoolProvider", () => ({
17 DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children,
18}));
19
1520vi.mock("~/composerDraftStore", () => ({
1621 useComposerDraftStore: (selector: (store: Record<string, unknown>) => unknown) =>
1722 selector({
apps/web/src/components/diffs/StyledDiffCodeView.test.tsx+474-6
......@@ -1,20 +1,153 @@
1import type * as NodeWorkerThreads from "node:worker_threads";
2import {
3 FileRenderer,
4 getSharedHighlighter,
5 type CodeViewScrollTarget,
6 type FileContents,
7} from "@pierre/diffs";
8import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react";
9import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker";
10import {
11 act,
12 useEffect,
13 useImperativeHandle,
14 useLayoutEffect,
15 useMemo,
16 useRef,
17 useState,
18 type Ref,
19} from "react";
120import { renderToStaticMarkup } from "react-dom/server";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
21import { create, type ReactTestRenderer } from "react-test-renderer";
22import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
323
424const testState = vi.hoisted(() => ({
525 codeViewClassName: null as string | null,
626 codeViewOptions: null as Record<string, unknown> | null,
27 workers: [] as NodeWorkerThreads.Worker[],
28 terminations: [] as Promise<number>[],
29 requests: [] as WorkerRequest[],
30 pools: new Map<WorkerPoolManager, Promise<void>>(),
31 renderPools: [] as (WorkerPoolManager | undefined)[],
32 failWorkers: false,
33 holdInitialization: false,
34 heldResponses: [] as (() => void)[],
35 onInitializationHeld: undefined as (() => void) | undefined,
736}));
837
9vi.mock("@pierre/diffs/react", () => ({
10 CodeView: (props: { className: string; options: Record<string, unknown> }) => {
11 testState.codeViewClassName = props.className;
12 testState.codeViewOptions = props.options;
13 return null;
38vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
39
40vi.mock("@pierre/diffs/worker/worker.js?worker", async () => {
41 const { Worker } = await import("node:worker_threads");
42 const moduleUrl = import.meta.resolve("@pierre/diffs/worker/worker.js");
43
44 // Adapt only the transport. Pierre's real worker loads its WASM and produces every response.
45 return {
46 default: class {
47 worker: NodeWorkerThreads.Worker;
48
49 constructor() {
50 if (testState.failWorkers) throw new Error("Worker creation failed");
51 this.worker = new Worker(
52 `const { parentPort, workerData } = require("node:worker_threads");
53 globalThis.self = {
54 addEventListener(type, listener) {
55 if (type === "message") parentPort.on("message", data => listener({ data }));
56 if (type === "error") process.on("uncaughtException", listener);
57 }
58 };
59 globalThis.postMessage = data => parentPort.postMessage(data);
60 import(workerData.moduleUrl);`,
61 { eval: true, workerData: { moduleUrl }, execArgv: [] },
62 );
63 testState.workers.push(this.worker);
64 }
65
66 addEventListener(
67 type: "message" | "error",
68 listener: (event: { data: WorkerResponse } | Error) => void,
69 ) {
70 if (type === "error") {
71 this.worker.on("error", listener);
72 return;
73 }
74 this.worker.on("message", (data: WorkerResponse) => {
75 const deliver = () => listener({ data });
76 if (
77 testState.holdInitialization &&
78 data.type === "success" &&
79 data.requestType === "initialize"
80 ) {
81 testState.heldResponses.push(deliver);
82 if (testState.heldResponses.length === 2) testState.onInitializationHeld?.();
83 } else {
84 deliver();
85 }
86 });
87 }
88
89 postMessage(message: WorkerRequest) {
90 testState.requests.push(message);
91 this.worker.postMessage(message, []);
92 }
93
94 terminate() {
95 testState.terminations.push(this.worker.terminate());
96 }
97 },
98 };
99});
100
101vi.mock("@pierre/diffs/react", async (importOriginal) => ({
102 ...(await importOriginal<typeof import("@pierre/diffs/react")>()),
103 CodeView: (props: CodeViewProps) => {
104 testState.codeViewClassName = props.className ?? null;
105 testState.codeViewOptions = props.options ? { ...props.options } : null;
106 return props.items?.map((item) =>
107 item.type === "file" ? <FileOutput key={item.id} file={item.file} /> : null,
108 );
14109 },
15110}));
16111
17112import { StyledDiffCodeView } from "./StyledDiffCodeView";
113import { useCodeViewFileReveal } from "./useCodeViewFileReveal";
114
115function FileOutput({ file }: { file: FileContents }) {
116 const pool = useWorkerPool();
117 const [html, setHtml] = useState("");
118 useEffect(() => {
119 testState.renderPools.push(pool);
120 const renderer = new FileRenderer(
121 { theme: "pierre-dark", preferredHighlighter: "shiki-wasm" },
122 render,
123 pool,
124 );
125 function render() {
126 const result = renderer.renderFile(file);
127 if (result) setHtml(renderer.renderFullHTML(result));
128 }
129 render();
130 return () => renderer.cleanUp();
131 }, [file, pool]);
132 return <pre data-code-file={file.name}>{html}</pre>;
133}
134
135const files = [
136 { name: "notes.txt", contents: "Plain text is ready.\n", cacheKey: "plain-text" },
137 { name: "answer.ts", contents: "const answer = 42;\n", cacheKey: "typescript" },
138] satisfies FileContents[];
139
140function Views({ count }: { count: number }) {
141 const [draft, setDraft] = useState("Draft");
142 return (
143 <>
144 <button onClick={() => setDraft("Edited draft")}>{draft}</button>
145 {files.slice(0, count).map((file) => (
146 <StyledDiffCodeView key={file.name} items={[{ type: "file", id: file.name, file }]} />
147 ))}
148 </>
149 );
150}
18151
19152describe("StyledDiffCodeView", () => {
20153 beforeEach(() => {
......@@ -58,3 +191,338 @@ describe("StyledDiffCodeView", () => {
58191 );
59192 });
60193});
194
195describe("code-view worker lifecycle", () => {
196 let renderer: ReactTestRenderer | undefined;
197
198 beforeEach(() => {
199 testState.workers = [];
200 testState.terminations = [];
201 testState.requests = [];
202 testState.pools.clear();
203 testState.renderPools = [];
204 testState.failWorkers = false;
205 testState.holdInitialization = false;
206 testState.heldResponses = [];
207 testState.onInitializationHeld = undefined;
208 vi.stubGlobal("window", {});
209 vi.stubGlobal("navigator", { hardwareConcurrency: 2 });
210 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
211 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) =>
212 setImmediate(() => callback(0)),
213 );
214 vi.stubGlobal("cancelAnimationFrame", clearImmediate);
215 const initialize = WorkerPoolManager.prototype.initialize;
216 vi.spyOn(WorkerPoolManager.prototype, "initialize").mockImplementation(function (
217 this: WorkerPoolManager,
218 ...args
219 ) {
220 const pending = initialize.apply(this, args);
221 testState.pools.set(this, pending);
222 return pending;
223 });
224 });
225
226 afterEach(async () => {
227 await act(async () => renderer?.unmount());
228 renderer = undefined;
229 await Promise.all(testState.terminations);
230 vi.restoreAllMocks();
231 vi.unstubAllGlobals();
232 });
233
234 it("starts on demand, shares one pool, disposes the last view, and reopens without resetting siblings", async () => {
235 await act(async () => {
236 renderer = create(<Views count={0} />);
237 });
238 const mounted = renderer!;
239 const button = mounted.root.findByType("button");
240 expect(testState.workers).toHaveLength(0);
241 expect(testState.pools.size).toBe(0);
242 await act(async () => {
243 (button.props as { onClick(): void }).onClick();
244 mounted.update(<Views count={1} />);
245 });
246 const pool = [...testState.pools.keys()][0]!;
247 await act(async () => testState.pools.get(pool));
248 expect(pool.isInitialized()).toBe(true);
249 expect(testState.workers).toHaveLength(2);
250 expect(mounted.root.findByProps({ "data-code-file": "notes.txt" }).children.join("")).toContain(
251 "Plain text is ready.",
252 );
253 expect(testState.requests.filter((request) => request.type === "file")).toHaveLength(0);
254
255 await act(async () => mounted.update(<Views count={2} />));
256 await act(async () => pool.primeFileHighlightCache(files[1]!));
257 expect(testState.pools.size).toBe(1);
258 expect(testState.workers).toHaveLength(2);
259 expect(testState.renderPools).toEqual([pool, pool]);
260 expect(pool.getFileResultCache(files[1]!)).toBeDefined();
261 expect(mounted.root.findByProps({ "data-code-file": "answer.ts" }).children.join("")).toContain(
262 "answer",
263 );
264
265 await act(async () => mounted.update(<Views count={1} />));
266 expect(pool.getStats().totalWorkers).toBe(2);
267 expect(testState.terminations).toHaveLength(0);
268 await act(async () => mounted.update(<Views count={0} />));
269 await Promise.all(testState.terminations);
270 expect(pool.getStats().totalWorkers).toBe(0);
271 expect(testState.terminations).toHaveLength(2);
272
273 await act(async () => mounted.update(<Views count={1} />));
274 const reopenedPool = [...testState.pools.keys()][1]!;
275 await act(async () => testState.pools.get(reopenedPool));
276 expect(reopenedPool.isInitialized()).toBe(true);
277 expect(testState.workers).toHaveLength(4);
278 expect(mounted.root.findByType("button")).toBe(button);
279 expect(button.children).toEqual(["Edited draft"]);
280 expect(mounted.root.findByProps({ "data-code-file": "notes.txt" }).children.join("")).toContain(
281 "Plain text is ready.",
282 );
283 });
284
285 it("renders through Pierre's main-thread fallback when worker creation fails", async () => {
286 testState.failWorkers = true;
287 await act(async () => {
288 renderer = create(<Views count={1} />);
289 });
290 const pool = [...testState.pools.keys()][0]!;
291 await act(async () => {
292 await expect(testState.pools.get(pool)).rejects.toMatchObject({ _tag: "DiffWorkerError" });
293 await getSharedHighlighter({
294 themes: ["pierre-dark"],
295 langs: ["text"],
296 preferredHighlighter: "shiki-wasm",
297 });
298 });
299 expect(pool.isWorkingPool()).toBe(false);
300 expect(testState.workers).toHaveLength(0);
301 expect(
302 renderer!.root.findByProps({ "data-code-file": "notes.txt" }).children.join(""),
303 ).toContain("Plain text is ready.");
304 });
305
306 it("keeps an initializing pool until its last view closes and ignores late responses", async () => {
307 testState.holdInitialization = true;
308 const held = new Promise<void>((resolve) => {
309 testState.onInitializationHeld = resolve;
310 });
311 await act(async () => {
312 renderer = create(<Views count={2} />);
313 });
314 const pool = [...testState.pools.keys()][0]!;
315 const pending = testState.pools.get(pool)!;
316 await held;
317 expect(pool.isInitialized()).toBe(false);
318 expect(testState.pools.size).toBe(1);
319 expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(2);
320 expect(testState.renderPools).toHaveLength(0);
321
322 await act(async () => renderer!.update(<Views count={1} />));
323 expect(pool.getStats().totalWorkers).toBe(2);
324 expect(testState.terminations).toHaveLength(0);
325 await act(async () => renderer!.update(<Views count={0} />));
326 await pending;
327 await Promise.all(testState.terminations);
328 await act(async () => {
329 for (const deliver of testState.heldResponses) deliver();
330 });
331 expect(pool.getStats().totalWorkers).toBe(0);
332 expect(testState.workers).toHaveLength(2);
333 expect(testState.terminations).toHaveLength(2);
334 expect(testState.renderPools).toHaveLength(0);
335 expect(renderer!.root.findAllByType("pre")).toHaveLength(0);
336 });
337});
338
339type RevealHandle = NonNullable<Parameters<typeof useCodeViewFileReveal>[0]>;
340const revealCalls: {
341 scope: string;
342 fileKey: string;
343 mounted: boolean;
344 collapsed: boolean | undefined;
345}[] = [];
346
347function RevealViewer({
348 viewerRef,
349 scope,
350 collapsed,
351}: {
352 viewerRef: Ref<RevealHandle>;
353 scope: string;
354 collapsed: boolean;
355}) {
356 const instance = useRef<{ collapsed: boolean } | undefined>(undefined);
357 useLayoutEffect(() => {
358 instance.current = { collapsed };
359 return () => {
360 instance.current = undefined;
361 };
362 }, [collapsed]);
363 const handle = useMemo(
364 () => ({
365 getInstance: () => instance.current,
366 scrollTo: (target: CodeViewScrollTarget) => {
367 if (target.type !== "item") return;
368 revealCalls.push({
369 scope,
370 fileKey: target.id,
371 mounted: instance.current !== undefined,
372 collapsed: instance.current?.collapsed,
373 });
374 },
375 }),
376 [scope],
377 );
378 useImperativeHandle(viewerRef, () => handle, [handle]);
379 return null;
380}
381
382function RevealViews({
383 scope,
384 visible,
385 selectedFile = null,
386 requestId = 0,
387}: {
388 scope: string;
389 visible: boolean;
390 selectedFile?: string | null;
391 requestId?: number;
392}) {
393 const [viewer, setViewer] = useState<RevealHandle | null>(null);
394 const [collapsed, setCollapsed] = useState(true);
395 const selectedRequest = useMemo(() => ({ selectedFile, requestId }), [requestId, selectedFile]);
396 useEffect(() => {
397 if (!selectedRequest.selectedFile || !viewer?.getInstance()) return;
398 viewer.scrollTo({ type: "item", id: selectedRequest.selectedFile, align: "start" });
399 }, [selectedRequest, viewer]);
400 const revealScope = useMemo(() => ({ scope, selectedRequest }), [scope, selectedRequest]);
401 const requestReveal = useCodeViewFileReveal(viewer, revealScope);
402 return (
403 <>
404 {visible && (
405 <RevealViewer key={scope} viewerRef={setViewer} scope={scope} collapsed={collapsed} />
406 )}
407 <button
408 onClick={() => {
409 setCollapsed(false);
410 requestReveal("tree-file");
411 }}
412 >
413 Reveal file
414 </button>
415 </>
416 );
417}
418
419describe("code-view file reveals", () => {
420 let renderer: ReactTestRenderer | undefined;
421
422 beforeEach(() => {
423 revealCalls.length = 0;
424 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
425 });
426
427 afterEach(async () => {
428 await act(async () => renderer?.unmount());
429 renderer = undefined;
430 vi.unstubAllGlobals();
431 });
432
433 it("does not replay a handled tree click or scroll an old handle when a selected file remounts the view", async () => {
434 await act(async () => {
435 renderer = create(<RevealViews scope="first" visible selectedFile="external-a" />);
436 });
437 await act(async () => {
438 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
439 });
440 expect(revealCalls.at(-1)?.fileKey).toBe("tree-file");
441 revealCalls.length = 0;
442
443 await act(async () => {
444 renderer!.update(
445 <RevealViews scope="second" visible selectedFile="external-b" requestId={1} />,
446 );
447 });
448 expect(revealCalls).toEqual([
449 { scope: "second", fileKey: "external-b", mounted: true, collapsed: false },
450 ]);
451 });
452
453 it("waits for a live viewer and applies each pending tree click only once", async () => {
454 await act(async () => {
455 renderer = create(<RevealViews scope="diff" visible={false} selectedFile="external" />);
456 });
457 await act(async () => {
458 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
459 });
460 expect(revealCalls).toEqual([]);
461
462 await act(async () => {
463 renderer!.update(<RevealViews scope="diff" visible selectedFile="external" />);
464 });
465 expect(revealCalls.map((call) => call.fileKey)).toEqual(["external", "tree-file"]);
466 revealCalls.length = 0;
467 await act(async () => {
468 renderer!.update(<RevealViews scope="diff" visible={false} selectedFile="external" />);
469 });
470 await act(async () => {
471 renderer!.update(<RevealViews scope="diff" visible selectedFile="external" />);
472 });
473 expect(revealCalls).toEqual([
474 { scope: "diff", fileKey: "external", mounted: true, collapsed: false },
475 ]);
476
477 revealCalls.length = 0;
478 await act(async () => {
479 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
480 renderer!.update(<RevealViews scope="diff" visible={false} selectedFile="external" />);
481 });
482 expect(revealCalls).toEqual([]);
483 await act(async () => {
484 renderer!.update(<RevealViews scope="diff" visible selectedFile="external" />);
485 });
486 expect(revealCalls).toEqual([
487 { scope: "diff", fileKey: "external", mounted: true, collapsed: false },
488 { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false },
489 ]);
490 });
491
492 it("cancels pending tree clicks when an external selection or diff scope changes", async () => {
493 await act(async () => {
494 renderer = create(<RevealViews scope="first" visible={false} selectedFile="external-a" />);
495 });
496 await act(async () => {
497 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
498 });
499 await act(async () => {
500 renderer!.update(
501 <RevealViews scope="first" visible selectedFile="external-b" requestId={1} />,
502 );
503 });
504 expect(revealCalls.map((call) => call.fileKey)).toEqual(["external-b"]);
505 revealCalls.length = 0;
506
507 await act(async () => renderer!.update(<RevealViews scope="first" visible={false} />));
508 await act(async () => {
509 (renderer!.root.findByType("button").props as { onClick(): void }).onClick();
510 });
511 await act(async () => renderer!.update(<RevealViews scope="second" visible={false} />));
512 await act(async () => renderer!.update(<RevealViews scope="first" visible />));
513 expect(revealCalls).toEqual([]);
514 });
515
516 it("reveals after a collapsed file expands and honors another click on the same file", async () => {
517 await act(async () => {
518 renderer = create(<RevealViews scope="diff" visible />);
519 });
520 const reveal = renderer!.root.findByType("button").props as { onClick(): void };
521 await act(async () => reveal.onClick());
522 await act(async () => reveal.onClick());
523 expect(revealCalls).toEqual([
524 { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false },
525 { scope: "diff", fileKey: "tree-file", mounted: true, collapsed: false },
526 ]);
527 });
528});
apps/web/src/components/diffs/StyledDiffCodeView.tsx+36-33
......@@ -10,6 +10,7 @@ import {
1010import type { Ref } from "react";
1111
1212import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering";
13import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
1314
1415const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}
1516:is(
......@@ -285,38 +286,40 @@ export function StyledDiffCodeView<LAnnotation = undefined>({
285286 ...props
286287}: StyledDiffCodeViewProps<LAnnotation>) {
287288 return (
288 <CodeView<LAnnotation>
289 {...props}
290 {...(viewerRef ? { ref: viewerRef } : {})}
291 // The custom element itself is focusable for keyboard scrolling. Its native outline sits
292 // outside the panel clipping boundary; actual controls inside retain their own indicators.
293 className={
294 className
295 ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}`
296 : "diff-render-surface [--code-background:var(--background)] outline-none"
297 }
298 options={{
299 ...options,
300 unsafeCSS: unsafeCSSExtra
301 ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}`
302 : DIFF_VIEW_UNSAFE_CSS,
303 itemMetrics: {
304 diffHeaderHeight: 32,
305 hunkSeparatorHeight: 24,
306 // Pierre uses its general file spacing as a fallback in expanded-file layout paths.
307 // Keep it zero alongside the explicit paddingTop or expanding the first file can
308 // reintroduce the library's default 8px gap above its header.
309 spacing: 0,
310 paddingTop: 0,
311 // Unlike the gap above, the 8px under a file's last line is painted
312 // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has
313 // to count it: at zero every expanded file's virtual height ran 8px short of its
314 // rendered height, and the end of the list sat past the reachable scroll range —
315 // one clipped file row per expanded file above it.
316 paddingBottom: 8,
317 },
318 layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
319 }}
320 />
289 <DiffWorkerPoolProvider>
290 <CodeView<LAnnotation>
291 {...props}
292 {...(viewerRef ? { ref: viewerRef } : {})}
293 // The custom element itself is focusable for keyboard scrolling. Its native outline sits
294 // outside the panel clipping boundary; actual controls inside retain their own indicators.
295 className={
296 className
297 ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}`
298 : "diff-render-surface [--code-background:var(--background)] outline-none"
299 }
300 options={{
301 ...options,
302 unsafeCSS: unsafeCSSExtra
303 ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}`
304 : DIFF_VIEW_UNSAFE_CSS,
305 itemMetrics: {
306 diffHeaderHeight: 32,
307 hunkSeparatorHeight: 24,
308 // Pierre uses its general file spacing as a fallback in expanded-file layout paths.
309 // Keep it zero alongside the explicit paddingTop or expanding the first file can
310 // reintroduce the library's default 8px gap above its header.
311 spacing: 0,
312 paddingTop: 0,
313 // Unlike the gap above, the 8px under a file's last line is painted
314 // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has
315 // to count it: at zero every expanded file's virtual height ran 8px short of its
316 // rendered height, and the end of the list sat past the reachable scroll range —
317 // one clipped file row per expanded file above it.
318 paddingBottom: 8,
319 },
320 layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
321 }}
322 />
323 </DiffWorkerPoolProvider>
321324 );
322325}
apps/web/src/components/diffs/useCodeViewFileReveal.ts created+28
......@@ -0,0 +1,28 @@
1import type { CodeViewScrollTarget } from "@pierre/diffs";
2import { useCallback, useEffect, useRef, useState } from "react";
3
4interface FileRevealHandle {
5 getInstance(): object | undefined;
6 scrollTo(target: CodeViewScrollTarget): void;
7}
8
9// Wait for a mounted viewer and expanded rows, then apply each tree click once.
10// Keep scope stable until the diff or external file selection changes.
11export function useCodeViewFileReveal<TScope>(viewer: FileRevealHandle | null, scope: TScope) {
12 const [request, setRequest] = useState<{ fileKey: string; scope: TScope } | null>(null);
13 const handledRequest = useRef<typeof request>(null);
14
15 useEffect(() => {
16 if (request === null || handledRequest.current === request) return;
17 if (request.scope !== scope) {
18 handledRequest.current = request;
19 return;
20 }
21 if (!viewer?.getInstance()) return;
22
23 viewer.scrollTo({ type: "item", id: request.fileKey, align: "start" });
24 handledRequest.current = request;
25 }, [request, scope, viewer]);
26
27 return useCallback((fileKey: string) => setRequest({ fileKey, scope }), [scope]);
28}
apps/web/src/components/files/FilePreviewPanel.tsx+43-38
......@@ -12,6 +12,7 @@ import {
1212import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
1313import { Editor } from "@pierre/diffs/editor";
1414import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
15import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
1516import {
1617 isAtomCommandInterrupted,
1718 squashAtomCommandFailure,
......@@ -1304,46 +1305,50 @@ export default function FilePreviewPanel({
13041305 onPendingChange={onPendingChange}
13051306 />
13061307 ) : file.data.truncated || isHostFile ? (
1307 <Virtualizer
1308 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}
1309 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
1310 config={{
1311 overscrollSize: 600,
1312 intersectionObserverMargin: 1200,
1313 }}
1314 >
1315 <File
1316 file={{
1317 name: relativePath,
1318 contents: file.data.contents,
1319 cacheKey: projectFileCacheKey(cwd, relativePath, file.data.contents),
1308 <DiffWorkerPoolProvider>
1309 <Virtualizer
1310 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}
1311 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
1312 config={{
1313 overscrollSize: 600,
1314 intersectionObserverMargin: 1200,
13201315 }}
1321 options={{
1322 disableFileHeader: true,
1323 overflow: wordWrap ? "wrap" : "scroll",
1324 theme: resolveDiffThemeName(resolvedTheme),
1325 preferredHighlighter: PREFERRED_HIGHLIGHTER,
1326 themeType: resolvedTheme,
1327 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
1328 onPostRender: onFilePostRender,
1329 }}
1330 className="min-h-full"
1331 />
1332 </Virtualizer>
1316 >
1317 <File
1318 file={{
1319 name: relativePath,
1320 contents: file.data.contents,
1321 cacheKey: projectFileCacheKey(cwd, relativePath, file.data.contents),
1322 }}
1323 options={{
1324 disableFileHeader: true,
1325 overflow: wordWrap ? "wrap" : "scroll",
1326 theme: resolveDiffThemeName(resolvedTheme),
1327 preferredHighlighter: PREFERRED_HIGHLIGHTER,
1328 themeType: resolvedTheme,
1329 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,
1330 onPostRender: onFilePostRender,
1331 }}
1332 className="min-h-full"
1333 />
1334 </Virtualizer>
1335 </DiffWorkerPoolProvider>
13331336 ) : (
1334 <EditableFileSurface
1335 key={`${relativePath}:${resolvedTheme}`}
1336 environmentId={environmentId}
1337 cwd={cwd}
1338 relativePath={relativePath}
1339 composerDraftTarget={composerDraftTarget}
1340 contents={file.data.contents}
1341 resolvedTheme={resolvedTheme}
1342 revealRequestId={revealRequestId}
1343 wordWrap={wordWrap}
1344 onPostRender={onFilePostRender}
1345 onPendingChange={onPendingChange}
1346 />
1337 <DiffWorkerPoolProvider>
1338 <EditableFileSurface
1339 key={`${relativePath}:${resolvedTheme}`}
1340 environmentId={environmentId}
1341 cwd={cwd}
1342 relativePath={relativePath}
1343 composerDraftTarget={composerDraftTarget}
1344 contents={file.data.contents}
1345 resolvedTheme={resolvedTheme}
1346 revealRequestId={revealRequestId}
1347 wordWrap={wordWrap}
1348 onPostRender={onFilePostRender}
1349 onPendingChange={onPendingChange}
1350 />
1351 </DiffWorkerPoolProvider>
13471352 )
13481353 ) : null}
13491354 </div>
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+146-158
......@@ -58,9 +58,9 @@ import { useEnvironmentQuery } from "~/state/query";
5858import { useAtomCommand } from "~/state/use-atom-command";
5959
6060import { DiffPanelLoadingState } from "../DiffPanelShell";
61import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
6261import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
6362import { DiffFileTree } from "../diffs/DiffFileTree";
63import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal";
6464import { diffFileTreeEntries } from "../diffs/diffFileTree.logic";
6565import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView";
6666import { Button } from "../ui/button";
......@@ -250,7 +250,7 @@ export function PullRequestCodeTab({
250250 readonly slices: ReadonlyArray<DiffSlice>;
251251 }>({ key: "", cursor: null, slices: NO_SLICES });
252252 const parseCache = useRef(new Map<string, RenderablePatch>());
253 const viewerRef = useRef<CodeViewHandle<ReviewAnnotationGroup> | null>(null);
253 const [viewer, setViewer] = useState<CodeViewHandle<ReviewAnnotationGroup> | null>(null);
254254
255255 const referenceKey = pullRequestReviewKey(reference);
256256 const commit = selectedCommitOid;
......@@ -603,21 +603,15 @@ export function PullRequestCodeTab({
603603 [],
604604 );
605605
606 // Held as state so the scroll runs after a folded file has been drawn open; scrolling in the
607 // same tick would land on the folded header's position.
608 const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null);
609 useEffect(() => {
610 if (treeReveal === null) return;
611 viewerRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" });
612 }, [treeReveal]);
606 const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey);
613607 const revealFile = useCallback(
614608 (path: string) => {
615609 const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path);
616610 if (item === undefined) return;
617611 if (item.collapsed === true) toggleFile(item.id);
618 setTreeReveal((current) => ({ fileKey: item.id, id: (current?.id ?? 0) + 1 }));
612 requestTreeReveal(item.id);
619613 },
620 [items, toggleFile],
614 [items, requestTreeReveal, toggleFile],
621615 );
622616
623617 const toggleAllFiles = () => {
......@@ -1290,164 +1284,158 @@ export function PullRequestCodeTab({
12901284 );
12911285
12921286 return (
1293 <DiffWorkerPoolProvider>
1294 <div className="flex h-full min-h-0 flex-col">
1295 {toolbar}
1296 {/* Above the code, closed, and counted: these belong to the change rather than to any
1287 <div className="flex h-full min-h-0 flex-col">
1288 {toolbar}
1289 {/* Above the code, closed, and counted: these belong to the change rather than to any
12971290 line of it, and in the stream they read as cards dropped into the patch. */}
1298 {orphanFiles.size > 0 ? (
1299 <Collapsible
1300 className="shrink-0 border-b border-border/60"
1301 open={orphansOpen}
1302 onOpenChange={setOrphansOpen}
1303 >
1304 {/* Still a heading, so the section keeps its place in a screen reader's outline;
1291 {orphanFiles.size > 0 ? (
1292 <Collapsible
1293 className="shrink-0 border-b border-border/60"
1294 open={orphansOpen}
1295 onOpenChange={setOrphansOpen}
1296 >
1297 {/* Still a heading, so the section keeps its place in a screen reader's outline;
13051298 the count is spelled out there rather than left as a bare number. */}
1306 <h2>
1307 <CollapsibleTrigger className="flex w-full items-center gap-1.5 px-4 py-2 text-left text-xs text-muted-foreground">
1308 {/* While slices are still arriving a conversation may simply belong to a file
1299 <h2>
1300 <CollapsibleTrigger className="flex w-full items-center gap-1.5 px-4 py-2 text-left text-xs text-muted-foreground">
1301 {/* While slices are still arriving a conversation may simply belong to a file
13091302 that has not landed yet, which is not the same as being off the diff. */}
1310 <span>
1311 {nextCursor === null
1312 ? "Conversations not on the current diff"
1313 : "Conversations not on the diff loaded so far"}
1314 </span>
1315 <ChevronRightIcon
1316 aria-hidden
1317 className={cn("size-3.5 transition-transform", orphansOpen && "rotate-90")}
1318 />
1319 <span aria-hidden className="tabular-nums">
1320 {orphanThreads.length}
1321 </span>
1322 <span className="sr-only">
1323 {orphanThreads.length === 1
1324 ? "1 conversation"
1325 : `${orphanThreads.length} conversations`}
1326 </span>
1327 </CollapsibleTrigger>
1328 </h2>
1329 <CollapsiblePanel>
1330 {/* Capped: opened on a change with dozens of them, this would otherwise leave no
1303 <span>
1304 {nextCursor === null
1305 ? "Conversations not on the current diff"
1306 : "Conversations not on the diff loaded so far"}
1307 </span>
1308 <ChevronRightIcon
1309 aria-hidden
1310 className={cn("size-3.5 transition-transform", orphansOpen && "rotate-90")}
1311 />
1312 <span aria-hidden className="tabular-nums">
1313 {orphanThreads.length}
1314 </span>
1315 <span className="sr-only">
1316 {orphanThreads.length === 1
1317 ? "1 conversation"
1318 : `${orphanThreads.length} conversations`}
1319 </span>
1320 </CollapsibleTrigger>
1321 </h2>
1322 <CollapsiblePanel>
1323 {/* Capped: opened on a change with dozens of them, this would otherwise leave no
13311324 room for the diff it sits above. */}
1332 <div className="max-h-64 space-y-3 overflow-auto px-4 pb-3">
1333 {[...orphanFiles].map(([path, threads]) => (
1334 <div key={path}>
1335 <Tooltip>
1336 <TooltipTrigger
1337 render={
1338 <p className="truncate px-3 text-xs text-muted-foreground">{path}</p>
1339 }
1340 />
1341 <TooltipPopup side="top">{path}</TooltipPopup>
1342 </Tooltip>
1343 <div className="mt-1 space-y-2">
1344 {threads.map((thread) => (
1345 <div key={thread.id}>
1346 {thread.line === null ? null : (
1347 <p className="px-3 text-xs text-muted-foreground">Line {thread.line}</p>
1348 )}
1349 {renderThreadCard(thread)}
1350 </div>
1351 ))}
1352 </div>
1325 <div className="max-h-64 space-y-3 overflow-auto px-4 pb-3">
1326 {[...orphanFiles].map(([path, threads]) => (
1327 <div key={path}>
1328 <Tooltip>
1329 <TooltipTrigger
1330 render={<p className="truncate px-3 text-xs text-muted-foreground">{path}</p>}
1331 />
1332 <TooltipPopup side="top">{path}</TooltipPopup>
1333 </Tooltip>
1334 <div className="mt-1 space-y-2">
1335 {threads.map((thread) => (
1336 <div key={thread.id}>
1337 {thread.line === null ? null : (
1338 <p className="px-3 text-xs text-muted-foreground">Line {thread.line}</p>
1339 )}
1340 {renderThreadCard(thread)}
1341 </div>
1342 ))}
13531343 </div>
1354 ))}
1355 </div>
1356 </CollapsiblePanel>
1357 </Collapsible>
1358 ) : null}
1359 <div className="flex min-h-0 flex-1 overflow-hidden">
1360 {/* Relative wrapper so the review overlay floats over the diff rather than pushing it
1344 </div>
1345 ))}
1346 </div>
1347 </CollapsiblePanel>
1348 </Collapsible>
1349 ) : null}
1350 <div className="flex min-h-0 flex-1 overflow-hidden">
1351 {/* Relative wrapper so the review overlay floats over the diff rather than pushing it
13611352 up; the viewer inside still owns its own scrolling. */}
1362 <div
1363 className="relative min-h-0 min-w-0 flex-1"
1364 // The chevron answers this too, but the whole header row is the target a reader
1365 // actually aims for. The header lives in the viewer's shadow tree, so the capture
1366 // listener walks `composedPath` — the only way to see through the shadow boundary.
1367 onClickCapture={(event) => {
1368 const composedPath = event.nativeEvent.composedPath?.() ?? [];
1369 for (const node of composedPath) {
1370 if (!(node instanceof HTMLElement)) continue;
1371 // A control inside the header — the collapse chevron — handles itself, and
1372 // this capture listener fires before its own click does. Leave it alone or
1373 // the two toggles cancel out.
1374 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {
1375 return;
1376 }
1377 if (node.hasAttribute("data-diffs-header")) {
1378 const filePath = node.querySelector("[data-title]")?.textContent?.trim();
1379 if (filePath === undefined || filePath === "") return;
1380 const item = items.find(
1381 (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath,
1382 );
1383 if (item !== undefined) toggleFile(item.id);
1384 return;
1385 }
1353 <div
1354 className="relative min-h-0 min-w-0 flex-1"
1355 // The chevron answers this too, but the whole header row is the target a reader
1356 // actually aims for. The header lives in the viewer's shadow tree, so the capture
1357 // listener walks `composedPath` — the only way to see through the shadow boundary.
1358 onClickCapture={(event) => {
1359 const composedPath = event.nativeEvent.composedPath?.() ?? [];
1360 for (const node of composedPath) {
1361 if (!(node instanceof HTMLElement)) continue;
1362 // A control inside the header — the collapse chevron — handles itself, and
1363 // this capture listener fires before its own click does. Leave it alone or
1364 // the two toggles cancel out.
1365 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {
1366 return;
13861367 }
1387 }}
1388 >
1389 {/* The viewer virtualizes against the element it is told is scrolling and places its
1368 if (node.hasAttribute("data-diffs-header")) {
1369 const filePath = node.querySelector("[data-title]")?.textContent?.trim();
1370 if (filePath === undefined || filePath === "") return;
1371 const item = items.find(
1372 (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath,
1373 );
1374 if (item !== undefined) toggleFile(item.id);
1375 return;
1376 }
1377 }
1378 }}
1379 >
1380 {/* The viewer virtualizes against the element it is told is scrolling and places its
13901381 rows absolutely, so it has to own that element — the thread diff panel hands it the
13911382 same one. Scrolling from a parent instead leaves it painting over its neighbours. */}
1392 <StyledDiffCodeView<ReviewAnnotationGroup>
1393 // Keep scrollbar space stable so file metadata and line numbers do not shift as a
1394 // diff crosses the overflow boundary. The viewer is itself focusable for keyboard
1395 // interaction, but its native host outline clips and competes with the focus
1396 // indicators on its actual controls.
1397 className="h-full overflow-auto [scrollbar-gutter:stable]"
1398 viewerRef={viewerRef}
1399 items={items}
1400 selectedLines={selectedLines}
1401 onSelectedLinesChange={setSelectedLines}
1402 options={diffViewOptions}
1403 // The viewer owns the scroll container, so the sentinel that asks for the next slice
1404 // has to live inside it — at the end of the files, where reaching it means the reader
1405 // is running out of diff.
1406 renderCodeViewFooter={renderCodeViewFooter}
1407 renderHeaderPrefix={renderHeaderPrefix}
1408 renderHeaderMetadata={renderHeaderMetadata}
1409 renderAnnotation={renderAnnotation}
1410 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
1411 />
1412 {reviewOverlay}
1413 </div>
1414 {fileTreeOpen ? (
1415 <aside className="flex w-[min(20rem,40%)] min-w-48 shrink-0 border-l border-border/60">
1416 <DiffFileTree
1417 ariaLabel={`Pull request #${detail.number} files`}
1418 entries={fileTreeEntries}
1419 onSelectFile={revealFile}
1420 // The tree lists only what has arrived; a footer says so while the diff is still
1421 // paging, and lets the reader pull the rest in without scrolling for it.
1422 footer={
1423 nextCursor === null ? null : (
1424 <div className="shrink-0 border-t border-border/60 p-2">
1425 <Button
1426 type="button"
1427 size="xs"
1428 variant="outline"
1429 className="w-full"
1430 disabled={diffQuery.isPending}
1431 onClick={
1432 diffQuery.error !== null ? () => diffQuery.refresh() : loadNextSlice
1433 }
1434 >
1435 {diffQuery.error !== null
1436 ? "Retry"
1437 : diffQuery.isPending
1438 ? "Loading more files..."
1439 : "Load more files"}
1440 </Button>
1441 </div>
1442 )
1443 }
1444 />
1445 </aside>
1446 ) : null}
1383 <StyledDiffCodeView<ReviewAnnotationGroup>
1384 // Keep scrollbar space stable so file metadata and line numbers do not shift as a
1385 // diff crosses the overflow boundary. The viewer is itself focusable for keyboard
1386 // interaction, but its native host outline clips and competes with the focus
1387 // indicators on its actual controls.
1388 className="h-full overflow-auto [scrollbar-gutter:stable]"
1389 viewerRef={setViewer}
1390 items={items}
1391 selectedLines={selectedLines}
1392 onSelectedLinesChange={setSelectedLines}
1393 options={diffViewOptions}
1394 // The viewer owns the scroll container, so the sentinel that asks for the next slice
1395 // has to live inside it — at the end of the files, where reaching it means the reader
1396 // is running out of diff.
1397 renderCodeViewFooter={renderCodeViewFooter}
1398 renderHeaderPrefix={renderHeaderPrefix}
1399 renderHeaderMetadata={renderHeaderMetadata}
1400 renderAnnotation={renderAnnotation}
1401 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
1402 />
1403 {reviewOverlay}
14471404 </div>
1448 {unstructured}
1405 {fileTreeOpen ? (
1406 <aside className="flex w-[min(20rem,40%)] min-w-48 shrink-0 border-l border-border/60">
1407 <DiffFileTree
1408 ariaLabel={`Pull request #${detail.number} files`}
1409 entries={fileTreeEntries}
1410 onSelectFile={revealFile}
1411 // The tree lists only what has arrived; a footer says so while the diff is still
1412 // paging, and lets the reader pull the rest in without scrolling for it.
1413 footer={
1414 nextCursor === null ? null : (
1415 <div className="shrink-0 border-t border-border/60 p-2">
1416 <Button
1417 type="button"
1418 size="xs"
1419 variant="outline"
1420 className="w-full"
1421 disabled={diffQuery.isPending}
1422 onClick={diffQuery.error !== null ? () => diffQuery.refresh() : loadNextSlice}
1423 >
1424 {diffQuery.error !== null
1425 ? "Retry"
1426 : diffQuery.isPending
1427 ? "Loading more files..."
1428 : "Load more files"}
1429 </Button>
1430 </div>
1431 )
1432 }
1433 />
1434 </aside>
1435 ) : null}
14491436 </div>
1450 </DiffWorkerPoolProvider>
1437 {unstructured}
1438 </div>
14511439 );
14521440}
14531441