max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+1332 insertions,
-68 deletions
| @@ -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), ¤t, 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), ¤t, 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 | + | } |