fix(SearchLocal, locate-db): enhance loading progress tracking for engine and database with visual indicators
This commit is contained in:
parent
65bf69fb2a
commit
7f218f4225
|
|
@ -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
|
||||
|
|
|
|||
67
index.html
67
index.html
|
|
@ -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>
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user