fix(app): keep the app on its feet — a boundary, one window, and a reading that ends

- boundary: src/ui/ErrorBoundary.tsx, around the whole app rather than around a
  screen. A throw on the way to the first paint used to be a white page with
  nothing in it to report; it is a sentence, the message and the stack now, with
  a reload beside them, so the next report of a crash carries what went wrong.

- one window: src/ui/SingleTab.tsx holds the app to one tab over localStorage and
  a BroadcastChannel. A second tab on /app or /library is told so and offered the
  roll — "use this tab" takes it, and the tab that held it gets a bar and the
  offer back. A tab holding a reading is a tab the machine has already spent its
  memory on, and a second one beside it is the memory the first will not get.

- mount: the studio and the library are mounted the first time their route is
  asked for and stay mounted after that, not on every visit. Booting CanvasKit
  (~20MB) and reading the whole catalogue on a page that shows neither is the
  read and the memory a machine short of either takes for a crash; a trip back
  from the studio is a screen opening rather than a page loading.

- reading: READ_MS bounds one touch of the disk at 30s. getFile and arrayBuffer
  on a drive that has gone away do not fail, they never settle — the lane waits
  for ever, the reading never ends, and STOP is a button with nothing left to
  read it. A frame given up on is a frame the next reading takes up again.

- reading: the rows of a walk go down a pass at a time, so a tab that never comes
  back leaves the column with the folders this reading walked rather than the ones
  the last reading did.

- reading: a stopped reading hands over the numbers it counted instead of null.
  Handed null it threw its own count away and had noughts written for every folder
  it walked that the file did not already hold, so a folder added to a drive read
  nought until the next scan.

- repair: STOP ends the afternoon. A repair is a loop the button has no handle on,
  so it holds the count of stops it started at and compares between rolls, and
  says it was cut short rather than reporting a run it did not make.

- add: a folder the browser could not name — every drive root on Windows is "\" —
  is asked for one, and a folder whose name is already another folder's is refused
  rather than folded in with it. The record, the frame ids, the walk's position
  and the number the column draws are all keyed by that name.

- wall: four hundred tiles queued ahead is a reader waiting on frames three
  screens below the one they are looking at; sixty is a screenful and the wall
  mints the rest as it scrolls. The catalogue is read back every 30s, not every
  5s — reading it back costs every thumbnail in it.

- library: the note is drawn under the frame rather than above it. A line that
  comes and goes in the flow moved the frame, the column and the strip by its own
  height every time a scan started or ended.

- manifest: "id": "/", so the installed app and the tab are one app.

- checks: tree-scan-check waited for a count of exactly four rows — a state the
  screen does not have. The wait ran out its clock and then measured whatever the
  reading had done since; it waits for the whole tree now.
This commit is contained in:
2026-10-10 17:46:44 +07:00
parent efa162f2ad
commit 7f455ef987
10 changed files with 532 additions and 52 deletions
+115 -14
View File
@@ -44,6 +44,7 @@ import {
setStar,
stopScan,
stopScanAt,
stopCount,
unfinished,
watchRows,
watchScan,
@@ -211,7 +212,12 @@ const WALL_LANES = 2;
// the rest as it scrolls. A job over every frame of the roll is a decode of every
// frame of the roll — half an hour of disk for a folder nobody has scrolled past
// the first screen of.
const THUMB_AHEAD = 400;
// Sixty and not four hundred: a tile is a decode and a JPEG encode on the main
// thread (`makeThumbs`, two lanes), and four hundred of them queued behind one
// folder opening is the reader waiting on tiles for frames three screens below
// the one they are looking at. The wall mints the rest as it scrolls, which is
// who the queue was for.
const THUMB_AHEAD = 60;
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
// distance past the last row it replaces as the row itself would have.
const WALL_GAP = 12;
@@ -257,7 +263,7 @@ function keepShot(id: string, promise: Promise<Shot | null>): void {
// same trade — the reading is watched, not driven — and both are seconds for the
// same reason: the roll is the long thing, and neither the strip nor a folder
// that has not moved is worth a frame of it.
const CATALOGUE_MS = 5000;
const CATALOGUE_MS = 30000;
const CATCHUP_MS = 30000;
// The two refusals the write comes back with when the folder is not there or the
@@ -274,6 +280,23 @@ const why = (err: unknown) => {
return err instanceof Error ? err.message : String(err);
};
// Whether two handles are the same folder on the disk. It is what tells a reader
// picking the folder they already have — a permission to hand back, a roll to
// read again — from a second folder that happens to be called the same thing.
// The API is Chromium's own; a browser without it is a browser where every
// same-named folder is taken for another one, which is the older behaviour.
async function sameDirectory(
a: FileSystemDirectoryHandle,
b: FileSystemDirectoryHandle | undefined
): Promise<boolean> {
if (!b || typeof a.isSameEntry !== 'function') return false;
try {
return await a.isSameEntry(b);
} catch {
return false;
}
}
// The catalogue is for people with an account. The shelves hold nothing the app
@@ -650,6 +673,15 @@ function Catalogue({ admin }: { admin: boolean }) {
// one that says the scan is through — is recognisable as an end rather than
// as a screen with nothing to do.
let was: string | null = null;
// Whether the reading that has just left was stopped rather than finished.
// The copy out is made for a reading that ran to its end; one that was called
// off gets nothing but the read back.
let stopped = false;
// The stop count the reading started at. A reading hands over its folder and
// its numbers and nothing about why it ended — a stop is not in the record it
// leaves — so the screen works it out on the count the engine keeps of every
// stop, this window's or the one beside's.
let stopMark = 0;
// How much of the roll the catalogue was last read back at, and the shape the
// column was last drawn at: reading the whole catalogue back costs every
// thumbnail in it, so it waits for a batch to have landed rather than for a
@@ -678,22 +710,35 @@ function Catalogue({ admin }: { admin: boolean }) {
// one beside it — and the window watching never saw it otherwise, because a
// reading that is through has nothing left to hand over.
if (was && !folder) {
if (stopCount() !== stopMark) stopped = true;
was = null;
void reload();
afterScan.current();
// A reading the reader stopped is a reading that did not finish: the copy
// out reads the whole catalogue and every tile in it, and the peak that
// makes is the thing the STOP press was getting away from.
if (!stopped) afterScan.current();
stopped = false;
return;
}
// Where a reading's start is counted from: the first poll that sees a
// folder there is the first poll after the reading began.
if (!was && folder) stopMark = stopCount();
was = folder;
if (!session) return;
const { progress } = session;
const shape = progress.dirs.length ? `${progress.dirs.length}:${progress.dirs[progress.dirs.length - 1].id}` : '';
// A window beside the reading is handed the progress of another build, and
// a build whose progress carries no folder list at all is a build whose
// reading this is not — reading a length off it is a throw, and a throw in
// here leaves the scan unwatched in this window for the rest of its life.
const dirs = progress.dirs ?? [];
const shape = dirs.length ? `${dirs.length}:${dirs[dirs.length - 1].id}` : '';
if (shape !== drew) {
drew = shape;
if (progress.dirs.length) {
if (dirs.length) {
setDirs((prev) => {
const map = new Map(prev.map((d) => [normPath(d.id).toLowerCase(), d]));
let changed = false;
for (const d of progress.dirs) {
for (const d of dirs) {
const k = normPath(d.id).toLowerCase();
if (!map.has(k)) {
map.set(k, d);
@@ -894,13 +939,21 @@ function Catalogue({ admin }: { admin: boolean }) {
try {
const result = await scanAsked(folder, from, mode);
if (!result) return;
if (!quiet) setNote(t('lib.scanned', { folder: from ? `${folder.name}/${from.slice(0, -1)}` : folder.name, added: result.added, total: result.total }));
// The name the reader gave a folder wins over the one the browser had
// for it: a drive root's own name is `\`, which is no name at all. See
// the label the ADD passes to `putFolder`.
const named = folder.label || folder.name;
if (!quiet) setNote(t('lib.scanned', { folder: from ? `${named}/${from.slice(0, -1)}` : named, added: result.added, total: result.total }));
// A reading kept to one folder names the folders under that one, which
// is no reason to fold the column down to them: the roll is still the
// roll, and the rows outside the folder being re-read are rows the
// reader is looking at.
if (!from) setDirs(result.dirs);
await reload();
// The repair reads every roll in the catalogue one after another, and a
// read of the whole catalogue between two rolls — plus the copy out
// `afterScan` makes after each one — is the repair's own way of running
// a page out of memory. It reads back once, at its end.
if (!quiet) await reload();
} catch {
setNote(t('lib.failed'));
}
@@ -923,8 +976,18 @@ function Catalogue({ admin }: { admin: boolean }) {
if (!rolls.length) return;
if (!window.confirm(t('lib.repairConfirm', { n: rolls.length }))) return;
setNote(t('lib.repairRun', { n: rolls.length }));
// The stop button stops the afternoon, repair included: the count is read
// before each roll and a press of STOP anywhere in between — this window's or
// the window beside's, which is told over the same channel — ends the loop
// instead of moving on to the next roll.
const mark = stopCount();
let done = 0;
let stopped = false;
for (const folder of rolls) {
if (stopCount() !== mark) {
stopped = true;
break;
}
// One roll's failure is one roll's: a handle the browser will not hand
// back, a drive that is not there — the rest of the repair still runs.
try {
@@ -935,7 +998,7 @@ function Catalogue({ admin }: { admin: boolean }) {
}
}
await reload();
setNote(t('lib.repairDone', { n: done }));
setNote(stopped ? t('lib.repairHalt', { n: done }) : t('lib.repairDone', { n: done }));
}, [folders, reload, scan, t]);
// A reading that ran to its end has nothing left to find, and the reader who
@@ -994,9 +1057,32 @@ function Catalogue({ admin }: { admin: boolean }) {
const add = useCallback(async () => {
setNote(null);
try {
const folder = await pickFolder();
if (!folder?.handle) return;
const handle = folder.handle;
const picked = await pickFolder();
if (!picked?.handle) return;
const handle = picked.handle;
// A folder is named, and everything downstream is named after it: the
// record in the store, the frame ids, the file the walk's position is kept
// in, and the number the column draws. Two folders with one name are one
// folder to all of it — and that is exactly what two drives on Windows are,
// because the browser hands every drive root the same name. So a folder the
// browser could not name is asked for one, and a folder whose name is
// already another folder's is refused rather than folded in with it.
const norm = normPath(picked.name);
const clash = folders.find((f) => normPath(f.name) === norm);
const mine = clash ? await sameDirectory(handle, clash.handle) : false;
if (clash && !mine) {
setNote(t('lib.dupFolder', { folder: clash.label || clash.name || picked.name }));
return;
}
let folder = picked;
if (!norm || (clash && !clash.label)) {
const asked = window.prompt(t('lib.renameAsk'), clash?.label ?? '')?.trim();
if (asked) {
folder = { ...picked, label: asked };
await putFolder(folder);
}
}
const relInfo = await findFolderRelationship(handle, folders);
@@ -2068,7 +2154,11 @@ function Catalogue({ admin }: { admin: boolean }) {
</a>
</header>
{note ? <p className="hint adm-note">{note}</p> : null}
{/* The note is not drawn here any more. It is one line that comes and goes
— every scan and every press writes it — and a line in the flow above
the frame moved the frame, the column and the strip up and down by its
own height every time it appeared or went. It sits under the lot now,
in a row that is always there: see `.lib-status`. */}
{canBrowseFolders() || servers.length ? (
<div className="adm-frame" data-key="lib-frame">
@@ -2192,7 +2282,12 @@ function Catalogue({ admin }: { admin: boolean }) {
if (folder) void scan(folder, false);
}}
>
<span className="chip-label">{t('lib.resume', { folder: resume })}</span>
<span className="chip-label">
{/* `resume` is the folder's own name, which is what the store
is keyed by; the chip reads the label the reader gave it,
because a drive root's own name is `\`. */}
{t('lib.resume', { folder: folders.find((f) => f.name === resume)?.label || resume })}
</span>
</button>
) : null}
<span className="adm-spacer" />
@@ -3128,6 +3223,12 @@ function Catalogue({ admin }: { admin: boolean }) {
onClose={() => setEditAlbums(null)}
/>
) : null}
{/* The whole screen's one line of news, and the last thing in it: the frame
above keeps its height whether there is anything to say or not, so a
scan starting or ending no longer moves the wall. `:empty` hides the
text without taking the row away. */}
<p className="hint lib-status" data-key="lib-status" aria-live="polite" title={note ?? undefined}>{note}</p>
</div>
);
}