import React from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Icon } from "@/components/icon/Icon"; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import type { GitRemote } from '@/lib/api/types'; import { rankByQuery } from '@/lib/search/fuzzySearch'; import { useI18n } from '@/lib/i18n'; import { useDeviceInfo } from '@/lib/device'; import { getGitUnpushedBranchCounts } from '@/lib/gitApi'; import { getRecentBranches, rememberRecentBranch } from './recentBranches'; interface BranchInfo { ahead?: number; behind?: number; } interface BranchSelectorProps { currentBranch: string | null | undefined; localBranches: string[]; remoteBranches: string[]; branchInfo: Record | undefined; currentBranchAhead?: number; onCheckout: (branch: string) => void; onCreate: (name: string, remote?: GitRemote) => Promise; remotes?: GitRemote[]; disabled?: boolean; directory: string; /** * Shown above the branch list while the working tree has uncommitted * changes: selecting a branch will not switch directly but opens the * commit-or-revert resolution instead. */ switchBlockedNotice?: string | null; } const sanitizeBranchNameInput = (value: string): string => { return value .trim() .replace(/\s+/g, '-') .replace(/[^A-Za-z0-9._/-]/g, '-') .replace(/-+/g, '-') .replace(/\/{2,}/g, '/') .replace(/\/-+/g, '/') .replace(/-+\//g, '/') .replace(/^[-/]+/, '') .replace(/[-/]+$/, ''); }; export const BranchSelector: React.FC = ({ currentBranch, localBranches, remoteBranches, branchInfo, currentBranchAhead = 0, onCheckout, onCreate, remotes = [], disabled = false, directory, switchBlockedNotice = null, }) => { const { t } = useI18n(); const { isMobile } = useDeviceInfo(); const [isOpen, setIsOpen] = React.useState(false); const [search, setSearch] = React.useState(''); const [showCreate, setShowCreate] = React.useState(false); const [showRemoteSelect, setShowRemoteSelect] = React.useState(false); const [newBranchName, setNewBranchName] = React.useState(''); const [isCreating, setIsCreating] = React.useState(false); const [recentBranches, setRecentBranches] = React.useState(() => getRecentBranches(directory)); const [unpushedCounts, setUnpushedCounts] = React.useState>({}); const createInputRef = React.useRef(null); const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent) => { event.stopPropagation(); }, []); const hasMultipleRemotes = remotes.length > 1; const sanitizedNewBranch = React.useMemo( () => sanitizeBranchNameInput(newBranchName), [newBranchName] ); const filteredLocal = React.useMemo( () => rankByQuery(localBranches, search, (branch) => [branch]), [search, localBranches] ); const filteredRemote = React.useMemo( () => rankByQuery(remoteBranches, search, (branch) => [branch]), [search, remoteBranches] ); const handleCheckout = (branch: string) => { if (branch === currentBranch) { setIsOpen(false); return; } setRecentBranches(rememberRecentBranch(directory, branch)); onCheckout(branch); setIsOpen(false); setSearch(''); }; const handleShowCreate = () => { setShowCreate(true); setTimeout(() => createInputRef.current?.focus(), 50); }; const handleCreate = async () => { if (!sanitizedNewBranch || isCreating) return; // If multiple remotes, show remote selection first if (hasMultipleRemotes) { setShowRemoteSelect(true); return; } // Single or no remote - proceed directly setIsCreating(true); try { await onCreate(sanitizedNewBranch, remotes[0]); setNewBranchName(''); setShowCreate(false); setIsOpen(false); } finally { setIsCreating(false); } }; const handleSelectRemote = async (remote: GitRemote) => { if (!sanitizedNewBranch || isCreating) return; setIsCreating(true); try { await onCreate(sanitizedNewBranch, remote); setNewBranchName(''); setShowCreate(false); setShowRemoteSelect(false); setIsOpen(false); } finally { setIsCreating(false); } }; const handleBackFromRemoteSelect = () => { setShowRemoteSelect(false); }; const handleCancelCreate = () => { setNewBranchName(''); setShowCreate(false); setShowRemoteSelect(false); }; React.useEffect(() => { if (!isOpen) { setSearch(''); setShowCreate(false); setShowRemoteSelect(false); setNewBranchName(''); } }, [isOpen]); React.useEffect(() => { if (!directory) return; setRecentBranches(currentBranch ? rememberRecentBranch(directory, currentBranch) : getRecentBranches(directory)); }, [currentBranch, directory]); React.useEffect(() => { if (!isOpen) return; const branches = recentBranches.filter((branch) => localBranches.includes(branch)).slice(0, 5); if (branches.length === 0) return setUnpushedCounts({}); let cancelled = false; getGitUnpushedBranchCounts(directory, branches) .then(({ counts }) => { if (!cancelled) setUnpushedCounts(counts); }) .catch(() => { if (!cancelled) setUnpushedCounts({}); }); return () => { cancelled = true; }; }, [directory, isOpen, localBranches, recentBranches]); if (isMobile) { const recentLocalBranches = recentBranches.filter((branch) => localBranches.includes(branch)); const renderBranch = (branch: string, remote = false) => { const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0); const aheadLabel = ahead === 1 ? t('gitView.branch.unpushedSingle') : t('gitView.branch.unpushedPlural', { count: ahead }); return ( ); }; return ( <> setIsOpen(false)} >
setSearch(event.target.value)} placeholder={t('gitView.branch.searchPlaceholder')} className="h-9 w-full rounded-lg border border-border bg-transparent px-3 typography-meta outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary" /> {switchBlockedNotice ? (
) : null} {recentLocalBranches.length > 0 ? (

{t('gitView.branch.recentBranches')}

{recentLocalBranches.map((branch) => renderBranch(branch))}
) : null}

{t('gitView.branch.localBranches')}

{filteredLocal.map((branch) => renderBranch(branch))}

{t('gitView.branch.remoteBranches')}

{filteredRemote.map((branch) => renderBranch(branch, true))}
); } return ( {t('gitView.branch.currentBranchTooltip')} {/* Filtering and ordering are owned by rankByQuery above; cmdk's own filter would re-filter and reorder the already-ranked rows. */} {switchBlockedNotice ? (
) : null} {t('gitView.branch.empty')} {showRemoteSelect ? ( // Remote selection step
{t('gitView.branch.pushToPrefix')} {sanitizedNewBranch} {t('gitView.branch.pushToSuffix')}
{remotes.map((remote) => ( ))}
) : !showCreate ? ( {t('gitView.branch.create')} ) : (
setNewBranchName(e.target.value)} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { stopDropdownTypeahead(e); if (e.key === 'Enter') { e.preventDefault(); handleCreate(); } else if (e.key === 'Escape') { e.preventDefault(); handleCancelCreate(); } }} className="flex-1 min-w-0 bg-transparent typography-meta outline-none placeholder:text-muted-foreground" />
)}
{recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? ( <> {recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => ( handleCheckout(branch)}> {branch} {(() => { const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0); const aheadLabel = ahead === 1 ? t('gitView.branch.unpushedSingle') : t('gitView.branch.unpushedPlural', { count: ahead }); return ahead > 0 ? ( ) : null; })()} {currentBranch === branch ? {t('gitView.branch.currentBadge')} : null} ))} ) : null} {filteredLocal.map((branch) => ( handleCheckout(branch)} > {branch} {(branchInfo?.[branch]?.ahead || branchInfo?.[branch]?.behind) && ( {branchInfo[branch].ahead || 0} ahead ยท{' '} {branchInfo[branch].behind || 0} behind )} {currentBranch === branch && ( {t('gitView.branch.currentBadge')} )} ))} {filteredLocal.length === 0 && ( {t('gitView.branch.noLocalBranches')} )} {filteredRemote.map((branch) => ( handleCheckout(branch)} > {branch} ))} {filteredRemote.length === 0 && ( {t('gitView.branch.noRemoteBranches')} )}
); };