fix(SearchLocal, locate-db): enhance database download progress tracking and UI feedback

This commit is contained in:
Jaime Idolpx 2026-06-14 05:51:03 -04:00
parent 3b94d3d956
commit 9d7034e7d2
2 changed files with 43 additions and 9 deletions

View File

@ -175,7 +175,7 @@ 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 [dbBytes, setDbBytes] = useState<number | null>(null); const [dbProgress, setDbProgress] = useState<{ received: number; total: number | null }>({ received: 0, total: null });
const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle'); const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle');
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);
@ -229,8 +229,8 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
try { try {
if (!isLocateDbLoaded()) { if (!isLocateDbLoaded()) {
setDbPhase('downloading'); setDbPhase('downloading');
setDbBytes(null); setDbProgress({ received: 0, total: null });
await openLocateDb(bytes => flushSync(() => setDbBytes(bytes))); await openLocateDb(p => flushSync(() => setDbProgress(p)));
setDbPhase('ready'); setDbPhase('ready');
} }
const needle = query.trim(); const needle = query.trim();
@ -297,9 +297,17 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
}; };
const loadingLabel = dbPhase === 'downloading' const loadingLabel = dbPhase === 'downloading'
? (dbBytes === null ? 'Loading database…' : `Loading database… ${humanFileSize(dbBytes)}`) ? (dbProgress.received === 0
? 'Loading database…'
: dbProgress.total === null
? `Loading database… ${humanFileSize(dbProgress.received)}`
: `Loading database… ${humanFileSize(dbProgress.received)} / ${humanFileSize(dbProgress.total)}`)
: 'Searching…'; : 'Searching…';
const downloadPct = dbProgress.total && dbProgress.total > 0
? Math.min(100, Math.round((dbProgress.received / dbProgress.total) * 100))
: null;
const busy = isSearching || isScanning; const busy = isSearching || isScanning;
const facets = useMemo(() => { const facets = useMemo(() => {
@ -439,9 +447,22 @@ 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"> <div className="flex flex-col items-center justify-center py-16 gap-3 w-full px-8">
<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">{loadingLabel}</p> <p className="text-sm text-neutral-500 text-center">{loadingLabel}</p>
{dbPhase === 'downloading' && (
<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>
)}
</div>
)}
</div> </div>
)} )}

View File

@ -3,6 +3,12 @@ import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath
const LOCATE_PATH = '/sd/.locate'; const LOCATE_PATH = '/sd/.locate';
function parseContentLength(header: string | null): number | null {
if (!header) return null;
const n = Number(header);
return Number.isFinite(n) && n > 0 ? n : null;
}
// Memoize the module init — loading the WASM binary is expensive. // Memoize the module init — loading the WASM binary is expensive.
let _sqlite3Promise: Promise<any> | null = null; let _sqlite3Promise: Promise<any> | null = null;
function getSqlite3(): Promise<any> { function getSqlite3(): Promise<any> {
@ -26,9 +32,13 @@ export function resetLocateDb(): void {
/** /**
* Fetch /sd/.locate and open it as an in-memory SQLite database. * Fetch /sd/.locate and open it as an in-memory SQLite database.
* Calling again when already loaded is a no-op unless you call resetLocateDb() first. * Calling again when already loaded is a no-op unless you call resetLocateDb() first.
* onProgress receives raw bytes received so far during the download. * onProgress receives { received, total } for each chunk; total is the value
* of the Content-Length response header, or null if the server didn't send
* one (chunked transfer, etc.).
*/ */
export async function openLocateDb(onProgress?: (bytes: number) => void): Promise<void> { export async function openLocateDb(
onProgress?: (progress: { received: number; total: number | null }) => void,
): Promise<void> {
if (_db) return; if (_db) return;
const sqlite3 = await getSqlite3(); const sqlite3 = await getSqlite3();
@ -37,6 +47,8 @@ export async function openLocateDb(onProgress?: (bytes: number) => void): Promis
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(`Cannot fetch locate database: ${response.status} ${response.statusText}`); if (!response.ok) throw new Error(`Cannot fetch locate database: ${response.status} ${response.statusText}`);
const total = parseContentLength(response.headers.get('content-length'));
// Stream the response body so onProgress gets called per chunk. // Stream the response body so onProgress gets called per chunk.
let bytes: Uint8Array; let bytes: Uint8Array;
if (response.body) { if (response.body) {
@ -48,7 +60,7 @@ export async function openLocateDb(onProgress?: (bytes: number) => void): Promis
if (done) break; if (done) break;
chunks.push(value); chunks.push(value);
received += value.byteLength; received += value.byteLength;
onProgress?.(received); onProgress?.({ received, total });
} }
const combined = new Uint8Array(received); const combined = new Uint8Array(received);
let offset = 0; let offset = 0;
@ -56,6 +68,7 @@ export async function openLocateDb(onProgress?: (bytes: number) => void): Promis
bytes = combined; bytes = combined;
} else { } else {
bytes = new Uint8Array(await response.arrayBuffer()); bytes = new Uint8Array(await response.arrayBuffer());
onProgress?.({ received: bytes.byteLength, total: total ?? bytes.byteLength });
} }
// 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.