web: name a roll's folders before reading their frames

This commit is contained in:
2026-09-28 20:06:10 +07:00
parent d0fddba1e8
commit 52732a17ee
5 changed files with 300 additions and 101 deletions
+64 -31
View File
@@ -1,14 +1,17 @@
// Walking a picked folder is the one part of the catalogue that can run for
// minutes: a roll is dated folders inside dated folders, and a library can hold
// thousands. The walk goes from the parent down, one layer at a time, so the
// frames nearest the top are indexed first — and a folder the reader clicks is
// put in front of the queue, so the branch in front of them is read before the
// layer it sits in would have come round. A pass covers `DIRS_PER_PASS`
// directories and then hands the thread back, which is what keeps the page
// answering while a scan runs.
// thousands. The walk goes from the parent down, one layer at a time — the picked
// folder and its own frames, then every folder one level down with theirs, then
// the level below that — so the names in the column and the frames near the top
// are on screen within the first pass. A folder the reader clicks is opened
// straight away and put in front of the queue, so the branch in front of them is
// read before the layer it sits in would have come round. A pass covers
// `DIRS_PER_PASS` directories and then hands the thread back, which is what keeps
// the page answering while a scan runs.
//
// Pure on purpose: it only reads `values()` off a directory handle, so a check
// can walk a stand-in tree without a browser (scripts/roll-walk-check.mjs).
// Pure on purpose: it only reads `values()` and `getDirectoryHandle()` off a
// directory handle, so a check can walk a stand-in tree without a browser
// (scripts/roll-walk-check.mjs).
export interface WalkFile {
handle: FileSystemFileHandle;
@@ -20,49 +23,79 @@ interface Pending {
rel: string;
}
// Directories one pass walks before it yields: small enough that a click is
// What the walk is: the folder everything is measured from, the directories still
// to read, and the ones already read. `rel` is how a folder is spelled here — ''
// for the picked one, `2026/04/` under it, always with the trailing slash.
export interface Walk {
root: FileSystemDirectoryHandle;
pending: Pending[];
walked: Set<string>;
}
// Directories one pass reads before it yields: small enough that a click is
// answered within a second, large enough that a folder of a few hundred is one
// pass rather than a hundred.
export const DIRS_PER_PASS = 200;
// The folder the reader clicked goes first. `rel` is how the queue spells a
// folder — '' for the picked one, `2026/04/` under it — and a folder already
// walked is simply not in the queue, which is the no-op case.
//
// ponytail: only a folder the queue already holds can jump, which is every
// folder the tree can draw, because a row exists per indexed frame. A tree that
// listed folders before their first frame would need `getDirectoryHandle` to
// descend to the clicked path here.
export function jumpQueue(pending: Pending[], rel: string | null): void {
if (rel === null) return;
const at = pending.findIndex((p) => p.rel === rel);
if (at > 0) pending.unshift(pending.splice(at, 1)[0]);
// The folder the reader clicked goes first. When it is not in the queue the walk
// has not reached it yet — its row is on screen because the parent pass named it
// — so it is opened from the picked folder and read next. A folder already read,
// or one that will not open, changes nothing.
export async function jumpQueue(walk: Walk, rel: string | null): Promise<void> {
if (!rel || walk.walked.has(rel)) return;
const at = walk.pending.findIndex((p) => p.rel === rel);
if (at >= 0) {
if (at > 0) walk.pending.unshift(walk.pending.splice(at, 1)[0]);
return;
}
let dir = walk.root;
for (const part of rel.split('/').filter(Boolean)) {
try {
dir = await dir.getDirectoryHandle(part);
} catch {
return;
}
}
// ponytail: the reader's folder jumps the whole queue, ancestors included —
// the folders between it and the picked one are read when their layer comes.
walk.pending.unshift({ dir, rel });
}
// One pass over the queue. The queue is the walk's own state: what is behind has
// been walked, what is ahead is in the order it was found — parents before
// children, shallower layers before deeper ones. Returns true when nothing is
// left to walk.
// been read, what is ahead is in the order it was found — parents before
// children, shallower layers before deeper ones. `seen` collects the folders this
// pass found, which is what the column draws before they hold a frame. Returns
// true when nothing is left to walk.
export async function walkPass(
pending: Pending[],
walk: Walk,
out: WalkFile[],
seen: string[],
keep: (name: string) => boolean,
jump: () => string | null
): Promise<boolean> {
for (let i = 0; i < DIRS_PER_PASS && pending.length; i++) {
jumpQueue(pending, jump());
const here = pending.shift()!;
for (let i = 0; i < DIRS_PER_PASS && walk.pending.length; i++) {
await jumpQueue(walk, jump());
const here = walk.pending.shift()!;
// A folder opened by a click is still in the queue its parent filled: it is
// read once, in the place the click put it.
if (walk.walked.has(here.rel)) continue;
walk.walked.add(here.rel);
try {
for await (const entry of here.dir.values()) {
// Names beginning with `.` or `@` are skipped — that is `.thumbnails`,
// `.git`, and the `@eaDir` a Synology writes beside every frame.
if (/^[.@]/.test(entry.name)) continue;
if (entry.kind === 'directory') pending.push({ dir: entry as FileSystemDirectoryHandle, rel: `${here.rel}${entry.name}/` });
else if (keep(entry.name)) out.push({ handle: entry as FileSystemFileHandle, rel: `${here.rel}${entry.name}` });
if (entry.kind === 'directory') {
const rel = `${here.rel}${entry.name}/`;
walk.pending.push({ dir: entry as FileSystemDirectoryHandle, rel });
seen.push(rel);
} else if (keep(entry.name)) {
out.push({ handle: entry as FileSystemFileHandle, rel: `${here.rel}${entry.name}` });
}
}
} catch {
// A subfolder that will not open is skipped; the rest of the roll stands.
}
}
return pending.length === 0;
return walk.pending.length === 0;
}