| 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,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"; |
| 182 | import { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | ||
| 183 | import { BranchToolbar } from "./BranchToolbar"; | 182 | import { BranchToolbar } from "./BranchToolbar"; |
| 184 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; | 183 | import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; |
| 185 | import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; | 184 | import 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 | } |
| 1353 | 1352 | ||
| 1354 | function ChatViewContent(props: ChatViewProps) { | 1353 | export 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 | |||
| 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,6 +24,7 @@ import { |
| 24 | } from "lucide-react"; | 24 | } from "lucide-react"; |
| 25 | import * as Schema from "effect/Schema"; | 25 | import * as Schema from "effect/Schema"; |
| 26 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 26 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 27 | import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; | ||
| 27 | import { useOpenInPreferredEditor } from "../editorPreferences"; | 28 | import { useOpenInPreferredEditor } from "../editorPreferences"; |
| 28 | import { type DraftId } from "../composerDraftStore"; | 29 | import { type DraftId } from "../composerDraftStore"; |
| 29 | import { openDiffFilePrimaryAction } from "../diffFileActions"; | 30 | import { 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); |
| 133 | 134 | ||
| 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; |
| 449 | 450 | ||
| 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]); |
| 454 | 455 | ||
| 455 | // Held as state so the scroll runs after a collapsed file has been drawn open again; scrolling | 456 | 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 | ); |
| 477 | 476 | ||
| 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 | <AnnotatableCodeView | 972 | <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 @@ |
| 1 | import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; | 1 | import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; |
| 2 | import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; | 2 | import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; |
| 3 | import * as Schema from "effect/Schema"; | 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 | import { useTheme } from "../hooks/useTheme"; | 5 | import { useTheme } from "../hooks/useTheme"; |
| 6 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; | 6 | import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; |
| 7 | import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; | 7 | import { 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 | } |
| 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 | export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { | 82 | export 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 | }); |
| 124 | 124 | ||
| 125 | vi.mock("../DiffWorkerPoolProvider", () => ({ | ||
| 126 | DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, | ||
| 127 | })); | ||
| 128 | |||
| 125 | function matchMedia() { | 129 | function 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"; |
| 52 | import { FileDiff } from "@pierre/diffs/react"; | 52 | import { FileDiff } from "@pierre/diffs/react"; |
| 53 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | ||
| 53 | import { | 54 | import { |
| 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 | <FileDiff | 2536 | {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 @@ |
| 1 | import type { ReactNode } from "react"; | ||
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 4 | ||
| ... | @@ -12,6 +13,10 @@ vi.mock("@pierre/diffs/react", () => ({ | ... | @@ -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 | vi.mock("~/composerDraftStore", () => ({ | 20 | vi.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 @@ |
| 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 | import { renderToStaticMarkup } from "react-dom/server"; | 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 | const testState = vi.hoisted(() => ({ | 24 | const 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 | })); |
| 8 | 37 | ||
| 9 | vi.mock("@pierre/diffs/react", () => ({ | 38 | vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); |
| 10 | CodeView: (props: { className: string; options: Record<string, unknown> }) => { | 39 | |
| 11 | testState.codeViewClassName = props.className; | 40 | vi.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 | |||
| 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 | import { StyledDiffCodeView } from "./StyledDiffCodeView"; | 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 | describe("StyledDiffCodeView", () => { | 152 | describe("StyledDiffCodeView", () => { |
| 20 | beforeEach(() => { | 153 | beforeEach(() => { |
| ... | @@ -58,3 +191,338 @@ describe("StyledDiffCodeView", () => { | ... | @@ -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,6 +10,7 @@ import { |
| 10 | import type { Ref } from "react"; | 10 | import type { Ref } from "react"; |
| 11 | 11 | ||
| 12 | import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; | 12 | import { DIFF_SURFACE_THEME_UNSAFE_CSS } from "~/lib/diffRendering"; |
| 13 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | ||
| 13 | 14 | ||
| 14 | const DIFF_VIEW_UNSAFE_CSS = `${DIFF_SURFACE_THEME_UNSAFE_CSS} | 15 | const 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 | ...props | 286 | ...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 sits | 292 | {...(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 | className | 295 | 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: unsafeCSSExtra | 301 | ...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 can | 308 | // 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 painted | 312 | paddingTop: 0, |
| 312 | // unconditionally by Pierre's stylesheet (`--diffs-gap-fallback`), so the metric has | 313 | // 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 its | 314 | // 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 @@ | ||
| 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,6 +12,7 @@ import { |
| 12 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; | 12 | import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; |
| 13 | import { Editor } from "@pierre/diffs/editor"; | 13 | import { Editor } from "@pierre/diffs/editor"; |
| 14 | import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; | 14 | import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; |
| 15 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | ||
| 15 | import { | 16 | import { |
| 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 | <Virtualizer | 1308 | <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 | <EditableFileSurface | 1337 | <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"; |
| 58 | import { useAtomCommand } from "~/state/use-atom-command"; | 58 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 59 | 59 | ||
| 60 | import { DiffPanelLoadingState } from "../DiffPanelShell"; | 60 | import { DiffPanelLoadingState } from "../DiffPanelShell"; |
| 61 | import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider"; | ||
| 62 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; | 61 | import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; |
| 63 | import { DiffFileTree } from "../diffs/DiffFileTree"; | 62 | import { DiffFileTree } from "../diffs/DiffFileTree"; |
| 63 | import { useCodeViewFileReveal } from "../diffs/useCodeViewFileReveal"; | ||
| 64 | import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; | 64 | import { diffFileTreeEntries } from "../diffs/diffFileTree.logic"; |
| 65 | import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; | 65 | import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView"; |
| 66 | import { Button } from "../ui/button"; | 66 | import { 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); |
| 254 | 254 | ||
| 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 | ); |
| 605 | 605 | ||
| 606 | // Held as state so the scroll runs after a folded file has been drawn open; scrolling in the | 606 | 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 | ); |
| 622 | 616 | ||
| 623 | const toggleAllFiles = () => { | 617 | const toggleAllFiles = () => { |
| ... | @@ -1290,164 +1284,158 @@ export function PullRequestCodeTab({ | ... | @@ -1290,164 +1284,158 @@ export function PullRequestCodeTab({ |
| 1290 | ); | 1284 | ); |
| 1291 | 1285 | ||
| 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 | <Collapsible | 1292 | <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 file | 1301 | {/* 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 === null | 1304 | {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 | <ChevronRightIcon | 1308 | <ChevronRightIcon |
| 1316 | aria-hidden | 1309 | 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 === 1 | 1316 | {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 no | 1323 | {/* 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 | <TooltipTrigger | 1329 | <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 it | 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 | up; the viewer inside still owns its own scrolling. */} | 1352 | up; the viewer inside still owns its own scrolling. */} |
| 1362 | <div | 1353 | <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 reader | 1355 | // 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 | 1356 | // 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, and | 1362 | // 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 | 1363 | // 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 its | 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 | rows absolutely, so it has to own that element — the thread diff panel hands it the | 1381 | 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 a | 1384 | // 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 | 1385 | // 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 | 1386 | // 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 slice | 1394 | // 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 | 1395 | // 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 | } |
| 1453 | 1441 |