diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index afbddec..b5eb062 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -1027,6 +1027,37 @@ function Catalogue() { [nodes, expanded] ); + // Where a mark goes on the column: the row for the folder a reading — or a + // request waiting its turn — was asked of, and every row above it short of the + // roll's own row. A right click on April is a reading of April, and the shelf + // is not the folder; a right click on 2026/04 marks 2026 with it, so a branch + // folded shut still shows the work going on under it. The rows are found by + // the key the walk would give them — `folder/sub` — rather than by taking the + // row's own name apart, which is the same path spelled by two pieces of code + // and has to agree with the reading's own spelling, not merely nearly. + // The roll's own row is worn for a reading of the whole roll, and for one whose + // own row is not on the column at all — a branch folded away, or a folder that + // has gone from the disk. A reading the column cannot point at is a reading + // running with nothing to say so, which is worse than a mark on the roll. + interface Mark { + root: string; + own: string; + onOwn: boolean; + } + const markOf = (folderName: string, rel: string): Mark => { + const root = normPath(folderName); + const own = rel ? `${root}/${normPath(rel)}` : root; + return { root, own, onOwn: rel === '' || rows.some((n) => n.key === own) }; + }; + const liveMark = live ? markOf(live.folder, live.progress.from) : null; + const askedMarks = scanQueue().map((j) => markOf(j.folder, j.rel)); + const wearsMark = (n: Node, m: Mark): boolean => { + // Another roll's rows are neither the reading's nor the request's news. + if (n.key !== m.root && !n.key.startsWith(`${m.root}/`)) return false; + if (!m.onOwn) return n.key === m.root; + return m.own === m.root ? n.key === m.root : n.key !== m.root && (m.own === n.key || m.own.startsWith(`${n.key}/`)); + }; + // A remembered subfolder that no longer holds a frame falls back to the folder // it sits in, so the screen never opens on a node that is not drawn. useEffect(() => { @@ -1654,57 +1685,23 @@ function Catalogue() { }} > {rows.map((n) => { + // The row's folder, found the way the row's key is spelled: + // `normPath` on both sides, since a folder that came in from + // another machine carries that machine's separators. const folder = folders.find((f) => { - const fNorm = (f.name || '').replace(/\\/g, '/'); - return n.key === fNorm || n.key.startsWith(`${fNorm}/`) || n.key === f.name || n.key.startsWith(`${f.name}/`); + const fNorm = normPath(f.name); + return n.key === fNorm || n.key.startsWith(`${fNorm}/`); }) ?? folders[0] ?? null; - // The row that says a reading is under way: the row the - // reading was asked of, and every row above it short of the - // roll's own row. A right click on April is a reading of - // April, and April is what gets marked — the shelf it sits on - // is not the folder the reader asked for — while a right - // click on 2026/04 marks 2026 with it, so a branch folded - // shut still shows the work going on under it. The roll's own - // row is worn only when the reading is the whole roll's. - // The ring is worn from the click, not from the first frame - // in hand: the walk reads the catalogue and the position back - // before it touches the disk, and a command that shows nothing - // at all for that while is a command the reader cannot tell - // from one that never ran. - const isScanFolderMatch = !!(live && folder && normPath(live.folder) === normPath(folder.name)); - // The folder the reading was asked of, spelled the way the - // walk spells a path: '' for the whole roll. - const askedFrom = live && isScanFolderMatch ? normPath(live.progress.from) : ''; - // The row's own path under its own roll, spelled the way the - // walk spells one. Not `relOf`, which answers for the open - // node's roll alone: a reading of another roll draws its ring - // on that roll's rows too, and those rows still have to say - // where it stands. - const normFolder = folder ? normPath(folder.name) : ''; - const nodeRel = - n.depth === 0 || !normFolder || !n.key.startsWith(`${normFolder}/`) - ? '' - : n.key.slice(normFolder.length + 1); - const scanning = - isScanFolderMatch && - (askedFrom === '' - ? n.depth === 0 - : nodeRel !== '' && (nodeRel === askedFrom || askedFrom.startsWith(`${nodeRel}/`))); - // What is waiting its turn is waiting on the row it was asked - // of, and on every row above it short of the roll's row — the - // same rows the reading itself will wear, so the mark a wait - // leaves is the mark the reading takes up. A request belongs - // to the roll it was asked of, so another roll's queue is not - // this row's news. - const waiting = - !scanning && - scanQueue().some((j) => { - if (j.folder !== folder?.name) return false; - const rel = normPath(j.rel); - if (rel === '') return n.depth === 0; - return nodeRel !== '' && (nodeRel === rel || rel.startsWith(`${nodeRel}/`)); - }); + // The row that says a reading is under way, and the row that + // says a request is waiting its turn — see `wearsMark` for + // which rows those are. The ring is worn from the click, not + // from the first frame in hand: the walk reads the catalogue + // and the position back before it touches the disk, and a + // command that shows nothing at all for that while is a + // command the reader cannot tell from one that never ran. + const scanning = !!liveMark && wearsMark(n, liveMark); + const waiting = !scanning && askedMarks.some((m) => wearsMark(n, m)); const under = parents.has(n.key); return (