crabidy/architecture/queue-register.md

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), and d, 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 kept p backward-compatible, but it means s in the library silently clobbers a clipboard you are about to paste.
  • Both p (after cursor) and P (before cursor), so d then P restores exactly what you deleted, and dp is a move.
  • c/C fill 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-web gets queue marks, queue visual mode, and the register — plus the library visual mode that was deferred when v/V landed 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

  1. Resolved tracks (Track messages, 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.

  2. 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 (y an album, p it into the queue); and the register is one Vec<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.

  1. Remap marks by index. Cheapest, and silently wrong: if playback advanced and the queue shifted by one, d deletes the wrong tracks. Rejected — a data-losing failure mode.
  2. 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.
  3. 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-tui owns a Vec<UiItem> per pane and already has marked on it (the queue builds every row marked: false today).
  • cbd-web owns no queue list at all — just QueueCursor { selected }, with rows rendered straight from the server's Queue signal.

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; dp 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_selected already does; y/d consume 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 leaving a/L/Enter alone and documenting the change; not avoidable if p is 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.