Files
Codewalkers/apps/web/src/components/review/DiffViewer.tsx
Lukas May 72f8b07b10 feat: wire phaseId + commit-view detail into DiffViewer; fix files prop
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>
2026-03-06 20:36:46 +01:00

205 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}