Download desktop/src/components/shared/RepositoryLaunchControls.tsx from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 29.5 kB
-
https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/shared/RepositoryLaunchControls.tsx
- Command line
-
hf download hf://chenbhao/codev@e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/shared/RepositoryLaunchControls.tsx
-
curl -L -o RepositoryLaunchControls.tsx https://huggingface.co/chenbhao/codev/resolve/e40073cc959cd4729bf4009662bca374c5c4e266/desktop/src/components/shared/RepositoryLaunchControls.tsx
29.5 kB
| 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 | |
| placement?: 'standalone' | 'composer' | |
| } | |
| 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, | |
| placement = 'standalone', | |
| }: Props) { | |
| const t = useTranslation() | |
| const isMobileBrowser = useMobileViewport() && !isTauriRuntime() | |
| const isComposerPlacement = placement === 'composer' && !isMobileBrowser | |
| const [context, setContext] = useState<RepositoryContextResult | null>(null) | |
| const [loading, setLoading] = useState(false) | |
| const [error, setError] = useState<string | null>(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<HTMLDivElement>(null) | |
| const branchButtonRef = useRef<HTMLButtonElement>(null) | |
| const worktreeButtonRef = useRef<HTMLButtonElement>(null) | |
| const menuRef = useRef<HTMLDivElement>(null) | |
| const worktreeMenuRef = useRef<HTMLDivElement>(null) | |
| const searchRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => { | |
| 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 ( | |
| <div ref={rootRef} className={`flex min-w-0 flex-col ${isMobileBrowser ? 'gap-0' : isComposerPlacement ? 'gap-1' : 'gap-2'}`}> | |
| <div className={`flex min-w-0 items-center justify-start gap-x-1.5 gap-y-1 overflow-hidden border-t border-[var(--color-border-separator)] ${ | |
| isMobileBrowser | |
| ? 'min-h-[52px] flex-wrap rounded-none bg-[var(--color-surface-container-lowest)] px-3 py-2 shadow-none' | |
| : isComposerPlacement | |
| ? 'min-h-[44px] flex-nowrap bg-transparent px-4 py-2' | |
| : 'min-h-[48px] flex-nowrap rounded-b-xl bg-[var(--color-surface-container-low)] px-4 py-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]' | |
| }`}> | |
| <DirectoryPicker value={workDir} onChange={onWorkDirChange} variant="workbar" isGitProject={isGitReady} /> | |
| {loading && workDir && !isMobileBrowser && ( | |
| <div className="inline-flex h-9 items-center gap-1.5 rounded-[7px] px-2.5 text-[13px] text-[var(--color-text-secondary)]"> | |
| <Loader2 size={14} className="shrink-0 animate-spin" /> | |
| <span>{t('common.loading')}</span> | |
| </div> | |
| )} | |
| {isGitReady && ( | |
| <> | |
| <span className="hidden h-4 w-px shrink-0 bg-[var(--color-border-separator)] opacity-70 sm:block" aria-hidden="true" /> | |
| <button | |
| ref={branchButtonRef} | |
| type="button" | |
| disabled={disabled || loading || context.branches.length === 0} | |
| aria-haspopup="listbox" | |
| aria-expanded={branchMenuOpen} | |
| aria-label={`${t('repoLaunch.selectBranch')}: ${selectedBranch?.name || t('repoLaunch.noBranch')}`} | |
| title={selectedBranch?.name || t('repoLaunch.noBranch')} | |
| onClick={() => { | |
| setBranchMenuOpen((prev) => !prev) | |
| setWorktreeMenuOpen(false) | |
| setBranchFilter('') | |
| }} | |
| className={`${workbarButtonClassName} ${isMobileBrowser ? 'max-w-[160px] shrink-0 bg-[var(--color-surface-container)]' : 'max-w-[260px] shrink'}`} | |
| > | |
| <GitBranch size={17} className="shrink-0 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)]" /> | |
| <span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]"> | |
| {selectedBranch?.name || t('repoLaunch.noBranch')} | |
| </span> | |
| <ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| </button> | |
| <button | |
| ref={worktreeButtonRef} | |
| type="button" | |
| disabled={disabled} | |
| aria-haspopup="listbox" | |
| aria-expanded={worktreeMenuOpen} | |
| aria-controls={worktreeMenuOpen ? worktreeListboxId : undefined} | |
| aria-label={`${t('repoLaunch.selectWorktree')}: ${worktreeLabel}`} | |
| title={worktreeLabel} | |
| onClick={() => { | |
| setWorktreeMenuOpen((prev) => !prev) | |
| setBranchMenuOpen(false) | |
| }} | |
| className={`${workbarButtonClassName} shrink-0 ${isMobileBrowser ? 'bg-[var(--color-surface-container)]' : ''} ${ | |
| useWorktree | |
| ? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]' | |
| : '' | |
| }`} | |
| > | |
| <GitFork size={17} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| <span className="min-w-0 truncate"> | |
| {worktreeLabel} | |
| </span> | |
| <ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| {message && workDir && ( | |
| <div className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-text-tertiary)]"> | |
| <AlertCircle size={13} className="shrink-0" /> | |
| <span> | |
| {message === 'missing' | |
| ? t('repoLaunch.missingWorkdir') | |
| : message} | |
| </span> | |
| </div> | |
| )} | |
| {warningMessage && ( | |
| <div className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-warning)]"> | |
| <AlertCircle size={13} className="shrink-0" /> | |
| <span>{warningMessage}</span> | |
| </div> | |
| )} | |
| {branchMenuOpen && menuPos && ( | |
| isMobileBrowser ? ( | |
| <MobileBottomSheet | |
| open={branchMenuOpen} | |
| onClose={() => setBranchMenuOpen(false)} | |
| title={t('repoLaunch.selectBranch')} | |
| closeLabel={t('tabs.close')} | |
| panelRef={menuRef} | |
| headerExtra={( | |
| <div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"> | |
| <Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| <input | |
| id={searchInputId} | |
| ref={searchRef} | |
| value={branchFilter} | |
| onChange={(event) => 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)]" | |
| /> | |
| </div> | |
| )} | |
| > | |
| <div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="py-1"> | |
| {filteredBranches.length === 0 ? ( | |
| <div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]"> | |
| {t('repoLaunch.noBranchMatch')} | |
| </div> | |
| ) : filteredBranches.map((candidate, index) => { | |
| const isSelected = candidate.name === selectedBranch?.name | |
| return ( | |
| <button | |
| key={candidate.name} | |
| id={`${listboxId}-option-${index}`} | |
| ref={(el) => { itemRefs.current[index] = el }} | |
| type="button" | |
| role="option" | |
| aria-selected={isSelected} | |
| onMouseEnter={() => setSelectedIndex(index)} | |
| onClick={() => selectBranch(candidate)} | |
| className={`flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${ | |
| index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| > | |
| <span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} /> | |
| <GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]"> | |
| {candidate.name} | |
| </span> | |
| <span className="block truncate text-[11px] text-[var(--color-text-tertiary)]"> | |
| {candidate.current | |
| ? t('repoLaunch.currentBranch') | |
| : candidate.checkedOut | |
| ? t('repoLaunch.checkedOut') | |
| : candidate.remote && !candidate.local | |
| ? candidate.remoteRef || t('repoLaunch.remoteBranch') | |
| : t('repoLaunch.localBranch')} | |
| </span> | |
| </span> | |
| {isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </MobileBottomSheet> | |
| ) : createPortal( | |
| <div | |
| ref={menuRef} | |
| className={branchMenuClassName} | |
| style={branchMenuStyle} | |
| > | |
| <div className="border-b border-[var(--color-border)] p-3"> | |
| <label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]"> | |
| {t('repoLaunch.selectBranch')} | |
| </label> | |
| <div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"> | |
| <Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| <input | |
| id={searchInputId} | |
| ref={searchRef} | |
| value={branchFilter} | |
| onChange={(event) => 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)]" | |
| /> | |
| </div> | |
| </div> | |
| <div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="max-h-[280px] overflow-y-auto py-1"> | |
| {filteredBranches.length === 0 ? ( | |
| <div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]"> | |
| {t('repoLaunch.noBranchMatch')} | |
| </div> | |
| ) : filteredBranches.map((candidate, index) => { | |
| const isSelected = candidate.name === selectedBranch?.name | |
| return ( | |
| <button | |
| key={candidate.name} | |
| id={`${listboxId}-option-${index}`} | |
| ref={(el) => { itemRefs.current[index] = el }} | |
| type="button" | |
| role="option" | |
| aria-selected={isSelected} | |
| onMouseEnter={() => setSelectedIndex(index)} | |
| onClick={() => selectBranch(candidate)} | |
| className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${ | |
| index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| > | |
| <span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} /> | |
| <GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]"> | |
| {candidate.name} | |
| </span> | |
| <span className="block truncate text-[11px] text-[var(--color-text-tertiary)]"> | |
| {candidate.current | |
| ? t('repoLaunch.currentBranch') | |
| : candidate.checkedOut | |
| ? t('repoLaunch.checkedOut') | |
| : candidate.remote && !candidate.local | |
| ? candidate.remoteRef || t('repoLaunch.remoteBranch') | |
| : t('repoLaunch.localBranch')} | |
| </span> | |
| </span> | |
| {isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </div>, | |
| document.body, | |
| ) | |
| )} | |
| {worktreeMenuOpen && worktreeMenuPos && ( | |
| isMobileBrowser ? ( | |
| <MobileBottomSheet | |
| open={worktreeMenuOpen} | |
| onClose={() => setWorktreeMenuOpen(false)} | |
| title={t('repoLaunch.selectWorktree')} | |
| closeLabel={t('tabs.close')} | |
| panelRef={worktreeMenuRef} | |
| contentClassName="py-2" | |
| > | |
| <div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}> | |
| <button | |
| type="button" | |
| role="option" | |
| aria-selected={!useWorktree} | |
| onClick={() => selectWorktreeMode(false)} | |
| className={`flex min-h-[52px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${ | |
| !useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| > | |
| <GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]"> | |
| {t('repoLaunch.worktreeCurrent')} | |
| </span> | |
| </span> | |
| {!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />} | |
| </button> | |
| <button | |
| type="button" | |
| role="option" | |
| aria-selected={useWorktree} | |
| onClick={() => selectWorktreeMode(true)} | |
| className={`flex min-h-[52px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${ | |
| useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| > | |
| <GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]"> | |
| {t('repoLaunch.worktreeIsolated')} | |
| </span> | |
| </span> | |
| {useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />} | |
| </button> | |
| </div> | |
| </MobileBottomSheet> | |
| ) : createPortal( | |
| <div | |
| ref={worktreeMenuRef} | |
| className={worktreeMenuClassName} | |
| style={worktreeMenuStyle} | |
| > | |
| <div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}> | |
| <button | |
| type="button" | |
| role="option" | |
| aria-selected={!useWorktree} | |
| onClick={() => selectWorktreeMode(false)} | |
| className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${ | |
| !useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| > | |
| <GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]"> | |
| {t('repoLaunch.worktreeCurrent')} | |
| </span> | |
| </span> | |
| {!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />} | |
| </button> | |
| <button | |
| type="button" | |
| role="option" | |
| aria-selected={useWorktree} | |
| onClick={() => selectWorktreeMode(true)} | |
| className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${ | |
| useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]' | |
| }`} | |
| > | |
| <GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" /> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]"> | |
| {t('repoLaunch.worktreeIsolated')} | |
| </span> | |
| </span> | |
| {useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />} | |
| </button> | |
| </div> | |
| </div>, | |
| document.body, | |
| ) | |
| )} | |
| </div> | |
| ) | |
| } | |