diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index c56e9c5..f7d77c6 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -195,7 +195,7 @@ export function Library() { // right click landed on the empty part of the column, which is the way in for // one more folder; `root` means it landed on a row at the top level — the head // of a tree — which also carries folding the whole tree. - const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; x: number; y: number } | null>(null); + const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; x: number; y: number } | null>(null); // Where the drag of the column divider started, and how wide the column was // then — the pointer only ever reports a position, not a distance moved. const grip = useRef<{ x: number; w: number } | null>(null); @@ -394,7 +394,10 @@ export function Library() { const shape = progress.dirs.length ? `${progress.dirs.length}:${progress.dirs[progress.dirs.length - 1].id}` : ''; if (shape !== drew) { drew = shape; - setDirs(progress.dirs); + // The names of a reading kept to one folder are the folders under that + // one and no others: the column keeps what it has, and the rows it + // already drew stay where they are. + if (!progress.from) setDirs(progress.dirs); } // A window beside the reading is handed the progress of another build, which // may have no such count: the frames it has stored are then read back on a @@ -497,12 +500,16 @@ export function Library() { }, [menu]); const scan = useCallback( - async (folder: LibraryFolder, quiet = false) => { + async (folder: LibraryFolder, quiet = false, from = '') => { setNote(null); try { - const result = await startScan(folder); - if (!quiet) setNote(t('lib.scanned', { folder: folder.name, added: result.added, total: result.total })); - setDirs(result.dirs); + const result = await startScan(folder, from); + if (!quiet) setNote(t('lib.scanned', { folder: from ? `${folder.name}/${from.slice(0, -1)}` : folder.name, 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); // No read back here. The reading handed its frames over as it stored // them, and the screen watching it reads the catalogue back once the // reading is done — the same read, half a beat later, and not a second @@ -652,7 +659,11 @@ export function Library() { // running the count is the scan's own, so a row says how far the reading has // come; the frames only reach the catalogue in one batch at the end, and a row // that stood still until then would say nothing at all. - const read = live?.progress.counts ?? null; + // A reading kept to one folder counts the frames of that folder and of what + // lies under it, with nothing at all to say about the rest of the roll: its + // numbers drawn over the column would count a roll down to a branch of itself. + // The column falls back on the catalogue until the reading is through. + const read = live && !live.progress.from ? live.progress.counts : null; const nodes = useMemo(() => { const hits = new Map(); const counted = (key: string, hit: number | undefined) => read?.[key] ?? hit ?? 0; @@ -1074,20 +1085,30 @@ export function Library() { return next; }); }} - // A folder's own menu: rename, scan again, forget. All three - // act on the folder that was picked, so a right click on a - // subfolder row brings up its roll's menu. A row at the top - // level is a whole tree's head, so it also carries folding - // what sits under it, where a reader actually right-clicks. - // Shift+F10 raises the same event, which is the keyboard's - // way in. + // A folder's own menu: rename, scan again, forget. Rename + // and forget act on the folder that was picked, so a right + // click on a subfolder row brings up its roll's menu. The + // scan acts on the row under the pointer instead: the + // reader who right-clicks April wants April read, and + // reading the four years around it to reach the one folder + // they are looking at is a roll read for nothing. A row at + // the top level is a whole tree's head, so it also carries + // folding what sits under it, where a reader actually + // right-clicks. Shift+F10 raises the same event, which is + // the keyboard's way in. onContextMenu={(e) => { if (!folder) return; e.preventDefault(); const box = e.currentTarget.getBoundingClientRect(); + // The row's own path under the picked folder, '' for + // the picked folder itself — what a scan started from + // here would be kept to. Spelled the way the walk + // spells it, trailing slash and all. + const under = n.key.slice(folder.name.length + 1); setMenu({ folder, root: n.depth === 0, + rel: under ? `${under}/` : '', x: Math.min(e.clientX || box.left, window.innerWidth - 190), y: Math.min(e.clientY || box.top, window.innerHeight - 96), }); @@ -1406,7 +1427,7 @@ export function Library() { disabled={!!live} onClick={() => { setMenu(null); - void scan(menu.folder!); + void scan(menu.folder!, false, menu.rel ?? ''); }} > diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index f69740b..fdefd50 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -321,6 +321,11 @@ async function tile(blob: Blob, size?: { width: number; height: number } | null) export interface ScanProgress { folder: string; + // The subfolder this reading was kept to, spelled the way the walk spells a + // path (`''` for the whole roll). A reading kept to one folder is a reading of + // a fraction of one roll, and everything downstream — the column's counts, the + // position on the disk — belongs to the reading that walks the whole of it. + from: string; total: number; done: number; added: number; @@ -494,20 +499,29 @@ export async function scanFolder( folder: LibraryFolder, onProgress?: (p: ScanProgress) => void, shouldStop?: () => boolean, - jump?: () => string | null + jump?: () => string | null, + from = '' ): Promise { const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p])); - const saved = await loadWalk(folder.name); + // A reading kept to one folder keeps no position of its own. The position file + // belongs to the reading that walks the whole roll, and a fraction of the tree + // written into it would hand the next visit a roll with the rest of itself + // missing from the walk. One folder is short enough to read again, and the + // frames it does not re-read are skipped on their size and their time. + const bounded = from !== ''; + const saved = bounded ? null : await loadWalk(folder.name); const tree: Walk = { root: folder.handle, pending: [], walked: new Set(saved?.walked ?? []) }; // A queue comes back as paths, so the folders are asked for again; a reading - // with nothing written down starts at the picked folder. + // with nothing written down starts at the folder it was pointed at — the + // picked one, or the one the reader right-clicked inside it. if (saved) { for (const rel of saved.pending) { const dir = await openAt(folder.handle, rel); if (dir) tree.pending.push({ dir, rel }); } } else { - tree.pending.push({ dir: folder.handle, rel: '' }); + const dir = from ? await openAt(folder.handle, from) : folder.handle; + if (dir) tree.pending.push({ dir, rel: from }); } // The frames this reading has found and not read: the walk fills it, the lanes // empty it, and a frame taken off the front is one a reload will not read @@ -519,6 +533,7 @@ export async function scanFolder( const names: string[] = []; const progress: ScanProgress = saved?.progress ?? { folder: folder.name, + from, total: 0, done: 0, added: 0, @@ -527,6 +542,11 @@ export async function scanFolder( counts: {}, }; progress.folder = folder.name; + // Which folder the reading was pointed at, for the same reason the name is: + // the screen up when the reading ends is not the one that started it, and a + // reading kept to one folder has to say so to a column that would otherwise + // draw its counts over the whole roll. + progress.from = from; // A position written down by an older visit has no count to carry on from. progress.written ??= 0; const found = new Set(progress.dirs.map((d) => d.id)); @@ -570,6 +590,10 @@ export async function scanFolder( // again is skipped on its size and its time. let wroteAt = 0; const write = async () => { + // A reading kept to one folder has no position to write: the file it would + // go in is the whole roll's, and a fraction of the tree written there is a + // roll that comes back with the rest of itself missing. + if (bounded) return; const at = Date.now(); if (at - wroteAt < WALK_MS) return; wroteAt = at; @@ -735,7 +759,12 @@ export async function scanFolder( // is asked for nothing until it is through: the frames already found are // what that reading was in the middle of. if (!entries.length) { - walked = await walkPass(tree, entries, names, isSupportedPhoto, jump ?? (() => null)); + // A reading kept to one folder is asked for nothing: the frames of the + // folder it was pointed at, and of what lies below it, and then it is + // through. A jump would take it out of the subtree it was kept to, and + // the reading it left behind is the reading that folder wanted. + const asked = bounded ? () => null : (jump ?? (() => null)); + walked = await walkPass(tree, entries, names, isSupportedPhoto, asked); // The names come in before the frames they hold: the column grows one pass // ahead of the strip, which is the whole point of reading layer by layer. for (const rel of names) { @@ -763,8 +792,10 @@ export async function scanFolder( } while (!walked && !stop); await flush(); // A reading that came to its end has no position worth keeping: the next one - // walks the roll from the top and skips what has not moved. - if (!stop) await clearWalk(folder.name); + // walks the roll from the top and skips what has not moved. A reading kept to + // one folder never wrote one down, and the file it would clear is the whole + // roll's — a position another reading is in the middle of. + if (!stop && !bounded) await clearWalk(folder.name); return progress; } @@ -957,11 +988,11 @@ export function jumpScan(rel: string | null): void { // 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 { +export function startScan(folder: LibraryFolder, from = ''): Promise { 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, written: 0, dirs: [], counts: {} }, + progress: { folder: folder.name, from, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {} }, stop: false, jump: null, }; @@ -980,7 +1011,8 @@ export function startScan(folder: LibraryFolder): Promise { announce(); }, () => session.stop, - () => session.jump + () => session.jump, + from ).finally(() => { if (heart) { clearInterval(heart);