Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
522 lines
17 KiB
TypeScript
522 lines
17 KiB
TypeScript
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 {
|
|
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;
|
|
absolute_path?: string;
|
|
added: number;
|
|
deleted: number;
|
|
approximate: boolean;
|
|
binary: boolean;
|
|
status: UIFileEdit["status"];
|
|
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) => {
|
|
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)
|
|
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
|
|
: "";
|
|
const statusIcon = failed ? (
|
|
<AlertCircle className="h-3 w-3" aria-hidden />
|
|
) : editing ? (
|
|
<CircleDashed className="h-3 w-3 animate-spin" aria-hidden />
|
|
) : (
|
|
<CheckCircle2 className="h-3 w-3" aria-hidden />
|
|
);
|
|
return (
|
|
<ActivityStep
|
|
as="li"
|
|
marker={(
|
|
<span
|
|
className={cn(
|
|
"grid h-3.5 w-3.5 place-items-center rounded-full border bg-background transition-colors",
|
|
failed && "border-destructive/30 text-destructive/78",
|
|
editing && "border-muted-foreground/24 text-muted-foreground/65",
|
|
!failed && !editing && "border-emerald-500/28 text-emerald-500/78",
|
|
)}
|
|
>
|
|
{statusIcon}
|
|
</span>
|
|
)}
|
|
active={editing}
|
|
tone={failed ? "error" : editing ? "active" : "success"}
|
|
className="text-xs"
|
|
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…" })
|
|
: (
|
|
<FileReferenceChip
|
|
path={edit.path}
|
|
tooltipPath={edit.absolute_path}
|
|
previewPath={edit.absolute_path || edit.path}
|
|
onOpen={onOpenFilePreview}
|
|
display="path"
|
|
active={editing}
|
|
className="min-w-0"
|
|
textClassName="text-[12px]"
|
|
testId="activity-file-reference"
|
|
/>
|
|
)}
|
|
detail={null}
|
|
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
|
|
>
|
|
{failed ? (
|
|
<span className="block max-w-[42rem] truncate text-[11px] leading-4 text-destructive/75">
|
|
{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>
|
|
);
|
|
}
|
|
|
|
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
|
return edit.added > 0 || edit.deleted > 0;
|
|
}
|
|
|
|
function cleanFileEditError(error?: string): string {
|
|
const firstLine = (error || "").replace(/\s+/g, " ").trim();
|
|
if (!firstLine) return "";
|
|
return firstLine
|
|
.replace(/^Error applying patch:\s*/i, "")
|
|
.replace(/^Error writing file:\s*/i, "")
|
|
.replace(/^Error editing file:\s*/i, "")
|
|
.replace(/^Error:\s*/i, "");
|
|
}
|
|
|
|
function formatFileEditError(error?: string): string {
|
|
const cleaned = cleanFileEditError(error);
|
|
if (!cleaned) return "";
|
|
|
|
if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) {
|
|
return "No permission to change this location.";
|
|
}
|
|
|
|
return cleaned
|
|
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
|
|
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
|
|
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
|
|
.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>
|
|
);
|
|
}
|