max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+110 insertions,
-57 deletions
| @@ -81,24 +81,27 @@ | |||
| 81 | 81 | //! | invalid-tag warning per folder | yes | `tags::validate_tag` over what was typed, and pure | | |
| 82 | 82 | //! | BPM range and top keys over the batch | yes | arithmetic over the results the screen already carries | | |
| 83 | 83 | //! | **rate and ETA** | no | a rolling wall-clock buffer, which is a fact about this machine | | |
| 84 | - | //! | **`Import files...`**, and the source door not converted yet | no | see below | | |
| 84 | + | //! | **`Import files...`** | no | one ask, many paths, and the batch is the point | | |
| 85 | 85 | //! | **↑/↓ walking the review list** | no | a key bound to "the next row of a list", which `Chrome` cannot say | | |
| 86 | 86 | //! | |
| 87 | 87 | //! The pickers are the sharper half. `Import folder...`, `Quick import | |
| 88 | 88 | //! folder...`, `Import files...` and `Change...` each open a native dialog and | |
| 89 | 89 | //! then act, which this header filed as consumers five to eight of | |
| 90 | 90 | //! `quasi:vocabulary:host-save-location`. That gap closed in quasi 0.60 | |
| 91 | - | //! (`ec92f9cb`) and the two folder doors are converted: | |
| 92 | - | //! [`open_folder`](fn@open_folder) and [`open_quickly`](fn@open_quickly) say a | |
| 93 | - | //! folder is wanted, and the answer lands on a second address that does the | |
| 94 | - | //! work. | |
| 91 | + | //! (`ec92f9cb`) and three of the four are converted: | |
| 92 | + | //! [`open_folder`](fn@open_folder), [`open_quickly`](fn@open_quickly) and | |
| 93 | + | //! [`change_source`](fn@change_source) say a folder is wanted, and the answer | |
| 94 | + | //! lands on a second address that does the work. | |
| 95 | 95 | //! | |
| 96 | - | //! The other two still hand off, and a route that hands off to the host has | |
| 97 | - | //! nothing true to answer. [`Outcome`](quasi_router::Outcome) is `Screen`, | |
| 98 | - | //! `Fragment`, `Goto` or `Over`, and none of them is "nothing here changed". | |
| 99 | - | //! They answer `Goto(/import)`, right by the time the picker returns and a | |
| 100 | - | //! frame early when it is pressed, which is the standing cost | |
| 101 | - | //! `Runtime::reload` corrects. | |
| 96 | + | //! **`Import files...` is not a conversion owed.** `start_files_import` takes | |
| 97 | + | //! every picked path in one call and hashes them off the GUI thread, which is | |
| 98 | + | //! the shipped menu entry's own fix, and `Locating::answered` hands back one | |
| 99 | + | //! handle at a time. So it still opens the host's picker itself, and a route | |
| 100 | + | //! that hands off has nothing true to answer: | |
| 101 | + | //! [`Outcome`](quasi_router::Outcome) is `Screen`, `Fragment`, `Goto` or | |
| 102 | + | //! `Over`, and none of them is "nothing here changed". It answers | |
| 103 | + | //! `Goto(/import)`, right by the time the picker returns and a frame early when | |
| 104 | + | //! it is pressed, which is the standing cost `Runtime::reload` corrects. | |
| 102 | 105 | //! | |
| 103 | 106 | //! # The preflight | |
| 104 | 107 | //! | |
| @@ -227,6 +230,7 @@ | |||
| 227 | 230 | .post("/import/open/quick/chosen", quick_chosen) | |
| 228 | 231 | .post("/import/open/files", open_files) | |
| 229 | 232 | .post("/import/source", change_source) | |
| 233 | + | .post("/import/source/chosen", source_chosen) | |
| 230 | 234 | .post("/import/set/{decision}", decide) | |
| 231 | 235 | .post("/import/start", start) | |
| 232 | 236 | .post("/import/stop", stop) | |
| @@ -437,13 +441,37 @@ | |||
| 437 | 441 | /// configure screen is about, and there is no honest screen for changing the | |
| 438 | 442 | /// source of an import that is already copying. | |
| 439 | 443 | fn change_source(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 440 | - | if !matches!(state.importing.stage(), Stage::Configuring { .. }) { | |
| 441 | - | return Err(RouteError::not_found("nothing is being configured")); | |
| 444 | + | being_configured(state)?; | |
| 445 | + | Ok(Response::locate(Locating::folder( | |
| 446 | + | "Choose source folder", | |
| 447 | + | Action::post("/import/source/chosen"), | |
| 448 | + | FOLDER, | |
| 449 | + | ))) | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | /// `POST /import/source/chosen` | |
| 453 | + | /// | |
| 454 | + | /// The stage is checked again rather than trusted from the ask: the picker runs | |
| 455 | + | /// for as long as the reader takes over it, and the copy could have started in | |
| 456 | + | /// the meantime. | |
| 457 | + | fn source_chosen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 458 | + | being_configured(state)?; | |
| 459 | + | let folder = request.payload.get(FOLDER).unwrap_or_default(); | |
| 460 | + | if !folder.is_empty() { | |
| 461 | + | state.importing.change_source(folder); | |
| 442 | 462 | } | |
| 443 | - | state.importing.change_source(); | |
| 444 | 463 | Ok(handed_off()) | |
| 445 | 464 | } | |
| 446 | 465 | ||
| 466 | + | /// Refuse unless there is an import being configured. | |
| 467 | + | fn being_configured(state: &Panels<'_>) -> Result<(), RouteError> { | |
| 468 | + | if matches!(state.importing.stage(), Stage::Configuring { .. }) { | |
| 469 | + | Ok(()) | |
| 470 | + | } else { | |
| 471 | + | Err(RouteError::not_found("nothing is being configured")) | |
| 472 | + | } | |
| 473 | + | } | |
| 474 | + | ||
| 447 | 475 | /// What a door answers, having asked the host. | |
| 448 | 476 | /// | |
| 449 | 477 | /// See the module header: no outcome says "nothing here changed", so it says |
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | //! | |
| 21 | 21 | //! `Locate missing files...` opens a native folder picker, and this module's | |
| 22 | 22 | //! header used to call that the fourth consumer of | |
| 23 | - | //! `quasi:vocabulary:host-save-location` — the gap that nothing describes one. | |
| 23 | + | //! `quasi:vocabulary:host-save-location`, the gap that nothing describes one. | |
| 24 | 24 | //! `ec92f9cb` closed it in quasi 0.60 with [`Outcome::Locate`], and the pair | |
| 25 | 25 | //! below is the conversion: [`locate`](fn@locate) says a folder is wanted and | |
| 26 | 26 | //! [`located`](fn@located) is called with what came back. |
| @@ -120,20 +120,29 @@ | |||
| 120 | 120 | //! half, serves every described window, and handles all three of | |
| 121 | 121 | //! [`Sought`](quasi_router::Sought) with the accept-list plumbed through. | |
| 122 | 122 | //! | |
| 123 | - | //! **Seven picker sites are still ordinary intents** (measured 2026-08-25: | |
| 124 | - | //! `panel.rs` 1122, 1127, 1130, 1149, 1469, 1685, 1693 — the import flow's four | |
| 125 | - | //! doors, Locate missing files, and the classifier's `.afcl` export and | |
| 126 | - | //! import). [`storage`] converted two more when it landed. Each of the seven is | |
| 127 | - | //! a conversion owed rather than a shape it was argued into. | |
| 123 | + | //! **Two picker sites are still ordinary intents**, and each is held back by | |
| 124 | + | //! something the vocabulary does not say yet rather than by a conversion nobody | |
| 125 | + | //! got to: | |
| 128 | 126 | //! | |
| 129 | - | //! **None of them wants bytes.** Every one takes a path and reads it host-side, | |
| 127 | + | //! - **The classifier's `.afcl` export** ([`classifier`]). A save dialog with a | |
| 128 | + | //! suggested filename, and [`Sought`](quasi_router::Sought) has no save shape: | |
| 129 | + | //! it asks where something *is*, not where something should go. `Outcome::File` | |
| 130 | + | //! is the other half of that and cannot serve this one either, since the export | |
| 131 | + | //! is built on a worker and answers through `BackendEvent`, so the route has no | |
| 132 | + | //! bytes to hand over when it answers. | |
| 133 | + | //! - **`Import files...`** ([`importing`]). It batches every picked path into one | |
| 134 | + | //! `start_files_import`, which is what keeps the hashing off the GUI thread and | |
| 135 | + | //! is the shipped menu entry's own fix. `Locating::answered` takes one handle at | |
| 136 | + | //! a time, so converting it today would put the batch back on the frame thread. | |
| 137 | + | //! | |
| 138 | + | //! **Neither wants bytes.** Every site here takes a path and reads it host-side, | |
| 130 | 139 | //! which is worth stating because `settings`'s header claimed the opposite about | |
| 131 | 140 | //! Import Theme and was written before `Outcome::Locate` existed. There is no | |
| 132 | 141 | //! bytes-in gap in this app, and no site waiting on one. | |
| 133 | 142 | //! | |
| 134 | - | //! There is a second half, filed with the import flow: a route that hands off to | |
| 135 | - | //! the host has no [`Outcome`](quasi_router::Outcome) meaning "nothing here | |
| 136 | - | //! changed". `dialog.rs` is why — the answer arrives frames later, on a thread, | |
| 143 | + | //! The second half of the gap survives with `Import files...`: a route that hands | |
| 144 | + | //! off to the host has no [`Outcome`](quasi_router::Outcome) meaning "nothing here | |
| 145 | + | //! changed". `dialog.rs` is why: the answer arrives frames later, on a thread, | |
| 137 | 146 | //! through a closure, and the route that asked is long finished. | |
| 138 | 147 | ||
| 139 | 148 | // Handlers take their request by value because `quasi_router::Handler` is a | |
| @@ -846,8 +855,8 @@ | |||
| 846 | 855 | QuickImportFolder(std::path::PathBuf), | |
| 847 | 856 | /// Ask the host for files, then merge them into the vault that is open. | |
| 848 | 857 | OpenImportFiles, | |
| 849 | - | /// Ask the host for a different folder for the import being configured. | |
| 850 | - | ChangeImportSource, | |
| 858 | + | /// Take this folder as the source of the import being configured. | |
| 859 | + | ImportSource(std::path::PathBuf), | |
| 851 | 860 | /// Answer one of the configure screen's three questions. | |
| 852 | 861 | Decide(Decision, String), | |
| 853 | 862 | /// Start copying the files in. | |
| @@ -3808,8 +3817,8 @@ | |||
| 3808 | 3817 | /// Pick files, then merge them into the vault that is open. | |
| 3809 | 3818 | fn open_files(&self); | |
| 3810 | 3819 | ||
| 3811 | - | /// Pick a different folder for the import being configured. | |
| 3812 | - | fn change_source(&self); | |
| 3820 | + | /// Use this folder for the import being configured instead. | |
| 3821 | + | fn change_source(&self, folder: &str); | |
| 3813 | 3822 | ||
| 3814 | 3823 | // Configuring. | |
| 3815 | 3824 | ||
| @@ -4105,8 +4114,8 @@ | |||
| 4105 | 4114 | self.push(Intent::OpenImportFiles); | |
| 4106 | 4115 | } | |
| 4107 | 4116 | ||
| 4108 | - | fn change_source(&self) { | |
| 4109 | - | self.push(Intent::ChangeImportSource); | |
| 4117 | + | fn change_source(&self, folder: &str) { | |
| 4118 | + | self.push(Intent::ImportSource(std::path::PathBuf::from(folder))); | |
| 4110 | 4119 | } | |
| 4111 | 4120 | ||
| 4112 | 4121 | fn decide(&self, decision: Decision, value: &str) { |
| @@ -1067,13 +1067,7 @@ | |||
| 1067 | 1067 | }, | |
| 1068 | 1068 | ); | |
| 1069 | 1069 | } | |
| 1070 | - | Intent::ChangeImportSource => { | |
| 1071 | - | state | |
| 1072 | - | .dialogs | |
| 1073 | - | .pick_folder("Choose source folder", |state, folder| { | |
| 1074 | - | state.change_import_source(folder); | |
| 1075 | - | }); | |
| 1076 | - | } | |
| 1070 | + | Intent::ImportSource(folder) => state.change_import_source(folder), | |
| 1077 | 1071 | Intent::Decide(decision, value) => decide(state, decision, &value), | |
| 1078 | 1072 | Intent::BeginImport => begin_import(state), | |
| 1079 | 1073 | Intent::StopImport => state.cancel_import(), | |
| @@ -2517,8 +2511,9 @@ | |||
| 2517 | 2511 | /// The host half of `Outcome::Locate`, which quasi ruled and shipped in 0.60 | |
| 2518 | 2512 | /// (`ec92f9cb`, Max: the route says a place is wanted, the host performs the | |
| 2519 | 2513 | /// picker, and back comes a handle and a label). audiofiles is the app the | |
| 2520 | - | /// ruling was measured against: seven sites here open a native picker, and the | |
| 2521 | - | /// export destination is the one that is part of a form. | |
| 2514 | + | /// ruling was measured against: seven sites here opened a native picker of | |
| 2515 | + | /// their own, and two still do for reasons `quasi`'s header names. The export | |
| 2516 | + | /// destination is the one ask that is part of a form. | |
| 2522 | 2517 | /// | |
| 2523 | 2518 | /// **Nothing is drawn and nothing navigates.** `ui::dialog` runs the picker off | |
| 2524 | 2519 | /// the frame thread and answers frames later through a handler that holds |
| @@ -5758,7 +5758,7 @@ | |||
| 5758 | 5758 | fn open_folder(&self, _folder: &str) {} | |
| 5759 | 5759 | fn open_quickly(&self, _folder: &str) {} | |
| 5760 | 5760 | fn open_files(&self) {} | |
| 5761 | - | fn change_source(&self) {} | |
| 5761 | + | fn change_source(&self, _folder: &str) {} | |
| 5762 | 5762 | fn decide(&self, _decision: Decision, _value: &str) {} | |
| 5763 | 5763 | fn begin(&self) {} | |
| 5764 | 5764 | fn stop(&self) {} | |
| @@ -6424,8 +6424,8 @@ | |||
| 6424 | 6424 | self.say("open:files"); | |
| 6425 | 6425 | } | |
| 6426 | 6426 | ||
| 6427 | - | fn change_source(&self) { | |
| 6428 | - | self.say("open:source"); | |
| 6427 | + | fn change_source(&self, folder: &str) { | |
| 6428 | + | self.say(format!("open:source {folder}")); | |
| 6429 | 6429 | } | |
| 6430 | 6430 | ||
| 6431 | 6431 | fn decide(&self, decision: Decision, value: &str) { | |
| @@ -8155,19 +8155,19 @@ | |||
| 8155 | 8155 | } | |
| 8156 | 8156 | ||
| 8157 | 8157 | #[test] | |
| 8158 | - | fn the_doors_hand_off_to_the_host_and_say_where_the_answer_will_be() { | |
| 8159 | - | // No outcome means "nothing here changed", so they answer the flow, which | |
| 8160 | - | // is right by the time the picker returns. See the module header. | |
| 8161 | - | for (address, expected) in [("/import/open/files", "open:files")] { | |
| 8162 | - | let import = FakeImport::default(); | |
| 8163 | - | let response = importing(&import, Request::post(address)).unwrap(); | |
| 8164 | - | assert!( | |
| 8165 | - | matches!(&response.outcome, Outcome::Goto(action) if action.destination.as_str() == "/import"), | |
| 8166 | - | "{address} answered {:?}", | |
| 8167 | - | response.outcome | |
| 8168 | - | ); | |
| 8169 | - | assert_eq!(import.answered(), [expected]); | |
| 8170 | - | } | |
| 8158 | + | fn the_files_door_hands_off_to_the_host_and_says_where_the_answer_will_be() { | |
| 8159 | + | // The one door that opens its own picker, because the batch it hands to | |
| 8160 | + | // `start_files_import` is what keeps the hashing off the GUI thread. No | |
| 8161 | + | // outcome means "nothing here changed", so it answers the flow, which is | |
| 8162 | + | // right by the time the picker returns. See the module header. | |
| 8163 | + | let import = FakeImport::default(); | |
| 8164 | + | let response = importing(&import, Request::post("/import/open/files")).unwrap(); | |
| 8165 | + | assert!( | |
| 8166 | + | matches!(&response.outcome, Outcome::Goto(action) if action.destination.as_str() == "/import"), | |
| 8167 | + | "answered {:?}", | |
| 8168 | + | response.outcome | |
| 8169 | + | ); | |
| 8170 | + | assert_eq!(import.answered(), ["open:files"]); | |
| 8171 | 8171 | } | |
| 8172 | 8172 | ||
| 8173 | 8173 | #[test] | |
| @@ -8223,8 +8223,17 @@ | |||
| 8223 | 8223 | #[test] | |
| 8224 | 8224 | fn the_source_can_only_be_changed_while_there_is_one_being_configured() { | |
| 8225 | 8225 | let configuring = FakeImport::at(configuring(Strategy::Flat, "", &[])); | |
| 8226 | - | importing(&configuring, Request::post("/import/source")).unwrap(); | |
| 8227 | - | assert_eq!(configuring.answered(), ["open:source"]); | |
| 8226 | + | let response = importing(&configuring, Request::post("/import/source")).unwrap(); | |
| 8227 | + | let Outcome::Locate(asking) = &response.outcome else { | |
| 8228 | + | panic!("expected an ask, got {:?}", response.outcome); | |
| 8229 | + | }; | |
| 8230 | + | assert_eq!(asking.answers.route(), Some("/import/source/chosen")); | |
| 8231 | + | assert_eq!(asking.sought, quasi_router::Sought::Folder); | |
| 8232 | + | ||
| 8233 | + | let answered = Request::post("/import/source/chosen") | |
| 8234 | + | .sending(Params::new().with("folder".to_owned(), "/mnt/other".to_owned())); | |
| 8235 | + | importing(&configuring, answered).unwrap(); | |
| 8236 | + | assert_eq!(configuring.answered(), ["open:source /mnt/other"]); | |
| 8228 | 8237 | ||
| 8229 | 8238 | let running = FakeImport::at(Stage::Copying { | |
| 8230 | 8239 | done: 1, | |
| @@ -8235,6 +8244,18 @@ | |||
| 8235 | 8244 | failures: Vec::new(), | |
| 8236 | 8245 | }); | |
| 8237 | 8246 | assert!(importing(&running, Request::post("/import/source")).is_err()); | |
| 8247 | + | // And the answer is refused too: the picker runs for as long as the reader | |
| 8248 | + | // takes over it, and the copy can start while it is open. | |
| 8249 | + | let answered = Request::post("/import/source/chosen") | |
| 8250 | + | .sending(Params::new().with("folder".to_owned(), "/mnt/other".to_owned())); | |
| 8251 | + | assert!(importing(&running, answered).is_err()); | |
| 8252 | + | } | |
| 8253 | + | ||
| 8254 | + | #[test] | |
| 8255 | + | fn backing_out_of_the_source_picker_changes_nothing() { | |
| 8256 | + | let configuring = FakeImport::at(configuring(Strategy::Flat, "", &[])); | |
| 8257 | + | importing(&configuring, Request::post("/import/source/chosen")).unwrap(); | |
| 8258 | + | assert!(configuring.answered().is_empty()); | |
| 8238 | 8259 | } | |
| 8239 | 8260 | ||
| 8240 | 8261 | #[test] |