Files
kandev/apps/web/components/review/review-diff-list.tsx
T
José Almeida 214a8f5cce fix: scope multi-repo file editor open/save to the right repo (#1382)
* fix: scope multi-repo file editor open/save to the right repo

The editor open/save/sync/delete chain dropped the repository_name in
multi-repo task workspaces, so the backend resolved paths against the
bare task root and failed with "file not found". Thread the repo
subpath through openFile, save, sync, and delete, and store it on
FileEditorState.

* fix: pass repo from diff Edit button to multi-repo file editor

The diff/changes "Edit" button opened files without their repository
scope, so multi-repo files resolved against the bare task root and
failed with "file not found". Thread repository_name through the diff
and review toolbars into onOpenFile.

* fix: preserve repo across tab restore and stale syncs

Address review feedback: persisted file tabs dropped the repo subpath,
so a multi-repo file reopened against the bare task root after a browser
refresh. Persist and restore `repo` on StoredFileTab. Also guard the
workspace sync against a tab being swapped to a different repo mid-fetch,
and widen the remaining onOpenFile type declarations to carry repo.

* fix: thread repo into editor panel fallback fetch

Address review feedback: FileEditorPanel's useFileLoader fallback fetch
(triggered when the open-files map is transiently empty, e.g. after a
session switch) called requestFileContent without the repo subpath,
re-introducing the multi-repo "file not found" failure. Record repo on
the panel params and forward it through useFileLoader. Also assert the
full restored-tab shape in the local-storage round-trip test.

* fix: guard editor fetches against stale session/repo races

Address review feedback: file open and the panel fallback fetch could
apply a late response after the user navigated away. openFile now drops
the result if the active session changed mid-fetch (via fetchFileEditorState),
and the panel loader keys its in-flight guard by session+path+repo so a
changed target re-fetches and stale responses are discarded.
2026-06-15 17:59:05 +01:00

525 lines
18 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, repo?: 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, repo?: 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, repo?: 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}
repo={file.repository_name}
/>
</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>
);
}