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, filePath: string, ): Map { const result = new Map(); 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; 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; 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>(new Set()); // Map from filePath → wrapper div ref const wrapperRefs = useRef>(new Map()); // Increment to trigger re-render when visibility changes const [visibilityVersion, setVisibilityVersion] = useState(0); // Single IntersectionObserver for all wrappers const observerRef = useRef(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>(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 (
{files.map((file) => { const isVisible = isSingleFile || visibleFiles.current.has(file.newPath); const isExpandedOverride = expandedFiles.has(file.newPath) ? true : undefined; return (
registerWrapper(file.newPath, el)} data-file-path={file.newPath} > {isVisible ? ( onToggleViewed?.(file.newPath)} /> ) : (
)}
); })}
); }