summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/mesh/OverlayDiffViewer.tsx')
-rw-r--r--makima/frontend/src/components/mesh/OverlayDiffViewer.tsx476
1 files changed, 476 insertions, 0 deletions
diff --git a/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
new file mode 100644
index 0000000..74059a0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
@@ -0,0 +1,476 @@
+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>
+ );
+}