| author | |
| committer | |
| log | b3e1d88590489da2bb63b95c18a57e6f400b8b7f |
| tree | 4aea3286a686cfe9eb3e344c6b1935fe204af243 |
| parent | c163d502dd32b993c03d8c20a5fae55b159bd8dc |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 802 insertions(+), 274 deletions(-)
apps/web/src/components/ChatView.tsx+1-10| ... | ... | @@ -179,7 +179,6 @@ import { |
| 179 | 179 | deriveAgentPanelModel, |
| 180 | 180 | foldSubagentActivities, |
| 181 | 181 | } from "@t3tools/client-runtime/state/subagentRuntime"; |
| 182 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | |
| 183 | 182 | import { BranchToolbar } from "./BranchToolbar"; |
| 184 | 183 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 185 | 184 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; |
| ... | ... | @@ -1351,7 +1350,7 @@ function releaseChatTimelineAnchor<T extends { readonly messageId: MessageId | n |
| 1351 | 1350 | return current.messageId === null ? current : { ...current, messageId: null }; |
| 1352 | 1351 | } |
| 1353 | 1352 | |
| 1354 | function ChatViewContent(props: ChatViewProps) { | |
| 1353 | export default function ChatView(props: ChatViewProps) { | |
| 1355 | 1354 | const { |
| 1356 | 1355 | environmentId, |
| 1357 | 1356 | threadId, |
| ... | ... | @@ -8226,11 +8225,3 @@ function ChatViewContent(props: ChatViewProps) { |
| 8226 | 8225 | </div> |
| 8227 | 8226 | ); |
| 8228 | 8227 | } |
| 8229 | ||
| 8230 | export 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 | 24 | } from "lucide-react"; |
| 25 | 25 | import * as Schema from "effect/Schema"; |
| 26 | 26 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 27 | import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; | |
| 27 | 28 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 28 | 29 | import { type DraftId } from "../composerDraftStore"; |
| 29 | 30 | import { openDiffFilePrimaryAction } from "../diffFileActions"; |
| ... | ... | @@ -129,7 +130,7 @@ export default function DiffPanel({ |
| 129 | 130 | fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, |
| 130 | 131 | })); |
| 131 | 132 | const [codeViewRevision, setCodeViewRevision] = useState(0); |
| 132 | const codeViewRef = useRef<AnnotatableCodeViewHandle>(null); | |
| 133 | const [codeView, setCodeView] = useState<AnnotatableCodeViewHandle | null>(null); | |
| 133 | 134 | |
| 134 | 135 | const routeThreadRef = useParams({ |
| 135 | 136 | strict: false, |
| ... | ... | @@ -448,17 +449,15 @@ export default function DiffPanel({ |
| 448 | 449 | : null; |
| 449 | 450 | |
| 450 | 451 | 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]); | |
| 454 | 455 | |
| 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); | |
| 462 | 461 | const revealDiffFile = useCallback( |
| 463 | 462 | (filePath: string) => { |
| 464 | 463 | const file = codeViewFiles.find((candidate) => candidate.filePath === filePath); |
| ... | ... | @@ -470,9 +469,9 @@ export default function DiffPanel({ |
| 470 | 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 | ); |
| 477 | 476 | |
| 478 | 477 | const openDiffFile = useCallback( |
| ... | ... | @@ -972,7 +971,7 @@ export default function DiffPanel({ |
| 972 | 971 | > |
| 973 | 972 | <AnnotatableCodeView |
| 974 | 973 | key={collapseScopeKey ?? reviewSectionId} |
| 975 | viewerRef={codeViewRef} | |
| 974 | viewerRef={setCodeView} | |
| 976 | 975 | codeViewKey={codeViewMountKey} |
| 977 | 976 | className="h-full min-h-0 overflow-auto" |
| 978 | 977 | files={codeViewFiles} |
apps/web/src/components/DiffWorkerPoolProvider.tsx+35-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; |
| 2 | 2 | import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; |
| 3 | 3 | import * as Schema from "effect/Schema"; |
| 4 | import { useEffect, useMemo, type ReactNode } from "react"; | |
| 4 | import { useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 5 | 5 | import { useTheme } from "../hooks/useTheme"; |
| 6 | 6 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 7 | 7 | import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; |
| ... | ... | @@ -46,6 +46,39 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { |
| 46 | 46 | return null; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | // Plain-text views do not queue a highlight task that could retry a blank first render. | |
| 50 | function 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 | ||
| 49 | 82 | export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { |
| 50 | 83 | const { resolvedTheme } = useTheme(); |
| 51 | 84 | const diffThemeName = resolveDiffThemeName(resolvedTheme); |
| ... | ... | @@ -80,7 +113,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { |
| 80 | 113 | }} |
| 81 | 114 | > |
| 82 | 115 | <DiffWorkerThemeSync themeName={diffThemeName} /> |
| 83 | {children} | |
| 116 | <DiffWorkerReady>{children}</DiffWorkerReady> | |
| 84 | 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 | 122 | return { FileDiff: MockFileDiff }; |
| 123 | 123 | }); |
| 124 | 124 | |
| 125 | vi.mock("../DiffWorkerPoolProvider", () => ({ | |
| 126 | DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, | |
| 127 | })); | |
| 128 | ||
| 125 | 129 | function matchMedia() { |
| 126 | 130 | return { |
| 127 | 131 | matches: false, |
apps/web/src/components/chat/MessagesTimeline.tsx+17-13| ... | ... | @@ -50,6 +50,7 @@ import { |
| 50 | 50 | type MaintainScrollAtEndOptions, |
| 51 | 51 | } from "@legendapp/list/react"; |
| 52 | 52 | import { FileDiff } from "@pierre/diffs/react"; |
| 53 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | |
| 53 | 54 | import { |
| 54 | 55 | deriveTimelineEntries, |
| 55 | 56 | workEntryDisplayIndicatesToolFailure, |
| ... | ... | @@ -2530,19 +2531,22 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte |
| 2530 | 2531 | className="text-message-foreground" |
| 2531 | 2532 | /> |
| 2532 | 2533 | )} |
| 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 | )} | |
| 2546 | 2550 | {renderablePatch?.kind === "raw" && ( |
| 2547 | 2551 | <pre className="overflow-x-auto rounded-md bg-muted/40 p-2 text-xs"> |
| 2548 | 2552 | {renderablePatch.text} |
apps/web/src/components/diffs/AnnotatableCodeView.test.tsx+5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ReactNode } from "react"; | |
| 1 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 2 | 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 4 | |
| ... | ... | @@ -12,6 +13,10 @@ vi.mock("@pierre/diffs/react", () => ({ |
| 12 | 13 | }, |
| 13 | 14 | })); |
| 14 | 15 | |
| 16 | vi.mock("../DiffWorkerPoolProvider", () => ({ | |
| 17 | DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, | |
| 18 | })); | |
| 19 | ||
| 15 | 20 | vi.mock("~/composerDraftStore", () => ({ |
| 16 | 21 | useComposerDraftStore: (selector: (store: Record<string, unknown>) => unknown) => |
| 17 | 22 | selector({ |
apps/web/src/components/diffs/StyledDiffCodeView.test.tsx+474-6| ... | ... | @@ -1,20 +1,153 @@ |
| 1 | import type * as NodeWorkerThreads from "node:worker_threads"; | |
| 2 | import { | |
| 3 | FileRenderer, | |
| 4 | getSharedHighlighter, | |
| 5 | type CodeViewScrollTarget, | |
| 6 | type FileContents, | |
| 7 | } from "@pierre/diffs"; | |
| 8 | import { useWorkerPool, type CodeViewProps } from "@pierre/diffs/react"; | |
| 9 | import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; | |
| 10 | import { | |
| 11 | act, | |
| 12 | useEffect, | |
| 13 | useImperativeHandle, | |
| 14 | useLayoutEffect, | |
| 15 | useMemo, | |
| 16 | useRef, | |
| 17 | useState, | |
| 18 | type Ref, | |
| 19 | } from "react"; | |
| 1 | 20 | import { renderToStaticMarkup } from "react-dom/server"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 21 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 22 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | 23 | |
| 4 | 24 | const testState = vi.hoisted(() => ({ |
| 5 | 25 | codeViewClassName: null as string | null, |
| 6 | 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 | })); |
| 8 | 37 | |
| 9 | vi.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; | |
| 38 | vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | |
| 39 | ||
| 40 | vi.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 | ||
| 101 | vi.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 | })); |
| 16 | 111 | |
| 17 | 112 | import { StyledDiffCodeView } from "./StyledDiffCodeView"; |
| 113 | import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; | |
| 114 | ||
| 115 | function 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 | ||
| 135 | const 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 | ||
| 140 | function 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 | } | |
| 18 | 151 | |
| 19 | 152 | describe("StyledDiffCodeView", () => { |
| 20 | 153 | beforeEach(() => { |
| ... | ... | @@ -58,3 +191,338 @@ describe("StyledDiffCodeView", () => { |
| 58 | 191 | ); |
| 59 | 192 | }); |
| 60 | 193 | }); |
| 194 | ||
| 195 | describe("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 | ||
| 339 | type RevealHandle = NonNullable<Parameters<typeof useCodeViewFileReveal>[0]>; | |
| 340 | const revealCalls: { | |
| 341 | scope: string; | |
| 342 | fileKey: string; | |
| 343 | mounted: boolean; | |
| 344 | collapsed: boolean | undefined; | |
| 345 | }[] = []; | |
| 346 | ||
| 347 | function 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 | ||
| 382 | function 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 | ||
| 419 | describe("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 | 10 | import type { Ref } from "react"; |
| 11 | 11 | |
| 12 | 12 | import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; |
| 13 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | |
| 13 | 14 | |
| 14 | 15 | const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} |
| 15 | 16 | :is( |
| ... | ... | @@ -285,38 +286,40 @@ export function StyledDiffCodeView<LAnnotation = undefined>({ |
| 285 | 286 | ...props |
| 286 | 287 | }: StyledDiffCodeViewProps<LAnnotation>) { |
| 287 | 288 | 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> | |
| 321 | 324 | ); |
| 322 | 325 | } |
apps/web/src/components/diffs/useCodeViewFileReveal.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import type { CodeViewScrollTarget } from "@pierre/diffs"; | |
| 2 | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 3 | ||
| 4 | interface 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. | |
| 11 | export 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 | 12 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; |
| 13 | 13 | import { Editor } from "@pierre/diffs/editor"; |
| 14 | 14 | import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; |
| 15 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | |
| 15 | 16 | import { |
| 16 | 17 | isAtomCommandInterrupted, |
| 17 | 18 | squashAtomCommandFailure, |
| ... | ... | @@ -1304,46 +1305,50 @@ export default function FilePreviewPanel({ |
| 1304 | 1305 | onPendingChange={onPendingChange} |
| 1305 | 1306 | /> |
| 1306 | 1307 | ) : 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, | |
| 1320 | 1315 | }} |
| 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> | |
| 1333 | 1336 | ) : ( |
| 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> | |
| 1347 | 1352 | ) |
| 1348 | 1353 | ) : null} |
| 1349 | 1354 | </div> |
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+146-158| ... | ... | @@ -58,9 +58,9 @@ import { useEnvironmentQuery } from "~/state/query"; |
| 58 | 58 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 59 | 59 | |
| 60 | 60 | import { DiffPanelLoadingState } from "../DiffPanelShell"; |
| 61 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | |
| 62 | 61 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; |
| 63 | 62 | import { DiffFileTree } from "../diffs/DiffFileTree"; |
| 63 | import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; | |
| 64 | 64 | import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; |
| 65 | 65 | import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; |
| 66 | 66 | import { Button } from "../ui/button"; |
| ... | ... | @@ -250,7 +250,7 @@ export function PullRequestCodeTab({ |
| 250 | 250 | readonly slices: ReadonlyArray<DiffSlice>; |
| 251 | 251 | }>({ key: "", cursor: null, slices: NO_SLICES }); |
| 252 | 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); | |
| 254 | 254 | |
| 255 | 255 | const referenceKey = pullRequestReviewKey(reference); |
| 256 | 256 | const commit = selectedCommitOid; |
| ... | ... | @@ -603,21 +603,15 @@ export function PullRequestCodeTab({ |
| 603 | 603 | [], |
| 604 | 604 | ); |
| 605 | 605 | |
| 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); | |
| 613 | 607 | const revealFile = useCallback( |
| 614 | 608 | (path: string) => { |
| 615 | 609 | const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path); |
| 616 | 610 | if (item === undefined) return; |
| 617 | 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 | ); |
| 622 | 616 | |
| 623 | 617 | const toggleAllFiles = () => { |
| ... | ... | @@ -1290,164 +1284,158 @@ export function PullRequestCodeTab({ |
| 1290 | 1284 | ); |
| 1291 | 1285 | |
| 1292 | 1286 | 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 | |
| 1297 | 1290 | 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; | |
| 1305 | 1298 | 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 | |
| 1309 | 1302 | 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 | |
| 1331 | 1324 | 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 | ))} | |
| 1353 | 1343 | </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 | |
| 1361 | 1352 | 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; | |
| 1386 | 1367 | } |
| 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 | |
| 1390 | 1381 | rows absolutely, so it has to own that element — the thread diff panel hands it the |
| 1391 | 1382 | 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} | |
| 1447 | 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 | 1436 | </div> |
| 1450 | </DiffWorkerPoolProvider> | |
| 1437 | {unstructured} | |
| 1438 | </div> | |
| 1451 | 1439 | ); |
| 1452 | 1440 | } |
| 1453 | 1441 |