feat: add file edit diff progress view

Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
chengyongru
2026-07-09 10:42:43 +08:00
committed by Xubin Ren
parent 207813d3b5
commit 7768672c5b
40 changed files with 2224 additions and 1753 deletions
@@ -1,13 +1,47 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
CircleDashed,
ExternalLink,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import type { UIFileEdit } from "@/lib/types";
import {
hasRenderableFileDiff,
parseRenderableFileDiff,
type RenderableFileDiff,
type RenderableFileDiffHunk,
type RenderableFileDiffLine,
} from "@/lib/file-diff";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep";
import { DiffPair } from "./DiffPair";
const INITIAL_VISIBLE_DIFF_LINES = 160;
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
type DiffFileEditDisplayMode = Exclude<FileEditDisplayMode, "summary">;
interface VisibleDiffHunk {
hunk: RenderableFileDiffHunk;
skippedBefore: number;
}
interface VisibleDiff {
hunks: VisibleDiffHunk[];
hiddenLineCount: number;
}
const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 };
export interface FileEditSummary {
key: string;
path: string;
@@ -20,40 +54,97 @@ export interface FileEditSummary {
operation?: UIFileEdit["operation"];
pending: boolean;
error?: string;
diff?: UIFileDiff;
}
export function FileEditGroup({
edits,
displayMode,
onOpenFilePreview,
density = "default",
}: {
edits: FileEditSummary[];
displayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
density?: "default" | "diff-only";
}) {
if (edits.length === 0) return null;
return (
<ul className="space-y-1">
{edits.map((edit) => (
<FileEditRow
key={edit.key}
edit={edit}
onOpenFilePreview={onOpenFilePreview}
/>
))}
{edits.map((edit) => {
if (density === "diff-only" && canRenderDiff(edit, displayMode)) {
return (
<FileEditDiffOnly
key={edit.key}
edit={edit}
displayMode={displayMode}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
return (
<FileEditRow
key={edit.key}
edit={edit}
displayMode={displayMode}
onOpenFilePreview={onOpenFilePreview}
/>
);
})}
</ul>
);
}
function canRenderDiff(
edit: FileEditSummary,
displayMode: FileEditDisplayMode,
): displayMode is DiffFileEditDisplayMode {
return (
displayMode !== "summary"
&& edit.status !== "editing"
&& edit.status !== "error"
&& hasRenderableFileDiff(edit.diff)
);
}
function FileEditDiffOnly({
edit,
displayMode,
onOpenFilePreview,
}: {
edit: FileEditSummary;
displayMode: DiffFileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
}) {
return (
<li className="min-w-0 py-0.5">
<FileUnifiedDiff
diff={edit.diff!}
collapsed={displayMode === "collapsed_diff"}
added={edit.added}
deleted={edit.deleted}
showCollapsedStats={false}
previewPath={edit.absolute_path || edit.path}
onOpenFilePreview={onOpenFilePreview}
/>
</li>
);
}
function FileEditRow({
edit,
displayMode,
onOpenFilePreview,
}: {
edit: FileEditSummary;
displayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const showDiff = canRenderDiff(edit, displayMode);
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
const failureDetail = failed
? formatFileEditError(edit.error)
@@ -84,7 +175,7 @@ function FileEditRow({
active={editing}
tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs"
contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
title={rawFailureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
@@ -109,6 +200,16 @@ function FileEditRow({
{failureDetail}
</span>
) : null}
{showDiff ? (
<FileUnifiedDiff
diff={edit.diff!}
collapsed={displayMode === "collapsed_diff"}
added={edit.added}
deleted={edit.deleted}
previewPath={edit.absolute_path || edit.path}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</ActivityStep>
);
}
@@ -142,3 +243,279 @@ function formatFileEditError(error?: string): string {
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180);
}
function FileUnifiedDiff({
diff,
collapsed,
added,
deleted,
showCollapsedStats = true,
previewPath,
onOpenFilePreview,
}: {
diff: UIFileDiff;
collapsed: boolean;
added: number;
deleted: number;
showCollapsedStats?: boolean;
previewPath?: string;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [expandedLines, setExpandedLines] = useState(false);
const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]);
const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]);
const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated;
const startsCollapsed = collapsed || shouldAutoCollapse;
const shouldRenderBody = !startsCollapsed || open;
const shouldLimitLines = totalLineCount > INITIAL_VISIBLE_DIFF_LINES;
const lineLimit = expandedLines || !shouldLimitLines
? totalLineCount
: INITIAL_VISIBLE_DIFF_LINES;
const visibleDiff = useMemo(
() => shouldRenderBody
? selectVisibleDiffLines(renderableDiff, lineLimit, totalLineCount)
: EMPTY_VISIBLE_DIFF,
[lineLimit, renderableDiff, shouldRenderBody, totalLineCount],
);
const lineCountLabel = t("message.fileEditDiffLineCount", {
count: diff.truncated ? `${totalLineCount}+` : totalLineCount,
defaultValue: "{{count}} lines",
});
const viewDiffLabel = shouldAutoCollapse
? tx("message.fileEditViewLargeDiff", "View large diff")
: tx("message.fileEditViewDiff", "View diff");
useEffect(() => {
setOpen(false);
setExpandedLines(false);
}, [diff]);
const handleToggleOpen = () => {
if (open) setExpandedLines(false);
setOpen(!open);
};
if (totalLineCount === 0) return null;
const renderBody = () => (
<div
className="mt-1 overflow-hidden rounded-md border border-border/55 bg-background/80 shadow-[0_1px_0_rgba(15,23,42,0.03)]"
data-testid="file-edit-diff"
>
{visibleDiff.hunks.map(({ hunk, skippedBefore }, index) => (
<div
key={`${hunk.old_start}-${hunk.new_start}-${index}`}
className={cn("min-w-0", index > 0 && "border-t border-border/45")}
>
{skippedBefore > 0 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-[11px] leading-5">
<tbody>
{hunk.lines.map((line, lineIndex) => (
<DiffLineRow
key={`${line.old_lineno ?? ""}:${line.new_lineno ?? ""}:${lineIndex}`}
line={line}
/>
))}
</tbody>
</table>
</div>
</div>
))}
{visibleDiff.hiddenLineCount > 0 ? (
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-expand-lines"
onClick={() => setExpandedLines(true)}
>
<ChevronDown className="h-3 w-3" aria-hidden />
{t("message.fileEditShowMoreLines", {
count: visibleDiff.hiddenLineCount,
defaultValue: "Show {{count}} more lines",
})}
</button>
</div>
) : expandedLines && shouldLimitLines ? (
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-collapse-lines"
onClick={() => setExpandedLines(false)}
>
<ChevronUp className="h-3 w-3" aria-hidden />
{tx("message.fileEditShowFewerLines", "Show fewer lines")}
</button>
</div>
) : null}
{diff.truncated ? (
<div
className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/45 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
data-testid="file-edit-diff-truncated"
>
<span>
{tx("message.fileEditDiffTruncated", "Diff truncated. Open the file for the full change.")}
</span>
{previewPath && onOpenFilePreview ? (
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-open-file"
onClick={() => onOpenFilePreview(previewPath)}
>
<ExternalLink className="h-3 w-3" aria-hidden />
{tx("message.fileEditOpenFile", "Open file")}
</button>
) : null}
</div>
) : null}
</div>
);
if (!startsCollapsed) return renderBody();
return (
<div className="mt-1">
<button
type="button"
aria-expanded={open}
data-testid="file-edit-diff-toggle"
onClick={handleToggleOpen}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-md border border-border/45 bg-muted/35 px-2 py-1 text-left",
"text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/50",
)}
>
<ChevronRight
className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")}
aria-hidden
/>
<span className="min-w-0 flex-1">{viewDiffLabel}</span>
<span className="shrink-0 text-muted-foreground/65">{lineCountLabel}</span>
{showCollapsedStats ? <DiffPair added={added} deleted={deleted} /> : null}
</button>
{open ? renderBody() : null}
</div>
);
}
function countDiffLines(diff: RenderableFileDiff): number {
return diff.hunks.reduce((total, hunk) => total + hunk.lines.length, 0);
}
function selectVisibleDiffLines(
diff: RenderableFileDiff,
lineLimit: number,
totalLineCount: number,
): VisibleDiff {
if (lineLimit >= totalLineCount) {
return {
hunks: diff.hunks.map((hunk, index) => ({
hunk,
skippedBefore: index > 0 ? countSkippedUnchangedLines(diff.hunks[index - 1], hunk) : 0,
})),
hiddenLineCount: 0,
};
}
let remaining = Math.max(0, lineLimit);
const hunks: VisibleDiffHunk[] = [];
let previousHunk: RenderableFileDiffHunk | null = null;
for (const hunk of diff.hunks) {
if (remaining <= 0) break;
const skippedBefore = previousHunk ? countSkippedUnchangedLines(previousHunk, hunk) : 0;
if (hunk.lines.length <= remaining) {
hunks.push({ hunk, skippedBefore });
remaining -= hunk.lines.length;
previousHunk = hunk;
continue;
}
hunks.push({ hunk: { ...hunk, lines: hunk.lines.slice(0, remaining) }, skippedBefore });
remaining = 0;
previousHunk = hunk;
}
return {
hunks,
hiddenLineCount: Math.max(0, totalLineCount - lineLimit),
};
}
function countSkippedUnchangedLines(
previous: RenderableFileDiffHunk,
current: RenderableFileDiffHunk,
): number {
const oldGap = current.old_start - (previous.old_start + previous.old_lines);
const newGap = current.new_start - (previous.new_start + previous.new_lines);
return Math.max(0, oldGap, newGap);
}
function DiffHunkGap({ lineCount }: { lineCount: number }) {
const { t } = useTranslation();
return (
<div
className="flex items-center gap-2 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
data-testid="file-edit-diff-hunk-gap"
>
<span
className="select-none rounded border border-border/45 bg-background/70 px-1 font-mono text-muted-foreground/70"
aria-hidden
>
...
</span>
<span>
{t("message.fileEditUnchangedLinesHidden", {
count: lineCount,
defaultValue: "{{count}} unchanged lines hidden",
})}
</span>
</div>
);
}
function DiffLineRow({ line }: { line: RenderableFileDiffLine }) {
const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context";
const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " ";
return (
<tr
className={cn(
"border-0",
kind === "add" && "bg-emerald-500/[0.09] dark:bg-emerald-300/[0.11]",
kind === "delete" && "bg-rose-500/[0.09] dark:bg-rose-300/[0.11]",
)}
>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.old_lineno ?? ""}
</td>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.new_lineno ?? ""}
</td>
<td
className={cn(
"w-5 select-none px-1 text-center",
kind === "add" && "text-emerald-600/80 dark:text-emerald-300/85",
kind === "delete" && "text-rose-600/80 dark:text-rose-300/85",
kind === "context" && "text-muted-foreground/45",
)}
>
{marker}
</td>
<td className="min-w-[16rem] px-1.5 text-foreground/86">
<span className="whitespace-pre">{line.content || " "}</span>
</td>
</tr>
);
}