//! The four name modals, described: new vault, rename vault, new folder, //! rename folder. //! //! The eleventh audiofiles port, and the one with the least left to invent. The //! forms pass had already made `widgets::name_modal` a described `Field::text` //! (`audiofiles@21dd4e6`), so the *question* has been described for a day; what //! was missing was the address that asks it. One helper behind four screens //! there, four routes over one screen builder here. //! //! # The write happens in the route, and it is the first one that does //! //! Every capability before this records an [`Intent`](super::Intent) and hears //! nothing back. This one calls `Backend::create_vfs` and reads its `Result`, //! and the reason is worth stating because it looks like the rule being broken: //! //! - `Backend::create_vfs` and `rename_node` are `&self`. A route can call them. //! - `refresh_vfs_list` and `refresh_contents` are `&mut BrowserState`. A route //! cannot. //! //! So the write is here and the refresh is the intent. [`Detail`](super::Detail) //! settled that *what the app does about a write decides where the write goes*, //! and this is the case where the app does two things about one write, with //! different lifetimes. The half that has to answer is the half that stayed. //! //! It has to answer because of the error. A vault name the store refuses belongs //! on the field it was typed into, and an intent applied after the answer was //! built has no way to put it there — the modal would close on a failure and the //! typed name would be gone. That is exactly the bug the shipped modal's C-3 //! comment exists to have fixed ("keep the modal open; surface the error inline //! so the user can edit and retry without re-typing the name"), and a described //! screen that could not say it would be a port that lost a fix. //! //! # The error answers a fragment, not the modal again //! //! `Outcome::Over` twice is two modals. [`bulk`](super::bulk)'s finding 2 says an //! overlay cannot re-answer itself, and this port hits it from the ordinary //! direction rather than the exotic one: a refused name is the commonest thing a //! modal has to survive. `Outcome::Fragment` over the form's own region is the //! way through, the same one the rename preview uses, which is why the form sits //! in a [`Node::Region`] rather than loose in the body. //! //! # What is not here: two of the four doors //! //! New Vault and Rename Vault are reachable — the described sidebar offers both. //! **New Folder and Rename Folder are addresses with no described control //! pointing at them**, because the only door the shipped app has for either is //! `ui/file_list_menus.rs`'s right-click menus, and whether a context menu is //! describable at all is its own measurement (audiofiles `0341c7b5`). The //! screens are complete and tested; what is missing is one act in a module that //! does not exist yet. //! //! An address nothing links to is still a screen here, on the rule this port has //! used since `library`: an address is reachable by typing, so what a control //! offers is an affordance rather than a guarantee. //! //! # What the description does not carry //! //! **The autofocus.** `name_modal` grabs focus when the input is empty and //! nothing else holds it, and re-grabs it when an error appears. Both are //! renderer policy — where the caret goes is a fact about a host with a caret — //! and neither is described. A host that has no focus to give loses nothing. use quasi_router::layout::{FieldKind, Tone}; use quasi_router::{ Act, Action, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen, Slot, }; use super::Panels; /// The region a modal answers into. const BODY: &str = "naming-body"; /// The region the form itself sits in, so a refusal can replace just that. const FORM: &str = "naming-form"; /// The name the value is submitted under. const NAME: &str = "name"; /// Where a finished or cancelled modal goes. /// /// The main window, which is what all four of these are drawn over. Same finding /// as [`bulk`](super::bulk)'s first: this navigates rather than dismisses, /// because there is no described way to close what is on top. /// /// Navigating is not enough on its own, which the flip found (2026-08-22). /// **What keeps one of these on screen is the host's own flag**, not the /// runtime's layer stack: `vfs_modal`'s two bools and two targets are what the /// app checks before drawing anything, so a route that navigates away and says /// nothing else leaves the window up with the main screen inside it. So every /// exit goes through [`DONE`] first, which is `integrity`'s `dismiss` in a /// second consumer: a route whose whole job is to tell the host the screen is /// finished with. That is the app's answer to the finding above, not the /// vocabulary's -- an `Outcome` meaning "this overlay is done" is still missing. const BACK: &str = "/"; /// The route that says a modal is finished with, whichever of the four it was. const DONE: &str = "/naming/done"; /// Register the four modals' routes. pub fn routes(router: Router>) -> Router> { router .get("/vaults/new", new_vault_screen) .post("/vaults/new", new_vault) .get("/vaults/{id}/rename", rename_vault_screen) .post("/vaults/{id}/rename", rename_vault) .get("/folders/new", new_folder_screen) .post("/folders/new", new_folder) .get("/folders/{id}/rename", rename_folder_screen) .post("/folders/{id}/rename", rename_folder) .post(DONE, done) } /// What one of these modals asks. /// /// The shipped `NameModalSpec` minus its `placeholder`, which none of the four /// ever set, and minus its `submit_label`'s twin problem: the title and the /// button are the modal's own words and stay strings. struct Asking { /// The modal's title. title: &'static str, /// Standing help about the modal, above the field. lead_in: Option<&'static str>, /// The field's own label. label: &'static str, /// Standing help about the answer. hint: Option<&'static str>, /// What the submit button says. submit: &'static str, /// Where the answer goes. action: String, } /// `GET /vaults/new` fn new_vault_screen(_state: &Panels<'_>, _request: Request) -> Result { Ok(over(&asking_new_vault(), "", None)) } /// `POST /vaults/new` fn new_vault(state: &Panels<'_>, request: Request) -> Result { submitted(state, &request, &asking_new_vault(), |name| { state.naming.create_vault(name) }) } /// The New Vault modal's words. fn asking_new_vault() -> Asking { Asking { title: "New Vault", // What a vault is, and how to nest one, are facts about the modal // rather than about the name being typed, so they are a lead-in above // the field and not the field's hint. The forms pass split these two // slots and this is the site that made the distinction. lead_in: Some( "A vault is a separate sample collection, like a folder, but with its own tags and analysis. Right-click inside to create sub-folders.", ), label: "Vault name", hint: None, submit: "Create", action: "/vaults/new".to_owned(), } } /// `GET /vaults/{id}/rename` fn rename_vault_screen(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request)?; let current = state .naming .vault(id) .ok_or_else(|| RouteError::not_found("no such vault"))?; Ok(over(&asking_rename_vault(id), ¤t, None)) } /// `POST /vaults/{id}/rename` fn rename_vault(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request)?; submitted(state, &request, &asking_rename_vault(id), |name| { state.naming.rename_vault(id, name) }) } /// The Rename Vault modal's words. fn asking_rename_vault(id: i64) -> Asking { Asking { title: "Rename Vault", lead_in: None, label: "New name", // About the answer, so it is the field's hint. hint: Some("Vault names can contain spaces."), submit: "Save", action: format!("/vaults/{id}/rename"), } } /// `GET /folders/new` fn new_folder_screen(_state: &Panels<'_>, _request: Request) -> Result { Ok(over(&asking_new_folder(), "", None)) } /// `POST /folders/new` fn new_folder(state: &Panels<'_>, request: Request) -> Result { submitted(state, &request, &asking_new_folder(), |name| { state.naming.create_folder(name) }) } /// The New Folder modal's words. fn asking_new_folder() -> Asking { Asking { title: "New Folder", lead_in: None, label: "Folder name", // A constraint on the answer, so it is the field's hint. hint: Some("Folder names cannot contain /"), submit: "Create", action: "/folders/new".to_owned(), } } /// `GET /folders/{id}/rename` fn rename_folder_screen(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request)?; let current = state .naming .folder(id) .ok_or_else(|| RouteError::not_found("no such folder"))?; Ok(over(&asking_rename_folder(id), ¤t, None)) } /// `POST /folders/{id}/rename` fn rename_folder(state: &Panels<'_>, request: Request) -> Result { let id = numbered(&request)?; submitted(state, &request, &asking_rename_folder(id), |name| { state.naming.rename_folder(id, name) }) } /// The Rename Folder modal's words. fn asking_rename_folder(id: i64) -> Asking { Asking { title: "Rename", lead_in: None, label: "New name", hint: None, submit: "Save", action: format!("/folders/{id}/rename"), } } /// `POST /naming/done` /// /// Tell the host the modal is finished with, then leave. See [`DONE`]. fn done(state: &Panels<'_>, _request: Request) -> Result { state.naming.done(); Ok(Response::from(leaving())) } /// The id a request names. fn numbered(request: &Request) -> Result { request .captures .require("id")? .parse() .map_err(|_| RouteError::not_found("that is not an id")) } /// What every one of the four does with what was typed. /// /// One function because the four differ in their words and in which method they /// call, and in nothing else — which is what `handle_name_modal_outcome` says /// about the shipped four, in a comment, having been factored out for exactly /// this reason. /// /// **An empty submit closes the modal as a no-op**, which is the shipped rule /// and the reason none of these fields is `required`: the marker would claim a /// refusal that never happens. fn submitted( state: &Panels<'_>, request: &Request, asking: &Asking, commit: impl FnOnce(&str) -> Result, ) -> Result { let typed = request.payload.get(NAME).unwrap_or_default().trim(); if typed.is_empty() { state.naming.done(); return Ok(Response::from(leaving())); } match commit(typed) { Ok(say) => { state.naming.done(); Ok(Response::from(leaving()).toast(Tone::Success, say)) } // The modal stays up with the name still in it. See the module header: // a fragment rather than a second `Over`. Err(why) => Ok(Response::from(Outcome::Fragment { region: FORM.to_owned(), node: form(asking, typed, Some(&why)), })), } } /// The modal, whatever it is asking. fn over(asking: &Asking, value: &str, error: Option<&str>) -> Response { let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page(asking.title)); if let Some(lead_in) = asking.lead_in { body = body.with(Node::text(lead_in)); } let body = body .with(form(asking, value, error)) .with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc"))); Response::from(Outcome::Over( Screen::sidebar_content(asking.title).with(body), )) } /// The one question, in a region of its own so a refusal can replace it. fn form(asking: &Asking, value: &str, error: Option<&str>) -> Node { let mut field = Field::new(FieldKind::Text, NAME, asking.label).value(value); if let Some(hint) = asking.hint { field = field.hint(hint); } if let Some(error) = error { field = field.error(error); } Node::Region(Slot::new(FORM, RegionKind::Group).with(Node::Form { fields: vec![field], submit: asking.submit.to_owned(), action: Action::post(asking.action.clone()), })) } /// The screen a finished or cancelled modal leaves behind. fn leaving() -> Outcome { Outcome::Goto(Action::get(BACK)) }