Skip to main content

max / audiofiles

Change import source asks through Outcome::Locate The configure screen's source door says a folder is wanted and the answer lands on /import/source/chosen, which takes it. The stage guard is checked on both halves, since the copy can start while the picker is open. That is the last of the five conversions: quasi's header now names the two sites still opening their own picker, the classifier export because Sought has no save shape and Import files because its batch is what keeps hashing off the GUI thread.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 00:33 UTC
Signed with PGP, not checked
Commit: af28ae8a3639bee6965d8c95bf760347c1009e65
Parent: 40e6663
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]