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 {...@@ -179,7 +179,6 @@ import {
179 deriveAgentPanelModel,179 deriveAgentPanelModel,
180 foldSubagentActivities,180 foldSubagentActivities,
181} from "@t3tools/client-runtime/state/subagentRuntime";181} from "@t3tools/client-runtime/state/subagentRuntime";
182import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
183import { BranchToolbar } from "./BranchToolbar";182import { BranchToolbar } from "./BranchToolbar";
184import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";183import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings";
185import ThreadTerminalDrawer from "./ThreadTerminalDrawer";184import ThreadTerminalDrawer from "./ThreadTerminalDrawer";
...@@ -1351,7 +1350,7 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n...@@ -1351,7 +1350,7 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n
1351 return current.messageId === null ? current : { ...current, messageId: null };1350 return current.messageId === null ? current : { ...current, messageId: null };
1352}1351}
13531352
1354function ChatViewContent(props: ChatViewProps) {1353export default function ChatView(props: ChatViewProps) {
1355 const {1354 const {
1356 environmentId,1355 environmentId,
1357 threadId,1356 threadId,
...@@ -8226,11 +8225,3 @@ function ChatViewContent(props: ChatViewProps) {...@@ -8226,11 +8225,3 @@ function ChatViewContent(props: ChatViewProps) {
8226 </div>8225 </div>
8227 );8226 );
8228}8227}
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 {...@@ -24,6 +24,7 @@ import {
24} from "lucide-react";24} from "lucide-react";
25import * as Schema from "effect/Schema";25import * as Schema from "effect/Schema";
26import { useCallback, useEffect, useMemo, useRef, useState } from "react";26import { useCallback, useEffect, useMemo, useRef, useState } from "react";
27import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
27import { useOpenInPreferredEditor } from "../editorPreferences";28import { useOpenInPreferredEditor } from "../editorPreferences";
28import { type DraftId } from "../composerDraftStore";29import { type DraftId } from "../composerDraftStore";
29import { openDiffFilePrimaryAction } from "../diffFileActions";30import { openDiffFilePrimaryAction } from "../diffFileActions";
...@@ -129,7 +130,7 @@ export default function DiffPanel({...@@ -129,7 +130,7 @@ export default function DiffPanel({
129 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,130 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
130 }));131 }));
131 const [codeViewRevision, setCodeViewRevision] = useState(0);132 const [codeViewRevision, setCodeViewRevision] = useState(0);
132 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);133 const [codeView, setCodeView] = useState<AnnotatableCodeViewHandle | null>(null);
133134
134 const routeThreadRef = useParams({135 const routeThreadRef = useParams({
135 strict: false,136 strict: false,
...@@ -448,17 +449,15 @@ export default function DiffPanel({...@@ -448,17 +449,15 @@ export default function DiffPanel({
448 : null;449 : null;
449450
450 useEffect(() => {451 useEffect(() => {
451 if (!selectedDiffFileKey) return;452 if (!selectedDiffFileKey || !codeView?.getInstance()) return;
452 codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });453 codeView.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
453 }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);454 }, [codeView, codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
454455
455 // Held as state so the scroll runs after a collapsed file has been drawn open again; scrolling456 const treeRevealScope = useMemo(
456 // in the same tick would land on the folded header's position.457 () => ({ collapseScopeKey, diffSelection }),
457 const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null);458 [collapseScopeKey, diffSelection],
458 useEffect(() => {459 );
459 if (treeReveal === null) return;460 const requestTreeReveal = useCodeViewFileReveal(codeView, treeRevealScope);
460 codeViewRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" });
461 }, [treeReveal]);
462 const revealDiffFile = useCallback(461 const revealDiffFile = useCallback(
463 (filePath: string) => {462 (filePath: string) => {
464 const file = codeViewFiles.find((candidate) => candidate.filePath === filePath);463 const file = codeViewFiles.find((candidate) => candidate.filePath === filePath);
...@@ -470,9 +469,9 @@ export default function DiffPanel({...@@ -470,9 +469,9 @@ export default function DiffPanel({
470 return { scopeKey: collapseScopeKey, fileKeys: next };469 return { scopeKey: collapseScopeKey, fileKeys: next };
471 });470 });
472 }471 }
473 setTreeReveal((current) => ({ fileKey: file.fileKey, id: (current?.id ?? 0) + 1 }));472 requestTreeReveal(file.fileKey);
474 },473 },
475 [codeViewFiles, collapseScopeKey],474 [codeViewFiles, collapseScopeKey, requestTreeReveal],
476 );475 );
477476
478 const openDiffFile = useCallback(477 const openDiffFile = useCallback(
...@@ -972,7 +971,7 @@ export default function DiffPanel({...@@ -972,7 +971,7 @@ export default function DiffPanel({
972 >971 >
973 <AnnotatableCodeView972 <AnnotatableCodeView
974 key={collapseScopeKey ?? reviewSectionId}973 key={collapseScopeKey ?? reviewSectionId}
975 viewerRef={codeViewRef}974 viewerRef={setCodeView}
976 codeViewKey={codeViewMountKey}975 codeViewKey={codeViewMountKey}
977 className="h-full min-h-0 overflow-auto"976 className="h-full min-h-0 overflow-auto"
978 files={codeViewFiles}977 files={codeViewFiles}
apps/web/src/components/DiffWorkerPoolProvider.tsx+35-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";1import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react";
2import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";2import DiffsWorker from "@pierre/diffs/worker/worker.js?worker";
3import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
4import { useEffect, useMemo, type ReactNode } from "react";4import { useEffect, useMemo, useState, type ReactNode } from "react";
5import { useTheme } from "../hooks/useTheme";5import { useTheme } from "../hooks/useTheme";
6import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";6import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
7import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting";7import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting";
...@@ -46,6 +46,39 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {...@@ -46,6 +46,39 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) {
46 return null;46 return null;
47}47}
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
49export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {82export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
50 const { resolvedTheme } = useTheme();83 const { resolvedTheme } = useTheme();
51 const diffThemeName = resolveDiffThemeName(resolvedTheme);84 const diffThemeName = resolveDiffThemeName(resolvedTheme);
...@@ -80,7 +113,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {...@@ -80,7 +113,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) {
80 }}113 }}
81 >114 >
82 <DiffWorkerThemeSync themeName={diffThemeName} />115 <DiffWorkerThemeSync themeName={diffThemeName} />
83 {children}116 <DiffWorkerReady>{children}</DiffWorkerReady>
84 </WorkerPoolContextProvider>117 </WorkerPoolContextProvider>
85 );118 );
86}119}
apps/web/src/components/chat/MessagesTimeline.test.tsx+4
...@@ -122,6 +122,10 @@ vi.mock("@pierre/diffs/react", () => {...@@ -122,6 +122,10 @@ vi.mock("@pierre/diffs/react", () => {
122 return { FileDiff: MockFileDiff };122 return { FileDiff: MockFileDiff };
123});123});
124124
125vi.mock("../DiffWorkerPoolProvider", () => ({
126 DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children,
127}));
128
125function matchMedia() {129function matchMedia() {
126 return {130 return {
127 matches: false,131 matches: false,
apps/web/src/components/chat/MessagesTimeline.tsx+17-13
...@@ -50,6 +50,7 @@ import {...@@ -50,6 +50,7 @@ import {
50 type MaintainScrollAtEndOptions,50 type MaintainScrollAtEndOptions,
51} from "@legendapp/list/react";51} from "@legendapp/list/react";
52import { FileDiff } from "@pierre/diffs/react";52import { FileDiff } from "@pierre/diffs/react";
53import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
53import {54import {
54 deriveTimelineEntries,55 deriveTimelineEntries,
55 workEntryDisplayIndicatesToolFailure,56 workEntryDisplayIndicatesToolFailure,
...@@ -2530,19 +2531,22 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte...@@ -2530,19 +2531,22 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte
2530 className="text-message-foreground"2531 className="text-message-foreground"
2531 />2532 />
2532 )}2533 )}
2533 {renderablePatch?.kind === "files" &&2534 {renderablePatch?.kind === "files" && (
2534 renderablePatch.files.map((fileDiff) => (2535 <DiffWorkerPoolProvider>
2535 <FileDiff2536 {renderablePatch.files.map((fileDiff) => (
2536 key={resolveFileDiffPath(fileDiff)}2537 <FileDiff
2537 fileDiff={fileDiff}2538 key={resolveFileDiffPath(fileDiff)}
2538 options={{2539 fileDiff={fileDiff}
2539 collapsed: false,2540 options={{
2540 diffStyle: "unified",2541 collapsed: false,
2541 theme: resolveDiffThemeName(ctx.resolvedTheme),2542 diffStyle: "unified",
2542 preferredHighlighter: PREFERRED_HIGHLIGHTER,2543 theme: resolveDiffThemeName(ctx.resolvedTheme),
2543 }}2544 preferredHighlighter: PREFERRED_HIGHLIGHTER,
2544 />2545 }}
2545 ))}2546 />
2547 ))}
2548 </DiffWorkerPoolProvider>
2549 )}
2546 {renderablePatch?.kind === "raw" && (2550 {renderablePatch?.kind === "raw" && (
2547 <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs">2551 <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs">
2548 {renderablePatch.text}2552 {renderablePatch.text}
apps/web/src/components/diffs/AnnotatableCodeView.test.tsx+5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import type { ReactNode } from "react";
1import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";3import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
34
...@@ -12,6 +13,10 @@ vi.mock("@pierre/diffs/react", () => ({...@@ -12,6 +13,10 @@ vi.mock("@pierre/diffs/react", () => ({
12 },13 },
13}));14}));
1415
16vi.mock("../DiffWorkerPoolProvider", () => ({
17 DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children,
18}));
19
15vi.mock("~/composerDraftStore", () => ({20vi.mock("~/composerDraftStore", () => ({
16 useComposerDraftStore: (selector: (store: Record<string, unknown>) => unknown) =>21 useComposerDraftStore: (selector: (store: Record<string, unknown>) => unknown) =>
17 selector({22 selector({
apps/web/src/components/diffs/StyledDiffCodeView.test.tsx+474-6
...@@ -1,20 +1,153 @@...@@ -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";
1import { renderToStaticMarkup } from "react-dom/server";20import { 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
4const testState = vi.hoisted(() => ({24const testState = vi.hoisted(() => ({
5 codeViewClassName: null as string | null,25 codeViewClassName: null as string | null,
6 codeViewOptions: null as Record<string, unknown> | null,26 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,
7}));36}));
837
9vi.mock("@pierre/diffs/react", () => ({38vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) }));
10 CodeView: (props: { className: string; options: Record<string, unknown> }) => {39
11 testState.codeViewClassName = props.className;40vi.mock("@pierre/diffs/worker/worker.js?worker", async () => {
12 testState.codeViewOptions = props.options;41 const { Worker } = await import("node:worker_threads");
13 return null;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 );
14 },109 },
15}));110}));
16111
17import { StyledDiffCodeView } from "./StyledDiffCodeView";112import { 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
19describe("StyledDiffCodeView", () => {152describe("StyledDiffCodeView", () => {
20 beforeEach(() => {153 beforeEach(() => {
...@@ -58,3 +191,338 @@ describe("StyledDiffCodeView", () => {...@@ -58,3 +191,338 @@ describe("StyledDiffCodeView", () => {
58 );191 );
59 });192 });
60});193});
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 {...@@ -10,6 +10,7 @@ import {
10import type { Ref } from "react";10import type { Ref } from "react";
1111
12import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering";12import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering";
13import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
1314
14const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}15const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS}
15:is(16:is(
...@@ -285,38 +286,40 @@ export function StyledDiffCodeView<LAnnotation = undefined>({...@@ -285,38 +286,40 @@ export function StyledDiffCodeView<LAnnotation = undefined>({
285 ...props286 ...props
286}: StyledDiffCodeViewProps<LAnnotation>) {287}: StyledDiffCodeViewProps<LAnnotation>) {
287 return (288 return (
288 <CodeView<LAnnotation>289 <DiffWorkerPoolProvider>
289 {...props}290 <CodeView<LAnnotation>
290 {...(viewerRef ? { ref: viewerRef } : {})}291 {...props}
291 // The custom element itself is focusable for keyboard scrolling. Its native outline sits292 {...(viewerRef ? { ref: viewerRef } : {})}
292 // outside the panel clipping boundary; actual controls inside retain their own indicators.293 // The custom element itself is focusable for keyboard scrolling. Its native outline sits
293 className={294 // outside the panel clipping boundary; actual controls inside retain their own indicators.
294 className295 className={
295 ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}`296 className
296 : "diff-render-surface [--code-background:var(--background)] outline-none"297 ? `diff-render-surface [--code-background:var(--background)] outline-none ${className}`
297 }298 : "diff-render-surface [--code-background:var(--background)] outline-none"
298 options={{299 }
299 ...options,300 options={{
300 unsafeCSS: unsafeCSSExtra301 ...options,
301 ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}`302 unsafeCSS: unsafeCSSExtra
302 : DIFF_VIEW_UNSAFE_CSS,303 ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}`
303 itemMetrics: {304 : DIFF_VIEW_UNSAFE_CSS,
304 diffHeaderHeight: 32,305 itemMetrics: {
305 hunkSeparatorHeight: 24,306 diffHeaderHeight: 32,
306 // Pierre uses its general file spacing as a fallback in expanded-file layout paths.307 hunkSeparatorHeight: 24,
307 // Keep it zero alongside the explicit paddingTop or expanding the first file can308 // Pierre uses its general file spacing as a fallback in expanded-file layout paths.
308 // reintroduce the library's default 8px gap above its header.309 // Keep it zero alongside the explicit paddingTop or expanding the first file can
309 spacing: 0,310 // reintroduce the library's default 8px gap above its header.
310 paddingTop: 0,311 spacing: 0,
311 // Unlike the gap above, the 8px under a file's last line is painted312 paddingTop: 0,
312 // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has313 // Unlike the gap above, the 8px under a file's last line is painted
313 // to count it: at zero every expanded file's virtual height ran 8px short of its314 // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has
314 // rendered height, and the end of the list sat past the reachable scroll range —315 // to count it: at zero every expanded file's virtual height ran 8px short of its
315 // one clipped file row per expanded file above it.316 // rendered height, and the end of the list sat past the reachable scroll range —
316 paddingBottom: 8,317 // one clipped file row per expanded file above it.
317 },318 paddingBottom: 8,
318 layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },319 },
319 }}320 layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
320 />321 }}
322 />
323 </DiffWorkerPoolProvider>
321 );324 );
322}325}
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 {...@@ -12,6 +12,7 @@ import {
12import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";12import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs";
13import { Editor } from "@pierre/diffs/editor";13import { Editor } from "@pierre/diffs/editor";
14import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";14import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react";
15import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
15import {16import {
16 isAtomCommandInterrupted,17 isAtomCommandInterrupted,
17 squashAtomCommandFailure,18 squashAtomCommandFailure,
...@@ -1304,46 +1305,50 @@ export default function FilePreviewPanel({...@@ -1304,46 +1305,50 @@ export default function FilePreviewPanel({
1304 onPendingChange={onPendingChange}1305 onPendingChange={onPendingChange}
1305 />1306 />
1306 ) : file.data.truncated || isHostFile ? (1307 ) : file.data.truncated || isHostFile ? (
1307 <Virtualizer1308 <DiffWorkerPoolProvider>
1308 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}1309 <Virtualizer
1309 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"1310 key={`${relativePath}:${resolvedTheme}:${file.data.byteLength}`}
1310 config={{1311 className="file-preview-virtualizer min-h-0 flex-1 overflow-auto"
1311 overscrollSize: 600,1312 config={{
1312 intersectionObserverMargin: 1200,1313 overscrollSize: 600,
1313 }}1314 intersectionObserverMargin: 1200,
1314 >
1315 <File
1316 file={{
1317 name: relativePath,
1318 contents: file.data.contents,
1319 cacheKey: projectFileCacheKey(cwd, relativePath, file.data.contents),
1320 }}1315 }}
1321 options={{1316 >
1322 disableFileHeader: true,1317 <File
1323 overflow: wordWrap ? "wrap" : "scroll",1318 file={{
1324 theme: resolveDiffThemeName(resolvedTheme),1319 name: relativePath,
1325 preferredHighlighter: PREFERRED_HIGHLIGHTER,1320 contents: file.data.contents,
1326 themeType: resolvedTheme,1321 cacheKey: projectFileCacheKey(cwd, relativePath, file.data.contents),
1327 unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS,1322 }}
1328 onPostRender: onFilePostRender,1323 options={{
1329 }}1324 disableFileHeader: true,
1330 className="min-h-full"1325 overflow: wordWrap ? "wrap" : "scroll",
1331 />1326 theme: resolveDiffThemeName(resolvedTheme),
1332 </Virtualizer>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>
1333 ) : (1336 ) : (
1334 <EditableFileSurface1337 <DiffWorkerPoolProvider>
1335 key={`${relativePath}:${resolvedTheme}`}1338 <EditableFileSurface
1336 environmentId={environmentId}1339 key={`${relativePath}:${resolvedTheme}`}
1337 cwd={cwd}1340 environmentId={environmentId}
1338 relativePath={relativePath}1341 cwd={cwd}
1339 composerDraftTarget={composerDraftTarget}1342 relativePath={relativePath}
1340 contents={file.data.contents}1343 composerDraftTarget={composerDraftTarget}
1341 resolvedTheme={resolvedTheme}1344 contents={file.data.contents}
1342 revealRequestId={revealRequestId}1345 resolvedTheme={resolvedTheme}
1343 wordWrap={wordWrap}1346 revealRequestId={revealRequestId}
1344 onPostRender={onFilePostRender}1347 wordWrap={wordWrap}
1345 onPendingChange={onPendingChange}1348 onPostRender={onFilePostRender}
1346 />1349 onPendingChange={onPendingChange}
1350 />
1351 </DiffWorkerPoolProvider>
1347 )1352 )
1348 ) : null}1353 ) : null}
1349 </div>1354 </div>
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+146-158
...@@ -58,9 +58,9 @@ import { useEnvironmentQuery } from "~/state/query";...@@ -58,9 +58,9 @@ import { useEnvironmentQuery } from "~/state/query";
58import { useAtomCommand } from "~/state/use-atom-command";58import { useAtomCommand } from "~/state/use-atom-command";
5959
60import { DiffPanelLoadingState } from "../DiffPanelShell";60import { DiffPanelLoadingState } from "../DiffPanelShell";
61import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
62import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";61import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
63import { DiffFileTree } from "../diffs/DiffFileTree";62import { DiffFileTree } from "../diffs/DiffFileTree";
63import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal";
64import { diffFileTreeEntries } from "../diffs/diffFileTree.logic";64import { diffFileTreeEntries } from "../diffs/diffFileTree.logic";
65import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView";65import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView";
66import { Button } from "../ui/button";66import { Button } from "../ui/button";
...@@ -250,7 +250,7 @@ export function PullRequestCodeTab({...@@ -250,7 +250,7 @@ export function PullRequestCodeTab({
250 readonly slices: ReadonlyArray<DiffSlice>;250 readonly slices: ReadonlyArray<DiffSlice>;
251 }>({ key: "", cursor: null, slices: NO_SLICES });251 }>({ key: "", cursor: null, slices: NO_SLICES });
252 const parseCache = useRef(new Map<string, RenderablePatch>());252 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
255 const referenceKey = pullRequestReviewKey(reference);255 const referenceKey = pullRequestReviewKey(reference);
256 const commit = selectedCommitOid;256 const commit = selectedCommitOid;
...@@ -603,21 +603,15 @@ export function PullRequestCodeTab({...@@ -603,21 +603,15 @@ export function PullRequestCodeTab({
603 [],603 [],
604 );604 );
605605
606 // Held as state so the scroll runs after a folded file has been drawn open; scrolling in the606 const requestTreeReveal = useCodeViewFileReveal(viewer, scopeKey);
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]);
613 const revealFile = useCallback(607 const revealFile = useCallback(
614 (path: string) => {608 (path: string) => {
615 const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path);609 const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path);
616 if (item === undefined) return;610 if (item === undefined) return;
617 if (item.collapsed === true) toggleFile(item.id);611 if (item.collapsed === true) toggleFile(item.id);
618 setTreeReveal((current) => ({ fileKey: item.id, id: (current?.id ?? 0) + 1 }));612 requestTreeReveal(item.id);
619 },613 },
620 [items, toggleFile],614 [items, requestTreeReveal, toggleFile],
621 );615 );
622616
623 const toggleAllFiles = () => {617 const toggleAllFiles = () => {
...@@ -1290,164 +1284,158 @@ export function PullRequestCodeTab({...@@ -1290,164 +1284,158 @@ export function PullRequestCodeTab({
1290 );1284 );
12911285
1292 return (1286 return (
1293 <DiffWorkerPoolProvider>1287 <div className="flex h-full min-h-0 flex-col">
1294 <div className="flex h-full min-h-0 flex-col">1288 {toolbar}
1295 {toolbar}1289 {/* Above the code, closed, and counted: these belong to the change rather than to any
1296 {/* Above the code, closed, and counted: these belong to the change rather than to any
1297 line of it, and in the stream they read as cards dropped into the patch. */}1290 line of it, and in the stream they read as cards dropped into the patch. */}
1298 {orphanFiles.size > 0 ? (1291 {orphanFiles.size > 0 ? (
1299 <Collapsible1292 <Collapsible
1300 className="shrink-0 border-b border-border/60"1293 className="shrink-0 border-b border-border/60"
1301 open={orphansOpen}1294 open={orphansOpen}
1302 onOpenChange={setOrphansOpen}1295 onOpenChange={setOrphansOpen}
1303 >1296 >
1304 {/* Still a heading, so the section keeps its place in a screen reader's outline;1297 {/* Still a heading, so the section keeps its place in a screen reader's outline;
1305 the count is spelled out there rather than left as a bare number. */}1298 the count is spelled out there rather than left as a bare number. */}
1306 <h2>1299 <h2>
1307 <CollapsibleTrigger className="flex w-full items-center gap-1.5 px-4 py-2 text-left text-xs text-muted-foreground">1300 <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 file1301 {/* While slices are still arriving a conversation may simply belong to a file
1309 that has not landed yet, which is not the same as being off the diff. */}1302 that has not landed yet, which is not the same as being off the diff. */}
1310 <span>1303 <span>
1311 {nextCursor === null1304 {nextCursor === null
1312 ? "Conversations not on the current diff"1305 ? "Conversations not on the current diff"
1313 : "Conversations not on the diff loaded so far"}1306 : "Conversations not on the diff loaded so far"}
1314 </span>1307 </span>
1315 <ChevronRightIcon1308 <ChevronRightIcon
1316 aria-hidden1309 aria-hidden
1317 className={cn("size-3.5 transition-transform", orphansOpen && "rotate-90")}1310 className={cn("size-3.5 transition-transform", orphansOpen && "rotate-90")}
1318 />1311 />
1319 <span aria-hidden className="tabular-nums">1312 <span aria-hidden className="tabular-nums">
1320 {orphanThreads.length}1313 {orphanThreads.length}
1321 </span>1314 </span>
1322 <span className="sr-only">1315 <span className="sr-only">
1323 {orphanThreads.length === 11316 {orphanThreads.length === 1
1324 ? "1 conversation"1317 ? "1 conversation"
1325 : `${orphanThreads.length} conversations`}1318 : `${orphanThreads.length} conversations`}
1326 </span>1319 </span>
1327 </CollapsibleTrigger>1320 </CollapsibleTrigger>
1328 </h2>1321 </h2>
1329 <CollapsiblePanel>1322 <CollapsiblePanel>
1330 {/* Capped: opened on a change with dozens of them, this would otherwise leave no1323 {/* Capped: opened on a change with dozens of them, this would otherwise leave no
1331 room for the diff it sits above. */}1324 room for the diff it sits above. */}
1332 <div className="max-h-64 space-y-3 overflow-auto px-4 pb-3">1325 <div className="max-h-64 space-y-3 overflow-auto px-4 pb-3">
1333 {[...orphanFiles].map(([path, threads]) => (1326 {[...orphanFiles].map(([path, threads]) => (
1334 <div key={path}>1327 <div key={path}>
1335 <Tooltip>1328 <Tooltip>
1336 <TooltipTrigger1329 <TooltipTrigger
1337 render={1330 render={<p className="truncate px-3 text-xs text-muted-foreground">{path}</p>}
1338 <p className="truncate px-3 text-xs text-muted-foreground">{path}</p>1331 />
1339 }1332 <TooltipPopup side="top">{path}</TooltipPopup>
1340 />1333 </Tooltip>
1341 <TooltipPopup side="top">{path}</TooltipPopup>1334 <div className="mt-1 space-y-2">
1342 </Tooltip>1335 {threads.map((thread) => (
1343 <div className="mt-1 space-y-2">1336 <div key={thread.id}>
1344 {threads.map((thread) => (1337 {thread.line === null ? null : (
1345 <div key={thread.id}>1338 <p className="px-3 text-xs text-muted-foreground">Line {thread.line}</p>
1346 {thread.line === null ? null : (1339 )}
1347 <p className="px-3 text-xs text-muted-foreground">Line {thread.line}</p>1340 {renderThreadCard(thread)}
1348 )}1341 </div>
1349 {renderThreadCard(thread)}1342 ))}
1350 </div>
1351 ))}
1352 </div>
1353 </div>1343 </div>
1354 ))}1344 </div>
1355 </div>1345 ))}
1356 </CollapsiblePanel>1346 </div>
1357 </Collapsible>1347 </CollapsiblePanel>
1358 ) : null}1348 </Collapsible>
1359 <div className="flex min-h-0 flex-1 overflow-hidden">1349 ) : null}
1360 {/* Relative wrapper so the review overlay floats over the diff rather than pushing it1350 <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
1361 up; the viewer inside still owns its own scrolling. */}1352 up; the viewer inside still owns its own scrolling. */}
1362 <div1353 <div
1363 className="relative min-h-0 min-w-0 flex-1"1354 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 reader1355 // 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 capture1356 // 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.1357 // listener walks `composedPath` — the only way to see through the shadow boundary.
1367 onClickCapture={(event) => {1358 onClickCapture={(event) => {
1368 const composedPath = event.nativeEvent.composedPath?.() ?? [];1359 const composedPath = event.nativeEvent.composedPath?.() ?? [];
1369 for (const node of composedPath) {1360 for (const node of composedPath) {
1370 if (!(node instanceof HTMLElement)) continue;1361 if (!(node instanceof HTMLElement)) continue;
1371 // A control inside the header — the collapse chevron — handles itself, and1362 // A control inside the header — the collapse chevron — handles itself, and
1372 // this capture listener fires before its own click does. Leave it alone or1363 // this capture listener fires before its own click does. Leave it alone or
1373 // the two toggles cancel out.1364 // the two toggles cancel out.
1374 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {1365 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {
1375 return;1366 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 }
1386 }1367 }
1387 }}1368 if (node.hasAttribute("data-diffs-header")) {
1388 >1369 const filePath = node.querySelector("[data-title]")?.textContent?.trim();
1389 {/* The viewer virtualizes against the element it is told is scrolling and places its1370 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
1390 rows absolutely, so it has to own that element — the thread diff panel hands it the1381 rows absolutely, so it has to own that element — the thread diff panel hands it the
1391 same one. Scrolling from a parent instead leaves it painting over its neighbours. */}1382 same one. Scrolling from a parent instead leaves it painting over its neighbours. */}
1392 <StyledDiffCodeView<ReviewAnnotationGroup>1383 <StyledDiffCodeView<ReviewAnnotationGroup>
1393 // Keep scrollbar space stable so file metadata and line numbers do not shift as a1384 // 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 keyboard1385 // diff crosses the overflow boundary. The viewer is itself focusable for keyboard
1395 // interaction, but its native host outline clips and competes with the focus1386 // interaction, but its native host outline clips and competes with the focus
1396 // indicators on its actual controls.1387 // indicators on its actual controls.
1397 className="h-full overflow-auto [scrollbar-gutter:stable]"1388 className="h-full overflow-auto [scrollbar-gutter:stable]"
1398 viewerRef={viewerRef}1389 viewerRef={setViewer}
1399 items={items}1390 items={items}
1400 selectedLines={selectedLines}1391 selectedLines={selectedLines}
1401 onSelectedLinesChange={setSelectedLines}1392 onSelectedLinesChange={setSelectedLines}
1402 options={diffViewOptions}1393 options={diffViewOptions}
1403 // The viewer owns the scroll container, so the sentinel that asks for the next slice1394 // 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 reader1395 // has to live inside it — at the end of the files, where reaching it means the reader
1405 // is running out of diff.1396 // is running out of diff.
1406 renderCodeViewFooter={renderCodeViewFooter}1397 renderCodeViewFooter={renderCodeViewFooter}
1407 renderHeaderPrefix={renderHeaderPrefix}1398 renderHeaderPrefix={renderHeaderPrefix}
1408 renderHeaderMetadata={renderHeaderMetadata}1399 renderHeaderMetadata={renderHeaderMetadata}
1409 renderAnnotation={renderAnnotation}1400 renderAnnotation={renderAnnotation}
1410 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}1401 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
1411 />1402 />
1412 {reviewOverlay}1403 {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}
1447 </div>1404 </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}
1449 </div>1436 </div>
1450 </DiffWorkerPoolProvider>1437 {unstructured}
1438 </div>
1451 );1439 );
1452}1440}
14531441