// 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 — 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()` 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; rel: string; } interface Pending { dir: FileSystemDirectoryHandle; rel: string; } // 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; } // 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; // Folders a roll drags along that are never the reader's photographs. A leading // `.` or `@` is the `.thumbnails` a camera writes, `.git`, `.Trash`, and the // `@eaDir` a Synology puts beside every frame; the spelled-out names are the // recycle bins and system folders of Windows, Synology and Linux volumes, which // hold deleted or machine-written files rather than frames. Matching is // case-insensitive because the same folder is `$RECYCLE.BIN` on one volume and // `$Recycle.Bin` on the next. export const SYSTEM_DIR = /^[.@]|^\$RECYCLE\.BIN$|^RECYCLER$|^System Volume Information$|^#recycle$|^lost\+found$/i; // The folder a path names, asked from the root one segment at a time. A queue // that was written down as paths comes back as handles this way, and a path that // will not open answers null rather than a folder that is not there. export async function openAt(root: FileSystemDirectoryHandle, rel: string): Promise { let dir = root; try { for (const part of rel.split('/').filter(Boolean)) dir = await dir.getDirectoryHandle(part); } catch { return null; } return dir; } // 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 { 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; } const dir = await openAt(walk.root, rel); if (!dir) 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 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( walk: Walk, out: WalkFile[], seen: string[], keep: (name: string) => boolean, jump: () => string | null ): Promise { 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 { const baseRel = here.rel ? (here.rel.endsWith('/') ? here.rel : `${here.rel}/`) : ''; for await (const entry of here.dir.values()) { if (SYSTEM_DIR.test(entry.name)) continue; if (entry.kind === 'directory') { const rel = `${baseRel}${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: `${baseRel}${entry.name}` }); } } } catch { // A subfolder that will not open is skipped; the rest of the roll stands. } } return walk.pending.length === 0; }