import { useState, useEffect, useMemo, useCallback, useRef } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { GitCommit, GitMerge, Sparkles, FilePlus, FileX, FilePen, FileText, File, ChevronDown, ChevronRight, Upload, RefreshCw, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; import { resolveModelString } from '@automaker/model-resolver'; import { cn } from '@/lib/utils'; import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import type { FileStatus, MergeStateInfo } from '@/types/electron'; import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils'; interface RemoteInfo { name: string; url: string; } 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; } 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'; } }; const getMergeTypeLabel = (mergeType?: string) => { switch (mergeType) { case 'both-modified': return 'Both Modified'; case 'added-by-us': return 'Added by Us'; case 'added-by-them': return 'Added by Them'; case 'deleted-by-us': return 'Deleted by Us'; case 'deleted-by-them': return 'Deleted by Them'; case 'both-added': return 'Both Added'; case 'both-deleted': return 'Both Deleted'; default: return 'Merge'; } }; 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); // Commit message model override const commitModelOverride = useModelOverride({ phase: 'commitMessageModel' }); const { effectiveModel: commitEffectiveModel, effectiveModelEntry: commitEffectiveModelEntry } = commitModelOverride; // 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); const [mergeState, setMergeState] = useState(undefined); // Push after commit state const [pushAfterCommit, setPushAfterCommit] = useState(false); const [remotes, setRemotes] = useState([]); const [selectedRemote, setSelectedRemote] = useState(''); const [isLoadingRemotes, setIsLoadingRemotes] = useState(false); const [isPushing, setIsPushing] = useState(false); const [remotesFetched, setRemotesFetched] = useState(false); const [remotesFetchError, setRemotesFetchError] = useState(null); // Track whether the commit already succeeded so retries can skip straight to push const [commitSucceeded, setCommitSucceeded] = 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]); // Fetch remotes when push option is enabled const fetchRemotesForWorktree = useCallback( async (worktreePath: string, signal?: { cancelled: boolean }) => { setIsLoadingRemotes(true); setRemotesFetchError(null); try { const api = getElectronAPI(); if (api?.worktree?.listRemotes) { const result = await api.worktree.listRemotes(worktreePath); if (signal?.cancelled) return; setRemotesFetched(true); if (result.success && result.result) { const remoteInfos = result.result.remotes.map((r) => ({ name: r.name, url: r.url, })); setRemotes(remoteInfos); // Auto-select 'origin' if available, otherwise first remote if (remoteInfos.length > 0) { const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0]; setSelectedRemote(defaultRemote.name); } } } else { // API not available — mark fetch as complete with an error so the UI // shows feedback instead of remaining in an empty/loading state. setRemotesFetchError('Remote listing not available'); setRemotesFetched(true); return; } } catch (err) { if (signal?.cancelled) return; // Don't mark as successfully fetched — show an error with retry instead setRemotesFetchError(err instanceof Error ? err.message : 'Failed to fetch remotes'); console.warn('Failed to fetch remotes:', err); } finally { if (!signal?.cancelled) setIsLoadingRemotes(false); } }, [] ); useEffect(() => { if (pushAfterCommit && worktree && !remotesFetched && !remotesFetchError) { const signal = { cancelled: false }; fetchRemotesForWorktree(worktree.path, signal); return () => { signal.cancelled = true; }; } }, [pushAfterCommit, worktree, remotesFetched, remotesFetchError, fetchRemotesForWorktree]); // Load diffs when dialog opens useEffect(() => { if (open && worktree) { setIsLoadingDiffs(true); setFiles([]); setDiffContent(''); setSelectedFiles(new Set()); setExpandedFile(null); setMergeState(undefined); // Reset push state setPushAfterCommit(false); setRemotes([]); setSelectedRemote(''); setIsPushing(false); setRemotesFetched(false); setRemotesFetchError(null); setCommitSucceeded(false); let cancelled = false; 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 ?? []; // Sort merge-affected files first when a merge is in progress if (result.mergeState?.isMerging) { const mergeSet = new Set(result.mergeState.mergeAffectedFiles); fileList.sort((a, b) => { const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false); const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false); if (aIsMerge && !bIsMerge) return -1; if (!aIsMerge && bIsMerge) return 1; return 0; }); } if (!cancelled) setFiles(fileList); if (!cancelled) setDiffContent(result.diff ?? ''); if (!cancelled) setMergeState(result.mergeState); // If any files are already staged, pre-select only staged files // Otherwise select all files by default const stagedFiles = fileList.filter((f) => { const idx = f.indexStatus ?? ' '; return idx !== ' ' && idx !== '?'; }); if (!cancelled) { if (stagedFiles.length > 0) { // Also include untracked files that are staged (A status) setSelectedFiles(new Set(stagedFiles.map((f) => f.path))); } else { setSelectedFiles(new Set(fileList.map((f) => f.path))); } } } else { const errorMsg = result.error ?? 'Failed to load diffs'; console.warn('Failed to load diffs for commit dialog:', errorMsg); if (!cancelled) { setError(errorMsg); toast.error(errorMsg); } } } } catch (err) { console.error('Failed to load diffs for commit dialog:', err); if (!cancelled) { const errorMsg = err instanceof Error ? err.message : 'Failed to load diffs'; setError(errorMsg); toast.error(errorMsg); } } finally { if (!cancelled) setIsLoadingDiffs(false); } }; loadDiffs(); return () => { cancelled = true; }; } }, [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)); }, []); /** Shared push helper — returns true if the push succeeded */ const performPush = async ( api: ReturnType, worktreePath: string, remoteName: string ): Promise => { if (!api?.worktree?.push) { toast.error('Push API not available'); return false; } setIsPushing(true); try { const pushResult = await api.worktree.push(worktreePath, false, remoteName); if (pushResult.success && pushResult.result) { toast.success('Pushed to remote', { description: pushResult.result.message, }); return true; } else { toast.error(pushResult.error || 'Failed to push to remote'); return false; } } catch (pushErr) { toast.error(pushErr instanceof Error ? pushErr.message : 'Failed to push to remote'); return false; } finally { setIsPushing(false); } }; const handleCommit = async () => { if (!worktree) return; const api = getElectronAPI(); // If commit already succeeded on a previous attempt, skip straight to push (or close if no push needed) if (commitSucceeded) { if (pushAfterCommit && selectedRemote) { const ok = await performPush(api, worktree.path, selectedRemote); if (ok) { onCommitted(); onOpenChange(false); setMessage(''); } } else { onCommitted(); onOpenChange(false); setMessage(''); } return; } if (!message.trim() || selectedFiles.size === 0) return; setIsLoading(true); setError(null); try { 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) { setCommitSucceeded(true); toast.success('Changes committed', { description: `Commit ${result.result.commitHash} on ${result.result.branch}`, }); // Push after commit if enabled let pushSucceeded = false; if (pushAfterCommit && selectedRemote) { pushSucceeded = await performPush(api, worktree.path, selectedRemote); } // Only close the dialog when no push was requested or the push completed successfully. // If push failed, keep the dialog open so the user can retry. if (!pushAfterCommit || pushSucceeded) { onCommitted(); onOpenChange(false); setMessage(''); } else { // Commit succeeded but push failed — notify parent of commit but keep dialog open for retry onCommitted(); } } 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); } }; // When the commit succeeded but push failed, allow retrying the push without // requiring a commit message or file selection. const isPushRetry = commitSucceeded && pushAfterCommit && !isPushing; const handleKeyDown = (e: React.KeyboardEvent) => { if ( e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isLoading && !isPushing && !isGenerating ) { if (isPushRetry) { // Push retry only needs a selected remote if (selectedRemote) { handleCommit(); } } else if ( message.trim() && selectedFiles.size > 0 && !(pushAfterCommit && !selectedRemote) ) { handleCommit(); } } }; // Generate AI commit message const generateCommitMessage = useCallback(async () => { if (!worktree) return; setIsGenerating(true); try { const resolvedCommitModel = resolveModelString(commitEffectiveModel); const api = getHttpApiClient(); const result = await api.worktree.generateCommitMessage( worktree.path, resolvedCommitModel, commitEffectiveModelEntry?.thinkingLevel, commitEffectiveModelEntry?.providerId ); if (result.success && result.message) { setMessage(result.message); } else { console.warn('Failed to generate commit message:', result.error); toast.error('Failed to generate commit message', { description: result.error || 'Unknown error', }); } } catch (err) { console.warn('Error generating commit message:', err); toast.error('Failed to generate commit message', { description: err instanceof Error ? err.message : 'Unknown error', }); } finally { setIsGenerating(false); } }, [worktree, commitEffectiveModel, commitEffectiveModelEntry]); // Keep a stable ref to generateCommitMessage so the open-dialog effect // doesn't re-fire (and erase user edits) when the model override changes. const generateCommitMessageRef = useRef(generateCommitMessage); useEffect(() => { generateCommitMessageRef.current = generateCommitMessage; }); // Generate AI commit message when dialog opens (if enabled) useEffect(() => { if (open && worktree) { // Reset state setMessage(''); setError(null); if (!enableAiCommitMessages) { return; } generateCommitMessageRef.current(); } }, [open, worktree, enableAiCommitMessages]); if (!worktree) return null; const allSelected = selectedFiles.size === files.length && files.length > 0; // Prevent the dialog from being dismissed while a push or generation is in progress. // Overlay clicks and Escape key both route through onOpenChange(false); we // intercept those here so the UI stays open until the operation completes. const handleOpenChange = (nextOpen: boolean) => { if (!nextOpen && (isLoading || isPushing || isGenerating)) { // Ignore close requests during an active commit, push, or generation. return; } onOpenChange(nextOpen); }; return ( Commit Changes Commit changes in the{' '} {worktree.branch} worktree.
{/* Merge state banner */} {mergeState?.isMerging && (
{mergeState.mergeOperationType === 'cherry-pick' ? 'Cherry-pick' : mergeState.mergeOperationType === 'rebase' ? 'Rebase' : 'Merge'}{' '} in progress {mergeState.conflictFiles.length > 0 ? ( — {mergeState.conflictFiles.length} file {mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts ) : mergeState.isCleanMerge ? ( — Clean merge, {mergeState.mergeAffectedFiles.length} file {mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected ) : null}
)} {/* 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?.additions ?? 0; const deletions = fileDiff?.deletions ?? 0; // Determine staging state from index/worktree status const idx = file.indexStatus ?? ' '; const wt = file.workTreeStatus ?? ' '; const isStaged = idx !== ' ' && idx !== '?'; const isUnstaged = wt !== ' ' && wt !== '?'; const isUntracked = idx === '?' && wt === '?'; const isMergeFile = file.isMergeAffected || (mergeState?.mergeAffectedFiles?.includes(file.path) ?? false); 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 */}
{enableAiCommitMessages && ( <> )}