claw-web-v2 / client /src /components /CodeDiffViewer.tsx
Claw Web
feat: complete P0 fixes + Manus UI overhaul phase 2
6aeba60
/**
* CodeDiffViewer — Manus-style code diff viewer with tabs:
* Diff / Original / Modified
* Used in ActionTree when edit_file or write_file tool calls are shown.
*/
import { useState, useMemo } from "react";
import { cn } from "@/lib/utils";
type DiffTab = "diff" | "original" | "modified";
interface DiffLine {
type: "added" | "removed" | "context";
lineOld?: number;
lineNew?: number;
content: string;
}
/**
* Simple unified diff parser.
* Takes old text and new text, produces a line-by-line diff.
*/
function computeDiff(oldText: string, newText: string): DiffLine[] {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
const result: DiffLine[] = [];
// Simple LCS-based diff
const m = oldLines.length;
const n = newLines.length;
// For large files, fall back to a simpler approach
if (m * n > 1_000_000) {
// Just show removed then added for very large files
oldLines.forEach((line, i) => {
result.push({ type: "removed", lineOld: i + 1, content: line });
});
newLines.forEach((line, i) => {
result.push({ type: "added", lineNew: i + 1, content: line });
});
return result;
}
// Build LCS table
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to produce diff
const diffLines: DiffLine[] = [];
let i = m, j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
diffLines.unshift({ type: "context", lineOld: i, lineNew: j, content: oldLines[i - 1] });
i--; j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
diffLines.unshift({ type: "added", lineNew: j, content: newLines[j - 1] });
j--;
} else {
diffLines.unshift({ type: "removed", lineOld: i, content: oldLines[i - 1] });
i--;
}
}
return diffLines;
}
interface CodeDiffViewerProps {
oldContent: string;
newContent: string;
fileName?: string;
className?: string;
}
export function CodeDiffViewer({ oldContent, newContent, fileName, className }: CodeDiffViewerProps) {
const [activeTab, setActiveTab] = useState<DiffTab>("diff");
const diffLines = useMemo(
() => computeDiff(oldContent, newContent),
[oldContent, newContent]
);
const stats = useMemo(() => {
const added = diffLines.filter(l => l.type === "added").length;
const removed = diffLines.filter(l => l.type === "removed").length;
return { added, removed };
}, [diffLines]);
const tabs: { id: DiffTab; label: string }[] = [
{ id: "diff", label: "Diff" },
{ id: "original", label: "Original" },
{ id: "modified", label: "Modified" },
];
return (
<div className={cn("flex flex-col rounded-md border border-border overflow-hidden", className)}>
{/* Header with tabs */}
<div className="flex items-center justify-between bg-secondary/30 border-b border-border">
<div className="flex">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"px-3 py-1.5 text-[11px] font-medium transition-colors border-b-2 -mb-px",
activeTab === tab.id
? "border-primary text-foreground bg-background/50"
: "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab.label}
</button>
))}
</div>
<div className="flex items-center gap-2 px-2">
{fileName && (
<span className="text-[10px] font-mono text-muted-foreground">
{fileName}
</span>
)}
<span className="text-[10px] font-mono text-green-500">+{stats.added}</span>
<span className="text-[10px] font-mono text-red-500">-{stats.removed}</span>
</div>
</div>
{/* Content */}
<div className="overflow-auto max-h-[400px] bg-[#0d1117]">
{activeTab === "diff" && (
<table className="w-full text-[11px] font-mono border-collapse">
<tbody>
{diffLines.map((line, idx) => (
<tr
key={idx}
className={cn(
line.type === "added" && "bg-green-500/10",
line.type === "removed" && "bg-red-500/10"
)}
>
<td className="w-[1px] px-1 text-right text-muted-foreground/40 select-none border-r border-border/30 whitespace-nowrap">
{line.lineOld || ""}
</td>
<td className="w-[1px] px-1 text-right text-muted-foreground/40 select-none border-r border-border/30 whitespace-nowrap">
{line.lineNew || ""}
</td>
<td className="w-[1px] px-1 select-none font-bold">
<span className={cn(
line.type === "added" && "text-green-500",
line.type === "removed" && "text-red-500",
line.type === "context" && "text-muted-foreground/30"
)}>
{line.type === "added" ? "+" : line.type === "removed" ? "-" : " "}
</span>
</td>
<td className="px-2 whitespace-pre-wrap break-all">
<span className={cn(
line.type === "added" && "text-green-400/90",
line.type === "removed" && "text-red-400/90",
line.type === "context" && "text-foreground/60"
)}>
{line.content}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{activeTab === "original" && (
<pre className="text-[11px] font-mono p-3 whitespace-pre-wrap break-all text-foreground/70">
{oldContent || "(empty)"}
</pre>
)}
{activeTab === "modified" && (
<pre className="text-[11px] font-mono p-3 whitespace-pre-wrap break-all text-foreground/70">
{newContent || "(empty)"}
</pre>
)}
</div>
</div>
);
}