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 ( Commit Changes Commit changes in the{' '} {worktree.branch} worktree.
{/* File Selection */}
{files.length > 0 && ( )}
{isLoadingDiffs ? (
Loading changes...
) : files.length === 0 ? (
No changes detected
) : (
{files.map((file) => { const isChecked = selectedFiles.has(file.path); const isExpanded = expandedFile === file.path; const fileDiff = diffsByFile.get(file.path); const additions = fileDiff ? fileDiff.hunks.reduce( (acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length, 0 ) : 0; const deletions = fileDiff ? fileDiff.hunks.reduce( (acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length, 0 ) : 0; return (
{/* Checkbox */} handleToggleFile(file.path)} className="flex-shrink-0" /> {/* Clickable file row to show diff */}
{/* Expanded diff view */} {isExpanded && fileDiff && (
{fileDiff.hunks.map((hunk, hunkIndex) => (
{hunk.lines.map((line, lineIndex) => ( ))}
))}
)} {isExpanded && !fileDiff && (
{file.status === '?' ? ( New file - diff preview not available ) : file.status === 'D' ? ( File deleted ) : ( Diff content not available )}
)}
); })}
)}
{/* Commit Message */}