fix(SearchLocal, locate-db): enhance loading progress tracking for engine and database with visual indicators

This commit is contained in:
Jaime Idolpx 2026-06-14 21:15:50 -04:00
parent 65bf69fb2a
commit 7f218f4225
4 changed files with 173 additions and 54 deletions

View File

@ -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 `<script>` attaches a `MutationObserver` to `#root` and fades the splash out (250 ms) once React replaces it. If JS or the bundle fails, the splash stays visible — never a blank page.
41. **MediaViewerEditor tiled background** — icon tile overlay (`z-index: -1`) inside a `z-index: 0` stacking context; sub-components (HexEditor, ConfigEditor, CodeEditor) use transparent backgrounds so the tile shows through
42. **HexEditor responsive columns**`ResizeObserver` on scroll container switches between 8 columns (< 600px) and 16 columns (≥ 600px); address column brightened to `text-neutral-400`
43. **ProfilePage**`ProfilePage.tsx` replaces the header dropdown; iOS-style grouped list with Preferences → GeneralPage, Notifications (stub), Documentation (stub), About Meatloaf → AboutMeatloafPage, Log Out; profile button in header navigates to `'profile'` page key

View File

@ -18,12 +18,77 @@
<style>
html, body { height: 100%; margin: 0; overscroll-behavior: none; }
#root { height: 100%; }
/* Pre-React splash: shown from first paint until React mounts and
replaces #root. Visually matches the in-app LazyLoader (dark
backdrop, blue progress bar, "Loading…" label) so the handoff is
seamless. Pure CSS + inline SVG — no JS, no extra requests. */
#splash {
position: fixed; inset: 0; z-index: 40;
background: #0a0a0a;
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #525252; font: 500 12px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
letter-spacing: 0.05em;
transition: opacity 250ms ease-out;
}
#splash.gone { opacity: 0; pointer-events: none; }
#splash .bar {
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: #262626; overflow: hidden;
}
#splash .bar::after {
content: ""; display: block; height: 100%; width: 30%;
background: #3b82f6;
animation: splash-bar 1500ms ease-in-out infinite;
}
#splash .ring {
width: 24px; height: 24px; margin-bottom: 12px;
border: 2px solid #262626; border-top-color: #525252;
border-radius: 50%;
animation: splash-spin 800ms linear infinite;
}
#splash .icon { width: 48px; height: 48px; margin-bottom: 16px; opacity: 0.7; }
@keyframes splash-spin { to { transform: rotate(360deg); } }
@keyframes splash-bar { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
</style>
</head>
<body>
<div id="root"></div>
<div id="root">
<div id="splash" role="status" aria-live="polite" aria-label="Loading Meatloaf Manipulator">
<div class="bar"></div>
<!-- Meatloaf logo from public/assets/icon.svg, inlined as a data URI
so it works under any Vite base path (/, /config/, etc.) and
adds no extra HTTP request during the initial bundle load. -->
<img class="icon" alt="" aria-hidden="true"
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iM2luIiBoZWlnaHQ9IjNpbiIgdmlld0JveD0iMCAwIDYyOS4wMDcgNjI5LjAwNyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBmaWxsPSIjNGQ0ZDRkIiBzdHJva2U9IiM0ZDRkNGQiIHN0cm9rZS13aWR0aD0iMjcuOTc0IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGQ9Ik0xNC4zMTggMTMuNzQ5aDYwMC42NjZ2NjAxLjA5NUgxNC4zMTh6Ii8+PGcgc3Ryb2tlLXdpZHRoPSIuMTE0Ij48cGF0aCBkPSJNNTE4LjM3IDQ2MC4yOTVsLjAxMiA3NC41MjhoNzQuNTE1di03NC4zeiIgZmlsbD0iI2ZhNTc0YSIgc3Ryb2tlPSIjZmE1NzRhIiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDM2Ni45MDNsLjAxMiA3NC41MjdoNzQuNTE2di03NC4zeiIgZmlsbD0iI2ZjOTE0OSIgc3Ryb2tlPSIjZmM5MTQ5IiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDI3NS45MDNsLjAxMiA3NC41MjdoNzQuNTE2di03NC4zeiIgZmlsbD0iI2VkZTI0YyIgc3Ryb2tlPSIjZWRlMjRjIiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDE4NC45MDNsLjAxMiA3NC41MjhoNzQuNTE2di03NC4zeiIgZmlsbD0iIzkxY2I0MSIgc3Ryb2tlPSIjOTFjYjQxIiBzdHJva2Utd2lkdGg9Ii40Njk3NjY2NCIvPjxwYXRoIGQ9Ik01MTguMzY5IDkzLjg2NGwuMDEyIDc0LjUyOGg3NC41MTZ2LTc0LjN6IiBmaWxsPSIjNzZjNGYyIiBzdHJva2U9IiM3NmM0ZjIiIHN0cm9rZS13aWR0aD0iLjQ2OTc2NjY0Ii8+PC9nPjxnIGZpbGw9IiNmZmZmZmIiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIuMjY1Ij48cGF0aCBkPSJNMzcuMDkyIDk1LjA2NmwyMDYuNjEyIDIwNi42MTIuMzM2IDIzMy4xNTItMjA3LjYyLS4zMzZ6TTQ5Ni40MTggOTUuMDY4TDI4OS44MDUgMzAxLjY4bC0uMzM2IDIzMy4xNTMgMjA3LjYyMS0uMzM2eiIgc3Ryb2tlLXdpZHRoPSIxLjA5MjAwMTQiLz48L2c+PC9zdmc+" />
<div class="ring"></div>
<span>Loading…</span>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
<script>
// Fade the splash as React takes over. React renders into #root and
// replaces its children, so we listen for that swap and animate the
// old splash out instead of just letting it disappear.
(function () {
var root = document.getElementById('root');
if (!root) return;
var splash = document.getElementById('splash');
if (!splash) return;
// Use a MutationObserver to detect when React replaces the splash.
// The splash node is removed entirely when React renders <App />.
new MutationObserver(function () {
if (!document.getElementById('splash')) return;
if (root.firstChild && root.firstChild.id !== 'splash') {
splash.classList.add('gone');
setTimeout(function () { splash.remove(); }, 300);
}
}).observe(root, { childList: true });
// Hard fallback: if JS-driven React never mounts (network failure,
// bundle error, etc.), keep the splash visible rather than showing
// a blank page. We do nothing here; the splash is the fallback.
})();
</script>
</body>
</html>

View File

@ -175,8 +175,9 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
const [mountEntry, setMountEntry] = useState<SearchResult | null>(null);
const [actionEntry, setActionEntry] = useState<SearchResult | null>(null);
const [searchError, setSearchError] = useState<string | null>(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<string | null>(() => _store.filterSystem);
const [filterVideo, setFilterVideo] = useState<string | null>(() => _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 }
<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" />
<p className="text-sm text-neutral-500 text-center">{loadingLabel}</p>
{dbPhase === 'downloading' && (
{(dbPhase === 'engine' || dbPhase === 'downloading') && (
<div className="w-full max-w-xs">
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
<div
@ -461,6 +472,17 @@ export default function SearchLocal({ config, setConfig, onClose, onOpenFolder }
{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>

View File

@ -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<any> | null = null;
function getSqlite3(): Promise<any> {
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<Uint8Array> {
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<any> | null = null;
function getSqlite3(
onProgress?: (p: { received: number; total: number | null }) => void,
): Promise<any> {
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<void> {
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);