11 KiB
Queue selection, visual mode, and the register
Marks and visual mode in the queue pane, paired with a vim-style register: deleting or yanking queue entries puts them in the register, pasting brings them back. One model, implemented in both clients.
Context and problem statement
The library pane has marks (s) and visual mode (v/V); the queue pane
has neither — d removes exactly the row under the cursor, one at a time,
and queue.rs carries the standing note "FIXME: mark multiple tracks on
queue and remove them". Clearing 200 tracks with c/C, or deleting the
wrong row, is unrecoverable: there is no undo anywhere in the system.
Meanwhile p in the queue means "insert the library selection after
this track" — a cross-pane action with no name for what it is inserting.
The ask is to close both gaps with one concept borrowed from vim: a client
register that deletes and yanks write to, and paste reads from. That turns
multi-delete into a safe operation (it is recoverable), gives "move these
three tracks down" a natural spelling (d then P), and gives p a single
meaning.
Assumptions (confirmed)
- Only explicit commands write the register:
y(both panes), andd,c,C(queue). Marking or moving the cursor never writes it. This was the one point of disagreement — an implicit fill from the library selection would have keptpbackward-compatible, but it meanssin the library silently clobbers a clipboard you are about to paste. - Both
p(after cursor) andP(before cursor), sodthenPrestores exactly what you deleted, andd…pis a move. c/Cfill the register too — the destructive ops most worth undoing.- One unnamed register, but the type is shaped so named registers
(
"a) are additive rather than a rewrite. - Both clients, now.
cbd-webgets queue marks, queue visual mode, and the register — plus the library visual mode that was deferred whenv/Vlanded in the TUI, so the two clients do not drift further. - The register is per-client, in-memory, one level deep. It is not a server-side undo log; another client cannot undo your delete, and a restart forgets it. Same expectations as vim.
What the protocol already gives us
No .proto change, no server change. The two RPCs this feature needs are
already shaped for it:
Remove { positions: repeated uint32 }— already takes many positions, so multi-delete is a client-side gather.Insert { position, paths: repeated string }— takes a path list, so a register of paths pastes with the existing call.
That is what makes this a client-only feature.
Options considered
What the register holds
-
Resolved tracks (
Trackmessages, as deleted). Exact: what you deleted is what comes back, and the UI can show real titles. But it cannot express "the album node I yanked in the library", and pasting still has to send paths, so the extra fidelity buys nothing at the wire. -
Paths, with titles alongside for display (chosen). It is what both RPCs speak; a yanked library node expands at paste time, which is a feature (
yan album,pit into the queue); and the register is oneVec<String>plus labels.The cost is honest and worth stating: paste re-resolves. A yanked child of a search term whose term has since been deleted may not come back, and a node's track count is unknown until paste, so the UI can only say "3 entries", not "12 tracks".
Keeping marks alive in a moving queue
This is the only genuinely new problem. The library never had it: a listing is a snapshot you re-fetch deliberately. The queue is server-pushed and rebuilt on every change — another client appending, playback advancing, and each chunk of a streaming resolve.
- Remap marks by index. Cheapest, and silently wrong: if playback
advanced and the queue shifted by one,
ddeletes the wrong tracks. Rejected — a data-losing failure mode. - Clear marks on every snapshot. Never wrong, but a resolve streaming in or another client's append wipes a selection mid-flow, which makes the feature feel broken exactly when the queue is busy.
- Greedy in-order match on track path (chosen). Walk the old and new path lists together and carry a mark to the new position of the same path; drop marks whose track is gone. Survives appends, removals, and playback advancing. Duplicate paths (the same track queued twice) are ambiguous by nature — in-order matching degrades sanely, keeping the n-th occurrence marked. If the lists diverge past recognition (no common prefix worth speaking of), clear rather than guess.
The safety rule that makes any of this sound: positions handed to
Remove are always read off the newest snapshot, never a remembered
index.
Where marks live
The two clients are shaped differently and this is not worth papering over:
cbd-tuiowns aVec<UiItem>per pane and already hasmarkedon it (the queue builds every rowmarked: falsetoday).cbd-webowns no queue list at all — justQueueCursor { selected }, with rows rendered straight from the server'sQueuesignal.
So the shared thing is the rule, not the struct: marks are a set of
positions plus the path list they were taken against, reconciled on each
snapshot. The TUI keeps them in its UiItems (it rebuilds that list
anyway); the web client keeps a position set beside the signal. Both
implement the same reconciliation, and both unit-test it against the same
cases.
Decisions
D1 — Register holds paths plus display labels. One unnamed slot:
struct Register {
/// Library paths, in the order they were yanked or deleted. Empty means
/// nothing to paste.
paths: Vec<String>,
/// Row labels for the status line only — never sent anywhere.
labels: Vec<String>,
}
Named registers stay additive: the owner becomes a small map keyed by a
register name, with None meaning the unnamed one. Not built now.
D2 — Only y, d, c, C write the register. Marks, visual mode,
and cursor movement never do. A write overwrites: there is no history
and no numbered registers.
D3 — p pastes after the cursor, P before it. Both are queue-only
(there is nothing to paste into a library listing). Paste sends
Insert { position, paths } and leaves the register intact, so you can
paste twice. d then P is an exact restore; d … p is a move.
D4 — y works in both panes. In the library it yanks the marked items,
or the cursor item, under the same is_queable gate that a/Enter use —
so yanking cannot put an unqueueable folder in the register. In the queue it
yanks the marked rows, or the cursor row. y clears the marks it consumed,
exactly as queueing does today.
D5 — Queue marks reconcile per snapshot by the greedy in-order path
match above, clearing on divergence. Remove/Insert positions always come
from the newest snapshot.
D6 — d in the queue deletes every marked row (or the cursor row when
nothing is marked), in one Remove call, and writes them to the register
first. c/C write the tracks they are about to drop.
D7 — Queue visual mode mirrors the library's anchored paint: v (and
V) enters, movement toggles the marks of the swept range against the
anchor so moving back reverses, Esc or any non-movement action leaves it.
The mark+visual logic is extracted so both panes share one implementation
per client, rather than a second copy in the queue.
One concrete wrinkle: queue rows are built is_queable: false, is_deletable: false, so the library's mark gate would refuse all of them.
The extracted code takes the gate as a parameter — the queue's is "always
allowed".
D8 — cbd-web reaches parity in the same change, including the library
visual mode it does not have yet. Its queue marks live beside the server
signal (D-above); its keymap and help overlay gain the same rows as the
TUI's.
D9 — Keys. The queue gains s, v, V, y, P; the library gains
y. No chord collides with an existing one in either scope.
D10 — p changes meaning, and that is a breaking change to muscle
memory: it pastes the register instead of the library selection. The
browse→queue flows that do not go through p (a append, L queue-next,
Enter replace) are untouched, so the cost is confined to "insert what I
picked on the left at this exact position", which becomes y then p.
Documented in the key tables and in the book.
Structure
direction: right
lib: Library pane {
libsel: "marked rows,\nelse cursor row\n(is_queable gate)"
}
q: Queue pane {
qsel: "marked rows,\nelse cursor row"
}
reg: "Register (per client, one slot)\npaths + labels" {
shape: cylinder
}
rpc: Server (unchanged) {
ins: "Insert { position, paths }"
rem: "Remove { positions }"
}
lib.libsel -> reg: "y"
q.qsel -> reg: "y · d · c · C"
q.qsel -> rpc.rem: "d · c · C\n(positions from the\nnewest snapshot)"
reg -> rpc.ins: "p (after cursor)\nP (before cursor)"
rpc -> q: "Queue snapshot\n(marks reconciled)"
Mark reconciliation, on every queue snapshot:
direction: down
snap: "Queue snapshot arrives"
cmp: "Walk old paths and new paths\nin order"
carry: "Carry each mark to the new\nposition of the same path"
drop: "Drop marks whose track is gone"
clear: "Clear all marks"
act: "d / y / c / C read positions\nfrom this snapshot only"
snap -> cmp
cmp -> carry: recognizable
cmp -> clear: "diverged past\nrecognition"
carry -> drop
drop -> act
clear -> act
Boundaries and interfaces
- Register — owned by each client's app state, written only by the four commands, read only by paste. No I/O, no server involvement; a pure value that is trivially unit-testable.
- Mark reconciliation — one function per client,
(old_paths, new_paths, marks) -> marks, tested against: append, remove-before, remove-marked, playback advance, streaming resolve, duplicate paths, and wholesale replacement. - Pane selection — both panes expose "the rows this action applies to"
the way the library's
get_selectedalready does;y/dconsume it. - Server — untouched. This whole feature is two clients.
Risks
- Mark drift deleting the wrong tracks. The reconciliation rule and the newest-snapshot rule exist for this; it needs the strongest tests in the feature.
p's changed meaning surprising existing users. Mitigated by leavinga/L/Enteralone and documenting the change; not avoidable ifpis to have one meaning.- Silent paste shortfall when a path no longer resolves — you paste 5 and get 4. The server already skips unresolvable paths in a resolve; the clients should say what they pasted rather than claim success blindly.
- Two implementations drifting (the very thing D8 is fixing for visual mode). Same rule, same test cases, both landed together.
- Duplicate-path ambiguity in reconciliation is inherent, not solvable without a per-entry queue id in the proto. In-order matching is the honest approximation; a queue id is the escape hatch if it ever bites.
Open questions
None blocking. Deferred by choice: named registers (D1 leaves room), a
numbered/history register stack, y in the queue putting entries somewhere
persistent (that is what w and saved queues are for), and a per-entry
queue id in the proto to make reconciliation exact.