openclaw/apps/web/app/components/diff-viewer.tsx

301 lines
9.3 KiB
TypeScript

"use client";
import { useState, useMemo } from "react";
type DiffCardProps = {
/** Raw unified diff text (contents of a ```diff block) */
diff: string;
};
type DiffFile = {
oldPath: string;
newPath: string;
hunks: DiffHunk[];
additions: number;
deletions: number;
};
type DiffHunk = {
header: string;
lines: DiffLine[];
};
type DiffLine = {
type: "addition" | "deletion" | "context" | "header";
content: string;
oldLine?: number;
newLine?: number;
};
/** Parse unified diff text into structured file sections. */
function parseDiff(raw: string): DiffFile[] {
const files: DiffFile[] = [];
const lines = raw.split("\n");
let current: DiffFile | null = null;
let currentHunk: DiffHunk | null = null;
let oldLine = 0;
let newLine = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// File header: --- a/path or --- /dev/null
if (line.startsWith("--- ")) {
const nextLine = lines[i + 1];
if (nextLine?.startsWith("+++ ")) {
const oldPath = line.replace(/^--- (a\/)?/, "").trim();
const newPath = nextLine.replace(/^\+\+\+ (b\/)?/, "").trim();
current = { oldPath, newPath, hunks: [], additions: 0, deletions: 0 };
files.push(current);
i++; // skip +++ line
continue;
}
}
// Hunk header: @@ -old,count +new,count @@
const hunkMatch = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)/);
if (hunkMatch) {
oldLine = parseInt(hunkMatch[1], 10);
newLine = parseInt(hunkMatch[2], 10);
currentHunk = {
header: line,
lines: [{ type: "header", content: line }],
};
if (current) {
current.hunks.push(currentHunk);
} else {
// Diff without file headers -- create an implicit file
current = { oldPath: "", newPath: "", hunks: [currentHunk], additions: 0, deletions: 0 };
files.push(current);
}
continue;
}
if (!currentHunk || !current) {continue;}
if (line.startsWith("+")) {
currentHunk.lines.push({ type: "addition", content: line.slice(1), newLine });
current.additions++;
newLine++;
} else if (line.startsWith("-")) {
currentHunk.lines.push({ type: "deletion", content: line.slice(1), oldLine });
current.deletions++;
oldLine++;
} else if (line.startsWith(" ") || line === "") {
currentHunk.lines.push({ type: "context", content: line.slice(1) || "", oldLine, newLine });
oldLine++;
newLine++;
}
}
// If no structured files were found, treat the whole thing as one block
if (files.length === 0 && raw.trim()) {
const fallbackLines = raw.split("\n").map((l): DiffLine => {
if (l.startsWith("+")) {return { type: "addition", content: l.slice(1) };}
if (l.startsWith("-")) {return { type: "deletion", content: l.slice(1) };}
return { type: "context", content: l };
});
files.push({
oldPath: "",
newPath: "",
hunks: [{ header: "", lines: fallbackLines }],
additions: fallbackLines.filter((l) => l.type === "addition").length,
deletions: fallbackLines.filter((l) => l.type === "deletion").length,
});
}
return files;
}
function displayPath(file: DiffFile): string {
if (file.newPath && file.newPath !== "/dev/null") {return file.newPath;}
if (file.oldPath && file.oldPath !== "/dev/null") {return file.oldPath;}
return "diff";
}
/* ─── Icons ─── */
function FileIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}
function ChevronIcon({ expanded }: { expanded: boolean }) {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transition: "transform 150ms ease",
transform: expanded ? "rotate(90deg)" : "rotate(0deg)",
}}
>
<polyline points="9 18 15 12 9 6" />
</svg>
);
}
/* ─── Single file diff ─── */
function DiffFileCard({ file }: { file: DiffFile }) {
const [expanded, setExpanded] = useState(true);
const path = displayPath(file);
return (
<div
className="rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)" }}
>
{/* File header */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 text-left"
style={{
background: "var(--color-surface)",
borderBottom: expanded ? "1px solid var(--color-border)" : "none",
}}
>
<ChevronIcon expanded={expanded} />
<FileIcon />
<span
className="text-sm font-mono font-medium flex-1 truncate"
style={{ color: "var(--color-text)" }}
>
{path}
</span>
{file.additions > 0 && (
<span className="text-xs font-mono font-medium" style={{ color: "var(--diff-add-badge)" }}>
+{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="text-xs font-mono font-medium" style={{ color: "var(--diff-del-badge)" }}>
-{file.deletions}
</span>
)}
</button>
{/* Diff lines */}
{expanded && (
<div
className="overflow-x-auto"
style={{ background: "var(--color-bg)" }}
>
<table className="w-full text-xs font-mono leading-5 border-collapse" style={{ tabSize: 4 }}>
<tbody>
{file.hunks.map((hunk, hi) =>
hunk.lines.map((line, li) => {
if (line.type === "header") {
return (
<tr key={`${hi}-${li}`}>
<td
colSpan={3}
className="px-3 py-1 select-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text-muted)",
}}
>
{line.content}
</td>
</tr>
);
}
const bgColor =
line.type === "addition"
? "var(--diff-add-bg)"
: line.type === "deletion"
? "var(--diff-del-bg)"
: "transparent";
const textColor =
line.type === "addition"
? "var(--diff-add-text)"
: line.type === "deletion"
? "var(--diff-del-text)"
: "var(--color-text)";
const prefix =
line.type === "addition"
? "+"
: line.type === "deletion"
? "-"
: " ";
return (
<tr key={`${hi}-${li}`} style={{ background: bgColor }}>
{/* Old line number */}
<td
className="select-none text-right pr-2 pl-3"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
width: "1%",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{line.type !== "addition" ? line.oldLine : ""}
</td>
{/* New line number */}
<td
className="select-none text-right pr-3"
style={{
color: "var(--color-text-muted)",
opacity: 0.5,
width: "1%",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{line.type !== "deletion" ? line.newLine : ""}
</td>
{/* Content */}
<td
className="pr-4"
style={{ color: textColor }}
>
<span
className="select-none inline-block w-4 text-center"
style={{ opacity: 0.6, userSelect: "none" }}
>
{prefix}
</span>
{line.content}
</td>
</tr>
);
}),
)}
</tbody>
</table>
</div>
)}
</div>
);
}
/* ─── Main DiffCard ─── */
export function DiffCard({ diff }: DiffCardProps) {
const files = useMemo(() => parseDiff(diff), [diff]);
return (
<div className="space-y-2 my-3">
{files.map((file, i) => (
<DiffFileCard key={i} file={file} />
))}
</div>
);
}