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, RiSplitCellsHorizontal, } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi'; import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types'; import type { WorktreeMetadata } from '@/types/worktree'; import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; import { getWorktreeSetupCommands } from '@/lib/openchamberConfig'; import { sessionEvents } from '@/lib/sessionEvents'; import { useSessions } from '@/sync/sync-context'; import { useI18n } from '@/lib/i18n'; 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; const normalizeBranchName = (value: string | null | undefined): string => { return String(value || '') .trim() .replace(/^refs\/heads\//, '') .replace(/^heads\//, '') .replace(/^remotes\//, ''); }; const normalizePath = (value: string | null | undefined): string => { const raw = String(value || '').trim().replace(/\\/g, '/'); if (!raw) { return ''; } if (raw === '/') { return '/'; } return raw.length > 1 ? raw.replace(/\/+$/, '') : raw; }; export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) { const { t } = useI18n(); const sessions = useSessions(); const [searchQuery, setSearchQuery] = React.useState(''); const [branches, setBranches] = React.useState(null); const [worktrees, setWorktrees] = React.useState([]); const [rootBranchName, setRootBranchName] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); const [creatingWorktreeBranch, setCreatingWorktreeBranch] = 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, rootBranch] = await Promise.all([ getGitBranches(project.path), git.worktree.list(project.path), getRootBranch(project.path).catch(() => null), ]); setBranches(b); setWorktrees(w); setRootBranchName(rootBranch); } catch (err) { setError(err instanceof Error ? err.message : t('branchPickerDialog.error.failedToLoad')); setBranches(null); setWorktrees([]); setRootBranchName(null); } finally { setLoading(false); } }, [project, t]); React.useEffect(() => { if (!open) { setSearchQuery(''); setConfirmingDelete(null); setForceDeleteBranch(null); setEditingBranch(null); setEditValue(''); setRenamingBranchKey(null); setCreatingWorktreeBranch(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 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(t('branchPickerDialog.error.renameRejected')); } await refresh(); cancelRename(); toast.success(t('branchPickerDialog.toast.branchRenamed'), { description: `${oldName} -> ${newName}` }); } catch (err) { toast.error(t('branchPickerDialog.toast.failedToRenameBranch'), { description: err instanceof Error ? err.message : t('branchPickerDialog.error.renameFailed'), }); setRenamingBranchKey(null); } }, [project, editValue, refresh, cancelRename, t]); 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(t('branchPickerDialog.error.deleteRejected')); } await refresh(); toast.success(t('branchPickerDialog.toast.branchDeleted'), { description: branchName }); setConfirmingDelete(null); setForceDeleteBranch(null); } catch (err) { const message = err instanceof Error ? err.message : t('branchPickerDialog.error.deleteFailed'); // 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(t('branchPickerDialog.toast.branchNotMerged'), { description: t('branchPickerDialog.toast.confirmAgainToForceDelete'), }); } else { toast.error(t('branchPickerDialog.toast.failedToDeleteBranch'), { description: message }); } } finally { setDeletingBranch(null); } }, [project, refresh, forceDeleteBranch, t]); const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => { if (!project) { return; } setCreatingWorktreeBranch(branchName); try { const setupCommands = await getWorktreeSetupCommands({ id: project.id, path: project.path, }); await createWorktreeWithDefaults( { id: project.id, path: project.path, }, { preferredName: branchName, mode: 'existing', existingBranch: branchName, branchName, worktreeName: branchName, setupCommands, } ); await refresh(); toast.success(t('branchPickerDialog.toast.worktreeCreated'), { description: branchName }); } catch (err) { toast.error(t('branchPickerDialog.toast.failedToCreateWorktree'), { description: err instanceof Error ? err.message : t('branchPickerDialog.error.createWorktreeFailed'), }); } finally { setCreatingWorktreeBranch(null); } }, [project, refresh, t]); const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => { if (!project || !worktree) { return; } const normalizedWorktreePath = normalizePath(worktree.path); const directSessions = sessions.filter((session) => { const sessionPath = normalizePath(session.directory ?? null); return Boolean(sessionPath) && sessionPath === normalizedWorktreePath; }); const directSessionIds = new Set(directSessions.map((session) => session.id)); const findSubsessions = (parentIds: Set): typeof sessions => { const subsessions = sessions.filter((session) => { const parentID = (session as { parentID?: string | null }).parentID; if (!parentID) { return false; } return parentIds.has(parentID); }); if (subsessions.length === 0) { return []; } const subsessionIds = new Set(subsessions.map((session) => session.id)); return [...subsessions, ...findSubsessions(subsessionIds)]; }; const allSubsessions = findSubsessions(directSessionIds); const seenIds = new Set(); const allSessions = [...directSessions, ...allSubsessions].filter((session) => { if (seenIds.has(session.id)) { return false; } seenIds.add(session.id); return true; }); const normalizedBranch = normalizeBranchName(worktree.branch); const worktreeMetadata: WorktreeMetadata = { source: 'sdk', name: worktree.name, path: worktree.path, projectDirectory: project.path, branch: normalizedBranch, label: normalizedBranch || worktree.name, }; sessionEvents.requestDelete({ sessions: allSessions, mode: 'worktree', worktree: worktreeMetadata, }); }, [project, sessions]); const worktreeByBranch = new Map(); for (const worktree of worktrees) { const branchName = normalizeBranchName(worktree.branch); if (branchName && !worktreeByBranch.has(branchName)) { worktreeByBranch.set(branchName, worktree); } } const normalizedRootBranch = normalizeBranchName(rootBranchName); const allBranches = branches?.all || []; const filteredBranches = filterBranches(allBranches, searchQuery); const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/')); return ( {t('branchPickerDialog.title')} {project ? t('branchPickerDialog.description.localBranchesForProject', { project: displayProjectName(project) }) : t('branchPickerDialog.description.selectProject')}
setSearchQuery(e.target.value)} className="pl-9" />
{!project ? (
{t('branchPickerDialog.state.noProjectSelected')}
) : loading ? (
{t('branchPickerDialog.state.loadingBranches')}
) : error ? (
{error}
) : localBranches.length === 0 ? (
{searchQuery ? t('branchPickerDialog.state.noMatchingBranches') : t('branchPickerDialog.state.noBranchesFound')}
) : ( localBranches.map((branchName) => { const details = branches?.branches[branchName]; const normalizedBranchName = normalizeBranchName(branchName); const isCurrent = Boolean(details?.current); const isDeleting = deletingBranch === branchName; const isRenaming = renamingBranchKey === branchName; const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null; const hasAttachedWorktree = Boolean(attachedWorktree); const isProjectRootBranch = Boolean( normalizedBranchName && normalizedRootBranch && normalizedBranchName === normalizedRootBranch ); const isEditing = editingBranch === branchName; const isConfirming = confirmingDelete === branchName; const isForceDelete = forceDeleteBranch === branchName; const isCreatingWorktree = creatingWorktreeBranch === branchName; const disableCreateWorktree = Boolean( hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing ); const disableDelete = Boolean( isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch ); const disableRename = Boolean( isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch ); const disableWorktreeDelete = Boolean( isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree ); 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={t('branchPickerDialog.search.renameBranchPlaceholder')} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); cancelRename(); } if (event.key === 'Enter') { event.preventDefault(); void commitRename(branchName); } }} />
) : ( {branchName} )} {isCurrent && ( {t('branchPickerDialog.badge.head')} )} {hasAttachedWorktree && !isEditing && ( {t('branchPickerDialog.badge.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 ? (
{hasAttachedWorktree ? t('branchPickerDialog.tooltip.worktreeAlreadyExists') : t('branchPickerDialog.tooltip.createWorktree')} {isProjectRootBranch ? t('branchPickerDialog.tooltip.renameDisabledForRoot') : t('branchPickerDialog.tooltip.rename')} {hasAttachedWorktree ? isProjectRootBranch ? t('branchPickerDialog.tooltip.deleteWorktreeRootProtected') : t('branchPickerDialog.tooltip.deleteWorktree') : isCurrent ? t('branchPickerDialog.tooltip.deleteCurrentBranch') : isProjectRootBranch ? t('branchPickerDialog.tooltip.deleteDisabledForRoot') : t('branchPickerDialog.tooltip.delete')}
) : null} {isEditing ? (
) : null} {!isEditing && isConfirming && !hasAttachedWorktree ? (
{isForceDelete ? t('branchPickerDialog.actions.forceDeletePrompt') : t('branchPickerDialog.actions.deletePrompt')}
) : null}
); }) )}
); }