diff --git a/AGENTS.md b/AGENTS.md index 01466de..d55c9be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -189,6 +189,7 @@ Header search icon opens SearchPane at tab 0 (Local). "Assembly64" AppCard opens 38. **Split config / devices storage** — `settings.ts` loads `/.sys/config.json` and `/.sys/devices.json` in parallel; merges with one-level deep merge (so `devices.iec` from devices.json is merged into `iec` from config.json); saves with split: `devices.iec` → `devices.json`, everything else (including remaining `iec` bus settings) → `config.json`; `beforeunload` flush also split across both files 39. **SerialConsolePage** — xterm.js terminal (`@xterm/xterm` + `@xterm/addon-fit`) over shared `useWs()`; line-buffered input: printable chars echoed locally, `\r` sends buffer, `\x7f` backspaces, `\x03` clears; echo suppression via `echoQueue` ref; tiled icon background; lazy-loaded 40. **LazyLoader component** — `ui/lazy-loader.tsx`: animated progress bar with staged percentage steps (30 → 60 → 80 → 92%) for Suspense fallbacks; replaces inline `PageLoader` in `App.tsx` +40a. **Pre-React splash loader** — `index.html` now ships an inline `#splash` div inside `#root` that shows a dark backdrop, animated blue progress bar, Meatloaf "M" icon, spinner, and "Loading…" label from the moment the HTML parses. Pure CSS + inline SVG, no extra requests, no JS dependency. A tiny inline ` + \ No newline at end of file diff --git a/src/app/components/SearchLocal.tsx b/src/app/components/SearchLocal.tsx index 8b1a0e2..b579a9a 100644 --- a/src/app/components/SearchLocal.tsx +++ b/src/app/components/SearchLocal.tsx @@ -175,8 +175,9 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } const [mountEntry, setMountEntry] = useState(null); const [actionEntry, setActionEntry] = useState(null); const [searchError, setSearchError] = useState(null); - const [dbProgress, setDbProgress] = useState<{ received: number; total: number | null }>({ received: 0, total: null }); - const [dbPhase, setDbPhase] = useState<'idle' | 'downloading' | 'ready'>('idle'); + 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'); const [showFilter, setShowFilter] = useState(() => _store.showFilter); const [filterSystem, setFilterSystem] = useState(() => _store.filterSystem); const [filterVideo, setFilterVideo] = useState(() => _store.filterVideo); @@ -228,9 +229,13 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } setFilterLanguage(null); try { if (!isLocateDbLoaded()) { - setDbPhase('downloading'); - setDbProgress({ received: 0, total: null }); - await openLocateDb(p => flushSync(() => setDbProgress(p))); + 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 setDatabaseProgress({ received: p.received, total: p.total }); + })); setDbPhase('ready'); } const needle = query.trim(); @@ -296,16 +301,22 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder } toast.success(`Mounted "${mountEntry.name}" on ${deviceType} #${key}`); }; - const loadingLabel = dbPhase === 'downloading' - ? (dbProgress.received === 0 - ? 'Loading database…' - : dbProgress.total === null - ? `Loading database… ${humanFileSize(dbProgress.received)}` - : `Loading database… ${humanFileSize(dbProgress.received)} / ${humanFileSize(dbProgress.total)}`) - : 'Searching…'; + // 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 downloadPct = dbProgress.total && dbProgress.total > 0 - ? Math.min(100, Math.round((dbProgress.received / dbProgress.total) * 100)) + 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; @@ -450,7 +461,7 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }

{loadingLabel}

- {dbPhase === 'downloading' && ( + {(dbPhase === 'engine' || dbPhase === 'downloading') && (
{downloadPct}%

)} + {/* Step indicators so the user can see what's happening + when the network panel is closed. */} +
+ + 1. Engine + + + + 2. Database + +
)}
diff --git a/src/app/locate-db.ts b/src/app/locate-db.ts index c9befc8..0957186 100644 --- a/src/app/locate-db.ts +++ b/src/app/locate-db.ts @@ -2,6 +2,9 @@ import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; import { getWebDAVBaseUrl, basename, listDirectory, putFileContents, deletePath } from './webdav'; const LOCATE_PATH = '/sd/.locate'; +// Resolved against import.meta.url so it works under any Vite base path +// (/, /config/, etc.) the same way the sqlite-wasm library resolves it. +const WASM_URL = new URL('sqlite3.wasm', import.meta.url).toString(); function parseContentLength(header: string | null): number | null { if (!header) return null; @@ -9,12 +12,56 @@ function parseContentLength(header: string | null): number | null { return Number.isFinite(n) && n > 0 ? n : null; } -// Memoize the module init — loading the WASM binary is expensive. -let _sqlite3Promise: Promise | null = null; -function getSqlite3(): Promise { - if (!_sqlite3Promise) { - _sqlite3Promise = sqlite3InitModule({ print: () => {}, printErr: () => {} }); +// Fetch a URL and report progress as { received, total } chunks arrive. +// Returns the full byte array. Streams the body so progress is real-time +// (not just a single notification when the whole thing is buffered). +async function fetchWithProgress( + url: string, + onProgress?: (p: { received: number; total: number | null }) => void, +): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`Cannot fetch ${url}: ${response.status} ${response.statusText}`); + const total = parseContentLength(response.headers.get('content-length')); + if (!response.body) { + const buf = new Uint8Array(await response.arrayBuffer()); + onProgress?.({ received: buf.byteLength, total: total ?? buf.byteLength }); + return buf; } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + received += value.byteLength; + onProgress?.({ received, total }); + } + const combined = new Uint8Array(received); + let offset = 0; + for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } + return combined; +} + +// Memoize the module init — loading the WASM binary is expensive. +// We pre-fetch the WASM in the main thread (with progress reporting) +// and hand the bytes to Emscripten as Module.wasmBinary so it skips +// its own internal fetch. The sqlite-wasm worker still loads its own +// copy on first use, but the main-thread load (the one users see in +// the network panel as a 400 KB download) is gone. +let _sqlite3Promise: Promise | null = null; +function getSqlite3( + onProgress?: (p: { received: number; total: number | null }) => void, +): Promise { + if (_sqlite3Promise) return _sqlite3Promise; + _sqlite3Promise = (async () => { + const wasmBytes = await fetchWithProgress(WASM_URL, onProgress); + return sqlite3InitModule({ + print: () => {}, + printErr: () => {}, + wasmBinary: wasmBytes, + }); + })(); return _sqlite3Promise; } @@ -29,47 +76,31 @@ export function resetLocateDb(): void { _db = null; } +export type DbProgress = + | { kind: 'engine'; received: number; total: number | null } + | { kind: 'database'; received: number; total: number | null }; + /** - * 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. - * 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.). + * Fetch /sd/.locate and open it as an in-memory SQLite database. On the first + * call this also loads the SQLite WASM engine (~400 KB), so onProgress fires + * twice: first for the engine download, then for the locate database itself. + * Calling again when already loaded is a no-op unless you call + * resetLocateDb() first. */ export async function openLocateDb( - onProgress?: (progress: { received: number; total: number | null }) => void, + onProgress?: (p: DbProgress) => void, ): Promise { if (_db) return; - const sqlite3 = await getSqlite3(); + const sqlite3 = await getSqlite3( + p => onProgress?.({ kind: 'engine', received: p.received, total: p.total }), + ); const url = getWebDAVBaseUrl() + LOCATE_PATH; - const response = await fetch(url); - 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. - let bytes: Uint8Array; - if (response.body) { - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let received = 0; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - received += value.byteLength; - onProgress?.({ received, total }); - } - const combined = new Uint8Array(received); - let offset = 0; - for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.byteLength; } - bytes = combined; - } else { - bytes = new Uint8Array(await response.arrayBuffer()); - onProgress?.({ received: bytes.byteLength, total: total ?? bytes.byteLength }); - } + const bytes = await fetchWithProgress( + url, + p => onProgress?.({ kind: '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);