crabidy/architecture/node-editing.md

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/d act 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:

  1. One is_modifiable flag implying both rename and delete.
  2. Two flags is_editable / is_deletable.
  3. Flags on both LibraryNodeChild and LibraryNode (mirroring is_creatable).

Decision: (2), child-onlyLibraryNodeChild.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:

  1. One generic UpdateLibraryNode with optional fields + a separate delete.
  2. RenameLibraryNode(path, new_title) → node and DeleteLibraryNode(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-up GetLibraryNode and can never serve a stale cached listing.

D3 — Provider semantics (tidaldy)

  • rename_lib_node(path, new_title): path must parse to TidalPath::SearchTerm, else NotSupported. 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 as get_lib_node on unknown terms. Returns get_lib_node(new_path).
  • delete_lib_node(path): path must parse to TidalPath::SearchTerm, else NotSupported. Removing an unknown term succeeds silently — delete is idempotent. Returns the refreshed parent node (get_lib_node(/tidal/search)).
  • Both reuse the search_terms RwLock discipline: 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 } }
  • e opens the overlay prefilled with the current title (append-only editing as before: chars push, Backspace pops); Enter sends MessageFromUi::RenameNode, Esc cancels. Overlay label: rename: <buffer>▏ vs new 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).
  • d sends MessageFromUi::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 in COLOR_SECONDARY, alongside the existing [%] for creatable ones. The bindings table gains e/d in the Library scope (plain e and d are unbound there today; d only 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: NotSupportedfailed_precondition, InvalidInputinvalid_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).