From e421c2f747e6ad43a91db4862186ce1282147e64 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 22 Apr 2026 23:47:18 +0700 Subject: [PATCH] perf: fix DiffViewer scroll performance (#24300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The GitPanel's DiffViewer had several performance and correctness issues that manifested as CPU spikes and scrollbar jank, especially on Safari with large diffs: 1. The `onScroll` handler called `getBoundingClientRect()` on every file wrapper per scroll tick (O(N) forced layouts per frame). 2. `setActiveFile()` re-rendered the entire DiffViewer tree on every active file change. 3. `parsePatchFiles()` ran on every render in `LocalDiffPanel`, `RemoteDiffPanel`, and `GitPanel`. 4. File header `rootMargin` used a percentage, which resolves against the root's width (not height), collapsing the observation strip in wide viewports. 5. The IntersectionObserver effect didn't re-run when the viewport mounted after an initial empty state, or when the viewport was resized. ## Fix ### Scroll tracking Replaced the `getBoundingClientRect` scroll handler with an `IntersectionObserver` watching a narrow strip at the top of the viewport. The observation strip is a pixel value derived from `viewport.clientHeight` (the previous `-95%` margin was broken in wide viewports because CSS margin percentages resolve against width). A `ResizeObserver` tracks viewport height so the strip adapts to layout changes, and the effect keys off a stable `fileListKey` string so scroll-driven re-renders don't tear down the observer. ### Memoization: React Compiler `src/pages/AgentsPage/` is opted into the React Compiler via `site/vite.config.mts`. The compiler automatically memoizes values, callbacks, and JSX at build time. This PR removed the manual `useMemo` / `useCallback` wrappers that were added earlier in the review cycle and lets the compiler handle memoization. `React.memo()` is retained on `FileTreeNodeView` and `LazyFileDiff` — the documented list-item exception from `site/AGENTS.md`. Their `memo()` effectiveness depends on the compiler stabilizing prop references; moving these components outside the compiler scope without adding manual memoization would silently regress scroll performance. `useParsedDiff` uses explicit `useMemo` as a documented exception: the compiler cannot prove purity of the external `parsePatchFiles` function from `@pierre/diffs`, so without `useMemo` the parser would run on every render even when inputs are unchanged. ### `activeCommentBoxRef` (stable annotation handler) `CommentableDiffViewer` wraps `activeCommentBox` in a ref that's synced in event handlers (not during render). This gives `renderAnnotation`, `handleSubmitComment`, and the annotation getters stable identities via the compiler, so comment-box toggles no longer force every `LazyFileDiff` to re-render. ### CSS containment for Safari Added `will-change: transform` on the scroll container and `contain: layout style` on each file wrapper. Programmatic `scrollIntoView` / `scrollBy` calls use `behavior: "instant"` to avoid fighting Safari's scroll compositor. ### Hook extraction Extracted `useActiveFileTracking` (observer setup, viewport sizing, scroll-to-file) and `useParsedDiff` (shared diff parsing with memoization) to keep `DiffViewer` focused on layout and eliminate duplication between `LocalDiffPanel` and `RemoteDiffPanel`. ### Testing Added a `LargeDiff` Storybook story (40 files × 60+ context lines, ~2,400 diff lines) with `isExpanded: true` so the observer code path is exercised, plus a `play` function that scrolls the viewport and asserts the sidebar highlight updates. ## Expected impact - Scroll handler: O(N) `getBoundingClientRect` calls per frame → 0 - Re-renders on scroll: full DiffViewer tree → sidebar only - Diff parsing: on every render → only when `diffString` changes --- .../DiffViewer/CommentableDiffViewer.tsx | 27 ++- .../DiffViewer/DiffViewer.stories.tsx | 56 ++++++ .../components/DiffViewer/DiffViewer.tsx | 180 +++++------------- .../components/DiffViewer/LocalDiffPanel.tsx | 15 +- .../components/DiffViewer/RemoteDiffPanel.tsx | 36 ++-- .../components/DiffViewer/testHelpers.ts | 35 ++++ .../DiffViewer/useActiveFileTracking.ts | 146 ++++++++++++++ .../components/DiffViewer/useParsedDiff.ts | 22 +++ .../components/GitPanel/GitPanel.stories.tsx | 17 ++ 9 files changed, 356 insertions(+), 178 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/DiffViewer/testHelpers.ts create mode 100644 site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts create mode 100644 site/src/pages/AgentsPage/components/DiffViewer/useParsedDiff.ts diff --git a/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx b/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx index feae91a39c..c2fa76a22b 100644 --- a/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx +++ b/site/src/pages/AgentsPage/components/DiffViewer/CommentableDiffViewer.tsx @@ -211,6 +211,13 @@ export const CommentableDiffViewer: FC = ({ const [activeCommentBox, setActiveCommentBox] = useState(null); + const activeCommentBoxRef = useRef(null); + + const updateCommentBox = (box: CommentBoxState | null) => { + activeCommentBoxRef.current = box; + setActiveCommentBox(box); + }; + // --------------------------------------------------------------- // Line interaction callbacks // --------------------------------------------------------------- @@ -221,7 +228,7 @@ export const CommentableDiffViewer: FC = ({ annotationSide: "additions" | "deletions"; }, ) => { - setActiveCommentBox({ + updateCommentBox({ fileName, start: props.lineNumber, startSide: props.annotationSide, @@ -241,7 +248,7 @@ export const CommentableDiffViewer: FC = ({ ) => { const result = commentBoxFromRange(fileName, range); if (result === "ignore") return; - setActiveCommentBox(result); + updateCommentBox(result); }; // --------------------------------------------------------------- @@ -270,23 +277,24 @@ export const CommentableDiffViewer: FC = ({ }; const handleCancelComment = () => { - setActiveCommentBox(null); + updateCommentBox(null); }; const handleSubmitComment = (text: string) => { - if (!activeCommentBox) return; - const { startLine, endLine, side } = contentRangeForBox(activeCommentBox); + const box = activeCommentBoxRef.current; + if (!box) return; + const { startLine, endLine, side } = contentRangeForBox(box); const content = extractDiffContent( parsedFiles, - activeCommentBox.fileName, + box.fileName, startLine, endLine, side, ); - // Single imperative call -- chip inserted atomically + // Single imperative call: chip inserted atomically // in one Lexical update. No rAF hack needed. chatInputRef?.current?.addFileReference({ - fileName: activeCommentBox.fileName, + fileName: box.fileName, startLine, endLine, content, @@ -295,12 +303,11 @@ export const CommentableDiffViewer: FC = ({ chatInputRef?.current?.insertText(text); } chatInputRef?.current?.focus(); - setActiveCommentBox(null); + updateCommentBox(null); }; const renderAnnotation = (annotation: DiffLineAnnotation) => { if (annotation.metadata === "active-input") { - if (!activeCommentBox) return null; return ( p.files, + ), + isExpanded: true, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + play: async ({ canvasElement }) => { + // Wait for the file tree sidebar to render, proving that + // isExpanded activates the tree + observer code path. + await waitFor(() => { + const nav = canvasElement.querySelector("nav"); + expect(nav).not.toBeNull(); + }); + + // Find the diff content viewport (the one containing file + // sections) rather than the file-tree sidebar viewport. + const fileSection = canvasElement.querySelector("[data-file-name]"); + const viewport = fileSection?.closest( + "[data-radix-scroll-area-viewport]", + ); + if (!viewport) throw new Error("diff viewport not found"); + + // Capture the initial active file (whichever the observer picked + // up at mount), then scroll and verify it changed. + let initialFile: string | undefined; + await waitFor(() => { + const btn = canvasElement.querySelector( + 'nav button[aria-current="true"]', + ); + expect(btn).not.toBeNull(); + initialFile = btn!.title; + }); + + // Scroll to roughly the middle of the diff content. + viewport.scrollTop = viewport.scrollHeight / 2; + + // The observer should fire and highlight a different file. + await waitFor(() => { + const btn = canvasElement.querySelector( + 'nav button[aria-current="true"]', + ); + expect(btn).not.toBeNull(); + expect(btn!.title).not.toBe(initialFile); + }); + }, +}; diff --git a/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx b/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx index f9a6836fac..8e6267bfd3 100644 --- a/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx +++ b/site/src/pages/AgentsPage/components/DiffViewer/DiffViewer.tsx @@ -11,6 +11,7 @@ import { ChevronRightIcon } from "lucide-react"; import { type ComponentProps, type FC, + memo, type ReactNode, useCallback, useEffect, @@ -27,6 +28,7 @@ import { DIFFS_FONT_STYLE, getDiffViewerOptions, } from "../ChatElements/tools/utils"; +import { useActiveFileTracking } from "./useActiveFileTracking"; // ------------------------------------------------------------------- // Public interface @@ -271,12 +273,19 @@ function buildFileTree(files: readonly FileDiffMetadata[]): FileTreeNode[] { // Tree node renderer // ------------------------------------------------------------------- -const FileTreeNodeView: FC<{ +interface FileTreeNodeViewProps { node: FileTreeNode; depth: number; activeFile: string | null; onFileClick: (fullPath: string) => void; -}> = ({ node, depth, activeFile, onFileClick }) => { +} + +function FileTreeNodeViewInner({ + node, + depth, + activeFile, + onFileClick, +}: FileTreeNodeViewProps) { const [expanded, setExpanded] = useState(true); if (node.type === "directory") { @@ -316,6 +325,7 @@ const FileTreeNodeView: FC<{ return ( ); -}; +} + +// memo requires stable props; the React Compiler provides them here. +const FileTreeNodeView = memo(FileTreeNodeViewInner); // ------------------------------------------------------------------- // Virtualized scroll container @@ -379,7 +392,7 @@ const DiffScrollContainer: FC<{ // on every render, which calls virtualizer.cleanUp() and // wipes the observer map. The compiler can't preserve this // useCallback, but that only causes it to skip this small - // wrapper — not the entire DiffViewer. + // wrapper, not the entire DiffViewer. const contentRef = useCallback( (node: HTMLDivElement | null) => { const viewport = node?.closest( @@ -400,7 +413,7 @@ const DiffScrollContainer: FC<{ return ( @@ -422,7 +435,7 @@ const DiffScrollContainer: FC<{ * heavy component (Shadow DOM + shiki highlighting) is only mounted * once the placeholder scrolls into or near the viewport. * - * Once mounted the component stays mounted — we never unmount a + * Once mounted the component stays mounted. We never unmount a * FileDiff that the user has already scrolled past, which avoids * layout shifts and repeated highlighting work. */ @@ -434,13 +447,13 @@ interface LazyFileDiffProps { selectedLines?: SelectedLineRange | null; } -const LazyFileDiff: FC = ({ +function LazyFileDiffInner({ fileDiff, options, lineAnnotations, renderAnnotation: renderAnnotationProp, selectedLines, -}) => { +}: LazyFileDiffProps) { const placeholderRef = useRef(null); const [visible, setVisible] = useState(false); @@ -491,7 +504,10 @@ const LazyFileDiff: FC = ({ selectedLines={selectedLines} /> ); -}; +} + +// memo requires stable props; the React Compiler provides them here. +const LazyFileDiff = memo(LazyFileDiffInner); // ------------------------------------------------------------------- // Main component @@ -515,16 +531,14 @@ export const DiffViewer: FC = ({ const theme = useTheme(); const isDark = theme.palette.mode === "dark"; - const diffOptions = (() => { - const base = getDiffViewerOptions(isDark); - return { - ...base, - diffStyle, - // Extend the base CSS to make file headers sticky so they - // remain visible while scrolling through long diffs. - unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`, - }; - })(); + const base = getDiffViewerOptions(isDark); + const diffOptions = { + ...base, + diffStyle, + // Extend the base CSS to make file headers sticky so they + // remain visible while scrolling through long diffs. + unsafeCSS: `${base.unsafeCSS ?? ""} ${STICKY_HEADER_CSS}`, + }; const fileOptions = { ...diffOptions, @@ -651,113 +665,16 @@ export const DiffViewer: FC = ({ (isExpanded || containerWidth >= FILE_TREE_THRESHOLD) && sortedFiles.length > 0; - // --------------------------------------------------------------- - // Refs for each file diff wrapper so we can scroll-to and track - // which file is currently visible. - // --------------------------------------------------------------- - const fileRefs = useRef>(new Map()); - const [activeFile, setActiveFile] = useState(null); - - // Keep a ref callback that sets up per-file refs. - const setFileRef = (name: string, el: HTMLDivElement | null) => { - if (el) { - fileRefs.current.set(name, el); - } else { - fileRefs.current.delete(name); - } - }; - - // Track which file is at the top of the diff scroll area by - // listening to scroll events on the viewport. The active file - // is whichever file wrapper's top edge is closest to (but not - // below) the container's top — i.e. the one whose sticky - // header would be showing. const diffViewportRef = useRef(null); - - useEffect(() => { - if (!showTree || sortedFiles.length === 0) { - return; - } - - const viewport = diffViewportRef.current; - if (!viewport) { - return; - } - - let rafId = 0; - const onScroll = () => { - cancelAnimationFrame(rafId); - rafId = requestAnimationFrame(() => { - const containerTop = viewport.getBoundingClientRect().top; - let bestName: string | null = null; - let bestDistance = Number.POSITIVE_INFINITY; - - for (const [name, el] of fileRefs.current.entries()) { - const rect = el.getBoundingClientRect(); - // The file "owns" the scroll position when its top - // is at or above the container top and its bottom is - // still below it. - if (rect.bottom > containerTop && rect.top <= containerTop + 1) { - const distance = Math.abs(rect.top - containerTop); - if (distance < bestDistance) { - bestDistance = distance; - bestName = name; - } - } - } - - // If nothing is at the top (e.g. scrolled to the very top - // with padding), pick the first file whose top is closest - // to the container top. - if (!bestName) { - for (const [name, el] of fileRefs.current.entries()) { - const dist = Math.abs( - el.getBoundingClientRect().top - containerTop, - ); - if (dist < bestDistance) { - bestDistance = dist; - bestName = name; - } - } - } - - if (bestName) { - setActiveFile(bestName); - } - }); - }; - - // Fire once to set initial state. - onScroll(); - - viewport.addEventListener("scroll", onScroll, { passive: true }); - return () => { - cancelAnimationFrame(rafId); - viewport.removeEventListener("scroll", onScroll); - }; - }, [showTree, sortedFiles.length]); - - const handleFileClick = (name: string) => { - const el = fileRefs.current.get(name); - if (el) { - el.scrollIntoView({ block: "start" }); - setActiveFile(name); - } - }; - - // Scroll to a file programmatically when the parent sets - // scrollToFile. This enables external navigation (e.g. - // clicking a file reference chip in the chat input). - useEffect(() => { - if (scrollToFile) { - const el = fileRefs.current.get(scrollToFile); - if (el) { - el.scrollIntoView({ block: "start", behavior: "smooth" }); - setActiveFile(scrollToFile); - } - onScrollToFileComplete?.(); - } - }, [scrollToFile, onScrollToFileComplete]); + const { treeActiveFile, setFileRef, handleFileClick } = useActiveFileTracking( + { + viewportRef: diffViewportRef, + sortedFiles, + enabled: showTree, + scrollToFile, + onScrollToFileComplete, + }, + ); // --------------------------------------------------------------- // Loading state @@ -818,7 +735,7 @@ export const DiffViewer: FC = ({ key={node.fullPath} node={node} depth={1} - activeFile={activeFile} + activeFile={treeActiveFile} onFileClick={handleFileClick} /> ))} @@ -838,12 +755,13 @@ export const DiffViewer: FC = ({ return (
setFileRef(fileDiff.name, el)} - className={ - i > 0 - ? "border-0 border-t border-solid border-border-default" - : undefined - } + className={cn( + "[contain:layout_style]", + i > 0 && + "border-0 border-t border-solid border-border-default", + )} > = ({ diffStyle, chatInputRef, }) => { - const parsedFiles = (() => { - const diff = repo.unified_diff; - if (!diff) { - return []; - } - try { - const patches = parsePatchFiles(diff); - return patches.flatMap((p) => p.files); - } catch { - return []; - } - })(); + const parsedFiles = useParsedDiff(repo.unified_diff); return ( = ({ }); const diffContent = diffContentsQuery.data?.diff; - const parsedFiles = (() => { - if (!diffContent) { - return [] as FileDiffMetadata[]; - } - try { - // The @pierre/diffs worker pool only keys cached highlighted - // ASTs by `cacheKey`, so the key must change whenever the diff - // query updates. React Query's `dataUpdatedAt` survives panel - // remounts, which prevents stale cache hits from pairing a new - // FileDiffMetadata with an older highlighted AST. - const patches = parsePatchFiles( - diffContent, - getDiffCacheKeyPrefix( - `chat-${chatId}`, - diffContentsQuery.dataUpdatedAt, - ), - ); - return patches.flatMap((p) => p.files); - } catch { - return [] as FileDiffMetadata[]; - } - })(); + const dataUpdatedAt = diffContentsQuery.dataUpdatedAt; + + // The @pierre/diffs worker pool only keys cached highlighted + // ASTs by `cacheKey`, so the key must change whenever the diff + // query updates. React Query's `dataUpdatedAt` survives panel + // remounts, which prevents stale cache hits from pairing a new + // FileDiffMetadata with an older highlighted AST. + const parsedFiles = useParsedDiff( + diffContent, + getDiffCacheKeyPrefix(`chat-${chatId}`, dataUpdatedAt), + ); // --------------------------------------------------------------- // Scroll-to-file from chat input chip clicks diff --git a/site/src/pages/AgentsPage/components/DiffViewer/testHelpers.ts b/site/src/pages/AgentsPage/components/DiffViewer/testHelpers.ts new file mode 100644 index 0000000000..4d8c9c3309 --- /dev/null +++ b/site/src/pages/AgentsPage/components/DiffViewer/testHelpers.ts @@ -0,0 +1,35 @@ +export function generateLargeDiff( + fileCount: number, + linesPerFile: number, +): string { + const dirs = ["src", "lib", "utils", "components", "hooks"]; + const files: string[] = []; + for (let f = 0; f < fileCount; f++) { + const dir = dirs[f % dirs.length]; + const fileName = `${dir}/module${f}.ts`; + const deletions = Math.floor(linesPerFile / 10); + const additionsReplace = Math.floor(linesPerFile / 10); + const additionsNew = Math.floor(linesPerFile / 25); + const oldCount = linesPerFile + deletions; + const newCount = linesPerFile + additionsReplace + additionsNew; + const lines = [ + `diff --git a/${fileName} b/${fileName}`, + `index ${f.toString(16).padStart(7, "0")}..${(f + 1).toString(16).padStart(7, "0")} 100644`, + `--- a/${fileName}`, + `+++ b/${fileName}`, + `@@ -1,${oldCount} +1,${newCount} @@`, + ]; + for (let i = 1; i <= linesPerFile; i++) { + lines.push(` // context line ${i} in ${fileName}`); + if (i % 10 === 0) { + lines.push(`- const old${i} = getValue(${i});`); + lines.push(`+ const new${i} = getUpdatedValue(${i});`); + } + if (i % 25 === 0) { + lines.push(`+ // Added: validation for ${fileName} at line ${i}`); + } + } + files.push(lines.join("\n")); + } + return files.join("\n"); +} diff --git a/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts b/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts new file mode 100644 index 0000000000..2e659f7c66 --- /dev/null +++ b/site/src/pages/AgentsPage/components/DiffViewer/useActiveFileTracking.ts @@ -0,0 +1,146 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { type RefObject, useEffect, useRef, useState } from "react"; + +// Leaves a 5% strip at the top of the viewport as the "active file" band. +const VIEWPORT_BOTTOM_MARGIN_RATIO = 0.95; + +interface UseActiveFileTrackingOptions { + viewportRef: RefObject; + sortedFiles: readonly FileDiffMetadata[]; + enabled: boolean; + scrollToFile?: string | null; + onScrollToFileComplete?: () => void; +} + +interface UseActiveFileTrackingReturn { + treeActiveFile: string | null; + setFileRef: (name: string, el: HTMLDivElement | null) => void; + handleFileClick: (name: string) => void; +} + +export function useActiveFileTracking({ + viewportRef, + sortedFiles, + enabled, + scrollToFile, + onScrollToFileComplete, +}: UseActiveFileTrackingOptions): UseActiveFileTrackingReturn { + const fileRefs = useRef>(new Map()); + const [treeActiveFile, setTreeActiveFile] = useState(null); + + const [viewportHeight, setViewportHeight] = useState(0); + + // viewportRef is a stable RefObject whose identity never changes, so + // an effect that depends on it won't re-run when .current transitions + // from null to the actual DOM node (e.g. after a loading state). + // Keep a state mirror that flips exactly once when the element mounts. + const [viewportEl, setViewportEl] = useState(null); + useEffect(() => { + setViewportEl(viewportRef.current); + }); + + useEffect(() => { + if (!viewportEl) return; + setViewportHeight(viewportEl.clientHeight); + const ro = new ResizeObserver(([entry]) => { + setViewportHeight(Math.round(entry.contentRect.height)); + }); + ro.observe(viewportEl); + return () => ro.disconnect(); + }, [viewportEl]); + + const sortedFilesRef = useRef(sortedFiles); + useEffect(() => { + sortedFilesRef.current = sortedFiles; + }); + + const fileListKey = sortedFiles.map((f) => f.name).join("\0"); + + const setFileRef = (name: string, el: HTMLDivElement | null) => { + if (el) { + fileRefs.current.set(name, el); + } else { + fileRefs.current.delete(name); + } + }; + + useEffect(() => { + if (!enabled || fileListKey === "" || viewportHeight === 0) return; + if (!viewportEl) return; + + const bottomMargin = Math.round( + viewportHeight * VIEWPORT_BOTTOM_MARGIN_RATIO, + ); + + const intersecting = new Set(); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const name = (entry.target as HTMLElement).dataset.fileName; + if (!name) continue; + if (entry.isIntersecting) { + intersecting.add(name); + } else { + intersecting.delete(name); + } + } + for (const file of sortedFilesRef.current) { + if (intersecting.has(file.name)) { + setTreeActiveFile(file.name); + break; + } + } + }, + { + root: viewportEl, + // Observe only the top ~5% strip of the viewport height. + rootMargin: `0px 0px -${bottomMargin}px 0px`, + threshold: 0, + }, + ); + + for (const [, el] of fileRefs.current.entries()) { + observer.observe(el); + } + + return () => observer.disconnect(); + }, [enabled, fileListKey, viewportEl, viewportHeight]); + + const handleFileClick = (name: string) => { + const el = fileRefs.current.get(name); + if (el) { + el.scrollIntoView({ block: "start", behavior: "instant" }); + setTreeActiveFile(name); + } + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: fileListKey is an intentional trigger dep. The effect reads fileRefs (a mutable ref) and must retry when the file list changes so a previously-unmounted element can be found. + useEffect(() => { + if (!scrollToFile) return; + const el = fileRefs.current.get(scrollToFile); + if (el) { + el.scrollIntoView({ block: "start", behavior: "instant" }); + setTreeActiveFile(scrollToFile); + onScrollToFileComplete?.(); + return; + } + // Element not found. If the target isn't even in the current file + // list (e.g. stale chip after the diff changed), complete the + // request so the parent can clear its scroll target. Otherwise + // the target is present but not yet mounted; wait for fileListKey + // to change again. + const existsInFileList = sortedFilesRef.current.some( + (f) => f.name === scrollToFile, + ); + if (!existsInFileList) { + onScrollToFileComplete?.(); + } + }, [scrollToFile, onScrollToFileComplete, fileListKey]); + + return { + treeActiveFile, + setFileRef, + handleFileClick, + }; +} diff --git a/site/src/pages/AgentsPage/components/DiffViewer/useParsedDiff.ts b/site/src/pages/AgentsPage/components/DiffViewer/useParsedDiff.ts new file mode 100644 index 0000000000..437f42416d --- /dev/null +++ b/site/src/pages/AgentsPage/components/DiffViewer/useParsedDiff.ts @@ -0,0 +1,22 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs"; +import { useMemo } from "react"; + +// Uses explicit useMemo despite the React Compiler scope because +// parsePatchFiles is external to the compiler's static analysis. +export function useParsedDiff( + diffString: string | undefined | null, + cacheKeyPrefix?: string, +): FileDiffMetadata[] { + return useMemo(() => { + if (!diffString) return []; + try { + return parsePatchFiles(diffString, cacheKeyPrefix).flatMap( + (p) => p.files, + ); + } catch (e) { + console.error("Failed to parse diff:", e); + return []; + } + }, [diffString, cacheKeyPrefix]); +} diff --git a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx index 1e1208a8e6..2704f28c1b 100644 --- a/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/GitPanel/GitPanel.stories.tsx @@ -6,6 +6,7 @@ import type { ChatDiffStatus, WorkspaceAgentRepoChanges, } from "#/api/typesGenerated"; +import { generateLargeDiff } from "../DiffViewer/testHelpers"; import { GitPanel } from "./GitPanel"; // --------------------------------------------------------------------------- @@ -322,3 +323,19 @@ export const InlineCommentInput: Story = { } }, }; + +export const LargeDiff: Story = { + args: { + repositories: new Map([ + [ + "/home/coder/large-project", + makeRepo({ + repo_root: "/home/coder/large-project", + branch: "feat/large-refactor", + remote_origin: "https://github.com/coder/large-project.git", + unified_diff: generateLargeDiff(40, 60), + }), + ], + ]), + }, +};