import * as React from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from '@/components/ui'; import { RiCheckLine, RiCloseLine, RiDeleteBinLine, RiGitBranchLine, RiLoader4Line, RiPencilLine, RiSearchLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi'; import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types'; import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator'; export interface BranchPickerProject { id: string; path: string; normalizedPath: string; label?: string; } interface BranchPickerDialogProps { open: boolean; onOpenChange: (open: boolean) => void; project: BranchPickerProject | null; } const displayProjectName = (project: BranchPickerProject): string => project.label || project.normalizedPath.split('/').pop() || project.normalizedPath; export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) { const [searchQuery, setSearchQuery] = React.useState(''); const [branches, setBranches] = React.useState(null); const [worktrees, setWorktrees] = React.useState([]); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [creatingWorktree, setCreatingWorktree] = React.useState(null); const [deletingBranch, setDeletingBranch] = React.useState(null); const [confirmingDelete, setConfirmingDelete] = React.useState(null); const [forceDeleteBranch, setForceDeleteBranch] = React.useState(null); const [editingBranch, setEditingBranch] = React.useState(null); const [editValue, setEditValue] = React.useState(''); const [renamingBranchKey, setRenamingBranchKey] = React.useState(null); const refresh = React.useCallback(async () => { if (!project) return; setLoading(true); setError(null); try { const [b, w] = await Promise.all([ getGitBranches(project.path), listGitWorktrees(project.path), ]); setBranches(b); setWorktrees(w); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load'); setBranches(null); setWorktrees([]); } finally { setLoading(false); } }, [project]); React.useEffect(() => { if (!open) { setSearchQuery(''); setConfirmingDelete(null); setForceDeleteBranch(null); setEditingBranch(null); setEditValue(''); setRenamingBranchKey(null); return; } void refresh(); }, [open, refresh]); const filterBranches = (list: string[], query: string): string[] => { if (!query.trim()) return list; const lower = query.toLowerCase(); return list.filter((b) => b.toLowerCase().includes(lower)); }; const handleCreateWorktree = async (branchName: string) => { if (!project) return; setCreatingWorktree(branchName); try { await createWorktreeSessionForBranch(project.path, branchName); onOpenChange(false); } catch (err) { toast.error('Failed to create worktree', { description: err instanceof Error ? err.message : 'Create failed', }); } finally { setCreatingWorktree(null); } }; const beginRename = React.useCallback((branchName: string) => { setEditingBranch(branchName); setEditValue(branchName); }, []); const cancelRename = React.useCallback(() => { setEditingBranch(null); setEditValue(''); setRenamingBranchKey(null); }, []); const cancelDelete = React.useCallback(() => { setConfirmingDelete(null); setForceDeleteBranch(null); }, []); const commitRename = React.useCallback(async (oldName: string) => { if (!project) return; const newName = editValue.trim(); if (!newName || newName === oldName) { cancelRename(); return; } setRenamingBranchKey(oldName); try { const result = await renameBranch(project.path, oldName, newName); if (!result?.success) { throw new Error('Rename rejected'); } await refresh(); cancelRename(); toast.success('Branch renamed', { description: `${oldName} -> ${newName}` }); } catch (err) { toast.error('Failed to rename branch', { description: err instanceof Error ? err.message : 'Rename failed', }); setRenamingBranchKey(null); } }, [project, editValue, refresh, cancelRename]); const handleDeleteBranch = React.useCallback(async (branchName: string) => { if (!project) return; setDeletingBranch(branchName); try { const force = forceDeleteBranch === branchName; const result = await deleteGitBranch(project.path, { branch: branchName, force }); if (!result?.success) { throw new Error('Delete rejected'); } await refresh(); toast.success('Branch deleted', { description: branchName }); setConfirmingDelete(null); setForceDeleteBranch(null); } catch (err) { const message = err instanceof Error ? err.message : 'Delete failed'; // If branch isn't merged, prompt for force delete on next confirm. if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) { setForceDeleteBranch(branchName); toast.error('Branch not merged', { description: 'Confirm again to force delete', }); } else { toast.error('Failed to delete branch', { description: message }); } } finally { setDeletingBranch(null); } }, [project, refresh, forceDeleteBranch]); const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean)); const allBranches = branches?.all || []; const filteredBranches = filterBranches(allBranches, searchQuery); const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/')); return ( Manage Branches {project ? `Local branches for ${displayProjectName(project)}` : 'Select a project'}
setSearchQuery(e.target.value)} className="pl-9" />
{!project ? (
No project selected
) : loading ? (
Loading branches...
) : error ? (
{error}
) : localBranches.length === 0 ? (
{searchQuery ? 'No matching branches' : 'No branches found'}
) : ( localBranches.map((branchName) => { const details = branches?.branches[branchName]; const isCurrent = Boolean(details?.current); const isCreating = creatingWorktree === branchName; const isDeleting = deletingBranch === branchName; const isRenaming = renamingBranchKey === branchName; const hasAttachedWorktree = worktreeBranches.has(branchName); const isEditing = editingBranch === branchName; const isConfirming = confirmingDelete === branchName; const isForceDelete = forceDeleteBranch === branchName; const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing); const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing); return (
{isEditing ? (
{ event.preventDefault(); void commitRename(branchName); }} > setEditValue(event.target.value)} className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground" autoFocus placeholder="Rename branch" onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); cancelRename(); } if (event.key === 'Enter') { event.preventDefault(); void commitRename(branchName); } }} />
) : ( {branchName} )} {isCurrent && ( current )} {hasAttachedWorktree && !isEditing && ( worktree )}
{details?.commit ? ( {details.commit.slice(0, 7)} ) : null} {typeof details?.ahead === 'number' && details.ahead > 0 ? ( ↑{details.ahead} ) : null} {typeof details?.behind === 'number' && details.behind > 0 ? ( ↓{details.behind} ) : null}
{!isEditing && !isConfirming ? (
Create worktree from {hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'} {isCurrent ? 'Delete (current branch)' : hasAttachedWorktree ? 'Delete (remove worktree first)' : 'Delete'}
) : null} {isEditing ? (
) : null} {!isEditing && isConfirming ? (
{isForceDelete ? 'Force delete?' : 'Delete?'}
) : null}
); }) )}
); }