Skip to main content

max / audiofiles

12.7 KB · 338 lines History Blame Raw
1 //! The four name modals, described: new vault, rename vault, new folder,
2 //! rename folder.
3 //!
4 //! The eleventh audiofiles port, and the one with the least left to invent. The
5 //! forms pass had already made `widgets::name_modal` a described `Field::text`
6 //! (`audiofiles@21dd4e6`), so the *question* has been described for a day; what
7 //! was missing was the address that asks it. One helper behind four screens
8 //! there, four routes over one screen builder here.
9 //!
10 //! # The write happens in the route, and it is the first one that does
11 //!
12 //! Every capability before this records an [`Intent`](super::Intent) and hears
13 //! nothing back. This one calls `Backend::create_vfs` and reads its `Result`,
14 //! and the reason is worth stating because it looks like the rule being broken:
15 //!
16 //! - `Backend::create_vfs` and `rename_node` are `&self`. A route can call them.
17 //! - `refresh_vfs_list` and `refresh_contents` are `&mut BrowserState`. A route
18 //! cannot.
19 //!
20 //! So the write is here and the refresh is the intent. [`Detail`](super::Detail)
21 //! settled that *what the app does about a write decides where the write goes*,
22 //! and this is the case where the app does two things about one write, with
23 //! different lifetimes. The half that has to answer is the half that stayed.
24 //!
25 //! It has to answer because of the error. A vault name the store refuses belongs
26 //! on the field it was typed into, and an intent applied after the answer was
27 //! built has no way to put it there — the modal would close on a failure and the
28 //! typed name would be gone. That is exactly the bug the shipped modal's C-3
29 //! comment exists to have fixed ("keep the modal open; surface the error inline
30 //! so the user can edit and retry without re-typing the name"), and a described
31 //! screen that could not say it would be a port that lost a fix.
32 //!
33 //! # The error answers a fragment, not the modal again
34 //!
35 //! `Outcome::Over` twice is two modals. [`bulk`](super::bulk)'s finding 2 says an
36 //! overlay cannot re-answer itself, and this port hits it from the ordinary
37 //! direction rather than the exotic one: a refused name is the commonest thing a
38 //! modal has to survive. `Outcome::Fragment` over the form's own region is the
39 //! way through, the same one the rename preview uses, which is why the form sits
40 //! in a [`Node::Region`] rather than loose in the body.
41 //!
42 //! # What is not here: two of the four doors
43 //!
44 //! New Vault and Rename Vault are reachable — the described sidebar offers both.
45 //! **New Folder and Rename Folder are addresses with no described control
46 //! pointing at them**, because the only door the shipped app has for either is
47 //! `ui/file_list_menus.rs`'s right-click menus, and whether a context menu is
48 //! describable at all is its own measurement (audiofiles `0341c7b5`). The
49 //! screens are complete and tested; what is missing is one act in a module that
50 //! does not exist yet.
51 //!
52 //! An address nothing links to is still a screen here, on the rule this port has
53 //! used since `library`: an address is reachable by typing, so what a control
54 //! offers is an affordance rather than a guarantee.
55 //!
56 //! # What the description does not carry
57 //!
58 //! **The autofocus.** `name_modal` grabs focus when the input is empty and
59 //! nothing else holds it, and re-grabs it when an error appears. Both are
60 //! renderer policy — where the caret goes is a fact about a host with a caret —
61 //! and neither is described. A host that has no focus to give loses nothing.
62
63 use quasi_router::layout::{FieldKind, Tone};
64 use quasi_router::{
65 Act, Action, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen,
66 Slot,
67 };
68
69 use super::Panels;
70
71 /// The region a modal answers into.
72 const BODY: &str = "naming-body";
73
74 /// The region the form itself sits in, so a refusal can replace just that.
75 const FORM: &str = "naming-form";
76
77 /// The name the value is submitted under.
78 const NAME: &str = "name";
79
80 /// Where a finished or cancelled modal goes.
81 ///
82 /// The main window, which is what all four of these are drawn over. Same finding
83 /// as [`bulk`](super::bulk)'s first: this navigates rather than dismisses,
84 /// because there is no described way to close what is on top.
85 ///
86 /// Navigating is not enough on its own, which the flip found (2026-08-22).
87 /// **What keeps one of these on screen is the host's own flag**, not the
88 /// runtime's layer stack: `vfs_modal`'s two bools and two targets are what the
89 /// app checks before drawing anything, so a route that navigates away and says
90 /// nothing else leaves the window up with the main screen inside it. So every
91 /// exit goes through [`DONE`] first, which is `integrity`'s `dismiss` in a
92 /// second consumer: a route whose whole job is to tell the host the screen is
93 /// finished with. That is the app's answer to the finding above, not the
94 /// vocabulary's -- an `Outcome` meaning "this overlay is done" is still missing.
95 const BACK: &str = "/";
96
97 /// The route that says a modal is finished with, whichever of the four it was.
98 const DONE: &str = "/naming/done";
99
100 /// Register the four modals' routes.
101 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
102 router
103 .get("/vaults/new", new_vault_screen)
104 .post("/vaults/new", new_vault)
105 .get("/vaults/{id}/rename", rename_vault_screen)
106 .post("/vaults/{id}/rename", rename_vault)
107 .get("/folders/new", new_folder_screen)
108 .post("/folders/new", new_folder)
109 .get("/folders/{id}/rename", rename_folder_screen)
110 .post("/folders/{id}/rename", rename_folder)
111 .post(DONE, done)
112 }
113
114 /// What one of these modals asks.
115 ///
116 /// The shipped `NameModalSpec` minus its `placeholder`, which none of the four
117 /// ever set, and minus its `submit_label`'s twin problem: the title and the
118 /// button are the modal's own words and stay strings.
119 struct Asking {
120 /// The modal's title.
121 title: &'static str,
122 /// Standing help about the modal, above the field.
123 lead_in: Option<&'static str>,
124 /// The field's own label.
125 label: &'static str,
126 /// Standing help about the answer.
127 hint: Option<&'static str>,
128 /// What the submit button says.
129 submit: &'static str,
130 /// Where the answer goes.
131 action: String,
132 }
133
134 /// `GET /vaults/new`
135 fn new_vault_screen(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
136 Ok(over(&asking_new_vault(), "", None))
137 }
138
139 /// `POST /vaults/new`
140 fn new_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
141 submitted(state, &request, &asking_new_vault(), |name| {
142 state.naming.create_vault(name)
143 })
144 }
145
146 /// The New Vault modal's words.
147 fn asking_new_vault() -> Asking {
148 Asking {
149 title: "New Vault",
150 // What a vault is, and how to nest one, are facts about the modal
151 // rather than about the name being typed, so they are a lead-in above
152 // the field and not the field's hint. The forms pass split these two
153 // slots and this is the site that made the distinction.
154 lead_in: Some(
155 "A vault is a separate sample collection, like a folder, but with its own tags and analysis. Right-click inside to create sub-folders.",
156 ),
157 label: "Vault name",
158 hint: None,
159 submit: "Create",
160 action: "/vaults/new".to_owned(),
161 }
162 }
163
164 /// `GET /vaults/{id}/rename`
165 fn rename_vault_screen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
166 let id = numbered(&request)?;
167 let current = state
168 .naming
169 .vault(id)
170 .ok_or_else(|| RouteError::not_found("no such vault"))?;
171 Ok(over(&asking_rename_vault(id), &current, None))
172 }
173
174 /// `POST /vaults/{id}/rename`
175 fn rename_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
176 let id = numbered(&request)?;
177 submitted(state, &request, &asking_rename_vault(id), |name| {
178 state.naming.rename_vault(id, name)
179 })
180 }
181
182 /// The Rename Vault modal's words.
183 fn asking_rename_vault(id: i64) -> Asking {
184 Asking {
185 title: "Rename Vault",
186 lead_in: None,
187 label: "New name",
188 // About the answer, so it is the field's hint.
189 hint: Some("Vault names can contain spaces."),
190 submit: "Save",
191 action: format!("/vaults/{id}/rename"),
192 }
193 }
194
195 /// `GET /folders/new`
196 fn new_folder_screen(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
197 Ok(over(&asking_new_folder(), "", None))
198 }
199
200 /// `POST /folders/new`
201 fn new_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
202 submitted(state, &request, &asking_new_folder(), |name| {
203 state.naming.create_folder(name)
204 })
205 }
206
207 /// The New Folder modal's words.
208 fn asking_new_folder() -> Asking {
209 Asking {
210 title: "New Folder",
211 lead_in: None,
212 label: "Folder name",
213 // A constraint on the answer, so it is the field's hint.
214 hint: Some("Folder names cannot contain /"),
215 submit: "Create",
216 action: "/folders/new".to_owned(),
217 }
218 }
219
220 /// `GET /folders/{id}/rename`
221 fn rename_folder_screen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
222 let id = numbered(&request)?;
223 let current = state
224 .naming
225 .folder(id)
226 .ok_or_else(|| RouteError::not_found("no such folder"))?;
227 Ok(over(&asking_rename_folder(id), &current, None))
228 }
229
230 /// `POST /folders/{id}/rename`
231 fn rename_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
232 let id = numbered(&request)?;
233 submitted(state, &request, &asking_rename_folder(id), |name| {
234 state.naming.rename_folder(id, name)
235 })
236 }
237
238 /// The Rename Folder modal's words.
239 fn asking_rename_folder(id: i64) -> Asking {
240 Asking {
241 title: "Rename",
242 lead_in: None,
243 label: "New name",
244 hint: None,
245 submit: "Save",
246 action: format!("/folders/{id}/rename"),
247 }
248 }
249
250 /// `POST /naming/done`
251 ///
252 /// Tell the host the modal is finished with, then leave. See [`DONE`].
253 fn done(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
254 state.naming.done();
255 Ok(Response::from(leaving()))
256 }
257
258 /// The id a request names.
259 fn numbered(request: &Request) -> Result<i64, RouteError> {
260 request
261 .captures
262 .require("id")?
263 .parse()
264 .map_err(|_| RouteError::not_found("that is not an id"))
265 }
266
267 /// What every one of the four does with what was typed.
268 ///
269 /// One function because the four differ in their words and in which method they
270 /// call, and in nothing else — which is what `handle_name_modal_outcome` says
271 /// about the shipped four, in a comment, having been factored out for exactly
272 /// this reason.
273 ///
274 /// **An empty submit closes the modal as a no-op**, which is the shipped rule
275 /// and the reason none of these fields is `required`: the marker would claim a
276 /// refusal that never happens.
277 fn submitted(
278 state: &Panels<'_>,
279 request: &Request,
280 asking: &Asking,
281 commit: impl FnOnce(&str) -> Result<String, String>,
282 ) -> Result<Response, RouteError> {
283 let typed = request.payload.get(NAME).unwrap_or_default().trim();
284 if typed.is_empty() {
285 state.naming.done();
286 return Ok(Response::from(leaving()));
287 }
288 match commit(typed) {
289 Ok(say) => {
290 state.naming.done();
291 Ok(Response::from(leaving()).toast(Tone::Success, say))
292 }
293 // The modal stays up with the name still in it. See the module header:
294 // a fragment rather than a second `Over`.
295 Err(why) => Ok(Response::from(Outcome::Fragment {
296 region: FORM.to_owned(),
297 node: form(asking, typed, Some(&why)),
298 })),
299 }
300 }
301
302 /// The modal, whatever it is asking.
303 fn over(asking: &Asking, value: &str, error: Option<&str>) -> Response {
304 let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page(asking.title));
305 if let Some(lead_in) = asking.lead_in {
306 body = body.with(Node::text(lead_in));
307 }
308 let body = body
309 .with(form(asking, value, error))
310 .with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc")));
311
312 Response::from(Outcome::Over(
313 Screen::sidebar_content(asking.title).with(body),
314 ))
315 }
316
317 /// The one question, in a region of its own so a refusal can replace it.
318 fn form(asking: &Asking, value: &str, error: Option<&str>) -> Node {
319 let mut field = Field::new(FieldKind::Text, NAME, asking.label).value(value);
320 if let Some(hint) = asking.hint {
321 field = field.hint(hint);
322 }
323 if let Some(error) = error {
324 field = field.error(error);
325 }
326
327 Node::Region(Slot::new(FORM, RegionKind::Group).with(Node::Form {
328 fields: vec![field],
329 submit: asking.submit.to_owned(),
330 action: Action::post(asking.action.clone()),
331 }))
332 }
333
334 /// The screen a finished or cancelled modal leaves behind.
335 fn leaving() -> Outcome {
336 Outcome::Goto(Action::get(BACK))
337 }
338