perf: fix DiffViewer scroll performance (#24300)

## 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
This commit is contained in:
Jaayden Halko
2026-04-22 23:47:18 +07:00
committed by GitHub
parent 72e3ae9c5f
commit e421c2f747
9 changed files with 356 additions and 178 deletions
@@ -211,6 +211,13 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
const [activeCommentBox, setActiveCommentBox] =
useState<CommentBoxState | null>(null);
const activeCommentBoxRef = useRef<CommentBoxState | null>(null);
const updateCommentBox = (box: CommentBoxState | null) => {
activeCommentBoxRef.current = box;
setActiveCommentBox(box);
};
// ---------------------------------------------------------------
// Line interaction callbacks
// ---------------------------------------------------------------
@@ -221,7 +228,7 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
annotationSide: "additions" | "deletions";
},
) => {
setActiveCommentBox({
updateCommentBox({
fileName,
start: props.lineNumber,
startSide: props.annotationSide,
@@ -241,7 +248,7 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
) => {
const result = commentBoxFromRange(fileName, range);
if (result === "ignore") return;
setActiveCommentBox(result);
updateCommentBox(result);
};
// ---------------------------------------------------------------
@@ -270,23 +277,24 @@ export const CommentableDiffViewer: FC<CommentableDiffViewerProps> = ({
};
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<CommentableDiffViewerProps> = ({
chatInputRef?.current?.insertText(text);
}
chatInputRef?.current?.focus();
setActiveCommentBox(null);
updateCommentBox(null);
};
const renderAnnotation = (annotation: DiffLineAnnotation<string>) => {
if (annotation.metadata === "active-input") {
if (!activeCommentBox) return null;
return (
<InlinePromptInput
onSubmit={handleSubmitComment}
@@ -5,6 +5,7 @@ import { expect, fn, waitFor } from "storybook/test";
import type { DiffStyle } from "../DiffViewer/DiffViewer";
import { DiffViewer } from "../DiffViewer/DiffViewer";
import { InlinePromptInput } from "../DiffViewer/RemoteDiffPanel";
import { generateLargeDiff } from "./testHelpers";
// biome-ignore format: raw diff string must preserve exact whitespace
const sampleDiff = [
@@ -429,3 +430,58 @@ export const RenameWithLongPaths: Story = {
parsedFiles: renameFiles,
},
};
export const LargeDiff: Story = {
args: {
parsedFiles: parsePatchFiles(generateLargeDiff(40, 60)).flatMap(
(p) => p.files,
),
isExpanded: true,
},
decorators: [
(Story) => (
<div style={{ height: 800, width: 900 }}>
<Story />
</div>
),
],
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<HTMLElement>(
"[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<HTMLElement>(
'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<HTMLElement>(
'nav button[aria-current="true"]',
);
expect(btn).not.toBeNull();
expect(btn!.title).not.toBe(initialFile);
});
},
};
@@ -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 (
<button
type="button"
aria-current={isActive ? "true" : undefined}
onClick={() => onFileClick(node.fullPath)}
className={cn(
"flex w-full items-center gap-1.5 rounded-none border-none bg-transparent py-1 text-left cursor-pointer outline-none border-0 border-r-2 border-solid border-transparent",
@@ -347,7 +357,10 @@ const FileTreeNodeView: FC<{
)}
</button>
);
};
}
// 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<HTMLElement>(
@@ -400,7 +413,7 @@ const DiffScrollContainer: FC<{
return (
<ScrollArea
className={className}
className={cn(className, "will-change-transform")}
scrollBarClassName="w-1.5"
viewportClassName="[&>div]:!block"
>
@@ -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<LazyFileDiffProps> = ({
function LazyFileDiffInner({
fileDiff,
options,
lineAnnotations,
renderAnnotation: renderAnnotationProp,
selectedLines,
}) => {
}: LazyFileDiffProps) {
const placeholderRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
@@ -491,7 +504,10 @@ const LazyFileDiff: FC<LazyFileDiffProps> = ({
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<DiffViewerProps> = ({
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<DiffViewerProps> = ({
(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<Map<string, HTMLDivElement>>(new Map());
const [activeFile, setActiveFile] = useState<string | null>(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<HTMLElement | null>(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<DiffViewerProps> = ({
key={node.fullPath}
node={node}
depth={1}
activeFile={activeFile}
activeFile={treeActiveFile}
onFileClick={handleFileClick}
/>
))}
@@ -838,12 +755,13 @@ export const DiffViewer: FC<DiffViewerProps> = ({
return (
<div
key={fileDiff.name}
data-file-name={fileDiff.name}
ref={(el) => 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",
)}
>
<LazyFileDiff
fileDiff={fileDiff}
@@ -1,9 +1,9 @@
import { parsePatchFiles } from "@pierre/diffs";
import type { FC, RefObject } from "react";
import type { WorkspaceAgentRepoChanges } from "#/api/typesGenerated";
import type { ChatMessageInputRef } from "../AgentChatInput";
import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
import type { DiffStyle } from "../DiffViewer/DiffViewer";
import { useParsedDiff } from "../DiffViewer/useParsedDiff";
interface LocalDiffPanelProps {
repo: WorkspaceAgentRepoChanges;
@@ -18,18 +18,7 @@ export const LocalDiffPanel: FC<LocalDiffPanelProps> = ({
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 (
<CommentableDiffViewer
@@ -1,5 +1,3 @@
import type { FileDiffMetadata } from "@pierre/diffs";
import { parsePatchFiles } from "@pierre/diffs";
import {
ArrowLeftIcon,
ExternalLinkIcon,
@@ -20,6 +18,7 @@ import { CommentableDiffViewer } from "../DiffViewer/CommentableDiffViewer";
import { DiffStatBadge } from "../DiffViewer/DiffStats";
import type { DiffStyle } from "../DiffViewer/DiffViewer";
import { getDiffCacheKeyPrefix } from "../DiffViewer/diffCacheKey";
import { useParsedDiff } from "../DiffViewer/useParsedDiff";
export { InlinePromptInput } from "../DiffViewer/CommentableDiffViewer";
@@ -90,28 +89,17 @@ export const RemoteDiffPanel: FC<RemoteDiffPanelProps> = ({
});
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
@@ -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");
}
@@ -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<HTMLElement | null>;
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<Map<string, HTMLDivElement>>(new Map());
const [treeActiveFile, setTreeActiveFile] = useState<string | null>(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<HTMLElement | null>(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<string>();
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,
};
}
@@ -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]);
}
@@ -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),
}),
],
]),
},
};