summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
blob: 704ec807fdb7b03ba0b9232f87c4880c58dd487c (plain) (tree)
1
2
3
4
5
6
7
8
9

                                          
                           





                                                         
                           
















                                                       
                                                         

















































































































                                                                         
                                                                                                                           












































































































































































































































































































































                                                                                                                                        
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<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>
  );
}