max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+155 insertions,
-162 deletions
| @@ -92,10 +92,6 @@ | |||
| 92 | 92 | // input to the live UI underneath (P2). | |
| 93 | 93 | let modal_active = state.overlay.pending_confirm.is_some() | |
| 94 | 94 | || state.bulk_modal.is_some() | |
| 95 | - | || state.vfs_modal.show_vfs_create | |
| 96 | - | || state.vfs_modal.vfs_rename_target.is_some() | |
| 97 | - | || state.vfs_modal.show_dir_create | |
| 98 | - | || state.vfs_modal.dir_rename_target.is_some() | |
| 99 | 95 | || state.loose_files.show_loose_files_warning | |
| 100 | 96 | || state.import_wf.pending_import_preflight.is_some(); | |
| 101 | 97 | if modal_active { | |
| @@ -120,23 +116,10 @@ | |||
| 120 | 116 | } | |
| 121 | 117 | None => {} | |
| 122 | 118 | } | |
| 123 | - | // The four name modals, all of them `quasi::naming`. Which one is showing is | |
| 124 | - | // still the app's own flag, so the address is built here rather than read | |
| 125 | - | // off anything a route knows. | |
| 126 | - | if state.vfs_modal.show_vfs_create { | |
| 127 | - | crate::quasi::panel::draw_naming(ctx, state, "New Vault", "/vaults/new"); | |
| 128 | - | } | |
| 129 | - | if let Some((id, _)) = &state.vfs_modal.vfs_rename_target { | |
| 130 | - | let home = format!("/vaults/{}/rename", id.as_i64()); | |
| 131 | - | crate::quasi::panel::draw_naming(ctx, state, "Rename Vault", &home); | |
| 132 | - | } | |
| 133 | - | if state.vfs_modal.show_dir_create { | |
| 134 | - | crate::quasi::panel::draw_naming(ctx, state, "New Folder", "/folders/new"); | |
| 135 | - | } | |
| 136 | - | if let Some((id, _)) = &state.vfs_modal.dir_rename_target { | |
| 137 | - | let home = format!("/folders/{}/rename", id.as_i64()); | |
| 138 | - | crate::quasi::panel::draw_naming(ctx, state, "Rename", &home); | |
| 139 | - | } | |
| 119 | + | // The four name modals are not drawn here. They answer `Outcome::Over`, so | |
| 120 | + | // the shell's own runtime layers them over the browser when their act is | |
| 121 | + | // pressed, and the window path this used to take was never entered -- the | |
| 122 | + | // flags gating it went with `49b7429`'s sidebar. Measured 2026-08-25. | |
| 140 | 123 | if state.loose_files.show_loose_files_warning { | |
| 141 | 124 | crate::quasi::panel::draw_integrity(ctx, state); | |
| 142 | 125 | } | |
| @@ -144,20 +127,10 @@ | |||
| 144 | 127 | crate::quasi::panel::draw_preflight(ctx, state); | |
| 145 | 128 | } | |
| 146 | 129 | ||
| 147 | - | // Settings window. Served from the description now, and `ui/settings_panel.rs` | |
| 148 | - | // is gone with the second call: five of its nine sections are not described | |
| 149 | - | // yet and are missing until they are, which is `da48cb6d`'s ruling applied | |
| 150 | - | // here rather than a coexistence this port would have had to invent. | |
| 151 | - | if state.settings.show_manager { | |
| 152 | - | crate::quasi::panel::draw_settings(ctx, state); | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // Sync panel overlay. One call for both cases now: `None` is `Unconfigured`, | |
| 156 | - | // which says syncing is unavailable and offers nothing, where the shipped | |
| 157 | - | // side had a second window for it. | |
| 158 | - | if state.sync.show_panel { | |
| 159 | - | crate::quasi::panel::draw_sync(ctx, state, sync_manager); | |
| 160 | - | } | |
| 130 | + | // Settings and Cloud Sync are not drawn here either, and for a different | |
| 131 | + | // reason than the modals above: they answer `Outcome::Screen`, so the | |
| 132 | + | // toolbar's act replaces the shell's screen with them. That was a one-way | |
| 133 | + | // trip until 2026-08-25; `quasi::panel::leave` is the way off. | |
| 161 | 134 | ||
| 162 | 135 | // Floating sample editor window | |
| 163 | 136 | if state.edit.show_window { | |
| @@ -275,14 +248,10 @@ | |||
| 275 | 248 | ctx.input(|input| { | |
| 276 | 249 | // Escape: dismiss dialogs in priority order | |
| 277 | 250 | if input.key_pressed(egui::Key::Escape) { | |
| 278 | - | if state.settings.show_manager { | |
| 279 | - | state.settings.show_manager = false; | |
| 280 | - | } else if state.forge.show_window { | |
| 251 | + | if state.forge.show_window { | |
| 281 | 252 | state.close_forge_window(); | |
| 282 | 253 | } else if state.edit.show_window { | |
| 283 | 254 | state.close_edit_window(); | |
| 284 | - | } else if state.sync.show_panel { | |
| 285 | - | state.sync.show_panel = false; | |
| 286 | 255 | } else if state.bulk_modal.is_some() { | |
| 287 | 256 | state.close_bulk_modal(); | |
| 288 | 257 | } else if state.import_wf.pending_import_preflight.is_some() { |
| @@ -55,8 +55,6 @@ | |||
| 55 | 55 | /// from the description every frame, so their buffers have to outlive one. | |
| 56 | 56 | #[derive(Debug, Default)] | |
| 57 | 57 | pub struct Described { | |
| 58 | - | settings: Option<Runtime>, | |
| 59 | - | sync: Option<Runtime>, | |
| 60 | 58 | export: Option<Runtime>, | |
| 61 | 59 | detail: Option<Runtime>, | |
| 62 | 60 | shell: Option<Runtime>, | |
| @@ -107,80 +105,6 @@ | |||
| 107 | 105 | pub(super) free: crate::export::FreeSpace, | |
| 108 | 106 | } | |
| 109 | 107 | ||
| 110 | - | /// Draw the described settings window, and act on whatever was pressed. | |
| 111 | - | pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) { | |
| 112 | - | // The Trash section reads a list the app loads on demand, and loading it is | |
| 113 | - | // `&mut`. Same housekeeping `draw_sync` does above its own window: a route | |
| 114 | - | // cannot fetch, so the host fetches before it asks. Cheap after the first | |
| 115 | - | // time — `ensure_trash_loaded` is a flag check once the list is in hand. | |
| 116 | - | state.ensure_trash_loaded(); | |
| 117 | - | ||
| 118 | - | let intents = RefCell::new(Vec::new()); | |
| 119 | - | let mut runtime = state.described.settings.take(); | |
| 120 | - | let stale = state.described.stale; | |
| 121 | - | let host = Host { | |
| 122 | - | state, | |
| 123 | - | sync: None, | |
| 124 | - | themes: themes(), | |
| 125 | - | intents: &intents, | |
| 126 | - | }; | |
| 127 | - | let closed = window(ctx, "Settings", &mut runtime, &host, "/settings", stale); | |
| 128 | - | state.described.settings = runtime; | |
| 129 | - | apply(ctx, state, None, intents.into_inner()); | |
| 130 | - | if closed { | |
| 131 | - | state.settings.show_manager = false; | |
| 132 | - | state.described.settings = None; | |
| 133 | - | } | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | /// Draw the sync window, and act on whatever was pressed. | |
| 137 | - | /// | |
| 138 | - | /// `sync` is `None` when the app has no manager, and it becomes | |
| 139 | - | /// [`Unconfigured`], which says syncing is unavailable and offers nothing. The | |
| 140 | - | /// shipped panel had a second window for that case. | |
| 141 | - | pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) { | |
| 142 | - | // Two pieces of housekeeping that came off `ui::sync_panel::draw_sync_panel` | |
| 143 | - | // when it was deleted. Neither is describable and neither is a control: they | |
| 144 | - | // are caches the window owns, dropped when what they were about is over. | |
| 145 | - | // | |
| 146 | - | // The auth URL is only meaningful while the Copy URL fallback is on screen, | |
| 147 | - | // and keeping it would show a stale PKCE state if the panel were reopened. | |
| 148 | - | if let Some(manager) = sync | |
| 149 | - | && !matches!( | |
| 150 | - | manager.status().state, | |
| 151 | - | audiofiles_sync::SyncState::Authenticating | |
| 152 | - | ) | |
| 153 | - | && state.sync.auth_url.is_some() | |
| 154 | - | { | |
| 155 | - | state.sync.auth_url = None; | |
| 156 | - | } | |
| 157 | - | // The per-vault storage numbers go when the panel closes, so reopening | |
| 158 | - | // fetches fresh ones: the user may have imported or deleted since. | |
| 159 | - | if !state.sync.show_panel { | |
| 160 | - | state.sync.vfs_storage_fetched = false; | |
| 161 | - | state.sync.vfs_storage_cache.clear(); | |
| 162 | - | state.sync.synced_bytes = None; | |
| 163 | - | state.sync.cap_picker_gib = None; | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | let intents = RefCell::new(Vec::new()); | |
| 167 | - | let mut runtime = state.described.sync.take(); | |
| 168 | - | let stale = state.described.stale; | |
| 169 | - | let host = Host { | |
| 170 | - | state, | |
| 171 | - | sync, | |
| 172 | - | themes: themes(), | |
| 173 | - | intents: &intents, | |
| 174 | - | }; | |
| 175 | - | let closed = window(ctx, "Cloud Sync", &mut runtime, &host, "/sync", stale); | |
| 176 | - | state.described.sync = runtime; | |
| 177 | - | apply(ctx, state, sync, intents.into_inner()); | |
| 178 | - | if closed { | |
| 179 | - | state.sync.show_panel = false; | |
| 180 | - | state.described.sync = None; | |
| 181 | - | } | |
| 182 | - | } | |
| 183 | - | ||
| 184 | 108 | /// Draw the export flow, and act on whatever was pressed. | |
| 185 | 109 | /// | |
| 186 | 110 | /// One call for the whole flow, the way `draw_import` is: the shipped side was | |
| @@ -474,10 +398,6 @@ | |||
| 474 | 398 | // what `Intent::NamingDone` would have done. | |
| 475 | 399 | if closed || finished { | |
| 476 | 400 | state.described.naming = None; | |
| 477 | - | state.vfs_modal.show_vfs_create = false; | |
| 478 | - | state.vfs_modal.show_dir_create = false; | |
| 479 | - | state.vfs_modal.vfs_rename_target = None; | |
| 480 | - | state.vfs_modal.dir_rename_target = None; | |
| 481 | 401 | state.vfs_modal.name_modal_error = None; | |
| 482 | 402 | } | |
| 483 | 403 | } | |
| @@ -1000,13 +920,7 @@ | |||
| 1000 | 920 | // into. What is left is the half a route cannot do. | |
| 1001 | 921 | // Whichever of the four was up, and the error with it: the modal is | |
| 1002 | 922 | // finished with, so a refusal it was showing is finished with too. | |
| 1003 | - | Intent::NamingDone => { | |
| 1004 | - | state.vfs_modal.show_vfs_create = false; | |
| 1005 | - | state.vfs_modal.show_dir_create = false; | |
| 1006 | - | state.vfs_modal.vfs_rename_target = None; | |
| 1007 | - | state.vfs_modal.dir_rename_target = None; | |
| 1008 | - | state.vfs_modal.name_modal_error = None; | |
| 1009 | - | } | |
| 923 | + | Intent::NamingDone => state.vfs_modal.name_modal_error = None, | |
| 1010 | 924 | // Whichever of the three was up. | |
| 1011 | 925 | Intent::BulkDone => state.close_bulk_modal(), | |
| 1012 | 926 | Intent::VaultsChanged(say) => { | |
| @@ -1488,7 +1402,6 @@ | |||
| 1488 | 1402 | // instead, so by the time an intent lands the question has been put. | |
| 1489 | 1403 | Intent::SwitchLibrary(path) => { | |
| 1490 | 1404 | state.settings.pending_action = Some(crate::state::VaultAction::SwitchVault(path)); | |
| 1491 | - | close_settings(state); | |
| 1492 | 1405 | } | |
| 1493 | 1406 | Intent::RenamingLibrary(target) => state.settings.rename_target = target, | |
| 1494 | 1407 | Intent::RenameLibrary { path, name } => { | |
| @@ -1526,7 +1439,6 @@ | |||
| 1526 | 1439 | loose_files, | |
| 1527 | 1440 | }); | |
| 1528 | 1441 | discard_draft(state); | |
| 1529 | - | close_settings(state); | |
| 1530 | 1442 | } | |
| 1531 | 1443 | } | |
| 1532 | 1444 | Intent::AddExistingLibrary => { | |
| @@ -1534,10 +1446,6 @@ | |||
| 1534 | 1446 | state.settings.pending_action = | |
| 1535 | 1447 | Some(crate::state::VaultAction::AddExistingVault { name, path }); | |
| 1536 | 1448 | discard_draft(state); | |
| 1537 | - | // Both commit paths close Settings: a Create makes the new | |
| 1538 | - | // library active, and an Add Existing typically motivates | |
| 1539 | - | // browsing it straight away. | |
| 1540 | - | close_settings(state); | |
| 1541 | 1449 | } | |
| 1542 | 1450 | } | |
| 1543 | 1451 | Intent::DiscardLibraryDraft => discard_draft(state), | |
| @@ -1754,16 +1662,6 @@ | |||
| 1754 | 1662 | state.settings.create_loose_files = false; | |
| 1755 | 1663 | } | |
| 1756 | 1664 | ||
| 1757 | - | /// Put the settings window away, and the runtime that was serving it. | |
| 1758 | - | /// | |
| 1759 | - | /// The runtime goes because it holds the address the window was showing, and a | |
| 1760 | - | /// window that closed on a switch should reopen at `/settings` rather than where | |
| 1761 | - | /// it left off. | |
| 1762 | - | fn close_settings(state: &mut BrowserState) { | |
| 1763 | - | state.settings.show_manager = false; | |
| 1764 | - | state.described.settings = None; | |
| 1765 | - | } | |
| 1766 | - | ||
| 1767 | 1665 | /// Add a theme file to the custom themes, and show it. | |
| 1768 | 1666 | /// | |
| 1769 | 1667 | /// Lifted from the deleted `ui/settings_panel.rs`. Importing a theme selects it, | |
| @@ -2304,7 +2202,9 @@ | |||
| 2304 | 2202 | } | |
| 2305 | 2203 | ||
| 2306 | 2204 | let step = runtime.show(ui, &immediate); | |
| 2205 | + | let taken = !matches!(step, Step::Idle); | |
| 2307 | 2206 | perform(runtime, ui, host, step); | |
| 2207 | + | leave(runtime, ui, host, taken); | |
| 2308 | 2208 | ||
| 2309 | 2209 | // One drain for every described window, rather than one per | |
| 2310 | 2210 | // `draw_*`: a file is produced by a route and a route is reachable | |
| @@ -2315,6 +2215,45 @@ | |||
| 2315 | 2215 | locate(runtime, host.state, home); | |
| 2316 | 2216 | } | |
| 2317 | 2217 | ||
| 2218 | + | /// Escape goes back, when nothing on the screen wanted it. | |
| 2219 | + | /// | |
| 2220 | + | /// **The way out of a described screen**, ruled by Max on 2026-08-25 after the | |
| 2221 | + | /// door sweep found that `/settings` and `/sync` replace the shell's screen and | |
| 2222 | + | /// nothing described a way off them. `Runtime::back` was written, popped | |
| 2223 | + | /// `history`, and had never been called. | |
| 2224 | + | /// | |
| 2225 | + | /// Three conditions, and each is the answer to a way this could be wrong: | |
| 2226 | + | /// | |
| 2227 | + | /// - **The description gets first refusal.** `runtime.show` answers a `Step` | |
| 2228 | + | /// when a described control claimed the press, and every modal in this app | |
| 2229 | + | /// carries `Act::key("esc")` on its Cancel. Firing as well would cancel the | |
| 2230 | + | /// modal *and* navigate off what was under it. | |
| 2231 | + | /// - **Never while an overlay is open.** An overlay is not a place and never | |
| 2232 | + | /// entered `history`, so going back from one would leave the layer stack up | |
| 2233 | + | /// and change the screen underneath it. Dismissing is the overlay's own | |
| 2234 | + | /// Cancel, which is the first condition. | |
| 2235 | + | /// - **Only where there is somewhere to go.** `back` answers `Step::Idle` on an | |
| 2236 | + | /// empty history, so the shell's own home is not a screen you can leave. | |
| 2237 | + | /// | |
| 2238 | + | /// # What this is not, and it is worth saying at the binding | |
| 2239 | + | /// | |
| 2240 | + | /// A host affordance rather than a described fact. Nothing in the vocabulary | |
| 2241 | + | /// says "this is the way back": `Chrome::bind` carries an `Action`, and an | |
| 2242 | + | /// action names a route rather than a movement through history. So a keyboard | |
| 2243 | + | /// host gets this and **a touch host gets nothing** -- its system back gesture | |
| 2244 | + | /// has no described thing to bind to. Measured 2026-08-25 and filed as | |
| 2245 | + | /// quasicoherent `33c27e81`; when that lands, this function is what it replaces. | |
| 2246 | + | fn leave(runtime: &mut Runtime, ui: &mut egui::Ui, host: &Host<'_>, taken: bool) { | |
| 2247 | + | if taken || runtime.overlaid() { | |
| 2248 | + | return; | |
| 2249 | + | } | |
| 2250 | + | if !ui.ctx().input(|input| input.key_pressed(egui::Key::Escape)) { | |
| 2251 | + | return; | |
| 2252 | + | } | |
| 2253 | + | let step = runtime.back(); | |
| 2254 | + | perform(runtime, ui, host, step); | |
| 2255 | + | } | |
| 2256 | + | ||
| 2318 | 2257 | /// One described window: draw it, act on it, and say whether it was closed. | |
| 2319 | 2258 | fn window( | |
| 2320 | 2259 | ctx: &egui::Context, | |
| @@ -2697,6 +2636,31 @@ | |||
| 2697 | 2636 | .collect() | |
| 2698 | 2637 | } | |
| 2699 | 2638 | ||
| 2639 | + | /// Draw one described screen inline, for the parity tests. | |
| 2640 | + | /// | |
| 2641 | + | /// The companion to [`described_screen`], and it exists because two screens lost | |
| 2642 | + | /// the window that used to render them. `/settings` and `/sync` are reached by | |
| 2643 | + | /// navigating the shell now (2026-08-25), so there is no `draw_settings` to read | |
| 2644 | + | /// a description against; this stands a bare [`Runtime`] at the address and | |
| 2645 | + | /// drives one frame of it, which is what the shell does to them and all a parity | |
| 2646 | + | /// read needs. | |
| 2647 | + | /// | |
| 2648 | + | /// `refresh` is false: a parity read presses nothing, so there is nothing to | |
| 2649 | + | /// reload for, and asking twice would only widen what the fixture has to answer. | |
| 2650 | + | #[cfg(test)] | |
| 2651 | + | pub(super) fn draw_described(ui: &mut egui::Ui, state: &mut BrowserState, home: &str) { | |
| 2652 | + | let intents = RefCell::new(Vec::new()); | |
| 2653 | + | let mut runtime = None; | |
| 2654 | + | let host = Host { | |
| 2655 | + | state, | |
| 2656 | + | sync: None, | |
| 2657 | + | themes: themes(), | |
| 2658 | + | intents: &intents, | |
| 2659 | + | }; | |
| 2660 | + | inline(ui, &mut runtime, &host, home, false); | |
| 2661 | + | apply(ui.ctx(), state, None, intents.into_inner()); | |
| 2662 | + | } | |
| 2663 | + | ||
| 2700 | 2664 | /// The screen the app's own adapters answer at `address`, for the parity tests. | |
| 2701 | 2665 | /// | |
| 2702 | 2666 | /// The parity harness compares a described screen against the shipped panel it |
| @@ -804,22 +804,22 @@ | |||
| 804 | 804 | // | |
| 805 | 805 | // The comparison this replaces was against four hand-drawn bodies of | |
| 806 | 806 | // `settings_panel.rs`, because the description covered four of the shipped | |
| 807 | - | // panel's nine sections. The file is deleted, so there is nothing left to | |
| 808 | - | // compare against and nothing left holding the other five: Storage, Trash, | |
| 809 | - | // License, the classifier and Import Theme are missing from the app until | |
| 810 | - | // each is described. `da48cb6d` is the ruling that allows the interval. | |
| 807 | + | // panel's nine sections. That file is deleted and so are the other five | |
| 808 | + | // sections' excuses: all nine are answered as of 2026-08-25. | |
| 809 | + | // | |
| 810 | + | // Drawn inline rather than in a window, which is the second thing that | |
| 811 | + | // changed: `draw_settings` is gone with the door that could never open it, | |
| 812 | + | // and Settings is a screen the shell navigates to. `draw_described` is what | |
| 813 | + | // the shell does to it, minus the browser around it. | |
| 811 | 814 | let (mut state, _dir) = fixture(); | |
| 812 | - | state.settings.show_manager = true; | |
| 813 | 815 | ||
| 814 | 816 | let described = described(&super::panel::described_screen(&state, "/settings")); | |
| 815 | 817 | let drawn = shipped(|ui| { | |
| 816 | - | super::panel::draw_settings(ui.ctx(), &mut state); | |
| 818 | + | super::panel::draw_described(ui, &mut state, "/settings"); | |
| 817 | 819 | }); | |
| 818 | 820 | ||
| 819 | 821 | described.addresses_resolve(); | |
| 820 | - | Parity::strict() | |
| 821 | - | .in_a_window("Settings") | |
| 822 | - | .assert(&described, &drawn); | |
| 822 | + | Parity::strict().assert(&described, &drawn); | |
| 823 | 823 | } | |
| 824 | 824 | ||
| 825 | 825 | #[test] | |
| @@ -1078,18 +1078,18 @@ | |||
| 1078 | 1078 | // With no manager, which is the one sync state a test can stand up without | |
| 1079 | 1079 | // a server. `Unconfigured` says syncing is unavailable and offers nothing, | |
| 1080 | 1080 | // where the shipped side had a whole second window for it. | |
| 1081 | + | // | |
| 1082 | + | // Inline for the same reason Settings is: `draw_sync` went with the door | |
| 1083 | + | // nothing could open, and the shell navigates to `/sync` instead. | |
| 1081 | 1084 | let (mut state, _dir) = fixture(); | |
| 1082 | - | state.sync.show_panel = true; | |
| 1083 | 1085 | ||
| 1084 | 1086 | let described = described(&super::panel::described_screen(&state, "/sync")); | |
| 1085 | 1087 | let drawn = shipped(|ui| { | |
| 1086 | - | super::panel::draw_sync(ui.ctx(), &mut state, None); | |
| 1088 | + | super::panel::draw_described(ui, &mut state, "/sync"); | |
| 1087 | 1089 | }); | |
| 1088 | 1090 | ||
| 1089 | 1091 | described.addresses_resolve(); | |
| 1090 | - | Parity::strict() | |
| 1091 | - | .in_a_window("Cloud Sync") | |
| 1092 | - | .assert(&described, &drawn); | |
| 1092 | + | Parity::strict().assert(&described, &drawn); | |
| 1093 | 1093 | } | |
| 1094 | 1094 | ||
| 1095 | 1095 | #[test] |
| @@ -80,8 +80,8 @@ | |||
| 80 | 80 | ||
| 81 | 81 | use quasi_router::layout::{FieldKind, Tone}; | |
| 82 | 82 | use quasi_router::{ | |
| 83 | - | Act, Action, Choice, Field, Locating, Node, Request, Response, RouteError, Router, Row, Slot, | |
| 84 | - | Tag, | |
| 83 | + | Act, Action, Choice, Field, Locating, Node, Outcome, Request, Response, RouteError, Router, | |
| 84 | + | Row, Slot, Tag, | |
| 85 | 85 | }; | |
| 86 | 86 | ||
| 87 | 87 | use super::Panels; | |
| @@ -89,6 +89,9 @@ | |||
| 89 | 89 | /// The name a picked folder comes back under. | |
| 90 | 90 | const FOLDER: &str = "folder"; | |
| 91 | 91 | ||
| 92 | + | /// Where a library change leaves you: browsing the library it changed to. | |
| 93 | + | const BROWSER: &str = "/"; | |
| 94 | + | ||
| 92 | 95 | /// The name a typed library name is submitted under. | |
| 93 | 96 | const NAME: &str = "name"; | |
| 94 | 97 | ||
| @@ -126,9 +129,13 @@ | |||
| 126 | 129 | } | |
| 127 | 130 | ||
| 128 | 131 | /// `POST /settings/storage/open/{at}` | |
| 132 | + | /// | |
| 133 | + | /// Leaves for the browser rather than answering with Settings again, which is | |
| 134 | + | /// what opening a library is for. Said as a navigation since 2026-08-25: it was | |
| 135 | + | /// the host clearing the window's runtime until Settings stopped being a window. | |
| 129 | 136 | fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 130 | 137 | state.storage.open(row(&request)?); | |
| 131 | - | settled(state) | |
| 138 | + | Ok(Response::from(Outcome::Goto(Action::get(BROWSER)))) | |
| 132 | 139 | } | |
| 133 | 140 | ||
| 134 | 141 | /// `POST /settings/storage/rename/{at}` | |
| @@ -262,7 +269,7 @@ | |||
| 262 | 269 | return Err(RouteError::not_found("the form is not finished")); | |
| 263 | 270 | } | |
| 264 | 271 | state.storage.create(); | |
| 265 | - | settled(state) | |
| 272 | + | Ok(Response::from(Outcome::Goto(Action::get(BROWSER)))) | |
| 266 | 273 | } | |
| 267 | 274 | ||
| 268 | 275 | /// `POST /settings/storage/add` | |
| @@ -271,7 +278,7 @@ | |||
| 271 | 278 | return Err(RouteError::not_found("the form is not finished")); | |
| 272 | 279 | } | |
| 273 | 280 | state.storage.add_existing(); | |
| 274 | - | settled(state) | |
| 281 | + | Ok(Response::from(Outcome::Goto(Action::get(BROWSER)))) | |
| 275 | 282 | } | |
| 276 | 283 | ||
| 277 | 284 | /// `POST /settings/storage/discard` |
| @@ -11817,3 +11817,56 @@ | |||
| 11817 | 11817 | .any(|label| label == "Export current theme") | |
| 11818 | 11818 | ); | |
| 11819 | 11819 | } | |
| 11820 | + | ||
| 11821 | + | #[test] | |
| 11822 | + | fn changing_which_library_is_open_leaves_settings_for_the_browser() { | |
| 11823 | + | // Opening a library is what Settings is being used *for*, so the three acts | |
| 11824 | + | // that change which one is open navigate rather than answering with Settings | |
| 11825 | + | // again. It was the host clearing the window's runtime until 2026-08-25, | |
| 11826 | + | // when Settings stopped being a window and a navigation became the only way | |
| 11827 | + | // to say it. | |
| 11828 | + | let mut storage = FakeStorage::two(); | |
| 11829 | + | storage.libraries[1].reachable = true; | |
| 11830 | + | storage.draft = Draft { | |
| 11831 | + | name: "Kit".to_owned(), | |
| 11832 | + | folder: Some("/mnt/fast/Kit".to_owned()), | |
| 11833 | + | reference_in_place: false, | |
| 11834 | + | }; | |
| 11835 | + | ||
| 11836 | + | for address in [ | |
| 11837 | + | "/settings/storage/open/1", | |
| 11838 | + | "/settings/storage/create", | |
| 11839 | + | "/settings/storage/add", | |
| 11840 | + | ] { | |
| 11841 | + | let response = storing(&storage, Request::post(address)).expect("answered"); | |
| 11842 | + | let Outcome::Goto(action) = &response.outcome else { | |
| 11843 | + | panic!( | |
| 11844 | + | "{address} answered {:?} rather than leaving", | |
| 11845 | + | response.outcome | |
| 11846 | + | ); | |
| 11847 | + | }; | |
| 11848 | + | assert_eq!(action.route(), Some("/"), "{address} went somewhere else"); | |
| 11849 | + | } | |
| 11850 | + | assert_eq!(storage.asked(), ["open 1", "create", "add_existing"]); | |
| 11851 | + | } | |
| 11852 | + | ||
| 11853 | + | #[test] | |
| 11854 | + | fn every_other_storage_act_stays_on_the_settings_screen() { | |
| 11855 | + | // The converse, so the rule above is a distinction rather than a habit: | |
| 11856 | + | // scanning, renaming and the rest are things you do *to* a library while | |
| 11857 | + | // looking at the list, and leaving after each would be unusable. | |
| 11858 | + | let storage = FakeStorage::two(); | |
| 11859 | + | for address in [ | |
| 11860 | + | "/settings/storage/scan", | |
| 11861 | + | "/settings/storage/orphans", | |
| 11862 | + | "/settings/storage/forget/1", | |
| 11863 | + | "/settings/storage/discard", | |
| 11864 | + | ] { | |
| 11865 | + | let response = storing(&storage, Request::post(address)).expect("answered"); | |
| 11866 | + | assert_eq!( | |
| 11867 | + | screen_of(&response).title, | |
| 11868 | + | "Settings", | |
| 11869 | + | "{address} left the screen" | |
| 11870 | + | ); | |
| 11871 | + | } | |
| 11872 | + | } |