diff --git a/src/app/components/SearchLocal.tsx b/src/app/components/SearchLocal.tsx index 6fa1f15..3219b4f 100644 --- a/src/app/components/SearchLocal.tsx +++ b/src/app/components/SearchLocal.tsx @@ -175,9 +175,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } const [mountEntry, setMountEntry] = useState(null); const [actionEntry, setActionEntry] = useState(null); const [searchError, setSearchError] = useState(null); - const [engineProgress, setEngineProgress] = useState<{ received: number; total: number | null }>({ received: 0, total: null }); - const [databaseProgress, setDatabaseProgress] = useState<{ received: number; total: number | null }>({ received: 0, total: null }); - const [dbPhase, setDbPhase] = useState<'idle' | 'engine' | 'downloading' | 'ready'>('idle'); + // 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); @@ -228,20 +229,13 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } 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()) { - setDbPhase('engine'); - setEngineProgress({ received: 0, total: null }); - setDatabaseProgress({ received: 0, total: null }); - await openLocateDb(p => flushSync(() => { - if (p.kind === 'engine') { - setEngineProgress({ received: p.received, total: p.total }); - } else { - // First database chunk arrives → engine phase is done, advance UI. - setDatabaseProgress({ received: p.received, total: p.total }); - setDbPhase(phase => phase === 'engine' ? 'downloading' : phase); - } - })); - setDbPhase('ready'); + await openLocateDb(); } const needle = query.trim(); const entries = searchLocate(needle); @@ -306,24 +300,6 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`); }; - // Active progress state: which transfer is in flight right now, and where it stands. - // The engine load (sqlite3.wasm) runs first; the locate-database download - // (.locate) runs once the engine is initialized. We show whichever is active. - const activeProgress = dbPhase === 'engine' ? engineProgress : databaseProgress; - const activeLabel = dbPhase === 'engine' ? 'Loading engine' : 'Loading database'; - - const loadingLabel = dbPhase === 'idle' || dbPhase === 'ready' - ? 'Searching…' - : (activeProgress.received === 0 - ? `${activeLabel}…` - : activeProgress.total === null - ? `${activeLabel}… ${humanFileSize(activeProgress.received)}` - : `${activeLabel}… ${humanFileSize(activeProgress.received)} / ${humanFileSize(activeProgress.total)}`); - - const downloadPct = activeProgress.total && activeProgress.total > 0 - ? Math.min(100, Math.round((activeProgress.received / activeProgress.total) * 100)) - : null; - const busy = isSearching || isScanning; const facets = useMemo(() => { @@ -463,33 +439,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } )} {isSearching && !hasSearched && ( -
+
-

{loadingLabel}

- {(dbPhase === 'engine' || dbPhase === 'downloading') && ( -
-
-
-
- {downloadPct !== null && ( -

{downloadPct}%

- )} - {/* Step indicators so the user can see what's happening - when the network panel is closed. */} -
- - 1. Engine - - - - 2. Database - -
-
- )} +

Searching…

+

The locate database is loading above.

)} diff --git a/src/app/components/SearchPane.tsx b/src/app/components/SearchPane.tsx index d938e3f..847b351 100644 --- a/src/app/components/SearchPane.tsx +++ b/src/app/components/SearchPane.tsx @@ -1,8 +1,15 @@ import { useEffect, useRef, useState } from 'react'; -import { X } from 'lucide-react'; +import { X, Loader2, Database } from 'lucide-react'; import { motion } from 'motion/react'; import SearchLocal from './SearchLocal'; import SearchAssembly64 from './SearchAssembly64'; +import { humanFileSize } from '../webdav'; +import { + openLocateDb, + isLocateDbLoaded, + subscribeLoadProgress, + type DbProgress, +} from '../locate-db'; interface SearchPaneProps { config: any; @@ -20,6 +27,26 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO const panelRef = useRef(null); const [activeTab, setActiveTab] = useState<0 | 1>(initialTab ?? _lastTab); + // Subscribe to the locate-database load pipeline so the user can see the + // ~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 setLoadState({ kind: s.phase, received: s.received, total: s.total }); + }); + }, []); + // Jump to starting tab without animation on first render useEffect(() => { const el = panelRef.current; @@ -43,6 +70,15 @@ 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 */}
void>(); + +function _setLoadState(s: LoadState) { + _loadState = s; + for (const cb of _loadSubscribers) cb(s); +} + +/** + * Subscribe to the in-flight (or already-completed) locate-database load. + * The callback fires immediately with the current state, then on every + * change. Returns an unsubscribe function. Multiple subscribers (e.g. the + * SearchPane header and the SearchLocal panel) can observe the same load. + */ +export function subscribeLoadProgress(cb: (s: LoadState) => void): () => void { + _loadSubscribers.add(cb); + cb(_loadState); + return () => { _loadSubscribers.delete(cb); }; } export type DbProgress = @@ -93,15 +129,16 @@ export async function openLocateDb( ): Promise { if (_db) return; - const sqlite3 = await getSqlite3( - p => onProgress?.({ kind: 'engine', received: p.received, total: p.total }), - ); + const sqlite3 = await getSqlite3(p => { + onProgress?.({ kind: 'engine', received: p.received, total: p.total }); + _setLoadState({ phase: 'engine', received: p.received, total: p.total }); + }); const url = getWebDAVBaseUrl() + LOCATE_PATH; - const bytes = await fetchWithProgress( - url, - p => onProgress?.({ kind: 'database', received: p.received, total: p.total }), - ); + const bytes = await fetchWithProgress(url, p => { + onProgress?.({ kind: 'database', received: p.received, total: p.total }); + _setLoadState({ phase: 'database', received: p.received, total: p.total }); + }); // Allocate the bytes in WASM heap, then deserialize into a fresh in-memory DB. const p = sqlite3.wasm.allocFromTypedArray(bytes); @@ -119,6 +156,7 @@ export async function openLocateDb( throw new Error(`sqlite3_deserialize failed (code ${rc})`); } _db = db; + _setLoadState({ phase: 'ready' }); } export type ScanPhase = 'scanning' | 'building' | 'saving';