diff --git a/src/app/components/SearchOverlay.tsx b/src/app/components/SearchOverlay.tsx index a59360b..a099825 100644 --- a/src/app/components/SearchOverlay.tsx +++ b/src/app/components/SearchOverlay.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { flushSync } from 'react-dom'; -import { X, Search, HardDrive, Loader2, RefreshCw } from 'lucide-react'; +import { X, Search, Loader2, RefreshCw, FolderSearch, File, Folder } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { toast } from 'sonner'; import { humanFileSize } from '../webdav'; @@ -9,7 +9,9 @@ import { searchLocate, isLocateDbLoaded, resetLocateDb, + buildLocateDb, type LocateEntry, + type ScanPhase, } from '../locate-db'; interface SearchOverlayProps { @@ -24,11 +26,12 @@ interface SearchResult { type: string; size: number; sizeText: string; + isDir: boolean; } -const HARDWARE_FILE_EXTS = new Set([ - 'd64', 'd71', 'd81', 'd82', 'dnp', 't64', 'tap', 'prg', 'p00', 'crt', 'bin', 'g64', 'nib', -]); +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']); function fileExtension(p: string): string { const dot = p.lastIndexOf('.'); @@ -36,18 +39,32 @@ function fileExtension(p: string): string { } function entryToResult(e: LocateEntry): SearchResult { - if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: e.size, sizeText: '—' }; + if (e.is_dir) return { name: e.name, path: e.path, type: 'DIR', size: 0, sizeText: '—', isDir: true }; const ext = fileExtension(e.name); - const type = ext ? (HARDWARE_FILE_EXTS.has(ext) ? ext.toUpperCase() : ext.toUpperCase()) : 'FILE'; - return { name: e.name, path: e.path, type, size: e.size, sizeText: humanFileSize(e.size) }; + return { name: e.name, path: e.path, type: ext ? ext.toUpperCase() : 'FILE', size: e.size, sizeText: humanFileSize(e.size), isDir: false }; +} + +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)}`; } export default function SearchOverlay({ config, setConfig, onClose }: SearchOverlayProps) { const [query, setQuery] = useState(''); - const [systemType, setSystemType] = useState('all'); - const [videoStandard, setVideoStandard] = useState('all'); - const [language, setLanguage] = useState('all'); const [isSearching, setIsSearching] = useState(false); + const [isScanning, setIsScanning] = useState(false); + const [scanPhase, setScanPhase] = useState(null); + const [scanValue, setScanValue] = useState(0); const [results, setResults] = useState([]); const [hasSearched, setHasSearched] = useState(false); const [showDeviceMenu, setShowDeviceMenu] = useState(null); @@ -55,9 +72,6 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver const [dbBytes, setDbBytes] = useState(null); const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle'); - void systemType; void videoStandard; void language; - - // Show "ready" phase if DB was already loaded from a previous search. useEffect(() => { if (isLocateDbLoaded()) setDbPhase('ready'); }, []); @@ -68,7 +82,6 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver setHasSearched(true); setResults([]); setSearchError(null); - try { if (!isLocateDbLoaded()) { setDbPhase('downloading'); @@ -76,19 +89,15 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver await openLocateDb(bytes => flushSync(() => setDbBytes(bytes))); setDbPhase('ready'); } - const needle = query.trim(); const entries = searchLocate(needle); - - // Sort: name starts with query first, then by path depth (shorter = closer to root). - const lower = needle.toLowerCase(); entries.sort((a, b) => { + 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'); @@ -99,7 +108,25 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver } }; - const handleRefreshDb = async () => { + 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); @@ -117,170 +144,176 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver } }; - const getAvailableDevices = () => { - const devices: { number: string; name: string; url?: string }[] = []; + const availableDevices = (() => { + const out: { number: string }[] = []; if (config.devices?.iec) { - for (const [num, device] of Object.entries(config.devices.iec)) { - const d = device as any; - if (d.type === 'drive' && d.enabled) { - devices.push({ number: num, name: `Drive ${num}`, url: d.url }); - } + for (const [num, d] of Object.entries(config.devices.iec)) { + if ((d as any).type === 'drive' && (d as any).enabled) out.push({ number: num }); } } - return devices; - }; - - const availableDevices = getAvailableDevices(); + return out; + })(); const loadingLabel = dbPhase === 'downloading' ? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`) : 'Searching…'; + const busy = isSearching || isScanning; + return ( e.stopPropagation()} + initial={{ y: '100%' }} + animate={{ y: 0 }} + exit={{ y: '100%' }} + transition={{ type: 'spring', damping: 28, stiffness: 280 }} + className="fixed inset-x-0 bottom-0 top-12 bg-white rounded-t-2xl shadow-2xl flex flex-col overflow-hidden" + onClick={e => e.stopPropagation()} > -
-
-

Search

-
- {dbPhase === 'ready' && ( + {/* Header */} +
+
+

Search

+
+ {dbPhase === 'ready' && !busy && ( )} - +
-
-
+ {/* Search input */} +
+
+ setQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder="Search for games, programs, files..." - className="flex-1 px-4 py-3 border border-neutral-300 rounded-lg text-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + onChange={e => setQuery(e.target.value)} + onKeyDown={e => e.key === 'Enter' && !busy && handleSearch()} + placeholder="Search games, programs, files…" + 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} /> -
+
-
-
- - -
-
- - -
-
- - -
-
+ {/* DB status chip */} + {dbPhase === 'ready' && !busy && ( +

+ + Database ready +

+ )} +
+ {/* Body */} +
+ {/* Scanning progress */} + {isScanning && scanPhase && ( +
+ +

{scanPhaseLabel(scanPhase, scanValue)}

+

Scanning /sd recursively…

+
+ )} + + {/* Searching / loading DB progress */} {isSearching && ( -
- -
{loadingLabel}
+
+ +

{loadingLabel}

)} - {!isSearching && searchError && ( -
Search failed: {searchError}
+ {/* Error */} + {!busy && searchError && ( +
+

{searchError}

+
)} - {!isSearching && !searchError && hasSearched && ( -
+ {/* Results */} + {!busy && !searchError && hasSearched && ( +
{results.length > 0 ? ( <> -
- {results.length} result{results.length !== 1 ? 's' : ''} found -
-
+

+ {results.length} result{results.length !== 1 ? 's' : ''} +

+
{results.map((result, index) => (
-
-
- -
-
-
{result.name}
-
{result.path}
-
-
- {result.type} · {result.sizeText} -
+
+ {result.isDir + ? + : + }
-
+
+
+ {result.name} + +
+

{result.path}

+
+ {!result.isDir && ( + {result.sizeText} + )} +
{showDeviceMenu === index && ( -
- {availableDevices.map((device) => ( +
+ {availableDevices.map(d => ( ))} {availableDevices.length === 0 && ( -
No enabled devices
+

No enabled devices

)}
)} @@ -290,20 +323,24 @@ export default function SearchOverlay({ config, setConfig, onClose }: SearchOver
) : ( -
- No results found for "{query}" +
+ +

No results for "{query}"

)}
)} - {!hasSearched && ( -
- -
Enter a search term to find files on the device
- {dbPhase === 'ready' && ( -
Database loaded
- )} + {/* Empty state */} + {!busy && !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.'} +

)}
diff --git a/src/app/locate-db.ts b/src/app/locate-db.ts index f8744f8..28ee1fa 100644 --- a/src/app/locate-db.ts +++ b/src/app/locate-db.ts @@ -1,5 +1,5 @@ import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; -import { getWebDAVBaseUrl, basename } from './webdav'; +import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav'; const LOCATE_PATH = '/sd/.locate'; @@ -76,6 +76,87 @@ export async function openLocateDb(onProgress?: (bytes: number) => void): Promis _db = db; } +export type ScanPhase = 'scanning' | 'building' | 'saving'; + +/** + * Recursively scan /sd, build a fresh SQLite locate database in memory, + * and upload it to /sd/.locate via WebDAV PUT. + * + * onProgress(phase, value): + * scanning → bytes of PROPFIND XML received so far + * building → number of entries inserted so far + * saving → bytes of the serialized DB written to the server + */ +export async function buildLocateDb( + onProgress?: (phase: ScanPhase, value: number) => void, +): Promise<{ count: number; bytes: number }> { + const sqlite3 = await getSqlite3(); + + // ── 1. Recursive PROPFIND on /sd ──────────────────────────────────────── + const entries = await listDirectory( + '/sd', true, + bytes => onProgress?.('scanning', bytes), + ); + + // ── 2. Build in-memory DB ─────────────────────────────────────────────── + const db = new sqlite3.oo1.DB(':memory:', 'ct'); + db.exec( + 'CREATE TABLE files (' + + ' path TEXT PRIMARY KEY,' + + ' size INTEGER NOT NULL DEFAULT 0,' + + ' mtime INTEGER NOT NULL DEFAULT 0,' + + ' is_dir INTEGER NOT NULL DEFAULT 0' + + ');' + + 'CREATE INDEX IF NOT EXISTS idx_path ON files(path);', + ); + + db.exec('BEGIN'); + const stmt = db.prepare('INSERT OR REPLACE INTO files VALUES (?,?,?,?)'); + try { + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + stmt.bind([ + e.path, + e.size, + e.lastModified ? Math.floor(e.lastModified.getTime() / 1000) : 0, + e.type === 'folder' ? 1 : 0, + ]).stepReset(); + if (i % 250 === 0) onProgress?.('building', i); + } + } finally { + stmt.finalize(); + } + db.exec('COMMIT'); + onProgress?.('building', entries.length); + + // ── 3. Serialize to bytes ──────────────────────────────────────────────── + const pSaved = sqlite3.wasm.pstack.pointer; + let dbBytes: Uint8Array; + try { + const pSize = sqlite3.wasm.pstack.alloc(8); + const pData = sqlite3.capi.sqlite3_serialize(db.pointer, 'main', pSize, 0); + if (!pData) throw new Error('sqlite3_serialize returned NULL'); + const rawSize = sqlite3.wasm.peek(pSize, 'i64'); + const size = Number(typeof rawSize === 'bigint' ? rawSize : rawSize); + dbBytes = sqlite3.wasm.heap8u().slice(pData, pData + size); + sqlite3.capi.sqlite3_free(pData); + } finally { + sqlite3.wasm.pstack.restore(pSaved); + db.close(); + } + + // ── 4. Delete existing + upload ───────────────────────────────────────── + onProgress?.('saving', 0); + await deletePath(LOCATE_PATH).catch(() => {}); + await putFileContents(LOCATE_PATH, dbBytes); + onProgress?.('saving', dbBytes.length); + + // Invalidate cached DB so the next search reloads the fresh file. + resetLocateDb(); + + return { count: entries.length, bytes: dbBytes.length }; +} + export interface LocateEntry { path: string; name: string;