/** * EditCell — renders an edit tool call as a syntax-highlighted unified diff. * * The diff is computed client-side from oldText / newText stored in the * structured metadata. The HighlightedCodeBlock from AssistantChat is not * accessible here, so we do a lightweight line-diff + per-line class approach * instead (no dep on shiki required for the diff view itself). * * Collapsed by default beyond MAX_COLLAPSED_LINES; expand button shows all. */ import { IconChevronDown, IconFile, IconFileDiff, IconLoader2, } from "@tabler/icons-react"; import { memo, useMemo, useState } from "react"; import { AnimatedCollapse } from "../chat/tool-call-display.js"; import { cn } from "../utils.js"; export interface EditCellMeta { toolKind: "edit"; filePath: string; oldText?: string; newText?: string; truncated?: boolean; } interface EditCellProps { meta: EditCellMeta; isRunning: boolean; } /** Lines shown collapsed before "expand" is offered. */ const MAX_COLLAPSED_LINES = 40; // ─── Diff computation ──────────────────────────────────────────────────────── interface DiffLine { kind: "context" | "added" | "removed"; text: string; oldLineNo?: number; newLineNo?: number; } function computeLineDiff(oldText: string, newText: string): DiffLine[] { const oldLines = oldText.split("\n"); const newLines = newText.split("\n"); // Simple Myers-style LCS via DP for line-level diffs. // For very large files we cap input to keep it snappy. const MAX_LINES = 2000; const a = oldLines.slice(0, MAX_LINES); const b = newLines.slice(0, MAX_LINES); const m = a.length; const n = b.length; // Build LCS table const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0), ); for (let i = m - 1; i >= 0; i--) { for (let j = n - 1; j >= 0; j--) { if (a[i] === b[j]) { dp[i][j] = dp[i + 1][j + 1] + 1; } else { dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); } } } const result: DiffLine[] = []; let i = 0; let j = 0; let oldLineNo = 1; let newLineNo = 1; while (i < m || j < n) { if (i < m && j < n && a[i] === b[j]) { result.push({ kind: "context", text: a[i], oldLineNo: oldLineNo++, newLineNo: newLineNo++, }); i++; j++; } else if (j < n && (i >= m || dp[i + 1][j] >= dp[i][j + 1])) { result.push({ kind: "added", text: b[j], newLineNo: newLineNo++ }); j++; } else { result.push({ kind: "removed", text: a[i], oldLineNo: oldLineNo++ }); i++; } } // Append any overflow lines as context if (oldLines.length > MAX_LINES) { for (let k = MAX_LINES; k < oldLines.length; k++) { result.push({ kind: "removed", text: oldLines[k], oldLineNo: oldLineNo++, }); } } if (newLines.length > MAX_LINES) { for (let k = MAX_LINES; k < newLines.length; k++) { result.push({ kind: "added", text: newLines[k], newLineNo: newLineNo++, }); } } return result; } /** Compute +N -N line counts from a diff. */ function diffStats(lines: DiffLine[]): { added: number; removed: number } { let added = 0; let removed = 0; for (const line of lines) { if (line.kind === "added") added++; else if (line.kind === "removed") removed++; } return { added, removed }; } // ─── Component ─────────────────────────────────────────────────────────────── const DiffView = memo(function DiffView({ lines, maxLines, }: { lines: DiffLine[]; maxLines: number | null; }) { const visible = maxLines === null ? lines : lines.slice(0, maxLines); return (
{visible.map((line, idx) => ( {/* Old line number */} {/* New line number */} {/* Sigil */} {/* Content */} ))}
{line.oldLineNo ?? ""} {line.newLineNo ?? ""} {line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "} {line.text}
); }); export function EditCell({ meta, isRunning }: EditCellProps) { const [expanded, setExpanded] = useState(false); const [showAll, setShowAll] = useState(false); const diff = useMemo(() => { if (!meta.oldText && !meta.newText) return null; return computeLineDiff(meta.oldText ?? "", meta.newText ?? ""); }, [meta.oldText, meta.newText]); const stats = useMemo(() => (diff ? diffStats(diff) : null), [diff]); const hasDiff = diff !== null && diff.length > 0; const totalLines = diff?.length ?? 0; const maxCollapsed = showAll || totalLines <= MAX_COLLAPSED_LINES ? null : MAX_COLLAPSED_LINES; const hiddenLines = maxCollapsed !== null ? totalLines - MAX_COLLAPSED_LINES : 0; return (
{/* Header */} {/* Diff body */} {hasDiff && (
{hiddenLines > 0 && (
)}
)}
); }