From a1b2fa5ea7794da0abda0c3770cf04f0829b9dc3 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Wed, 17 Jun 2026 13:55:22 -0400 Subject: [PATCH] feat(SearchLocal, SearchPane): enhance loading state management and UI feedback for database scanning --- src/app/components/SearchLocal.tsx | 96 ++++++++++++++++++++++++++---- src/app/components/SearchPane.tsx | 70 ---------------------- 2 files changed, 86 insertions(+), 80 deletions(-) diff --git a/src/app/components/SearchLocal.tsx b/src/app/components/SearchLocal.tsx index f661330..058d713 100644 --- a/src/app/components/SearchLocal.tsx +++ b/src/app/components/SearchLocal.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; -import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X, DatabaseZap } from 'lucide-react'; +import { Search, Loader2, RefreshCw, FolderSearch, SlidersHorizontal, HardDrive, FolderOpen, X, DatabaseZap, AlertCircle } from 'lucide-react'; import { toast } from 'sonner'; -import { humanFileSize, splitPath } from '../webdav'; +import { humanFileSize, splitPath, stat, fileExists } from '../webdav'; import type { EntryInfo } from '../webdav'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog'; import { MediaEntry } from './MediaEntry'; @@ -13,6 +13,7 @@ import { isLocateDbLoaded, resetLocateDb, buildLocateDb, + subscribeLoadProgress, type LocateEntry, type ScanPhase, } from '../locate-db'; @@ -180,10 +181,11 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } 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 [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready'); + const [checking, setChecking] = useState(() => !isLocateDbLoaded()); + const [sdMissing, setSdMissing] = useState(false); + const [locateMissing, setLocateMissing] = useState(false); + const [loadState, setLoadState] = useState<{ kind: 'idle' | 'engine' | 'database' | 'ready'; received: number; total: number | null }>({ kind: 'idle', received: 0, total: null }); const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [filterText, setFilterText] = useState(() => _store.filterText); const [filterSystem, setFilterSystem] = useState(() => _store.filterSystem); @@ -193,9 +195,34 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } const [sortDir, setSortDir] = useState(() => _store.sortDir); useEffect(() => { - if (isLocateDbLoaded()) setDbPhase('ready'); + return subscribeLoadProgress(s => { + if (s.phase === 'ready' || s.phase === 'error') { + setLoadState({ kind: 'ready', received: 0, total: null }); + setDbPhase('ready'); + } else if (s.phase === 'idle') { + setLoadState({ kind: 'idle', received: 0, total: null }); + } else { + setLoadState({ kind: s.phase, received: s.received, total: s.total }); + } + }); }, []); + useEffect(() => { + if (!checking) return; + let cancelled = false; + void (async () => { + const sdEntry = await stat('/sd').catch(() => null); + if (cancelled) return; + if (!sdEntry) { setSdMissing(true); setChecking(false); return; } + const hasLocate = await fileExists('/sd/.locate').catch(() => false); + if (cancelled) return; + setLocateMissing(!hasLocate); + setChecking(false); + if (hasLocate) openLocateDb().catch(() => {}); + })(); + return () => { cancelled = true; }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { _store.query = query; _store.results = results; @@ -285,6 +312,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } ); toast.success(`Indexed ${count.toLocaleString()} items · ${humanFileSize(bytes)}`); setDbPhase('ready'); + setLocateMissing(false); } catch (e: any) { if (e?.name === 'AbortError') toast.info('Scan stopped.'); else toast.error(`Scan failed: ${e?.message ?? e}`); @@ -366,7 +394,47 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } - {/* Search input */} + {/* Search input slot — swaps content based on device/db state */} + {sdMissing ? ( +
+
+ +
+

No SD card detected

+

Insert an SD card and reload.

+
+
+
+ ) : checking ? ( +
+
+ + Checking device… +
+
+ ) : (loadState.kind === 'engine' || loadState.kind === 'database') ? ( +
+
+ + + {loadState.kind === 'engine' ? 'Loading engine' : 'Loading database'}… + + {loadState.received > 0 && ( + + {loadState.total + ? `${humanFileSize(loadState.received)} / ${humanFileSize(loadState.total)}` + : humanFileSize(loadState.received)} + + )} +
+
+
+
+
+ ) : (
@@ -400,7 +468,12 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } )} */} - {hasSearched && ( @@ -415,6 +488,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } )}
+ )} {/* Filter + sort bar — same style as MediaManager */}
@@ -551,7 +625,9 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }

Search your device

- {dbPhase === 'idle' + {locateMissing + ? 'No search index found. Tap the highlighted scan button to build one.' + : 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/components/SearchPane.tsx b/src/app/components/SearchPane.tsx index dc23688..8e17a2a 100644 --- a/src/app/components/SearchPane.tsx +++ b/src/app/components/SearchPane.tsx @@ -1,16 +1,9 @@ import { useEffect, useRef, useState } from 'react'; -import { Loader2, Database } from 'lucide-react'; import { motion } from 'motion/react'; import SearchLocal from './SearchLocal'; import SearchAssembly64 from './SearchAssembly64'; import SearchCommoServe from './SearchCommoServe'; import SearchCSDbNG from './SearchCSDbNG'; -import { humanFileSize } from '../webdav'; -import { - openLocateDb, - isLocateDbLoaded, - subscribeLoadProgress, -} from '../locate-db'; interface SearchPaneProps { config: any; @@ -35,22 +28,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO // ~17 MB /sd/.locate download happening on the panel itself, not buried // inside the search action. Multiple subscribers (this header bar + the // SearchLocal panel) can observe the same in-flight load. - const [loadState, setLoadState] = useState<{ kind: 'idle' | 'engine' | 'database' | 'ready' | 'error'; received: number; total: number | null; message?: string }>({ kind: 'idle', received: 0, total: null }); - - useEffect(() => { - // Kick off the load immediately on mount (if not already loaded). - // openLocateDb is a no-op once the database is loaded, so this is safe - // to call every time the pane opens. - if (!isLocateDbLoaded()) { - openLocateDb().catch(() => { /* errors are surfaced via loadState */ }); - } - return subscribeLoadProgress(s => { - if (s.phase === 'idle') setLoadState({ kind: 'idle', received: 0, total: null }); - else if (s.phase === 'ready') setLoadState({ kind: 'ready', received: 0, total: null }); - else if (s.phase === 'error') setLoadState({ kind: 'error', received: 0, total: null }); - else setLoadState({ kind: s.phase, received: s.received, total: s.total }); - }); - }, []); // Jump to starting tab without animation on first render useEffect(() => { @@ -73,15 +50,6 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO localStorage.setItem('search.tab', String(idx)); }; - // Active-transfer UI for the slim bar that lives just under the tab bar. - // We only show the bar while a transfer is in flight; once it's ready - // (or never started) the bar collapses to zero height. - const showLoadBar = loadState.kind === 'engine' || loadState.kind === 'database'; - const activeLabel = loadState.kind === 'engine' ? 'Loading engine' : 'Loading database'; - const loadPct = loadState.total && loadState.total > 0 - ? Math.min(100, Math.round((loadState.received / loadState.total) * 100)) - : null; - return (
- {/* Locate-database load bar. Visible only while a transfer is in - flight; collapses to zero height otherwise so it doesn't take - up space when there's nothing to show. */} -
-
- - -
-
- - {activeLabel}… - {loadState.received > 0 && ( - - {' '} - {loadState.total === null - ? humanFileSize(loadState.received) - : `${humanFileSize(loadState.received)} / ${humanFileSize(loadState.total)}`} - - )} - - {loadPct !== null && ( - {loadPct}% - )} -
-
-
-
-
-
-
- {/* Swipeable panels */}