fix(SearchLocal, SearchPane, locate-db): enhance loading progress tracking and UI feedback for locate database
This commit is contained in:
parent
b0c37694ba
commit
251e9a9495
|
|
@ -175,9 +175,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
|
||||||
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
|
||||||
const [searchError, setSearchError] = useState<string | null>(null);
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const [engineProgress, setEngineProgress] = useState<{ received: number; total: number | null }>({ received: 0, total: null });
|
// The locate-database load progress is rendered by the SearchPane (above
|
||||||
const [databaseProgress, setDatabaseProgress] = useState<{ received: number; total: number | null }>({ received: 0, total: null });
|
// this panel). The pane pre-fetches the database on mount, so by the time
|
||||||
const [dbPhase, setDbPhase] = useState<'idle' | 'engine' | 'downloading' | 'ready'>('idle');
|
// the user clicks Search the database is almost always ready.
|
||||||
|
const [dbPhase, setDbPhase] = useState<'idle' | 'ready'>('ready');
|
||||||
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
const [showFilter, setShowFilter] = useState(() => _store.showFilter);
|
||||||
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
const [filterSystem, setFilterSystem] = useState<string | null>(() => _store.filterSystem);
|
||||||
const [filterVideo, setFilterVideo] = useState<string | null>(() => _store.filterVideo);
|
const [filterVideo, setFilterVideo] = useState<string | null>(() => _store.filterVideo);
|
||||||
|
|
@ -228,20 +229,13 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
setFilterVideo(null);
|
setFilterVideo(null);
|
||||||
setFilterLanguage(null);
|
setFilterLanguage(null);
|
||||||
try {
|
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()) {
|
if (!isLocateDbLoaded()) {
|
||||||
setDbPhase('engine');
|
await openLocateDb();
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
const needle = query.trim();
|
const needle = query.trim();
|
||||||
const entries = searchLocate(needle);
|
const entries = searchLocate(needle);
|
||||||
|
|
@ -306,24 +300,6 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`);
|
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 busy = isSearching || isScanning;
|
||||||
|
|
||||||
const facets = useMemo(() => {
|
const facets = useMemo(() => {
|
||||||
|
|
@ -463,33 +439,10 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSearching && !hasSearched && (
|
{isSearching && !hasSearched && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3 w-full px-8">
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
|
||||||
<p className="text-sm text-neutral-500 text-center">{loadingLabel}</p>
|
<p className="text-sm text-neutral-500">Searching…</p>
|
||||||
{(dbPhase === 'engine' || dbPhase === 'downloading') && (
|
<p className="text-xs text-neutral-400">The locate database is loading above.</p>
|
||||||
<div className="w-full max-w-xs">
|
|
||||||
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full bg-blue-500 transition-all duration-150 ease-out ${downloadPct === null ? 'animate-pulse w-1/3' : ''}`}
|
|
||||||
style={downloadPct !== null ? { width: `${downloadPct}%` } : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{downloadPct !== null && (
|
|
||||||
<p className="text-xs text-neutral-400 text-center mt-1.5">{downloadPct}%</p>
|
|
||||||
)}
|
|
||||||
{/* Step indicators so the user can see what's happening
|
|
||||||
when the network panel is closed. */}
|
|
||||||
<div className="flex items-center gap-1.5 mt-3 text-[10px] text-neutral-400">
|
|
||||||
<span className={dbPhase === 'engine' || dbPhase === 'downloading' || dbPhase === 'ready' ? 'text-blue-600 font-medium' : ''}>
|
|
||||||
1. Engine
|
|
||||||
</span>
|
|
||||||
<span className="opacity-50">›</span>
|
|
||||||
<span className={dbPhase === 'downloading' || dbPhase === 'ready' ? 'text-blue-600 font-medium' : ''}>
|
|
||||||
2. Database
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X, Loader2, Database } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import SearchLocal from './SearchLocal';
|
import SearchLocal from './SearchLocal';
|
||||||
import SearchAssembly64 from './SearchAssembly64';
|
import SearchAssembly64 from './SearchAssembly64';
|
||||||
|
import { humanFileSize } from '../webdav';
|
||||||
|
import {
|
||||||
|
openLocateDb,
|
||||||
|
isLocateDbLoaded,
|
||||||
|
subscribeLoadProgress,
|
||||||
|
type DbProgress,
|
||||||
|
} from '../locate-db';
|
||||||
|
|
||||||
interface SearchPaneProps {
|
interface SearchPaneProps {
|
||||||
config: any;
|
config: any;
|
||||||
|
|
@ -20,6 +27,26 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const [activeTab, setActiveTab] = useState<0 | 1>(initialTab ?? _lastTab);
|
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
|
// Jump to starting tab without animation on first render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = panelRef.current;
|
const el = panelRef.current;
|
||||||
|
|
@ -43,6 +70,15 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
localStorage.setItem('search.tab', String(idx));
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -77,6 +113,44 @@ export default function SearchPane({ config, setConfig, initialTab, onClose, onO
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 overflow-hidden border-b border-neutral-200/70 transition-[max-height,opacity] duration-200 ease-in-out"
|
||||||
|
style={{ maxHeight: showLoadBar ? '64px' : '0px', opacity: showLoadBar ? 1 : 0 }}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-2 flex items-center gap-3">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin flex-shrink-0" />
|
||||||
|
<Database className="w-3.5 h-3.5 text-neutral-400 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<span className="text-xs text-neutral-600 font-medium truncate">
|
||||||
|
{activeLabel}…
|
||||||
|
{loadState.received > 0 && (
|
||||||
|
<span className="text-neutral-400 font-normal">
|
||||||
|
{' '}
|
||||||
|
{loadState.total === null
|
||||||
|
? humanFileSize(loadState.received)
|
||||||
|
: `${humanFileSize(loadState.received)} / ${humanFileSize(loadState.total)}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{loadPct !== null && (
|
||||||
|
<span className="text-xs text-neutral-500 tabular-nums">{loadPct}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-1 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full bg-blue-500 transition-all duration-150 ease-out ${loadPct === null ? 'animate-pulse w-1/3' : ''}`}
|
||||||
|
style={loadPct !== null ? { width: `${loadPct}%` } : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Swipeable panels */}
|
{/* Swipeable panels */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,42 @@ export function isLocateDbLoaded(): boolean {
|
||||||
export function resetLocateDb(): void {
|
export function resetLocateDb(): void {
|
||||||
try { _db?.close(); } catch { /* ignore */ }
|
try { _db?.close(); } catch { /* ignore */ }
|
||||||
_db = null;
|
_db = null;
|
||||||
|
_loadState = { phase: 'idle' };
|
||||||
|
for (const cb of _loadSubscribers) cb(_loadState);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-subscriber progress for the load pipeline ────────────────────────
|
||||||
|
// The SearchPane kicks off openLocateDb() on mount (so the user can see the
|
||||||
|
// transfer happen on the panel itself, not buried inside the search action).
|
||||||
|
// SearchLocal may also call openLocateDb() when the user types a query, and
|
||||||
|
// it needs to receive the same progress events. This module-level pub/sub
|
||||||
|
// fan-outs events to every active subscriber; late subscribers get an
|
||||||
|
// immediate "done" notification so they can skip their own progress UI.
|
||||||
|
|
||||||
|
type LoadState =
|
||||||
|
| { phase: 'idle' }
|
||||||
|
| { phase: 'engine' | 'database'; received: number; total: number | null }
|
||||||
|
| { phase: 'ready' }
|
||||||
|
| { phase: 'error'; message: string };
|
||||||
|
|
||||||
|
let _loadState: LoadState = { phase: 'idle' };
|
||||||
|
const _loadSubscribers = new Set<(s: LoadState) => 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 =
|
export type DbProgress =
|
||||||
|
|
@ -93,15 +129,16 @@ export async function openLocateDb(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (_db) return;
|
if (_db) return;
|
||||||
|
|
||||||
const sqlite3 = await getSqlite3(
|
const sqlite3 = await getSqlite3(p => {
|
||||||
p => onProgress?.({ kind: 'engine', received: p.received, total: p.total }),
|
onProgress?.({ kind: 'engine', received: p.received, total: p.total });
|
||||||
);
|
_setLoadState({ phase: 'engine', received: p.received, total: p.total });
|
||||||
|
});
|
||||||
|
|
||||||
const url = getWebDAVBaseUrl() + LOCATE_PATH;
|
const url = getWebDAVBaseUrl() + LOCATE_PATH;
|
||||||
const bytes = await fetchWithProgress(
|
const bytes = await fetchWithProgress(url, p => {
|
||||||
url,
|
onProgress?.({ kind: 'database', received: p.received, total: p.total });
|
||||||
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.
|
// Allocate the bytes in WASM heap, then deserialize into a fresh in-memory DB.
|
||||||
const p = sqlite3.wasm.allocFromTypedArray(bytes);
|
const p = sqlite3.wasm.allocFromTypedArray(bytes);
|
||||||
|
|
@ -119,6 +156,7 @@ export async function openLocateDb(
|
||||||
throw new Error(`sqlite3_deserialize failed (code ${rc})`);
|
throw new Error(`sqlite3_deserialize failed (code ${rc})`);
|
||||||
}
|
}
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_setLoadState({ phase: 'ready' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScanPhase = 'scanning' | 'building' | 'saving';
|
export type ScanPhase = 'scanning' | 'building' | 'saving';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user