import { useState, useEffect, useRef, useCallback } from 'react' import { createPortal } from 'react-dom' import { sessionsApi, type RecentProject } from '../../api/sessions' import { filesystemApi } from '../../api/filesystem' import { useTranslation } from '../../i18n' import { useMobileViewport } from '../../hooks/useMobileViewport' import { MobileBottomSheet } from './MobileBottomSheet' type Props = { value: string onChange: (path: string) => void variant?: 'chip' | 'workbar' isGitProject?: boolean } type DirEntry = { name: string; path: string; isDirectory: boolean } // Module-level cache for recent projects (shared across instances, survives re-renders) let cachedProjects: RecentProject[] | null = null let cacheTimestamp = 0 const CACHE_TTL = 30_000 // 30s const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/' const DROPDOWN_WIDTH = 400 const DROPDOWN_VIEWPORT_MARGIN = 12 const DROPDOWN_HEIGHT = 380 // approximate max height function isTauriRuntime() { return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window) } function projectNameFromPath(filePath: string) { const displayRoot = filePath.includes(DESKTOP_WORKTREE_MARKER) ? filePath.slice(0, filePath.indexOf(DESKTOP_WORKTREE_MARKER)) : filePath return displayRoot.split('/').filter(Boolean).pop() || filePath } export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProject = false }: Props) { const t = useTranslation() const [isOpen, setIsOpen] = useState(false) const [mode, setMode] = useState<'recent' | 'browse'>('recent') const [projects, setProjects] = useState([]) const [browseEntries, setBrowseEntries] = useState([]) const [browsePath, setBrowsePath] = useState('') const [browseParent, setBrowseParent] = useState('') const [loading, setLoading] = useState(false) const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; width: number; direction: 'up' | 'down' } | null>(null) const ref = useRef(null) const triggerRef = useRef(null) const isMobileBrowser = useMobileViewport() && !isTauriRuntime() const dropdownRef = useRef(null) const updateDropdownPos = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() const spaceAbove = rect.top const spaceBelow = window.innerHeight - rect.bottom const direction = spaceBelow >= DROPDOWN_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up' const width = Math.min(DROPDOWN_WIDTH, Math.max(0, window.innerWidth - DROPDOWN_VIEWPORT_MARGIN * 2)) const maxLeft = Math.max(DROPDOWN_VIEWPORT_MARGIN, window.innerWidth - width - DROPDOWN_VIEWPORT_MARGIN) const left = Math.min(Math.max(rect.left, DROPDOWN_VIEWPORT_MARGIN), maxLeft) setDropdownPos({ top: direction === 'down' ? rect.bottom + 4 : rect.top - 4, left, width, direction, }) }, []) // Close on outside click (checks both trigger and portal dropdown) useEffect(() => { if (!isOpen) return const handleClick = (e: MouseEvent) => { const target = e.target as Node if (ref.current?.contains(target)) return if (dropdownRef.current?.contains(target)) return setIsOpen(false) } document.addEventListener('mousedown', handleClick) return () => document.removeEventListener('mousedown', handleClick) }, [isOpen]) // Recalculate position on scroll/resize while open useEffect(() => { if (!isOpen) return updateDropdownPos() window.addEventListener('scroll', updateDropdownPos, true) window.addEventListener('resize', updateDropdownPos) return () => { window.removeEventListener('scroll', updateDropdownPos, true) window.removeEventListener('resize', updateDropdownPos) } }, [isOpen, updateDropdownPos]) // Load recent projects when opened (with client-side cache) useEffect(() => { if (!isOpen || mode !== 'recent') return // Use cache if fresh if (cachedProjects && Date.now() - cacheTimestamp < CACHE_TTL) { setProjects(cachedProjects) return } setLoading(true) sessionsApi.getRecentProjects() .then(({ projects: p }) => { cachedProjects = p cacheTimestamp = Date.now() setProjects(p) }) .catch(() => setProjects([])) .finally(() => setLoading(false)) }, [isOpen, mode]) const loadBrowseDir = async (path?: string) => { setLoading(true) try { const result = await filesystemApi.browse(path) setBrowsePath(result.currentPath) setBrowseParent(result.parentPath) setBrowseEntries(result.entries) } catch { /* API not available */ } setLoading(false) } const handleSelect = (path: string) => { onChange(path) setIsOpen(false) setMode('recent') // Invalidate cache so next open reflects the new selection cachedProjects = null } const handleChooseFolder = async () => { if (isTauriRuntime()) { // Desktop: native OS folder dialog setIsOpen(false) try { const { open } = await import('@tauri-apps/plugin-dialog') const selected = await open({ directory: true, multiple: false, title: t('dirPicker.chooseProjectFolder'), }) if (selected) onChange(selected) } catch (err) { console.error('[DirectoryPicker] Failed to open folder dialog:', err) } } else { // Web browser: directory tree via backend API setMode('browse') loadBrowseDir(value || undefined) } } // Find selected project info const selectedProject = projects.find((p) => p.realPath === value) const isWorkbar = variant === 'workbar' const selectedLabel = selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value) const showGitIcon = selectedProject?.isGit || isGitProject const triggerClassName = isWorkbar ? 'inline-flex h-9 max-w-full 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' : 'flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]' const emptyTriggerClassName = isWorkbar ? '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' : 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors' const dropdownClassName = 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]' const dropdownStyle = { position: 'fixed' as const, left: dropdownPos?.left, width: dropdownPos?.width, ...(dropdownPos?.direction === 'down' ? { top: dropdownPos.top } : { bottom: window.innerHeight - (dropdownPos?.top ?? 0) }), zIndex: 9999, } const dropdownTitle = mode === 'recent' ? t('dirPicker.recent') : t('dirPicker.chooseProjectFolder') const dropdownContent = mode === 'recent' ? ( <> {!isMobileBrowser && (
{t('dirPicker.recent')}
)}
{loading ? (
{t('common.loading')}
) : projects.length === 0 ? (
{t('dirPicker.noRecent')}
) : ( projects.map((project) => { const isSelected = project.realPath === value return ( ) }) )}
) : ( <>
{browsePath.split('/').filter(Boolean).map((seg, i, arr) => ( / ))}
{loading ? (
{t('common.loading')}
) : ( <> {browseParent && browseParent !== browsePath && ( )} {browseEntries.length === 0 ? (
{t('dirPicker.noSubdirs')}
) : browseEntries.map((entry) => (
))} )}
{browsePath}
) return (
{/* Trigger — shows selected project chip or placeholder */} {value ? ( ) : ( )} {isOpen && dropdownPos && ( isMobileBrowser ? ( setIsOpen(false)} title={dropdownTitle} closeLabel={t('tabs.close')} panelRef={dropdownRef} > {dropdownContent} ) : createPortal(
{dropdownContent}
, document.body, ) )}
) }