import { useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, ArrowUp, ArrowDown, ArrowUpDown, HardDrive, FolderOpen } from 'lucide-react'; import { toast } from 'sonner'; import { humanFileSize, splitPath } from '../webdav'; import type { EntryInfo } from '../webdav'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { MediaEntry } from './MediaEntry'; import { MarqueeText } from './ui/marquee-text'; import { openLocateDb, searchLocate, isLocateDbLoaded, resetLocateDb, buildLocateDb, type LocateEntry, type ScanPhase, } from '../locate-db'; interface SearchLocalProps { config: any; setConfig: (config: any) => void; onClose: () => void; onOpenFolder: (path: string) => void; } interface SearchResult { name: string; path: string; type: string; size: number; sizeText: string; isDir: boolean; system: string | null; video: string | null; language: string | null; } const DISK_EXTS = new Set(['d64','d71','d81','d82','dnp','t64','tap','g64','nib']); const CART_EXTS = new Set(['crt','bin']); const PRG_EXTS = new Set(['prg','p00']); const LANG_CODES = ['En','De','Fr','Es','It','Nl','Sv','Da','Fi','Pt','Pl','Ru','Ja','Ko','Zh','Cs','Hu','No']; const LANG_NAMES: Record = { En:'English', De:'German', Fr:'French', Es:'Spanish', It:'Italian', Nl:'Dutch', Sv:'Swedish', Da:'Danish', Fi:'Finnish', Pt:'Portuguese', Pl:'Polish', Ru:'Russian', Ja:'Japanese', Ko:'Korean', Zh:'Chinese', Cs:'Czech', Hu:'Hungarian', No:'Norwegian', }; const LANG_RE = new RegExp( '\\((' + LANG_CODES.join('|') + ')(?:[,+](?:' + LANG_CODES.join('|') + '))*\\)', 'i' ); const VIDEO_RE = /\b(PAL(?:[_-]?(?:60|NTSC))?|NTSC(?:[_-]?PAL)?)\b/i; const VIDEO_NORM: Record = { 'PAL': 'PAL', 'PAL60': 'PAL60', 'PAL-60': 'PAL60', 'PAL_60': 'PAL60', 'PAL-NTSC': 'PAL/NTSC', 'PAL_NTSC': 'PAL/NTSC', 'NTSC-PAL': 'PAL/NTSC', 'NTSC_PAL': 'PAL/NTSC', 'NTSC': 'NTSC', }; const SYSTEM_RE = /\b(C[-_]?64|C[-_]?128|VIC[-_]?20|Plus\/?4|C[-_]?16|PET|C[-_]?64DTV)\b/i; const SYSTEM_NORM: Record = { 'C64':'C64','C-64':'C64','C_64':'C64', 'C128':'C128','C-128':'C128','C_128':'C128', 'VIC20':'VIC-20','VIC-20':'VIC-20','VIC_20':'VIC-20', 'PLUS4':'Plus/4','PLUS/4':'Plus/4', 'C16':'C16','C-16':'C16','C_16':'C16', 'PET':'PET', 'C64DTV':'C64DTV','C-64DTV':'C64DTV', }; function fileExtension(p: string): string { const dot = p.lastIndexOf('.'); return dot < 0 ? '' : p.slice(dot + 1).toLowerCase(); } function parseTags(path: string): { system: string | null; video: string | null; language: string | null } { const videoM = path.match(VIDEO_RE); const video = videoM ? (VIDEO_NORM[videoM[1].toUpperCase().replace(/[-_]/g, '-')] ?? videoM[1].toUpperCase()) : null; const sysM = path.match(SYSTEM_RE); const system = sysM ? (SYSTEM_NORM[sysM[1].toUpperCase().replace(/[-_]/g, '')] ?? sysM[1]) : null; const langM = path.match(LANG_RE); const language = langM ? (LANG_NAMES[langM[1]] ?? langM[1]) : null; return { system, video, language }; } function entryToResult(e: LocateEntry): SearchResult { const tags = parseTags(e.path); if (e.is_dir) { return { name: e.name, path: e.path, type: 'DIR', size: 0, sizeText: '—', isDir: true, ...tags }; } const ext = fileExtension(e.name); return { name: e.name, path: e.path, type: ext ? ext.toUpperCase() : 'FILE', size: e.size, sizeText: humanFileSize(e.size), isDir: false, ...tags, }; } function resultToEntry(r: SearchResult): EntryInfo { return { name: r.name, path: r.path, type: r.isDir ? 'folder' : 'file', size: r.size, lastModified: null, contentType: null }; } function TypeBadge({ type, isDir }: { type: string; isDir: boolean }) { if (isDir) return DIR; const ext = type.toLowerCase(); if (DISK_EXTS.has(ext)) return {type}; if (CART_EXTS.has(ext)) return {type}; if (PRG_EXTS.has(ext)) return {type}; return {type}; } function scanPhaseLabel(phase: ScanPhase, value: number): string { if (phase === 'scanning') return `Scanning… ${humanFileSize(value)}`; if (phase === 'building') return `Building… ${value.toLocaleString()} entries`; return `Saving… ${humanFileSize(value)}`; } type SortField = 'name' | 'size'; type SortDir = 'asc' | 'desc'; const _store = { query: '', results: [] as SearchResult[], hasSearched: false, showFilter: false, filterSystem: null as string | null, filterVideo: null as string | null, filterLanguage: null as string | null, sortField: 'name' as SortField, sortDir: 'asc' as SortDir, scrollTop: 0, }; function FilterChips({ label, values, selected, onSelect, }: { label: string; values: string[]; selected: string | null; onSelect: (v: string | null) => void; }) { if (values.length === 0) return null; return (
{label} {values.map(v => ( ))}
); } export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }: SearchLocalProps) { const scrollRef = useRef(null); const [query, setQuery] = useState(() => _store.query); const [isSearching, setIsSearching] = useState(false); const [isScanning, setIsScanning] = useState(false); const [scanPhase, setScanPhase] = useState(null); const [scanValue, setScanValue] = useState(0); const [results, setResults] = useState(() => _store.results); const [hasSearched, setHasSearched] = useState(() => _store.hasSearched); const [mountEntry, setMountEntry] = useState(null); const [actionEntry, setActionEntry] = useState(null); const [showScanConfirm, setShowScanConfirm] = useState(false); const [searchError, setSearchError] = useState(null); // The locate-database load progress is rendered by the SearchPane (above // this panel). The pane pre-fetches the database on mount, so by the time // the user clicks Search the database is almost always ready. const [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready'); const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [filterSystem, setFilterSystem] = useState(() => _store.filterSystem); const [filterVideo, setFilterVideo] = useState(() => _store.filterVideo); const [filterLanguage, setFilterLanguage] = useState(() => _store.filterLanguage); const [sortField, setSortField] = useState(() => _store.sortField); const [sortDir, setSortDir] = useState(() => _store.sortDir); useEffect(() => { if (isLocateDbLoaded()) setDbPhase('ready'); }, []); useEffect(() => { _store.query = query; _store.results = results; _store.hasSearched = hasSearched; _store.showFilter = showFilter; _store.filterSystem = filterSystem; _store.filterVideo = filterVideo; _store.filterLanguage = filterLanguage; _store.sortField = sortField; _store.sortDir = sortDir; }, [query, results, hasSearched, showFilter, filterSystem, filterVideo, filterLanguage, sortField, sortDir]); useEffect(() => { if (_store.scrollTop > 0 && scrollRef.current) { scrollRef.current.scrollTop = _store.scrollTop; } }, []); const mountedPaths = useMemo(() => { const paths = new Set(); for (const d of Object.values(config?.devices?.iec ?? {})) { const dev = d as any; if (!dev?.url) continue; const base = (dev.base_url ?? '').replace(/\/$/, ''); const url = dev.url.startsWith('/') ? dev.url : '/' + dev.url; paths.add(base ? base + url : dev.url); } return paths; }, [config]); const handleSearch = async () => { if (!query.trim()) { toast.error('Please enter a search term'); return; } setIsSearching(true); setHasSearched(true); setSearchError(null); setFilterSystem(null); setFilterVideo(null); setFilterLanguage(null); try { // The SearchPane pre-fetches the locate database when it opens, so by // the time the user clicks Search the database is almost always ready. // This branch only fires if the user types immediately on mount, before // the pane's pre-fetch has resolved. The pane-level progress bar above // the panel already shows the transfer; we don't need a second bar. if (!isLocateDbLoaded()) { await openLocateDb(); } const needle = query.trim(); const entries = searchLocate(needle); const hasWildcard = /[*?]/.test(needle); entries.sort((a, b) => { if (!hasWildcard) { const lower = needle.toLowerCase(); const aStart = a.name.toLowerCase().startsWith(lower) ? 0 : 1; const bStart = b.name.toLowerCase().startsWith(lower) ? 0 : 1; if (aStart !== bStart) return aStart - bStart; } return a.path.length - b.path.length; }); setResults(entries.map(entryToResult)); } catch (e: any) { setSearchError(e?.message ?? 'Search failed'); resetLocateDb(); setDbPhase('idle'); } finally { setIsSearching(false); } }; const handleScan = async () => { setIsScanning(true); setScanPhase('scanning'); setScanValue(0); try { const { count, bytes } = await buildLocateDb((phase, value) => flushSync(() => { setScanPhase(phase); setScanValue(value); }) ); toast.success(`Indexed ${count.toLocaleString()} items · ${humanFileSize(bytes)}`); setDbPhase('ready'); } catch (e: any) { toast.error(`Scan failed: ${e?.message ?? e}`); } finally { setIsScanning(false); setScanPhase(null); } }; const handleRefreshDb = () => { resetLocateDb(); setDbPhase('idle'); setHasSearched(false); setResults([]); toast.info('Database will be reloaded on next search'); }; const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => { if (!mountEntry) return; const newConfig = JSON.parse(JSON.stringify(config)); if (!newConfig.devices) newConfig.devices = {}; if (!newConfig.devices.iec) newConfig.devices.iec = {}; if (!newConfig.devices.iec[key]) newConfig.devices.iec[key] = { type: deviceType }; const dev = newConfig.devices.iec[key]; dev.url = mountEntry.path; delete dev.media_set; if (!dev.enabled) dev.enabled = 1; setConfig(newConfig); setMountEntry(null); toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`); }; const busy = isSearching || isScanning; const facets = useMemo(() => { const systems = [...new Set(results.map(r => r.system).filter(Boolean) as string[])].sort(); const videos = [...new Set(results.map(r => r.video).filter(Boolean) as string[])].sort(); const langs = [...new Set(results.map(r => r.language).filter(Boolean) as string[])].sort(); return { systems, videos, langs }; }, [results]); const hasAnyFacets = facets.systems.length > 0 || facets.videos.length > 0 || facets.langs.length > 0; const visibleResults = useMemo(() => { let list = results.filter(r => (filterSystem === null || r.system === filterSystem) && (filterVideo === null || r.video === filterVideo) && (filterLanguage === null || r.language === filterLanguage) ); list = [...list].sort((a, b) => { const cmp = sortField === 'name' ? a.name.localeCompare(b.name) : a.size - b.size; return sortDir === 'asc' ? cmp : -cmp; }); return list; }, [results, filterSystem, filterVideo, filterLanguage, sortField, sortDir]); const toggleSort = (field: SortField) => { if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc'); else { setSortField(field); setSortDir('asc'); } }; const SortIcon = ({ field }: { field: SortField }) => { if (sortField !== field) return ; return sortDir === 'asc' ? : ; }; const activeFilters = [filterSystem, filterVideo, filterLanguage].filter(Boolean).length; return ( <>
{/* Header */}
{dbPhase === 'ready' && !busy && ( )} {hasAnyFacets && ( )}
{/* Search input */}
setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()} placeholder="Search… (* any chars, ? one char)" className="w-full pl-9 pr-3 py-2.5 bg-neutral-100 border-0 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:bg-white transition-colors" autoFocus disabled={busy} />
{/* Filter panel */}
Sort
{/* Body */}
{ _store.scrollTop = (e.currentTarget as HTMLDivElement).scrollTop; }} > {isScanning && scanPhase && (

{scanPhaseLabel(scanPhase, scanValue)}

Scanning /sd recursively…

)} {isSearching && !hasSearched && (

Searching…

The locate database is loading above.

)} {!busy && searchError && (

{searchError}

)} {!searchError && hasSearched && (
{visibleResults.length > 0 ? ( <>

{isSearching && } {visibleResults.length}{results.length !== visibleResults.length ? ` of ${results.length}` : ''} result{visibleResults.length !== 1 ? 's' : ''}

{visibleResults.map((result) => ( setMountEntry(result)} onActionsClick={e => { e.stopPropagation(); setActionEntry(result); }} selected={mountedPaths.has(result.path)} nameSlot={ <>
{result.name} {result.system && {result.system}} {result.video && {result.video}} {result.language && {result.language}}
{result.path}
} /> ))}
) : (

{results.length > 0 ? 'No results match the current filters' : `No results for "${query}"`}

{results.length > 0 && ( )}
)}
)} {!isSearching && !hasSearched && (

Search your device

{dbPhase === 'idle' ? 'The locate database will be downloaded on first search, or tap the scan icon to rebuild it.' : 'Type a filename or path to search the index.'}

)}
{/* Scan confirm dialog */} Rebuild Search Index This will scan all files on /sd and rebuild the local search database. It may take a minute on large collections.
{/* Actions dialog */} !open && setActionEntry(null)}> {actionEntry?.name}

{actionEntry?.name}

{actionEntry?.isDir ? 'Folder' : actionEntry?.sizeText}
{!actionEntry?.isDir && ( )}
{/* Mount dialog */} !open && setMountEntry(null)}> Mount on Virtual Drive {mountEntry?.name}
{(() => { const allDevices = Object.entries(config?.devices?.iec ?? {}); const drives = allDevices .filter(([, v]: [string, any]) => (v as any)?.type === 'drive') .map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); const meatloafs = allDevices .filter(([, v]: [string, any]) => (v as any)?.type === 'meatloaf') .map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, base_url: v?.base_url as string | undefined, url: v?.url as string | undefined, enabled: !!v?.enabled })); const devices = [...drives, ...meatloafs]; if (!devices.length) return

No drive devices found in config.

; return (
{devices.map(dev => ( ))}
); })()}
); }