web: a row's menu is the tree's own and speaks only of that row, and only the row being read admits to it

The menu that hangs off a folder is a menu read mid-scan, with the eye still
moving down the column, and it was drawn to the measure of a page rather than of
a list: a box wide enough to be a destination, type the size of a heading. A
catalogue's own context menu is a thing passed over on the way somewhere else —
it is sized to its words and to the row it hangs off, and the reader's eye steps
across it without stopping. The type comes down a point, the rows tighten to the
tree's own measure, and the frame around them thickens to a radius the rows
themselves use. The clamp that keeps it in the window follows the smaller box,
so a menu opened near the right or the bottom edge no longer floats an inch off
the pointer for room it does not use.

A row that is being read has exactly one thing to be asked of it — that it stop
— where a row at rest has the reading that brings it up to date. The menu said
RESCAN either way, greyed while a scan was up, which is a command offered and
refused in the same breath. The two never both apply, so the menu now carries
the one the row is actually in: a roll being read offers STOP SCAN and stops
that roll; a roll at rest offers UPDATE... and reads it again. A row with
folders under it grows a third thing — a fold that closes the branch and not
merely the row, because a reader who is done with a tree means the whole of it
shut, and closing only the row they happened to point at leaves the children
standing open underneath it.

The last is a matter of what a row is allowed to claim. Every row of a roll
spanning the tree spun while that roll was being read, so a reader looking at a
tree of a hundred folders saw a hundred rows each insisting it was the one being
read, when the reading had been pointed at one of them. A row now says it is
being read only when it is the row the reading was kept to — the folder it was
pointed at, which for a whole roll is the roll's own row. A tree that says all
of itself is busy when one branch of it is says nothing a reader can trust.
This commit is contained in:
2026-10-01 19:00:21 +07:00
parent 4f87f022da
commit d5da6ebcba
4 changed files with 86 additions and 33 deletions
+66 -20
View File
@@ -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; rel?: string; x: number; y: number } | null>(null);
const [menu, setMenu] = useState<{ folder: LibraryFolder | null; root?: boolean; rel?: string; node?: 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);
@@ -1060,14 +1060,17 @@ export function Library() {
onContextMenu={(e) => {
if (e.target !== e.currentTarget) return;
e.preventDefault();
setMenu({ folder: null, x: Math.min(e.clientX, window.innerWidth - 190), y: Math.min(e.clientY, window.innerHeight - 96) });
setMenu({ folder: null, x: Math.min(e.clientX, window.innerWidth - 150), y: Math.min(e.clientY, window.innerHeight - 150) });
}}
>
{rows.map((n) => {
const folder = folders.find((f) => f.name === n.key.split('/')[0]) ?? null;
// A roll being read says so on its own row: while a scan runs
// the toolbar's progress line is nowhere near the eye.
const scanning = !!folder && live?.folder === folder.name;
// The row being read says so, and no other: a roll is read
// from one folder down, and every row of that roll spinning
// is a tree that says all of itself is being read when one
// branch of it is. The row is the folder the reading was
// kept to, which for a whole roll is the roll's own row.
const scanning = !!folder && live?.folder === folder.name && relOf(n.key) === live.progress.from;
const under = parents.has(n.key);
return (
<button
@@ -1120,8 +1123,9 @@ export function Library() {
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),
node: n.key,
x: Math.min(e.clientX || box.left, window.innerWidth - 150),
y: Math.min(e.clientY || box.top, window.innerHeight - 150),
});
}}
>
@@ -1431,19 +1435,61 @@ export function Library() {
<span className="lib-menu-icon" aria-hidden="true">✎</span>
{t('lib.rename')}
</button>
<button
type="button"
className="lib-menu-item"
data-key={`lib-rescan-${menu.folder.name}`}
disabled={!!live}
onClick={() => {
setMenu(null);
void scan(menu.folder!, false, menu.rel ?? '');
}}
>
<span className="lib-menu-icon" aria-hidden="true">↻</span>
{t('lib.rescan')}
</button>
{/* 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 ? (
<button
type="button"
className="lib-menu-item"
data-key={`lib-menu-stop-${menu.folder.name}`}
onClick={() => {
setMenu(null);
stopScan();
}}
>
<span className="lib-menu-icon" aria-hidden="true">■</span>
{t('lib.stop')}
</button>
) : (
<button
type="button"
className="lib-menu-item"
data-key={`lib-rescan-${menu.folder.name}`}
disabled={!!live}
onClick={() => {
setMenu(null);
void scan(menu.folder!, false, menu.rel ?? '');
}}
>
<span className="lib-menu-icon" aria-hidden="true">↻</span>
{t('lib.update')}
</button>
)}
{/* 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
at with its children left open underneath. */}
{menu.node && parents.has(menu.node) ? (
<button
type="button"
className="lib-menu-item"
data-key={`lib-fold-${menu.node}`}
onClick={() => {
const key = menu.node!;
setMenu(null);
setExpanded((prev) => {
const next = new Set(prev);
for (const k of next) if (k === key || k.startsWith(`${key}/`)) next.delete(k);
return next;
});
}}
>
<span className="lib-menu-icon" aria-hidden="true">▴</span>
{t('lib.collapse')}
</button>
) : null}
<button
type="button"
className="lib-menu-item"