import { useState, useMemo } from "react";
interface DiffLine {
type: "add" | "remove" | "context" | "header" | "hunk";
content: string;
oldLineNumber?: number;
newLineNumber?: number;
}
interface DiffFile {
path: string;
status: "added" | "modified" | "deleted" | "renamed";
oldPath?: string; // For renames
additions: number;
deletions: number;
lines: DiffLine[];
}
interface OverlayDiffViewerProps {
diff: string;
changedFiles?: string[];
loading?: boolean;
error?: string;
onClose?: () => void;
title?: string;
}
function parseDiff(diffText: string): DiffFile[] {
if (!diffText.trim()) return [];
const files: DiffFile[] = [];
const lines = diffText.split("\n");
let currentFile: DiffFile | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// File header (diff --git a/path b/path)
if (line.startsWith("diff --git")) {
if (currentFile) {
files.push(currentFile);
}
// Extract paths
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
const oldPath = match?.[1] || "";
const newPath = match?.[2] || oldPath;
currentFile = {
path: newPath,
oldPath: oldPath !== newPath ? oldPath : undefined,
status: "modified",
additions: 0,
deletions: 0,
lines: [],
};
continue;
}
if (!currentFile) continue;
// New file indicator
if (line.startsWith("new file mode")) {
currentFile.status = "added";
continue;
}
// Deleted file indicator
if (line.startsWith("deleted file mode")) {
currentFile.status = "deleted";
continue;
}
// Rename indicator
if (line.startsWith("rename from") || line.startsWith("rename to")) {
currentFile.status = "renamed";
continue;
}
// Hunk header (@@ -1,3 +1,4 @@)
if (line.startsWith("@@")) {
const hunkMatch = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
if (hunkMatch) {
oldLineNum = parseInt(hunkMatch[1], 10);
newLineNum = parseInt(hunkMatch[2], 10);
}
currentFile.lines.push({
type: "hunk",
content: line,
});
continue;
}
// Skip other headers (---, +++, index, etc.)
if (
line.startsWith("---") ||
line.startsWith("+++") ||
line.startsWith("index ") ||
line.startsWith("Binary files")
) {
currentFile.lines.push({
type: "header",
content: line,
});
continue;
}
// Diff content
if (line.startsWith("+")) {
currentFile.additions++;
currentFile.lines.push({
type: "add",
content: line.substring(1),
newLineNumber: newLineNum++,
});
} else if (line.startsWith("-")) {
currentFile.deletions++;
currentFile.lines.push({
type: "remove",
content: line.substring(1),
oldLineNumber: oldLineNum++,
});
} else if (line.startsWith(" ") || line === "") {
currentFile.lines.push({
type: "context",
content: line.substring(1) || "",
oldLineNumber: oldLineNum++,
newLineNumber: newLineNum++,
});
}
}
if (currentFile) {
files.push(currentFile);
}
return files;
}
function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) {
const statusColors: Record<DiffFile["status"], string> = {
added: "text-green-400 bg-green-400/10",
modified: "text-yellow-400 bg-yellow-400/10",
deleted: "text-red-400 bg-red-400/10",
renamed: "text-purple-400 bg-purple-400/10",
};
const statusLabels: Record<DiffFile["status"], string> = {
added: "A",
modified: "M",
deleted: "D",
renamed: "R",
};
return (
<div className="border border-[rgba(117,170,252,0.2)] mb-2">
{/* File header */}
<button
onClick={onToggle}
className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] transition-colors text-left"
>
<span className="font-mono text-[10px] text-[#555]">
{collapsed ? "▶" : "▼"}
</span>
<span
className={`px-1.5 py-0.5 font-mono text-[9px] font-bold ${statusColors[file.status]}`}
>
{statusLabels[file.status]}
</span>
<span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
{file.oldPath ? (
<>
<span className="text-[#555]">{file.oldPath}</span>
<span className="text-[#75aafc] mx-1">→</span>
{file.path}
</>
) : (
file.path
)}
</span>
<span className="font-mono text-[10px]">
{file.additions > 0 && (
<span className="text-green-400 mr-2">+{file.additions}</span>
)}
{file.deletions > 0 && (
<span className="text-red-400">-{file.deletions}</span>
)}
</span>
</button>
{/* File content */}
{!collapsed && (
<div className="overflow-x-auto">
<table className="w-full font-mono text-xs">
<tbody>
{file.lines.map((line, i) => {
if (line.type === "header" || line.type === "hunk") {
return (
<tr
key={i}
className="bg-[rgba(117,170,252,0.05)]"
>
<td
colSpan={3}
className="px-2 py-0.5 text-[#75aafc] select-none"
>
{line.content}
</td>
</tr>
);
}
const bgColor =
line.type === "add"
? "bg-green-400/10"
: line.type === "remove"
? "bg-red-400/10"
: "";
const textColor =
line.type === "add"
? "text-green-400"
: line.type === "remove"
? "text-red-400"
: "text-[#9bc3ff]";
const prefix =
line.type === "add"
? "+"
: line.type === "remove"
? "-"
: " ";
return (
<tr key={i} className={bgColor}>
{/* Old line number */}
<td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
{line.type !== "add" ? line.oldLineNumber : ""}
</td>
{/* New line number */}
<td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
{line.type !== "remove" ? line.newLineNumber : ""}
</td>
{/* Content */}
<td className={`px-2 py-0 whitespace-pre ${textColor}`}>
<span className="select-none mr-1 text-[#555]">
{prefix}
</span>
{line.content}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
export function OverlayDiffViewer({
diff,
changedFiles,
loading,
error,
onClose,
title = "Overlay Changes",
}: OverlayDiffViewerProps) {
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const [showFullDiff, setShowFullDiff] = useState(true);
const parsedFiles = useMemo(() => parseDiff(diff), [diff]);
const toggleFile = (path: string) => {
setCollapsedFiles((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const expandAll = () => setCollapsedFiles(new Set());
const collapseAll = () => setCollapsedFiles(new Set(parsedFiles.map((f) => f.path)));
// Calculate totals
const totals = useMemo(() => {
return parsedFiles.reduce(
(acc, file) => ({
additions: acc.additions + file.additions,
deletions: acc.deletions + file.deletions,
files: acc.files + 1,
}),
{ additions: 0, deletions: 0, files: 0 }
);
}, [parsedFiles]);
if (loading) {
return (
<div className="panel p-4">
<div className="flex items-center justify-center h-32">
<div className="font-mono text-sm text-[#75aafc]">
Loading diff...
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
{title}
</div>
{onClose && (
<button
onClick={onClose}
className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
>
Close
</button>
)}
</div>
<div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-sm text-red-400">
{error}
</div>
</div>
);
}
if (!diff.trim() && (!changedFiles || changedFiles.length === 0)) {
return (
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
{title}
</div>
{onClose && (
<button
onClick={onClose}
className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
>
Close
</button>
)}
</div>
<div className="text-center py-8 font-mono text-sm text-[#555]">
No changes detected in overlay
</div>
</div>
);
}
return (
<div className="panel flex flex-col max-h-[600px]">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.2)] shrink-0">
<div className="flex items-center gap-4">
<div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
{title}
</div>
<div className="font-mono text-[10px] text-[#75aafc]">
{totals.files} file{totals.files !== 1 ? "s" : ""} changed
{totals.additions > 0 && (
<span className="text-green-400 ml-2">+{totals.additions}</span>
)}
{totals.deletions > 0 && (
<span className="text-red-400 ml-2">-{totals.deletions}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={expandAll}
className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
>
Expand All
</button>
<button
onClick={collapseAll}
className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
>
Collapse All
</button>
<button
onClick={() => setShowFullDiff(!showFullDiff)}
className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
>
{showFullDiff ? "File List" : "Full Diff"}
</button>
{onClose && (
<button
onClick={onClose}
className="font-mono text-xs text-[#555] hover:text-[#9bc3ff] ml-2"
>
Close
</button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-3">
{showFullDiff ? (
// Full diff view
parsedFiles.length > 0 ? (
parsedFiles.map((file) => (
<DiffFileView
key={file.path}
file={file}
collapsed={collapsedFiles.has(file.path)}
onToggle={() => toggleFile(file.path)}
/>
))
) : (
// Fallback to raw diff
<pre className="font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap">
{diff}
</pre>
)
) : (
// File list view
<div className="space-y-1">
{(changedFiles || parsedFiles.map((f) => f.path)).map((path) => {
const file = parsedFiles.find((f) => f.path === path);
return (
<div
key={path}
className="flex items-center gap-2 px-2 py-1 hover:bg-[rgba(117,170,252,0.05)]"
>
{file && (
<span
className={`px-1 py-0.5 font-mono text-[9px] ${
file.status === "added"
? "text-green-400 bg-green-400/10"
: file.status === "deleted"
? "text-red-400 bg-red-400/10"
: file.status === "renamed"
? "text-purple-400 bg-purple-400/10"
: "text-yellow-400 bg-yellow-400/10"
}`}
>
{file.status.charAt(0).toUpperCase()}
</span>
)}
<span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
{path}
</span>
{file && (
<span className="font-mono text-[10px]">
{file.additions > 0 && (
<span className="text-green-400 mr-1">+{file.additions}</span>
)}
{file.deletions > 0 && (
<span className="text-red-400">-{file.deletions}</span>
)}
</span>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}