Skip to main content

max / audiofiles

Describe Add to Collection, the file list's last menu entry The entry was filed as a vocabulary question with three options: flatten it and the menu grows a line per collection, grow Act a child list and every renderer learns nesting for one consumer, or spend a screen and a gesture on a chooser. The premise was stale. Act::asking landed after that was written and is exactly this shape -- a control that wants a value before it fires, already carried by every renderer. So the entry is one act with one Select on it: the menu stays one line however many collections exist, and nothing was added to the vocabulary. The submenu was the wrong question; picking one of a list was the right one. Files grows add_to_collection, taking two ids where remove_from_collection takes one, because removing acts on the collection already being viewed and adding does not. A collection id that names nothing is refused rather than guessed at: the list the act offered was built when the menu opened, so a value outside it means the collection was deleted since. Four tests: the entry asks rather than nests, it is absent when there is no collection to offer, both ids reach the app, and a stale id is refused.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 00:03 UTC
Signed with PGP, not checked
Commit: b0038b876c343126a2d76654fb1853946496d38f
Parent: e3018fa
4 files changed, +186 insertions, -19 deletions
@@ -100,11 +100,11 @@
100 100
101 101 use quasi_router::layout::{Priority, Sort, Tone, Width};
102 102 use quasi_router::{
103 - Act, Action, Cell, Cells, Column, Node, RegionKind, Request, Response, RouteError, Router,
104 - Screen, Slot, Tag,
103 + Act, Action, Cell, Cells, Choice, Column, Field, Node, RegionKind, Request, Response,
104 + RouteError, Router, Screen, Slot, Tag,
105 105 };
106 106
107 - use super::{Panels, Sample};
107 + use super::{Collection, Panels, Sample};
108 108
109 109 /// The region the screen answers into.
110 110 const BODY: &str = "files-body";
@@ -118,6 +118,9 @@
118 118 const TAGS: &str = "Tags";
119 119 const PLAY: &str = "Play";
120 120
121 + /// What the Add to Collection act asks for, and what its handler reads back.
122 + const COLLECTION: &str = "collection";
123 +
121 124 /// Register this screen's routes.
122 125 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
123 126 router
@@ -141,6 +144,7 @@
141 144 .post("/files/{id}/delete", delete)
142 145 .post("/files/{id}/download", download)
143 146 .post("/files/{id}/collection/remove", remove_from_collection)
147 + .post("/files/{id}/collection/add", add_to_collection)
144 148 }
145 149
146 150 /// `GET /files`
@@ -271,6 +275,30 @@
271 275 Ok(screen(state).into())
272 276 }
273 277
278 + /// `POST /files/{id}/collection/add`
279 + ///
280 + /// The collection arrives in the payload because the act asked for it, so this
281 + /// reads a submitted value the same way a form's handler does. An id that names
282 + /// no collection is a not-found rather than a silent no-op: the list the act
283 + /// offered was built from `Library::collections`, so a value outside it means
284 + /// the collection went away between the menu opening and the press.
285 + fn add_to_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
286 + let id = id_of(&request)?;
287 + let chosen = request
288 + .payload
289 + .get(COLLECTION)
290 + .and_then(|value| value.parse::<i64>().ok())
291 + .ok_or_else(|| RouteError::not_found("no collection named"))?;
292 + let named = state
293 + .library
294 + .collections()
295 + .into_iter()
296 + .find(|collection| collection.id == chosen)
297 + .ok_or_else(|| RouteError::not_found("no such collection"))?;
298 + state.files.add_to_collection(id, named.id);
299 + Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name)))
300 + }
301 +
274 302 /// `POST /files/sort/{column}`
275 303 ///
276 304 /// The heading a user pressed. Which way it then sorts is the app's: pressing
@@ -318,14 +346,12 @@
318 346 let shown = state.files.columns();
319 347 let samples = state.files.samples();
320 348 let current = state.files.current();
321 - // Whether a collection is being shown, which decides one menu entry. Read
322 - // once for the table rather than per row: it is a fact about the screen and
323 - // every row would otherwise ask the library the same question.
324 - let collection = state
325 - .library
326 - .collections()
327 - .into_iter()
328 - .any(|collection| collection.active);
349 + // The collections, which decide two menu entries: which one to take a sample
350 + // out of, and the list to offer for putting one in. Read once for the table
351 + // rather than per row -- it is a fact about the screen, and every row would
352 + // otherwise ask the library the same question.
353 + let collections = state.library.collections();
354 + let collection = collections.iter().any(|collection| collection.active);
329 355
330 356 if samples.is_empty() {
331 357 // The sentence and the way out are both on the node rather than on the
@@ -340,7 +366,7 @@
340 366 columns: columns(state, shown),
341 367 rows: samples
342 368 .iter()
343 - .map(|sample| row(sample, shown, current, collection))
369 + .map(|sample| row(sample, shown, current, collection, &collections))
344 370 .collect(),
345 371 // Everything the app has loaded and filtered is here. Windowing
346 372 // rows it already holds is a renderer's job, which is the note in
@@ -408,6 +434,7 @@
408 434 shown: super::ColumnsShown,
409 435 current: Option<i64>,
410 436 collection: bool,
437 + collections: &[Collection],
411 438 ) -> Cells {
412 439 let mut values = vec![Cell::new(&sample.name)];
413 440 if shown.duration {
@@ -462,7 +489,7 @@
462 489
463 490 let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id)));
464 491 row.current = current == Some(sample.id);
465 - row.menu = menu(sample, collection);
492 + row.menu = menu(sample, collection, collections);
466 493 row
467 494 }
468 495
@@ -474,11 +501,6 @@
474 501 ///
475 502 /// # What is not here, and why each one is not a gap
476 503 ///
477 - /// - **Add to Collection.** The one nested entry, over a list the app holds.
478 - /// [`Cells::menu`] is flat, matching `Row::menu`, and flattening this reads
479 - /// "Add to Kicks", "Add to Breaks" for as many collections as exist. Growing
480 - /// [`Act`] a child list wants a second consumer first, which is the rule the
481 - /// menu member itself was held back by.
482 504 /// - **The selection menu and the background menu.** Eleven entries and five,
483 505 /// neither of them per-row: `draw_multi_context_menu` acts on the ticked set
484 506 /// and the empty-space menu on the folder being shown. Neither has a container
@@ -486,7 +508,22 @@
486 508 /// surface -- and described as [`Outcome::Over`](quasi_router::Outcome::Over)
487 509 /// they become app-modal overlays, which is near enough and not exact. Filed as
488 510 /// `quasi:vocabulary:anchored-menu`; see this module's header.
489 - fn menu(sample: &Sample, collection: bool) -> Vec<Act> {
511 + /// # Add to Collection, which needed no submenu after all
512 + ///
513 + /// This entry was the module's one measured hole and was filed as a vocabulary
514 + /// question with three options, all of them bad: flatten it and the menu grows
515 + /// by one line per collection, grow [`Act`] a child list and every renderer
516 + /// learns nesting for one consumer, or spend a screen and a gesture on a
517 + /// chooser.
518 + ///
519 + /// The premise was stale. [`Act::asking`] landed after that was written, and it
520 + /// is exactly this shape: a control that wants a value before it fires, carried
521 + /// by every renderer already. So the entry is one act with one
522 + /// [`FieldKind::Select`](quasi_router::layout::FieldKind::Select) on it, the
523 + /// menu stays one line however many collections exist, and nothing new was
524 + /// added to the vocabulary. A submenu was the wrong question — the nesting was
525 + /// never the point, picking one of a list was.
526 + fn menu(sample: &Sample, collection: bool, collections: &[Collection]) -> Vec<Act> {
490 527 let id = sample.id;
491 528 let at = |verb: &str| Action::post(format!("/files/{id}/{verb}"));
492 529
@@ -529,6 +566,23 @@
529 566 acts.push(Act::new("Find Similar", at("similar")).key("shift+f"));
530 567 acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d"));
531 568
569 + // A collection to put it in, if there is one. Offered for a cloud-only
570 + // sample too: membership is a fact about the sample rather than about the
571 + // bytes, which is the same reason the shipped menu guards this on the hash
572 + // and not on `cloud_only`.
573 + if !collections.is_empty() {
574 + acts.push(
575 + Act::new("Add to Collection", at("collection/add")).asking(Field::select(
576 + COLLECTION,
577 + "Collection",
578 + collections
579 + .iter()
580 + .map(|it| Choice::new(it.id.to_string(), it.name.clone()))
581 + .collect(),
582 + )),
583 + );
584 + }
585 +
532 586 if collection {
533 587 acts.push(Act::new("Remove from Collection", at("collection/remove")).tone(Tone::Danger));
534 588 }
@@ -598,6 +598,15 @@
598 598
599 599 /// Take this sample out of the collection being viewed.
600 600 fn remove_from_collection(&self, id: i64);
601 +
602 + /// Put this sample into that collection.
603 + ///
604 + /// Two ids rather than one, and that is the difference from
605 + /// [`remove_from_collection`](Self::remove_from_collection): removing acts on
606 + /// the collection already being viewed, so the screen knows which one without
607 + /// being told. Adding does not, and the collection is what the act asked the
608 + /// user for.
609 + fn add_to_collection(&self, id: i64, collection: i64);
601 610 }
602 611
603 612 /// What a described screen asked the app to do to itself.
@@ -630,6 +639,8 @@
630 639 Download(i64),
631 640 /// Take a sample out of the collection being viewed.
632 641 RemoveFromCollection(i64),
642 + /// Put a sample into a collection the user named, by sample then collection.
643 + AddToCollection(i64, i64),
633 644 /// Order by a column.
634 645 SortBy(String),
635 646 /// Change one export setting.
@@ -994,6 +1005,12 @@
994 1005 .borrow_mut()
995 1006 .push(Intent::RemoveFromCollection(id));
996 1007 }
1008 +
1009 + fn add_to_collection(&self, id: i64, collection: i64) {
1010 + self.intents
1011 + .borrow_mut()
1012 + .push(Intent::AddToCollection(id, collection));
1013 + }
997 1014 }
998 1015
999 1016 /// Where the export flow has got to.
@@ -584,6 +584,29 @@
584 584 }
585 585 }
586 586 }
587 + Intent::AddToCollection(id, collection) => {
588 + // The description carries an `i64` because a described id is a
589 + // number, so the newtype is put back here -- the same round trip
590 + // `OpenCollection` makes, and for the same reason: the
591 + // collection is found by comparing against the typed id rather
592 + // than by constructing one out of an unchecked number.
593 + let named = state
594 + .collections_ui
595 + .collections
596 + .iter()
597 + .find(|it| it.id.as_i64() == collection)
598 + .map(|it| (it.id, it.name.clone()));
599 + if let Some((collection, name)) = named
600 + && let Some(at) = index_of(state, id)
601 + {
602 + state.nav.selection.set_single(at);
603 + if let Some(hash) = selected_hash(state) {
604 + let _ = state.backend.add_to_collection(collection, &hash);
605 + state.refresh_collections();
606 + state.status = format!("Added to {name}");
607 + }
608 + }
609 + }
587 610 Intent::SortBy(column) => {
588 611 let key = match column.as_str() {
589 612 "Name" => crate::state::SortColumn::Name,
@@ -145,6 +145,11 @@
145 145 fn remove_from_collection(&self, id: i64) {
146 146 self.asked.borrow_mut().push(format!("uncollect:{id}"));
147 147 }
148 + fn add_to_collection(&self, id: i64, collection: i64) {
149 + self.asked
150 + .borrow_mut()
151 + .push(format!("collect:{id}->{collection}"));
152 + }
148 153 }
149 154
150 155 /// An export flow that is not running, for the screens that are not about one.
@@ -1805,6 +1810,74 @@
1805 1810 assert!(labels.contains(&"Remove from Collection"), "{labels:?}");
1806 1811 }
1807 1812
1813 + #[test]
1814 + fn add_to_collection_is_one_entry_that_asks_which_one_rather_than_a_submenu() {
1815 + // The member this entry waited on was never a submenu. `Act::asking` is a
1816 + // control that wants a value before it fires, so the menu holds one line
1817 + // however many collections exist, and the list is on the act.
1818 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1819 + let response = listing_in_collection(&files, Request::get("/files")).expect("answered");
1820 + let (_, rows) = table_of(screen_of(&response));
1821 +
1822 + let add = rows[0]
1823 + .menu
1824 + .iter()
1825 + .find(|act| act.label == "Add to Collection")
1826 + .expect("the entry is offered when there is a collection to offer");
1827 +
1828 + assert_eq!(add.action.destination.as_str(), "/files/7/collection/add");
1829 + let asked = add.asks.first().expect("it asks which collection");
1830 + assert_eq!(asked.name, "collection");
1831 + assert_eq!(asked.kind, quasi_router::layout::FieldKind::Select);
1832 + // The value is the id and the label is the name, so the handler reads a
1833 + // number and the user reads a collection.
1834 + let offered: Vec<(&str, &str)> = asked
1835 + .options
1836 + .iter()
1837 + .map(|choice| (choice.value.as_str(), choice.label.as_str()))
1838 + .collect();
1839 + assert_eq!(offered, [("3", "Kicks")]);
1840 + }
1841 +
1842 + #[test]
1843 + fn nothing_offers_add_to_collection_when_there_is_no_collection() {
1844 + // Not drawn dead: an act asking a question with no answers is a control the
1845 + // user can press and cannot satisfy.
1846 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1847 + let response = listing(&files, Request::get("/files")).expect("answered");
1848 + let (_, rows) = table_of(screen_of(&response));
1849 +
1850 + let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
1851 + assert!(!labels.contains(&"Add to Collection"), "{labels:?}");
1852 + }
1853 +
1854 + #[test]
1855 + fn adding_to_a_collection_carries_both_ids_to_the_app() {
1856 + // The row comes from the address and the collection from what the act
1857 + // asked, which is the whole difference between this entry and every other
1858 + // one in the menu.
1859 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1860 + let request = Request::post("/files/7/collection/add")
1861 + .sending(Params::new().with("collection".to_owned(), "3".to_owned()));
1862 + listing_in_collection(&files, request).expect("answered");
1863 +
1864 + assert_eq!(files.asked(), ["collect:7->3"]);
1865 + }
1866 +
1867 + #[test]
1868 + fn a_collection_that_went_away_is_refused_rather_than_guessed_at() {
1869 + // The list the act offered was built when the menu opened. A value outside
1870 + // it means the collection was deleted since, and adding to a collection
1871 + // that is not there is not something to do quietly.
1872 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1873 + let request = Request::post("/files/7/collection/add")
1874 + .sending(Params::new().with("collection".to_owned(), "99".to_owned()));
1875 + let refused = listing_in_collection(&files, request);
1876 +
1877 + assert!(refused.is_err(), "an unknown collection is not an add");
1878 + assert!(files.asked().is_empty(), "and nothing reached the app");
1879 + }
1880 +
1808 1881 #[test]
1809 1882 fn every_menu_entry_reaches_the_app_at_the_row_it_was_opened_on() {
1810 1883 // The whole point of the member: the acts are addresses, and pressing one