import { useState, useEffect, useCallback, useRef } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Download, AlertTriangle, Archive, CheckCircle2, XCircle, FileWarning, Wrench, Sparkles, GitMerge, GitCommitHorizontal, FileText, Settings, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { Checkbox } from '@/components/ui/checkbox'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import type { MergeConflictInfo } from '../worktree-panel/types'; interface WorktreeInfo { path: string; branch: string; isMain: boolean; hasChanges?: boolean; changedFilesCount?: number; } type PullPhase = | 'checking' // Initial check for local changes | 'local-changes' // Local changes detected, asking user what to do | 'pulling' // Actively pulling (with or without stash) | 'success' // Pull completed successfully | 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts) | 'conflict' // Merge conflicts detected | 'error'; // Something went wrong interface PullResult { branch?: string; remote?: string; pulled?: boolean; message?: string; hasLocalChanges?: boolean; localChangedFiles?: string[]; hasConflicts?: boolean; conflictSource?: 'pull' | 'stash'; conflictFiles?: string[]; stashed?: boolean; stashRestored?: boolean; stashRecoveryFailed?: boolean; isMerge?: boolean; isFastForward?: boolean; mergeAffectedFiles?: string[]; } interface GitPullDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; remote?: string; onPulled?: () => void; onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; /** Called when user chooses to commit the merge — opens the commit dialog */ onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void; } export function GitPullDialog({ open, onOpenChange, worktree, remote, onPulled, onCreateConflictResolutionFeature, onCommitMerge, }: GitPullDialogProps) { const [phase, setPhase] = useState('checking'); const [pullResult, setPullResult] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [rememberChoice, setRememberChoice] = useState(false); const [showMergeFiles, setShowMergeFiles] = useState(false); const mergePostAction = useAppStore((s) => s.mergePostAction); const setMergePostAction = useAppStore((s) => s.setMergePostAction); /** * Determine the appropriate phase after a successful pull. * If the pull resulted in a merge (not fast-forward) and no conflicts, * check user preference before deciding whether to show merge prompt. */ const handleSuccessfulPull = useCallback( (result: PullResult) => { setPullResult(result); if (result.isMerge && !result.hasConflicts) { // Merge happened — check user preference if (mergePostAction === 'commit') { // User preference: auto-commit setPhase('success'); onPulled?.(); // Auto-trigger commit dialog if (worktree && onCommitMerge) { onCommitMerge(worktree); onOpenChange(false); } } else if (mergePostAction === 'manual') { // User preference: manual review setPhase('success'); onPulled?.(); } else { // No preference — show merge prompt; onPulled will be called from the // user-action handlers (handleCommitMerge / handleMergeManually) once // the user makes their choice, consistent with the conflict phase. setPhase('merge-complete'); } } else { setPhase('success'); onPulled?.(); } }, [mergePostAction, worktree, onCommitMerge, onPulled, onOpenChange] ); const checkForLocalChanges = useCallback(async () => { if (!worktree) return; setPhase('checking'); try { const api = getElectronAPI(); if (!api?.worktree?.pull) { setErrorMessage('Pull API not available'); setPhase('error'); return; } // Call pull without stashIfNeeded to just check status const result = await api.worktree.pull(worktree.path, remote); if (!result.success) { setErrorMessage(result.error || 'Failed to pull'); setPhase('error'); return; } if (result.result?.hasLocalChanges) { // Local changes detected - ask user what to do setPullResult(result.result); setPhase('local-changes'); } else if (result.result?.pulled !== undefined) { // No local changes, pull went through (or already up to date) handleSuccessfulPull(result.result); } else { // Unexpected response: success but no recognizable fields setPullResult(result.result ?? null); setErrorMessage('Unexpected pull response'); setPhase('error'); } } catch (err) { setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes'); setPhase('error'); } }, [worktree, remote, handleSuccessfulPull]); // Keep a ref to the latest checkForLocalChanges to break the circular dependency // between the "reset/start" effect and the callback chain. Without this, any // change in onPulled (passed from the parent) would recreate handleSuccessfulPull // → checkForLocalChanges → re-trigger the effect while the dialog is already open, // causing the pull flow to restart unintentionally. const checkForLocalChangesRef = useRef(checkForLocalChanges); useEffect(() => { checkForLocalChangesRef.current = checkForLocalChanges; }); // Reset state when dialog opens and start the initial pull check. // Depends only on `open` and `worktree` — NOT on `checkForLocalChanges` — // so that parent callback re-creations don't restart the pull flow mid-flight. useEffect(() => { if (open && worktree) { setPhase('checking'); setPullResult(null); setErrorMessage(null); setRememberChoice(false); setShowMergeFiles(false); // Start the initial check using the ref so we always call the latest version // without making it a dependency of this effect. checkForLocalChangesRef.current(); } }, [open, worktree]); const handlePullWithStash = useCallback(async () => { if (!worktree) return; setPhase('pulling'); try { const api = getElectronAPI(); if (!api?.worktree?.pull) { setErrorMessage('Pull API not available'); setPhase('error'); return; } // Call pull with stashIfNeeded const result = await api.worktree.pull(worktree.path, remote, true); if (!result.success) { setErrorMessage(result.error || 'Failed to pull'); setPhase('error'); return; } setPullResult(result.result || null); if (result.result?.hasConflicts) { setPhase('conflict'); } else if (result.result?.pulled) { handleSuccessfulPull(result.result); } else { // Unrecognized response: no pulled flag and no conflicts console.warn('handlePullWithStash: unrecognized response', result.result); setErrorMessage('Unexpected pull response'); setPhase('error'); } } catch (err) { setErrorMessage(err instanceof Error ? err.message : 'Failed to pull'); setPhase('error'); } }, [worktree, remote, handleSuccessfulPull]); const handleResolveWithAI = useCallback(() => { if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return; const effectiveRemote = pullResult.remote || remote; const branch = pullResult.branch ?? worktree.branch; const conflictInfo: MergeConflictInfo = { sourceBranch: `${effectiveRemote || 'origin'}/${branch}`, targetBranch: branch, targetWorktreePath: worktree.path, conflictFiles: pullResult.conflictFiles || [], operationType: 'merge', }; onCreateConflictResolutionFeature(conflictInfo); onOpenChange(false); }, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]); const handleCommitMerge = useCallback(() => { if (!worktree || !onCommitMerge) { // No handler available — show feedback and bail without persisting preference toast.error('Commit merge is not available', { description: 'The commit merge action is not configured for this context.', duration: 4000, }); return; } if (rememberChoice) { setMergePostAction('commit'); } onPulled?.(); onCommitMerge(worktree); onOpenChange(false); }, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]); const handleMergeManually = useCallback(() => { if (rememberChoice) { setMergePostAction('manual'); } toast.info('Merge left for manual review', { description: 'Review the merged files and commit when ready.', duration: 5000, }); onPulled?.(); onOpenChange(false); }, [rememberChoice, setMergePostAction, onPulled, onOpenChange]); const handleClose = useCallback(() => { onOpenChange(false); }, [onOpenChange]); if (!worktree) return null; return ( {/* Checking Phase */} {phase === 'checking' && ( <> Pull Changes Checking for local changes on{' '} {worktree.branch}...
Fetching remote and checking status...
)} {/* Local Changes Detected Phase */} {phase === 'local-changes' && ( <> Local Changes Detected
You have uncommitted changes on{' '} {worktree.branch} that need to be handled before pulling. {pullResult?.localChangedFiles && pullResult.localChangedFiles.length > 0 && (
{pullResult.localChangedFiles.map((file) => (
{file}
))}
)}
Your changes will be automatically stashed before pulling and restored afterward. If restoring causes conflicts, you'll be able to resolve them.
)} {/* Pulling Phase */} {phase === 'pulling' && ( <> Pulling Changes {pullResult?.hasLocalChanges ? 'Stashing changes, pulling from remote, and restoring your changes...' : 'Pulling latest changes from remote...'}
This may take a moment...
)} {/* Success Phase */} {phase === 'success' && ( <> Pull Complete
{pullResult?.message || 'Changes pulled successfully'} {pullResult?.stashed && pullResult?.stashRestored && !pullResult?.stashRecoveryFailed && (
Your stashed changes have been restored successfully.
)} {pullResult?.stashed && (!pullResult?.stashRestored || pullResult?.stashRecoveryFailed) && (
{pullResult?.message ?? 'Stash could not be restored. Your changes remain in the stash.'}
)}
)} {/* Merge Complete Phase — post-merge prompt */} {phase === 'merge-complete' && ( <> Merge Complete
Pull resulted in a merge on{' '} {worktree.branch} {pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && ( {' '} affecting {pullResult.mergeAffectedFiles.length} file {pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''} )} . How would you like to proceed? {pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
{showMergeFiles && (
{pullResult.mergeAffectedFiles.map((file) => (
{file}
))}
)}
)} {pullResult?.stashed && pullResult?.stashRestored && !pullResult?.stashRecoveryFailed && (
Your stashed changes have been restored successfully.
)}

Choose how to proceed:

  • Commit Merge — Open the commit dialog with a merge commit message
  • Review Manually — Leave the working tree as-is for manual review
{/* Remember choice option */}
{(rememberChoice || mergePostAction) && ( Current:{' '} {mergePostAction === 'commit' ? 'auto-commit' : mergePostAction === 'manual' ? 'manual review' : 'ask every time'} )}
{worktree && onCommitMerge && ( )} )} {/* Conflict Phase */} {phase === 'conflict' && ( <> Merge Conflicts Detected
{pullResult?.conflictSource === 'stash' ? 'Pull succeeded but reapplying your stashed changes resulted in merge conflicts.' : 'The pull resulted in merge conflicts that need to be resolved.'} {pullResult?.conflictFiles && pullResult.conflictFiles.length > 0 && (
Conflicting files ({pullResult.conflictFiles.length}):
{pullResult.conflictFiles.map((file) => (
{file}
))}
)}

Choose how to resolve:

  • Resolve with AI — Creates a task to analyze and resolve conflicts automatically
  • Resolve Manually — Leaves conflict markers in place for you to edit directly
{onCreateConflictResolutionFeature && ( )} )} {/* Error Phase */} {phase === 'error' && ( <> Pull Failed
Failed to pull changes for{' '} {worktree.branch}. {errorMessage && (
{errorMessage}
)}
)}
); }