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 { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { GitCommit, AlertTriangle, Wrench, User, Clock, Copy, Check, Cherry, ChevronDown, ChevronRight, FileText, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types'; export interface CherryPickConflictInfo { commitHashes: string[]; targetBranch: string; targetWorktreePath: string; } interface RemoteInfo { name: string; url: string; branches: Array<{ name: string; fullRef: string; }>; } interface CommitInfo { hash: string; shortHash: string; author: string; authorEmail: string; date: string; subject: string; body: string; files: string[]; } interface CherryPickDialogProps { open: boolean; onOpenChange: (open: boolean) => void; worktree: WorktreeInfo | null; onCherryPicked: () => void; onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void; } function formatRelativeDate(dateStr: string): string { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); const diffWeeks = Math.floor(diffDays / 7); const diffMonths = Math.floor(diffDays / 30); if (diffSecs < 60) return 'just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; if (diffWeeks < 5) return `${diffWeeks}w ago`; if (diffMonths < 12) return `${diffMonths}mo ago`; return date.toLocaleDateString(); } function CopyHashButton({ hash }: { hash: string }) { const [copied, setCopied] = useState(false); const handleCopy = async (e: React.MouseEvent) => { e.stopPropagation(); try { await navigator.clipboard.writeText(hash); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { toast.error('Failed to copy hash'); } }; return ( ); } type Step = 'select-branch' | 'select-commits' | 'conflict'; export function CherryPickDialog({ open, onOpenChange, worktree, onCherryPicked, onCreateConflictResolutionFeature, }: CherryPickDialogProps) { // Step management const [step, setStep] = useState('select-branch'); // Branch selection state const [remotes, setRemotes] = useState([]); const [localBranches, setLocalBranches] = useState([]); const [selectedRemote, setSelectedRemote] = useState(''); const [selectedBranch, setSelectedBranch] = useState(''); const [loadingBranches, setLoadingBranches] = useState(false); // Commits state const [commits, setCommits] = useState([]); const [selectedCommitHashes, setSelectedCommitHashes] = useState>(new Set()); const [expandedCommits, setExpandedCommits] = useState>(new Set()); const [loadingCommits, setLoadingCommits] = useState(false); const [loadingMoreCommits, setLoadingMoreCommits] = useState(false); const [commitsError, setCommitsError] = useState(null); const [commitLimit, setCommitLimit] = useState(30); const [hasMoreCommits, setHasMoreCommits] = useState(false); // Ref to track the latest fetchCommits request and ignore stale responses const fetchCommitsRequestRef = useRef(0); // Cherry-pick state const [isCherryPicking, setIsCherryPicking] = useState(false); // Conflict state const [conflictInfo, setConflictInfo] = useState(null); // All available branch options for the current remote selection const branchOptions = selectedRemote === '__local__' ? localBranches.filter((b) => b !== worktree?.branch) : (remotes.find((r) => r.name === selectedRemote)?.branches || []).map((b) => b.fullRef); // Reset state when dialog opens useEffect(() => { if (open) { setStep('select-branch'); setSelectedRemote(''); setSelectedBranch(''); setCommits([]); setSelectedCommitHashes(new Set()); setExpandedCommits(new Set()); setConflictInfo(null); setCommitsError(null); setCommitLimit(30); setHasMoreCommits(false); setLoadingBranches(false); } }, [open]); // Fetch remotes and local branches when dialog opens useEffect(() => { if (!open || !worktree) return; let mounted = true; const fetchBranchData = async () => { setLoadingBranches(true); try { const api = getHttpApiClient(); // Fetch remotes and local branches in parallel const [remotesResult, branchesResult] = await Promise.all([ api.worktree.listRemotes(worktree.path), api.worktree.listBranches(worktree.path, false), ]); if (!mounted) return; if (remotesResult.success && remotesResult.result) { setRemotes(remotesResult.result.remotes); // Default to first remote if available, otherwise local if (remotesResult.result.remotes.length > 0) { setSelectedRemote(remotesResult.result.remotes[0].name); } else { setSelectedRemote('__local__'); } } if (branchesResult.success && branchesResult.result) { const branches = branchesResult.result.branches .filter( (b: { isRemote: boolean; name: string }) => !b.isRemote && b.name !== worktree.branch ) .map((b: { name: string }) => b.name); setLocalBranches(branches); } } catch (err) { if (!mounted) return; console.error('Failed to fetch branch data:', err); } finally { if (mounted) { setLoadingBranches(false); } } }; fetchBranchData(); return () => { mounted = false; }; }, [open, worktree]); // Fetch commits when branch is selected const fetchCommits = useCallback( async (limit: number = 30, append: boolean = false) => { if (!worktree || !selectedBranch) return; // Increment the request counter and capture the current request ID const requestId = ++fetchCommitsRequestRef.current; if (append) { setLoadingMoreCommits(true); } else { setLoadingCommits(true); setCommitsError(null); setCommits([]); setSelectedCommitHashes(new Set()); } try { const api = getHttpApiClient(); const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit); // Ignore stale responses from superseded requests if (requestId !== fetchCommitsRequestRef.current) return; if (result.success && result.result) { setCommits(result.result.commits); // If we got exactly the limit, there may be more commits setHasMoreCommits(result.result.commits.length >= limit); } else if (!append) { setCommitsError(result.error || 'Failed to load commits'); } } catch (err) { // Ignore stale responses from superseded requests if (requestId !== fetchCommitsRequestRef.current) return; if (!append) { setCommitsError(err instanceof Error ? err.message : 'Failed to load commits'); } } finally { // Only update loading state if this is still the current request if (requestId === fetchCommitsRequestRef.current) { setLoadingCommits(false); setLoadingMoreCommits(false); } } }, [worktree, selectedBranch] ); // Handle proceeding from branch selection to commit selection const handleProceedToCommits = useCallback(() => { if (!selectedBranch) return; setStep('select-commits'); fetchCommits(commitLimit); }, [selectedBranch, fetchCommits, commitLimit]); // Handle loading more commits const handleLoadMore = useCallback(() => { const newLimit = Math.min(commitLimit + 30, 100); setCommitLimit(newLimit); fetchCommits(newLimit, true); }, [commitLimit, fetchCommits]); // Toggle commit selection const toggleCommitSelection = useCallback((hash: string) => { setSelectedCommitHashes((prev) => { const next = new Set(prev); if (next.has(hash)) { next.delete(hash); } else { next.add(hash); } return next; }); }, []); // Toggle commit file list expansion const toggleCommitExpanded = useCallback((hash: string, e: React.MouseEvent) => { e.stopPropagation(); setExpandedCommits((prev) => { const next = new Set(prev); if (next.has(hash)) { next.delete(hash); } else { next.add(hash); } return next; }); }, []); // Handle cherry-pick execution const handleCherryPick = useCallback(async () => { if (!worktree || selectedCommitHashes.size === 0) return; setIsCherryPicking(true); try { const api = getHttpApiClient(); // Order commits from oldest to newest (reverse of display order) // so they're applied in chronological order const orderedHashes = commits .filter((c) => selectedCommitHashes.has(c.hash)) .reverse() .map((c) => c.hash); const result = await api.worktree.cherryPick(worktree.path, orderedHashes); if (result.success) { toast.success(`Cherry-picked ${orderedHashes.length} commit(s)`, { description: `Successfully applied to ${worktree.branch}`, }); onCherryPicked(); onOpenChange(false); } else { // Check for conflicts const errorMessage = result.error || ''; const hasConflicts = errorMessage.toLowerCase().includes('conflict') || result.hasConflicts; if (hasConflicts && onCreateConflictResolutionFeature) { setConflictInfo({ commitHashes: orderedHashes, targetBranch: worktree.branch, targetWorktreePath: worktree.path, }); setStep('conflict'); toast.error('Cherry-pick conflicts detected', { description: 'The cherry-pick was aborted due to conflicts. No changes were applied.', }); } else { toast.error('Cherry-pick failed', { description: result.error, }); } } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; const hasConflicts = errorMessage.toLowerCase().includes('conflict') || errorMessage.toLowerCase().includes('cherry-pick failed'); if (hasConflicts && onCreateConflictResolutionFeature) { const orderedHashes = commits .filter((c) => selectedCommitHashes.has(c.hash)) .reverse() .map((c) => c.hash); setConflictInfo({ commitHashes: orderedHashes, targetBranch: worktree.branch, targetWorktreePath: worktree.path, }); setStep('conflict'); toast.error('Cherry-pick conflicts detected', { description: 'The cherry-pick was aborted due to conflicts. No changes were applied.', }); } else { toast.error('Cherry-pick failed', { description: errorMessage, }); } } finally { setIsCherryPicking(false); } }, [ worktree, selectedCommitHashes, commits, onCherryPicked, onOpenChange, onCreateConflictResolutionFeature, ]); // Handle creating a conflict resolution feature const handleCreateConflictResolutionFeature = useCallback(() => { if (conflictInfo && onCreateConflictResolutionFeature) { onCreateConflictResolutionFeature({ sourceBranch: selectedBranch, targetBranch: conflictInfo.targetBranch, targetWorktreePath: conflictInfo.targetWorktreePath, operationType: 'cherry-pick', }); onOpenChange(false); } }, [conflictInfo, selectedBranch, onCreateConflictResolutionFeature, onOpenChange]); if (!worktree) return null; // Conflict resolution UI if (step === 'conflict' && conflictInfo) { return ( Cherry-Pick Conflicts Detected
There are conflicts when cherry-picking commits from{' '} {selectedBranch} into{' '} {conflictInfo.targetBranch} .
The cherry-pick could not be completed automatically. You can create a feature task to resolve the conflicts in the{' '} {conflictInfo.targetBranch} {' '} branch.

This will create a high-priority feature task that will:

  • Cherry-pick the selected commit(s) from{' '} {selectedBranch}
  • Resolve any cherry-pick conflicts
  • Ensure the code compiles and tests pass
); } // Step 2: Select commits if (step === 'select-commits') { return ( Cherry Pick Commits Select commits from{' '} {selectedBranch} to apply to{' '} {worktree.branch}
{loadingCommits && (
Loading commits...
)} {commitsError && (

{commitsError}

)} {!loadingCommits && !commitsError && commits.length === 0 && (

No commits found on this branch

)} {!loadingCommits && !commitsError && commits.length > 0 && (
{commits.map((commit, index) => { const isSelected = selectedCommitHashes.has(commit.hash); const isExpanded = expandedCommits.has(commit.hash); const hasFiles = commit.files && commit.files.length > 0; return (
toggleCommitSelection(commit.hash)} className={cn( 'flex gap-3 py-2.5 px-3 cursor-pointer rounded-md transition-colors', !isSelected && 'hover:bg-muted/50' )} > {/* Checkbox */}
toggleCommitSelection(commit.hash)} onClick={(e) => e.stopPropagation()} className="mt-0.5" />
{/* Commit content */}

{commit.subject}

{commit.body && (

{commit.body}

)}
{commit.author} {hasFiles && ( )}
{/* Expanded file list */} {isExpanded && hasFiles && (
{commit.files.map((file) => (
{file}
))}
)}
); })} {/* Load More button */} {hasMoreCommits && commitLimit < 100 && (
)}
)}
); } // Step 1: Select branch (and optionally remote) return ( Cherry Pick
Select a branch to cherry-pick commits from into{' '} {worktree.branch} {loadingBranches ? (
Loading branches...
) : ( <> {/* Remote selector - only show if there are remotes */} {remotes.length > 0 && (
)} {/* Branch selector */}
{branchOptions.length === 0 ? (

No other branches available

) : ( )}
)}
); }