import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react' import { ApiError } from '../../api/client' import { filesystemApi } from '../../api/filesystem' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' type DirEntry = { name: string path: string isDirectory: boolean relativePath?: string } export type FileSearchMenuHandle = { handleKeyDown: (e: KeyboardEvent) => void } type Props = { cwd: string filter?: string compact?: boolean onSelect: (path: string, relativePath: string, isDirectory: boolean) => void onNavigate?: (relativePath: string) => void } export const FileSearchMenu = forwardRef(({ cwd, filter = '', compact = false, onSelect, onNavigate }, ref) => { const t = useTranslation() const [entries, setEntries] = useState([]) const [errorMessage, setErrorMessage] = useState(null) const [errorKey, setErrorKey] = useState(null) const [currentPath, setCurrentPath] = useState(cwd) const [isSearchMode, setIsSearchMode] = useState(false) const [loading, setLoading] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const [rootPath, setRootPath] = useState(cwd) const listRef = useRef(null) const currentPathRef = useRef(cwd) const rootPathRef = useRef(cwd) const getErrorState = (error: unknown): { errorKey: TranslationKey | null; errorMessage: string | null } => { if (error instanceof ApiError) { if (error.status === 403) { return { errorKey: 'fileSearch.accessDenied', errorMessage: null } } const apiMessage = typeof error.body === 'string' ? error.body : typeof error.body === 'object' && error.body !== null && 'error' in error.body && typeof error.body.error === 'string' ? error.body.error : null if (apiMessage) { return { errorKey: null, errorMessage: apiMessage } } } return { errorKey: 'fileSearch.loadFailed', errorMessage: null } } const getRelativePath = useCallback((entry: DirEntry) => { const basePath = (cwd || rootPath).replace(/\/+$/, '') if (entry.path.startsWith(`${basePath}/`)) return entry.path.slice(basePath.length + 1) if (entry.relativePath) return entry.relativePath return entry.name }, [cwd, rootPath]) const getDisplayPath = useCallback((entry: DirEntry) => { const relativePath = getRelativePath(entry).replace(/\\/g, '/') if (!entry.isDirectory) return relativePath return `${relativePath.replace(/\/+$/, '')}/` }, [getRelativePath]) const selectEntry = useCallback((entry: DirEntry) => { onSelect(entry.path, getRelativePath(entry), entry.isDirectory) }, [getRelativePath, onSelect]) const parseFilter = (rawFilter: string): { navigateTo: string; searchQuery: string } => { const trimmed = rawFilter.trim() const basePath = (cwd || rootPathRef.current).replace(/\/+$/, '') if (!trimmed) return { navigateTo: basePath, searchQuery: '' } if (trimmed.endsWith('/')) { if (!basePath) return { navigateTo: '', searchQuery: trimmed.replace(/\/+$/, '') } return { navigateTo: `${basePath}/${trimmed.replace(/\/+$/, '')}`, searchQuery: '' } } return { navigateTo: basePath, searchQuery: trimmed } } // Load directory entries const loadDir = useCallback(async (dirPath: string, searchQuery: string) => { setLoading(true) setErrorMessage(null) setErrorKey(null) // Only update currentPath if actually navigating to a different directory if (dirPath !== currentPathRef.current) { setCurrentPath(dirPath) currentPathRef.current = dirPath } try { if (searchQuery) { setIsSearchMode(true) const result = await filesystemApi.search(searchQuery, dirPath) setCurrentPath(result.currentPath) currentPathRef.current = result.currentPath if (!cwd) { setRootPath(result.currentPath) rootPathRef.current = result.currentPath } setEntries(result.entries) } else { setIsSearchMode(false) const result = await filesystemApi.browse(dirPath, { includeFiles: true }) setCurrentPath(result.currentPath) currentPathRef.current = result.currentPath if (!cwd) { setRootPath(result.currentPath) rootPathRef.current = result.currentPath } setEntries(result.entries) } setSelectedIndex(0) } catch (error) { setEntries([]) const nextError = getErrorState(error) setErrorKey(nextError.errorKey) setErrorMessage(nextError.errorMessage) } setLoading(false) }, [cwd]) const navigateEntry = useCallback((entry: DirEntry) => { if (!entry.isDirectory) return const relativePath = `${getRelativePath(entry).replace(/\/+$/, '')}/` void loadDir(entry.path, '') onNavigate?.(relativePath) }, [getRelativePath, loadDir, onNavigate]) // Keep the explicit workspace root stable when the host session changes. useEffect(() => { currentPathRef.current = cwd rootPathRef.current = cwd setRootPath(cwd) setCurrentPath(cwd) }, [cwd]) // Initial load: parse filter path and navigate accordingly useEffect(() => { const { navigateTo, searchQuery } = parseFilter(filter) void loadDir(navigateTo, searchQuery) }, [cwd, filter, loadDir]) // Keyboard navigation handler exposed via ref const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault() setSelectedIndex((prev) => Math.min(prev + 1, entries.length - 1)) return } if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex((prev) => Math.max(prev - 1, 0)) return } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault() const selected = entries[selectedIndex] if (selected) { selectEntry(selected) } return } if (e.key === 'ArrowRight') { const selected = entries[selectedIndex] if (selected?.isDirectory) { e.preventDefault() navigateEntry(selected) } return } // eslint-disable-next-line react-hooks/exhaustive-deps }, [entries, selectedIndex, selectEntry, navigateEntry]) useImperativeHandle(ref, () => ({ handleKeyDown }), [handleKeyDown]) // Scroll selected into view useEffect(() => { const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`) as HTMLButtonElement | null el?.scrollIntoView({ block: 'nearest' }) }, [selectedIndex]) // Build breadcrumb segments from current path relative to cwd const breadcrumbs: string[] = [] if (currentPath !== cwd && currentPath.startsWith(cwd)) { const rel = currentPath.slice(cwd.length).replace(/^\//, '') if (rel) breadcrumbs.push(...rel.split('/')) } const renderEntry = (entry: DirEntry, index: number) => { const relativePath = getRelativePath(entry) const displayPath = getDisplayPath(entry) const parentPath = relativePath.split('/').slice(0, -1).join('/') const selected = selectedIndex === index return (
setSelectedIndex(index)} > {entry.isDirectory ? ( ) : null}
) } return (
e.stopPropagation()} > {/* Header with path */}
folder_open {cwd.split('/').pop() || cwd} {breadcrumbs.map((seg, i) => ( / {seg} ))} {isSearchMode && filter ? ( @{filter} ) : null} {loading && ( progress_activity )}
{/* File list */}
{loading && entries.length === 0 ? (
{t('fileSearch.searching')}
) : (errorKey || errorMessage) ? (
{errorKey ? t(errorKey) : errorMessage}
) : entries.length === 0 ? (
{filter ? t('fileSearch.noMatch') : t('fileSearch.noFiles')}
) : ( <> {entries.map(renderEntry)} )}
{/* Footer hint */} {!compact ? (
↑↓ {t('fileSearch.navigate')} Enter {t('fileSearch.select')} {t('fileSearch.open')} Esc {t('fileSearch.close')}
) : null}
) }) FileSearchMenu.displayName = 'FileSearchMenu'