Skip to main content

max / audiofiles

Describe the overlays the app raises, and the four name modals The twelfth port. Three new capabilities, one of which breaks a rule on purpose: Naming's writes answer, because a store's refusal has to reach the field it was typed into and an intent applied after the answer was built cannot carry it. The write is &self on the backend, the refresh is &mut on the app, so the write stayed in the route and the refresh is the intent. Converted: the four name modals (new/rename vault, new/rename folder), the Quick-Import preflight, and the loose-files warning. The sidebar's New vault was an Intent that opened the shipped modal and is now a navigation to a described one, so Intent::NewVault and Library::new_vault are gone. A refused name answers Fragment over the form's own region rather than a second Over, which would be two modals. Filed: quasi:vocabulary:unprompted-overlay. Nothing in the description can raise an overlay -- Outcome::Over is what a route answers, so an overlay exists because something was pressed. Two consumers in this one pass: the preflight, raised by dropping a folder, and the loose-files warning, raised by a vault load. The band says what is missing and the modal is one act away. Fourth consumer for quasi:vocabulary:host-save-location: Locate opens a native folder picker, which is a host act with no described step. Not ported: draw_confirm_dialog, which is Act::confirm and Act::tone. New Folder and Rename Folder are addresses with no described door yet -- the only door either has is a right-click menu, and whether a context menu is describable is its own measurement. 503 tests pass with the feature on, clippy clean, fmt clean. The lock takes makeover-layout and makeover-immediate 0.28.0 from crates.io now that the suite is published and the temporary patch block is out.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 19:47 UTC
Signed with PGP, not checked
Commit: e47053eb6a71e817cdd43e1260af1ac140c28d18
Parent: a544d47
9 files changed, +1332 insertions, -68 deletions
M Cargo.lock +16 -24
@@ -3073,6 +3073,8 @@
3073 3073 [[package]]
3074 3074 name = "makeover-immediate"
3075 3075 version = "0.28.0"
3076 + source = "registry+https://github.com/rust-lang/crates.io-index"
3077 + checksum = "53eb783cd5fda606d921e021af39d4c4604e6a7bd3fe9747c3739fe5ff7224b1"
3076 3078 dependencies = [
3077 3079 "egui",
3078 3080 "egui_extras",
@@ -3082,6 +3084,8 @@
3082 3084 [[package]]
3083 3085 name = "makeover-layout"
3084 3086 version = "0.28.0"
3087 + source = "registry+https://github.com/rust-lang/crates.io-index"
3088 + checksum = "4621aad492adac3b258a3a2822b5e4661b0ed3884ca565c29c933f32f5acc050"
3085 3089
3086 3090 [[package]]
3087 3091 name = "maplit"
@@ -7539,6 +7543,18 @@
7539 7543 "winnow 1.0.4",
7540 7544 ]
7541 7545
7546 + [[patch.unused]]
7547 + name = "kberg"
7548 + version = "0.1.0"
7549 +
7550 + [[patch.unused]]
7551 + name = "ops-status"
7552 + version = "0.1.0"
7553 +
7554 + [[patch.unused]]
7555 + name = "painhours"
7556 + version = "0.1.0"
7557 +
7542 7558 [[patch.unused]]
7543 7559 name = "quasi-axum"
7544 7560 version = "0.19.0"
@@ -7566,27 +7582,3 @@
7566 7582 [[patch.unused]]
7567 7583 name = "quasi-type"
7568 7584 version = "0.1.0"
7569 -
7570 - [[patch.unused]]
7571 - name = "makeover-touch"
7572 - version = "0.20.0"
7573 -
7574 - [[patch.unused]]
7575 - name = "makeover-tui"
7576 - version = "0.27.0"
7577 -
7578 - [[patch.unused]]
7579 - name = "makeover-webview"
7580 - version = "0.47.0"
7581 -
7582 - [[patch.unused]]
7583 - name = "kberg"
7584 - version = "0.1.0"
7585 -
7586 - [[patch.unused]]
7587 - name = "ops-status"
7588 - version = "0.1.0"
7589 -
7590 - [[patch.unused]]
7591 - name = "painhours"
7592 - version = "0.1.0"
@@ -91,7 +91,6 @@
91 91 /// answer is the window rather than the corner of it that was pressed.
92 92 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
93 93 router
94 - .post("/vaults/new", new_vault)
95 94 .post("/vaults/{id}/open", open_vault)
96 95 .post("/vaults/{id}/delete", delete_vault)
97 96 .post("/tags/{path}/filter", toggle_tag)
@@ -101,12 +100,6 @@
101 100 .post("/collections/{id}/delete", delete_collection)
102 101 }
103 102
104 - /// `POST /vaults/new`
105 - fn new_vault(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
106 - state.library.new_vault();
107 - Ok(super::shell::screen(state).into())
108 - }
109 -
110 103 /// `POST /vaults/{id}/open`
111 104 fn open_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
112 105 let id = numbered(&request, "no such vault")?;
@@ -194,10 +187,12 @@
194 187 let all = state.library.vaults();
195 188 let alone = all.len() < 2;
196 189
197 - let mut side = side.with(Node::section("Vaults")).with(Node::Act(Act::new(
198 - "New vault",
199 - Action::post("/vaults/new"),
200 - )));
190 + // The door to the described New Vault modal (see `naming`). It was an
191 + // `Intent::NewVault` that opened the *shipped* modal until 2026-08-17, which
192 + // was the one control on this screen whose answer was drawn by hand.
193 + let mut side = side
194 + .with(Node::section("Vaults"))
195 + .with(Node::Act(Act::new("New vault", Action::get("/vaults/new"))));
201 196
202 197 let mut rows = Vec::with_capacity(all.len());
203 198 for vault in &all {
@@ -224,6 +219,10 @@
224 219 // An inline Delete on every vault row would be a different screen.
225 220 let mut row = Row::new(&vault.name)
226 221 .activate(Action::post(format!("/vaults/{}/open", vault.id)))
222 + .offers(Act::new(
223 + "Rename",
224 + Action::get(format!("/vaults/{}/rename", vault.id)),
225 + ))
227 226 .offers(delete);
228 227 row.current = vault.current;
229 228 rows.push(row);
@@ -78,7 +78,10 @@
78 78 pub mod export;
79 79 pub mod files;
80 80 pub mod help;
81 + pub mod importing;
82 + pub mod integrity;
81 83 pub mod library;
84 + pub mod naming;
82 85 pub mod panel;
83 86 pub mod settings;
84 87 pub mod shell;
@@ -561,8 +564,6 @@
561 564 GoTo(i64, usize),
562 565 /// Leave whatever mode the list is in.
563 566 Leave,
564 - /// Make a new vault.
565 - NewVault,
566 567 /// Switch to this vault.
567 568 OpenVault(i64),
568 569 /// Delete this vault and everything in it.
@@ -587,6 +588,20 @@
587 588 BulkMove(Option<i64>),
588 589 /// Rename everything chosen by this pattern.
589 590 BulkRename(String),
591 + /// Re-read the vault list, and say this about why.
592 + VaultsChanged(String),
593 + /// Re-read the current folder, and say this about why.
594 + ContentsChanged(String),
595 + /// Go ahead with the import that is waiting, and remember the answer.
596 + AcceptImport { again: bool },
597 + /// Drop the import that is waiting.
598 + CancelImport,
599 + /// Put the loose-files warning away without acting.
600 + DismissLooseFiles,
601 + /// Delete the registry entries whose files are gone.
602 + PurgeLooseFiles,
603 + /// Ask the host for a folder to look for the missing files in.
604 + LocateLooseFiles,
590 605 }
591 606
592 607 /// The app's file list, as the narrow thing a described screen borrows.
@@ -1895,9 +1910,6 @@
1895 1910 /// Every tag the vault knows, and whether it is filtering.
1896 1911 fn tags(&self) -> Vec<Filter>;
1897 1912
1898 - /// Make a new vault.
1899 - fn new_vault(&self);
1900 -
1901 1913 /// Browse this vault.
1902 1914 fn open_vault(&self, id: i64);
1903 1915
@@ -1974,10 +1986,6 @@
1974 1986 .collect()
1975 1987 }
1976 1988
1977 - fn new_vault(&self) {
1978 - self.push(Intent::NewVault);
1979 - }
1980 -
1981 1989 fn open_vault(&self, id: i64) {
1982 1990 self.push(Intent::OpenVault(id));
1983 1991 }
@@ -2328,6 +2336,284 @@
2328 2336 }
2329 2337 }
2330 2338
2339 + /// A folder or vault being named, as the four name modals need it.
2340 + ///
2341 + /// The ninth, tenth and eleventh narrow traits are below, and this one is the
2342 + /// odd member of the set: **its writes answer**. Every other capability records
2343 + /// an [`Intent`] and hears nothing back, because what it asks for is
2344 + /// `&mut BrowserState` and cannot happen inside a frame. Naming a vault is both
2345 + /// at once — `Backend::create_vfs` is `&self` and returns whether it worked,
2346 + /// `refresh_vfs_list` is `&mut` — so the write happens here and the refresh is
2347 + /// the intent.
2348 + ///
2349 + /// That split is what makes the described modal able to keep its own error. A
2350 + /// name that the store refuses has to come back to the field it was typed into,
2351 + /// and an intent applied after the answer was built could not carry it. See
2352 + /// [`naming`]'s header for the rule this sharpens: [`Detail`] settled that *what
2353 + /// the app does about a write decides where the write goes*, and here what the
2354 + /// app does about it is two things with different lifetimes.
2355 + pub trait Naming {
2356 + /// What this vault is called, if it is one.
2357 + fn vault(&self, id: i64) -> Option<String>;
2358 +
2359 + /// What this folder is called, if it is one here.
2360 + fn folder(&self, id: i64) -> Option<String>;
2361 +
2362 + /// Make a vault by this name.
2363 + ///
2364 + /// # Errors
2365 + /// Whatever the store said, as text, for the field to carry.
2366 + fn create_vault(&self, name: &str) -> Result<String, String>;
2367 +
2368 + /// Rename this vault.
2369 + ///
2370 + /// # Errors
2371 + /// Whatever the store said, as text.
2372 + fn rename_vault(&self, id: i64, name: &str) -> Result<String, String>;
2373 +
2374 + /// Make a folder by this name, where the app is looking.
2375 + ///
2376 + /// # Errors
2377 + /// Whatever the store said, as text.
2378 + fn create_folder(&self, name: &str) -> Result<String, String>;
2379 +
2380 + /// Rename this folder.
2381 + ///
2382 + /// # Errors
2383 + /// Whatever the store said, as text.
2384 + fn rename_folder(&self, id: i64, name: &str) -> Result<String, String>;
2385 + }
2386 +
2387 + /// The app's vaults and folders, as the narrow thing the name modals borrow.
2388 + pub struct FromNaming<'a> {
2389 + /// What the app has loaded.
2390 + pub state: &'a crate::state::BrowserState,
2391 + /// The refresh the write needs afterwards, applied after the frame.
2392 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2393 + }
2394 +
2395 + impl FromNaming<'_> {
2396 + /// The vault this id names, as the app's own id type.
2397 + ///
2398 + /// Looked up in the loaded list rather than built with `VfsId::from`, which
2399 + /// is [`library`]'s rule: an address is reachable by typing, so an id that
2400 + /// names nothing is a refusal rather than a call against the store.
2401 + fn vault_id(&self, id: i64) -> Option<audiofiles_core::id_types::VfsId> {
2402 + self.state
2403 + .nav
2404 + .vfs_list
2405 + .iter()
2406 + .find(|vfs| vfs.id.as_i64() == id)
2407 + .map(|vfs| vfs.id)
2408 + }
2409 +
2410 + /// The folder this id names, where it is a folder in the current listing.
2411 + fn folder_id(&self, id: i64) -> Option<audiofiles_core::id_types::NodeId> {
2412 + self.state
2413 + .nav
2414 + .contents
2415 + .iter()
2416 + .map(|node| &node.node)
2417 + .find(|node| node.id.as_i64() == id && node.sample_hash.is_none())
2418 + .map(|node| node.id)
2419 + }
2420 +
2421 + /// Record the refresh the write owes, and hand back what to say.
2422 + fn changed(&self, intent: Intent, say: String) -> Result<String, String> {
2423 + self.intents.borrow_mut().push(intent);
2424 + Ok(say)
2425 + }
2426 + }
2427 +
2428 + impl Naming for FromNaming<'_> {
2429 + fn vault(&self, id: i64) -> Option<String> {
2430 + self.state
2431 + .nav
2432 + .vfs_list
2433 + .iter()
2434 + .find(|vfs| vfs.id.as_i64() == id)
2435 + .map(|vfs| vfs.name.clone())
2436 + }
2437 +
2438 + fn folder(&self, id: i64) -> Option<String> {
2439 + self.state
2440 + .nav
2441 + .contents
2442 + .iter()
2443 + .map(|node| &node.node)
2444 + .find(|node| node.id.as_i64() == id && node.sample_hash.is_none())
2445 + .map(|node| node.name.clone())
2446 + }
2447 +
2448 + fn create_vault(&self, name: &str) -> Result<String, String> {
2449 + self.state
2450 + .backend
2451 + .create_vfs(name)
2452 + .map_err(|error| error.to_string())?;
2453 + let say = format!("Created vault: {name}");
2454 + self.changed(Intent::VaultsChanged(say.clone()), say)
2455 + }
2456 +
2457 + fn rename_vault(&self, id: i64, name: &str) -> Result<String, String> {
2458 + let vault = self
2459 + .vault_id(id)
2460 + .ok_or_else(|| "No such vault".to_owned())?;
2461 + self.state
2462 + .backend
2463 + .rename_vfs(vault, name)
2464 + .map_err(|error| error.to_string())?;
2465 + let say = format!("Renamed vault to: {name}");
2466 + self.changed(Intent::VaultsChanged(say.clone()), say)
2467 + }
2468 +
2469 + fn create_folder(&self, name: &str) -> Result<String, String> {
2470 + // The shipped modal's own guard, kept as a guard: New Folder is only
2471 + // reachable inside a vault, so this is the defensive path rather than a
2472 + // real one.
2473 + let vault = self
2474 + .state
2475 + .current_vfs_id()
2476 + .ok_or_else(|| "No vault selected".to_owned())?;
2477 + self.state
2478 + .backend
2479 + .create_directory(vault, self.state.nav.current_dir, name)
2480 + .map_err(|error| error.to_string())?;
2481 + let say = format!("Created folder: {name}");
2482 + self.changed(Intent::ContentsChanged(say.clone()), say)
2483 + }
2484 +
2485 + fn rename_folder(&self, id: i64, name: &str) -> Result<String, String> {
2486 + let node = self
2487 + .folder_id(id)
2488 + .ok_or_else(|| "No such folder".to_owned())?;
2489 + self.state
2490 + .backend
2491 + .rename_node(node, name)
2492 + .map_err(|error| error.to_string())?;
2493 + let say = format!("Renamed to: {name}");
2494 + self.changed(Intent::ContentsChanged(say.clone()), say)
2495 + }
2496 + }
2497 +
2498 + /// An import waiting to be agreed to, as the description needs to name it.
2499 + ///
2500 + /// The source is a `String` rather than a `PathBuf` for the reason [`Status`] is
2501 + /// not `SyncStatus`: what a screen says about a path is the path, and a
2502 + /// described screen that took the app's own type would carry the app's platform
2503 + /// with it.
2504 + #[derive(Debug, Clone, PartialEq, Eq)]
2505 + pub struct Preflight {
2506 + /// Where the files are.
2507 + pub source: String,
2508 + /// How many there are.
2509 + pub files: usize,
2510 + /// How big they are, as the app formats a size.
2511 + pub size: String,
2512 + }
2513 +
2514 + /// The import waiting for an answer, as much as a described screen needs.
2515 + ///
2516 + /// The tenth narrow trait, and the smallest: one read and two writes. It is
2517 + /// deliberately only the *preflight* half of importing rather than the whole
2518 + /// flow — the configure, tagging, progress and summary screens are their own
2519 + /// pass, and this trait is what that pass grows.
2520 + pub trait Importing {
2521 + /// The import waiting to be agreed to, if one is.
2522 + fn waiting(&self) -> Option<Preflight>;
2523 +
2524 + /// Go ahead with it, and remember the answer if `again` is false.
2525 + fn accept(&self, again: bool);
2526 +
2527 + /// Do not.
2528 + fn cancel(&self);
2529 + }
2530 +
2531 + /// The app's import workflow, as the narrow thing the preflight borrows.
2532 + pub struct FromImport<'a> {
2533 + /// What the app is holding.
2534 + pub state: &'a crate::state::BrowserState,
2535 + /// What the described screen asked for, applied after the frame.
2536 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2537 + }
2538 +
2539 + impl Importing for FromImport<'_> {
2540 + fn waiting(&self) -> Option<Preflight> {
2541 + let waiting = self.state.import_wf.pending_import_preflight.as_ref()?;
2542 + Some(Preflight {
2543 + source: waiting.source.display().to_string(),
2544 + files: waiting.file_count,
2545 + // Formatted by the app's own function rather than by the
2546 + // description, which is the rule the footer's status tone follows:
2547 + // how this app writes a size is the app's, and a second copy of it
2548 + // here would drift.
2549 + size: crate::ui::widgets::format_bytes(waiting.total_bytes),
2550 + })
2551 + }
2552 +
2553 + fn accept(&self, again: bool) {
2554 + self.intents
2555 + .borrow_mut()
2556 + .push(Intent::AcceptImport { again });
2557 + }
2558 +
2559 + fn cancel(&self) {
2560 + self.intents.borrow_mut().push(Intent::CancelImport);
2561 + }
2562 + }
2563 +
2564 + /// The vault's own health, as much as the warning needs.
2565 + ///
2566 + /// The eleventh narrow trait. Every write is an intent, and the third of them is
2567 + /// the interesting one: locating the missing files opens a native folder picker,
2568 + /// which is a host act with no described step. See [`integrity`]'s header — it is
2569 + /// the **fourth consumer** of `quasi:vocabulary:host-save-location`.
2570 + pub trait Integrity {
2571 + /// How many samples have lost the file they point at.
2572 + fn missing(&self) -> usize;
2573 +
2574 + /// Put the warning away without acting.
2575 + fn dismiss(&self);
2576 +
2577 + /// Go looking for the files.
2578 + fn locate(&self);
2579 +
2580 + /// Delete the entries whose files are gone.
2581 + fn purge(&self);
2582 + }
2583 +
2584 + /// The app's loose-files state, as the narrow thing the warning borrows.
2585 + pub struct FromIntegrity<'a> {
2586 + /// What the app has checked.
2587 + pub state: &'a crate::state::BrowserState,
2588 + /// What the described screen asked for, applied after the frame.
2589 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
2590 + }
2591 +
2592 + impl Integrity for FromIntegrity<'_> {
2593 + fn missing(&self) -> usize {
2594 + self.state.loose_files.loose_files_missing_count
2595 + }
2596 +
2597 + fn dismiss(&self) {
2598 + self.push(Intent::DismissLooseFiles);
2599 + }
2600 +
2601 + fn locate(&self) {
2602 + self.push(Intent::LocateLooseFiles);
2603 + }
2604 +
2605 + fn purge(&self) {
2606 + self.push(Intent::PurgeLooseFiles);
2607 + }
2608 + }
2609 +
2610 + impl FromIntegrity<'_> {
2611 + /// Record what the described screen asked for.
2612 + fn push(&self, intent: Intent) {
2613 + self.intents.borrow_mut().push(intent);
2614 + }
2615 + }
2616 +
2331 2617 /// A theme the host resolved, as the description needs to name it.
2332 2618 ///
2333 2619 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
@@ -2372,6 +2658,15 @@
2372 2658 /// the narrowing is the point: the detail screen may not move a file and
2373 2659 /// the bulk screens may not read an analysis.
2374 2660 pub bulk: &'a dyn Bulk,
2661 + /// Vaults and folders again, for the four name modals. A third capability
2662 + /// over ground [`Library`] already covers, and the narrowing is the same
2663 + /// argument: the sidebar may delete a vault and may not name one, and the
2664 + /// modal is the other way round.
2665 + pub naming: &'a dyn Naming,
2666 + /// The import waiting to be agreed to, for the preflight.
2667 + pub importing: &'a dyn Importing,
2668 + /// The vault's health, for the loose-files warning.
2669 + pub integrity: &'a dyn Integrity,
2375 2670 /// The themes on offer, resolved by the host at startup.
2376 2671 pub themes: &'a [ThemeChoice],
2377 2672 }
@@ -2382,11 +2677,11 @@
2382 2677 /// cost is nothing, and building it fresh is what lets the state borrow.
2383 2678 #[must_use]
2384 2679 pub fn router<'a>() -> Router<Panels<'a>> {
2385 - toolbar::routes(library::routes(shell::routes(help::routes(bulk::routes(
2386 - detail::routes(export::routes(files::routes(sync::routes(
2387 - settings::routes(Router::new()),
2388 - )))),
2389 - )))))
2680 + integrity::routes(importing::routes(naming::routes(toolbar::routes(
2681 + library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
2682 + export::routes(files::routes(sync::routes(settings::routes(Router::new())))),
2683 + ))))),
2684 + ))))
2390 2685 }
2391 2686
2392 2687 #[cfg(test)]
@@ -33,8 +33,9 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromBar, FromBulk, FromContents, FromExport, FromLibrary, FromSelection,
37 - FromSyncManager, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
36 + FromBackend, FromBar, FromBulk, FromContents, FromExport, FromImport, FromIntegrity,
37 + FromLibrary, FromNaming, FromSelection, FromSyncManager, FromWindow, Intent, Panels, Setting,
38 + Sync, ThemeChoice, Unconfigured,
38 39 };
39 40 use crate::state::BrowserState;
40 41 use crate::ui::theme;
@@ -440,10 +441,6 @@
440 441 // yes, and `execute_confirmed_action` is what knows how to do it.
441 442 // `ConfirmAction` becomes an argument carrier rather than a
442 443 // question, which is `library`'s header made concrete.
443 - Intent::NewVault => {
444 - state.vfs_modal.show_vfs_create = true;
445 - state.vfs_modal.vfs_create_input.clear();
446 - }
447 444 Intent::OpenVault(id) => {
448 445 if let Some(at) = state
449 446 .nav
@@ -552,6 +549,45 @@
552 549 state.execute_bulk_rename();
553 550 state.close_bulk_modal();
554 551 }
552 + // The name modals. The write already happened in the route, which
553 + // is the one place this port does that and `naming`'s header is
554 + // why: the store's refusal has to reach the field it was typed
555 + // into. What is left is the half a route cannot do.
556 + Intent::VaultsChanged(say) => {
557 + state.refresh_vfs_list();
558 + state.status = say;
559 + }
560 + Intent::ContentsChanged(say) => {
561 + state.refresh_contents();
562 + state.status = say;
563 + }
564 + Intent::AcceptImport { again } => {
565 + if !again {
566 + if let Err(error) = state
567 + .backend
568 + .set_config(crate::backend::ConfigKey::ImportPreflightDisabled, "1")
569 + {
570 + tracing::warn!("Failed to persist preflight dismissal: {error}");
571 + }
572 + // The in-memory mirror as well, so the next bypass check
573 + // sees the answer without a reload. The shipped modal writes
574 + // both for the same reason.
575 + state.import_wf.import_preflight_disabled = true;
576 + }
577 + state.accept_import_preflight();
578 + }
579 + Intent::CancelImport => state.cancel_import_preflight(),
580 + Intent::DismissLooseFiles => state.dismiss_loose_files_warning(),
581 + Intent::PurgeLooseFiles => state.purge_missing_loose_files(),
582 + // The host act with no described step. See `integrity`'s header:
583 + // fourth consumer of `quasi:vocabulary:host-save-location`.
584 + Intent::LocateLooseFiles => {
585 + state
586 + .dialogs
587 + .pick_folder("Locate missing sample files", |state, folder| {
588 + state.locate_missing_loose_files(&folder);
589 + });
590 + }
555 591 }
556 592 }
557 593 }
@@ -880,6 +916,9 @@
880 916 let shell = FromWindow { state, intents };
881 917 let library = FromLibrary { state, intents };
882 918 let bar = FromBar { state, intents };
919 + let naming = FromNaming { state, intents };
920 + let importing = FromImport { state, intents };
921 + let integrity = FromIntegrity { state, intents };
883 922 let panels = Panels {
884 923 config: &config,
885 924 sync,
@@ -890,6 +929,9 @@
890 929 shell: &shell,
891 930 library: &library,
892 931 bar: &bar,
932 + naming: &naming,
933 + importing: &importing,
934 + integrity: &integrity,
893 935 themes,
894 936 };
895 937 super::router()
@@ -146,6 +146,7 @@
146 146 }
147 147
148 148 band = coverage(band, state);
149 + band = missing(band, state);
149 150 band = saying(band, state);
150 151
151 152 if let Some(device) = state.shell.device() {
@@ -223,6 +224,35 @@
223 224 band
224 225 }
225 226
227 + /// The samples that have lost their files, and the way in to what can be done.
228 + ///
229 + /// **The band says it because no description can raise the overlay that says
230 + /// it.** The shipped app puts the warning up over whatever the user was doing,
231 + /// as soon as a vault load finds anything missing, and nothing a route answers
232 + /// can do that: `Outcome::Over` exists because something was pressed. See
233 + /// [`integrity`](super::integrity)'s header and the finding it shares with the
234 + /// import preflight. So the fact is stated where the app states its other facts
235 + /// and the modal is one act away.
236 + fn missing(band: Slot, state: &Panels<'_>) -> Slot {
237 + let missing = state.integrity.missing();
238 + if missing == 0 {
239 + return band;
240 + }
241 + band.with(Node::Notice {
242 + kind: Notice::Banner,
243 + tone: Tone::Warning,
244 + text: format!(
245 + "{missing} sample{} cannot find {} file.",
246 + if missing == 1 { "" } else { "s" },
247 + if missing == 1 { "its" } else { "their" },
248 + ),
249 + })
250 + .with(Node::Act(Act::new(
251 + "What is missing",
252 + Action::get("/library/loose-files"),
253 + )))
254 + }
255 +
226 256 /// Whatever the app is telling the user.
227 257 ///
228 258 /// Tone rather than a timer. The shipped footer decides the colour from
@@ -13,10 +13,10 @@
13 13
14 14 use super::{
15 15 Analysed, Analysis, Bar, Bulk, Channels, Chosen, Collection, ColumnsShown, Config, Coverage,
16 - Crumb, Detail, Detailed, Export, Files, Filter, Focus, Folder, Format, Holding, Library, Panel,
17 - Panels, Phase, Playing, Pricing, ProfileChoice, Sample, Saying, Searching, Setting, Settings,
18 - Shared, Shell, Source, Spread, State, Status, Subject, Subscription, Suggested, Sync, Tagged,
19 - ThemeChoice, Vault, Where, router,
16 + Crumb, Detail, Detailed, Export, Files, Filter, Focus, Folder, Format, Holding, Importing,
17 + Integrity, Library, Naming, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice,
18 + Sample, Saying, Searching, Setting, Settings, Shared, Shell, Source, Spread, State, Status,
19 + Subject, Subscription, Suggested, Sync, Tagged, ThemeChoice, Vault, Where, router,
20 20 };
21 21
22 22 /// A config store in memory.
@@ -218,6 +218,9 @@
218 218 sync: &sync,
219 219 files: &files,
220 220 export,
221 + naming: &Unnamed,
222 + importing: &NoImport,
223 + integrity: &Sound,
221 224 themes: &themes,
222 225 };
223 226 router().handle(&state, request)
@@ -284,6 +287,9 @@
284 287 sync: &sync,
285 288 files,
286 289 export: &Idle,
290 + naming: &Unnamed,
291 + importing: &NoImport,
292 + integrity: &Sound,
287 293 themes: &themes,
288 294 };
289 295 router().handle(&state, request)
@@ -382,9 +388,14 @@
382 388 }
383 389
384 390 /// The screen out of a response, or a failure naming what came instead.
391 + ///
392 + /// Either kind of screen, because an overlay is a screen drawn over something
393 + /// rather than a different thing: what a test asks of `/vaults/1/rename` is
394 + /// what it says, and whether it is over the main window is
395 + /// [`overlaid`](overlaid)'s question.
385 396 fn screen_of(response: &Response) -> &Screen {
386 397 match &response.outcome {
387 - Outcome::Screen(screen) => screen,
398 + Outcome::Screen(screen) | Outcome::Over(screen) => screen,
388 399 other => panic!("expected a screen, got {other:?}"),
389 400 }
390 401 }
@@ -429,6 +440,9 @@
429 440 sync: &sync,
430 441 files: &files,
431 442 export: &Idle,
443 + naming: &Unnamed,
444 + importing: &NoImport,
445 + integrity: &Sound,
432 446 themes: &themes,
433 447 };
434 448 let response = router()
@@ -479,6 +493,9 @@
479 493 sync: &sync,
480 494 files: &files,
481 495 export: &Idle,
496 + naming: &Unnamed,
497 + importing: &NoImport,
498 + integrity: &Sound,
482 499 themes: &themes,
483 500 };
484 501
@@ -523,6 +540,9 @@
523 540 sync: &sync,
524 541 files: &files,
525 542 export: &Idle,
543 + naming: &Unnamed,
544 + importing: &NoImport,
545 + integrity: &Sound,
526 546 themes: &themes,
527 547 };
528 548 let refused = router().handle(
@@ -553,6 +573,9 @@
553 573 sync: &sync,
554 574 files: &files,
555 575 export: &Idle,
576 + naming: &Unnamed,
577 + importing: &NoImport,
578 + integrity: &Sound,
556 579 themes: &themes,
557 580 };
558 581
@@ -606,6 +629,9 @@
606 629 sync: &sync,
607 630 files: &files,
608 631 export: &Idle,
632 + naming: &Unnamed,
633 + importing: &NoImport,
634 + integrity: &Sound,
609 635 themes: &themes,
610 636 };
611 637 let response = router()
@@ -655,6 +681,9 @@
655 681 sync: &sync,
656 682 files: &files,
657 683 export: &Idle,
684 + naming: &Unnamed,
685 + importing: &NoImport,
686 + integrity: &Sound,
658 687 themes: &themes,
659 688 };
660 689 let response = router()
@@ -810,6 +839,9 @@
810 839 sync,
811 840 files: &files,
812 841 export: &Idle,
842 + naming: &Unnamed,
843 + importing: &NoImport,
844 + integrity: &Sound,
813 845 themes: &themes,
814 846 };
815 847 router().handle(&state, request)
@@ -1826,6 +1858,9 @@
1826 1858 shell: &Quiet,
1827 1859 library: &Empty,
1828 1860 bar: &Still,
1861 + naming: &Unnamed,
1862 + importing: &NoImport,
1863 + integrity: &Sound,
1829 1864 themes: &themes,
1830 1865 };
1831 1866 router().handle(&state, request)
@@ -2356,6 +2391,9 @@
2356 2391 shell: &Quiet,
2357 2392 library: &Empty,
2358 2393 bar: &Still,
2394 + naming: &Unnamed,
2395 + importing: &NoImport,
2396 + integrity: &Sound,
2359 2397 themes: &themes,
2360 2398 };
2361 2399 router().handle(&state, request)
@@ -2726,6 +2764,9 @@
2726 2764 shell: &Quiet,
2727 2765 library: &Empty,
2728 2766 bar: &Still,
2767 + naming: &Unnamed,
2768 + importing: &NoImport,
2769 + integrity: &Sound,
2729 2770 themes: &themes,
2730 2771 };
2731 2772 router().handle(&state, request)
@@ -2776,6 +2817,9 @@
2776 2817 shell: &Quiet,
2777 2818 library: &Empty,
2778 2819 bar: &Still,
2820 + naming: &Unnamed,
2821 + importing: &NoImport,
2822 + integrity: &Sound,
2779 2823 themes: &themes,
2780 2824 };
2781 2825
@@ -2987,6 +3031,9 @@
2987 3031 shell,
2988 3032 library: &Empty,
2989 3033 bar: &Still,
3034 + naming: &Unnamed,
3035 + importing: &NoImport,
3036 + integrity: &Sound,
2990 3037 themes: &themes,
2991 3038 };
2992 3039 router().handle(&state, request)
@@ -3262,7 +3309,6 @@
3262 3309 Vec::new()
3263 3310 }
3264 3311
3265 - fn new_vault(&self) {}
3266 3312 fn open_vault(&self, _id: i64) {}
3267 3313 fn delete_vault(&self, _id: i64) {}
3268 3314 fn toggle_tag(&self, _path: &str) {}
@@ -3357,10 +3403,6 @@
3357 3403 self.tags.clone()
3358 3404 }
3359 3405
3360 - fn new_vault(&self) {
3361 - self.note("new vault");
3362 - }
3363 -
3364 3406 fn open_vault(&self, id: i64) {
3365 3407 self.note(format!("open vault {id}"));
3366 3408 }
@@ -3406,6 +3448,9 @@
3406 3448 shell: &Quiet,
3407 3449 library,
3408 3450 bar: &Still,
3451 + naming: &Unnamed,
3452 + importing: &NoImport,
3453 + integrity: &Sound,
3409 3454 themes: &themes,
3410 3455 };
3411 3456 router().handle(&state, request)
@@ -3637,17 +3682,36 @@
3637 3682 }
3638 3683
3639 3684 #[test]
3640 - fn making_a_vault_and_deleting_a_collection_are_asked_for() {
3685 + fn deleting_a_collection_and_a_tag_are_asked_for() {
3641 3686 let library = FakeLibrary::stocked();
3642 - browsing(&library, Request::post("/vaults/new")).unwrap();
3643 3687 browsing(&library, Request::post("/collections/11/delete")).unwrap();
3644 3688 browsing(&library, Request::post("/tags/drums.kick/remove")).unwrap();
3645 3689 assert_eq!(
3646 3690 library.asked(),
3647 - ["new vault", "delete collection 11", "remove drums.kick"]
3691 + ["delete collection 11", "remove drums.kick"]
3648 3692 );
3649 3693 }
3650 3694
3695 + #[test]
3696 + fn new_vault_opens_a_described_modal_rather_than_asking_the_app_for_one() {
3697 + // It was `Intent::NewVault`, which opened the *shipped* name modal: the one
3698 + // control on this screen whose answer was still drawn by hand. It is a
3699 + // navigation to `naming`'s address now, and the sidebar asks the app for
3700 + // nothing.
3701 + let library = FakeLibrary::stocked();
3702 + let opened = acts(&browsed(&library));
3703 + assert!(opened.contains(&"New vault".to_owned()), "{opened:?}");
3704 + // The address is `naming`'s now, both verbs of it, and the sidebar's own
3705 + // capability is never asked for a vault it cannot make.
3706 + assert!(matches!(
3707 + browsing(&library, Request::get("/vaults/new"))
3708 + .unwrap()
3709 + .outcome,
3710 + Outcome::Over(_)
3711 + ));
3712 + assert!(library.asked().is_empty());
3713 + }
3714 +
3651 3715 // The toolbar.
3652 3716
3653 3717 /// A toolbar at the root with nothing typed.
@@ -3817,6 +3881,9 @@
3817 3881 shell: &Quiet,
3818 3882 library: &Empty,
3819 3883 bar,
3884 + naming: &Unnamed,
3885 + importing: &NoImport,
3886 + integrity: &Sound,
3820 3887 themes: &themes,
3821 3888 };
3822 3889 router().handle(&state, request)
@@ -4173,3 +4240,554 @@
4173 4240 .collect();
4174 4241 assert_eq!(optional, ["drums", "loop"], "{worth:?}");
4175 4242 }
4243 +
4244 + // --- The name modals, the preflight and the loose-files warning --------------
4245 +
4246 + /// A tree with nothing to name, for every test that is not about naming.
4247 + struct Unnamed;
4248 +
4249 + impl Naming for Unnamed {
4250 + fn vault(&self, _id: i64) -> Option<String> {
4251 + None
4252 + }
4253 +
4254 + fn folder(&self, _id: i64) -> Option<String> {
4255 + None
4256 + }
4257 +
4258 + fn create_vault(&self, _name: &str) -> Result<String, String> {
4259 + Ok(String::new())
4260 + }
4261 +
4262 + fn rename_vault(&self, _id: i64, _name: &str) -> Result<String, String> {
4263 + Ok(String::new())
4264 + }
4265 +
4266 + fn create_folder(&self, _name: &str) -> Result<String, String> {
4267 + Ok(String::new())
4268 + }
4269 +
4270 + fn rename_folder(&self, _id: i64, _name: &str) -> Result<String, String> {
4271 + Ok(String::new())
4272 + }
4273 + }
4274 +
4275 + /// No import is waiting.
4276 + struct NoImport;
4277 +
4278 + impl Importing for NoImport {
4279 + fn waiting(&self) -> Option<Preflight> {
4280 + None
4281 + }
4282 +
4283 + fn accept(&self, _again: bool) {}
4284 + fn cancel(&self) {}
4285 + }
4286 +
4287 + /// Every file is where it should be.
4288 + struct Sound;
4289 +
4290 + impl Integrity for Sound {
4291 + fn missing(&self) -> usize {
4292 + 0
4293 + }
4294 +
4295 + fn dismiss(&self) {}
4296 + fn locate(&self) {}
4297 + fn purge(&self) {}
4298 + }
4299 +
4300 + /// A namer in memory, recording what was asked of it and refusing on demand.
4301 + #[derive(Default)]
4302 + struct FakeNaming {
4303 + vaults: Vec<(i64, String)>,
4304 + folders: Vec<(i64, String)>,
4305 + refusing: Option<String>,
4306 + asked: RefCell<Vec<String>>,
4307 + }
4308 +
4309 + impl FakeNaming {
4310 + fn stocked() -> Self {
4311 + Self {
4312 + vaults: vec![(1, "Drums".to_owned())],
4313 + folders: vec![(7, "kicks".to_owned())],
4314 + ..Self::default()
4315 + }
4316 + }
4317 +
4318 + fn refusing(why: &str) -> Self {
4319 + Self {
4320 + refusing: Some(why.to_owned()),
4321 + ..Self::stocked()
4322 + }
4323 + }
4324 +
4325 + fn asked(&self) -> Vec<String> {
4326 + self.asked.borrow().clone()
4327 + }
4328 +
4329 + fn did(&self, what: String) -> Result<String, String> {
4330 + self.asked.borrow_mut().push(what.clone());
4331 + match &self.refusing {
4332 + Some(why) => Err(why.clone()),
4333 + None => Ok(what),
4334 + }
4335 + }
4336 + }
4337 +
4338 + impl Naming for FakeNaming {
4339 + fn vault(&self, id: i64) -> Option<String> {
4340 + self.vaults
4341 + .iter()
4342 + .find(|(at, _)| *at == id)
4343 + .map(|(_, name)| name.clone())
4344 + }
4345 +
4346 + fn folder(&self, id: i64) -> Option<String> {
4347 + self.folders
4348 + .iter()
4349 + .find(|(at, _)| *at == id)
4350 + .map(|(_, name)| name.clone())
4351 + }
4352 +
4353 + fn create_vault(&self, name: &str) -> Result<String, String> {
4354 + self.did(format!("create vault {name}"))
4355 + }
4356 +
4357 + fn rename_vault(&self, id: i64, name: &str) -> Result<String, String> {
4358 + self.did(format!("rename vault {id} to {name}"))
4359 + }
4360 +
4361 + fn create_folder(&self, name: &str) -> Result<String, String> {
4362 + self.did(format!("create folder {name}"))
4363 + }
4364 +
4365 + fn rename_folder(&self, id: i64, name: &str) -> Result<String, String> {
4366 + self.did(format!("rename folder {id} to {name}"))
4367 + }
4368 + }
4369 +
4370 + /// A post carrying what a form submitted.
4371 + ///
4372 + /// The captures are the router's to fill from the path; what a test supplies is
4373 + /// the payload, which is the half a form sends.
4374 + fn posting(path: &str, payload: Params) -> Request {
4375 + Request {
4376 + method: Method::Post,
4377 + path: path.to_owned(),
4378 + captures: Params::new(),
4379 + payload,
4380 + carried: Params::new(),
4381 + }
4382 + }
4383 +
4384 + /// A router call against this namer.
4385 + fn naming(naming: &FakeNaming, request: Request) -> Result<Response, quasi_router::RouteError> {
4386 + let store = Store::default();
4387 + let sync = Offline;
4388 + let files = FakeFiles::default();
4389 + let themes = themes();
4390 + let state = Panels {
4391 + config: &store,
4392 + sync: &sync,
4393 + files: &files,
4394 + export: &Idle,
4395 + detail: &Unfocused,
4396 + bulk: &Unchosen,
4397 + shell: &Quiet,
4398 + library: &Empty,
4399 + bar: &Still,
4400 + naming,
4401 + importing: &NoImport,
4402 + integrity: &Sound,
4403 + themes: &themes,
4404 + };
4405 + router().handle(&state, request)
4406 + }
4407 +
4408 + /// The one field on whatever modal answered.
4409 + fn only_field(response: &Response) -> quasi_router::Field {
4410 + let screen = screen_of(response);
4411 + let mut found = Vec::new();
4412 + fn walk(body: &[quasi_router::Ranked], found: &mut Vec<quasi_router::Field>) {
4413 + for placed in body {
4414 + match &placed.node {
4415 + Node::Field(field) => found.push((**field).clone()),
4416 + Node::Form { fields, .. } => found.extend(fields.iter().cloned()),
4417 + Node::Region(slot) => walk(&slot.body, found),
4418 + _ => {}
4419 + }
4420 + }
4421 + }
4422 + for slot in &screen.slots {
4423 + walk(&slot.body, &mut found);
4424 + }
4425 + assert_eq!(found.len(), 1, "{found:?}");
4426 + found.remove(0)
4427 + }
4428 +
4429 + #[test]
4430 + fn a_rename_modal_opens_holding_the_name_it_is_about_to_change() {
4431 + let namer = FakeNaming::stocked();
4432 +
4433 + let vault = naming(&namer, Request::get("/vaults/1/rename")).unwrap();
4434 + assert!(matches!(vault.outcome, Outcome::Over(_)), "{vault:?}");
4435 + assert_eq!(only_field(&vault).value.as_deref(), Some("Drums"));
4436 +
4437 + let folder = naming(&namer, Request::get("/folders/7/rename")).unwrap();
4438 + assert_eq!(only_field(&folder).value.as_deref(), Some("kicks"));
4439 +
4440 + // Nothing was renamed by looking at it.
4441 + assert!(namer.asked().is_empty());
4442 + }
4443 +
4444 + #[test]
4445 + fn a_modal_is_drawn_over_what_it_was_opened_from() {
4446 + // All four, because `Outcome::Over` is what makes them modals rather than
4447 + // places, and a screen answered here would clear the layer underneath.
4448 + let namer = FakeNaming::stocked();
4449 + for address in [
4450 + "/vaults/new",
4451 + "/vaults/1/rename",
4452 + "/folders/new",
4453 + "/folders/7/rename",
4454 + ] {
4455 + let response = naming(&namer, Request::get(address)).unwrap();
4456 + assert!(
4457 + matches!(response.outcome, Outcome::Over(_)),
4458 + "{address}: {response:?}"
4459 + );
4460 + }
4461 + }
4462 +
4463 + #[test]
4464 + fn naming_something_that_is_not_there_is_a_refusal_rather_than_an_empty_modal() {
4465 + let namer = FakeNaming::stocked();
4466 + assert!(naming(&namer, Request::get("/vaults/99/rename")).is_err());
4467 + assert!(naming(&namer, Request::get("/folders/99/rename")).is_err());
4468 + // An address is reachable by typing, so the id is checked rather than
4469 + // trusted.
4470 + assert!(naming(&namer, Request::get("/vaults/not-a-number/rename")).is_err());
4471 + }
4472 +
4473 + #[test]
4474 + fn a_name_the_store_refuses_comes_back_on_the_field_it_was_typed_into() {
4475 + // The whole reason this port's writes happen in the route: an intent
4476 + // applied after the answer was built could not carry the refusal, so the
4477 + // modal would close and the typed name would be gone. C-3, kept.
4478 + let namer = FakeNaming::refusing("A vault called that already exists");
4479 + let response = naming(
4480 + &namer,
4481 + posting("/vaults/new", Params::new().with("name", "Drums")),
4482 + )
4483 + .unwrap();
4484 +
4485 + let Outcome::Fragment { region, node } = &response.outcome else {
4486 + panic!("{response:?}");
4487 + };
4488 + // A fragment rather than a second `Over`, which would be two modals. See
4489 + // `bulk`'s finding 2.
4490 + assert_eq!(region, "naming-form");
4491 +
4492 + let Node::Region(slot) = node else {
4493 + panic!("{node:?}");
4494 + };
4495 + let Some(Node::Form { fields, .. }) = slot.body.first().map(|placed| &placed.node) else {
4496 + panic!("{slot:?}");
4497 + };
4498 + assert_eq!(
4499 + fields[0].error.as_deref(),
4500 + Some("A vault called that already exists")
4501 + );
4502 + // And it still holds what was typed.
4503 + assert_eq!(fields[0].value.as_deref(), Some("Drums"));
4504 + }
4505 +
4506 + #[test]
4507 + fn an_empty_submit_closes_the_modal_and_names_nothing() {
4508 + // The shipped rule, and the reason none of these fields is `required`: the
4509 + // marker would claim a refusal that never happens.
4510 + let namer = FakeNaming::stocked();
Lines truncated
@@ -1,0 +1,134 @@
1 + //! The Quick-Import preflight, described: what is about to be indexed, and
2 + //! whether to ask again.
3 + //!
4 + //! Small, and it carries the finding this pass exists to have found.
5 + //!
6 + //! # THE FINDING: nothing in the description raises an overlay
7 + //!
8 + //! Every overlay described so far is one the user asked for. The bulk modals are
9 + //! three addresses because *the user picked which one*; the help overlay is a
10 + //! key press. This one is not asked for at all: `quick_import_folder` walks the
11 + //! dropped folder, finds it is over a threshold, and the modal appears. The user
12 + //! dropped a folder and the app had a question.
13 + //!
14 + //! A description cannot say that. `Outcome::Over` is what a *route* answers, so
15 + //! an overlay exists because something was pressed; there is no way for a screen
16 + //! to say "and by the way, this is waiting". The two shapes an app has for
17 + //! interrupting someone — a modal it raises, and a modal you opened — are one
18 + //! shape in the vocabulary, and it is the second one.
19 + //!
20 + //! Filed as `quasi:vocabulary:unprompted-overlay`, with **two consumers in this
21 + //! one pass**: this, and the loose-files warning in [`integrity`](super::integrity),
22 + //! which is raised by a vault load rather than by anything the user did.
23 + //!
24 + //! What the port does instead is make the waiting import an *address* and let
25 + //! the screen that knows about it offer the way in. The door belongs to the
26 + //! import act, which is the import flow's pass (audiofiles `f573573b`); until
27 + //! then this answers by address. The alternative — having `GET /` answer
28 + //! `Over` when something is pending — was tried and is wrong twice over: the
29 + //! host refuses anything but a `Screen` at a window's home address, and a
30 + //! screen that raises an overlay every time it is asked can never be asked
31 + //! again, since `Runtime::reload` is gated on not being overlaid.
32 + //!
33 + //! # What the description keeps that the shipped modal spends state on
34 + //!
35 + //! `preflight_dont_ask` is a `bool` on `BrowserState`, set by a checkbox,
36 + //! committed on confirm and reset on both exits — four writes to app state for a
37 + //! question that lives as long as one modal. Here it is a field in the form,
38 + //! submitted with the answer, and there is nothing to reset: what is being typed
39 + //! into a described screen is the runtime's, which is the same deletion
40 + //! [`bulk`](super::bulk) recorded for `BulkModal`'s eleven fields.
41 + //!
42 + //! # What is not described
43 + //!
44 + //! The threshold. `QUICK_IMPORT_PREFLIGHT_FILE_THRESHOLD` and its byte twin
45 + //! decide whether there is anything to confirm, and that is the app deciding
46 + //! when to ask rather than a fact about the question.
47 +
48 + use quasi_router::layout::{FieldKind, Tone};
49 + use quasi_router::{
50 + Act, Action, Field, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen,
51 + Slot,
52 + };
53 +
54 + use super::Panels;
55 +
56 + /// The region the preflight answers into.
57 + const BODY: &str = "import-preflight";
58 +
59 + /// The name the "don't ask again" answer is submitted under.
60 + const AGAIN: &str = "again";
61 +
62 + /// Where an answered preflight goes.
63 + const BACK: &str = "/";
64 +
65 + /// Register the preflight's routes.
66 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
67 + router
68 + .get("/import/preflight", screen)
69 + .post("/import/preflight", accept)
70 + .post("/import/preflight/cancel", cancel)
71 + }
72 +
73 + /// `GET /import/preflight`
74 + ///
75 + /// A refusal when nothing is waiting, rather than an empty modal. The address is
76 + /// reachable by typing and there is no honest screen for "no import is pending".
77 + fn screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
78 + let waiting = state
79 + .importing
80 + .waiting()
81 + .ok_or_else(|| RouteError::not_found("no import is waiting"))?;
82 +
83 + let body = Slot::new(BODY, RegionKind::Pane)
84 + .with(Node::page("Import folder"))
85 + .with(Node::text(format!(
86 + "About to import {} audio file{} (~{}) from {}",
87 + waiting.files,
88 + if waiting.files == 1 { "" } else { "s" },
89 + waiting.size,
90 + waiting.source,
91 + )))
92 + // The reassurance the shipped modal draws as a small muted line. It is a
93 + // fact about the whole operation rather than about either answer, so it
94 + // is prose and not a field's hint.
95 + .with(Node::text(
96 + "Files stay where they are: audiofiles only indexes them.",
97 + ))
98 + .with(Node::Form {
99 + // The checkbox the shipped modal keeps on `BrowserState`. See the
100 + // module header: here it is submitted with the answer it qualifies.
101 + fields: vec![Field::new(
102 + FieldKind::Checkbox,
103 + AGAIN,
104 + "Don't ask again for folders this size",
105 + )],
106 + submit: "Import".to_owned(),
107 + action: Action::post("/import/preflight"),
108 + })
109 + .with(Node::Act(
110 + Act::new("Cancel", Action::post("/import/preflight/cancel")).key("esc"),
111 + ));
112 +
113 + Ok(Response::from(Outcome::Over(
114 + Screen::sidebar_content("Import folder").with(body),
115 + )))
116 + }
117 +
118 + /// `POST /import/preflight`
119 + fn accept(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
120 + if state.importing.waiting().is_none() {
121 + return Err(RouteError::not_found("no import is waiting"));
122 + }
123 + // A checkbox submits "on" or nothing, which is the same reading the settings
124 + // screen makes of one.
125 + let again = request.payload.get(AGAIN).unwrap_or_default() != "on";
126 + state.importing.accept(again);
127 + Ok(Response::from(Outcome::Goto(Action::get(BACK))).toast(Tone::Success, "Importing."))
128 + }
129 +
130 + /// `POST /import/preflight/cancel`
131 + fn cancel(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
132 + state.importing.cancel();
133 + Ok(Response::from(Outcome::Goto(Action::get(BACK))))
134 + }
@@ -1,0 +1,127 @@
1 + //! The loose-files warning, described: what is missing, and the three things
2 + //! that can be done about it.
3 + //!
4 + //! # The second consumer of the unprompted-overlay finding
5 + //!
6 + //! [`importing`](super::importing)'s header files it and this is the other half:
7 + //! nobody asks for this modal either. `check_loose_files_integrity` runs after a
8 + //! vault loads, counts the samples whose source file is gone, and the shipped
9 + //! overlay appears over whatever the user was doing.
10 + //!
11 + //! The described side cannot raise itself, so the fact is said where it is
12 + //! known — the status band, which already reports what the app is doing — and the
13 + //! overlay is one act away from it. See [`shell`](super::shell)'s `foot`. That is
14 + //! a real behaviour difference from the shipped app and it is recorded rather
15 + //! than smoothed over: a warning you can ignore is not a warning that stopped
16 + //! you. Which of the two is right is a question for the eyeball, and neither is
17 + //! sayable today.
18 + //!
19 + //! # THE FINDING, fourth consumer: Locate is a host act
20 + //!
21 + //! `Locate missing files...` opens a native folder picker
22 + //! (`state.dialogs.pick_folder`), and `quasi:vocabulary:host-save-location` is
23 + //! the gap that nothing describes one. Same shape as the export destination, the
24 + //! import source and the theme export before it. It is an ordinary act here and
25 + //! the host does what only a host can, which is the same workaround those three
26 + //! took.
27 + //!
28 + //! # Purge says what it takes, on the control
29 + //!
30 + //! The shipped modal draws the blast radius as a warning-toned line above the
31 + //! button row: tags, analysis and history go. `Act::confirm` carries it now, so
32 + //! the sentence is attached to the thing that does it rather than sitting near
33 + //! it, which is `524a63fe`'s argument and the third `ConfirmAction`-shaped thing
34 + //! this port has replaced with a builder method.
35 +
36 + use quasi_router::layout::{Notice, Tone};
37 + use quasi_router::{
38 + Act, Action, Node, Outcome, RegionKind, Request, Response, RouteError, Router, Screen, Slot,
39 + };
40 +
41 + use super::Panels;
42 +
43 + /// The region the warning answers into.
44 + const BODY: &str = "loose-files";
45 +
46 + /// Where an answered warning goes.
47 + const BACK: &str = "/";
48 +
49 + /// What Purge takes with it, said on the control that does it.
50 + const BLAST: &str =
51 + "Tags, analysis results, and history for these samples will be permanently deleted. Purge?";
52 +
53 + /// Register the warning's routes.
54 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
55 + router
56 + .get("/library/loose-files", screen)
57 + .post("/library/loose-files/dismiss", dismiss)
58 + .post("/library/loose-files/locate", locate)
59 + .post("/library/loose-files/purge", purge)
60 + }
61 +
62 + /// `GET /library/loose-files`
63 + fn screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
64 + let missing = state.integrity.missing();
65 + if missing == 0 {
66 + return Err(RouteError::not_found("nothing is missing"));
67 + }
68 +
69 + let body = Slot::new(BODY, RegionKind::Pane)
70 + .with(Node::page("Loose-files mode warning"))
71 + .with(Node::Notice {
72 + kind: Notice::Banner,
73 + tone: Tone::Warning,
74 + text: format!(
75 + "{missing} sample{} in this vault {} missing source {}.",
76 + if missing == 1 { "" } else { "s" },
77 + if missing == 1 { "has a" } else { "have" },
78 + if missing == 1 { "file" } else { "files" },
79 + ),
80 + })
81 + .with(Node::text(
82 + "The original files may have been moved or deleted. These samples cannot be played or exported until the files are restored.",
83 + ))
84 + .with(Node::Act(
85 + Act::new("Locate missing files", Action::post("/library/loose-files/locate")),
86 + ))
87 + .with(Node::Act(
88 + Act::new("Purge", Action::post("/library/loose-files/purge"))
89 + .tone(Tone::Danger)
90 + .confirm(BLAST),
91 + ))
92 + .with(Node::Act(
93 + Act::new("Cancel", Action::post("/library/loose-files/dismiss")).key("esc"),
94 + ));
95 +
96 + Ok(Response::from(Outcome::Over(
97 + Screen::sidebar_content("Loose-files mode warning").with(body),
98 + )))
99 + }
100 +
101 + /// `POST /library/loose-files/dismiss`
102 + fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
103 + state.integrity.dismiss();
104 + Ok(Response::from(Outcome::Goto(Action::get(BACK))))
105 + }
106 +
107 + /// `POST /library/loose-files/locate`
108 + ///
109 + /// The answer leaves before the picker opens, and that is honest rather than
110 + /// hurried: the host's dialog runs on its own and lands in the app whenever the
111 + /// user is done with it, so there is nothing for this screen to wait for. See
112 + /// the module header on why a picker is not described at all.
113 + fn locate(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
114 + state.integrity.locate();
115 + Ok(Response::from(Outcome::Goto(Action::get(BACK))))
116 + }
117 +
118 + /// `POST /library/loose-files/purge`
119 + fn purge(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
120 + let missing = state.integrity.missing();
121 + if missing == 0 {
122 + return Err(RouteError::not_found("nothing is missing"));
123 + }
124 + state.integrity.purge();
125 + Ok(Response::from(Outcome::Goto(Action::get(BACK)))
126 + .toast(Tone::Warning, format!("Purging {missing} samples.")))
127 + }
@@ -1,0 +1,310 @@
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 + const BACK: &str = "/";
86 +
87 + /// Register the four modals' routes.
88 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
89 + router
90 + .get("/vaults/new", new_vault_screen)
91 + .post("/vaults/new", new_vault)
92 + .get("/vaults/{id}/rename", rename_vault_screen)
93 + .post("/vaults/{id}/rename", rename_vault)
94 + .get("/folders/new", new_folder_screen)
95 + .post("/folders/new", new_folder)
96 + .get("/folders/{id}/rename", rename_folder_screen)
97 + .post("/folders/{id}/rename", rename_folder)
98 + }
99 +
100 + /// What one of these modals asks.
101 + ///
102 + /// The shipped `NameModalSpec` minus its `placeholder`, which none of the four
103 + /// ever set, and minus its `submit_label`'s twin problem: the title and the
104 + /// button are the modal's own words and stay strings.
105 + struct Asking {
106 + /// The modal's title.
107 + title: &'static str,
108 + /// Standing help about the modal, above the field.
109 + lead_in: Option<&'static str>,
110 + /// The field's own label.
111 + label: &'static str,
112 + /// Standing help about the answer.
113 + hint: Option<&'static str>,
114 + /// What the submit button says.
115 + submit: &'static str,
116 + /// Where the answer goes.
117 + action: String,
118 + }
119 +
120 + /// `GET /vaults/new`
121 + fn new_vault_screen(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
122 + Ok(over(&asking_new_vault(), "", None))
123 + }
124 +
125 + /// `POST /vaults/new`
126 + fn new_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
127 + submitted(&request, &asking_new_vault(), |name| {
128 + state.naming.create_vault(name)
129 + })
130 + }
131 +
132 + /// The New Vault modal's words.
133 + fn asking_new_vault() -> Asking {
134 + Asking {
135 + title: "New Vault",
136 + // What a vault is, and how to nest one, are facts about the modal
137 + // rather than about the name being typed, so they are a lead-in above
138 + // the field and not the field's hint. The forms pass split these two
139 + // slots and this is the site that made the distinction.
140 + lead_in: Some(
141 + "A vault is a separate sample collection, like a folder, but with its own tags and analysis. Right-click inside to create sub-folders.",
142 + ),
143 + label: "Vault name",
144 + hint: None,
145 + submit: "Create",
146 + action: "/vaults/new".to_owned(),
147 + }
148 + }
149 +
150 + /// `GET /vaults/{id}/rename`
151 + fn rename_vault_screen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
152 + let id = numbered(&request)?;
153 + let current = state
154 + .naming
155 + .vault(id)
156 + .ok_or_else(|| RouteError::not_found("no such vault"))?;
157 + Ok(over(&asking_rename_vault(id), &current, None))
158 + }
159 +
160 + /// `POST /vaults/{id}/rename`
161 + fn rename_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
162 + let id = numbered(&request)?;
163 + submitted(&request, &asking_rename_vault(id), |name| {
164 + state.naming.rename_vault(id, name)
165 + })
166 + }
167 +
168 + /// The Rename Vault modal's words.
169 + fn asking_rename_vault(id: i64) -> Asking {
170 + Asking {
171 + title: "Rename Vault",
172 + lead_in: None,
173 + label: "New name",
174 + // About the answer, so it is the field's hint.
175 + hint: Some("Vault names can contain spaces."),
176 + submit: "Save",
177 + action: format!("/vaults/{id}/rename"),
178 + }
179 + }
180 +
181 + /// `GET /folders/new`
182 + fn new_folder_screen(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
183 + Ok(over(&asking_new_folder(), "", None))
184 + }
185 +
186 + /// `POST /folders/new`
187 + fn new_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
188 + submitted(&request, &asking_new_folder(), |name| {
189 + state.naming.create_folder(name)
190 + })
191 + }
192 +
193 + /// The New Folder modal's words.
194 + fn asking_new_folder() -> Asking {
195 + Asking {
196 + title: "New Folder",
197 + lead_in: None,
198 + label: "Folder name",
199 + // A constraint on the answer, so it is the field's hint.
200 + hint: Some("Folder names cannot contain /"),
201 + submit: "Create",
202 + action: "/folders/new".to_owned(),
203 + }
204 + }
205 +
206 + /// `GET /folders/{id}/rename`
207 + fn rename_folder_screen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
208 + let id = numbered(&request)?;
209 + let current = state
210 + .naming
211 + .folder(id)
212 + .ok_or_else(|| RouteError::not_found("no such folder"))?;
213 + Ok(over(&asking_rename_folder(id), &current, None))
214 + }
215 +
216 + /// `POST /folders/{id}/rename`
217 + fn rename_folder(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
218 + let id = numbered(&request)?;
219 + submitted(&request, &asking_rename_folder(id), |name| {
220 + state.naming.rename_folder(id, name)
221 + })
222 + }
223 +
224 + /// The Rename Folder modal's words.
225 + fn asking_rename_folder(id: i64) -> Asking {
226 + Asking {
227 + title: "Rename",
228 + lead_in: None,
229 + label: "New name",
230 + hint: None,
231 + submit: "Save",
232 + action: format!("/folders/{id}/rename"),
233 + }
234 + }
235 +
236 + /// The id a request names.
237 + fn numbered(request: &Request) -> Result<i64, RouteError> {
238 + request
239 + .captures
240 + .require("id")?
241 + .parse()
242 + .map_err(|_| RouteError::not_found("that is not an id"))
243 + }
244 +
245 + /// What every one of the four does with what was typed.
246 + ///
247 + /// One function because the four differ in their words and in which method they
248 + /// call, and in nothing else — which is what `handle_name_modal_outcome` says
249 + /// about the shipped four, in a comment, having been factored out for exactly
250 + /// this reason.
251 + ///
252 + /// **An empty submit closes the modal as a no-op**, which is the shipped rule
253 + /// and the reason none of these fields is `required`: the marker would claim a
254 + /// refusal that never happens.
255 + fn submitted(
256 + request: &Request,
257 + asking: &Asking,
258 + commit: impl FnOnce(&str) -> Result<String, String>,
259 + ) -> Result<Response, RouteError> {
260 + let typed = request.payload.get(NAME).unwrap_or_default().trim();
261 + if typed.is_empty() {
262 + return Ok(Response::from(leaving()));
263 + }
264 + match commit(typed) {
265 + Ok(say) => Ok(Response::from(leaving()).toast(Tone::Success, say)),
266 + // The modal stays up with the name still in it. See the module header:
267 + // a fragment rather than a second `Over`.
268 + Err(why) => Ok(Response::from(Outcome::Fragment {
269 + region: FORM.to_owned(),
270 + node: form(asking, typed, Some(&why)),
271 + })),
272 + }
273 + }
274 +
275 + /// The modal, whatever it is asking.
276 + fn over(asking: &Asking, value: &str, error: Option<&str>) -> Response {
277 + let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page(asking.title));
278 + if let Some(lead_in) = asking.lead_in {
279 + body = body.with(Node::text(lead_in));
280 + }
281 + let body = body
282 + .with(form(asking, value, error))
283 + .with(Node::Act(Act::new("Cancel", Action::get(BACK)).key("esc")));
284 +
285 + Response::from(Outcome::Over(
286 + Screen::sidebar_content(asking.title).with(body),
287 + ))
288 + }
289 +
290 + /// The one question, in a region of its own so a refusal can replace it.
291 + fn form(asking: &Asking, value: &str, error: Option<&str>) -> Node {
292 + let mut field = Field::new(FieldKind::Text, NAME, asking.label).value(value);
293 + if let Some(hint) = asking.hint {
294 + field = field.hint(hint);
295 + }
296 + if let Some(error) = error {
297 + field = field.error(error);
298 + }
299 +
300 + Node::Region(Slot::new(FORM, RegionKind::Group).with(Node::Form {
301 + fields: vec![field],
302 + submit: asking.submit.to_owned(),
303 + action: Action::post(asking.action.clone()),
304 + }))
305 + }
306 +
307 + /// The screen a finished or cancelled modal leaves behind.
308 + fn leaving() -> Outcome {
309 + Outcome::Goto(Action::get(BACK))
310 + }