import { forwardRef, useState, useEffect, useRef, useCallback, useImperativeHandle } from 'react' import { filesystemApi } from '../../api/filesystem' import { useTranslation } from '../../i18n' type DirEntry = { name: string path: string isDirectory: boolean } export type FileSearchMenuHandle = { handleKeyDown: (e: KeyboardEvent) => void } type Props = { cwd: string filter?: string onSelect: (path: string, relativePath: string) => void } export const FileSearchMenu = forwardRef(({ cwd, filter = '', onSelect }, ref) => { const t = useTranslation() const [entries, setEntries] = useState([]) const [currentPath, setCurrentPath] = useState(cwd) const [loading, setLoading] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const listRef = useRef(null) const currentPathRef = useRef(cwd) // Parse filter: if it contains '/', navigate to that subdir and search the rest // Uses currentPathRef as base so nested paths navigate from current depth const parseFilter = (rawFilter: string): { navigateTo: string; searchQuery: string } => { const base = currentPathRef.current if (!rawFilter || !rawFilter.includes('/')) { return { navigateTo: base, searchQuery: rawFilter } } const lastSlash = rawFilter.lastIndexOf('/') const dirPart = rawFilter.slice(0, lastSlash + 1) const searchPart = rawFilter.slice(lastSlash + 1) const navigateTo = dirPart === '' ? base : `${base}/${dirPart}` return { navigateTo, searchQuery: searchPart } } // Load directory entries const loadDir = useCallback(async (dirPath: string, searchQuery: string) => { setLoading(true) // Only update currentPath if actually navigating to a different directory if (dirPath !== currentPathRef.current) { setCurrentPath(dirPath) currentPathRef.current = dirPath } try { if (searchQuery) { const result = await filesystemApi.search(searchQuery, dirPath) setEntries(result.entries) } else { const result = await filesystemApi.browse(dirPath, { includeFiles: true }) setEntries(result.entries) } setSelectedIndex(0) } catch { setEntries([]) } setLoading(false) }, []) // Initial load: parse filter path and navigate accordingly useEffect(() => { currentPathRef.current = cwd 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() if (entries[selectedIndex]) { onSelect(entries[selectedIndex]!.path, entries[selectedIndex]!.name) } return } // eslint-disable-next-line react-hooks/exhaustive-deps }, [entries, selectedIndex]) 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 dirs = entries.filter((e) => e.isDirectory) const files = entries.filter((e) => !e.isDirectory) return (
e.stopPropagation()} > {/* Header with path */}
folder_open {cwd.split('/').pop() || cwd} {breadcrumbs.map((seg, i) => ( / {seg} ))} {loading && ( progress_activity )}
{/* File list */}
{loading && entries.length === 0 ? (
{t('fileSearch.searching')}
) : entries.length === 0 ? (
{filter ? t('fileSearch.noMatch') : t('fileSearch.noFiles')}
) : ( <> {/* Directories */} {dirs.map((entry, i) => ( ))} {/* Files */} {files.map((entry, i) => { const idx = dirs.length + i return ( ) })} )}
{/* Footer hint */}
↑↓ {t('fileSearch.navigate')} Enter {t('fileSearch.attach')} Esc {t('fileSearch.close')}
) }) FileSearchMenu.displayName = 'FileSearchMenu'