Skip to main content

max / audiofiles

Import folder asks through Outcome::Locate The wizard door says a folder is wanted and the answer lands on /import/open/folder/chosen, which sets the wizard up on it. The intent that opened the picker is gone, and the module header names the door as converted rather than as a consumer of a closed gap.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 00:30 UTC
Signed with PGP, not checked
Commit: 3d5a6c65cd9f2f59f753e26208b629d85408ae00
Parent: 8941901
5 files changed, +96 insertions, -45 deletions
M Cargo.lock +12 -12
@@ -7554,6 +7554,18 @@
7554 7554 "winnow 1.0.4",
7555 7555 ]
7556 7556
7557 + [[patch.unused]]
7558 + name = "kberg"
7559 + version = "0.1.0"
7560 +
7561 + [[patch.unused]]
7562 + name = "ops-status"
7563 + version = "0.1.0"
7564 +
7565 + [[patch.unused]]
7566 + name = "painhours"
7567 + version = "0.1.0"
7568 +
7557 7569 [[patch.unused]]
7558 7570 name = "quasi-axum"
7559 7571 version = "0.62.0"
@@ -7582,18 +7594,6 @@
7582 7594 name = "quasi-webview"
7583 7595 version = "0.62.0"
7584 7596
7585 - [[patch.unused]]
7586 - name = "kberg"
7587 - version = "0.1.0"
7588 -
7589 - [[patch.unused]]
7590 - name = "ops-status"
7591 - version = "0.1.0"
7592 -
7593 - [[patch.unused]]
7594 - name = "painhours"
7595 - version = "0.1.0"
7596 -
7597 7597 [[patch.unused]]
7598 7598 name = "quasi-type"
7599 7599 version = "0.1.0"
@@ -81,18 +81,23 @@
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 - //! | **the four native pickers** | no | `quasi:vocabulary:host-save-location`, consumers five to eight |
84 + //! | **`Import files...`**, and two doors not converted yet | no | see below |
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 - //! then act, which is the gap `integrity` filed as its fourth consumer. **Four
90 - //! more arrive here at once**, and with them a second half of the same gap that
91 - //! nothing had hit yet: a route that hands off to the host has nothing true to
92 - //! answer. [`Outcome`](quasi_router::Outcome) is `Screen`, `Fragment`, `Goto` or
93 - //! `Over`, and none of them is "nothing here changed". They answer
94 - //! `Goto(/import)` — right by the time the picker returns, and a frame early,
95 - //! which is the standing cost `Runtime::reload` corrects.
89 + //! then act, which this header filed as consumers five to eight of
90 + //! `quasi:vocabulary:host-save-location`. That gap closed in quasi 0.60
91 + //! (`ec92f9cb`) and `Import folder...` is converted: [`open_folder`](fn@open_folder)
92 + //! says a folder is wanted and [`folder_chosen`](fn@folder_chosen) sets the
93 + //! wizard up on the answer.
94 + //!
95 + //! The other three still hand off, and a route that hands off to the host has
96 + //! nothing true to answer. [`Outcome`](quasi_router::Outcome) is `Screen`,
97 + //! `Fragment`, `Goto` or `Over`, and none of them is "nothing here changed".
98 + //! They answer `Goto(/import)`, right by the time the picker returns and a
99 + //! frame early when it is pressed, which is the standing cost
100 + //! `Runtime::reload` corrects.
96 101 //!
97 102 //! # The preflight
98 103 //!
@@ -144,8 +149,8 @@
144 149
145 150 use quasi_router::layout::{FieldKind, Readiness, Selector, Tone};
146 151 use quasi_router::{
147 - Act, Action, Choice, Field, Figure, Meter, Node, Outcome, Prose, RegionKind, Request, Response,
148 - RouteError, Router, Row, Screen, Slot,
152 + Act, Action, Choice, Field, Figure, Locating, Meter, Node, Outcome, Prose, RegionKind, Request,
153 + Response, RouteError, Router, Row, Screen, Slot,
149 154 };
150 155
151 156 use super::{
@@ -178,6 +183,9 @@
178 183 /// The name the "don't ask again" answer is submitted under.
179 184 const AGAIN: &str = "again";
180 185
186 + /// The name a picked folder comes back under.
187 + const FOLDER: &str = "folder";
188 +
181 189 /// The name a folder's tags are submitted under.
182 190 const TAGS: &str = "tags";
183 191
@@ -213,6 +221,7 @@
213 221 .get("/import", index)
214 222 .get("/import/open", open)
215 223 .post("/import/open/folder", open_folder)
224 + .post("/import/open/folder/chosen", folder_chosen)
216 225 .post("/import/open/quick", open_quickly)
217 226 .post("/import/open/files", open_files)
218 227 .post("/import/source", change_source)
@@ -366,8 +375,27 @@
366 375 }
367 376
368 377 /// `POST /import/open/folder`
369 - fn open_folder(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
370 - state.importing.open_folder();
378 + ///
379 + /// Says a folder is wanted and leaves the picker to the host. Setting the
380 + /// wizard up is [`folder_chosen`](fn@folder_chosen)'s, on the answer.
381 + fn open_folder(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
382 + Ok(Response::locate(Locating::folder(
383 + "Import folder",
384 + Action::post("/import/open/folder/chosen"),
385 + FOLDER,
386 + )))
387 + }
388 +
389 + /// `POST /import/open/folder/chosen`
390 + ///
391 + /// A reader who backed out of the picker has answered nothing: `ui::dialog`
392 + /// skips its handler on an empty result, so this is the second guard rather than
393 + /// the only one, and it is here because the address is reachable by typing.
394 + fn folder_chosen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
395 + let folder = request.payload.get(FOLDER).unwrap_or_default();
396 + if !folder.is_empty() {
397 + state.importing.open_folder(folder);
398 + }
371 399 Ok(handed_off())
372 400 }
373 401
@@ -840,8 +840,8 @@
840 840 AcceptImport { again: bool },
841 841 /// Drop the import that is waiting.
842 842 CancelImport,
843 - /// Ask the host for a folder, then set the wizard up on it.
844 - OpenImportFolder,
843 + /// Set the wizard up on this folder, which the reader picked.
844 + ImportFolder(std::path::PathBuf),
845 845 /// Ask the host for a folder, then index it with no questions asked.
846 846 OpenQuickImport,
847 847 /// Ask the host for files, then merge them into the vault that is open.
@@ -3794,12 +3794,13 @@
3794 3794 /// Orphaned samples being swept up, if any are. See [`Sweep`].
3795 3795 fn sweeping(&self) -> Option<Sweep>;
3796 3796
3797 - // The doors. Each opens a native picker and then acts, which is a host act
3798 - // with no described step: see this module's `integrity` header, and
3799 - // `importing`'s for why three more consumers of it arrive at once.
3797 + // The doors. Three of them are answered pickers: the route asks with
3798 + // `Outcome::Locate` and the call lands with what the reader picked. The
3799 + // fourth, `open_files`, opens its own, for the reason `importing`'s header
3800 + // gives.
3800 3801
3801 - /// Pick a folder, then set the wizard up on it.
3802 - fn open_folder(&self);
3802 + /// Set the wizard up on this folder.
3803 + fn open_folder(&self, folder: &str);
3803 3804
3804 3805 /// Pick a folder, then index it with no questions asked.
3805 3806 fn open_quickly(&self);
@@ -4092,8 +4093,8 @@
4092 4093 }
4093 4094 }
4094 4095
4095 - fn open_folder(&self) {
4096 - self.push(Intent::OpenImportFolder);
4096 + fn open_folder(&self, folder: &str) {
4097 + self.push(Intent::ImportFolder(std::path::PathBuf::from(folder)));
4097 4098 }
4098 4099
4099 4100 fn open_quickly(&self) {
@@ -1044,14 +1044,11 @@
1044 1044 // is the app's own screen state, so the whole capability writes
1045 1045 // through intents -- see `Importing`'s header.
1046 1046 //
1047 - // The four doors are host acts with no described step, which is
1048 - // `quasi:vocabulary:host-save-location`'s fifth to eighth consumers
1049 - // and the same shape `LocateLooseFiles` takes below.
1050 - Intent::OpenImportFolder => {
1051 - state
1052 - .dialogs
1053 - .pick_folder("Import folder", BrowserState::show_import_options);
1054 - }
1047 + // Three of the four doors are answered pickers: the route asks
1048 + // with `Outcome::Locate` and the intent lands with what came back.
1049 + // `OpenImportFiles` opens its own, for the reason `importing`'s
1050 + // header gives.
1051 + Intent::ImportFolder(folder) => state.show_import_options(folder),
1055 1052 Intent::OpenQuickImport => {
1056 1053 state
1057 1054 .dialogs
@@ -5755,7 +5755,7 @@
5755 5755
5756 5756 fn accept(&self, _again: bool) {}
5757 5757 fn cancel(&self) {}
5758 - fn open_folder(&self) {}
5758 + fn open_folder(&self, _folder: &str) {}
5759 5759 fn open_quickly(&self) {}
5760 5760 fn open_files(&self) {}
5761 5761 fn change_source(&self) {}
@@ -6412,8 +6412,8 @@
6412 6412 self.say("cancel");
6413 6413 }
6414 6414
6415 - fn open_folder(&self) {
6416 - self.say("open:folder");
6415 + fn open_folder(&self, folder: &str) {
6416 + self.say(format!("open:folder {folder}"));
6417 6417 }
6418 6418
6419 6419 fn open_quickly(&self) {
@@ -8159,7 +8159,6 @@
8159 8159 // No outcome means "nothing here changed", so they answer the flow, which
8160 8160 // is right by the time the picker returns. See the module header.
8161 8161 for (address, expected) in [
8162 - ("/import/open/folder", "open:folder"),
8163 8162 ("/import/open/quick", "open:quick"),
8164 8163 ("/import/open/files", "open:files"),
8165 8164 ] {
@@ -8174,6 +8173,32 @@
8174 8173 }
8175 8174 }
8176 8175
8176 + #[test]
8177 + fn the_wizard_door_asks_the_host_for_a_folder_and_opens_on_the_answer() {
8178 + // The act shape of `Outcome::Locate`: picking the folder *is* opening the
8179 + // wizard, so the call that lands sets it up rather than stashing a path.
8180 + let import = FakeImport::default();
8181 + let response = importing(&import, Request::post("/import/open/folder")).unwrap();
8182 + let Outcome::Locate(asking) = &response.outcome else {
8183 + panic!("expected an ask, got {:?}", response.outcome);
8184 + };
8185 + assert_eq!(asking.answers.route(), Some("/import/open/folder/chosen"));
8186 + assert_eq!(asking.sought, quasi_router::Sought::Folder);
8187 + assert!(import.answered().is_empty(), "the ask opened something");
8188 +
8189 + let answered = Request::post("/import/open/folder/chosen")
8190 + .sending(Params::new().with("folder".to_owned(), "/mnt/kit".to_owned()));
8191 + importing(&import, answered).unwrap();
8192 + assert_eq!(import.answered(), ["open:folder /mnt/kit"]);
8193 + }
8194 +
8195 + #[test]
8196 + fn backing_out_of_the_wizard_picker_opens_nothing() {
8197 + let import = FakeImport::default();
8198 + importing(&import, Request::post("/import/open/folder/chosen")).unwrap();
8199 + assert!(import.answered().is_empty());
8200 + }
8201 +
8177 8202 #[test]
8178 8203 fn the_source_can_only_be_changed_while_there_is_one_being_configured() {
8179 8204 let configuring = FakeImport::at(configuring(Strategy::Flat, "", &[]));