Files
kandev/apps/web/components/review/review-diff-list.tsx
T
2026-08-22 18:42:56 +00:00

637 lines
23 KiB
TypeScript

"use client";
import { memo, useEffect, useMemo, useRef, useState, useCallback } from "react";
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 { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo";
import type { DiffComment } from "@/lib/diff/types";
import {
diffSkipReasonLabel,
hasTextualDiff,
reviewDiffUnavailableLabel,
reviewFileKey,
} from "./types";
import type { ReviewFile } from "./types";
import { ReviewDiffGroup } from "./review-diff-group";
import { ReviewDiffHeader, type ReviewExternalLinkContext } from "./review-diff-header";
import { extractReviewMarkdownPreview } from "./review-markdown-diff-preview";
import { ReviewMarkdownDiffPreviewContent } from "./review-markdown-diff-preview-content";
import { groupByRepositoryName } from "@/lib/group-by-repo";
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { cn } from "@/lib/utils";
const SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "]);
type ReviewDiffListProps = {
files: ReviewFile[];
reviewedFiles: Set<string>;
staleFiles: Set<string>;
sessionId: string;
autoMarkOnScroll: boolean;
wordWrap: boolean;
enableWalkthroughAnnotations: boolean;
selectedFile?: string | null;
onToggleReviewed: (path: string, reviewed: boolean) => void;
onDiscard: (path: string) => void;
onOpenFile?: (filePath: string, repo?: string) => void;
onPreviewMarkdown?: (filePath: string, repo?: string) => void;
fileRefs: Map<string, React.RefObject<HTMLDivElement | null>>;
};
export const ReviewDiffList = memo(function ReviewDiffList({
files,
reviewedFiles,
staleFiles,
sessionId,
autoMarkOnScroll,
wordWrap,
enableWalkthroughAnnotations,
selectedFile,
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
fileRefs,
}: ReviewDiffListProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
// Opening the dialog and restoring a selected file can move the scroll
// container before the selected-file effect runs. Start suppressed so those
// initial layout movements can never count as user review activity; genuine
// wheel, touch, pointer, or keyboard input releases the guard below.
const suppressAutoMarkRef = useRef(true);
const allowAutoMark = useCallback(() => {
suppressAutoMarkRef.current = false;
}, []);
const handlePointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) suppressAutoMarkRef.current = false;
}, []);
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (SCROLL_KEYS.has(event.key)) suppressAutoMarkRef.current = false;
}, []);
// Resolve base branches once per list (not per row) — the value is identical
// for every file. Only a single-repo task has an unambiguous fallback; with
// multiple repos a committed file lacking `repository_name` must NOT borrow
// an arbitrary repo's base branch, so the fallback stays undefined there.
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeTaskPR = useActiveTaskPR();
const baseBranchByRepo = useBaseBranchByRepo(activeTaskId);
const fallbackBaseBranch = useMemo(() => {
const branches = Object.values(baseBranchByRepo);
return branches.length === 1 ? branches[0] : undefined;
}, [baseBranchByRepo]);
// 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}
data-testid="review-diff-scroll"
className="overflow-y-auto h-full"
onWheelCapture={allowAutoMark}
onTouchMoveCapture={allowAutoMark}
onPointerDownCapture={handlePointerDown}
onKeyDownCapture={handleKeyDown}
>
{groups.map((group) => (
<ReviewDiffGroup
key={group.repositoryName || "__no_repo__"}
group={group}
showRepoHeaders={showRepoHeaders}
renderFile={(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}
enableWalkthroughAnnotations={enableWalkthroughAnnotations}
hasStickyRepoHeader={showRepoHeaders}
isSelected={selectedFile === key}
forceLoad={
selectedIndex >= 0 &&
files.findIndex((candidate) => reviewFileKey(candidate) === key) <= selectedIndex
}
onToggleReviewed={onToggleReviewed}
onDiscard={onDiscard}
onOpenFile={onOpenFile}
onPreviewMarkdown={onPreviewMarkdown}
sectionRef={fileRefs.get(key)}
scrollContainer={scrollContainerRef}
suppressAutoMark={suppressAutoMarkRef}
externalLinkContext={{
baseBranchByRepo,
fallbackBaseBranch,
taskId: activeTaskId,
publishedPRBranch: activeTaskPR?.head_branch,
publishedPRRepositoryId: activeTaskPR?.repository_id,
}}
/>
);
}}
/>
))}
</div>
);
});
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;
enableWalkthroughAnnotations: boolean;
hasStickyRepoHeader: boolean;
isSelected?: boolean;
forceLoad?: boolean;
onToggleReviewed: (key: string, reviewed: boolean) => void;
onDiscard: (key: string) => void;
onOpenFile?: (filePath: string, repo?: string) => void;
onPreviewMarkdown?: (filePath: string, repo?: string) => void;
sectionRef?: React.RefObject<HTMLDivElement | null>;
scrollContainer: React.RefObject<HTMLDivElement | null>;
suppressAutoMark: React.RefObject<boolean>;
/** Per-repo base branches + single-repo fallback, resolved once by the list
* and shared across rows so diff expansion can fetch the correct old side. */
externalLinkContext: ReviewExternalLinkContext;
};
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>;
suppressAutoMark: React.RefObject<boolean>;
};
function useAutoMarkOnScroll({
autoMarkOnScroll,
isReviewed,
isStale,
fileKey,
onToggleReviewed,
scrollContainer,
suppressAutoMark,
}: 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 &&
!suppressAutoMark.current &&
!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,
suppressAutoMark,
]);
return scrollSentinelRef;
}
function useCommentRunHandler(sessionId: string) {
const { t } = useTranslation();
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: t("review:commentSent"),
description: queued ? t("review:queuedForTheAgent") : t("review:sentToTheAgent"),
});
} catch (err) {
console.error("Failed to run diff comment:", err);
toast({
title: t("review:failedToSendComment"),
description: t("review:pleaseTryAgain"),
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>>,
suppressAutoMark: React.RefObject<boolean>,
) {
useEffect(() => {
if (isSelected) {
suppressAutoMark.current = true;
setCollapsed(false);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
sectionRef?.current?.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
}
}, [isSelected, sectionRef, setCollapsed, suppressAutoMark]);
}
/**
* Decide whether a review row can expand its collapsed context, and which git
* ref supplies the "old" side when reconstructing full file context.
*
* Expansion reveals the *unmodified* lines hidden between hunks. @pierre/diffs
* needs the full old+new file contents (paired with the patch) to render those
* controls; we fetch the new side from the working tree and the old side from
* `baseRef`. The ref has to match the base the diff was computed against, or
* the reparse comes out inconsistent and silently falls back to a partial
* (controls-less) render:
*
* - uncommitted rows: diff is working-tree-vs-HEAD, so baseRef="HEAD".
* - committed rows: diff is base-vs-HEAD, so baseRef is the exact old-side
* commit carried by the file; payloads without that ref fall back to the
* repo's base branch. HEAD already contains the commits, so expanding against it
* pairs identical old/new content and yields no controls (the bug behind
* "expansion stopped working in the review screen"). With no known base
* branch we can't fetch the pre-change content, so expansion is disabled
* rather than rendering dead separators.
* - PR rows: the working tree belongs to the local branch, not the PR head,
* so the fetched content would be paired with the wrong patch — disabled.
* - untracked files: a synthetic all-additions hunk against /dev/null with no
* real context to expand — disabled.
*
* The @pierre/diffs trailing-context guard in `useExpandableDiff` keeps any
* mismatch (stale snapshot, wrong base, file edited mid-flight) from crashing
* the renderer, so enabling expansion here is always safe.
*/
export function resolveDiffExpansion(
file: Pick<ReviewFile, "source" | "status" | "repository_name" | "base_ref">,
baseBranchByRepo: Record<string, string>,
fallbackBaseBranch?: string,
): { enableExpansion: boolean; baseRef: string } {
if (file.source === "pr" || file.status === "untracked") {
return { enableExpansion: false, baseRef: "HEAD" };
}
if (file.source === "committed") {
if (file.base_ref) return { enableExpansion: true, baseRef: file.base_ref };
const repoName = file.repository_name ?? "";
// Exact per-repo base wins. Fall back to the task's sole base branch ONLY
// for single-repo files (no repository_name) — a multi-repo file whose repo
// isn't in the map must NOT borrow another repo's base branch, which would
// fetch the wrong "old" content and silently drop expansion.
const base = baseBranchByRepo[repoName] ?? (repoName === "" ? fallbackBaseBranch : undefined);
if (!base) return { enableExpansion: false, baseRef: "HEAD" };
return { enableExpansion: true, baseRef: base };
}
return { enableExpansion: true, baseRef: "HEAD" };
}
function renderDiffContent(opts: {
shouldRender: boolean;
file: ReviewFile;
sessionId: string;
wordWrap: boolean;
enableWalkthroughAnnotations: boolean;
expandUnchanged: boolean;
enableExpansion: boolean;
baseRef: string;
onRevertBlock: (filePath: string, info: RevertBlockInfo) => void;
onCommentRun: (comment: DiffComment) => void;
onToggleExpandUnchanged: () => void;
}) {
const {
shouldRender,
file,
sessionId,
wordWrap,
enableWalkthroughAnnotations,
expandUnchanged,
enableExpansion,
baseRef,
onRevertBlock,
onCommentRun,
onToggleExpandUnchanged,
} = opts;
const hasText = hasTextualDiff(file);
if (shouldRender && hasText) {
return (
<>
<DiffErrorBoundary filePath={file.path}>
<FileDiffViewer
filePath={file.path}
diff={file.diff}
status={file.status}
enableComments
enableAcceptReject
enableWalkthroughAnnotations={enableWalkthroughAnnotations}
onRevertBlock={onRevertBlock}
onCommentRun={onCommentRun}
sessionId={sessionId}
wordWrap={wordWrap}
enableExpansion={enableExpansion}
baseRef={baseRef}
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">
{t("review:diffTruncated")}
</div>
)}
</>
);
}
const message = hasText
? diffSkipReasonLabel(file.diff_skip_reason)
: reviewDiffUnavailableLabel(file);
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
{message}
</div>
);
}
function useMarkdownPreview(file: ReviewFile) {
const [markdownPreview, setMarkdownPreview] = useState(false);
const markdownPreviewContent = useMemo(() => extractReviewMarkdownPreview(file), [file]);
const handleToggleMarkdownPreview = useCallback(() => setMarkdownPreview((v) => !v), []);
useEffect(() => {
if (markdownPreviewContent.fragments.length === 0) setMarkdownPreview(false);
}, [markdownPreviewContent.fragments.length]);
return { markdownPreview, markdownPreviewContent, handleToggleMarkdownPreview };
}
function useFileDiffDisplayControls(wordWrap: boolean) {
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],
);
return {
collapsed,
setCollapsed,
expandUnchanged,
effectiveWordWrap,
handleToggleCollapse,
handleToggleExpandUnchanged,
handleToggleWordWrap,
};
}
function getMarkdownPreviewToggle({
file,
fragments,
onPreviewMarkdown,
onTogglePreview,
}: {
file: ReviewFile;
fragments: string[];
onPreviewMarkdown?: (filePath: string, repo?: string) => void;
onTogglePreview: () => void;
}) {
if (fragments.length === 0) return undefined;
if (!onPreviewMarkdown) return onTogglePreview;
return () => onPreviewMarkdown(file.path, file.repository_name);
}
type FileDiffActionsArgs = Pick<
FileDiffSectionProps,
"file" | "fileKey" | "sessionId" | "onToggleReviewed" | "onDiscard"
>;
function useFileDiffActions({
file,
fileKey,
sessionId,
onToggleReviewed,
onDiscard,
}: FileDiffActionsArgs) {
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 { handleCheckboxChange, handleDiscard, handleRevertBlock, handleCommentRun };
}
function FileDiffSection({
file,
fileKey,
isReviewed,
isStale,
sessionId,
autoMarkOnScroll,
wordWrap,
enableWalkthroughAnnotations,
hasStickyRepoHeader,
isSelected,
forceLoad,
onToggleReviewed,
onDiscard,
onOpenFile,
onPreviewMarkdown,
sectionRef,
scrollContainer,
suppressAutoMark,
externalLinkContext,
}: FileDiffSectionProps) {
const controls = useFileDiffDisplayControls(wordWrap);
const { markdownPreview, markdownPreviewContent, handleToggleMarkdownPreview } =
useMarkdownPreview(file);
const { isVisible, sentinelRef } = useLazyVisible(scrollContainer);
// Force load when visible via intersection observer, or forceLoad is true
const shouldRenderContent = isVisible || !!forceLoad;
useScrollIntoViewOnSelect(isSelected, sectionRef, controls.setCollapsed, suppressAutoMark);
// 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,
suppressAutoMark,
});
const { handleCheckboxChange, handleDiscard, handleRevertBlock, handleCommentRun } =
useFileDiffActions({ file, fileKey, sessionId, onToggleReviewed, onDiscard });
const { enableExpansion, baseRef } = resolveDiffExpansion(
file,
externalLinkContext.baseBranchByRepo,
externalLinkContext.fallbackBaseBranch,
);
const onToggleMarkdownPreview = getMarkdownPreviewToggle({
file,
fragments: markdownPreviewContent.fragments,
onPreviewMarkdown,
onTogglePreview: handleToggleMarkdownPreview,
});
return (
<div
ref={sectionRef}
className={cn("border-b border-border", hasStickyRepoHeader && "scroll-mt-8")}
>
<div ref={scrollSentinelRef} className="h-0" />
<ReviewDiffHeader
file={file}
isReviewed={isReviewed}
isStale={isStale}
sessionId={sessionId}
collapsed={controls.collapsed}
wordWrap={controls.effectiveWordWrap}
expandUnchanged={controls.expandUnchanged}
hasStickyRepoHeader={hasStickyRepoHeader}
onCheckboxChange={handleCheckboxChange}
onDiscard={handleDiscard}
onOpenFile={onOpenFile}
markdownPreview={!onPreviewMarkdown && markdownPreview}
onToggleMarkdownPreview={onToggleMarkdownPreview}
onToggleCollapse={controls.handleToggleCollapse}
onToggleExpandUnchanged={controls.handleToggleExpandUnchanged}
onToggleWordWrap={controls.handleToggleWordWrap}
{...externalLinkContext}
/>
<div ref={sentinelRef} />
{!controls.collapsed &&
(markdownPreview ? (
<ReviewMarkdownDiffPreviewContent preview={markdownPreviewContent} />
) : (
renderDiffContent({
shouldRender: shouldRenderContent,
file,
sessionId,
wordWrap: controls.effectiveWordWrap,
enableWalkthroughAnnotations,
expandUnchanged: controls.expandUnchanged,
enableExpansion,
baseRef,
onRevertBlock: handleRevertBlock,
onCommentRun: handleCommentRun,
onToggleExpandUnchanged: controls.handleToggleExpandUnchanged,
})
))}
</div>
);
}