From 1cb2618fd7245ae665b81c675aa179a726324dba Mon Sep 17 00:00:00 2001 From: 3dtours Date: Mon, 28 Sep 2026 20:39:51 +0700 Subject: [PATCH] web: count a roll as it is read, and open a frame without losing the count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes left by the last change. A frame is opened with `window.location.href` rather than a link, so the studio was still handed a fresh page and the scan with it — every route out of the catalogue now goes through `go()`, which pushes the address instead of reloading while a scan is in flight. And a row counted what was filed away rather than what had been read, so it sat at zero for the whole of a first scan: the catalogue's frames only reach IndexedDB in one batch at the end. `ScanProgress.counts` carries the frames the scan has reached, per row, off the scan's own bookkeeping, and the column reads it while the scan runs — the row under the reader's eye moves as the roll is read, and the toolbar's line stays the whole picture. A re-read counts from the start, which is what it is doing. The regression check grows the two: a frame opened mid-scan from the catalogue (of a roll it already holds) has to stay in the page, and the row has to count. --- docker/frontend/scripts/scan-nav-check.mjs | 62 +++++++++++++++++++--- docker/frontend/src/Library.tsx | 20 ++++--- docker/frontend/src/engine/library.ts | 27 +++++++++- docker/frontend/src/main.tsx | 5 +- docker/frontend/src/nav.ts | 16 ++++++ 5 files changed, 113 insertions(+), 17 deletions(-) create mode 100644 docker/frontend/src/nav.ts diff --git a/docker/frontend/scripts/scan-nav-check.mjs b/docker/frontend/scripts/scan-nav-check.mjs index 2dd498e..6b1f736 100644 --- a/docker/frontend/scripts/scan-nav-check.mjs +++ b/docker/frontend/scripts/scan-nav-check.mjs @@ -90,8 +90,8 @@ const browser = await playwright.chromium.launch({ }); const context = await browser.newContext(); -// Documents this tab has loaded, across navigations: 1 means the click stayed in -// the page, 2 means the page was thrown away and reloaded. +// Documents this tab has loaded, across navigations: the count only moves when a +// click threw the page away and started a new one. await context.addInitScript(() => { const n = Number(sessionStorage.getItem('docLoads') ?? '0') + 1; sessionStorage.setItem('docLoads', String(n)); @@ -104,6 +104,10 @@ await context.addInitScript( if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); return bytes.get(name); }; + // A second scan of the same roll reads the same names again; dropping what the + // stand-in already holds makes it go back to the server for each of them, so + // a re-reading roll has the same minutes in it a first one does. + window.__forget = () => bytes.clear(); const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { @@ -112,7 +116,11 @@ await context.addInitScript( return handle; }; const dirHandle = (label, node) => { - const handle = { kind: 'directory', name: label }; + // `__fake` on a folder too: the catalogue reads its folders back out of the + // store after every remount, and a handle that does not say what it is comes + // back as a plain object with no `queryPermission` — a folder it cannot ask + // about is a folder it offers to reconnect. + const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => @@ -186,6 +194,11 @@ const progressText = (timeout) => const docs = () => page.evaluate(() => Number(sessionStorage.getItem('docLoads') ?? '0')); const tiles = () => page.$$eval('[data-key^="lib-thumb-"]', (els) => els.length); +// The count of documents standing when the page was last settled: every step that +// follows a link or a frame asserts against this, because a link followed with no +// scan in flight is *meant* to load the page again. +let seen = await docs(); +const noReload = async () => (await docs()) === seen; // Hand over to the studio while the roll is still being read, which is the whole // point: part of it in, the rest still to come. @@ -194,16 +207,17 @@ step('the roll is half read when the studio is opened', !!midway, String(midway) await page.click('[data-key="lib-studio"]'); await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 }); -step('the handover stays in the page, it does not reload it', (await docs()) === 1, `${await docs()} document load(s)`); +step('the handover stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`); step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url()); // Back to the catalogue mid-scan: the screen that started the scan is gone and // back, and the roll is still being read — the reading belongs to the tab. await page.click('[data-key="nav-library"]'); await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 }); -step('coming back stays in the page too', (await docs()) === 1, `${await docs()} document load(s)`); +step('coming back stays in the page too', await noReload(), `${await docs()} document load(s)`); const progress = await progressText(15_000); step('the catalogue comes back showing the scan it missed', !!progress, String(progress)); +await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 15_000 }); const tree = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key)); step('the tree is there, from the scan that outlived its screen', tree.includes('lib-node-SlowRoll'), JSON.stringify(tree)); @@ -217,7 +231,7 @@ for (let i = 0; i < 300 && indexed_to < TOTAL; i++) { if (indexed_to < TOTAL) await sleep(200); } step('the whole roll is read with the studio up', indexed_to === TOTAL, `${indexed_to}/${TOTAL} indexed`); -step('and the studio is still the page it was', (await docs()) === 1, `${await docs()} document load(s)`); +step('and the studio is still the page it was', await noReload(), `${await docs()} document load(s)`); await page.click('[data-key="nav-library"]'); await page.waitForFunction( @@ -228,6 +242,42 @@ await page.waitForFunction( step('every frame ends up as a thumbnail', (await tiles()) === TOTAL, `${await tiles()} tiles`); const grown = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key)); step('the tree ends up holding the deepest folder of the roll', grown.includes('lib-node-SlowRoll/2026/04'), JSON.stringify(grown)); +// Nothing was scanning at that last click, so that one did load the page again — +// which is the whole point of the guard. Everything below counts from here. +seen = await docs(); + +// Now the reader's own way into the studio, and the noisiest one for this page: a +// frame opened from a roll that is being read again. The rows are drawn from the +// catalogue it already has, so this is the one route out of here that the browser +// could take with no link anywhere in sight. +const rows = () => page.$eval('[data-key="lib-node-SlowRoll"] .adm-album-count', (el) => Number(el.textContent)); +await page.evaluate(() => window.__forget()); +await page.click('[data-key="lib-node-SlowRoll"]', { button: 'right' }); +await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); +const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); +const rescan = menu.find((k) => /^lib-(rescan|reconnect)-/.test(k)); +step('the roll can be read again from its own menu', !!rescan, JSON.stringify(menu)); +await page.click(`[data-key="${rescan}"]`); +await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 }); +await sleep(DELAY); +const early = await rows(); +await sleep(DELAY * 2); +const later = await rows(); +step('the row counts the reading as it comes, not the catalogue standing still', later !== early, `${early} → ${later} of ${TOTAL}`); +step('the strip is still the catalogue the roll left behind', (await tiles()) === TOTAL, `${await tiles()} tiles`); + +await page.click('[data-key="lib-open"]'); +await page.waitForSelector('[data-key="nav-library"]', { timeout: 30_000 }); +step('opening a frame stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`); +// The studio takes the frame out of the address as soon as it has it, so the +// route is what is left to say the handover arrived. +step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url()); + +await page.click('[data-key="nav-library"]'); +await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 }); +step('and the scan it interrupted is still the scan in hand', await noReload(), `${await docs()} document load(s)`); +const steady = await progressText(15_000); +step('the catalogue is still reading the roll it was reading', !!steady, String(steady)); await browser.close(); app.kill('SIGTERM'); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index bc030fc..8a7da6a 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react'; import { useI18n } from './i18n/I18nProvider'; +import { go } from './nav'; import { canBrowseFolders, ensurePermission, @@ -272,7 +273,9 @@ export function Library() { ); // The frame goes to the studio through the address bar: the studio owns the - // load (develop, EXIF, session) and this page owns no part of it. + // load (develop, EXIF, session) and this page owns no part of it. While a scan + // is running the address bar is the one thing that changes — `go` keeps the + // reading where it is instead of reloading the page out from under it. const open = useCallback(async (photo: LibraryPhoto) => { const row = await getPhoto(photo.id); if (!row) return; @@ -284,7 +287,7 @@ export function Library() { setNote(t('lib.missing', { name: row.name })); return; } - window.location.href = `/app?lib=${encodeURIComponent(photo.id)}`; + go(`/app?lib=${encodeURIComponent(photo.id)}`); }, [t]); const rootName = node ? node.split('/')[0] : null; @@ -297,9 +300,14 @@ export function Library() { // The tree: one row per picked folder, then one per subfolder, indented by its // depth — the folders the walk has named, whether or not a frame has been read - // out of them, each carrying how many frames sit at or under it. + // out of them, each carrying how many frames sit at or under it. While a scan is + // running the count is the scan's own, so a row says how far the reading has + // come; the frames only reach the catalogue in one batch at the end, and a row + // that stood still until then would say nothing at all. + const read = live?.progress.counts ?? null; const nodes = useMemo(() => { const hits = new Map(); + const counted = (key: string, hit: number | undefined) => read?.[key] ?? hit ?? 0; const foldersUnder = new Map>(); // One path per folder between the picked one and `rel`, so a row is drawn for // every folder on the way to a frame as well as for the frame's own. @@ -322,7 +330,7 @@ export function Library() { for (const dir of dirs) add(dir.folder, dir.rel, false); const out: Node[] = []; for (const folder of folders) { - out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: hits.get(folder.name) ?? 0 }); + out.push({ key: folder.name, label: folder.label || folder.name, depth: 0, count: counted(folder.name, hits.get(folder.name)) }); // Path order puts a folder before its own children, which is the order the // indented rows have to come in. for (const dir of [...(foldersUnder.get(folder.name) ?? [])].sort((a, b) => a.localeCompare(b))) { @@ -331,12 +339,12 @@ export function Library() { key: `${folder.name}/${dir}`, label: parts[parts.length - 1], depth: parts.length, - count: hits.get(`${folder.name}/${dir}`) ?? 0, + count: counted(`${folder.name}/${dir}`, hits.get(`${folder.name}/${dir}`)), }); } } return out; - }, [dirs, folders, photos]); + }, [dirs, folders, photos, read]); // A row with anything under it is the one that folds: its key is the prefix of // its children's, in the path order the rows come in. diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index fb32991..e1654e6 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -253,6 +253,13 @@ export interface ScanProgress { total: number; done: number; added: number; + // How many frames the scan has read out of each row of the tree, keyed the way + // the tree spells a row (`folder` for the picked folder, `folder/sub/dir` for + // the rest) and counted the way the rows count: a frame sits on its own row and + // on every row above it. A fresh object every frame, so a screen drawing the + // column sees a change; the frames themselves only reach the catalogue in one + // batch at the end, which would leave every row reading zero until then. + counts: Record; // 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. @@ -284,7 +291,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, dirs: [] }; + const progress: ScanProgress = { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} }; + const counts: Record = {}; + // Count the frame the moment the scan gets to it, before it knows whether the + // frame is new: the rows say how far the reading has come, not what it kept. + const count = (rel: string) => { + const cut = rel.lastIndexOf('/'); + counts[folder.name] = (counts[folder.name] ?? 0) + 1; + let path = ''; + for (const part of (cut < 0 ? '' : rel.slice(0, cut)).split('/')) { + if (!part) continue; + path = path ? `${path}/${part}` : part; + const key = `${folder.name}/${path}`; + counts[key] = (counts[key] ?? 0) + 1; + } + progress.counts = { ...counts }; + }; // 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[] = []; @@ -333,6 +355,7 @@ export async function scanFolder( break; } progress.done++; + count(rel); try { const file = await handle.getFile(); const id = photoId(folder.name, rel); @@ -423,7 +446,7 @@ export function startScan(folder: LibraryFolder): Promise { if (live) return Promise.reject(new Error('a scan is already running')); const session: ScanSession = { folder: folder.name, - progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [] }, + progress: { folder: folder.name, total: 0, done: 0, added: 0, dirs: [], counts: {} }, stop: false, jump: null, }; diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx index 5a5f286..8142b2a 100644 --- a/docker/frontend/src/main.tsx +++ b/docker/frontend/src/main.tsx @@ -13,6 +13,7 @@ import { ProfilePage } from './ProfilePage'; import { PhotosPage } from './PhotosPage'; import { Library } from './Library'; import { scanSession } from './engine/library'; +import { go } from './nav'; import { InstallPrompt } from './ui/InstallPrompt'; import { installTracking } from './track'; @@ -57,9 +58,7 @@ function Shell() { if (url.origin !== window.location.origin || url.hash) return; if (url.pathname + url.search === here) return; e.preventDefault(); - history.pushState(null, '', url.pathname + url.search); - setRoute(url.pathname + url.search); - window.scrollTo(0, 0); + go(url.pathname + url.search); }; const onPop = () => setRoute(window.location.pathname + window.location.search); document.addEventListener('click', onClick); diff --git a/docker/frontend/src/nav.ts b/docker/frontend/src/nav.ts new file mode 100644 index 0000000..8600dba --- /dev/null +++ b/docker/frontend/src/nav.ts @@ -0,0 +1,16 @@ +import { scanSession } from './engine/library'; + +// Internal navigation, in one place. The app has no router: a route is a page +// load, and that is fine for everything except a route change made while a scan +// is reading a roll, because the reading lives in this page's memory and a reload +// throws it away. So while a scan is in flight the route changes in place — +// push the address, tell whoever draws it — and with no scan running the browser +// navigates exactly as it always has. +export function go(url: string): void { + if (!scanSession()) { + window.location.href = url; + return; + } + history.pushState(null, '', url); + window.dispatchEvent(new PopStateEvent('popstate')); +}