mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user