fix(library): a folder opens on a screenful of tiles, not the whole roll

Choosing another row in the column read the whole roll back out of the store and asked it for a tile of every frame in one transaction: 20 000 requests in flight at once at 40 000 frames, a 200 ms block of the main thread per switch, and a mint job over the whole folder. The rows are the ones the screen is holding already, the store is asked a chunk at a time, and a folder opening under the pointer is the head of the list — the wall mints the rest as it scrolls. MAKE THUMBS by hand still walks the whole folder.
This commit is contained in:
2026-10-10 08:40:37 +07:00
parent 549bfab310
commit af1d047227
2 changed files with 65 additions and 25 deletions
+26 -3
View File
@@ -194,6 +194,12 @@ const WALL_ROWS = 2;
// thread handed back between them (`breathe`), is a shelf that fills in front of
// the reader — a screenful of frames read end to end is a dead screen.
const WALL_LANES = 2;
// A folder is walked into with a screenful in sight, not the whole roll: the tiles
// asked for when a folder opens are the ones about to be drawn, and the wall mints
// 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;
// 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;
@@ -1074,7 +1080,11 @@ function Catalogue({ admin }: { admin: boolean }) {
const thumbJob = useRef(0);
const thumbTarget = useRef<string | null>(null);
const makeThumbsFor = useCallback(
async (folder: LibraryFolder, rel: string, opts: { below?: boolean; loud?: boolean } = {}) => {
async (
folder: LibraryFolder,
rel: string,
opts: { below?: boolean; loud?: boolean; rows?: LibraryPhoto[] } = {}
) => {
const key = `${normPath(folder.name)}|${normPath(rel)}|${opts.below === false ? 0 : 1}`;
// A quiet ask for the target already being worked is the work in hand: the
// catalogue lands a batch at a time, and every batch is not a reason to drop
@@ -1083,8 +1093,12 @@ function Catalogue({ admin }: { admin: boolean }) {
const job = ++thumbJob.current;
thumbTarget.current = key;
try {
const made = await makeThumbs(folder, rel, {
const made = await makeThumbs(opts.rows ?? folder, rel, {
below: opts.below,
// The whole of a folder is the reader asking for it by hand; a folder
// opening under the pointer is a screenful, and the wall makes the rest
// as it scrolls.
limit: opts.loud ? undefined : THUMB_AHEAD,
shouldStop: () => thumbJob.current !== job,
});
if (opts.loud) setNote(t('lib.thumbsMade', { n: made, folder: folder.label ?? folder.name }));
@@ -1110,7 +1124,16 @@ function Catalogue({ admin }: { admin: boolean }) {
if (!loaded || !node || !rootFolder) return;
const rel = relOf(node);
if (rel === null) return;
void makeThumbsFor(rootFolder, rel, { below: deep });
// The rows come from the screen, which is holding the whole catalogue already:
// reading one folder's worth back out of the store is a walk of the disk for
// rows that are in hand. One folder's worth of it — the open one — so a roll
// picked alongside it is not asked for as well.
// ponytail: keyed on the count, not the array, so a star or a turn landing does
// not set the job off again; the rows of that same render are what goes.
const normRoot = normPath(rootFolder.name);
const rows = photos.filter((p) => normPath(p.folder ?? '') === normRoot);
void makeThumbsFor(rootFolder, rel, { below: deep, rows });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deep, loaded, makeThumbsFor, node, photos.length, relOf, rootFolder]);
// The tree: one row per picked folder, then one per subfolder, indented by its