Files
automaker/apps/app/src/components/ui/git-diff-panel.tsx

631 lines
19 KiB
TypeScript

"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 <FilePlus className="w-4 h-4 text-green-500" />;
case "D":
return <FileX className="w-4 h-4 text-red-500" />;
case "M":
case "U":
return <FilePen className="w-4 h-4 text-amber-500" />;
case "R":
case "C":
return <File className="w-4 h-4 text-blue-500" />;
default:
return <FileText className="w-4 h-4 text-muted-foreground" />;
}
};
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 (
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn("flex font-mono text-xs", bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ""}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ""}
</span>
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
{prefix[type]}
</span>
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
{content || "\u00A0"}
</span>
</div>
);
}
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 (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="flex-1 text-sm font-mono truncate text-foreground">
{fileDiff.filePath}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{fileDiff.isNew && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
new
</span>
)}
{fileDiff.isDeleted && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
deleted
</span>
)}
{fileDiff.isRenamed && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
renamed
</span>
)}
{additions > 0 && (
<span className="text-xs text-green-400">+{additions}</span>
)}
{deletions > 0 && (
<span className="text-xs text-red-400">-{deletions}</span>
)}
</div>
</button>
{isExpanded && (
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
</div>
);
}
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<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>("");
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(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 (
<div
className={cn(
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
className
)}
data-testid="git-diff-panel"
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between bg-card hover:bg-accent/50 transition-colors text-left flex-shrink-0"
data-testid="git-diff-panel-toggle"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<GitBranch className="w-4 h-4 text-brand-500" />
<span className="font-medium text-sm text-foreground">Git Changes</span>
</div>
<div className="flex items-center gap-3 text-xs">
{!isExpanded && files.length > 0 && (
<>
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
</span>
{totalAdditions > 0 && (
<span className="text-green-400">+{totalAdditions}</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">-{totalDeletions}</span>
)}
</>
)}
</div>
</button>
{/* Content */}
{isExpanded && (
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<AlertCircle className="w-5 h-5 text-amber-500" />
<span className="text-sm">{error}</span>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="mt-2"
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div>
{/* Summary bar */}
<div className="p-4 pb-2 border-b border-border-glass">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-wrap">
{(() => {
// 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<string, {count: number, statusText: string, files: string[]}>);
return Object.entries(statusGroups).map(([status, group]) => (
<div
key={status}
className="flex items-center gap-1.5"
title={group.files.join('\n')}
data-testid={`git-status-group-${status.toLowerCase()}`}
>
{getFileIcon(status)}
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
getStatusBadgeColor(status)
)}
>
{group.count} {group.statusText}
</span>
</div>
));
})()}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={expandAllFiles}
className="text-xs h-7"
>
Expand All
</Button>
<Button
variant="ghost"
size="sm"
onClick={collapseAllFiles}
className="text-xs h-7"
>
Collapse All
</Button>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="text-xs h-7"
>
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm mt-2">
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"} changed
</span>
{totalAdditions > 0 && (
<span className="text-green-400">
+{totalAdditions} additions
</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">
-{totalDeletions} deletions
</span>
)}
</div>
</div>
{/* File diffs */}
<div className="p-4 space-y-3">
{parsedDiffs.map((fileDiff) => (
<FileDiffSection
key={fileDiff.filePath}
fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}