diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 17e46ab..3dff333 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -447,6 +447,33 @@ await page.waitForTimeout(300); const walked = await page.$eval('[data-key="lib-strip"]', (el) => el.scrollLeft); step('a wheel tick walks the strip along', shelf.sw > shelf.cw && walked > 0, `${shelf.sw}px of tiles in ${shelf.cw}px, scrolled to ${walked}`); +// A catalogue another tab has open — the tab that is still running yesterday's +// build, holding the connection the new build used to ask for a version on. The +// screen has to come up on it: the catalogue is opened as it is, with no version +// to reach for and nothing for the other tab to block. +const hold = await context.newPage(); +await hold.route('**/assets/*.js', (r) => r.abort()); +await hold.goto(`${base}/library`).catch(() => {});const held = await hold.evaluate( + () => + new Promise((resolve, reject) => { + const req = indexedDB.open('recipescam-library'); + req.onsuccess = () => { + window.__held = req.result; + resolve(req.result.version); + }; + req.onerror = () => reject(req.error); + }) +); +await page.setViewportSize({ width: 1280, height: 720 }); +await page.goto(`${base}/library`); +await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); +const seenAgain = await page.$$eval(thumbs, (els) => els.length); +step( + 'a catalogue another tab is holding still comes up', + held >= 1 && seenAgain === 3, + `held at version ${held}, ${seenAgain} tiles on the other tab` +); + await browser.close(); sampleServer.close(); app.kill('SIGTERM'); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index b80f3c0..036e312 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -6,7 +6,6 @@ import { getPhoto, listEditedIds, listFolders, - listDirs, listPhotos, pickFolder, readPhotoFile, @@ -69,8 +68,10 @@ export function Library() { const [folders, setFolders] = useState([]); const [ready, setReady] = useState>(new Set()); const [photos, setPhotos] = useState([]); - // The folders a scan has walked into. A roll's own shape is on screen from the - // first pass, before the frames under its folders have been read. + // The folders the scan in flight has walked into: a roll's own shape is on + // screen from the first pass, before the frames under its folders are read. + // What a scan found is not filed away — the next visit draws the folders that + // hold frames, and the next scan names the rest again. const [dirs, setDirs] = useState([]); const [edited, setEdited] = useState>(new Set()); // Which tree node is open, which frame is up, and which of the two middle @@ -111,10 +112,9 @@ export function Library() { const [loaded, setLoaded] = useState(false); const reload = useCallback(async () => { - const [rows, edits, dirs] = await Promise.all([listPhotos(), listEditedIds(), listDirs()]); + const [rows, edits] = await Promise.all([listPhotos(), listEditedIds()]); setPhotos(rows); setEdited(edits); - setDirs(dirs); }, []); // The catalogue outlives the tab; the permission to read those folders does @@ -189,6 +189,7 @@ export function Library() { folder, (p) => { setProgress(p); + setDirs(p.dirs); if (p.done === 0 || Date.now() - last > 700) { last = Date.now(); void reload(); @@ -198,6 +199,7 @@ export function Library() { () => jumpRef.current ); setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); + setDirs(result.dirs); await reload(); } catch { setNote(t('lib.failed')); @@ -257,6 +259,7 @@ export function Library() { await removeFolder(folder.name); const rest = folders.filter((f) => f.name !== folder.name); setFolders(rest); + setDirs((prev) => prev.filter((d) => d.folder !== folder.name)); setNode(rest[0]?.name ?? null); await reload(); }, diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 4ee795a..089c4f1 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -21,9 +21,6 @@ const DB_NAME = 'recipescam-library'; const FOLDERS = 'folders'; const PHOTOS = 'photos'; const EDITS = 'edits'; -// The folders a scan has walked into. They are names before they are frames: the -// column draws the shape of a roll while the frames are still being read. -const DIRS = 'dirs'; // The grid cell is ~220px wide and a retina display doubles it: 512 on the long // edge is the largest a tile ever shows, and it costs ~30KB per frame. @@ -59,8 +56,10 @@ export interface LibraryPhoto { addedAt: number; } -// A folder the walk has found, whether or not a frame has been read out of it -// yet: the column draws a roll from these before its frames arrive. +// A folder a scan has walked into, whether or not a frame has been read out of +// it yet: the column draws a roll from these before its frames arrive. They are +// the scan's own report and live no longer than it does — a reload draws the +// folders that hold frames, and the next scan names the rest again. export interface LibraryDir { // `${folder}/${rel}` — the tree key, the same way a frame id is spelled. id: string; @@ -96,7 +95,12 @@ let dbPromise: Promise | null = null; function openDb(): Promise { dbPromise ??= new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 2); + // No version on purpose. A version the browser has to reach for is a version + // another tab can block: a tab still running the previous build holds the + // catalogue open, the upgrade waits on it, and this one never gets an answer + // — the screen comes up empty on a catalogue that is all there. Opening + // without one takes the catalogue as it is and creates it when it is missing. + const req = indexedDB.open(DB_NAME); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(FOLDERS)) db.createObjectStore(FOLDERS, { keyPath: 'name' }); @@ -106,9 +110,6 @@ function openDb(): Promise { photos.createIndex('taken', 'taken'); } if (!db.objectStoreNames.contains(EDITS)) db.createObjectStore(EDITS, { keyPath: 'photoId' }); - if (!db.objectStoreNames.contains(DIRS)) { - db.createObjectStore(DIRS, { keyPath: 'id' }).createIndex('folder', 'folder'); - } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -163,18 +164,15 @@ export async function renameFolder(folder: LibraryFolder, label: string): Promis export async function removeFolder(name: string): Promise { const ids = (await listPhotos(name)).map((p) => p.id); - const dirs = await listDirs(name); const db = await openDb(); await new Promise((resolve, reject) => { - const tx = db.transaction([FOLDERS, PHOTOS, EDITS, DIRS], 'readwrite'); + const tx = db.transaction([FOLDERS, PHOTOS, EDITS], 'readwrite'); tx.objectStore(FOLDERS).delete(name); const photos = tx.objectStore(PHOTOS); for (const id of ids) { photos.delete(id); tx.objectStore(EDITS).delete(id); } - const store = tx.objectStore(DIRS); - for (const dir of dirs) store.delete(dir.id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); @@ -255,6 +253,10 @@ export interface ScanProgress { total: number; done: number; added: number; + // Every folder the walk has been into so far, a fresh list each time: the + // column draws them while the frames under them are still being read. Not + // filed away — a folder name is worth nothing once the scan it came from ends. + dirs: LibraryDir[]; } // Walk the folder, keep what is new or changed, and leave the rest alone: a @@ -282,24 +284,22 @@ export async function scanFolder( // whole scan so a folder that has gone from the disk goes from the column too. const names: string[] = []; const found = new Set(); - const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0 }; + const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] }; + // The folders this scan has walked into, kept for the whole of it: the column + // is redrawn from this list, so it only ever grows. + const dirs: LibraryDir[] = []; let batch: LibraryPhoto[] = []; - let dirs: LibraryDir[] = []; // One transaction per batch, a put per row: a store with `keyPath: 'id'` takes // a record, not an array of them. const flush = async () => { - if (!batch.length && !dirs.length) return; + if (!batch.length) return; const rows = batch; - const folders = dirs; batch = []; - dirs = []; const db = await openDb(); await new Promise((resolve, reject) => { - const tx = db.transaction([PHOTOS, DIRS], 'readwrite'); + const tx = db.transaction(PHOTOS, 'readwrite'); const photos = tx.objectStore(PHOTOS); for (const row of rows) photos.put(row); - const store = tx.objectStore(DIRS); - for (const dir of folders) store.put(dir); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); @@ -322,7 +322,10 @@ export async function scanFolder( found.add(id); dirs.push({ id, folder: folder.name, rel: rel.slice(0, -1) }); } - if (dirs.length >= BATCH) await flush(); + // The column is handed the names the moment the pass is through, before a + // single frame under them has been read. A copy, so the redraw has something + // new to look at rather than the list growing under it. + progress.dirs = dirs.slice(); progress.total += entries.length - from; for (const { handle, rel } of entries.slice(from)) { if (shouldStop?.()) { @@ -362,27 +365,9 @@ export async function scanFolder( } } while (!walked && !stop); await flush(); - // A stopped scan has not seen the whole roll, so only a walk that reached the - // end may say which folders are gone. - if (walked) await pruneDirs(folder.name, found); return progress; } -// The folders that are no longer under the picked one: a rescan of a roll that -// has been tidied on the disk takes the tidied rows out of the column with it. -async function pruneDirs(folder: string, keep: Set): Promise { - const gone = (await listDirs(folder)).filter((d) => !keep.has(d.id)); - if (!gone.length) return; - const db = await openDb(); - await new Promise((resolve, reject) => { - const tx = db.transaction(DIRS, 'readwrite'); - const store = tx.objectStore(DIRS); - for (const dir of gone) store.delete(dir.id); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); -} - // --- reading --------------------------------------------------------------- export async function listPhotos(folder?: string): Promise { @@ -397,16 +382,6 @@ export async function listPhotos(folder?: string): Promise { } } -// The folders a scan has walked into, whether or not a frame has been read out -// of them yet — the column draws these as soon as the parent pass named them. -export async function listDirs(folder?: string): Promise { - try { - return await ask(DIRS, 'readonly', (s) => (folder ? s.index('folder').getAll(folder) : s.getAll())); - } catch { - return []; - } -} - export async function getPhoto(id: string): Promise { try { return (await ask(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;