crabidy/architecture/queue-register.md

266 lines
11 KiB
Markdown

# 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 `d``p` 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 `UiItem`s (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:
```rust
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
```d2
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:
```d2
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.