Skip to main content

max / audiofiles

Restore the selection menu and the empty-space menu Both had been unreachable since `49b7429` flipped the main window to the described screen: it describes the row menu through `Cells::menu` and said plainly that it described neither of the other two, and the shipped call sites went with the old file list. audiofiles `41996627`, and the `da48cb6d` bargain it was filed under -- losing a feature quietly is allowed temporarily and only while it has a task. The two turn out to want different subjects, and the ruling's premise was stale for one of them. quasi 0.77 ships `Anchor::Selection` for the selection menu; it resolves against the *description's* selection -- `Screen::selection` named, rows ticked -- and this list's selection is the app's own, live and unticked, which `row` records and does not intend to change. So the anchor would have resolved to nothing and the menu would have opened away from its subject. Ruled by Max 2026-08-29 on the measurement: - The selection menu hangs off the row the press landed on, which is what the shipped list did at exactly this fork (`selection.count() > 1 && contains(row_idx)`). Eleven entries, `Cells::menu` again. - The empty-space menu is `Outcome::Anchored` at `Anchor::Region`, the app's first, and its gesture is the host's: the absence of a row is not a described thing, so nothing in a description can answer a press on one. `panel::menued` reads it off `quasi_immediate::row_at` and raises the address. `Sample::selected` is what the fork reads, and nothing shows it: `Cells` marks the current row and holds a staged tick, and a live multi-selection is neither, so a five-hundred-row Cmd+A draws the same as a one-row click. Measured and filed against quasicoherent rather than papered over here. Also fixed on the way past: `panel::index_of` returned a position in `nav.contents` and fed it to `nav.selection`, which indexes the rows the list shows and is one longer while there is a `..` to go back to. Every row menu entry inside a folder was acting on the row above the one pressed. `ui/file_list_menus.rs` keeps only what the system does and the description cannot say: the reveal spawn table and the OS drag.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_0153yKAYUmoC9bZz2eV6cA88
Author: Max Johnson <me@maxj.phd> · 2026-08-29 15:32 UTC
Signed with PGP, not checked
Commit: 2317f9fac3a8892233b2a6c827b58f74455475a3
Parent: 3b2e153
6 files changed, +967 insertions, -395 deletions
M Cargo.lock +16 -16
@@ -7556,22 +7556,6 @@
7556 7556 "winnow 1.0.4",
7557 7557 ]
7558 7558
7559 - [[patch.unused]]
7560 - name = "quasi-type"
7561 - version = "0.1.2"
7562 -
7563 - [[patch.unused]]
7564 - name = "kberg"
7565 - version = "0.1.0"
7566 -
7567 - [[patch.unused]]
7568 - name = "ops-status"
7569 - version = "0.1.0"
7570 -
7571 - [[patch.unused]]
7572 - name = "painhours"
7573 - version = "0.1.0"
7574 -
7575 7559 [[patch.unused]]
7576 7560 name = "quasi-axum"
7577 7561 version = "0.77.0"
@@ -7599,3 +7583,19 @@
7599 7583 [[patch.unused]]
7600 7584 name = "quasi-webview"
7601 7585 version = "0.77.0"
7586 +
7587 + [[patch.unused]]
7588 + name = "quasi-type"
7589 + version = "0.1.2"
7590 +
7591 + [[patch.unused]]
7592 + name = "kberg"
7593 + version = "0.1.0"
7594 +
7595 + [[patch.unused]]
7596 + name = "ops-status"
7597 + version = "0.1.0"
7598 +
7599 + [[patch.unused]]
7600 + name = "painhours"
7601 + version = "0.1.0"
@@ -83,22 +83,35 @@
83 83 //! Kicks", "Add to Breaks" for as many collections as exist, which is honest and
84 84 //! gets long.
85 85 //!
86 - //! **The other two have no container at all.** A menu over the *selection* and a
87 - //! menu over the *surface* are not per-row, and nothing in the vocabulary holds
88 - //! acts back until a host asks for them except `Row::menu`. Described as
89 - //! [`Outcome::Over`](quasi_router::Outcome::Over) they become app-modal
90 - //! overlays, which is what [`toolbar`](super::toolbar) already recorded of the
91 - //! save-as-collection popover — "near enough and not exact" — and what
92 - //! [`importing`](super::importing) took for the Import menu as its second
93 - //! consumer. These are the third and fourth, and they are the ones that make the
94 - //! shape clear: an anchored menu is not a modal, and its subject is whatever it
95 - //! opened over. Filed as `quasi:vocabulary:anchored-menu`.
86 + //! **The other two had no container at all, and now have two different ones.**
87 + //! Restored 2026-08-29 (audiofiles `41996627`) after they had been unreachable
88 + //! since the flip. What was missing was never the contents — five of their
89 + //! sixteen entries are addresses this app already answered, [`bulk`](super::bulk)'s three
90 + //! modals and [`naming`](super::naming)'s and [`importing`](super::importing)'s doors — it was the gesture and
91 + //! the subject, and the two menus turn out to differ in both.
96 92 //!
97 - //! Two of the eleven selection entries and three of the five background entries
98 - //! are described already, at addresses of their own —
99 - //! [`bulk`](super::bulk)'s three modals, [`naming`](super::naming)'s New Folder,
100 - //! [`importing`](super::importing)'s two doors. So what is missing is never the
101 - //! contents. It is the gesture and the anchor, both times.
93 + //! - **The selection menu hangs off the row the press landed on**, as
94 + //! `chosen_menu` below. quasi 0.77 ships
95 + //! [`Anchor::Selection`](quasi_router::Anchor::Selection) for exactly this and
96 + //! it does not fit here: that member resolves against the *description's*
97 + //! selection — `Screen::selection` named, rows ticked — and this list's
98 + //! selection is the app's own, live and unticked, for the reason `row` below
99 + //! records. So the anchor would resolve to nothing and the menu would open
100 + //! away from its subject. Ruled by Max 2026-08-29 on the measurement; the row
101 + //! the press landed on is the anchor, which is what the shipped list did.
102 + //! - **The empty-space menu is anchored at the region**, as `background_menu`,
103 + //! because a press on nothing has no row. It is the app's first
104 + //! [`Outcome::Anchored`](quasi_router::Outcome::Anchored), and its gesture is
105 + //! the host's — `panel::menued` — since the absence of a row is not a
106 + //! described thing for a description to answer about.
107 + //!
108 + //! What is still owed, and it is the reason the first of those went the way it
109 + //! did: **a live multi-selection has no described shape.** `Cells` marks the
110 + //! current row and holds a tick, a tick is a staged set awaiting a commit
111 + //! control, and this list's selection is neither — so a five-hundred-row Cmd+A
112 + //! draws the same as a one-row click, and [`Sample::selected`] is read by the
113 + //! menu fork and shown nowhere. Measured 2026-08-29 and filed against
114 + //! quasicoherent.
102 115 //! - **Virtual scrolling.** Recorded in the findings note as renderer policy
103 116 //! from the start: windowing rows the app already holds is a performance
104 117 //! technique, not a described fact.
@@ -155,6 +168,22 @@
155 168 .post("/files/{id}/download", download)
156 169 .post("/files/{id}/collection/remove", remove_from_collection)
157 170 .post("/files/{id}/collection/add", add_to_collection)
171 + // The selection menu and the empty-space menu, restored 2026-08-29.
172 + // Every address under `/files/chosen` acts on the set rather than on a
173 + // row, which is why none of them carries an id.
174 + .post("/files/chosen/invert", invert_chosen)
175 + .post("/files/chosen/clear", deselect)
176 + .post("/files/chosen/export", export_chosen)
177 + .post("/files/chosen/reanalyze", reanalyze_chosen)
178 + .post("/files/chosen/path/copy", copy_chosen_paths)
179 + .post("/files/chosen/tags/copy", copy_tags_from_focus)
180 + .post("/files/chosen/delete", delete_chosen)
181 + .post("/files/chosen/collection/add", add_chosen_to_collection)
182 + .post(
183 + "/files/chosen/collection/remove",
184 + remove_chosen_from_collection,
185 + )
186 + .get("/files/menu/background", background_menu)
158 187 }
159 188
160 189 /// `GET /files`
@@ -320,6 +349,162 @@
320 349 Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name)))
321 350 }
322 351
352 + // --- the selection, which is the whole set rather than a row ---
353 +
354 + /// `POST /files/chosen/invert`
355 + fn invert_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
356 + state.files.invert_chosen();
357 + Ok(screen(state).into())
358 + }
359 +
360 + /// `POST /files/chosen/clear`
361 + fn deselect(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
362 + state.files.deselect();
363 + Ok(screen(state).into())
364 + }
365 +
366 + /// `POST /files/chosen/export`
367 + ///
368 + /// [`export`](fn@export)'s answer for a set: the flow opens on what is chosen
369 + /// and the screen it opens is a place, so this goes there rather than answering
370 + /// a changed file list.
371 + fn export_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
372 + state.files.export_chosen();
373 + Ok(Response::from(quasi_router::Outcome::Goto(Action::get(
374 + "/export",
375 + ))))
376 + }
377 +
378 + /// `POST /files/chosen/reanalyze`
379 + fn reanalyze_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
380 + state.files.reanalyze_chosen();
381 + Ok(screen(state).into())
382 + }
383 +
384 + /// `POST /files/chosen/path/copy`
385 + ///
386 + /// The clipboard is the host's, so what the toast can honestly say is that the
387 + /// paths went. Which paths, and how many, is the status line the host writes
388 + /// when it applies the intent -- the shipped menu names the first one so a
389 + /// reader can recognise what they are about to paste, and that is a fact this
390 + /// route does not hold.
391 + fn copy_chosen_paths(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
392 + state.files.copy_chosen_paths();
393 + Ok(screen(state).into())
394 + }
395 +
396 + /// `POST /files/chosen/tags/copy`
397 + fn copy_tags_from_focus(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
398 + state.files.copy_tags_from_focus();
399 + Ok(screen(state).into())
400 + }
401 +
402 + /// `POST /files/chosen/delete`
403 + ///
404 + /// [`delete`](fn@delete)'s reading, one level up: the act asks nothing and the
405 + /// app raises its own counted dialog, because how much is about to go is what a
406 + /// reader needs when the subject is a set.
407 + fn delete_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
408 + state.files.delete_chosen();
409 + Ok(screen(state).into())
410 + }
411 +
412 + /// `POST /files/chosen/collection/add`
413 + fn add_chosen_to_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
414 + let chosen = request
415 + .payload
416 + .get(COLLECTION)
417 + .and_then(|value| value.parse::<i64>().ok())
418 + .ok_or_else(|| RouteError::not_found("no collection named"))?;
419 + let named = state
420 + .library
421 + .collections()
422 + .into_iter()
423 + .find(|collection| collection.id == chosen)
424 + .ok_or_else(|| RouteError::not_found("no such collection"))?;
425 + state.files.add_chosen_to_collection(named.id);
426 + Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name)))
427 + }
428 +
429 + /// `POST /files/chosen/collection/remove`
430 + fn remove_chosen_from_collection(
431 + state: &Panels<'_>,
432 + _request: Request,
433 + ) -> Result<Response, RouteError> {
434 + state.files.remove_chosen_from_collection();
435 + Ok(screen(state).into())
436 + }
437 +
438 + /// `GET /files/menu/background`
439 + ///
440 + /// The empty-space menu, and the one of the two that really is
441 + /// [`Outcome::Anchored`](quasi_router::Outcome::Anchored): its subject is the
442 + /// region under the pointer rather than anything in it, so
443 + /// [`Anchor::Region`](quasi_router::Anchor::Region) names the list's own slot
444 + /// and every renderer resolves it with geometry it already has.
445 + ///
446 + /// Raised by `panel::menued`, which is the host half. A press on nothing is not
447 + /// a described gesture and cannot become one: a row answers a menu because a row
448 + /// is a described thing, and the absence of a row is not.
449 + ///
450 + /// Three of the five entries are addresses the app already answers, which is the
451 + /// same accounting the row menu keeps: New Folder is `naming`'s and the two
452 + /// import doors are `importing`'s. Only the two that act on the selection are
453 + /// this module's, and they are the ones the shipped menu withheld when nothing
454 + /// was chosen.
455 + fn background_menu(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
456 + let chosen = state
457 + .files
458 + .samples()
459 + .iter()
460 + .filter(|sample| sample.selected)
461 + .count();
462 +
463 + let mut body = Slot::new("files-menu", RegionKind::Pane)
464 + .with(Node::Act(Act::new(
465 + "New Folder",
466 + Action::get("/folders/new"),
467 + )))
468 + .with(Node::Act(Act::new(
469 + "Import files...",
470 + Action::post("/import/open/files"),
471 + )))
472 + // The wizard door rather than the quick one, which is the shipped menu's
473 + // own choice and its reason: the toolbar offers all three, and a context
474 + // menu that offered them too would be the same fork asked twice.
475 + .with(Node::Act(Act::new(
476 + "Import folder...",
477 + Action::post("/import/open/folder"),
478 + )));
479 +
480 + if chosen > 0 {
481 + body = body
482 + // The hint stays in the label and the binding does not: Escape
483 + // clears the selection from anywhere, which is `editor.rs`'s, and
484 + // an act claiming it here would take it off the Close below.
485 + .with(Node::Act(Act::new(
486 + format!("Deselect ({chosen}) (Esc)"),
487 + Action::post("/files/chosen/clear"),
488 + )))
489 + .with(Node::Act(Act::new(
490 + "Invert Selection (Cmd+Shift+I)",
491 + Action::post("/files/chosen/invert"),
492 + )));
493 + }
494 +
495 + // The way out, which an anchored screen has no described way to say: nothing
496 + // in the vocabulary means "close what is on top", so this navigates to the
497 + // window's own home and the layer goes with the screen. `bulk`'s finding 1,
498 + // met again one screen up and answered the same way `importing`'s menu
499 + // answers it.
500 + body = body.with(Node::Act(Act::new("Close", Action::get("/")).key("esc")));
501 +
502 + Ok(Response::anchored(
503 + Screen::sidebar_content("Here").with(body),
504 + quasi_router::Anchor::Region(BODY.to_owned()),
505 + ))
506 + }
507 +
323 508 /// `POST /files/sort/{column}`
324 509 ///
325 510 /// The heading a user pressed. Which way it then sorts is the app's: pressing
@@ -372,7 +557,23 @@
372 557 // rather than per row -- it is a fact about the screen, and every row would
373 558 // otherwise ask the library the same question.
374 559 let collections = state.library.collections();
375 - let collection = collections.iter().any(|collection| collection.active);
560 + let listing = Listing {
561 + shown,
562 + current,
563 + collection: collections.iter().any(|collection| collection.active),
564 + collections: &collections,
565 + basket_showing: state.bar.basket_showing(),
566 + // What the selection menu hangs on. Counted here for the same reason
567 + // the collections are read here: it is a fact about the screen, and a
568 + // row asking it would ask it once per row.
569 + chosen: samples.iter().filter(|sample| sample.selected).count(),
570 + focus: current.and_then(|id| {
571 + samples
572 + .iter()
573 + .find(|sample| sample.id == id)
574 + .map(|sample| sample.name.clone())
575 + }),
576 + };
376 577
377 578 if samples.is_empty() {
378 579 // The sentence and the way out are both on the node rather than on the
@@ -385,19 +586,7 @@
385 586 } else {
386 587 Slot::new(BODY, RegionKind::Pane).with(Node::Table {
387 588 columns: columns(state, shown),
388 - rows: samples
389 - .iter()
390 - .map(|sample| {
391 - row(
392 - sample,
393 - shown,
394 - current,
395 - collection,
396 - &collections,
397 - state.bar.basket_showing(),
398 - )
399 - })
400 - .collect(),
589 + rows: samples.iter().map(|sample| row(sample, &listing)).collect(),
401 590 // Everything the app has loaded and filtered is here. Windowing
402 591 // rows it already holds is a renderer's job, which is the note in
403 592 // this module's header.
@@ -469,15 +658,33 @@
469 658 columns
470 659 }
471 660
472 - /// One sample as a row.
473 - fn row(
474 - sample: &Sample,
661 + /// What every row of one drawing of the list is built against.
662 + ///
663 + /// Read once in [`body`] and handed down, rather than asked per row: each of
664 + /// these is a fact about the *screen* -- which columns are on, what is chosen,
665 + /// which collection is open -- and a row that read them itself would ask the
666 + /// library the same question five hundred times.
667 + struct Listing<'a> {
668 + /// Which columns the reader has switched on.
475 669 shown: super::ColumnsShown,
670 + /// The row the app is pointing at.
476 671 current: Option<i64>,
672 + /// Whether a collection is the thing being viewed.
477 673 collection: bool,
478 - collections: &[Collection],
674 + /// The collections a sample can be put into.
675 + collections: &'a [Collection],
676 + /// Whether a basket answer is on screen, which adds the anchors column.
479 677 basket_showing: bool,
480 - ) -> Cells {
678 + /// How many rows the reader has chosen.
679 + chosen: usize,
680 + /// What the row in focus is called, for the entry that spreads its tags.
681 + focus: Option<String>,
682 + }
683 +
684 + /// One sample as a row.
685 + fn row(sample: &Sample, listing: &Listing<'_>) -> Cells {
686 + let shown = listing.shown;
687 + let basket_showing = listing.basket_showing;
481 688 let mut values = vec![Cell::new(&sample.name)];
482 689 if basket_showing {
483 690 // A badge per anchor, in basket order, the way the tags cell holds a run
@@ -556,8 +763,16 @@
556 763 let mut row = Cells::new(values)
557 764 .identified(sample.id.to_string())
558 765 .activate(Action::post(format!("/files/{}/open", sample.id)));
559 - row.current = current == Some(sample.id);
560 - row.menu = menu(sample, collection, collections);
766 + row.current = listing.current == Some(sample.id);
767 + // Which menu a row offers is which subject the press is about. A row inside
768 + // a multi-selection is not being asked about itself, and the shipped list
769 + // said so at the same fork: `selection.count() > 1 && contains(row_idx)`
770 + // drew the selection menu and everything else drew the row's own.
771 + row.menu = if listing.chosen > 1 && sample.selected {
772 + chosen_menu(listing)
773 + } else {
774 + menu(sample, listing.collection, listing.collections)
775 + };
561 776 row
562 777 }
563 778
@@ -567,15 +782,13 @@
567 782 /// branching is the shipped menu's: a folder and a sample offer different things,
568 783 /// and a cloud-only sample withholds the four acts that need the bytes on disk.
569 784 ///
570 - /// # What is not here, and why each one is not a gap
785 + /// # What is not here, and why it is not a gap
786 + ///
787 + /// **What a chosen set offers**, which is [`chosen_menu`] and is reached from
788 + /// the same press: a row inside a multi-selection answers with that instead of
789 + /// with this. See [`row`], which is where the fork is, and [`chosen_menu`] for
790 + /// why the fork is on the row rather than on an anchor.
571 791 ///
572 - /// - **The selection menu and the background menu.** Eleven entries and five,
573 - /// neither of them per-row: `draw_multi_context_menu` acts on the ticked set
574 - /// and the empty-space menu on the folder being shown. Neither has a container
575 - /// in the vocabulary -- there is no menu over a selection and none over a
576 - /// surface -- and described as [`Outcome::Over`](quasi_router::Outcome::Over)
577 - /// they become app-modal overlays, which is near enough and not exact. Filed as
578 - /// `quasi:vocabulary:anchored-menu`; see this module's header.
579 792 /// # Add to Collection, which needed no submenu after all
580 793 ///
581 794 /// This entry was the module's one measured hole and was filed as a vocabulary
@@ -684,6 +897,123 @@
684 897 acts
685 898 }
686 899
900 + /// What a set of chosen rows offers, from any one of them.
901 + ///
902 + /// `ui/file_list_menus.rs::draw_multi_context_menu`, said as a description and
903 + /// restored 2026-08-29 after `49b7429` left it with no caller. Eleven entries,
904 + /// in the shipped order.
905 + ///
906 + /// # Why this hangs off a row and not off the selection
907 + ///
908 + /// The task filed it as [`Outcome::Anchored`](quasi_router::Outcome::Anchored)
909 + /// with [`Anchor::Selection`](quasi_router::Anchor::Selection), and that member
910 + /// resolves against the *description's* selection: `Screen::anchors` wants
911 + /// [`Screen::selection`] set, and `quasi_immediate::geometry` unions the rects
912 + /// of the rows the view holds **ticked**. This list has neither and cannot get
913 + /// them cheaply. Its selection is the app's own -- live, set by a click, by
914 + /// Cmd+A and by shift-arrow -- where a tick is a staged set awaiting a commit
915 + /// control, drawn as a checkbox column, and seeded once on arrival rather than
916 + /// re-read on the refresh this screen does every frame. So `Anchor::Selection`
917 + /// would resolve to nothing here and the menu would open away from its subject.
918 + ///
919 + /// Ruled by Max 2026-08-29, on the measurement: the row the press landed on is
920 + /// the anchor, which is what the shipped list did and what every file manager
921 + /// does. The empty-space menu is still anchored, at
922 + /// [`Anchor::Region`](quasi_router::Anchor::Region) -- see
923 + /// [`background_menu`](fn@background_menu) -- because a press on nothing has no
924 + /// row to hang off.
925 + ///
926 + /// What is left over, and it is small: a menu is a `Vec<Act>` and holds no
927 + /// prose, so the shipped header line "N items selected" has nowhere to go. The
928 + /// count survives where it does the most work -- the app's own counted delete
929 + /// dialog, and the Deselect entry on the background menu.
930 + ///
931 + /// Five of the eleven are addresses answered elsewhere: three bulk modals, and
932 + /// the collection acts that borrow this module's own. Same accounting the row
933 + /// menu keeps, and the same benefit.
934 + fn chosen_menu(listing: &Listing<'_>) -> Vec<Act> {
935 + let mut acts = vec![
936 + Act::new(
937 + "Invert Selection (Cmd+Shift+I)",
938 + Action::post("/files/chosen/invert"),
939 + ),
940 + // The three bulk modals, at their own addresses. Each acts on the
941 + // selection by reading it, so nothing about the set travels in the link.
942 + Act::new("Tag... (Cmd+T)", Action::get("/bulk/tag")),
943 + // m-16: the label advertises Cmd+Shift+M because Cmd+M is the macOS
944 + // minimize-window chord. The binding is `editor.rs`'s and has to move
945 + // with this label if it ever changes.
946 + Act::new("Move to... (Cmd+Shift+M)", Action::get("/bulk/move")),
947 + Act::new("Rename... (F2)", Action::get("/bulk/rename")),
948 + Act::new("Export...", Action::post("/files/chosen/export")),
949 + ];
950 +
951 + // The one entry the shipped menu nested. It flattens the way the row menu's
952 + // did and for the same reason: `Act::asking` carries the question, so the
953 + // entry stays one line however many collections exist.
954 + if !listing.collections.is_empty() {
955 + acts.push(
956 + Act::new(
957 + "Add to Collection",
958 + Action::post("/files/chosen/collection/add"),
959 + )
960 + .asking(Field::select(
961 + COLLECTION,
962 + "Collection",
963 + listing
964 + .collections
965 + .iter()
966 + .map(|it| Choice::new(it.id.to_string(), it.name.clone()))
967 + .collect(),
968 + )),
969 + );
970 + }
971 +
972 + if listing.collection {
973 + acts.push(
974 + Act::new(
975 + "Remove from Collection",
976 + Action::post("/files/chosen/collection/remove"),
977 + )
978 + .tone(Tone::Danger),
979 + );
980 + }
981 +
982 + acts.push(Act::new(
983 + "Re-analyze...",
984 + Action::post("/files/chosen/reanalyze"),
985 + ));
986 +
987 + // Withheld when nothing is in focus, which is what the shipped menu does by
988 + // asking for the focused node first: there is no sample to copy tags from.
989 + if let Some(name) = &listing.focus {
990 + acts.push(Act::new(
991 + format!("Copy Tags from \"{}\"", truncated(name, 20)),
992 + Action::post("/files/chosen/tags/copy"),
993 + ));
994 + }
995 +
996 + acts.push(Act::new(
997 + "Copy Path",
998 + Action::post("/files/chosen/path/copy"),
999 + ));
1000 + acts.push(Act::new("Delete", Action::post("/files/chosen/delete")).tone(Tone::Danger));
1001 + acts
1002 + }
1003 +
1004 + /// A name cut down to fit a menu entry.
1005 + ///
1006 + /// The shipped menu's `truncate_name`, brought over with the entry that is its
1007 + /// only caller. It stayed behind in `ui/file_list_menus.rs` when the rest of
1008 + /// that file's menus were described, and the file goes with this.
1009 + fn truncated(name: &str, most: usize) -> String {
1010 + if name.chars().count() <= most {
1011 + return name.to_owned();
1012 + }
1013 + let kept: String = name.chars().take(most.saturating_sub(3)).collect();
1014 + format!("{kept}...")
1015 + }
1016 +
687 1017 /// A duration as the list writes it.
688 1018 fn seconds(duration: Option<f64>) -> String {
689 1019 let Some(seconds) = duration else {
@@ -602,6 +602,22 @@
602 602 /// acts that need the file on disk. A sample nobody has fetched can still be
603 603 /// listed, named and tagged, so this is not the same fact as absence.
604 604 pub cloud_only: bool,
605 + /// Whether this row is one of the rows the reader has chosen.
606 + ///
607 + /// `41996627`. Not the same fact as [`Files::current`], which is the one row
608 + /// the app is pointing at: the file list carries a live multi-selection --
609 + /// `state.nav.selection`, set by a click, by Cmd+A and by shift-arrow -- and
610 + /// until this landed a description could not tell one chosen row from an
611 + /// unchosen one. What reads it is `files::menu`, which offers a row inside a
612 + /// multi-selection what can be done to the *set* rather than to the row.
613 + ///
614 + /// Worth knowing where this stops: nothing **shows** it. `Cells` marks the
615 + /// current row and holds a tick, and a tick is a staged set awaiting a
616 + /// commit control rather than a selection that is already in force, so the
617 + /// list draws a five-hundred-row Cmd+A the same as a one-row click. Measured
618 + /// 2026-08-29 and filed; it is why `Anchor::Selection` resolves against
619 + /// nothing here and why this menu hangs off the row instead.
620 + pub selected: bool,
605 621 /// Whether this sample is in the basket -- one of the samples being looked
606 622 /// *for*, rather than one of the answers.
607 623 ///
@@ -705,6 +721,51 @@
705 721
706 722 /// Put this sample in the basket, or take it out if it is already in.
707 723 fn toggle_basket(&self, id: i64);
724 +
725 + /// Choose every row that is not chosen, and unchoose every row that is.
726 + ///
727 + /// The selection menu, 2026-08-29, and the block below it. What tells these
728 + /// from the block above is their subject: each acts on **what is chosen**
729 + /// rather than on a row, so none of them takes an id. The shipped
730 + /// `draw_multi_context_menu` is where they come from, and five of its eleven
731 + /// entries are not here for the reason five of the row menu's are not: the
732 + /// three bulk modals are addresses [`Bulk`] answers, and New Folder and the
733 + /// import doors are [`Naming`]'s and [`Importing`]'s.
734 + fn invert_chosen(&self);
735 +
736 + /// Choose nothing.
737 + fn deselect(&self);
738 +
739 + /// Open the export flow on what is chosen.
740 + ///
741 + /// Not [`Export::open`], which is the same door opened on the folder being
742 + /// shown. The difference is the argument the app's own `start_export_flow`
743 + /// takes, and it is the whole of why this is a method rather than a link to
744 + /// `/export/begin`.
745 + fn export_chosen(&self);
746 +
747 + /// Analyse every chosen sample again.
748 + fn reanalyze_chosen(&self);
749 +
750 + /// Put every chosen sample's path on the clipboard, one per line.
751 + fn copy_chosen_paths(&self);
752 +
753 + /// Give every other chosen sample the tags the sample in focus carries.
754 + fn copy_tags_from_focus(&self);
755 +
756 + /// Delete what is chosen.
757 + ///
758 + /// The asking is the app's rather than the act's, which is the reading
759 + /// [`delete`](Self::delete) already records: `confirm_delete_selected`
760 + /// counts what is about to go, and a count is what the reader needs when the
761 + /// subject is a set.
762 + fn delete_chosen(&self);
763 +
764 + /// Put every chosen sample into that collection.
765 + fn add_chosen_to_collection(&self, collection: i64);
766 +
767 + /// Take every chosen sample out of the collection being viewed.
768 + fn remove_chosen_from_collection(&self);
708 769 }
709 770
710 771 /// What a described screen asked the app to do to itself.
@@ -809,6 +870,24 @@
809 870 WalkBack(usize),
810 871 /// Put a sample in the basket, or take it out of it.
811 872 ToggleBasket(i64),
873 + /// Choose what is not chosen and unchoose what is.
874 + InvertChosen,
875 + /// Choose nothing.
876 + Deselect,
877 + /// Open the export flow on what is chosen.
878 + ExportChosen,
879 + /// Analyse every chosen sample again.
880 + ReanalyzeChosen,
881 + /// Put every chosen sample's path on the clipboard.
882 + CopyChosenPaths,
883 + /// Spread the focused sample's tags across the rest of what is chosen.
884 + CopyTagsFromFocus,
885 + /// Delete what is chosen, the counted asking still to come.
886 + DeleteChosen,
887 + /// Put every chosen sample into this collection.
888 + AddChosenToCollection(i64),
889 + /// Take every chosen sample out of the collection being viewed.
890 + RemoveChosenFromCollection,
812 891 /// Take the anchor at this position out of the basket.
813 892 RemoveFromBasket(usize),
814 893 /// Show what is near every sample in the basket.
@@ -1180,11 +1259,13 @@
1180 1259
1181 1260 impl Files for FromContents<'_> {
1182 1261 fn samples(&self) -> Vec<Sample> {
1262 + let offset = usize::from(self.state.nav.current_dir.is_some());
1183 1263 self.state
1184 1264 .nav
1185 1265 .contents
1186 1266 .iter()
1187 - .map(|node| Sample {
1267 + .enumerate()
1268 + .map(|(at, node)| Sample {
1188 1269 id: node.node.id.as_i64(),
1189 1270 name: node.node.name.clone(),
1190 1271 duration: node.duration,
@@ -1202,6 +1283,13 @@
1202 1283 .sample_hash
1203 1284 .as_deref()
1204 1285 .is_some_and(|hash| self.state.search.basket_position(hash).is_some()),
1286 + // The offset is the `..` entry's: `nav.selection` indexes the
1287 + // rows the *list* shows and the parent entry is one of them,
1288 + // while `nav.contents` holds only what is in the folder. Every
1289 + // other reader of the selection makes the same adjustment --
1290 + // `selected_nodes`, `selected_node`, `panel::collapse_onto` --
1291 + // and one that did not would be off by one inside every folder.
1292 + selected: self.state.nav.selection.selected.contains(&(at + offset)),
1205 1293 // Anchor hashes are what the state holds, because a hash is what
1206 1294 // a query is about. A name is what a row can show, so the
1207 1295 // translation happens here, at the edge, rather than being
@@ -1306,6 +1394,46 @@
1306 1394 .borrow_mut()
1307 1395 .push(Intent::AddToCollection(id, collection));
1308 1396 }
1397 +
1398 + fn invert_chosen(&self) {
1399 + self.intents.borrow_mut().push(Intent::InvertChosen);
1400 + }
1401 +
1402 + fn deselect(&self) {
1403 + self.intents.borrow_mut().push(Intent::Deselect);
1404 + }
1405 +
1406 + fn export_chosen(&self) {
1407 + self.intents.borrow_mut().push(Intent::ExportChosen);
1408 + }
1409 +
1410 + fn reanalyze_chosen(&self) {
1411 + self.intents.borrow_mut().push(Intent::ReanalyzeChosen);
1412 + }
1413 +
1414 + fn copy_chosen_paths(&self) {
1415 + self.intents.borrow_mut().push(Intent::CopyChosenPaths);
1416 + }
1417 +
1418 + fn copy_tags_from_focus(&self) {
1419 + self.intents.borrow_mut().push(Intent::CopyTagsFromFocus);
1420 + }
1421 +
1422 + fn delete_chosen(&self) {
1423 + self.intents.borrow_mut().push(Intent::DeleteChosen);
1424 + }
1425 +
1426 + fn add_chosen_to_collection(&self, collection: i64) {
1427 + self.intents
1428 + .borrow_mut()
1429 + .push(Intent::AddChosenToCollection(collection));
1430 + }
1431 +
1432 + fn remove_chosen_from_collection(&self) {
1433 + self.intents
1434 + .borrow_mut()
1435 + .push(Intent::RemoveChosenFromCollection);
1436 + }
1309 1437 }
1310 1438
1311 1439 /// Where the export flow has got to.
@@ -86,20 +86,26 @@
86 86 /// moves with no intent at all. So the answer is `quasi` 0.12.0's
87 87 /// `Runtime::reload`, and this is the flag that says when to call it.
88 88 stale: bool,
89 - /// Calls a host picker answered, waiting for the frame that can make them.
89 + /// Calls the host wants made, waiting for the frame that can make them.
90 90 ///
91 - /// `Outcome::Locate`'s host half. A picker runs off the frame thread and
92 - /// answers through a handler that holds `&mut BrowserState` and no runtime,
93 - /// so what it can do is write the call down; [`drive`] makes it on the next
94 - /// frame of the screen that asked. Keyed by that screen's home address,
95 - /// because one of these is shared by every described window and a call
96 - /// belongs to the runtime that raised it.
91 + /// Two producers, and what they have in common is the frame boundary: each
92 + /// knows a call is wanted at a moment when there is no runtime to make it.
93 + ///
94 + /// - **`Outcome::Locate`'s host half.** A picker runs off the frame thread
95 + /// and answers through a handler that holds `&mut BrowserState` and no
96 + /// runtime, so what it can do is write the call down.
97 + /// - **A gesture the description cannot carry.** [`menued`] reads a press on
98 + /// the part of the list that is not a row, which no described thing can
99 + /// answer, and raises the empty-space menu's address.
100 + ///
101 + /// [`drive`] makes them on the next frame of the screen that asked. Keyed by
102 + /// that screen's home address, because one of these is shared by every
103 + /// described window and a call belongs to the runtime that raised it.
97 104 ///
98 105 /// 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 - located: parking_lot::Mutex<Vec<(String, Request)>>,
106 + /// once, so a slot would keep the last of them. One ask still puts one call
107 + /// here, however many files came back, which is [`write_down`]'s job.
108 + raised: parking_lot::Mutex<Vec<(String, Request)>>,
103 109 /// How much room is left where an export would write.
104 110 ///
105 111 /// A host fact the export description does arithmetic with, and the cache
@@ -192,11 +198,58 @@
192 198 state.described.shell = runtime;
193 199 apply(ui.ctx(), state, sync, intents.into_inner());
194 200
195 - // The one gesture the description does not carry, after the intents rather
201 + // The two gestures the description does not carry, after the intents rather
196 202 // than before them: what a press chose is what a drag should carry out.
197 203 if dragging_out(ui, state) {
198 204 crate::ui::file_list_menus::start_os_drag(state);
199 205 }
206 + menued(ui, state);
207 + }
208 +
209 + /// Raise the empty-space menu when a press lands on no row.
210 + ///
211 + /// The second host gesture, and the smaller half of audiofiles `41996627`. A
212 + /// row answers a menu because a row is a described thing and `Cells::menu` says
213 + /// what it offers; **the absence of a row is not a described thing**, so nothing
214 + /// in a description can answer a press on the part of the list that is not one.
215 + /// The renderer is the only side that knows -- it drew the rows and it knows
216 + /// where they went -- and `quasi_immediate::row_at` is where it says so.
217 + ///
218 + /// So the host reads the press and asks for the address; the route answers
219 + /// `Outcome::Anchored` at the region, and the renderer resolves that back to the
220 + /// rect it drew. The description still never carries a point.
221 + ///
222 + /// Three guards, and each is the answer to a way this could be wrong:
223 + ///
224 + /// - **The press landed on no row.** A press on one is the renderer's own
225 + /// context menu, which is the row's or -- when several rows are chosen and
226 + /// this is one of them -- the selection's. Both are already open by the time
227 + /// this runs, and raising a second menu over them is the failure this guard
228 + /// exists to stop.
229 + /// - **The press landed in the room the shell was given.** Which leaves out the
230 + /// filter panel, the detail panel and every floating window, the same way
231 + /// [`dragging_out`] does.
232 + /// - **The call is raised rather than made.** There is no runtime here: this
233 + /// runs after `inline` has given it back. [`Described::raised`] is the queue
234 + /// the next frame drains, which is the same road a picker's answer takes.
235 + fn menued(ui: &egui::Ui, state: &BrowserState) {
236 + let Some(origin) = ui.ctx().input(|input| {
237 + input
238 + .pointer
239 + .button_clicked(egui::PointerButton::Secondary)
240 + .then(|| input.pointer.interact_pos())
241 + .flatten()
242 + }) else {
243 + return;
244 + };
245 + if !ui.max_rect().contains(origin) || quasi_immediate::row_at(ui.ctx(), origin).is_some() {
246 + return;
247 + }
248 + state
249 + .described
250 + .raised
251 + .lock()
252 + .push(("/".to_owned(), Request::get("/files/menu/background")));
200 253 }
201 254
202 255 /// Draw the described sample editor, and act on whatever was pressed.
@@ -666,6 +719,152 @@
666 719 }
667 720 }
668 721 }
722 + // The selection menu and the empty-space menu, 2026-08-29. Every
723 + // arm below is `ui/file_list_menus.rs`'s own body, moved rather than
724 + // rewritten: the described side says what is offered and the app
725 + // does what it always did.
726 + Intent::InvertChosen => state.invert_selection(),
727 + Intent::Deselect => {
728 + state.nav.selection.clear();
729 + state.refresh_selected_tags();
730 + state.refresh_selected_detail();
731 + }
732 + Intent::ExportChosen => {
733 + let chosen = state.selected_node_ids();
734 + state.start_export_flow(Some(chosen));
735 + }
736 + // The overwrite branch is the shipped menu's, and it is the same
737 + // question `Reanalyze` asks one row at a time: analysis that would
738 + // replace numbers already there asks first, and the count is what
739 + // makes the asking worth anything over a set.
740 + Intent::ReanalyzeChosen => {
741 + let chosen = state.selected_nodes();
742 + let hashes: Vec<(String, String)> = chosen
743 + .iter()
744 + .filter_map(|node| {
745 + let hash = node.node.sample_hash.as_ref()?;
746 + let ext = state.backend.sample_extension(hash).ok()?;
747 + Some((hash.to_string(), ext))
748 + })
749 + .collect();
750 + let overwriting = chosen
751 + .iter()
752 + .filter(|node| node.bpm.is_some() || node.musical_key.is_some())
753 + .count();
754 + if overwriting > 0 {
755 + state.overlay.pending_confirm =
756 + Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
757 + sample_hashes: hashes,
758 + overwrite_count: overwriting,
759 + });
760 + } else {
761 + state.start_analysis_flow(hashes);
762 + }
763 + }
764 + // The status line names the first path and counts the rest, which is
765 + // the shipped line and its reason: a bare "Copied N paths" gives a
766 + // reader no way to tell which selection won the race when they
767 + // copied, changed their mind, and then pasted into a DAW.
768 + Intent::CopyChosenPaths => {
769 + let paths: Vec<String> = state
770 + .selected_nodes()
771 + .iter()
772 + .filter_map(|node| {
773 + let hash = node.node.sample_hash.as_ref()?;
774 + let ext = state.backend.sample_extension(hash).ok()?;
775 + Some(
776 + state
777 + .backend
778 + .sample_path(hash, &ext)
779 + .ok()?
780 + .to_string_lossy()
781 + .into_owned(),
782 + )
783 + })
784 + .collect();
785 + if let Some(first) = paths.first() {
786 + state.status = if paths.len() == 1 {
787 + format!("Copied: {first}")
788 + } else {
789 + format!("Copied: {first} (+{} more)", paths.len() - 1)
790 + };
791 + ctx.copy_text(paths.join("\n"));
792 + }
793 + }
794 + Intent::CopyTagsFromFocus => {
795 + let focused = state
796 + .selected_node()
797 + .and_then(|node| node.node.sample_hash.clone());
798 + if let Some(source) = focused
799 + && let Ok(tags) = state.backend.get_sample_tags(&source)
800 + {
801 + let source = source.to_string();
802 + let mut applied = 0usize;
803 + let mut failed = 0usize;
804 + for hash in &state.selected_sample_hashes() {
805 + if *hash == source {
806 + continue;
807 + }
808 + if tags
809 + .iter()
810 + .all(|tag| state.backend.add_tag(hash, tag).is_ok())
811 + {
812 + applied += 1;
813 + } else {
814 + failed += 1;
815 + }
816 + }
817 + state.status = if failed == 0 {
818 + format!("Copied {} tags to {applied} samples", tags.len())
819 + } else {
820 + format!(
821 + "Copied {} tags to {applied} samples ({failed} failed)",
822 + tags.len()
823 + )
824 + };
825 + state.refresh_selected_tags();
826 + }
827 + }
828 + Intent::DeleteChosen => state.confirm_delete_selected(),
829 + // Directories are skipped rather than counted: a folder has no hash,
830 + // so it was never going into the collection and reporting it as
831 + // added would over-count what happened.
832 + Intent::AddChosenToCollection(collection) => {
833 + let named = state
834 + .collections_ui
835 + .collections
836 + .iter()
837 + .find(|it| it.id.as_i64() == collection)
838 + .map(|it| (it.id, it.name.clone()));
839 + if let Some((collection, name)) = named {
840 + let (mut added, mut failed) = (0usize, 0usize);
841 + for node in &state.selected_nodes() {
842 + if let Some(hash) = &node.node.sample_hash {
843 + match state.backend.add_to_collection(collection, hash) {
844 + Ok(()) => added += 1,
845 + Err(_) => failed += 1,
846 + }
847 + }
848 + }
849 + state.refresh_collections();
850 + state.status = if failed == 0 {
851 + format!("Added {added} items to {name}")
852 + } else {
853 + format!("Added {added} items to {name} ({failed} failed)")
854 + };
855 + }
856 + }
857 + Intent::RemoveChosenFromCollection => {
858 + if let Some(active) = state.collections_ui.active_collection {
859 + for node in &state.selected_nodes() {
860 + if let Some(hash) = &node.node.sample_hash {
861 + let _ = state.backend.remove_from_collection(active, hash);
862 + }
863 + }
864 + state.refresh_collections();
865 + state.activate_collection(active);
866 + }
867 + }
669 868 Intent::SortBy(column) => {
670 869 let key = match column.as_str() {
671 870 "Name" => crate::state::SortColumn::Name,
@@ -2043,11 +2242,18 @@
2043 2242 /// an index is a fact about the current filter and sort, which is exactly the
2044 2243 /// kind of thing an address should not be.
2045 2244 fn index_of(state: &BrowserState, id: i64) -> Option<usize> {
2245 + // The `..` entry's offset, which every other reader of the selection applies
2246 + // -- `selected_nodes`, `selected_node`, `collapse_onto`. Without it every
2247 + // row menu inside a folder acted on the row above the one pressed: this
2248 + // returns a position in `nav.contents` and `nav.selection` indexes the rows
2249 + // the list shows, which is one longer while there is a parent to go back to.
2250 + let offset = usize::from(state.nav.current_dir.is_some());
2046 2251 state
2047 2252 .nav
2048 2253 .contents
2049 2254 .iter()
2050 2255 .position(|node| node.node.id.as_i64() == id)
2256 + .map(|at| at + offset)
2051 2257 }
2052 2258
2053 2259 /// Everything a described screen is answered out of.
@@ -2118,7 +2324,7 @@
2118 2324 // What a host picker answered, called now that there is a runtime to answer
2119 2325 // into. Before the refresh below, so the frame that shows the new
2120 2326 // destination is the frame the reader's choice lands on. See `locate`.
2121 - for request in answered(host.state, home) {
2327 + for request in raised_for(host.state, home) {
2122 2328 call(runtime, host, request);
2123 2329 }
2124 2330
@@ -2575,7 +2781,7 @@
2575 2781 /// **Nothing is drawn and nothing navigates.** `ui::dialog` runs the picker off
2576 2782 /// the frame thread and answers frames later through a handler that holds
2577 2783 /// `&mut BrowserState`, so the call the ask names cannot be made where it is
2578 - /// raised. The handler writes it into [`Described::located`] and [`drive`] makes
2784 + /// raised. The handler writes it into [`Described::raised`] and [`drive`] makes
2579 2785 /// it on the next frame of the screen that asked.
2580 2786 ///
2581 2787 /// A reader who backs out has answered nothing: `ui::dialog` skips the handler
@@ -2655,18 +2861,18 @@
2655 2861 if let Some(request) = asking.answered(picks) {
2656 2862 state
2657 2863 .described
2658 - .located
2864 + .raised
2659 2865 .lock()
2660 2866 .push((home.to_owned(), request));
2661 2867 }
2662 2868 }
2663 2869
2664 - /// The calls this screen's pickers have answered, in the order they were picked.
2870 + /// The calls waiting for this screen, in the order they were raised.
2665 2871 ///
2666 2872 /// Keyed by home address because one queue serves every described window, and a
2667 2873 /// call raised by the export flow is not the settings screen's to make.
2668 - fn answered(state: &BrowserState, home: &str) -> Vec<Request> {
2669 - let mut waiting = state.described.located.lock();
2874 + fn raised_for(state: &BrowserState, home: &str) -> Vec<Request> {
2875 + let mut waiting = state.described.raised.lock();
2670 2876 let (mine, theirs) = waiting
2671 2877 .drain(..)
2672 2878 .partition::<Vec<_>, _>(|(asked, _)| asked == home);
@@ -150,6 +150,35 @@
150 150 fn toggle_basket(&self, id: i64) {
151 151 self.asked.borrow_mut().push(format!("basket:{id}"));
152 152 }
153 + fn invert_chosen(&self) {
154 + self.asked.borrow_mut().push("invert".to_owned());
155 + }
156 + fn deselect(&self) {
157 + self.asked.borrow_mut().push("deselect".to_owned());
158 + }
159 + fn export_chosen(&self) {
160 + self.asked.borrow_mut().push("export-chosen".to_owned());
161 + }
162 + fn reanalyze_chosen(&self) {
163 + self.asked.borrow_mut().push("reanalyze-chosen".to_owned());
164 + }
165 + fn copy_chosen_paths(&self) {
166 + self.asked.borrow_mut().push("copy-paths".to_owned());
167 + }
168 + fn copy_tags_from_focus(&self) {
169 + self.asked.borrow_mut().push("copy-tags".to_owned());
170 + }
171 + fn delete_chosen(&self) {
172 + self.asked.borrow_mut().push("delete-chosen".to_owned());
173 + }
174 + fn add_chosen_to_collection(&self, collection: i64) {
175 + self.asked
176 + .borrow_mut()
177 + .push(format!("add-chosen:{collection}"));
178 + }
179 + fn remove_chosen_from_collection(&self) {
180 + self.asked.borrow_mut().push("remove-chosen".to_owned());
181 + }
153 182 fn add_to_collection(&self, id: i64, collection: i64) {
154 183 self.asked
155 184 .borrow_mut()
@@ -377,10 +406,19 @@
377 406 directory: false,
378 407 cloud_only: false,
379 408 basket: false,
409 + selected: false,
380 410 matched: Vec::new(),
381 411 }
382 412 }
383 413
414 + /// A sample the reader has chosen, which is what the selection menu hangs on.
415 + fn chosen(id: i64, name: &str) -> Sample {
416 + Sample {
417 + selected: true,
418 + ..sample(id, name)
419 + }
420 + }
421 +
384 422 /// A folder row, which offers a different menu.
385 423 fn folder(id: i64, name: &str) -> Sample {
386 424 Sample {
@@ -2240,6 +2278,177 @@
2240 2278 );
2241 2279 }
2242 2280
2281 + #[test]
2282 + fn a_row_inside_a_multi_selection_offers_the_selection_menu() {
2283 + // `41996627`. The subject of a press is what the press is about, and a row
2284 + // the reader has chosen among several is not being asked about itself. The
2285 + // shipped list forked at exactly this test -- `selection.count() > 1 &&
2286 + // contains(row_idx)` -- and the assertion is against what it drew there.
2287 + let files = FakeFiles::with(vec![chosen(7, "kick.wav"), chosen(9, "snare.wav")]);
2288 + let response = listing(&files, Request::get("/files")).expect("answered");
2289 + let (_, rows) = table_of(screen_of(&response));
2290 +
2291 + let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
2292 + assert_eq!(
2293 + labels,
2294 + [
2295 + "Invert Selection (Cmd+Shift+I)",
2296 + "Tag... (Cmd+T)",
2297 + "Move to... (Cmd+Shift+M)",
2298 + "Rename... (F2)",
2299 + "Export...",
2300 + "Re-analyze...",
2301 + "Copy Path",
2302 + "Delete",
2303 + ],
2304 + "the described selection menu drifted from the shipped one"
2305 + );
2306 + // Every chosen row offers the same menu, because the subject is the set
2307 + // rather than the row the pointer happens to be over.
2308 + let second: Vec<&str> = rows[1].menu.iter().map(|act| act.label.as_str()).collect();
2309 + assert_eq!(labels, second);
2310 +
2311 + // The three bulk modals are addresses that already answer, which is the
2312 + // accounting the row menu keeps: nothing about the set travels in the link.
2313 + let bulk: Vec<&str> = rows[0].menu[1..4]
2314 + .iter()
2315 + .map(|act| act.action.destination.as_str())
2316 + .collect();
2317 + assert_eq!(bulk, ["/bulk/tag", "/bulk/move", "/bulk/rename"]);
2318 +
2319 + let delete = rows[0].menu.last().expect("the menu ends in Delete");
2320 + assert_eq!(delete.action.destination.as_str(), "/files/chosen/delete");
2321 + assert_eq!(delete.tone, quasi_router::layout::Tone::Danger);
2322 + // No `confirm`, and that is not an omission: the app raises its own dialog
2323 + // and counts what is about to go, which is what a reader needs when the
2324 + // subject is a set rather than one named file.
2325 + assert_eq!(delete.confirm, None);
2326 + }
2327 +
2328 + #[test]
2329 + fn one_chosen_row_still_offers_its_own_menu() {
2330 + // The other side of the fork. Choosing a row and then right-clicking it is
2331 + // asking about that row, and the count is what tells the two apart.
2332 + let files = FakeFiles::with(vec![chosen(7, "kick.wav"), sample(9, "snare.wav")]);
2333 + let response = listing(&files, Request::get("/files")).expect("answered");
2334 + let (_, rows) = table_of(screen_of(&response));
2335 +
2336 + let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect();
2337 + assert_eq!(labels.first(), Some(&"Preview"), "{labels:?}");
2338 + assert!(!labels.contains(&"Invert Selection (Cmd+Shift+I)"));
2339 + }
2340 +
2341 + #[test]
2342 + fn the_selection_menu_names_the_sample_whose_tags_would_spread() {
2343 + // Withheld when nothing is in focus, which is what the shipped entry does by
2344 + // asking for the focused node first: there is no sample to copy tags from.
2345 + let mut files = FakeFiles::with(vec![chosen(7, "kick.wav"), chosen(9, "snare.wav")]);
2346 + files.current = Some(9);
2347 + let response = listing(&files, Request::get("/files")).expect("answered");
2348 + let (_, rows) = table_of(screen_of(&response));
2349 +
2350 + let copy = rows[0]
2351 + .menu
2352 + .iter()
2353 + .find(|act| act.label.starts_with("Copy Tags"))
2354 + .expect("the entry is offered when a row is in focus");
2355 + assert_eq!(copy.label, "Copy Tags from \"snare.wav\"");
2356 + assert_eq!(copy.action.destination.as_str(), "/files/chosen/tags/copy");
2357 + }
2358 +
2359 + #[test]
2360 + fn every_selection_entry_reaches_the_app_over_the_whole_set() {
2361 + // None of these addresses carries an id, which is the whole of what tells
2362 + // them from the row menu: the subject is what is chosen, and the app reads
2363 + // its own selection rather than being handed one.
2364 + let files = FakeFiles::with(vec![chosen(7, "kick.wav"), chosen(9, "snare.wav")]);
2365 + for verb in [
2366 + "invert",
2367 + "clear",
2368 + "export",
2369 + "reanalyze",
2370 + "path/copy",
2371 + "tags/copy",
2372 + "delete",
2373 + "collection/remove",
2374 + ] {
2375 + listing(&files, Request::post(format!("/files/chosen/{verb}"))).expect("answered");
2376 + }
2377 + let add = Request::post("/files/chosen/collection/add")
2378 + .sending(Params::new().with("collection".to_owned(), "3".to_owned()));
2379 + listing_in_collection(&files, add).expect("answered");
2380 +
2381 + assert_eq!(
2382 + files.asked(),
2383 + [
2384 + "invert",
2385 + "deselect",
2386 + "export-chosen",
2387 + "reanalyze-chosen",
2388 + "copy-paths",
2389 + "copy-tags",
2390 + "delete-chosen",
2391 + "remove-chosen",
2392 + "add-chosen:3",
2393 + ]
2394 + );
2395 + }
2396 +
2397 + #[test]
2398 + fn the_empty_space_menu_is_anchored_at_the_list_it_was_opened_on() {
2399 + // The half of `41996627` that really is `Outcome::Anchored`: a press on the
2400 + // part of the list that is not a row has no row to hang a menu off, so the
2401 + // anchor names the region and the renderer resolves it against the rect it
2402 + // drew.
2403 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
2404 + let response = listing(&files, Request::get("/files/menu/background")).expect("answered");
2405 +
2406 + let quasi_router::Outcome::Anchored { screen, anchor } = &response.outcome else {
2407 + panic!(
2408 + "the empty-space menu is not anchored: {:?}",
2409 + response.outcome
2410 + );
2411 + };
2412 + assert_eq!(
2413 + anchor,
2414 + &quasi_router::Anchor::Region("files-body".to_owned())
2415 + );
2416 +
2417 + let labels = acts(screen);
2418 + assert_eq!(
2419 + labels,
2420 + ["New Folder", "Import files...", "Import folder...", "Close"],
2421 + "the described empty-space menu drifted from the shipped one"
2422 + );
2423 + }
2424 +
2425 + #[test]
2426 + fn the_empty_space_menu_offers_the_selection_only_when_there_is_one() {
2427 + // The shipped menu withholds both entries when nothing is chosen, which is
2428 + // the same judgement the row menu makes about Remove from Collection: an act
2429 + // with no subject is not drawn dead.
2430 + let files = FakeFiles::with(vec![chosen(7, "kick.wav"), sample(9, "snare.wav")]);
2431 + let response = listing(&files, Request::get("/files/menu/background")).expect("answered");
2432 + let quasi_router::Outcome::Anchored { screen, .. } = &response.outcome else {
2433 + panic!("the empty-space menu is not anchored");
2434 + };
2435 +
2436 + let labels = acts(screen);
2437 + assert_eq!(
2438 + labels,
2439 + [
2440 + "New Folder",
2441 + "Import files...",
2442 + "Import folder...",
2443 + // The count is here rather than in a header, because a menu is a
2444 + // list of acts and holds no prose.
2445 + "Deselect (1) (Esc)",
2446 + "Invert Selection (Cmd+Shift+I)",
2447 + "Close",
2448 + ]
2449 + );
2450 + }
2451 +
2243 2452 #[test]
2244 2453 fn a_column_with_no_sort_is_refused_rather_than_ordered_by() {
2245 2454 let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
@@ -1,32 +1,24 @@
1 - //! What is left of the file list's context menus, plus the drag-out handler.
1 + //! The two acts the file list borrows from the operating system.
2 2 //!
3 - //! **Two of the four things in here have no caller**, and that is a regression
4 - //! rather than dead code: the file-list flip (`49b7429`) replaced the shipped
5 - //! list with `quasi::files`, which describes the *row* menu through
6 - //! [`Cells::menu`](quasi_router::Cells) and does not describe the other two
7 - //! menus. So the selection menu and the empty-space menu are both unreachable in
8 - //! the running app.
3 + //! What is left of a file that used to hold four context menus. Each of the four
4 + //! is described now: the row menu since 2026-08-17 (`quasi::files::menu`), and
5 + //! the selection and empty-space menus since 2026-08-29 (`chosen_menu` and
6 + //! `background_menu` beside it, audiofiles `41996627`). The last two had gone
7 + //! unreachable in `49b7429` and were kept rather than deleted while they waited,
8 + //! which is the `da48cb6d` bargain: losing a feature quietly is allowed
9 + //! temporarily and only while it has a task.
9 10 //!
10 - //! They are kept rather than deleted, because deleting them is losing the
11 - //! features quietly, and what they wait on is `quasi:vocabulary:anchored-menu`:
12 - //! a menu over a selection and a menu over a surface have no container in the
13 - //! vocabulary. Filed as audiofiles `41996627`.
11 + //! What could never be described is here, and the three are one kind of thing --
12 + //! **the system, reached from the app**. Showing a file in Finder and handing a
13 + //! set of files to a drag are not facts about a sample, so no description says
14 + //! them; the row menu offers [`reveal_label`]'s entry and the host performs it.
14 15 //!
15 - //! [`start_os_drag`] was the third and has a caller again as of 2026-08-25.
16 - //! Nothing about it was ever going to be described. `quasi::files` records the
17 - //! drag as deliberately undescribed and the reason holds, so what it was missing
18 - //! was a host half, and `quasi::panel::dragging_out` is now that half.
19 - //! Ruled by Max on audiofiles `edd4d1d8`.
20 - //!
21 - //! `draw_context_menu` was the fourth and is deleted (2026-08-25): its
22 - //! replacement ships, so it was the one thing in here that really was dead.
23 -
24 - use egui;
16 + //! [`start_os_drag`] is called by `quasi::panel::dragging_out`, which is where
17 + //! the gesture is read. `quasi::files` records the drag as deliberately
18 + //! undescribed and the reason holds. Ruled by Max on audiofiles `edd4d1d8`.
25 19
26 20 use crate::state::BrowserState;
27 21
28 - use super::widgets;
29 -
30 22 #[cfg(any(target_os = "macos", target_os = "windows"))]
31 23 use crate::drag_out;
32 24
@@ -77,274 +69,6 @@
77 69 }
78 70 }
79 71
80 - /// Context menu when multiple items are selected.
81 - pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
82 - let count = state.nav.selection.count();
83 - ui.label(egui::RichText::new(format!("{count} items selected")).strong());
84 - ui.separator();
85 -
86 - if ui.button("Invert Selection (Cmd+Shift+I)").clicked() {
87 - state.invert_selection();
88 - ui.close();
89 - }
90 -
91 - ui.separator();
92 -
93 - if ui.button("Tag... (Cmd+T)").clicked() {
94 - state.open_bulk_tag_modal();
95 - ui.close();
96 - }
97 - // m-16: Cmd+M conflicts with the macOS minimize-window shortcut. Label
98 - // advertises Cmd+Shift+M; the actual key binding lives in
99 - // `editor.rs` (search for "Cmd+M: bulk move") and must be updated
100 - // there to match.
101 - if ui.button("Move to... (Cmd+Shift+M)").clicked() {
102 - state.open_bulk_move_modal();
103 - ui.close();
104 - }
105 - if ui.button("Rename... (F2)").clicked() {
106 - state.open_bulk_rename_modal();
107 - ui.close();
108 - }
109 - if ui.button("Export...").clicked() {
110 - let node_ids = state.selected_node_ids();
111 - state.start_export_flow(Some(node_ids));
112 - ui.close();
113 - }
114 -
115 - // Add to Collection submenu (bulk)
116 - let collections = state.collections_ui.collections.clone();
117 - if !collections.is_empty() {
118 - ui.menu_button("Add to Collection", |ui| {
119 - for coll in collections.iter() {
120 - if ui.button(&coll.name).clicked() {
121 - let nodes = state.selected_nodes();
122 - // Count only samples actually added, directories (no hash)
123 - // are skipped, so reporting nodes.len() over-counts.
124 - let mut added = 0usize;
125 - let mut failed = 0usize;
126 - for n in &nodes {
127 - if let Some(hash) = &n.node.sample_hash {
128 - match state.backend.add_to_collection(coll.id, hash) {
129 - Ok(()) => added += 1,
130 - Err(_) => failed += 1,
131 - }
132 - }
133 - }
134 - state.refresh_collections();
135 - state.status = if failed == 0 {
136 - format!("Added {added} items to {}", coll.name)
137 - } else {
138 - format!("Added {added} items to {} ({failed} failed)", coll.name)
139 - };
140 - ui.close();
141 - }
142 - }
143 - });
144 - }
145 -
146 - // Remove from Collection (when viewing a collection)
147 - if let Some(active_id) = state.collections_ui.active_collection
148 - && widgets::danger_button(ui, "Remove from Collection").clicked()
149 - {
150 - let nodes = state.selected_nodes();
151 - for n in &nodes {
152 - if let Some(hash) = &n.node.sample_hash {
153 - let _ = state.backend.remove_from_collection(active_id, hash);
154 - }
155 - }
156 - state.refresh_collections();
157 - state.activate_collection(active_id);
158 - ui.close();
159 - }
160 -
161 - ui.separator();
162 -
163 - if ui
164 - .button("Re-analyze...")
165 - .on_hover_text("Run analysis again on selected samples")
166 - .clicked()
167 - {
168 - let selected = state.selected_nodes();
169 - let hashes: Vec<(String, String)> = selected
170 - .iter()
171 - .filter_map(|n| {
172 - let hash = n.node.sample_hash.as_ref()?;
173 - let ext = state.backend.sample_extension(hash).ok()?;
174 - Some((hash.to_string(), ext))
175 - })
176 - .collect();
177 - // Count how many of the selected samples already have computed values
178 - //, re-analyzing those will overwrite the previous result, which a user
179 - // who hand-tuned the analysis would lose silently otherwise.
180 - let overwrite_count = selected
181 - .iter()
182 - .filter(|n| n.bpm.is_some() || n.musical_key.is_some())
183 - .count();
184 - if overwrite_count > 0 {
185 - state.overlay.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite {
186 - sample_hashes: hashes,
187 - overwrite_count,
188 - });
189 - } else {
190 - state.start_analysis_flow(hashes);
191 - }
192 - ui.close();
193 - }
194 -
195 - // Copy tags from focused sample to all selected
196 - if let Some(focused) = state.selected_node()
197 - && let Some(ref src_hash) = focused.node.sample_hash
198 - {
199 - let src_hash = src_hash.clone();
200 - let src_name = focused.node.name.clone();
201 - if ui
202 - .button(format!(
203 - "Copy Tags from \"{}\"",
204 - truncate_name(&src_name, 20)
205 - ))
206 - .on_hover_text("Apply this sample's tags to all other selected samples")
207 - .clicked()
208 - {
209 - let src_hash_str = src_hash.to_string();
210 - if let Ok(src_tags) = state.backend.get_sample_tags(&src_hash) {
211 - let target_hashes = state.selected_sample_hashes();
212 - let mut applied = 0;
213 - let mut failed = 0usize;
214 - for hash in &target_hashes {
215 - if *hash == src_hash_str {
216 - continue;
217 - }
218 - let mut ok = true;
219 - for tag in &src_tags {
220 - if state.backend.add_tag(hash, tag).is_err() {
221 - ok = false;
222 - }
223 - }
224 - if ok {
225 - applied += 1;
226 - } else {
227 - failed += 1;
228 - }
229 - }
230 - state.status = if failed == 0 {
231 - format!("Copied {} tags to {applied} samples", src_tags.len())
232 - } else {
233 - format!(
234 - "Copied {} tags to {applied} samples ({failed} failed)",
235 - src_tags.len()
236 - )
237 - };
238 - state.refresh_selected_tags();
239 - }
240 - ui.close();
241 - }
242 - }
243 -
244 - ui.separator();
245 -
246 - if ui.button("Copy Path").clicked() {
247 - let nodes = state.selected_nodes();
248 - let paths: Vec<String> = nodes
249 - .iter()
250 - .filter_map(|n| {
251 - n.node.sample_hash.as_ref().and_then(|hash| {
252 - let ext = state.backend.sample_extension(hash).ok()?;
253 - Some(
254 - state
255 - .backend
256 - .sample_path(hash, &ext)
257 - .ok()?
258 - .to_string_lossy()
259 - .into_owned(),
260 - )
261 - })
262 - })
263 - .collect();
264 - if !paths.is_empty() {
265 - // Include the first path in the status so the user can recognise the
266 - // clipboard contents at a glance, the bare count "Copied N paths"
267 - // gave no way to verify which selection won the race when the user
268 - // copied, then changed selection, then pasted into a DAW.
269 - let first = &paths[0];
270 - let count = paths.len();
271 - state.status = if count == 1 {
272 - format!("Copied: {first}")
273 - } else {
274 - format!("Copied: {first} (+{} more)", count - 1)
275 - };
276 - ui.ctx().copy_text(paths.join("\n"));
277 - }
278 - ui.close();
279 - }
280 -
281 - if widgets::danger_button(ui, "Delete").clicked() {
282 - state.confirm_delete_selected();
283 - ui.close();
284 - }
285 - }
286 -
287 - /// Context menu for right-clicking empty space in the file list.
288 - pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) {
289 - if ui.button("New Folder").clicked() {
290 - state.vfs_modal.show_dir_create = true;
291 - state.vfs_modal.dir_create_input.clear();
292 - ui.close();
293 - }
294 - if ui.button("Import files...").clicked() {
295 - state.dialogs.pick_files(
296 - "Import files",
297 - &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)],
298 - |s, paths| {
299 - // Batch through the background worker so a large multi-select
300 - // doesn't hash on the GUI thread (fuzz B3).
301 - if let Some(vfs_id) = s.current_vfs_id() {
302 - let strategy = crate::import::ImportStrategy::MergeIntoVfs {
303 - vfs_id,
304 - parent_id: s.nav.current_dir,
305 - };
306 - s.start_files_import(&paths, strategy);
307 - }
308 - },
309 - );
310 - ui.close();
311 - }
312 - // C-2: matches the toolbar's "Import folder..." (wizard path). The quick
313 - // import shortcut is only offered from the toolbar to keep this menu
314 - // short; users who want quick-import find it there.
315 - if ui.button("Import folder...").clicked() {
316 - state.dialogs.pick_folder(
317 - "Import folder",
318 - super::super::state::BrowserState::show_import_options,
319 - );
320 - ui.close();
321 - }
322 - if state.nav.selection.count() > 0 {
323 - ui.separator();
324 - let label = format!("Deselect ({}) (Esc)", state.nav.selection.count());
325 - if ui.button(label).clicked() {
326 - state.nav.selection.clear();
327 - state.refresh_selected_tags();
328 - state.refresh_selected_detail();
329 - ui.close();
330 - }
331 - if ui.button("Invert Selection (Cmd+Shift+I)").clicked() {
332 - state.invert_selection();
333 - ui.close();
334 - }
335 - }
336 - }
337 -
338 - /// Truncate a name for display in menus (avoids excessively wide menu items).
339 - fn truncate_name(name: &str, max_len: usize) -> String {
340 - if name.chars().count() <= max_len {
341 - name.to_string()
342 - } else {
343 - let truncated: String = name.chars().take(max_len.saturating_sub(3)).collect();
344 - format!("{truncated}...")
345 - }
346 - }
347 -
348 72 /// Hand what is chosen to the operating system as a drag.
349 73 ///
350 74 /// Called by `quasi::panel::dragging_out`, which is where the gesture is read.
@@ -390,28 +114,3 @@
390 114 /// the library when `drag_out` grows a backend for it, not before.
391 115 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
392 116 pub fn start_os_drag(_state: &mut BrowserState) {}
393 -
394 - #[cfg(test)]
395 - mod tests {
396 - use super::*;
397 -
398 - #[test]
399 - fn truncate_name_keeps_short_names() {
400 - assert_eq!(truncate_name("short", 10), "short");
401 - assert_eq!(truncate_name("abcdefghij", 10), "abcdefghij"); // exactly max
402 - }
403 -
404 - #[test]
405 - fn truncate_name_trims_long_names_with_ellipsis() {
406 - assert_eq!(truncate_name("abcdefghijk", 10), "abcdefg...");
407 - }
408 -
409 - #[test]
410 - fn truncate_name_counts_unicode_scalars() {
411 - // 5 multibyte chars, max 4 -> take(1) + "..."
412 - assert_eq!(
413 - truncate_name("\u{3b1}\u{3b1}\u{3b1}\u{3b1}\u{3b1}", 4),
414 - "\u{3b1}..."
415 - );
416 - }
417 - }