import { useState, useMemo } from "react"; export interface DiffLine { type: "add" | "remove" | "context" | "header" | "hunk"; content: string; oldLineNumber?: number; newLineNumber?: number; } export 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; } export 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; } export function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) { const statusColors: Record = { 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 = { added: "A", modified: "M", deleted: "D", renamed: "R", }; return (
{/* File header */} {/* File content */} {!collapsed && (
{file.lines.map((line, i) => { if (line.type === "header" || line.type === "hunk") { return ( ); } 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 ( {/* Old line number */} {/* New line number */} {/* Content */} ); })}
{line.content}
{line.type !== "add" ? line.oldLineNumber : ""} {line.type !== "remove" ? line.newLineNumber : ""} {prefix} {line.content}
)}
); } export function OverlayDiffViewer({ diff, changedFiles, loading, error, onClose, title = "Overlay Changes", }: OverlayDiffViewerProps) { const [collapsedFiles, setCollapsedFiles] = useState>(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 (
Loading diff...
); } if (error) { return (
{title}
{onClose && ( )}
{error}
); } if (!diff.trim() && (!changedFiles || changedFiles.length === 0)) { return (
{title}
{onClose && ( )}
No changes detected in overlay
); } return (
{/* Header */}
{title}
{totals.files} file{totals.files !== 1 ? "s" : ""} changed {totals.additions > 0 && ( +{totals.additions} )} {totals.deletions > 0 && ( -{totals.deletions} )}
{onClose && ( )}
{/* Content */}
{showFullDiff ? ( // Full diff view parsedFiles.length > 0 ? ( parsedFiles.map((file) => ( toggleFile(file.path)} /> )) ) : ( // Fallback to raw diff
              {diff}
            
) ) : ( // File list view
{(changedFiles || parsedFiles.map((f) => f.path)).map((path) => { const file = parsedFiles.find((f) => f.path === path); return (
{file && ( {file.status.charAt(0).toUpperCase()} )} {path} {file && ( {file.additions > 0 && ( +{file.additions} )} {file.deletions > 0 && ( -{file.deletions} )} )}
); })}
)}
); }