From 79f0d77a8ed0f33e5e2c97026f0e6ed62d2acc69 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 2 Oct 2026 22:25:43 +0700 Subject: [PATCH] feat(library): update, rescan and stop on the folder menu, queued per roll A folder row now asks for the rest of a roll, the whole of it from the top, or takes its request back. Requests queue behind the reading in flight and run one at a time; a row waiting its turn draws a ring that does not turn. --- docker/frontend/src/Library.tsx | 84 ++++++++++----- docker/frontend/src/engine/library.ts | 147 ++++++++++++++++++++++++-- docker/frontend/src/i18n/en.ts | 3 +- docker/frontend/src/i18n/vi.ts | 3 +- docker/frontend/src/styles/app.css | 3 + 5 files changed, 201 insertions(+), 39 deletions(-) diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 42fd2ec..7f129b4 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -18,17 +18,20 @@ import { readPhotos, removeFolder, renameFolder, + scanAsked, scanBusy, + scanQueue, scanSession, setStar, - startScan, stopScan, + stopScanAt, unfinished, watchRows, watchScan, type LibraryDir, type LibraryFolder, type LibraryPhoto, + type ScanMode, } from './engine/library'; import { backupNow, @@ -210,6 +213,9 @@ export function Library() { // 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; rel?: string; node?: string; x: number; y: number } | null>(null); + // A reading of this roll that is still waiting its turn. The menu is where + // that wait is called off, so it has to know one exists. + const queued = menu?.folder ? scanQueue().some((j) => j.folder === menu.folder!.name) : false; // 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); @@ -533,11 +539,17 @@ export function Library() { }; }, [menu]); + // A reading asked for here is a request, not a reading: one walk runs at a time, + // and a folder asked for while another is being read waits its turn — which is + // what makes a right click on four folders mean four readings rather than three + // refusals. The note lands when this request's turn comes, not when it is asked + // for, and a request called off before it ran says nothing at all. const scan = useCallback( - async (folder: LibraryFolder, quiet = false, from = '') => { + async (folder: LibraryFolder, quiet = false, from = '', mode: ScanMode = 'update') => { setNote(null); try { - const result = await startScan(folder, from); + 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 })); // 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 @@ -964,7 +976,7 @@ export function Library() { - {live ? ( + {live || scanQueue().length ? ( @@ -1113,6 +1125,10 @@ export function Library() { const dir = relOf(n.key); const at = live && live.folder === folder?.name && live.progress.at ? `${live.progress.at}/` : null; const scanning = dir !== null && dir !== '' && at !== null && (dir === at || at.startsWith(dir)); + // A row whose reading has not started yet: the ring is + // there, held still, so a reader sees the roll is pending + // rather than idle. + const waiting = dir !== null && dir !== '' && !scanning && scanQueue().some((j) => j.folder === folder?.name && j.rel === dir); const under = parents.has(n.key); return ( ); @@ -1477,38 +1497,50 @@ export function Library() { {t('lib.rename')} - {/* A roll being read has one thing to be asked of it — that it - stop — where a roll at rest has the reading that brings it up - to date; the two never both apply, so the menu carries the one - the row is actually in. */} - {live?.folder === menu.folder.name ? ( + {/* Three things can be asked of a roll: bring it up to date, read it + from the top again, or leave it — and the last only of the + reading that is this roll's, or of the request for it that is + still waiting its turn. The first two are always there, because + a reading already running is not a reason a reader cannot ask + for the next one: it queues. */} + {live?.folder === menu.folder.name || queued ? ( - ) : ( - - )} + ) : null} + + {/* A row with folders under it is a row that folds, and the branch it holds goes with it: a reader who is done with a tree means the whole of it shut, not the one row they happened to point diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 13ac5f4..a7f6a1f 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -1027,6 +1027,12 @@ export async function unfinished(folder: string): Promise { } export function stopScan(): void { + // Everything waiting is a reading that is not going to happen: the button that + // stops the walk stops the afternoon, and a queue that ran on after it would be + // a stop that stopped one folder and then read the next three anyway. + waiting.forEach(drop); + waiting = []; + tell(); // A reading another window holds is stopped by telling it so: the button this // window drew is the same button, on the window that can act on it. if (!live) { @@ -1037,6 +1043,22 @@ export function stopScan(): void { tell(); } +// The row's own stop: the request for this row that is still waiting its turn, +// and the reading of this roll, which is one at a time — a row under the roll +// stops the reading the same way the roll's own row does, because the reading it +// stops is the roll's. The other rolls' requests are other readings and run on. +export function stopScanAt(folder: string, rel: string): void { + for (const job of waiting) { + // The roll's own row stops the whole roll — the requests under it are readings + // of that one roll, a folder at a time. A subfolder's row stops that folder. + if (job.folder === folder && (rel === '' || job.rel === rel)) drop(job); + } + waiting = waiting.filter((j) => !j.dropped); + if (live?.folder === folder) live.stop = true; + else if (peer?.folder === folder) say({ k: 'stop', folder }); + tell(); +} + export function jumpScan(rel: string | null): void { if (live) { live.jump = rel; @@ -1048,8 +1070,20 @@ 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, from = ''): Promise { +// +// `update` is the reading that picks up where the last one left off: the position +// on the disk if there is one, the whole roll from the top if there is not, with +// every frame the catalogue already holds skipped on its size and its time. +// `rescan` is the same reading refusing that position — the roll walked again +// from the top — which is what finds a folder that was added while a reading was +// cut off, and what a frame written over is found by. +export function startScan(folder: LibraryFolder, from = '', mode: ScanMode = 'update'): Promise { if (live) return Promise.reject(new Error('a scan is already running')); + // A reading kept to one folder under the roll keeps no position of its own and + // reads that folder however it was asked for, so only a roll's own rescan has a + // position to throw away — and throwing away a position this reading is not + // going to use would cost the next update the rest of the roll. + const go = mode === 'rescan' && !from ? clearWalk(folder.name) : Promise.resolve(); const session: ScanSession = { folder: folder.name, progress: { folder: folder.name, from, total: 0, done: 0, added: 0, written: 0, dirs: [], counts: {}, at: '' }, @@ -1063,16 +1097,18 @@ export function startScan(folder: LibraryFolder, from = ''): Promise { - session.progress = p; - tell(); - announce(); - }, - () => session.stop, - () => session.jump, - from + return go.then(() => + scanFolder( + folder, + (p) => { + session.progress = p; + tell(); + announce(); + }, + () => session.stop, + () => session.jump, + from + ) ).finally(() => { if (heart) { clearInterval(heart); @@ -1084,6 +1120,95 @@ export function startScan(folder: LibraryFolder, from = ''): Promise void; + fail: (err: unknown) => void; + // The same request asked twice is one reading, and this is the answer both + // askers are waiting for. + answer: Promise; +} + +let waiting: Waiting[] = []; + +const drop = (job: Waiting): void => { + if (job.dropped) return; + job.dropped = true; + job.settle(null); +}; + +// What is waiting its turn, in the order it will be read: what a row draws its +// mark from, and the whole of what a screen knows about the queue. +export function scanQueue(): ScanJob[] { + return waiting.filter((j) => !j.dropped).map(({ folder, rel, mode }) => ({ folder, rel, mode })); +} + +// Ask for a reading of one folder: the row that was right-clicked, and whether +// what is wanted is what is new under it or the whole of it again. A request is +// never refused and never starts a second reading — it waits its turn and answers +// with the reading it asked for, or with null when it was taken out of the queue +// before it ran. +export function scanAsked(folder: LibraryFolder, rel = '', mode: ScanMode = 'update'): Promise { + const same = waiting.find((j) => !j.dropped && j.folder === folder.name && j.rel === rel && j.mode === mode); + if (same) return same.answer; + let settle!: (p: ScanProgress | null) => void; + let fail!: (err: unknown) => void; + const answer = new Promise((resolve, reject) => { + settle = resolve; + fail = reject; + }); + waiting.push({ folder: folder.name, rel, mode, handle: folder.handle, dropped: false, settle, fail, answer }); + tell(); + pump(); + return answer; +} + +// Hand the next request its turn. The reading that just ended is the one thing +// that can make room for it, and the window that holds the reading is the one +// that hears when it ends. +function pump(): void { + if (live) return; + const job = waiting.find((j) => !j.dropped); + if (!job) return; + // A reading another window holds is waited out rather than raced: the disk is + // one disk, and two readers of one roll is the whole of what this is for. The + // turn is taken when that window says it is through, which is the word the + // screens draw their progress from. + if (peer) { + const stop = watchScan(() => { + if (peer) return; + stop(); + pump(); + }); + return; + } + waiting = waiting.filter((j) => j !== job); + startScan({ name: job.folder, handle: job.handle }, job.rel, job.mode).then(job.settle, job.fail).finally(pump); +} + // --- reading --------------------------------------------------------------- // The catalogue, read once and kept. STUDIO and LIBRARY are two screens of one diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 8445315..b83d564 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -244,9 +244,10 @@ export const en: Dict = { 'lib.rename': 'RENAME FOLDER', 'lib.renameAsk': 'Name shown for this folder (the folder on the disk is not renamed):', 'lib.update': 'UPDATE...', + 'lib.rescan': 'RESCAN FROM THE TOP', 'lib.remove': 'REMOVE', 'lib.reconnect': 'GRANT ACCESS AGAIN', - 'lib.nodeHint': 'Right-click to rename, update, collapse or remove the folder.', + 'lib.nodeHint': 'Right-click to rename, update, rescan, collapse or remove the folder.', 'lib.collapseAll': 'COLLAPSE ALL', 'lib.collapse': 'COLLAPSE', 'lib.colHint': 'Right-click the empty part of this column to add a folder.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index f863a2f..af090ce 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -256,9 +256,10 @@ export const vi = { 'lib.rename': 'ĐỔI TÊN THƯ MỤC', 'lib.renameAsk': 'Tên hiển thị cho thư mục này (thư mục trên máy không bị đổi tên):', 'lib.update': 'CẬP NHẬT...', + 'lib.rescan': 'QUÉT LẠI TỪ ĐẦU', 'lib.remove': 'BỎ', 'lib.reconnect': 'CẤP LẠI QUYỀN', - 'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, thu gọn hay bỏ thư mục.', + 'lib.nodeHint': 'Chuột phải để đổi tên, cập nhật, quét lại, thu gọn hay bỏ thư mục.', 'lib.collapseAll': 'THU GỌN TẤT CẢ', 'lib.collapse': 'THU GỌN', 'lib.colHint': 'Chuột phải vào vùng trống của cột này để thêm thư mục.', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 6d2b2b5..d356ee5 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1161,6 +1161,9 @@ button.hint:disabled { cursor: default; } animation: lib-spin 0.7s linear infinite; } @keyframes lib-spin { to { transform: rotate(360deg); } } +/* The ring of a reading that is queued: still, and greyed, so it reads as a + wait rather than as work. */ +.lib-spin.wait { animation: none; border-top-color: var(--border); opacity: 0.6; } /* The same ring, out in the header: the reading belongs to this tab, not to a screen, so the studio says so too while it runs. */ .lib-scan { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }