fix(library): match the scan mark to the row by the walk's own key

The ring said which folder a reading was asked of by taking the row's own
name apart and comparing the pieces, which is the same path spelled twice and
only nearly the same. When the two spellings drifted the asked row never
matched, and the fallback that used to carry the mark on the roll's row had
gone, so a reading ran with nothing on the column at all.

The mark is now built with the key the walk gives the row — `folder/sub`,
through `normPath`, the same way a frame id is spelled — and a reading whose
own row is not on the column (a branch folded away, a folder gone from the
disk) falls back to the roll's own row, so a reading is never invisible.
This commit is contained in:
2026-10-08 10:17:45 +07:00
parent bb2d548d6a
commit 8d3b7bf134
+51 -49
View File
@@ -1027,6 +1027,37 @@ function Catalogue() {
[nodes, expanded] [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 // 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. // it sits in, so the screen never opens on a node that is not drawn.
useEffect(() => { useEffect(() => {
@@ -1654,57 +1685,23 @@ function Catalogue() {
}} }}
> >
{rows.map((n) => { {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 = const folder =
folders.find((f) => { folders.find((f) => {
const fNorm = (f.name || '').replace(/\\/g, '/'); const fNorm = normPath(f.name);
return n.key === fNorm || n.key.startsWith(`${fNorm}/`) || n.key === f.name || n.key.startsWith(`${f.name}/`); return n.key === fNorm || n.key.startsWith(`${fNorm}/`);
}) ?? folders[0] ?? null; }) ?? folders[0] ?? null;
// The row that says a reading is under way: the row the // The row that says a reading is under way, and the row that
// reading was asked of, and every row above it short of the // says a request is waiting its turn — see `wearsMark` for
// roll's own row. A right click on April is a reading of // which rows those are. The ring is worn from the click, not
// April, and April is what gets marked — the shelf it sits on // from the first frame in hand: the walk reads the catalogue
// is not the folder the reader asked for — while a right // and the position back before it touches the disk, and a
// click on 2026/04 marks 2026 with it, so a branch folded // command that shows nothing at all for that while is a
// shut still shows the work going on under it. The roll's own // command the reader cannot tell from one that never ran.
// row is worn only when the reading is the whole roll's. const scanning = !!liveMark && wearsMark(n, liveMark);
// The ring is worn from the click, not from the first frame const waiting = !scanning && askedMarks.some((m) => wearsMark(n, m));
// 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}/`));
});
const under = parents.has(n.key); const under = parents.has(n.key);
return ( return (
<button <button
@@ -1749,7 +1746,12 @@ function Catalogue() {
e.stopPropagation(); e.stopPropagation();
const targetFolder = folder || folders[0]; const targetFolder = folder || folders[0];
if (!targetFolder) return; if (!targetFolder) return;
const fNorm = (targetFolder.name || '').replace(/\\/g, '/'); // The subfolder's path, taken off the row's key with the
// same spelling the row's key was built from — a
// separator left in a name would cut the path in the
// wrong place, and a reading asked of a folder that is
// not there reads nothing at all.
const fNorm = normPath(targetFolder.name);
const sub = n.key.length > fNorm.length ? n.key.slice(fNorm.length + 1) : ''; const sub = n.key.length > fNorm.length ? n.key.slice(fNorm.length + 1) : '';
setMenu({ setMenu({
folder: targetFolder, folder: targetFolder,