Files
kandev/apps/web/components/review/review-diff-list.tsx
T

524 lines
17 KiB
TypeScript

"use client";
import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
import { IconAlertTriangle, IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { FileDiffViewer, DiffErrorBoundary } from "@/components/diff";
import type { RevertBlockInfo } from "@/components/diff";
import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileContent, updateFileContent } from "@/lib/ws/workspace-files";
import { generateUnifiedDiff, calculateHash } from "@/lib/utils/file-diff";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { useRunComment } from "@/hooks/domains/comments/use-run-comment";
import type { DiffComment } from "@/lib/diff/types";
import { diffSkipReasonLabel, reviewFileKey } from "./types";
import type { ReviewFile } from "./types";
import { RepoGroupHeader } from "./review-diff-list-groups";
import { FileDiffToolbar } from "./review-diff-toolbar";
import { groupByRepositoryName } from "@/lib/group-by-repo";
type ReviewDiffListProps = {
files: ReviewFile[];
reviewedFiles: Set<string>;
staleFiles: Set<string>;
sessionId: string;
autoMarkOnScroll: boolean;
wordWrap: boolean;
selectedFile?: string | null;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
};
export const ReviewDiffList = memo(function ReviewDiffList({
files,
reviewedFiles,
staleFiles,
sessionId,
autoMarkOnScroll,
wordWrap,
selectedFile,
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
fileRefs,
}: ReviewDiffListProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// All in-memory state (selectedFile, reviewedFiles, staleFiles, fileRefs)
// is keyed by `reviewFileKey(file)` so two files at the same path in
// different repos (e.g. `kandev/README.md` + `lvc/README.md`) get
// distinct rows. Without this every multi-repo review with same-named
// files would mark them all reviewed when one is checked.
const selectedIndex = selectedFile
? files.findIndex((f) => reviewFileKey(f) === selectedFile)
: -1;
const groups = useMemo(() => groupByRepositoryName(files, (f) => f.repository_name), [files]);
const showRepoHeaders = groups.length > 1 || (groups[0]?.repositoryName ?? "") !== "";
return (
<div ref={scrollContainerRef} className="overflow-y-auto h-full">
{groups.map((group) => (
<div
key={group.repositoryName || "__no_repo__"}
data-testid="changes-repo-group"
data-repository-name={group.repositoryName || ""}
>
{showRepoHeaders && (
<RepoGroupHeader name={group.repositoryName} fileCount={group.items.length} />
)}
{group.items.map((file) => {
const key = reviewFileKey(file);
return (
<FileDiffSection
key={key}
file={file}
fileKey={key}
isReviewed={reviewedFiles.has(key) && !staleFiles.has(key)}
isStale={staleFiles.has(key)}
sessionId={sessionId}
autoMarkOnScroll={autoMarkOnScroll}
wordWrap={wordWrap}
isSelected={selectedFile === key}
forceLoad={
selectedIndex >= 0 &&
files.findIndex((f) => reviewFileKey(f) === key) <= selectedIndex
}
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
sectionRef={fileRefs.get(key)}
scrollContainer={scrollContainerRef}
/>
);
})}
</div>
))}
</div>
);
});
// Per-repo grouping helpers live in ./review-diff-list-groups so this file
// stays under the 600-line lint cap.
type FileDiffSectionProps = {
file: ReviewFile;
/** Composite per-file key from `reviewFileKey(file)` — used as the arg
* to `onToggleReviewed` / `onDiscard` so callers can disambiguate
* same-named files in different repos. */
fileKey: string;
isReviewed: boolean;
isStale: boolean;
sessionId: string;
autoMarkOnScroll: boolean;
wordWrap: boolean;
isSelected?: boolean;
forceLoad?: boolean;
onToggleReviewed: (key: string, reviewed: boolean) => void;
onDiscard: (key: string) => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
sectionRef?: React.RefObject<HTMLDivElement | null>;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
function useLazyVisible(scrollContainer: React.RefObject<HTMLDivElement | null>) {
const [isVisible, setIsVisible] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: "200px 0px", root: scrollContainer.current },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [scrollContainer]);
return { isVisible, sentinelRef };
}
type AutoMarkArgs = {
autoMarkOnScroll: boolean;
isReviewed: boolean;
isStale: boolean;
/** Composite per-file key (matches what onToggleReviewed expects). */
fileKey: string;
onToggleReviewed: (key: string, reviewed: boolean) => void;
scrollContainer: React.RefObject<HTMLDivElement | null>;
};
function useAutoMarkOnScroll({
autoMarkOnScroll,
isReviewed,
isStale,
fileKey,
onToggleReviewed,
scrollContainer,
}: AutoMarkArgs) {
const scrollSentinelRef = useRef<HTMLDivElement | null>(null);
const autoMarkedRef = useRef(false);
useEffect(() => {
if (!autoMarkOnScroll || isReviewed || isStale) {
autoMarkedRef.current = false;
return;
}
const sentinel = scrollSentinelRef.current;
const root = scrollContainer.current;
if (!sentinel || !root) return;
const observer = new IntersectionObserver(
([entry]) => {
if (
!entry.isIntersecting &&
entry.boundingClientRect.top < root.getBoundingClientRect().top &&
!autoMarkedRef.current
) {
autoMarkedRef.current = true;
console.debug("[review] auto-mark reviewed:", fileKey);
onToggleReviewed(fileKey, true);
}
},
{ threshold: 0, root },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [autoMarkOnScroll, fileKey, isReviewed, isStale, onToggleReviewed, scrollContainer]);
return scrollSentinelRef;
}
type FileDiffHeaderProps = {
file: ReviewFile;
isReviewed: boolean;
isStale: boolean;
sessionId: string;
collapsed: boolean;
wordWrap: boolean;
expandUnchanged: boolean;
onCheckboxChange: (checked: boolean | "indeterminate") => void;
onDiscard: () => void;
onOpenFile?: (filePath: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onToggleCollapse: () => void;
onToggleExpandUnchanged: () => void;
onToggleWordWrap: () => void;
};
function FileDiffHeader({
file,
isReviewed,
isStale,
collapsed,
wordWrap,
expandUnchanged,
sessionId,
onCheckboxChange,
onDiscard,
onOpenFile,
onPreviewMarkdown,
onToggleCollapse,
onToggleExpandUnchanged,
onToggleWordWrap,
}: FileDiffHeaderProps) {
return (
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-card/95 backdrop-blur-sm border-b border-border/50">
<Checkbox
checked={isReviewed}
onCheckedChange={onCheckboxChange}
className="h-4 w-4 cursor-pointer"
/>
<button
onClick={onToggleCollapse}
className="flex items-center gap-1.5 flex-1 min-w-0 cursor-pointer text-left hover:text-foreground"
>
{collapsed ? (
<IconChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<IconChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="text-[13px] font-medium truncate">{file.path}</span>
</button>
{isStale && (
<span className="flex items-center gap-1 text-xs text-yellow-500">
<IconAlertTriangle className="h-3.5 w-3.5" />
changed
</span>
)}
<span className="text-xs text-muted-foreground">
{file.additions > 0 && <span className="text-emerald-500">+{file.additions}</span>}
{file.additions > 0 && file.deletions > 0 && " / "}
{file.deletions > 0 && <span className="text-rose-500">-{file.deletions}</span>}
</span>
<FileDiffToolbar
diff={file.diff}
filePath={file.path}
sessionId={sessionId}
source={file.source}
wordWrap={wordWrap}
expandUnchanged={expandUnchanged}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onToggleExpandUnchanged={onToggleExpandUnchanged}
onToggleWordWrap={onToggleWordWrap}
/>
</div>
);
}
function useCommentRunHandler(sessionId: string) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const { toast } = useToast();
const { runComment } = useRunComment({
sessionId,
taskId: activeTaskId ?? null,
});
return useCallback(
async (comment: DiffComment) => {
try {
const { queued } = await runComment(comment);
toast({
title: "Comment sent",
description: queued ? "Queued for the agent." : "Sent to the agent.",
});
} catch (err) {
console.error("Failed to run diff comment:", err);
toast({
title: "Failed to send comment",
description: "Please try again.",
variant: "error",
});
}
},
[runComment, toast],
);
}
async function revertBlock(
sessionId: string,
filePath: string,
info: RevertBlockInfo,
repo?: string,
) {
const client = getWebSocketClient();
if (!client) return;
try {
const response = await requestFileContent(client, sessionId, filePath, repo);
if (response.error) return;
const currentContent = response.content;
const hash = await calculateHash(currentContent);
const lines = currentContent.split("\n");
lines.splice(info.addStart - 1, info.addCount, ...info.oldLines);
const nextContent = lines.join("\n");
if (nextContent === currentContent) return;
const patch = generateUnifiedDiff(currentContent, nextContent, filePath);
if (!patch || !/^@@/m.test(patch)) return;
await updateFileContent(client, sessionId, {
path: filePath,
diff: patch,
originalHash: hash,
repo,
});
} catch (err) {
console.error("Failed to revert change block:", err);
}
}
function useScrollIntoViewOnSelect(
isSelected: boolean | undefined,
sectionRef: React.RefObject<HTMLDivElement | null> | undefined,
setCollapsed: React.Dispatch<React.SetStateAction<boolean>>,
) {
useEffect(() => {
if (isSelected) {
setCollapsed(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
sectionRef?.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
}, [isSelected, sectionRef, setCollapsed]);
}
function renderDiffContent(opts: {
shouldRender: boolean;
file: ReviewFile;
sessionId: string;
wordWrap: boolean;
expandUnchanged: boolean;
onRevertBlock: (filePath: string, info: RevertBlockInfo) => void;
onCommentRun: (comment: DiffComment) => void;
onToggleExpandUnchanged: () => void;
}) {
const {
shouldRender,
file,
sessionId,
wordWrap,
expandUnchanged,
onRevertBlock,
onCommentRun,
onToggleExpandUnchanged,
} = opts;
if (shouldRender && file.diff) {
// Expansion fetches the file content from the working tree to reconstruct
// full context around hunks. For PR files the working tree's content
// belongs to the local branch, not the PR head — when the same file has
// both PR and local changes, the wrong content gets paired with the PR
// patch and @pierre/diffs 1.1.x renders nothing/errors. Disable expansion
// for PR-sourced rows.
//
// Also disable expansion for committed-source rows: the backend computes
// the diff against the session's stored base commit, not HEAD. HEAD
// already contains the committed changes, so fetching old content at
// baseRef="HEAD" returns the same content as the working tree, leaving
// processFile's trailing-context counts inconsistent and crashing the
// renderer. Using the right base would need the session's base SHA
// plumbed in here; that's a follow-up.
//
// Also disable expansion for untracked files: the backend synthesizes a
// single all-additions hunk against /dev/null, so there is no real
// context to expand. Re-parsing with the live working-tree content
// races with concurrent edits — if the file shrinks after the snapshot,
// the cached hunk header advertises more lines than the fetched content
// has and @pierre/diffs' DiffHunksRenderer throws.
const enableExpansion = file.source === "uncommitted" && file.status !== "untracked";
return (
<>
<DiffErrorBoundary filePath={file.path}>
<FileDiffViewer
filePath={file.path}
diff={file.diff}
status={file.status}
enableComments
enableAcceptReject
onRevertBlock={onRevertBlock}
onCommentRun={onCommentRun}
sessionId={sessionId}
wordWrap={wordWrap}
enableExpansion={enableExpansion}
baseRef="HEAD"
hideHeader
expandUnchanged={expandUnchanged}
onToggleExpandUnchanged={onToggleExpandUnchanged}
repo={file.repository_name}
/>
</DiffErrorBoundary>
{file.diff_skip_reason === "truncated" && (
<div className="py-1 text-center text-xs text-muted-foreground border-t">
Diff truncated — showing first 256 KB
</div>
)}
</>
);
}
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
{diffSkipReasonLabel(file.diff_skip_reason)}
</div>
);
}
function FileDiffSection({
file,
fileKey,
isReviewed,
isStale,
sessionId,
autoMarkOnScroll,
wordWrap,
isSelected,
forceLoad,
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
sectionRef,
scrollContainer,
}: FileDiffSectionProps) {
const [collapsed, setCollapsed] = useState(false);
const [expandUnchanged, setExpandUnchanged] = useState(false);
const [localWordWrap, setLocalWordWrap] = useState<boolean | undefined>(undefined);
const effectiveWordWrap = localWordWrap ?? wordWrap;
const handleToggleCollapse = useCallback(() => setCollapsed((v) => !v), []);
const handleToggleExpandUnchanged = useCallback(() => setExpandUnchanged((v) => !v), []);
const handleToggleWordWrap = useCallback(
() => setLocalWordWrap((v) => !(v ?? wordWrap)),
[wordWrap],
);
const { isVisible, sentinelRef } = useLazyVisible(scrollContainer);
// Force load when visible via intersection observer, or forceLoad is true
const shouldRenderContent = isVisible || !!forceLoad;
useScrollIntoViewOnSelect(isSelected, sectionRef, setCollapsed);
// Auto-mark sends the composite key (matches the dialog's reviewed-set
// shape) so cross-repo same-named files don't all get marked when one
// scrolls past.
const scrollSentinelRef = useAutoMarkOnScroll({
autoMarkOnScroll,
isReviewed,
isStale,
fileKey,
onToggleReviewed,
scrollContainer,
});
const handleCheckboxChange = useCallback(
(checked: boolean | "indeterminate") => {
onToggleReviewed(fileKey, checked === true);
},
[fileKey, onToggleReviewed],
);
const handleDiscard = useCallback(() => {
onDiscard(fileKey);
}, [fileKey, onDiscard]);
const handleRevertBlock = useCallback(
(filePath: string, info: RevertBlockInfo) =>
revertBlock(sessionId, filePath, info, file.repository_name),
[sessionId, file.repository_name],
);
const handleCommentRun = useCommentRunHandler(sessionId);
return (
<div ref={sectionRef} className="border-b border-border">
<div ref={scrollSentinelRef} className="h-0" />
<FileDiffHeader
file={file}
isReviewed={isReviewed}
isStale={isStale}
sessionId={sessionId}
collapsed={collapsed}
wordWrap={effectiveWordWrap}
expandUnchanged={expandUnchanged}
onCheckboxChange={handleCheckboxChange}
onDiscard={handleDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
onToggleCollapse={handleToggleCollapse}
onToggleExpandUnchanged={handleToggleExpandUnchanged}
onToggleWordWrap={handleToggleWordWrap}
/>
<div ref={sentinelRef} />
{!collapsed &&
renderDiffContent({
shouldRender: shouldRenderContent,
file,
sessionId,
wordWrap: effectiveWordWrap,
expandUnchanged,
onRevertBlock: handleRevertBlock,
onCommentRun: handleCommentRun,
onToggleExpandUnchanged: handleToggleExpandUnchanged,
})}
</div>
);
}