web: open the catalogue without a version another tab can block

This commit is contained in:
2026-09-28 20:16:58 +07:00
parent 52732a17ee
commit 3aedd67ca0
3 changed files with 60 additions and 55 deletions
+27
View File
@@ -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');
+8 -5
View File
@@ -6,7 +6,6 @@ import {
getPhoto,
listEditedIds,
listFolders,
listDirs,
listPhotos,
pickFolder,
readPhotoFile,
@@ -69,8 +68,10 @@ export function Library() {
const [folders, setFolders] = useState<LibraryFolder[]>([]);
const [ready, setReady] = useState<Set<string>>(new Set());
const [photos, setPhotos] = useState<LibraryPhoto[]>([]);
// 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<LibraryDir[]>([]);
const [edited, setEdited] = useState<Set<string>>(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();
},
+25 -50
View File
@@ -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<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
dbPromise ??= new Promise<IDBDatabase>((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<IDBDatabase> {
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<void> {
const ids = (await listPhotos(name)).map((p) => p.id);
const dirs = await listDirs(name);
const db = await openDb();
await new Promise<void>((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<string>();
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<void>((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<string>): Promise<void> {
const gone = (await listDirs(folder)).filter((d) => !keep.has(d.id));
if (!gone.length) return;
const db = await openDb();
await new Promise<void>((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<LibraryPhoto[]> {
@@ -397,16 +382,6 @@ export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {
}
}
// 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<LibraryDir[]> {
try {
return await ask<LibraryDir[]>(DIRS, 'readonly', (s) => (folder ? s.index('folder').getAll(folder) : s.getAll()));
} catch {
return [];
}
}
export async function getPhoto(id: string): Promise<LibraryPhoto | null> {
try {
return (await ask<LibraryPhoto | undefined>(PHOTOS, 'readonly', (s) => s.get(id))) ?? null;