diff --git a/apps/web/src/components/review/DiffViewer.tsx b/apps/web/src/components/review/DiffViewer.tsx index 1ffbe22..5e9c536 100644 --- a/apps/web/src/components/review/DiffViewer.tsx +++ b/apps/web/src/components/review/DiffViewer.tsx @@ -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 { trpc } from "@/lib/trpc"; function getFileCommentMap( commentsByLine: Map, @@ -13,7 +16,9 @@ function getFileCommentMap( } interface DiffViewerProps { - files: FileDiffDetail[]; + files: (FileDiff | FileDiffDetail)[]; + phaseId: string; + commitMode: boolean; commentsByLine: Map; onAddComment: ( filePath: string, @@ -28,10 +33,13 @@ interface DiffViewerProps { viewedFiles?: Set; onToggleViewed?: (filePath: string) => void; onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void; + expandAll?: boolean; } export function DiffViewer({ files, + phaseId, + commitMode, commentsByLine, onAddComment, onResolveComment, @@ -41,24 +49,155 @@ export function DiffViewer({ 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; + + const isSingleFile = files.length === 1; + return (
- {files.map((file) => ( -
onRegisterRef?.(file.newPath, el)}> - onToggleViewed?.(file.newPath)} - /> -
- ))} + {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)} + /> + ) : ( +
+ )} +
+ ); + })}
); } diff --git a/apps/web/src/components/review/FileCard.tsx b/apps/web/src/components/review/FileCard.tsx index 4c84769..12cfb14 100644 --- a/apps/web/src/components/review/FileCard.tsx +++ b/apps/web/src/components/review/FileCard.tsx @@ -6,16 +6,16 @@ import { Minus, CheckCircle2, Circle, + Loader2, } from "lucide-react"; 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 { useHighlightedFile } from "./use-syntax-highlight"; +import { parseUnifiedDiff } from "./parse-diff"; +import { trpc } from "@/lib/trpc"; -const changeTypeBadge: Record< - FileDiffDetail['status'], - { label: string; classes: string } | null -> = { +const statusBadge: Record = { added: { label: "NEW", classes: @@ -32,10 +32,13 @@ const changeTypeBadge: Record< "bg-status-active-bg text-status-active-fg border-status-active-border", }, modified: null, - binary: null, + binary: { + label: "BINARY", + classes: "bg-muted text-muted-foreground border-border", + }, }; -const leftBorderClass: Record = { +const leftBorderClass: Record = { added: "border-l-2 border-l-status-success-fg", deleted: "border-l-2 border-l-status-error-fg", renamed: "border-l-2 border-l-status-active-fg", @@ -44,8 +47,12 @@ const leftBorderClass: Record = { }; interface FileCardProps { - file: FileDiffDetail; + file: FileDiff; + detail?: FileDiffDetail; + phaseId: string; + commitMode: boolean; commentsByLine: Map; + isExpandedOverride?: boolean; onAddComment: ( filePath: string, lineNumber: number, @@ -62,7 +69,11 @@ interface FileCardProps { export function FileCard({ file, + detail, + phaseId, + commitMode, commentsByLine, + isExpandedOverride, onAddComment, onResolveComment, onUnresolveComment, @@ -71,35 +82,65 @@ export function FileCard({ isViewed = false, onToggleViewed = () => {}, }: 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( - () => - Array.from(commentsByLine.values()).reduce( - (sum, arr) => sum + arr.length, - 0, - ), - [commentsByLine], + // Merge with override from DiffViewer expandAll + const isExpanded = isExpandedOverride ?? isExpandedLocal; + + const fileDiffQuery = trpc.getFileDiff.useQuery( + { phaseId, filePath: encodeURIComponent(file.newPath) }, + { + 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 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 (
- {/* File header — sticky so it stays visible when scrolling */} + {/* File header */} {/* Diff content */} - {expanded && ( + {isExpanded && (
- - - {file.hunks.map((hunk, hi) => ( - - ))} - -
+ {detail ? ( + // Commit mode: pre-parsed hunks from detail prop + detail.hunks.length === 0 ? ( +
No content changes
+ ) : ( + + + {detail.hunks.map((hunk, hi) => ( + + ))} + +
+ ) + ) : file.status === 'binary' ? ( +
Binary file — diff not shown
+ ) : fileDiffQuery.isLoading ? ( +
+ + Loading diff… +
+ ) : fileDiffQuery.isError ? ( +
+ Failed to load diff. + +
+ ) : fileDiffQuery.data ? ( + !parsedHunks || parsedHunks.hunks.length === 0 ? ( +
No content changes
+ ) : ( + + + {parsedHunks.hunks.map((hunk, hi) => ( + + ))} + +
+ ) + ) : null}
)}
diff --git a/apps/web/src/components/review/ReviewTab.tsx b/apps/web/src/components/review/ReviewTab.tsx index 8d0a2c0..df91c58 100644 --- a/apps/web/src/components/review/ReviewTab.tsx +++ b/apps/web/src/components/review/ReviewTab.tsx @@ -406,6 +406,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) { ) : (