Skip to main content

max / audiofiles

Take quasi 0.63, and describe the last two pickers `Sought` gained a save shape and `Locating::answered` now takes every pick at once, so both sites this port had written off as undescribable are describable. The classifier's `.afcl` export asks with `Sought::Save`, carrying the name the reader typed into the sharing field as the dialog's suggestion, and writes on the answer. `export_filename` moves to the description with it: the suggestion is a description fact now. `Import files...` asks with `Sought::Files` and merges the whole batch in one call, which is what kept it host-side. Nothing in the import flow hands off to the host any more, so no route needs an outcome meaning "nothing changed". The host half loops no longer: `write_down` builds one call from every pick, `locate` serves the save shape through `DialogManager::save_file`, and `with_filters` turns `Accepted::Family(Audio)` into the extensions this app can decode, so a description says "audio" rather than carrying the host's list.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 01:02 UTC
Commit: 27c2495666f58aae183a63e1a06c07a2da12f053
Parent: af28ae8
8 files changed, +358 insertions, -171 deletions
M Cargo.lock +8 -8
@@ -4255,7 +4255,7 @@
4255 4255
4256 4256 [[package]]
4257 4257 name = "quasi-immediate"
4258 - version = "0.62.0"
4258 + version = "0.63.0"
4259 4259 dependencies = [
4260 4260 "docengine",
4261 4261 "egui",
@@ -4265,7 +4265,7 @@
4265 4265
4266 4266 [[package]]
4267 4267 name = "quasi-router"
4268 - version = "0.62.0"
4268 + version = "0.63.0"
4269 4269 dependencies = [
4270 4270 "makeover-layout",
4271 4271 ]
@@ -7568,19 +7568,19 @@
7568 7568
7569 7569 [[patch.unused]]
7570 7570 name = "quasi-axum"
7571 - version = "0.62.0"
7571 + version = "0.63.0"
7572 7572
7573 7573 [[patch.unused]]
7574 7574 name = "quasi-basics"
7575 - version = "0.62.0"
7575 + version = "0.63.0"
7576 7576
7577 7577 [[patch.unused]]
7578 7578 name = "quasi-http"
7579 - version = "0.62.0"
7579 + version = "0.63.0"
7580 7580
7581 7581 [[patch.unused]]
7582 7582 name = "quasi-notifs"
7583 - version = "0.62.0"
7583 + version = "0.63.0"
7584 7584
7585 7585 [[patch.unused]]
7586 7586 name = "quasi-store"
@@ -7588,11 +7588,11 @@
7588 7588
7589 7589 [[patch.unused]]
7590 7590 name = "quasi-tauri"
7591 - version = "0.62.0"
7591 + version = "0.63.0"
7592 7592
7593 7593 [[patch.unused]]
7594 7594 name = "quasi-webview"
7595 - version = "0.62.0"
7595 + version = "0.63.0"
7596 7596
7597 7597 [[patch.unused]]
7598 7598 name = "quasi-type"
M Cargo.toml +2 -2
@@ -26,8 +26,8 @@
26 26 # The described screens, which are audiofiles-browser's screens. By git URL with
27 27 # a version requirement, per the tree's rule for cross-repo deps. They were
28 28 # behind a `quasi` feature until 2026-08-25, when the last flip landed.
29 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.62" }
30 - quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.62" }
29 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" }
30 + quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.63" }
31 31 egui = { version = "0.35", default-features = false, features = ["default_fonts"] }
32 32 egui_extras = { version = "0.35", default-features = false }
33 33 eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] }
@@ -15,8 +15,8 @@
15 15 //! file itself, names it and copies it in. No bytes go anywhere near a route.
16 16 //!
17 17 //! [`Outcome::Locate`]'s `Sought::File { accept }` asks for exactly that, and
18 - //! `panel::locate` has served all three `Sought` shapes since quasi 0.60 with
19 - //! the accept-list plumbed through. So this is the same `locate`-then-act pair
18 + //! `panel::locate` has served every `Sought` shape since quasi 0.60 with the
19 + //! accept-list plumbed through, the save shape included since 0.63. So this is the same `locate`-then-act pair
20 20 //! [`storage`](super::storage)'s relocation already uses, and the paragraph
21 21 //! that held it up was written before the member it needed shipped. Third time
22 22 //! that has happened in this set of five, after Storage and Trash.
@@ -85,21 +85,30 @@
85 85 //! goes with it. That is the fifth and sixth variant of that pattern this port
86 86 //! has replaced with a builder call.
87 87 //!
88 - //! # What stays an intent because the host has to be asked
88 + //! # Both pickers are described, and the export took the longer road
89 89 //!
90 90 //! Export writes a file the reader names a place for, and import reads one.
91 - //! Export could be [`Outcome::File`] — [`settings`](super::settings)'s theme
92 - //! export is that member's first consumer here — but the classifier's export is
93 - //! built on a worker thread and answers through `BackendEvent`, so the route has
94 - //! no bytes to hand over when it answers.
95 91 //!
96 - //! **Import is converted.** It was written here citing `settings`'s bytes-in
97 - //! paragraph, and that paragraph turned out to be stale (measured 2026-08-25):
98 - //! `classifier_import_afcl` takes a *path*, so [`Outcome::Locate`]'s
99 - //! `Sought::File` covers it exactly. [`import`](fn@import) asks for an `.afcl`
100 - //! and [`imported`](fn@imported) reads it, which is the act shape
101 - //! [`advanced`](super::advanced)'s Import Theme uses. Export stays an ordinary
102 - //! act, for the reason above, and is the one picker left in this module.
92 + //! **Import** was converted first. It was written here citing `settings`'s
93 + //! bytes-in paragraph, and that paragraph turned out to be stale (measured
94 + //! 2026-08-25): `classifier_import_afcl` takes a *path*, so
95 + //! [`Outcome::Locate`]'s `Sought::File` covers it exactly.
96 + //! [`import`](fn@import) asks for an `.afcl` and [`imported`](fn@imported)
97 + //! reads it, which is the act shape [`advanced`](super::advanced)'s Import
98 + //! Theme uses.
99 + //!
100 + //! **Export** stayed an intent until quasi 0.63, and this header said it always
101 + //! would. It is a save dialog: the reader is naming a file that does not exist
102 + //! yet, and `Sought` could only ask where something already was.
103 + //! [`Outcome::File`] is not the answer either, though
104 + //! [`settings`](super::settings)'s theme export is that member's consumer here:
105 + //! the classifier's export is built on a worker thread and answers through
106 + //! `BackendEvent`, so the route has no bytes to hand over. `7fda7ae3` added `Sought::Save`, which is
107 + //! that dialog said from the description's end: [`export`](fn@export) suggests
108 + //! the name and [`exported`](fn@exported) writes to wherever the reader put it.
109 + //! The sanitising moved with it, into [`export_filename`](fn@export_filename):
110 + //! the suggestion is a description fact now, and what the reader typed into the
111 + //! sharing field is not a file name until that has been over it.
103 112 //!
104 113 //! [`Outcome::Locate`]: quasi_router::Outcome::Locate
105 114 //!
@@ -186,6 +195,7 @@
186 195 .post("/classifier/sharing/name", name_export)
187 196 .post("/classifier/sharing/include/{part}", include)
188 197 .post("/classifier/sharing/export", export)
198 + .post("/classifier/sharing/exported", exported)
189 199 .post("/classifier/sharing/import", import)
190 200 .post("/classifier/sharing/imported", imported)
191 201 .post("/classifier/layers/{id}/enabled", enable_layer)
@@ -507,14 +517,69 @@
507 517 }
508 518
509 519 /// `POST /classifier/sharing/export`
520 + ///
521 + /// Says a destination is wanted and leaves the dialog to the host. Writing the
522 + /// file is [`exported`](fn@exported)'s, on the answer.
523 + ///
524 + /// The name is a suggestion, which is the whole reason this is `Sought::Save`
525 + /// and not a folder ask with a name appended: the reader typed
526 + /// [`export_name`](fn@export_name) into the sharing section and gets to change
527 + /// it again in the dialog.
510 528 fn export(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
511 - if !state.classifier.sharing().worth_writing() {
529 + let sharing = state.classifier.sharing();
530 + if !sharing.worth_writing() {
512 531 return Err(RouteError::not_found("there is nothing to export"));
513 532 }
514 - state.classifier.export();
533 + Ok(Response::locate(Locating::new(
534 + Sought::Save {
535 + name: format!("{}{SHARED}", export_filename(&sharing.name)),
536 + accept: vec![Accepted::suffix(SHARED)],
537 + },
538 + "Export classifier",
539 + Action::post("/classifier/sharing/exported"),
540 + FILE,
541 + )))
542 + }
543 +
544 + /// `POST /classifier/sharing/exported`
545 + ///
546 + /// The same second guard [`imported`](fn@imported) carries, for the same reason:
547 + /// an empty answer means the reader backed out, and the address is reachable by
548 + /// typing.
549 + fn exported(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
550 + let file = request.payload.get(FILE).unwrap_or_default();
551 + if !file.is_empty() {
552 + state.classifier.export_to(file);
553 + }
515 554 settled(state)
516 555 }
517 556
557 + /// A file name an export can be written under.
558 + ///
559 + /// Lifted verbatim from the deleted `ui/classifier.rs`, where it was
560 + /// `sanitize_filename`, underscore and all. It sits on the description side
561 + /// because the name is now something the description says: `Sought::Save` asks
562 + /// the host to offer it, and what the reader typed into the sharing field is not
563 + /// a file name until this has been over it.
564 + fn export_filename(name: &str) -> String {
565 + let cleaned: String = name
566 + .trim()
567 + .chars()
568 + .map(|c| {
569 + if c.is_alphanumeric() || c == '-' || c == '_' {
570 + c
571 + } else {
572 + '_'
573 + }
574 + })
575 + .collect();
576 + if cleaned.is_empty() {
577 + "classifier".to_owned()
578 + } else {
579 + cleaned
580 + }
581 + }
582 +
518 583 /// `POST /classifier/sharing/import`
519 584 ///
520 585 /// Says a file is wanted and leaves the picker to the host. Reading it is
@@ -81,27 +81,29 @@
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...`** | no | one ask, many paths, and the batch is the point |
84 + //! | **`Import files...`** | yes | one ask answered with every path, since quasi 0.63 |
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 three of the four are converted:
91 + //! (`ec92f9cb`) and **all four are converted**:
92 92 //! [`open_folder`](fn@open_folder), [`open_quickly`](fn@open_quickly) and
93 93 //! [`change_source`](fn@change_source) say a folder is wanted, and the answer
94 94 //! lands on a second address that does the work.
95 95 //!
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.
96 + //! **`Import files...` was the fourth and took until quasi 0.63.**
97 + //! `start_files_import` takes every picked path in one call and hashes them off
98 + //! the GUI thread, which is the shipped menu entry's own fix, and
99 + //! `Locating::answered` used to hand back one handle at a time: converting the
100 + //! door then would have turned the batch into N one-file imports on the frame
101 + //! thread. `8a246c02` made a [`Sought::Files`](quasi_router::Sought::Files) ask
102 + //! reach its route once with every pick, repeated under one name and read with
103 + //! `Params::get_all`, so [`open_files`](fn@open_files) asks and
104 + //! [`files_chosen`](fn@files_chosen) merges the batch. Nothing here hands off to
105 + //! the host any more, which retires the second half of the finding: no route
106 + //! needs an [`Outcome`](quasi_router::Outcome) meaning "nothing here changed".
105 107 //!
106 108 //! # The preflight
107 109 //!
@@ -151,10 +153,10 @@
151 153 //! decide whether there is anything to confirm, and that is the app deciding
152 154 //! when to ask rather than a fact about the question.
153 155
154 - use quasi_router::layout::{FieldKind, Readiness, Selector, Tone};
156 + use quasi_router::layout::{Family, FieldKind, Readiness, Selector, Tone};
155 157 use quasi_router::{
156 - Act, Action, Choice, Field, Figure, Locating, Meter, Node, Outcome, Prose, RegionKind, Request,
157 - Response, RouteError, Router, Row, Screen, Slot,
158 + Accepted, Act, Action, Choice, Field, Figure, Locating, Meter, Node, Outcome, Prose,
159 + RegionKind, Request, Response, RouteError, Router, Row, Screen, Slot, Sought,
158 160 };
159 161
160 162 use super::{
@@ -190,6 +192,13 @@
190 192 /// The name a picked folder comes back under.
191 193 const FOLDER: &str = "folder";
192 194
195 + /// The name every picked file comes back under.
196 + ///
197 + /// One name however many files there are: `Sought::Files` is answered once with
198 + /// all of them, repeated under this name in pick order, and
199 + /// [`files_chosen`](fn@files_chosen) reads them with `Params::get_all`.
200 + const FILES: &str = "file";
201 +
193 202 /// The name a folder's tags are submitted under.
194 203 const TAGS: &str = "tags";
195 204
@@ -229,6 +238,7 @@
229 238 .post("/import/open/quick", open_quickly)
230 239 .post("/import/open/quick/chosen", quick_chosen)
231 240 .post("/import/open/files", open_files)
241 + .post("/import/open/files/chosen", files_chosen)
232 242 .post("/import/source", change_source)
233 243 .post("/import/source/chosen", source_chosen)
234 244 .post("/import/set/{decision}", decide)
@@ -430,8 +440,41 @@
430 440 }
431 441
432 442 /// `POST /import/open/files`
433 - fn open_files(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
434 - state.importing.open_files();
443 + ///
444 + /// Says audio files are wanted and leaves the picker to the host. Merging them
445 + /// is [`files_chosen`](fn@files_chosen)'s, on the answer.
446 + ///
447 + /// [`Family::Audio`] rather than the extension list: which suffixes this machine
448 + /// can decode is `audiofiles_core::util::AUDIO_EXTENSIONS`, and a description
449 + /// that spelled them out would be carrying the host's answer. `panel::with_filters`
450 + /// turns the family back into the dialog's one Audio filter.
451 + fn open_files(_state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
452 + Ok(Response::locate(Locating::new(
453 + Sought::Files {
454 + accept: vec![Accepted::family(Family::Audio)],
455 + },
456 + "Import files",
457 + Action::post("/import/open/files/chosen"),
458 + FILES,
459 + )))
460 + }
461 +
462 + /// `POST /import/open/files/chosen`
463 + ///
464 + /// Every file the reader picked, in one call, which is the whole reason this
465 + /// door could be converted: `start_files_import` takes the batch and hashes it
466 + /// off the GUI thread, and N one-file calls would put that work back on the
467 + /// frame thread. Nothing picked means the reader backed out, the same second
468 + /// guard [`folder_chosen`](fn@folder_chosen) carries.
469 + fn files_chosen(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
470 + let files: Vec<&str> = request
471 + .payload
472 + .get_all(FILES)
473 + .filter(|file| !file.is_empty())
474 + .collect();
475 + if !files.is_empty() {
476 + state.importing.open_files(&files);
477 + }
435 478 Ok(handed_off())
436 479 }
437 480
@@ -472,10 +515,11 @@
472 515 }
473 516 }
474 517
475 - /// What a door answers, having asked the host.
518 + /// What a door answers once the reader has picked.
476 519 ///
477 - /// See the module header: no outcome says "nothing here changed", so it says
478 - /// where the answer will be once the picker returns.
520 + /// The work the pick started is the flow, so the answer is the flow's address.
521 + /// Right by the time it lands and one frame early when the pick was of nothing
522 + /// at all, which is the standing cost `Runtime::reload` corrects.
479 523 fn handed_off() -> Response {
480 524 Response::from(Outcome::Goto(Action::get(FLOW_HOME)))
481 525 }
@@ -117,33 +117,35 @@
117 117 //! says a place is wanted, the host performs the picker, and a handle comes
118 118 //! back. The export destination is the first site here to take it, because the
119 119 //! export flip is what the ruling was measured for; `panel::locate` is the host
120 - //! half, serves every described window, and handles all three of
120 + //! half, serves every described window, and handles every
121 121 //! [`Sought`](quasi_router::Sought) with the accept-list plumbed through.
122 122 //!
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:
123 + //! **All eight sites are described now**, and the last two went over on quasi
124 + //! 0.63. Each had been held back by something the vocabulary could not say, and
125 + //! both were the measured site for the ruling that fixed it:
126 126 //!
127 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.
128 + //! suggested filename, which `Sought` could not ask for: it said where
129 + //! something *is*, not where something should go. `7fda7ae3` added
130 + //! [`Sought::Save`](quasi_router::Sought::Save), which carries the name the
131 + //! description suggests and the reader may type over. `Outcome::File` is still
132 + //! not the answer here, because the export is built on a worker and answers
133 + //! through `BackendEvent`, so the route has no bytes to hand over.
133 134 //! - **`Import files...`** ([`importing`]). It batches every picked path into one
134 135 //! `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.
136 + //! is the shipped menu entry's own fix. `Locating::answered` took one handle at
137 + //! a time, so converting it would have put the batch back on the frame thread.
138 + //! `8a246c02` made one ask answer once with every pick, under one name read
139 + //! with `Params::get_all`, and the batch survives the round trip.
137 140 //!
138 - //! **Neither wants bytes.** Every site here takes a path and reads it host-side,
139 - //! which is worth stating because `settings`'s header claimed the opposite about
140 - //! Import Theme and was written before `Outcome::Locate` existed. There is no
141 - //! bytes-in gap in this app, and no site waiting on one.
141 + //! **None of them wants bytes.** Every site here takes a path and reads it
142 + //! host-side, which is worth stating because `settings`'s header claimed the
143 + //! opposite about Import Theme and was written before `Outcome::Locate` existed.
144 + //! There is no bytes-in gap in this app, and no site waiting on one.
142 145 //!
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,
146 - //! through a closure, and the route that asked is long finished.
146 + //! The second half of the gap is gone with the last handing-off route: every
147 + //! picker site now answers with an ask and lands on a second address, so nothing
148 + //! here needs an [`Outcome`](quasi_router::Outcome) meaning "nothing changed".
147 149
148 150 // Handlers take their request by value because `quasi_router::Handler` is a
149 151 // plain `fn(&S, Request)` pointer, so the signature is the router's rather than
@@ -853,8 +855,8 @@
853 855 ImportFolder(std::path::PathBuf),
854 856 /// Index this folder with no questions asked, the reader having picked it.
855 857 QuickImportFolder(std::path::PathBuf),
856 - /// Ask the host for files, then merge them into the vault that is open.
857 - OpenImportFiles,
858 + /// Merge these files, which the reader picked, into the vault that is open.
859 + ImportFiles(Vec<std::path::PathBuf>),
858 860 /// Take this folder as the source of the import being configured.
859 861 ImportSource(std::path::PathBuf),
860 862 /// Answer one of the configure screen's three questions.
@@ -1040,8 +1042,8 @@
1040 1042 NameExport(String),
1041 1043 /// Say whether an export carries one part.
1042 1044 Include(Shareable, bool),
1043 - /// Write the export.
1044 - ExportClassifier,
1045 + /// Write the export to the file the reader named.
1046 + ExportClassifierTo(std::path::PathBuf),
1045 1047 /// Read someone else's, from the file the reader picked.
1046 1048 ImportClassifierFrom(std::path::PathBuf),
1047 1049 /// Turn a layer on or off.
@@ -3803,10 +3805,8 @@
3803 3805 /// Orphaned samples being swept up, if any are. See [`Sweep`].
3804 3806 fn sweeping(&self) -> Option<Sweep>;
3805 3807
3806 - // The doors. Three of them are answered pickers: the route asks with
3807 - // `Outcome::Locate` and the call lands with what the reader picked. The
3808 - // fourth, `open_files`, opens its own, for the reason `importing`'s header
3809 - // gives.
3808 + // The doors. All four are answered pickers: the route asks with
3809 + // `Outcome::Locate` and the call lands with what the reader picked.
3810 3810
3811 3811 /// Set the wizard up on this folder.
3812 3812 fn open_folder(&self, folder: &str);
@@ -3814,8 +3814,11 @@
3814 3814 /// Index this folder with no questions asked.
3815 3815 fn open_quickly(&self, folder: &str);
3816 3816
3817 - /// Pick files, then merge them into the vault that is open.
3818 - fn open_files(&self);
3817 + /// Merge these files into the vault that is open.
3818 + ///
3819 + /// The whole batch in one call, which is what keeps the hashing off the GUI
3820 + /// thread. See `importing::files_chosen`.
3821 + fn open_files(&self, files: &[&str]);
3819 3822
3820 3823 /// Use this folder for the import being configured instead.
3821 3824 fn change_source(&self, folder: &str);
@@ -4110,8 +4113,10 @@
4110 4113 self.push(Intent::QuickImportFolder(std::path::PathBuf::from(folder)));
4111 4114 }
4112 4115
4113 - fn open_files(&self) {
4114 - self.push(Intent::OpenImportFiles);
4116 + fn open_files(&self, files: &[&str]) {
4117 + self.push(Intent::ImportFiles(
4118 + files.iter().map(std::path::PathBuf::from).collect(),
4119 + ));
4115 4120 }
4116 4121
4117 4122 fn change_source(&self, folder: &str) {
@@ -5068,8 +5073,8 @@
5068 5073 /// Say whether an export carries this part.
5069 5074 fn include(&self, part: Shareable, on: bool);
5070 5075
5071 - /// Write the export.
5072 - fn export(&self);
5076 + /// Write the export to the file the reader named.
5077 + fn export_to(&self, path: &str);
5073 5078
5074 5079 /// Read someone else's, out of this file.
5075 5080 fn import(&self, path: &str);
@@ -5669,8 +5674,8 @@
5669 5674 self.push(Intent::Include(part, on));
5670 5675 }
5671 5676
5672 - fn export(&self) {
5673 - self.push(Intent::ExportClassifier);
5677 + fn export_to(&self, path: &str) {
5678 + self.push(Intent::ExportClassifierTo(std::path::PathBuf::from(path)));
5674 5679 }
5675 5680
5676 5681 fn import(&self, path: &str) {
@@ -95,10 +95,10 @@
95 95 /// because one of these is shared by every described window and a call
96 96 /// belongs to the runtime that raised it.
97 97 ///
98 - /// A queue rather than a slot: [`Sought::Files`] is one ask answered once
99 - /// per file, and a slot would keep the last of them.
100 - ///
101 - /// [`Sought::Files`]: quasi_router::Sought::Files
98 + /// A queue rather than a slot: several windows can have a picker out at
99 + /// once and each one's call belongs to the runtime that raised it, so a
100 + /// slot would keep the last of them. One ask still puts one call here,
101 + /// however many files came back, which is [`write_down`]'s job.
102 102 located: parking_lot::Mutex<Vec<(String, Request)>>,
103 103 /// How much room is left where an export would write.
104 104 ///
@@ -1044,28 +1044,22 @@
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 - // 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.
1047 + // All four doors are answered pickers: the route asks with
1048 + // `Outcome::Locate` and the intent lands with what came back.
1051 1049 Intent::ImportFolder(folder) => state.show_import_options(folder),
1052 1050 Intent::QuickImportFolder(folder) => state.quick_import_folder(folder),
1053 - Intent::OpenImportFiles => {
1054 - state.dialogs.pick_files(
1055 - "Import files",
1056 - &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)],
1057 - |state, paths| {
1058 - // Batched through the worker rather than hashed on the
1059 - // GUI thread, which is the shipped menu entry's own fix.
1060 - if let Some(vfs_id) = state.current_vfs_id() {
1061 - let strategy = crate::import::ImportStrategy::MergeIntoVfs {
1062 - vfs_id,
1063 - parent_id: state.nav.current_dir,
1064 - };
1065 - state.start_files_import(&paths, strategy);
1066 - }
1067 - },
1068 - );
1051 + // Batched through the worker rather than hashed on the GUI thread,
1052 + // which is the shipped menu entry's own fix. `Sought::Files` is
1053 + // answered once with every path since quasi 0.63, so the batch
1054 + // survives the conversion.
1055 + Intent::ImportFiles(paths) => {
1056 + if let Some(vfs_id) = state.current_vfs_id() {
1057 + let strategy = crate::import::ImportStrategy::MergeIntoVfs {
1058 + vfs_id,
1059 + parent_id: state.nav.current_dir,
1060 + };
1061 + state.start_files_import(&paths, strategy);
1062 + }
1069 1063 }
1070 1064 Intent::ImportSource(folder) => state.change_import_source(folder),
1071 1065 Intent::Decide(decision, value) => decide(state, decision, &value),
@@ -1583,19 +1577,12 @@
1583 1577 super::Shareable::Rules => state.classifier.export_include_rules = on,
1584 1578 super::Shareable::Thresholds => state.classifier.export_include_policy = on,
1585 1579 },
1586 - // The host act with no described step, for the reason the module
1587 - // header gives: the export is built on a worker and answers through
1588 - // `BackendEvent`, so there are no bytes to hand back when the route
1589 - // answers.
1590 - Intent::ExportClassifier => {
1591 - let file = format!("{}.afcl", export_filename(&state.classifier.export_name));
1592 - state.dialogs.save_file(
1593 - "Export classifier",
1594 - file,
1595 - &[("AF classifier", &["afcl"])],
1596 - |state, path| state.classifier_export_afcl(&path),
1597 - );
1598 - }
1580 + // The picker is `locate` above: the sharing section asks where to
1581 + // write with `Sought::Save` and this lands with the destination the
1582 + // reader named. The export itself still runs on a worker and answers
1583 + // through `BackendEvent`, which is why no bytes go back through the
1584 + // route.
1585 + Intent::ExportClassifierTo(path) => state.classifier_export_afcl(&path),
1599 1586 // The picker is `locate` above: the sharing section asks for an
1600 1587 // `.afcl` with `Outcome::Locate` and this lands with the file that
1601 1588 // came back.
@@ -1743,29 +1730,6 @@
1743 1730 }
1744 1731 }
1745 1732
1746 - /// A filename an export can be written under.
1747 - ///
1748 - /// Lifted verbatim from the deleted `ui/classifier.rs`, where it was
1749 - /// `sanitize_filename`, underscore and all.
1750 - fn export_filename(name: &str) -> String {
1751 - let cleaned: String = name
1752 - .trim()
1753 - .chars()
1754 - .map(|c| {
1755 - if c.is_alphanumeric() || c == '-' || c == '_' {
1756 - c
1757 - } else {
1758 - '_'
1759 - }
1760 - })
1761 - .collect();
1762 - if cleaned.is_empty() {
1763 - "classifier".to_owned()
1764 - } else {
1765 - cleaned
1766 - }
1767 - }
1768 -
1769 1733 /// Put a tag on the selected sample, the way the shipped panel does.
1770 1734 ///
1771 1735 /// Validated here rather than in the route, because validation is the app's:
@@ -2545,9 +2509,9 @@
2545 2509 });
2546 2510 });
2547 2511 }
2548 - // One ask, one call per file. The alternative would be a convention for
2549 - // putting several paths in one handle, which is a spelling this host
2550 - // would be inventing: `Locating::answered` takes one.
2512 + // One ask, one call, however many files came back: `Locating::answered`
2513 + // takes every pick together since quasi 0.63 (`8a246c02`), which is what
2514 + // lets the Import files door keep its batch.
2551 2515 quasi_router::Sought::Files { accept } => {
2552 2516 with_filters(&accept, |filters| {
2553 2517 state
@@ -2557,27 +2521,50 @@
2557 2521 });
2558 2522 });
2559 2523 }
2524 + // The save dialog. The name is the description's suggestion and the
2525 + // reader may type over it; what comes back is a destination nothing has
2526 + // been written to yet, which is why it lands as a handle like the rest
2527 + // rather than as bytes.
2528 + quasi_router::Sought::Save { name, accept } => {
2529 + with_filters(&accept, |filters| {
2530 + state
2531 + .dialogs
2532 + .save_file(prompt, name, filters, move |state, file| {
2533 + write_down(state, &home, &asking, [file]);
2534 + });
2535 + });
2536 + }
2560 2537 }
2561 2538 }
2562 2539
2563 - /// Note down the calls a picker's answer makes, for the frame that can make them.
2540 + /// Note down the call a picker's answer makes, for the frame that can make it.
2564 2541 ///
2565 2542 /// The handle is the path as this host spells it, which is what a folder is here
2566 2543 /// and is the whole of what the description learns about it. The label is the
2567 2544 /// same string: nothing on this host is opaque enough for the two to differ, and
2568 2545 /// a route that asked for no label never sees it.
2546 + ///
2547 + /// **One call, however many were picked.** `Locating::answered` takes every pick
2548 + /// together, so a `Sought::Files` ask reaches its route once with all of them
2549 + /// under the one name and the route reads them with `Params::get_all`. Looping
2550 + /// here is what the batched import could not survive, and there is no longer a
2551 + /// single-handle door to loop over.
2569 2552 fn write_down(
2570 2553 state: &mut BrowserState,
2571 2554 home: &str,
2572 2555 asking: &quasi_router::Locating,
2573 2556 picked: impl IntoIterator<Item = std::path::PathBuf>,
2574 2557 ) {
2575 - let mut waiting = state.described.located.lock();
2576 - for path in picked {
2558 + let picks = picked.into_iter().map(|path| {
2577 2559 let shown = path.display().to_string();
2578 - if let Some(request) = asking.answered(&shown, &shown) {
2579 - waiting.push((home.to_owned(), request));
2580 - }
2560 + quasi_router::Picked::new(shown.clone(), shown)
2561 + });
2562 + if let Some(request) = asking.answered(picks) {
2563 + state
2564 + .described
2565 + .located
2566 + .lock()
2567 + .push((home.to_owned(), request));
2581 2568 }
2582 2569 }
2583 2570
@@ -2597,10 +2584,18 @@
2597 2584 /// Run `use_them` with the filters a native dialog takes, from what a
2598 2585 /// description accepts.
2599 2586 ///
2600 - /// Only a suffix is a filter a dialog can take; a family or a media type is a
2601 - /// fact about the file rather than a list of extensions, so those offer no
2602 - /// filter and the reader picks freely. The three borrows are what
2603 - /// `ui::dialog`'s signature asks for, done once here rather than at each site.
2587 + /// Only a suffix is a filter a dialog can take directly; a media type is a fact
2588 + /// about the file rather than a list of extensions, so it offers no filter and
2589 + /// the reader picks freely. The three borrows are what `ui::dialog`'s signature
2590 + /// asks for, done once here rather than at each site.
2591 + ///
2592 + /// **A family is the host's to spell out**, and this is the one place that knows
2593 + /// how. `Accepted::Family(Audio)` is what a description can honestly say about
2594 + /// the Import files door; which suffixes count as audio is
2595 + /// `audiofiles_core::util::AUDIO_EXTENSIONS`, a fact about what this app can
2596 + /// decode, and a description that listed them would be carrying the host's
2597 + /// answer. Image and video have no list here because nothing in this app opens a
2598 + /// picker for one.
2604 2599 fn with_filters<R>(
2605 2600 accept: &[quasi_router::Accepted],
2606 2601 use_them: impl FnOnce(&[(&str, &[&str])]) -> R,
@@ -2612,6 +2607,13 @@
2612 2607 let suffix = suffix.trim_start_matches('.').to_owned();
2613 2608 Some((suffix.to_uppercase(), vec![suffix]))
2614 2609 }
2610 + quasi_router::Accepted::Family(quasi_router::layout::Family::Audio) => Some((
2611 + "Audio".to_owned(),
2612 + audiofiles_core::util::AUDIO_EXTENSIONS
2613 + .iter()
2614 + .map(|suffix| (*suffix).to_owned())
2615 + .collect(),
2616 + )),
2615 2617 // `Accepted` is `#[non_exhaustive]`, so a kind added later offers no
2616 2618 // filter rather than breaking the build.
2617 2619 _ => None,
@@ -2511,7 +2511,7 @@
2511 2511 // What the host sends back is an ordinary write, built by the crate that
2512 2512 // stated the parameter name.
2513 2513 let answered = asking
2514 - .answered("/music/out", "/music/out")
2514 + .answered([quasi_router::Picked::new("/music/out", "/music/out")])
2515 2515 .expect("a route to answer to");
2516 2516 assert_eq!(answered.path, "/export/set/destination");
2517 2517 // Handed to the router as it stands, captures and all: the host does not
@@ -5757,7 +5757,7 @@
5757 5757 fn cancel(&self) {}
5758 5758 fn open_folder(&self, _folder: &str) {}
5759 5759 fn open_quickly(&self, _folder: &str) {}
5760 - fn open_files(&self) {}
5760 + fn open_files(&self, _files: &[&str]) {}
5761 5761 fn change_source(&self, _folder: &str) {}
5762 5762 fn decide(&self, _decision: Decision, _value: &str) {}
5763 5763 fn begin(&self) {}
@@ -5985,7 +5985,7 @@
5985 5985 fn apply_folder(&self, _at: usize) {}
5986 5986 fn name_export(&self, _name: &str) {}
5987 5987 fn include(&self, _part: Shareable, _on: bool) {}
5988 - fn export(&self) {}
5988 + fn export_to(&self, _path: &str) {}
5989 5989 fn import(&self, _path: &str) {}
5990 5990 fn enable_layer(&self, _id: &str, _on: bool) {}
5991 5991 fn weigh_layer(&self, _id: &str, _weight: f64) {}
@@ -6420,8 +6420,8 @@
6420 6420 self.say(format!("open:quick {folder}"));
6421 6421 }
6422 6422
6423 - fn open_files(&self) {
6424 - self.say("open:files");
6423 + fn open_files(&self, files: &[&str]) {
6424 + self.say(format!("open:files {}", files.join(" ")));
6425 6425 }
6426 6426
6427 6427 fn change_source(&self, folder: &str) {
@@ -8155,19 +8155,46 @@
8155 8155 }
8156 8156
8157 8157 #[test]
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.
8158 + fn the_files_door_asks_the_host_for_audio_and_merges_the_whole_batch() {
8159 + // The door that could not be converted until quasi 0.63: `Sought::Files` is
8160 + // answered once with every path, so the batch `start_files_import` takes
8161 + // survives the round trip and the hashing stays off the GUI thread.
8163 8162 let import = FakeImport::default();
8164 8163 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
8164 + let Outcome::Locate(asking) = &response.outcome else {
8165 + panic!("expected an ask, got {:?}", response.outcome);
8166 + };
8167 + assert_eq!(asking.answers.route(), Some("/import/open/files/chosen"));
8168 + // The family, not this machine's extension list: `panel::with_filters` is
8169 + // what knows which suffixes audiofiles can decode.
8170 + assert_eq!(
8171 + asking.sought,
8172 + quasi_router::Sought::Files {
8173 + accept: vec![quasi_router::Accepted::family(
8174 + quasi_router::layout::Family::Audio
8175 + )],
8176 + }
8169 8177 );
8170 - assert_eq!(import.answered(), ["open:files"]);
8178 + assert!(import.answered().is_empty(), "the ask imported something");
8179 +
8180 + // One call carrying both, which is what the host's `write_down` builds.
8181 + let answered = asking
8182 + .answered([
8183 + quasi_router::Picked::new("/a/kick.wav", "/a/kick.wav"),
8184 + quasi_router::Picked::new("/a/snare.wav", "/a/snare.wav"),
8185 + ])
8186 + .expect("a route to answer to");
8187 + importing(&import, answered).unwrap();
8188 + assert_eq!(import.answered(), ["open:files /a/kick.wav /a/snare.wav"]);
8189 + }
8190 +
8191 + #[test]
8192 + fn backing_out_of_the_files_picker_imports_nothing() {
8193 + // The second guard: nothing picked means nothing to merge, and the address
8194 + // is reachable by typing.
8195 + let import = FakeImport::default();
8196 + importing(&import, Request::post("/import/open/files/chosen")).unwrap();
8197 + assert!(import.answered().is_empty());
8171 8198 }
8172 8199
8173 8200 #[test]
@@ -10867,8 +10894,8 @@
10867 10894 fn include(&self, part: Shareable, on: bool) {
10868 10895 self.note(format!("include {part:?} {on}"));
10869 10896 }
10870 - fn export(&self) {
10871 - self.note("export");
10897 + fn export_to(&self, path: &str) {
10898 + self.note(format!("export {path}"));
10872 10899 }
10873 10900 fn import(&self, path: &str) {
10874 10901 self.note(format!("import {path}"));
@@ -11461,7 +11488,51 @@
11461 11488 classifier.sharing.rules.available = true;
11462 11489 assert!(can_export(&classifier));
11463 11490 classifying(&classifier, Request::post("/classifier/sharing/export")).expect("answered");
11464 - assert_eq!(classifier.asked(), ["export"]);
11491 + assert!(
11492 + classifier.asked().is_empty(),
11493 + "the ask wrote a file before the reader had named one"
11494 + );
11495 + }
11496 +
11497 + #[test]
11498 + fn exporting_a_classifier_asks_where_to_save_and_writes_on_the_answer() {
11499 + // The save shape of `Outcome::Locate`, which arrived in quasi 0.63: the
11500 + // description suggests a name, the host runs the dialog, and the reader may
11501 + // type over it. Held back before that because `Sought` could only ask where
11502 + // something already was.
11503 + let mut classifier = FakeClassifier::default();
11504 + classifier.sharing.rules = super::Included {
11505 + wanted: true,
11506 + available: true,
11507 + };
11508 + "Drum kits!".clone_into(&mut classifier.sharing.name);
11509 +
11510 + let response =
11511 + classifying(&classifier, Request::post("/classifier/sharing/export")).expect("answered");
11512 + let Outcome::Locate(asking) = &response.outcome else {
11513 + panic!("expected an ask, got {:?}", response.outcome);
11514 + };
11515 + assert_eq!(asking.answers.route(), Some("/classifier/sharing/exported"));
11516 + let quasi_router::Sought::Save { name, accept } = &asking.sought else {
11517 + panic!("wanted somewhere to save, asked for {:?}", asking.sought);
11518 + };
11519 + // The reader's own name, made writable: a space and a bang are not
11520 + // characters this export puts in a file name.
11521 + assert_eq!(name, "Drum_kits_.afcl");
11522 + assert_eq!(accept.as_slice(), [quasi_router::Accepted::suffix(".afcl")]);
11523 +
11524 + let answered = asking
11525 + .answered([quasi_router::Picked::new("/kits/Drum_kits_.afcl", "")])
11526 + .expect("a route to answer to");
11527 + classifying(&classifier, answered).expect("answered");
11528 + assert_eq!(classifier.asked(), ["export /kits/Drum_kits_.afcl"]);
11529 + }
11530 +
11531 + #[test]
11532 + fn backing_out_of_the_export_dialog_writes_nothing() {
11533 + let classifier = FakeClassifier::default();
11534 + classifying(&classifier, Request::post("/classifier/sharing/exported")).expect("answered");
11535 + assert!(classifier.asked().is_empty());
11465 11536 }
11466 11537
11467 11538 #[test]