"use client"; import { useState, useEffect, useMemo, useCallback } from "react"; import { getElectronAPI } from "@/lib/electron"; import { cn } from "@/lib/utils"; import { File, FileText, FilePlus, FileX, FilePen, ChevronDown, ChevronRight, Loader2, RefreshCw, GitBranch, AlertCircle, } from "lucide-react"; import { Button } from "./button"; import type { FileStatus } from "@/types/electron"; interface GitDiffPanelProps { projectPath: string; featureId: string; className?: string; /** Whether to show the panel in a compact/minimized state initially */ compact?: boolean; /** Whether worktrees are enabled - if false, shows diffs from main project */ useWorktrees?: boolean; } interface ParsedDiffHunk { header: string; lines: { type: "context" | "addition" | "deletion" | "header"; content: string; lineNumber?: { old?: number; new?: number }; }[]; } interface ParsedFileDiff { filePath: string; hunks: ParsedDiffHunk[]; isNew?: boolean; isDeleted?: boolean; isRenamed?: boolean; } const getFileIcon = (status: string) => { switch (status) { case "A": case "?": return ; case "D": return ; case "M": case "U": return ; case "R": case "C": return ; default: return ; } }; const getStatusBadgeColor = (status: string) => { switch (status) { case "A": case "?": return "bg-green-500/20 text-green-400 border-green-500/30"; case "D": return "bg-red-500/20 text-red-400 border-red-500/30"; case "M": case "U": return "bg-amber-500/20 text-amber-400 border-amber-500/30"; case "R": case "C": return "bg-blue-500/20 text-blue-400 border-blue-500/30"; default: return "bg-muted text-muted-foreground border-border"; } }; const getStatusDisplayName = (status: string) => { switch (status) { case "A": return "Added"; case "?": return "Untracked"; case "D": return "Deleted"; case "M": return "Modified"; case "U": return "Updated"; case "R": return "Renamed"; case "C": return "Copied"; default: return "Changed"; } }; /** * Parse unified diff format into structured data */ function parseDiff(diffText: string): ParsedFileDiff[] { if (!diffText) return []; const files: ParsedFileDiff[] = []; const lines = diffText.split("\n"); let currentFile: ParsedFileDiff | null = null; let currentHunk: ParsedDiffHunk | null = null; let oldLineNum = 0; let newLineNum = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // New file diff if (line.startsWith("diff --git")) { if (currentFile) { if (currentHunk) { currentFile.hunks.push(currentHunk); } files.push(currentFile); } // Extract file path from diff header const match = line.match(/diff --git a\/(.*?) b\/(.*)/); currentFile = { filePath: match ? match[2] : "unknown", hunks: [], }; currentHunk = null; continue; } // New file indicator if (line.startsWith("new file mode")) { if (currentFile) currentFile.isNew = true; continue; } // Deleted file indicator if (line.startsWith("deleted file mode")) { if (currentFile) currentFile.isDeleted = true; continue; } // Renamed file indicator if (line.startsWith("rename from") || line.startsWith("rename to")) { if (currentFile) currentFile.isRenamed = true; continue; } // Skip index, ---/+++ lines if ( line.startsWith("index ") || line.startsWith("--- ") || line.startsWith("+++ ") ) { continue; } // Hunk header if (line.startsWith("@@")) { if (currentHunk && currentFile) { currentFile.hunks.push(currentHunk); } // Parse line numbers from @@ -old,count +new,count @@ const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1; newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1; currentHunk = { header: line, lines: [{ type: "header", content: line }], }; continue; } // Diff content lines if (currentHunk) { if (line.startsWith("+")) { currentHunk.lines.push({ type: "addition", content: line.substring(1), lineNumber: { new: newLineNum }, }); newLineNum++; } else if (line.startsWith("-")) { currentHunk.lines.push({ type: "deletion", content: line.substring(1), lineNumber: { old: oldLineNum }, }); oldLineNum++; } else if (line.startsWith(" ") || line === "") { currentHunk.lines.push({ type: "context", content: line.substring(1) || "", lineNumber: { old: oldLineNum, new: newLineNum }, }); oldLineNum++; newLineNum++; } } } // Don't forget the last file and hunk if (currentFile) { if (currentHunk) { currentFile.hunks.push(currentHunk); } files.push(currentFile); } return files; } function DiffLine({ type, content, lineNumber, }: { type: "context" | "addition" | "deletion" | "header"; content: string; lineNumber?: { old?: number; new?: number }; }) { const bgClass = { context: "bg-transparent", addition: "bg-green-500/10", deletion: "bg-red-500/10", header: "bg-blue-500/10", }; const textClass = { context: "text-foreground-secondary", addition: "text-green-400", deletion: "text-red-400", header: "text-blue-400", }; const prefix = { context: " ", addition: "+", deletion: "-", header: "", }; if (type === "header") { return (
{content}
); } return (
{lineNumber?.old ?? ""} {lineNumber?.new ?? ""} {prefix[type]} {content || "\u00A0"}
); } function FileDiffSection({ fileDiff, isExpanded, onToggle, }: { fileDiff: ParsedFileDiff; isExpanded: boolean; onToggle: () => void; }) { const additions = fileDiff.hunks.reduce( (acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length, 0 ); const deletions = fileDiff.hunks.reduce( (acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length, 0 ); return (
{isExpanded && (
{fileDiff.hunks.map((hunk, hunkIndex) => (
{hunk.lines.map((line, lineIndex) => ( ))}
))}
)}
); } export function GitDiffPanel({ projectPath, featureId, className, compact = true, useWorktrees = false, }: GitDiffPanelProps) { const [isExpanded, setIsExpanded] = useState(!compact); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [files, setFiles] = useState([]); const [diffContent, setDiffContent] = useState(""); const [expandedFiles, setExpandedFiles] = useState>(new Set()); const loadDiffs = useCallback(async () => { setIsLoading(true); setError(null); try { const api = getElectronAPI(); // Use worktree API if worktrees are enabled, otherwise use git API for main project if (useWorktrees) { if (!api?.worktree?.getDiffs) { throw new Error("Worktree API not available"); } const result = await api.worktree.getDiffs(projectPath, featureId); if (result.success) { setFiles(result.files || []); setDiffContent(result.diff || ""); } else { setError(result.error || "Failed to load diffs"); } } else { // Use git API for main project diffs if (!api?.git?.getDiffs) { throw new Error("Git API not available"); } const result = await api.git.getDiffs(projectPath); if (result.success) { setFiles(result.files || []); setDiffContent(result.diff || ""); } else { setError(result.error || "Failed to load diffs"); } } } catch (err) { setError(err instanceof Error ? err.message : "Failed to load diffs"); } finally { setIsLoading(false); } }, [projectPath, featureId, useWorktrees]); // Load diffs when expanded useEffect(() => { if (isExpanded) { loadDiffs(); } }, [isExpanded, loadDiffs]); const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]); const toggleFile = (filePath: string) => { setExpandedFiles((prev) => { const next = new Set(prev); if (next.has(filePath)) { next.delete(filePath); } else { next.add(filePath); } return next; }); }; const expandAllFiles = () => { setExpandedFiles(new Set(parsedDiffs.map((d) => d.filePath))); }; const collapseAllFiles = () => { setExpandedFiles(new Set()); }; // Total stats const totalAdditions = parsedDiffs.reduce( (acc, file) => acc + file.hunks.reduce( (hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === "addition").length, 0 ), 0 ); const totalDeletions = parsedDiffs.reduce( (acc, file) => acc + file.hunks.reduce( (hAcc, hunk) => hAcc + hunk.lines.filter((l) => l.type === "deletion").length, 0 ), 0 ); return (
{/* Header */} {/* Content */} {isExpanded && (
{isLoading ? (
Loading changes...
) : error ? (
{error}
) : files.length === 0 ? (
No changes detected
) : (
{/* Summary bar */}
{(() => { // Group files by status const statusGroups = files.reduce((acc, file) => { const status = file.status; if (!acc[status]) { acc[status] = { count: 0, statusText: getStatusDisplayName(status), files: [] }; } acc[status].count += 1; acc[status].files.push(file.path); return acc; }, {} as Record); return Object.entries(statusGroups).map(([status, group]) => (
{getFileIcon(status)} {group.count} {group.statusText}
)); })()}
{/* Stats */}
{files.length} {files.length === 1 ? "file" : "files"} changed {totalAdditions > 0 && ( +{totalAdditions} additions )} {totalDeletions > 0 && ( -{totalDeletions} deletions )}
{/* File diffs */}
{parsedDiffs.map((fileDiff) => ( toggleFile(fileDiff.filePath)} /> ))}
)}
)}
); }