max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+358 insertions,
-171 deletions
| @@ -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" |
| @@ -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] |