feat: viewport virtualization for DiffViewer + lazy per-file hunk loading in FileCard
DiffViewer now uses IntersectionObserver to replace off-viewport FileCards with 48px placeholder divs, eliminating thousands of DOM nodes at initial render for large diffs. Files within 1× viewport buffer are rendered as real FileCards. FileCard defaults to collapsed state and only fires getFileDiff when expanded, with staleTime: Infinity to avoid re-fetches. Handles loading/error/binary/ no-hunks states. Commit mode passes detail prop to skip lazy loading entirely. DiffViewer batches expand-all in chunks of 10, prefetching via tRPC utils to saturate the network without blocking the UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
|||||||
import type { FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
||||||
import { FileCard } from "./FileCard";
|
import { FileCard } from "./FileCard";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
|
||||||
function getFileCommentMap(
|
function getFileCommentMap(
|
||||||
commentsByLine: Map<string, ReviewComment[]>,
|
commentsByLine: Map<string, ReviewComment[]>,
|
||||||
@@ -13,7 +16,9 @@ function getFileCommentMap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DiffViewerProps {
|
interface DiffViewerProps {
|
||||||
files: FileDiffDetail[];
|
files: (FileDiff | FileDiffDetail)[];
|
||||||
|
phaseId: string;
|
||||||
|
commitMode: boolean;
|
||||||
commentsByLine: Map<string, ReviewComment[]>;
|
commentsByLine: Map<string, ReviewComment[]>;
|
||||||
onAddComment: (
|
onAddComment: (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
@@ -28,10 +33,13 @@ interface DiffViewerProps {
|
|||||||
viewedFiles?: Set<string>;
|
viewedFiles?: Set<string>;
|
||||||
onToggleViewed?: (filePath: string) => void;
|
onToggleViewed?: (filePath: string) => void;
|
||||||
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
||||||
|
expandAll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiffViewer({
|
export function DiffViewer({
|
||||||
files,
|
files,
|
||||||
|
phaseId,
|
||||||
|
commitMode,
|
||||||
commentsByLine,
|
commentsByLine,
|
||||||
onAddComment,
|
onAddComment,
|
||||||
onResolveComment,
|
onResolveComment,
|
||||||
@@ -41,14 +49,141 @@ export function DiffViewer({
|
|||||||
viewedFiles,
|
viewedFiles,
|
||||||
onToggleViewed,
|
onToggleViewed,
|
||||||
onRegisterRef,
|
onRegisterRef,
|
||||||
|
expandAll,
|
||||||
}: DiffViewerProps) {
|
}: DiffViewerProps) {
|
||||||
|
// Set of file paths currently intersecting (or near) the viewport
|
||||||
|
const visibleFiles = useRef<Set<string>>(new Set());
|
||||||
|
// Map from filePath → wrapper div ref
|
||||||
|
const wrapperRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
// Increment to trigger re-render when visibility changes
|
||||||
|
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||||
|
|
||||||
|
// Single IntersectionObserver for all wrappers
|
||||||
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (files.length === 1) return; // skip for single file
|
||||||
|
|
||||||
|
observerRef.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let changed = false;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const filePath = (entry.target as HTMLDivElement).dataset['filePath'];
|
||||||
|
if (!filePath) continue;
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
if (!visibleFiles.current.has(filePath)) {
|
||||||
|
visibleFiles.current.add(filePath);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (visibleFiles.current.has(filePath)) {
|
||||||
|
visibleFiles.current.delete(filePath);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) setVisibilityVersion((v) => v + 1);
|
||||||
|
},
|
||||||
|
{ rootMargin: '100% 0px 100% 0px' }, // 1× viewport above and below
|
||||||
|
);
|
||||||
|
|
||||||
|
// Observe all current wrapper divs
|
||||||
|
for (const el of wrapperRefs.current.values()) {
|
||||||
|
observerRef.current.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observerRef.current?.disconnect();
|
||||||
|
};
|
||||||
|
}, [files]); // re-create observer when file list changes
|
||||||
|
|
||||||
|
// Register wrapper ref — observes the div, registers with parent
|
||||||
|
const registerWrapper = useCallback(
|
||||||
|
(filePath: string, el: HTMLDivElement | null) => {
|
||||||
|
if (el) {
|
||||||
|
wrapperRefs.current.set(filePath, el);
|
||||||
|
observerRef.current?.observe(el);
|
||||||
|
} else {
|
||||||
|
const prev = wrapperRefs.current.get(filePath);
|
||||||
|
if (prev) observerRef.current?.unobserve(prev);
|
||||||
|
wrapperRefs.current.delete(filePath);
|
||||||
|
}
|
||||||
|
onRegisterRef?.(filePath, el);
|
||||||
|
},
|
||||||
|
[onRegisterRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
// expandAll batch loading
|
||||||
|
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!expandAll || files.length === 0) return;
|
||||||
|
|
||||||
|
const BATCH = 10;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function batchExpand() {
|
||||||
|
const chunks: (FileDiff | FileDiffDetail)[][] = [];
|
||||||
|
for (let i = 0; i < files.length; i += BATCH) {
|
||||||
|
chunks.push(files.slice(i, i + BATCH));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
if (cancelled) break;
|
||||||
|
// Mark this batch as expanded (triggers FileCard renders + queries)
|
||||||
|
setExpandedFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const f of chunk) {
|
||||||
|
if (f.status !== 'binary') next.add(f.newPath);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Eagerly prefetch via React Query to saturate network
|
||||||
|
await Promise.all(
|
||||||
|
chunk
|
||||||
|
.filter((f) => f.status !== 'binary' && !('hunks' in f))
|
||||||
|
.map((f) =>
|
||||||
|
utils.getFileDiff
|
||||||
|
.fetch({ phaseId, filePath: encodeURIComponent(f.newPath) })
|
||||||
|
.catch(() => null), // swallow per-file errors; FileCard shows its own error state
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
batchExpand();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [expandAll]); // only re-run when expandAll toggles
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only on expandAll
|
||||||
|
|
||||||
|
// Suppress unused variable warning — used only to force re-render on visibility change
|
||||||
|
void visibilityVersion;
|
||||||
|
|
||||||
|
const isSingleFile = files.length === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{files.map((file) => (
|
{files.map((file) => {
|
||||||
<div key={file.newPath} ref={(el) => onRegisterRef?.(file.newPath, el)}>
|
const isVisible = isSingleFile || visibleFiles.current.has(file.newPath);
|
||||||
|
const isExpandedOverride = expandedFiles.has(file.newPath) ? true : undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.newPath}
|
||||||
|
ref={(el) => registerWrapper(file.newPath, el)}
|
||||||
|
data-file-path={file.newPath}
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
<FileCard
|
<FileCard
|
||||||
file={file}
|
file={file as FileDiff}
|
||||||
|
detail={'hunks' in file ? (file as FileDiffDetail) : undefined}
|
||||||
|
phaseId={phaseId}
|
||||||
|
commitMode={commitMode}
|
||||||
commentsByLine={getFileCommentMap(commentsByLine, file.newPath)}
|
commentsByLine={getFileCommentMap(commentsByLine, file.newPath)}
|
||||||
|
isExpandedOverride={isExpandedOverride}
|
||||||
onAddComment={onAddComment}
|
onAddComment={onAddComment}
|
||||||
onResolveComment={onResolveComment}
|
onResolveComment={onResolveComment}
|
||||||
onUnresolveComment={onUnresolveComment}
|
onUnresolveComment={onUnresolveComment}
|
||||||
@@ -57,8 +192,12 @@ export function DiffViewer({
|
|||||||
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
||||||
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ height: '48px' }} aria-hidden />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,16 @@ import {
|
|||||||
Minus,
|
Minus,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Circle,
|
Circle,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import type { FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
import type { FileDiff, FileDiffDetail, DiffLine, ReviewComment } from "./types";
|
||||||
import { HunkRows } from "./HunkRows";
|
import { HunkRows } from "./HunkRows";
|
||||||
import { useHighlightedFile } from "./use-syntax-highlight";
|
import { useHighlightedFile } from "./use-syntax-highlight";
|
||||||
|
import { parseUnifiedDiff } from "./parse-diff";
|
||||||
|
import { trpc } from "@/lib/trpc";
|
||||||
|
|
||||||
const changeTypeBadge: Record<
|
const statusBadge: Record<FileDiff['status'], { label: string; classes: string } | null> = {
|
||||||
FileDiffDetail['status'],
|
|
||||||
{ label: string; classes: string } | null
|
|
||||||
> = {
|
|
||||||
added: {
|
added: {
|
||||||
label: "NEW",
|
label: "NEW",
|
||||||
classes:
|
classes:
|
||||||
@@ -32,10 +32,13 @@ const changeTypeBadge: Record<
|
|||||||
"bg-status-active-bg text-status-active-fg border-status-active-border",
|
"bg-status-active-bg text-status-active-fg border-status-active-border",
|
||||||
},
|
},
|
||||||
modified: null,
|
modified: null,
|
||||||
binary: null,
|
binary: {
|
||||||
|
label: "BINARY",
|
||||||
|
classes: "bg-muted text-muted-foreground border-border",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const leftBorderClass: Record<FileDiffDetail['status'], string> = {
|
const leftBorderClass: Record<FileDiff['status'], string> = {
|
||||||
added: "border-l-2 border-l-status-success-fg",
|
added: "border-l-2 border-l-status-success-fg",
|
||||||
deleted: "border-l-2 border-l-status-error-fg",
|
deleted: "border-l-2 border-l-status-error-fg",
|
||||||
renamed: "border-l-2 border-l-status-active-fg",
|
renamed: "border-l-2 border-l-status-active-fg",
|
||||||
@@ -44,8 +47,12 @@ const leftBorderClass: Record<FileDiffDetail['status'], string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: FileDiffDetail;
|
file: FileDiff;
|
||||||
|
detail?: FileDiffDetail;
|
||||||
|
phaseId: string;
|
||||||
|
commitMode: boolean;
|
||||||
commentsByLine: Map<string, ReviewComment[]>;
|
commentsByLine: Map<string, ReviewComment[]>;
|
||||||
|
isExpandedOverride?: boolean;
|
||||||
onAddComment: (
|
onAddComment: (
|
||||||
filePath: string,
|
filePath: string,
|
||||||
lineNumber: number,
|
lineNumber: number,
|
||||||
@@ -62,7 +69,11 @@ interface FileCardProps {
|
|||||||
|
|
||||||
export function FileCard({
|
export function FileCard({
|
||||||
file,
|
file,
|
||||||
|
detail,
|
||||||
|
phaseId,
|
||||||
|
commitMode,
|
||||||
commentsByLine,
|
commentsByLine,
|
||||||
|
isExpandedOverride,
|
||||||
onAddComment,
|
onAddComment,
|
||||||
onResolveComment,
|
onResolveComment,
|
||||||
onUnresolveComment,
|
onUnresolveComment,
|
||||||
@@ -71,35 +82,65 @@ export function FileCard({
|
|||||||
isViewed = false,
|
isViewed = false,
|
||||||
onToggleViewed = () => {},
|
onToggleViewed = () => {},
|
||||||
}: FileCardProps) {
|
}: FileCardProps) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
// Uncontrolled expand for normal file clicks.
|
||||||
|
// Start expanded if detail prop is provided (commit mode).
|
||||||
|
const [isExpandedLocal, setIsExpandedLocal] = useState(() => !!detail);
|
||||||
|
|
||||||
const commentCount = useMemo(
|
// Merge with override from DiffViewer expandAll
|
||||||
() =>
|
const isExpanded = isExpandedOverride ?? isExpandedLocal;
|
||||||
Array.from(commentsByLine.values()).reduce(
|
|
||||||
(sum, arr) => sum + arr.length,
|
const fileDiffQuery = trpc.getFileDiff.useQuery(
|
||||||
0,
|
{ phaseId, filePath: encodeURIComponent(file.newPath) },
|
||||||
),
|
{
|
||||||
[commentsByLine],
|
enabled: isExpanded && !commitMode && file.status !== 'binary' && !detail,
|
||||||
|
staleTime: Infinity,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const badge = changeTypeBadge[file.status];
|
// Compute hunks from query data (phase mode)
|
||||||
|
const parsedHunks = useMemo(() => {
|
||||||
|
if (!fileDiffQuery.data?.rawDiff) return null;
|
||||||
|
const parsed = parseUnifiedDiff(fileDiffQuery.data.rawDiff);
|
||||||
|
return parsed[0] ?? null;
|
||||||
|
}, [fileDiffQuery.data]);
|
||||||
|
|
||||||
|
// Collect all lines for syntax highlighting
|
||||||
|
const allLines = useMemo(() => {
|
||||||
|
if (detail) return detail.hunks.flatMap((h) => h.lines);
|
||||||
|
if (parsedHunks) return parsedHunks.hunks.flatMap((h) => h.lines);
|
||||||
|
return [];
|
||||||
|
}, [detail, parsedHunks]);
|
||||||
|
|
||||||
// Flatten all hunk lines for syntax highlighting
|
|
||||||
const allLines = useMemo(
|
|
||||||
() => file.hunks.flatMap((h) => h.lines),
|
|
||||||
[file.hunks],
|
|
||||||
);
|
|
||||||
const tokenMap = useHighlightedFile(file.newPath, allLines);
|
const tokenMap = useHighlightedFile(file.newPath, allLines);
|
||||||
|
|
||||||
|
const commentCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
for (const [key, arr] of commentsByLine) {
|
||||||
|
if (key.startsWith(`${file.newPath}:`)) count += arr.length;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, [commentsByLine, file.newPath]);
|
||||||
|
|
||||||
|
const badge = statusBadge[file.status];
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
onAddComment,
|
||||||
|
onResolveComment,
|
||||||
|
onUnresolveComment,
|
||||||
|
onReplyComment,
|
||||||
|
onEditComment,
|
||||||
|
tokenMap,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border overflow-clip">
|
<div className="rounded-lg border border-border overflow-clip">
|
||||||
{/* File header — sticky so it stays visible when scrolling */}
|
{/* File header */}
|
||||||
<button
|
<button
|
||||||
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.status]}`}
|
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.status]}`}
|
||||||
style={{ top: 'var(--review-header-h, 0px)' }}
|
style={{ top: 'var(--review-header-h, 0px)' }}
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setIsExpandedLocal(!isExpandedLocal)}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
@@ -160,26 +201,63 @@ export function FileCard({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Diff content */}
|
{/* Diff content */}
|
||||||
{expanded && (
|
{isExpanded && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
{detail ? (
|
||||||
|
// Commit mode: pre-parsed hunks from detail prop
|
||||||
|
detail.hunks.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
|
||||||
|
) : (
|
||||||
<table className="w-full text-xs font-mono border-collapse">
|
<table className="w-full text-xs font-mono border-collapse">
|
||||||
<tbody>
|
<tbody>
|
||||||
{file.hunks.map((hunk, hi) => (
|
{detail.hunks.map((hunk, hi) => (
|
||||||
<HunkRows
|
<HunkRows
|
||||||
key={hi}
|
key={hi}
|
||||||
hunk={hunk}
|
hunk={hunk}
|
||||||
filePath={file.newPath}
|
filePath={file.newPath}
|
||||||
commentsByLine={commentsByLine}
|
commentsByLine={commentsByLine}
|
||||||
onAddComment={onAddComment}
|
{...handlers}
|
||||||
onResolveComment={onResolveComment}
|
|
||||||
onUnresolveComment={onUnresolveComment}
|
|
||||||
onReplyComment={onReplyComment}
|
|
||||||
onEditComment={onEditComment}
|
|
||||||
tokenMap={tokenMap}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)
|
||||||
|
) : file.status === 'binary' ? (
|
||||||
|
<div className="px-4 py-3 text-xs text-muted-foreground">Binary file — diff not shown</div>
|
||||||
|
) : fileDiffQuery.isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Loading diff…
|
||||||
|
</div>
|
||||||
|
) : fileDiffQuery.isError ? (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 text-xs text-destructive">
|
||||||
|
Failed to load diff.
|
||||||
|
<button
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
onClick={() => fileDiffQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : fileDiffQuery.data ? (
|
||||||
|
!parsedHunks || parsedHunks.hunks.length === 0 ? (
|
||||||
|
<div className="px-4 py-3 text-xs text-muted-foreground">No content changes</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs font-mono border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{parsedHunks.hunks.map((hunk, hi) => (
|
||||||
|
<HunkRows
|
||||||
|
key={hi}
|
||||||
|
hunk={hunk}
|
||||||
|
filePath={file.newPath}
|
||||||
|
commentsByLine={commentsByLine}
|
||||||
|
{...handlers}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -406,6 +406,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
) : (
|
) : (
|
||||||
<DiffViewer
|
<DiffViewer
|
||||||
files={files}
|
files={files}
|
||||||
|
phaseId={activePhaseId!}
|
||||||
|
commitMode={!!selectedCommit}
|
||||||
commentsByLine={commentsByLine}
|
commentsByLine={commentsByLine}
|
||||||
onAddComment={handleAddComment}
|
onAddComment={handleAddComment}
|
||||||
onResolveComment={handleResolveComment}
|
onResolveComment={handleResolveComment}
|
||||||
|
|||||||
Reference in New Issue
Block a user