Merges task KGRrdWohwZ6YcWjOZ0KqF (ReviewTab rawDiff → metadata file list, phaseId/commitMode wiring) into the viewport virtualization phase branch. Key resolution decisions: - Keep viewport virtualization IntersectionObserver logic from HEAD - Use activeFiles (not undefined `files`) in ReviewTab DiffViewer render — bug fix from incoming - Keep FileDiff/FileDiffDetail split from HEAD (not deprecated FileChangeType) - Keep FileDiff['status'] in parse-diff (status lives on base type) - Drop spurious `comments` prop the incoming branch added to DiffViewer (unused) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
6.7 KiB
TypeScript
205 lines
6.7 KiB
TypeScript
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 { trpc } from "@/lib/trpc";
|
||
|
||
function getFileCommentMap(
|
||
commentsByLine: Map<string, ReviewComment[]>,
|
||
filePath: string,
|
||
): Map<string, ReviewComment[]> {
|
||
const result = new Map<string, ReviewComment[]>();
|
||
for (const [key, val] of commentsByLine) {
|
||
if (key.startsWith(`${filePath}:`)) result.set(key, val);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
interface DiffViewerProps {
|
||
files: (FileDiff | FileDiffDetail)[];
|
||
phaseId: string;
|
||
commitMode: boolean;
|
||
commentsByLine: Map<string, ReviewComment[]>;
|
||
onAddComment: (
|
||
filePath: string,
|
||
lineNumber: number,
|
||
lineType: DiffLine["type"],
|
||
body: string,
|
||
) => void;
|
||
onResolveComment: (commentId: string) => void;
|
||
onUnresolveComment: (commentId: string) => void;
|
||
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||
onEditComment?: (commentId: string, body: string) => void;
|
||
viewedFiles?: Set<string>;
|
||
onToggleViewed?: (filePath: string) => void;
|
||
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
||
expandAll?: boolean;
|
||
}
|
||
|
||
export function DiffViewer({
|
||
files,
|
||
phaseId,
|
||
commitMode,
|
||
commentsByLine,
|
||
onAddComment,
|
||
onResolveComment,
|
||
onUnresolveComment,
|
||
onReplyComment,
|
||
onEditComment,
|
||
viewedFiles,
|
||
onToggleViewed,
|
||
onRegisterRef,
|
||
expandAll,
|
||
}: 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;
|
||
void queryClient; // imported for type alignment; actual prefetch goes through trpc utils
|
||
|
||
const isSingleFile = files.length === 1;
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{files.map((file) => {
|
||
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
|
||
file={file as FileDiff}
|
||
detail={'hunks' in file ? (file as FileDiffDetail) : undefined}
|
||
phaseId={phaseId}
|
||
commitMode={commitMode}
|
||
commentsByLine={getFileCommentMap(commentsByLine, file.newPath)}
|
||
isExpandedOverride={isExpandedOverride}
|
||
onAddComment={onAddComment}
|
||
onResolveComment={onResolveComment}
|
||
onUnresolveComment={onUnresolveComment}
|
||
onReplyComment={onReplyComment}
|
||
onEditComment={onEditComment}
|
||
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
||
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
||
/>
|
||
) : (
|
||
<div style={{ height: '48px' }} aria-hidden />
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|