import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { AlertCircle, Check, ChevronDown, GitBranch, GitFork, Loader2, Search, } from 'lucide-react' import { sessionsApi, type RepositoryBranchInfo, type RepositoryContextResult, } from '../../api/sessions' import { useTranslation } from '../../i18n' import { DirectoryPicker } from './DirectoryPicker' import { useMobileViewport } from '../../hooks/useMobileViewport' import { isTauriRuntime } from '../../lib/desktopRuntime' import { MobileBottomSheet } from './MobileBottomSheet' type Props = { workDir: string onWorkDirChange: (path: string) => void branch: string | null onBranchChange: (branch: string | null) => void useWorktree: boolean onUseWorktreeChange: (enabled: boolean) => void onLaunchReadyChange?: (ready: boolean) => void disabled?: boolean } const BRANCH_MENU_HEIGHT = 360 const BRANCH_MENU_WIDTH = 390 const WORKTREE_MENU_HEIGHT = 126 const WORKTREE_MENU_WIDTH = 226 const VIEWPORT_GUTTER = 12 function stateMessage(context: RepositoryContextResult | null, error: string | null) { if (error) return error if (!context) return null if (context.state === 'not_git_repo') return null if (context.state === 'missing_workdir') return 'missing' if (context.state === 'error') return context.error || 'error' return null } export function RepositoryLaunchControls({ workDir, onWorkDirChange, branch, onBranchChange, useWorktree, onUseWorktreeChange, onLaunchReadyChange, disabled = false, }: Props) { const t = useTranslation() const isMobileBrowser = useMobileViewport() && !isTauriRuntime() const [context, setContext] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [branchMenuOpen, setBranchMenuOpen] = useState(false) const [branchFilter, setBranchFilter] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null) const [worktreeMenuOpen, setWorktreeMenuOpen] = useState(false) const [worktreeMenuPos, setWorktreeMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null) const rootRef = useRef(null) const branchButtonRef = useRef(null) const worktreeButtonRef = useRef(null) const menuRef = useRef(null) const worktreeMenuRef = useRef(null) const searchRef = useRef(null) const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) const searchInputId = useId() const listboxId = useId() const worktreeListboxId = useId() const updateMenuPos = useCallback(() => { if (!branchButtonRef.current) return const rect = branchButtonRef.current.getBoundingClientRect() const spaceAbove = rect.top const spaceBelow = window.innerHeight - rect.bottom const direction = spaceBelow >= BRANCH_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up' const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - BRANCH_MENU_WIDTH - VIEWPORT_GUTTER) setMenuPos({ top: direction === 'down' ? rect.bottom + 6 : rect.top - 6, left: Math.min(Math.max(rect.left, VIEWPORT_GUTTER), maxLeft), direction, }) }, []) const updateWorktreeMenuPos = useCallback(() => { if (!worktreeButtonRef.current) return const rect = worktreeButtonRef.current.getBoundingClientRect() const spaceAbove = rect.top const spaceBelow = window.innerHeight - rect.bottom const direction = spaceBelow >= WORKTREE_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up' const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - WORKTREE_MENU_WIDTH - VIEWPORT_GUTTER) setWorktreeMenuPos({ top: direction === 'down' ? rect.bottom + 6 : rect.top - 6, left: Math.min(Math.max(rect.left, VIEWPORT_GUTTER), maxLeft), direction, }) }, []) useEffect(() => { if (!workDir) { setContext(null) setError(null) setLoading(false) onBranchChange(null) return } let cancelled = false setLoading(true) setError(null) sessionsApi.getRepositoryContext(workDir) .then((result) => { if (cancelled) return setContext(result) }) .catch((err) => { if (cancelled) return setContext(null) setError(err instanceof Error ? err.message : String(err)) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [workDir, onBranchChange]) useEffect(() => { if (context?.state !== 'ok') { if (context && branch !== null) onBranchChange(null) return } const branchExists = branch && context.branches.some((candidate) => candidate.name === branch) if (branchExists) return const fallbackBranch = [ context.currentBranch, context.defaultBranch, context.branches[0]?.name, ].find((name) => name && context.branches.some((candidate) => candidate.name === name)) onBranchChange(fallbackBranch || null) }, [branch, context, onBranchChange]) useEffect(() => { if (!branchMenuOpen && !worktreeMenuOpen) return const handleClick = (event: MouseEvent) => { const target = event.target as Node if (rootRef.current?.contains(target)) return if (menuRef.current?.contains(target)) return if (worktreeMenuRef.current?.contains(target)) return setBranchMenuOpen(false) setWorktreeMenuOpen(false) } const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return event.preventDefault() setBranchMenuOpen(false) setWorktreeMenuOpen(false) } document.addEventListener('mousedown', handleClick) document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('mousedown', handleClick) document.removeEventListener('keydown', handleKeyDown) } }, [branchMenuOpen, worktreeMenuOpen]) useEffect(() => { if (!branchMenuOpen) return updateMenuPos() window.addEventListener('scroll', updateMenuPos, true) window.addEventListener('resize', updateMenuPos) requestAnimationFrame(() => searchRef.current?.focus()) return () => { window.removeEventListener('scroll', updateMenuPos, true) window.removeEventListener('resize', updateMenuPos) } }, [branchMenuOpen, updateMenuPos]) useEffect(() => { if (!worktreeMenuOpen) return updateWorktreeMenuPos() window.addEventListener('scroll', updateWorktreeMenuPos, true) window.addEventListener('resize', updateWorktreeMenuPos) return () => { window.removeEventListener('scroll', updateWorktreeMenuPos, true) window.removeEventListener('resize', updateWorktreeMenuPos) } }, [worktreeMenuOpen, updateWorktreeMenuPos]) useEffect(() => { setSelectedIndex(0) }, [branchFilter]) useEffect(() => { const activeItem = branchMenuOpen ? itemRefs.current[selectedIndex] : null activeItem?.scrollIntoView({ block: 'nearest' }) }, [branchMenuOpen, selectedIndex]) const selectedBranch = useMemo(() => { if (context?.state !== 'ok') return null return context.branches.find((candidate) => candidate.name === branch) ?? null }, [branch, context]) const filteredBranches = useMemo(() => { if (context?.state !== 'ok') return [] const query = branchFilter.trim().toLowerCase() if (!query) return context.branches return context.branches.filter((candidate) => ( candidate.name.toLowerCase().includes(query) || candidate.remoteRef?.toLowerCase().includes(query) || candidate.worktreePath?.toLowerCase().includes(query) )) }, [branchFilter, context]) const warningMessage = useMemo(() => { if (context?.state !== 'ok' || !selectedBranch || useWorktree) return null if (selectedBranch.name !== context.currentBranch && context.dirty) { return t('repoLaunch.dirtyWarning') } if (selectedBranch.name !== context.currentBranch && selectedBranch.checkedOut) { return t('repoLaunch.checkedOutWarning') } return null }, [context, selectedBranch, t, useWorktree]) const selectBranch = (candidate: RepositoryBranchInfo) => { onBranchChange(candidate.name) setBranchMenuOpen(false) setBranchFilter('') } const selectWorktreeMode = (enabled: boolean) => { onUseWorktreeChange(enabled) setWorktreeMenuOpen(false) } const handleBranchKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'ArrowDown') { event.preventDefault() setSelectedIndex((prev) => Math.min(prev + 1, Math.max(filteredBranches.length - 1, 0))) return } if (event.key === 'ArrowUp') { event.preventDefault() setSelectedIndex((prev) => Math.max(prev - 1, 0)) return } if (event.key === 'Enter') { event.preventDefault() const candidate = filteredBranches[selectedIndex] if (candidate) selectBranch(candidate) return } if (event.key === 'Escape') { event.preventDefault() setBranchMenuOpen(false) } } const message = stateMessage(context, error) const isGitReady = context?.state === 'ok' const isLaunchReady = !workDir || ( !loading && (!!context || !!error) && ( context?.state !== 'ok' || context.branches.length === 0 || !!selectedBranch ) ) useEffect(() => { onLaunchReadyChange?.(isLaunchReady) }, [isLaunchReady, onLaunchReadyChange]) const worktreeLabel = useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent') const workbarButtonClassName = 'group inline-flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50' const branchMenuClassName = isMobileBrowser ? 'max-h-[72dvh] overflow-hidden rounded-t-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_-18px_48px_rgba(54,35,28,0.2)]' : 'w-[390px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]' const branchMenuStyle = isMobileBrowser ? { position: 'fixed' as const, left: 12, right: 12, bottom: 'calc(env(safe-area-inset-bottom) + 84px)', zIndex: 9999, } : { position: 'fixed' as const, left: menuPos?.left, ...(menuPos?.direction === 'down' ? { top: menuPos.top } : { bottom: window.innerHeight - (menuPos?.top ?? 0) }), zIndex: 9999, } const worktreeMenuClassName = isMobileBrowser ? 'overflow-hidden rounded-t-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[0_-18px_48px_rgba(54,35,28,0.2)]' : 'w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]' const worktreeMenuStyle = isMobileBrowser ? { position: 'fixed' as const, left: 12, right: 12, bottom: 'calc(env(safe-area-inset-bottom) + 84px)', zIndex: 9999, } : { position: 'fixed' as const, left: worktreeMenuPos?.left, ...(worktreeMenuPos?.direction === 'down' ? { top: worktreeMenuPos.top } : { bottom: window.innerHeight - (worktreeMenuPos?.top ?? 0) }), zIndex: 9999, } return (
{loading && workDir && !isMobileBrowser && (
{t('common.loading')}
)} {isGitReady && ( <>
{message && workDir && (
{message === 'missing' ? t('repoLaunch.missingWorkdir') : message}
)} {warningMessage && (
{warningMessage}
)} {branchMenuOpen && menuPos && ( isMobileBrowser ? ( setBranchMenuOpen(false)} title={t('repoLaunch.selectBranch')} closeLabel={t('tabs.close')} panelRef={menuRef} headerExtra={(
setBranchFilter(event.target.value)} onKeyDown={handleBranchKeyDown} aria-controls={listboxId} aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined} placeholder={t('repoLaunch.searchBranch')} className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" />
)} >
{filteredBranches.length === 0 ? (
{t('repoLaunch.noBranchMatch')}
) : filteredBranches.map((candidate, index) => { const isSelected = candidate.name === selectedBranch?.name return ( ) })}
) : createPortal(
setBranchFilter(event.target.value)} onKeyDown={handleBranchKeyDown} aria-controls={listboxId} aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined} placeholder={t('repoLaunch.searchBranch')} className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" />
{filteredBranches.length === 0 ? (
{t('repoLaunch.noBranchMatch')}
) : filteredBranches.map((candidate, index) => { const isSelected = candidate.name === selectedBranch?.name return ( ) })}
, document.body, ) )} {worktreeMenuOpen && worktreeMenuPos && ( isMobileBrowser ? ( setWorktreeMenuOpen(false)} title={t('repoLaunch.selectWorktree')} closeLabel={t('tabs.close')} panelRef={worktreeMenuRef} contentClassName="py-2" >
) : createPortal(
, document.body, ) )}
) }