web: keep reading a roll while the studio is up

The catalogue hands the visitor to the studio with a plain `<a href>`, and the
app has no router: following it threw the page away, so a scan that was halfway
through a roll died with it. A scan belongs to the tab, not to the screen that
started it.

The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`,
watched by whoever is up), so the catalogue can unmount and come back to a
reading that never stopped; a screen that returns joins it and sees the toolbar
line, the stop button and the rows filling in. The internal links are taken over
for a history push *only while a scan is in flight* — everywhere else the
browser navigates exactly as before, so the landing-to-studio flow is untouched.

`scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time
off a slow server, handed to the studio mid-scan, back to the catalogue, and the
whole roll read to the end with the studio up, counting documents along the way.
This commit is contained in:
2026-09-28 20:28:00 +07:00
parent 3aedd67ca0
commit 79b0d0db86
4 changed files with 414 additions and 57 deletions
+75
View File
@@ -368,6 +368,81 @@ export async function scanFolder(
return progress;
}
// --- the scan in flight ----------------------------------------------------
// A scan belongs to the tab, not to the screen it was started from. A roll takes
// minutes, and the reader who started it has every reason to go to the studio and
// work on a frame while it runs — so the screen that started it may well be gone
// before the scan is. What that screen would have held lives here instead: one
// scan at a time, watched by whichever screen is up.
export interface ScanSession {
// The folder being read, by name.
folder: string;
progress: ScanProgress;
// What the reader has asked for since: stop, or read this folder next (the path
// as `rollWalk` spells it — `''` for the picked folder itself).
stop: boolean;
jump: string | null;
}
let live: ScanSession | null = null;
const watchers = new Set<() => void>();
const tell = () => {
for (const fn of watchers) fn();
};
// The scan in flight, or null. The same object for the whole of the scan, with
// `progress` replaced on every pass — watchers are told, so they re-read it.
export function scanSession(): ScanSession | null {
return live;
}
// Watch, and get the unsubscribe back: the shape an effect already has.
export function watchScan(fn: () => void): () => void {
watchers.add(fn);
return () => {
watchers.delete(fn);
};
}
export function stopScan(): void {
if (!live) return;
live.stop = true;
tell();
}
export function jumpScan(rel: string | null): void {
if (live) live.jump = rel;
}
// Start reading a roll. Only one at a time: the walk reads one frame at a time on
// this thread, so a second scan would only slow the first one down. The promise
// settles when the scan does — the caller that started it may be long gone.
export function startScan(folder: LibraryFolder): Promise<ScanProgress> {
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: [] },
stop: false,
jump: null,
};
live = session;
tell();
return scanFolder(
folder,
(p) => {
session.progress = p;
tell();
},
() => session.stop,
() => session.jump
).finally(() => {
live = null;
tell();
});
}
// --- reading ---------------------------------------------------------------
export async function listPhotos(folder?: string): Promise<LibraryPhoto[]> {