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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user