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 (
{n.label}
- {scanning ? : null}
+ {scanning ? (
+
+ ) : waiting ? (
+
+ ) : null}
{n.count}
);
@@ -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; }