import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
GitCommit,
Sparkles,
FilePlus,
FileX,
FilePen,
FileText,
File,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { cn } from '@/lib/utils';
import type { FileStatus } from '@/types/electron';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CommitWorktreeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCommitted: () => void;
}
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 getStatusLabel = (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';
}
};
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';
}
};
/**
* 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];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
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;
}
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++;
}
}
}
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'}
);
}
export function CommitWorktreeDialog({
open,
onOpenChange,
worktree,
onCommitted,
}: CommitWorktreeDialogProps) {
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
// File selection state
const [files, setFiles] = useState([]);
const [diffContent, setDiffContent] = useState('');
const [selectedFiles, setSelectedFiles] = useState>(new Set());
const [expandedFile, setExpandedFile] = useState(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Create a map of file path to parsed diff for quick lookup
const diffsByFile = useMemo(() => {
const map = new Map();
for (const diff of parsedDiffs) {
map.set(diff.filePath, diff);
}
return map;
}, [parsedDiffs]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
setIsLoadingDiffs(true);
setFiles([]);
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
const loadDiffs = async () => {
try {
const api = getElectronAPI();
if (api?.git?.getDiffs) {
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
} catch (err) {
console.warn('Failed to load diffs for commit dialog:', err);
} finally {
setIsLoadingDiffs(false);
}
};
loadDiffs();
}
}, [open, worktree]);
const handleToggleFile = useCallback((filePath: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const handleToggleAll = useCallback(() => {
setSelectedFiles((prev) => {
if (prev.size === files.length) {
return new Set();
}
return new Set(files.map((f) => f.path));
});
}, [files]);
const handleFileClick = useCallback((filePath: string) => {
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleCommit = async () => {
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
if (!api?.worktree?.commit) {
setError('Worktree API not available');
return;
}
// Pass selected files if not all files are selected
const filesToCommit =
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
const result = await api.worktree.commit(worktree.path, message, filesToCommit);
if (result.success && result.result) {
if (result.result.committed) {
toast.success('Changes committed', {
description: `Commit ${result.result.commitHash} on ${result.result.branch}`,
});
onCommitted();
onOpenChange(false);
setMessage('');
} else {
toast.info('No changes to commit', {
description: result.result.message,
});
}
} else {
setError(result.error || 'Failed to commit changes');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to commit');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (
e.key === 'Enter' &&
e.metaKey &&
!isLoading &&
!isGenerating &&
message.trim() &&
selectedFiles.size > 0
) {
handleCommit();
}
};
// Generate AI commit message when dialog opens (if enabled)
useEffect(() => {
if (open && worktree) {
// Reset state
setMessage('');
setError(null);
if (!enableAiCommitMessages) {
return;
}
setIsGenerating(true);
let cancelled = false;
const generateMessage = async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.generateCommitMessage) {
if (!cancelled) {
setIsGenerating(false);
}
return;
}
const result = await api.worktree.generateCommitMessage(worktree.path);
if (cancelled) return;
if (result.success && result.message) {
setMessage(result.message);
} else {
console.warn('Failed to generate commit message:', result.error);
setMessage('');
}
} catch (err) {
if (cancelled) return;
console.warn('Error generating commit message:', err);
setMessage('');
} finally {
if (!cancelled) {
setIsGenerating(false);
}
}
};
generateMessage();
return () => {
cancelled = true;
};
}
}, [open, worktree, enableAiCommitMessages]);
if (!worktree) return null;
const allSelected = selectedFiles.size === files.length && files.length > 0;
return (
);
}