//! The sidebar, described: vaults, collections, and the tags you can filter by. //! //! The ninth audiofiles port. It completes the main window's regions — the shell //! now has a `Sidebar`, a `Pane` and a `Band`, which is every region kind this //! app has a use for — and it is the first port where a described control //! *replaces* the app's confirmation machinery rather than merely arguing that //! it could. //! //! # `Act::confirm` doing the job `ConfirmAction` was doing //! //! `quasi/mod.rs`'s header counts `draw_confirm_dialog` as ten variants //! replaceable by two builder methods. Two of those variants are here and are //! now written the other way: //! //! - `ConfirmAction::DeleteVfs` is `Act::new("Delete", ..).tone(Danger).confirm("Delete //! vault \"x\" and all its contents?")` on the vault's row menu. //! - `ConfirmAction::RemoveTagGlobally` is the same shape on a tag's. //! //! The shipped path for either is: a context menu writes `pending_confirm`, a //! 140-line `match` in `overlays.rs` turns the variant back into a prompt and a //! button label, a modal draws it, and `execute_confirmed_action` dispatches on //! the variant again to find what to do. The described path is one method on the //! control, and the runtime answers `Step::Ask`. **The prompt lives where the //! action does**, which is the whole of `524a63fe`'s argument, and the round trip //! through an enum is what a description makes unnecessary rather than shorter. //! //! # THE FINDING: a hierarchy of rows has no description //! //! audiofiles' tags are dotted — `drums.kick`, `genre.house` — and the shipped //! sidebar builds a real tree out of them: `TagNode` with `children`, a //! recursive `draw_tag_node`, a disclosure chevron that is deliberately a //! separate hit target from the label, per-node expansion persisted by egui, and //! a distinction between a parent that is itself a tag and one that only groups //! (filtering by the latter "would match zero samples", so its label is not //! interactive at all). //! //! None of that is sayable. `RowPart` is `Primary`, `Secondary`, `Meta`, //! `Actions`, `Tokens`, `Proportion` — there is no depth on a row and no member //! that holds rows inside a row. `Node::Region` nests, but a region is a rect //! with its own scroll, not a row with children, and building a tag tree out of //! nested regions would be describing a drawing rather than a hierarchy. //! //! So this port **flattens it**: every tag is one row at its full dotted path, //! togglable as a filter. That is honest about what the filter actually operates //! on — `required_tags` holds exact paths, and the tree is a navigation //! convenience over a flat set — and it loses three real things: the grouping, the //! ability to collapse a branch you are not using, and the parent/leaf //! distinction. On a vault with two hundred tags the described sidebar is a wall //! where the shipped one is an outline. //! //! Filed rather than faked -- `ccaa7e4b`, and it was not: that sentence stood //! here for four days over a task nobody had created. Filed for real 2026-08-21, //! with the flattening above as its measured cost. Note this is not the same gap as //! `Node::Heading { level }`, which says how far down the *document* a title //! sits: that is depth in prose, and this is containment in a set. //! //! # A fourth consumer for the disabled-control precondition //! //! The vault Delete is `danger_button_enabled(ui, "Delete", vfs_count > 1)` with //! `on_disabled_hover_text("Create another vault first, audiofiles needs at //! least one.")`. Same missing fact as `9bab759c`'s other three. The described //! act is disabled and the sentence is said in the section's prose, which is //! wrong in the way that finding predicts. //! //! # What is deliberately not described //! //! - **Renaming a tag or a collection.** The shipped rename opens an inline //! editor *and* computes what it is about to affect — how many samples carry //! the tag, and which descendant tags will not be carried along, because //! `rename_tag_globally` is exact-match-only. That is a flow with a //! consequences screen in it, and it deserves a pass rather than a row in this //! one. //! - **The library picker.** Switching library is `VaultAction::SwitchVault` //! guarded by `has_in_flight_work`, which tears down and rebuilds the whole //! app around a different database. Out of scope for a sidebar region. //! - **The onboarding banner.** `show_vfs_banner` explains what a vault is once. //! A described first run is its own subject. use quasi_router::layout::{Token, Tone}; use quasi_router::{ Act, Action, Node, RegionKind, Request, Response, RouteError, Router, Row, Slot, Tag, }; use super::{Holding, Panels}; /// The region the sidebar answers into. const SIDE: &str = "library-side"; /// Register the sidebar's routes. /// /// Every one answers the whole main screen, because the sidebar is a region of /// it and not a place: choosing a vault changes what the list shows, so the /// answer is the window rather than the corner of it that was pressed. pub fn routes(router: Router>) -> Router> { router .post("/vaults/{id}/open", open_vault) .post("/vaults/{id}/delete", delete_vault) .post("/tags/{path}/filter", toggle_tag) .post("/tags/{path}/remove", remove_tag) .post("/collections/{id}/open", open_collection) .post("/collections/close", close_collection) .post("/collections/{id}/delete", delete_collection) } /// `POST /vaults/{id}/open` fn open_vault(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request, "no such vault")?; // Re-opening the vault you are in is a no-op rather than a refusal, which is // the shipped list's own rule: it would otherwise clear the current // directory, the breadcrumb and the selection, and "the click matches user // expectation" is what that comment says about it. if !state.library.vaults().iter().any(|vault| vault.id == id) { return Err(RouteError::not_found("no such vault")); } state.library.open_vault(id); Ok(super::shell::screen(state).into()) } /// `POST /vaults/{id}/delete` /// /// Refused where it would leave none, which is the condition the shipped Delete /// is disabled on. A disabled control is an affordance and an address is /// reachable by typing, so the route says it too. fn delete_vault(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request, "no such vault")?; if state.library.vaults().len() < 2 { return Err(RouteError::not_found(LAST_VAULT)); } state.library.delete_vault(id); Ok(super::shell::screen(state).into()) } /// `POST /tags/{path}/filter` fn toggle_tag(state: &Panels<'_>, request: Request) -> Result { let path = request.captures.require("path")?.to_owned(); state.library.toggle_tag(&path); Ok(super::shell::screen(state).into()) } /// `POST /tags/{path}/remove` fn remove_tag(state: &Panels<'_>, request: Request) -> Result { let path = request.captures.require("path")?.to_owned(); state.library.remove_tag(&path); Ok(super::shell::screen(state).into()) } /// `POST /collections/{id}/open` fn open_collection(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request, "no such collection")?; state.library.open_collection(id); Ok(super::shell::screen(state).into()) } /// `POST /collections/close` fn close_collection(state: &Panels<'_>, _request: Request) -> Result { state.library.close_collection(); Ok(super::shell::screen(state).into()) } /// `POST /collections/{id}/delete` fn delete_collection(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request, "no such collection")?; state.library.delete_collection(id); Ok(super::shell::screen(state).into()) } /// The id a request names. fn numbered(request: &Request, whats_wrong: &'static str) -> Result { request .captures .require("id")? .parse() .map_err(|_| RouteError::not_found(whats_wrong)) } /// What the shipped Delete says when there is only one vault left. const LAST_VAULT: &str = "Create another vault first, audiofiles needs at least one."; /// The sidebar, as a region something else holds. pub fn body(state: &Panels<'_>) -> Slot { let side = Slot::new(SIDE, RegionKind::Sidebar); let side = vaults(side, state); let side = collections(side, state); tags(side, state) } /// The vaults, and what can be done to one. fn vaults(side: Slot, state: &Panels<'_>) -> Slot { let all = state.library.vaults(); let alone = all.len() < 2; // The door to the described New Vault modal (see `naming`). It was an // `Intent::NewVault` that opened the *shipped* modal until 2026-08-17, which // was the one control on this screen whose answer was drawn by hand. let mut side = side .with(Node::section("Vaults")) .with(Node::Act(Act::new("New vault", Action::get("/vaults/new")))); let mut rows = Vec::with_capacity(all.len()); for vault in &all { let mut delete = Act::new( "Delete", Action::post(format!("/vaults/{}/delete", vault.id)), ) .tone(Tone::Danger) // `ConfirmAction::DeleteVfs`, said where the action is. See the // module header. .confirm(format!( "Delete vault \"{}\" and all its contents?", vault.name )); if alone { // Offered dead rather than hidden, which is the shipped menu's // choice: "Always render Delete so the user can see the capability // exists." delete = delete.disabled(); } // `offers` rather than `act`: the shipped affordance is a right-click // menu, and `Row::menu` is what "held back until the host asks" means. // An inline Delete on every vault row would be a different screen. let mut row = Row::new(&vault.name) .activate(Action::post(format!("/vaults/{}/open", vault.id))) .offers(Act::new( "Rename", Action::get(format!("/vaults/{}/rename", vault.id)), )) .offers(delete); row.current = vault.current; rows.push(row); } side = side.with(Node::List { rows, more: None }); if alone { // The precondition, said beside the control rather than on it. THE // FINDING, fourth consumer -- see the module header. side = side.with(Node::Text { text: LAST_VAULT.to_owned(), tone: Tone::Info, }); } side } /// The collections, manual and dynamic. fn collections(side: Slot, state: &Panels<'_>) -> Slot { let all = state.library.collections(); let mut side = side.with(Node::section("Collections")); if all.is_empty() { return side.with(Node::empty("No collections yet.")); } let mut rows = Vec::with_capacity(all.len()); for collection in &all { // What kind it is, as a token rather than a suffix on the name. The // shipped row appends " (auto)" or " (12)" to the label, with a comment // saying it is a text suffix "instead of a glyph (per the no-emoji brand // rule, and for accessibility)" -- which is right about the glyph and // still puts a second fact inside the name. let mark = match collection.holding { Holding::Dynamic => Tag::badge("auto"), Holding::Fixed(count) => Tag::badge(count.to_string()), }; let mut row = Row::new(&collection.name) .token(mark) .activate(if collection.active { Action::post("/collections/close") } else { Action::post(format!("/collections/{}/open", collection.id)) }) .offers( Act::new( "Delete", Action::post(format!("/collections/{}/delete", collection.id)), ) .tone(Tone::Danger) .confirm(format!("Delete collection \"{}\"?", collection.name)), ); row.current = collection.active; rows.push(row); } side = side.with(Node::List { rows, more: None }); side } /// The tags, flat. /// /// See the module header: the hierarchy the shipped sidebar draws has no /// description, so what is here is every tag at its full path. The chips latch, /// because a tag filter is on or off and that is exactly what /// [`Token::Chip`]'s `latched` says. fn tags(side: Slot, state: &Panels<'_>) -> Slot { let all = state.library.tags(); let mut side = side.with(Node::section("Tags")); if all.is_empty() { return side.with(Node::empty("No tags yet.")); } for filter in &all { side = side.with(Node::Token(Tag { kind: Token::Chip { removable: false }, label: filter.path.clone(), tone: if filter.on { Tone::Info } else { Tone::Neutral }, latched: filter.on, action: Some(Action::post(format!("/tags/{}/filter", filter.path))), })); } // Removing a tag from every sample is not a filter, so it is not a chip. It // is a list of the same tags with a destructive act on each, which is the // shipped right-click menu made visible -- and the second // `ConfirmAction` variant this port replaces. side.with(Node::section("Remove a tag everywhere")) .with(Node::List { rows: all .iter() .map(|filter| { Row::new(&filter.path).offers( Act::new( "Remove", Action::post(format!("/tags/{}/remove", filter.path)), ) .tone(Tone::Danger) .confirm(format!( "Remove tag \"{}\" from every sample that has it?", filter.path )), ) }) .collect(), more: None, }) }