8.7 KiB
Editable and deletable library nodes
Context and problem statement
The search feature (architecture/search.md) introduced creatable nodes:
% under /tidal/search turns a typed term into a tree node holding search
results. Those nodes are currently immutable — a typo'd term can only be
abandoned, and stale terms accumulate for the lifetime of the server process.
This feature makes such nodes modifiable: in the library pane, e renames
the selected node (for a search term: re-runs the search under the new term)
and d deletes it. Modifiability is a per-node capability advertised by the
provider, exactly like is_creatable — the TUI never hardcodes which paths
support what.
Run autonomously per standing user instruction; every decision below records the options considered and the rationale.
Assumptions
- "Nodes that got created via
%" are today exactly the search-term nodes under/tidal/search; the design must not special-case them (playlists are the obvious future candidate), but they are the only provider implementation in this iteration. - Editing means renaming (the title is the only user-supplied property a node has). For a search term, the title is the query, so a rename re-runs the search.
e/dact on the selected item in the library list (the same selection model as queueing), not on the currently-open node.- Proto changes must stay wire-compatible (additive fields/rpcs only), as with the search feature.
Decisions
D1 — Two capability flags, on the child only
Options considered:
- One
is_modifiableflag implying both rename and delete. - Two flags
is_editable/is_deletable. - Flags on both
LibraryNodeChildandLibraryNode(mirroringis_creatable).
Decision: (2), child-only — LibraryNodeChild.is_editable = 5,
LibraryNodeChild.is_deletable = 6. Future node kinds plausibly support only
one of the two (a favorites entry may be deletable but not renamable), and an
extra bool costs nothing on the wire. Unlike is_creatable (consumed for the
open node: pane hint + % target), edit/delete are only ever checked
against the selected child, so node-level copies would have no consumer —
they are left out until something reads them.
D2 — RPC shapes
Options considered:
- One generic
UpdateLibraryNodewith optional fields + a separate delete. RenameLibraryNode(path, new_title) → nodeandDeleteLibraryNode(path) → parent node.
Decision: (2). Rename is the only edit that exists; a generic update
message would be speculative surface area. Both responses carry the node the
TUI should display next, following CreateLibraryNode:
- Rename returns the renamed node and the TUI navigates into it — the
same "show me the result" behavior as
%create. The path changes on rename (term is percent-encoded into the path), so returning the node is also what tells the client the new path. - Delete returns the refreshed parent node — the user is looking at the
parent listing when they press
d; returning it saves a follow-upGetLibraryNodeand can never serve a stale cached listing.
D3 — Provider semantics (tidaldy)
rename_lib_node(path, new_title):pathmust parse toTidalPath::SearchTerm, elseNotSupported. Title is trimmed; empty →InvalidInput. The old term is replaced in place (keeps its position in the terms list). Renaming to an already-existing term merges: the old entry is removed, the existing one wins — the list never holds duplicates. Renaming a term the server doesn't know (stale client cache, restart) registers the new term — same forgiveness asget_lib_nodeon unknown terms. Returnsget_lib_node(new_path).delete_lib_node(path):pathmust parse toTidalPath::SearchTerm, elseNotSupported. Removing an unknown term succeeds silently — delete is idempotent. Returns the refreshed parent node (get_lib_node(/tidal/search)).- Both reuse the
search_termsRwLockdiscipline: poison-tolerant, never held across an await. - Queued search tracks survive rename/delete: a queue entry
/tidal/search/<enc-term>/<track-id>resolves URLs/metadata from the embedded track id alone; term registration is irrelevant to playback.
D4 — No delete confirmation (for now)
Options: confirm prompt (y/n mini-mode) vs immediate delete.
Decision: immediate. The only deletable nodes are search terms, which are
free to recreate (% + retype); a confirmation mode adds a third input state
for no protected value. Open question: when higher-value nodes (user
playlists) become deletable, a confirmation step must be revisited — noted
here so the decision is rediscovered.
D5 — TUI input overlay grows a purpose
InputState today hardcodes creation (parent_path + buffer). It becomes
InputState { purpose: InputPurpose, buffer: String }
enum InputPurpose { Create { parent_path }, Rename { path } }
eopens the overlay prefilled with the current title (append-only editing as before: chars push, Backspace pops); Enter sendsMessageFromUi::RenameNode, Esc cancels. Overlay label:rename: <buffer>▏vsnew node: <buffer>▏.- Prefilling means "rename" degrades gracefully to "retype" — no cursor movement is introduced in this iteration (matches the existing overlay).
- Submitting an unchanged title is sent anyway; the provider treats it as a no-op rename and returns the node (harmless refresh).
dsendsMessageFromUi::DeleteNode { path }directly (D4).- Both keys are silently ignored when the selected item lacks the flag,
mirroring
%on non-creatable nodes. - List marker: editable/deletable children render a
[e],[d]or[ed]suffix inCOLOR_SECONDARY, alongside the existing[%]for creatable ones. The bindings table gainse/din the Library scope (plaineanddare unbound there today;donly exists in Queue scope), so the help modal picks them up automatically.
D6 — Client cache handling
RpcClient keeps library_node_cache. On rename: evict the old path and
the parent, insert the returned node under its new path. On delete: evict
the deleted path and the parent, insert the returned parent. (Same reasoning
as create's eviction — a stale /tidal/search listing would resurrect the
old term in the UI.)
D7 — Server plumbing
Two new ProviderCommands (RenameLibraryNode, DeleteLibraryNode) with the
established bounded(1) reply rendezvous; orchestrator routes /tidal-prefixed
paths to the tidal client and answers anything else NotSupported. gRPC error
mapping is identical to create: NotSupported → failed_precondition,
InvalidInput → invalid_argument, rest → internal; no internals leak into
Status messages.
Flows
shape: sequence_diagram
user: { shape: person }
tui: cbd-tui
server: crabidy-server
tidal: tidaldy
user -> tui: "e on selected [ed] node"
tui -> tui: open overlay prefilled with title
user -> tui: edit text, Enter
tui -> server: RenameLibraryNode(path, new_title)
server -> tidal: rename_lib_node
tidal -> tidal: replace term in list (merge on collision)
tidal -> server: node at new path (fresh search)
server -> tui: renamed node
tui -> tui: evict old path + parent, show renamed node
user -> tui: "d on selected [ed] node"
tui -> server: DeleteLibraryNode(path)
server -> tidal: delete_lib_node
tidal -> tidal: remove term (idempotent)
tidal -> server: refreshed parent node
server -> tui: parent node
tui -> tui: evict path + parent, show parent listing
Capability flags travel with every listing:
direction: right
tidaldy: {
search_arm: "get_lib_node(/tidal/search)"
}
proto: "LibraryNodeChild { is_editable=5, is_deletable=6 }"
tui: {
list: "library list: title [ed]"
keys: "e -> rename overlay\nd -> DeleteNode"
}
tidaldy.search_arm -> proto: term children flagged
proto -> tui.list: render marker
proto -> tui.keys: gate actions
Boundaries and risks
- Proto: additive only — two rpcs, two child fields (5, 6). Old clients
ignore the flags and never call the rpcs; old servers reject unknown rpcs
with
unimplemented(tonic default), which the TUI logs without crashing. - Rename-to-collision merges terms; the response node is the existing term's node. The user sees the results they asked for either way.
- Concurrent clients: two TUIs editing the same term list race benignly —
the list is a
RwLock-guarded Vec, every operation is atomic under the write lock, and stale views self-heal on the next listing fetch. - Not in scope: editing anything but the title; deleting non-search nodes; confirmation UX (D4); persistent search terms (still per-process, as shipped by the search feature).