Skip to main content

max / audiofiles

One anchor becomes a trail you can walk back down Similarity search kept a single anchor, so three samples into a walk there was no way back to the second: `similarity_search_hash` was overwritten by every new search and the only control was Clear, which threw the whole walk away. The three fields (anchor, kind, name) become one ordered `similarity_trail`, and every reader takes the top through `similarity_anchor`. Where a result lands on the trail is decided in one place: the current step is replaced (that is `refresh_contents` re-running its own query after a delete), a step already walked truncates back to it, anything else is a step forward. The kind travels with the step, so walking back into a near-duplicate search re-runs it as one. Both renderers show the walk. The egui breadcrumb draws each step as a button back to it; the described screen draws the same trail as links to `/here/similar/{at}`, which needed no new vocabulary -- it is the shape `Where::Folder`'s crumbs already had. Backspace pops one step instead of exiting outright, and the press that leaves the first step still exits. No change to how a query is computed: a step is the same one-anchor search that shipped.
Author: Max Johnson <me@maxj.phd> · 2026-08-23 12:34 UTC
Signed with PGP, not checked
Commit: a402c7f77dd293dad7647841dcba5c2f45684ecf
Parent: c55ce94
13 files changed, +527 insertions, -83 deletions
M Cargo.lock +16 -16
@@ -7554,6 +7554,22 @@
7554 7554 "winnow 1.0.4",
7555 7555 ]
7556 7556
7557 + [[patch.unused]]
7558 + name = "kberg"
7559 + version = "0.1.0"
7560 +
7561 + [[patch.unused]]
7562 + name = "ops-status"
7563 + version = "0.1.0"
7564 +
7565 + [[patch.unused]]
7566 + name = "painhours"
7567 + version = "0.1.0"
7568 +
7569 + [[patch.unused]]
7570 + name = "quasi-type"
7571 + version = "0.1.0"
7572 +
7557 7573 [[patch.unused]]
7558 7574 name = "quasi-axum"
7559 7575 version = "0.56.0"
@@ -7581,19 +7597,3 @@
7581 7597 [[patch.unused]]
7582 7598 name = "quasi-webview"
7583 7599 version = "0.56.0"
7584 -
7585 - [[patch.unused]]
7586 - name = "quasi-type"
7587 - version = "0.1.0"
7588 -
7589 - [[patch.unused]]
7590 - name = "kberg"
7591 - version = "0.1.0"
7592 -
7593 - [[patch.unused]]
7594 - name = "ops-status"
7595 - version = "0.1.0"
7596 -
7597 - [[patch.unused]]
7598 - name = "painhours"
7599 - version = "0.1.0"
@@ -435,12 +435,13 @@
435 435 }
436 436 }
437 437 if input.key_pressed(egui::Key::Backspace) || input.key_pressed(egui::Key::ArrowLeft) {
438 - // Two-step Backspace: while in similarity / duplicate mode, the
439 - // first press exits the mode; a follow-up press then falls through
440 - // to "go up one folder." Matches the "Esc closes the dialog, then
441 - // Esc closes the parent" muscle memory.
442 - if state.search.similarity_search_hash.is_some() {
443 - state.clear_similarity_search();
438 + // Step-at-a-time Backspace: while in similarity / duplicate mode,
439 + // each press walks one step back down the trail, and the press that
440 + // leaves the first step exits the mode; a follow-up press then falls
441 + // through to "go up one folder." Matches the "Esc closes the dialog,
442 + // then Esc closes the parent" muscle memory, one dialog per press.
443 + if state.search.in_similarity() {
444 + state.walk_back_one();
444 445 } else {
445 446 state.go_up();
446 447 }
@@ -744,6 +744,8 @@
744 744 GoTo(i64, usize),
745 745 /// Leave whatever mode the list is in.
746 746 Leave,
747 + /// Go back to this step of the similarity trail.
748 + WalkBack(usize),
747 749 /// Switch to this vault.
748 750 OpenVault(i64),
749 751 /// Delete this vault and everything in it.
@@ -2497,11 +2499,25 @@
2497 2499 },
2498 2500 /// Samples that sound like one particular sample.
2499 2501 Similar {
2500 - /// What that sample is called.
2501 - name: String,
2502 + /// The samples walked through to get here, oldest first. The last is
2503 + /// the one the visible results were computed from.
2504 + ///
2505 + /// A trail rather than a name, for [`Where::Folder`]'s reason: each
2506 + /// step is somewhere the reader can go back to, and where they are is
2507 + /// the end of it.
2508 + trail: Vec<Walked>,
2502 2509 },
2503 2510 }
2504 2511
2512 + /// One step of a similarity walk.
2513 + #[derive(Debug, Clone, PartialEq, Eq)]
2514 + pub struct Walked {
2515 + /// What the sample asked about is called.
2516 + pub name: String,
2517 + /// Whether the step asked for near-duplicates rather than similar samples.
2518 + pub near_dup: bool,
2519 + }
2520 +
2505 2521 /// What is being looked for.
2506 2522 #[derive(Debug, Clone, PartialEq, Eq)]
2507 2523 pub struct Searching {
@@ -2656,6 +2672,9 @@
2656 2672
2657 2673 /// Leave whatever mode the list is in.
2658 2674 fn leave(&self);
2675 +
2676 + /// Go back to this step of the similarity trail, this far along it.
2677 + fn walk_back(&self, at: usize);
2659 2678 }
2660 2679
2661 2680 /// The app's toolbar, as the narrow thing a described screen borrows.
@@ -2668,14 +2687,18 @@
2668 2687
2669 2688 impl Bar for FromBar<'_> {
2670 2689 fn place(&self) -> Where {
2671 - if self.state.search.similarity_search_hash.is_some() {
2690 + if self.state.search.in_similarity() {
2672 2691 return Where::Similar {
2673 - name: self
2692 + trail: self
2674 2693 .state
2675 2694 .search
2676 - .similarity_source_name
2677 - .clone()
2678 - .unwrap_or_else(|| "sample".to_owned()),
2695 + .similarity_trail
2696 + .iter()
2697 + .map(|step| Walked {
2698 + name: step.name.clone().unwrap_or_else(|| "sample".to_owned()),
2699 + near_dup: step.near_dup,
2700 + })
2701 + .collect(),
2679 2702 };
2680 2703 }
2681 2704 if let Some(active) = self.state.collections_ui.active_collection {
@@ -2773,6 +2796,10 @@
2773 2796 fn leave(&self) {
2774 2797 self.push(Intent::Leave);
2775 2798 }
2799 +
2800 + fn walk_back(&self, at: usize) {
2801 + self.push(Intent::WalkBack(at));
2802 + }
2776 2803 }
2777 2804
2778 2805 impl FromBar<'_> {
@@ -864,12 +864,16 @@
864 864 // thing to the user: go back to browsing. Which one is showing is
865 865 // what `Where` already says.
866 866 Intent::Leave => {
867 - if state.search.similarity_search_hash.is_some() {
867 + if state.search.in_similarity() {
868 868 state.clear_similarity_search();
869 869 } else {
870 870 state.deactivate_collection();
871 871 }
872 872 }
873 + // Walking back is the app's, not the screen's: the step names a
874 + // sample and returning to it re-runs that sample's query, which is
875 + // the same call the trail was built out of.
876 + Intent::WalkBack(at) => state.walk_back_to(at),
873 877 // The sidebar. Two of these hand an already-agreed decision to the
874 878 // app's own executor: the described control asked with
875 879 // `Act::confirm`, the runtime answered `Step::Ask`, the user said
@@ -19,7 +19,7 @@
19 19 Narrowing, Order, Panel, Panels, Phase, Playing, Preflight, Pricing, ProfileChoice, Queue,
20 20 Queued, Reviewed, Sample, Saying, Scope, Searching, Setting, Settings, Shared, Shell, Source,
21 21 Spread, Stage, State, Status, Strategy, Subject, Subscription, Suggested, Suggestion, Sweep,
22 - Sync, Tagged, ThemeChoice, Vault, VaultChoice, Where, router,
22 + Sync, Tagged, ThemeChoice, Vault, VaultChoice, Walked, Where, router,
23 23 };
24 24
25 25 /// A config store in memory.
@@ -4613,6 +4613,7 @@
4613 4613 fn go_root(&self) {}
4614 4614 fn go_to(&self, _id: i64, _depth: usize) {}
4615 4615 fn leave(&self) {}
4616 + fn walk_back(&self, _at: usize) {}
4616 4617 }
4617 4618
4618 4619 /// A toolbar in memory, recording what was asked of it.
@@ -4727,6 +4728,10 @@
4727 4728 fn leave(&self) {
4728 4729 self.note("leave");
4729 4730 }
4731 +
4732 + fn walk_back(&self, at: usize) {
4733 + self.note(format!("walk back to {at}"));
4734 + }
4730 4735 }
4731 4736
4732 4737 /// A router call against this toolbar.
@@ -4829,7 +4834,10 @@
4829 4834 name: "Favourites".to_owned(),
4830 4835 },
4831 4836 Where::Similar {
4832 - name: "kick.wav".to_owned(),
4837 + trail: vec![Walked {
4838 + name: "kick.wav".to_owned(),
4839 + near_dup: false,
4840 + }],
4833 4841 },
4834 4842 ] {
4835 4843 let bar = FakeBar::at(place);
@@ -4845,19 +4853,104 @@
4845 4853 // One control for both, because leaving either means the same thing to the
4846 4854 // user; which mode is showing is what `Where` already says.
4847 4855 let bar = FakeBar::at(Where::Similar {
4848 - name: "kick.wav".to_owned(),
4856 + trail: vec![Walked {
4857 + name: "kick.wav".to_owned(),
4858 + near_dup: false,
4859 + }],
4849 4860 });
4850 4861 barred(&bar, Request::post("/here/leave")).unwrap();
4851 4862 assert_eq!(bar.asked(), ["leave"]);
4852 4863 }
4853 4864
4865 + #[test]
4866 + fn a_similarity_trail_is_links_back_to_every_sample_walked_through() {
4867 + // The same distinction the folder crumbs make: a step you can return to is
4868 + // a link, and the step you are on is where you are, so it is prose.
4869 + let bar = FakeBar::at(Where::Similar {
4870 + trail: vec![
4871 + Walked {
4872 + name: "kick.wav".to_owned(),
4873 + near_dup: false,
4874 + },
4875 + Walked {
4876 + name: "snare.wav".to_owned(),
4877 + near_dup: false,
4878 + },
4879 + Walked {
4880 + name: "clap.wav".to_owned(),
4881 + near_dup: false,
4882 + },
4883 + ],
4884 + });
4885 + let screen = topped(&bar);
4886 +
4887 + assert_eq!(
4888 + links(&screen),
4889 + [
4890 + (
4891 + "Similar to: kick.wav".to_owned(),
4892 + "/here/similar/0".to_owned()
4893 + ),
4894 + ("snare.wav".to_owned(), "/here/similar/1".to_owned()),
4895 + ]
4896 + );
4897 + assert!(
4898 + said(&screen).contains("clap.wav"),
4899 + "where you are is not a link"
4900 + );
4901 + // Walking is not leaving: the way out of the mode is still offered.
4902 + assert!(
4903 + acts(&screen)
4904 + .iter()
4905 + .any(|label| label == "Back to browsing")
4906 + );
4907 + }
4908 +
4909 + #[test]
4910 + fn the_first_step_says_which_question_was_asked() {
4911 + // Two searches share the view and the trail. Which one this walk started
4912 + // as is the reader's only clue why these rows are the rows.
4913 + let bar = FakeBar::at(Where::Similar {
4914 + trail: vec![Walked {
4915 + name: "kick.wav".to_owned(),
4916 + near_dup: true,
4917 + }],
4918 + });
4919 + assert!(said(&topped(&bar)).contains("Duplicates of: kick.wav"));
4920 + }
4921 +
4922 + #[test]
4923 + fn a_step_is_asked_for_by_how_far_along_it_is() {
4924 + // A position in this walk, not an id: the sample a step asked about is
4925 + // what the app already holds there.
4926 + let bar = FakeBar::at(Where::Similar {
4927 + trail: vec![
4928 + Walked {
4929 + name: "kick.wav".to_owned(),
4930 + near_dup: false,
4931 + },
4932 + Walked {
4933 + name: "snare.wav".to_owned(),
4934 + near_dup: false,
4935 + },
4936 + ],
4937 + });
4938 + barred(&bar, Request::post("/here/similar/0")).unwrap();
4939 + assert_eq!(bar.asked(), ["walk back to 0"]);
4940 +
4941 + assert!(barred(&bar, Request::post("/here/similar/first")).is_err());
4942 + }
4943 +
4854 4944 #[test]
4855 4945 fn the_similarity_mode_says_why_the_columns_stopped_sorting() {
4856 4946 // The shipped breadcrumb moved this off the column headings, "where the
4857 4947 // explanation lived on a control the user had no reason to point at". Here
4858 4948 // it is prose beside the mode, which is where the mode is.
4859 4949 let bar = FakeBar::at(Where::Similar {
4860 - name: "kick.wav".to_owned(),
4950 + trail: vec![Walked {
4951 + name: "kick.wav".to_owned(),
4952 + near_dup: false,
4953 + }],
4861 4954 });
4862 4955 assert!(said(&topped(&bar)).contains("ranked by similarity"));
4863 4956 }
@@ -116,6 +116,7 @@
116 116 .post("/here/root", root)
117 117 .post("/here/{id}/{depth}", go)
118 118 .post("/here/leave", leave)
119 + .post("/here/similar/{at}", walk_back)
119 120 }
120 121
121 122 /// `POST /search`
@@ -235,6 +236,22 @@
235 236 Ok(super::shell::screen(state).into())
236 237 }
237 238
239 + /// `POST /here/similar/{at}`
240 + ///
241 + /// How far along the trail, and nothing else. A folder crumb carries an id as
242 + /// well because a folder is a thing that exists whether or not you walked to
243 + /// it; a similarity step is only ever a position in this walk, and the sample
244 + /// it asked about is what the app already holds there.
245 + fn walk_back(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
246 + let at: usize = request
247 + .captures
248 + .require("at")?
249 + .parse()
250 + .map_err(|_| RouteError::not_found("no such place in the trail"))?;
251 + state.bar.walk_back(at);
252 + Ok(super::shell::screen(state).into())
253 + }
254 +
238 255 /// The toolbar, as a region something else holds.
239 256 pub fn body(state: &Panels<'_>) -> Slot {
240 257 let bar = Slot::new(BAR, RegionKind::Band);
@@ -282,10 +299,44 @@
282 299 Where::Collection { name } => {
283 300 leaving(bar, format!("Collection: {name}"), "Back to browsing")
284 301 }
285 - Where::Similar { name } => leaving(bar, format!("Similar to: {name}"), "Back to browsing")
302 + // A mode, and a trail inside it. The steps are links for the folder
303 + // crumbs' reason and the way out is an act for the collection's, so
304 + // this is the one place where both are true at once: you can go back
305 + // one sample, or out of walking altogether.
306 + Where::Similar { trail } => {
307 + let last = trail.len().saturating_sub(1);
308 + let mut bar = bar;
309 + for (at, step) in trail.iter().enumerate() {
310 + let says = if at == 0 {
311 + let asked = if step.near_dup {
312 + "Duplicates of"
313 + } else {
314 + "Similar to"
315 + };
316 + format!("{asked}: {}", step.name)
317 + } else {
318 + step.name.clone()
319 + };
320 + if at == last {
321 + bar = bar.with(Node::Text {
322 + text: says,
323 + tone: Tone::Info,
324 + });
325 + } else {
326 + bar = bar.with(Node::Link {
327 + text: says,
328 + action: Action::post(format!("/here/similar/{at}")),
329 + });
330 + }
331 + }
332 + bar.with(Node::Act(Act::new(
333 + "Back to browsing",
334 + Action::post("/here/leave"),
335 + )))
286 336 .with(Node::text(
287 337 "Results are ranked by similarity, so column sort is off.",
288 - )),
338 + ))
339 + }
289 340 }
290 341 }
291 342
@@ -1,8 +1,8 @@
1 1 //! Library state: smart folders, collections, similarity search, refresh helpers, mirror.
2 2
3 3 use super::{
4 - Arc, BrowserState, CollectionId, ColumnConfig, ContentsRef, PathBuf, SearchFilter, SortColumn,
5 - SortDirection, error, warn,
4 + Arc, BrowserState, CollectionId, ColumnConfig, ContentsRef, PathBuf, SearchFilter, SimilarStep,
5 + SortColumn, SortDirection, error, warn,
6 6 };
7 7 use crate::backend::{BackendError, BackendResult};
8 8
@@ -265,8 +265,7 @@
265 265 self.nav.current_dir = None; // a filter view has no parent ".." row
266 266 self.search.search_filter = filter.clone();
267 267 self.search.search_query.clone_from(&filter.text_query);
268 - self.search.similarity_search_hash = None;
269 - self.search.similarity_source_name = None;
268 + self.search.similarity_trail.clear();
270 269 self.nav.selection.clear();
271 270 self.refresh_contents();
272 271 }
@@ -303,8 +302,7 @@
303 302 self.nav.contents = ContentsRef::new(nodes);
304 303 self.collections_ui.active_collection = Some(id);
305 304 self.nav.current_dir = None; // a collection view has no parent ".." row
306 - self.search.similarity_search_hash = None;
307 - self.search.similarity_source_name = None;
305 + self.search.similarity_trail.clear();
308 306 self.nav.selection.clear();
309 307 self.status = format!("{count} samples in collection");
310 308 }
@@ -386,17 +384,72 @@
386 384 // The in-flight request resolved, clear it so the repaint-while-busy
387 385 // guard stops and a later search starts from a clean slate.
388 386 self.search.latest_similarity_request = None;
389 - self.search.similarity_search_hash = Some(source_hash.to_string());
390 - self.search.similarity_is_near_dup = is_near_dup;
391 - self.search.similarity_source_name = self.backend.sample_original_name(source_hash).ok();
387 + let name = self.backend.sample_original_name(source_hash).ok();
388 + self.record_similarity_step(source_hash, name, is_near_dup);
392 389 self.nav.selection.clear();
393 390 self.status = format!("Found {count} {noun}");
394 391 }
395 392
393 + /// Where the results that just landed belong on the trail.
394 + ///
395 + /// Three cases, and only one of them grows it. A result for the step
396 + /// already on top is `refresh_contents` re-running the same query after a
397 + /// delete or a move, so it replaces that step rather than repeating it. A
398 + /// result for a step further down is the reader walking back, so the trail
399 + /// truncates to it and the steps after it are gone. Anything else is a new
400 + /// sample asked about from the current one, which is a step forward.
401 + fn record_similarity_step(&mut self, hash: &str, name: Option<String>, near_dup: bool) {
402 + let step = SimilarStep {
403 + hash: hash.to_string(),
404 + name,
405 + near_dup,
406 + };
407 + let existing = self
408 + .search
409 + .similarity_trail
410 + .iter()
411 + .position(|walked| walked.hash == step.hash && walked.near_dup == step.near_dup);
412 + match existing {
413 + Some(at) => {
414 + self.search.similarity_trail.truncate(at + 1);
415 + self.search.similarity_trail[at] = step;
416 + }
417 + None => self.search.similarity_trail.push(step),
418 + }
419 + }
420 +
421 + /// Re-run the step at `at`, dropping everything walked after it.
422 + ///
423 + /// The query is re-run rather than the rows cached: results are ranked
424 + /// against the library as it is now, and a step returned to after a delete
425 + /// should not show what was deleted. `record_similarity_step` does the
426 + /// truncation when the results land, so a step that fails to start leaves
427 + /// the trail alone.
428 + pub fn walk_back_to(&mut self, at: usize) {
429 + let Some(step) = self.search.similarity_trail.get(at).cloned() else {
430 + return;
431 + };
432 + if step.near_dup {
433 + self.find_near_duplicates(&step.hash);
434 + } else {
435 + self.find_similar(&step.hash);
436 + }
437 + }
438 +
439 + /// Drop the newest step and return to the one before it. Leaves similarity
440 + /// mode when the trail had one step, which is what the reader means by
441 + /// backing out of the only search they ran.
442 + pub fn walk_back_one(&mut self) {
443 + match self.search.similarity_trail.len() {
444 + 0 => {}
445 + 1 => self.clear_similarity_search(),
446 + len => self.walk_back_to(len - 2),
447 + }
448 + }
449 +
396 450 /// Clear similarity search mode and return to normal browsing.
397 451 pub fn clear_similarity_search(&mut self) {
398 - self.search.similarity_search_hash = None;
399 - self.search.similarity_source_name = None;
452 + self.search.similarity_trail.clear();
400 453 self.search.latest_similarity_request = None;
401 454 self.refresh_contents();
402 455 }
@@ -46,11 +46,11 @@
46 46 // stale rows until the user exits the view, so re-run the same query; the
47 47 // fresh results replace the view via apply_similarity_results. (Previously
48 48 // this early-returned and did nothing, which was the staleness bug.)
49 - if let Some(hash) = self.search.similarity_search_hash.clone() {
50 - if self.search.similarity_is_near_dup {
51 - self.find_near_duplicates(&hash);
49 + if let Some(step) = self.search.similarity_anchor().cloned() {
50 + if step.near_dup {
51 + self.find_near_duplicates(&step.hash);
52 52 } else {
53 - self.find_similar(&hash);
53 + self.find_similar(&step.hash);
54 54 }
55 55 return;
56 56 }
@@ -262,8 +262,7 @@
262 262
263 263 /// Navigate into the selected directory, or go up if ".." is selected.
264 264 pub fn enter_directory(&mut self) {
265 - self.search.similarity_search_hash = None;
266 - self.search.similarity_source_name = None;
265 + self.search.similarity_trail.clear();
267 266
268 267 if self.has_parent_entry() && self.nav.selection.focus == 0 {
269 268 self.go_up();
@@ -289,8 +288,7 @@
289 288 /// Navigate to the parent directory, or do nothing if already at root.
290 289 /// If a collection is active, exits collection view first.
291 290 pub fn go_up(&mut self) {
292 - self.search.similarity_search_hash = None;
293 - self.search.similarity_source_name = None;
291 + self.search.similarity_trail.clear();
294 292 if self.collections_ui.active_collection.is_some() {
295 293 self.deactivate_collection();
296 294 return;
@@ -322,8 +320,7 @@
322 320 self.nav.current_dir = None;
323 321 self.nav.breadcrumb.clear();
324 322 self.nav.selection.clear();
325 - self.search.similarity_search_hash = None;
326 - self.search.similarity_source_name = None;
323 + self.search.similarity_trail.clear();
327 324 // Persist the selection by VFS id (not index, indices shift when
328 325 // vaults are added or removed) so it restores on next launch.
329 326 super::log_backend_err(
@@ -3650,3 +3650,159 @@
3650 3650 );
3651 3651 }
3652 3652 }
3653 +
3654 + /// The similarity trail: walking through neighbours keeps every anchor.
3655 + mod similarity_trail {
3656 + use super::*;
3657 +
3658 + /// Land a similarity result for `hash`, the way the search worker would.
3659 + /// The request has to match or the result reads as stale and is dropped,
3660 + /// which is `apply_similarity_results`' own guard.
3661 + fn results_for(state: &mut BrowserState, hash: &str, near_dup: bool) {
3662 + state.search.latest_similarity_request = Some((hash.to_string(), near_dup));
3663 + let noun = if near_dup {
3664 + "near-duplicates"
3665 + } else {
3666 + "similar samples"
3667 + };
3668 + state.apply_similarity_results(hash, &[], noun, near_dup);
3669 + }
3670 +
3671 + fn walked(state: &BrowserState) -> Vec<String> {
3672 + state
3673 + .search
3674 + .similarity_trail
3675 + .iter()
3676 + .map(|step| step.hash.clone())
3677 + .collect()
3678 + }
3679 +
3680 + #[test]
3681 + fn each_sample_asked_about_is_another_step() {
3682 + let (mut state, _dir) = make_state();
3683 + add_sample_to_vfs(&state, "aaa", "a.wav");
3684 + add_sample_to_vfs(&state, "bbb", "b.wav");
3685 +
3686 + results_for(&mut state, "aaa", false);
3687 + results_for(&mut state, "bbb", false);
3688 +
3689 + assert_eq!(walked(&state), ["aaa", "bbb"]);
3690 + assert_eq!(
3691 + state
3692 + .search
3693 + .similarity_anchor()
3694 + .map(|step| step.hash.as_str()),
3695 + Some("bbb"),
3696 + "the anchor is the top of the stack"
3697 + );
3698 + }
3699 +
3700 + #[test]
3701 + fn re_running_the_current_anchor_replaces_it_rather_than_repeating_it() {
3702 + // What `refresh_contents` does after a delete or a move inside the
3703 + // view. It is the same step, so the trail must not grow a rung every
3704 + // time a row is removed.
3705 + let (mut state, _dir) = make_state();
3706 + add_sample_to_vfs(&state, "aaa", "a.wav");
3707 +
3708 + results_for(&mut state, "aaa", false);
3709 + results_for(&mut state, "aaa", false);
3710 +
3711 + assert_eq!(walked(&state), ["aaa"]);
3712 + }
3713 +
3714 + #[test]
3715 + fn walking_back_to_a_step_drops_the_ones_after_it() {
3716 + let (mut state, _dir) = make_state();
3717 + for hash in ["aaa", "bbb", "ccc"] {
3718 + add_sample_to_vfs(&state, hash, &format!("{hash}.wav"));
3719 + results_for(&mut state, hash, false);
3720 + }
3721 + assert_eq!(walked(&state), ["aaa", "bbb", "ccc"]);
3722 +
3723 + // Returning to the first step re-runs its query; when those results
3724 + // land, the two steps walked after it are gone.
3725 + results_for(&mut state, "aaa", false);
3726 + assert_eq!(walked(&state), ["aaa"]);
3727 + }
3728 +
3729 + #[test]
3730 + fn the_same_sample_asked_two_ways_is_two_steps() {
3731 + // The kind travels with the step because the queries differ. Walking
3732 + // back into a near-duplicate step must not re-run it as a feature
3733 + // search, so the two cannot collapse into one rung.
3734 + let (mut state, _dir) = make_state();
3735 + add_sample_to_vfs(&state, "aaa", "a.wav");
3736 +
3737 + results_for(&mut state, "aaa", false);
3738 + results_for(&mut state, "aaa", true);
3739 +
3740 + assert_eq!(walked(&state), ["aaa", "aaa"]);
3741 + assert!(state.search.similarity_anchor().unwrap().near_dup);
3742 + }
3743 +
3744 + #[test]
3745 + fn a_step_carries_the_name_the_breadcrumb_shows() {
3746 + let (mut state, _dir) = make_state();
3747 + add_sample_to_vfs(&state, "aaa", "a.wav");
3748 +
3749 + results_for(&mut state, "aaa", false);
3750 +
3751 + assert_eq!(
3752 + state.search.similarity_anchor().unwrap().name.as_deref(),
3753 + Some("aaa.wav"),
3754 + "resolved once, when the results landed, not per frame"
3755 + );
3756 + }
3757 +
3758 + #[test]
3759 + fn backing_out_of_the_only_step_leaves_the_mode() {
3760 + let (mut state, _dir) = make_state();
3761 + add_sample_to_vfs(&state, "aaa", "a.wav");
3762 + results_for(&mut state, "aaa", false);
3763 +
3764 + state.walk_back_one();
3765 +
3766 + assert!(!state.search.in_similarity());
3767 + assert!(state.search.latest_similarity_request.is_none());
3768 + }
3769 +
3770 + #[test]
3771 + fn leaving_similarity_forgets_the_walk() {
3772 + let (mut state, _dir) = make_state();
3773 + add_sample_to_vfs(&state, "aaa", "a.wav");
3774 + add_sample_to_vfs(&state, "bbb", "b.wav");
3775 + results_for(&mut state, "aaa", false);
3776 + results_for(&mut state, "bbb", false);
3777 +
3778 + state.clear_similarity_search();
3779 +
3780 + assert!(
3781 + walked(&state).is_empty(),
3782 + "a trail kept invisibly is a trap"
3783 + );
3784 + }
3785 +
3786 + #[test]
3787 + fn browsing_away_forgets_the_walk() {
3788 + // Every way out of the mode clears it, not just the Clear control.
3789 + let (mut state, _dir) = make_state();
3790 + add_sample_to_vfs(&state, "aaa", "a.wav");
3791 + results_for(&mut state, "aaa", false);
3792 +
3793 + state.go_up();
3794 +
3795 + assert!(walked(&state).is_empty());
3796 + }
3797 +
3798 + #[test]
3799 + fn a_step_past_the_end_of_the_trail_does_nothing() {
3800 + let (mut state, _dir) = make_state();
3801 + add_sample_to_vfs(&state, "aaa", "a.wav");
3802 + results_for(&mut state, "aaa", false);
3803 +
3804 + state.walk_back_to(9);
3805 +
3806 + assert_eq!(walked(&state), ["aaa"], "and it does not panic");
3807 + }
3808 + }
@@ -578,6 +578,24 @@
578 578 pub tag_folders_apply_all_input: String,
579 579 }
580 580
581 + /// One step of the similarity trail: a sample that was asked about, and what
582 + /// kind of question it was.
583 + ///
584 + /// The kind travels with the step because the two searches are different
585 + /// queries over the same anchor. Walking back into a near-duplicate step and
586 + /// re-running it as a feature search would answer a question nobody asked.
587 + #[derive(Debug, Clone, PartialEq, Eq)]
588 + pub struct SimilarStep {
589 + /// Content hash of the sample the results were computed from.
590 + pub hash: String,
591 + /// Display name, resolved when the results landed. `None` when the sample
592 + /// has no stored original name, which the breadcrumb renders as "sample".
593 + pub name: Option<String>,
594 + /// Whether this step was a near-duplicate search rather than a feature
595 + /// similarity search.
596 + pub near_dup: bool,
597 + }
598 +
581 599 /// Search bar, filter panel, and similarity-search state.
582 600 #[derive(Default)]
583 601 pub struct SearchUiState {
@@ -592,23 +610,36 @@
592 610 /// add tag filters from inside the filter panel itself (M-5 closed the
593 611 /// add/remove asymmetry, tag chips already had a remove X, but no entry).
594 612 pub filter_tag_input: String,
595 - pub similarity_search_hash: Option<String>,
613 + /// The samples walked through to get to the current results, oldest first.
614 + /// Empty means normal browsing; the last entry is the anchor the visible
615 + /// results were computed from.
616 + ///
617 + /// A stack rather than one anchor because walking is what the view is for:
618 + /// a sample's neighbours suggest the next sample, and three steps in there
619 + /// was no way back to the second. Every read of "the current similarity
620 + /// search" is [`Self::similarity_anchor`], the top of this stack.
621 + pub similarity_trail: Vec<SimilarStep>,
596 622 /// The most recent similarity request as `(source_hash, is_near_dup)`. Async
597 623 /// results that don't match it are stale (the user fired another search
598 624 /// first) and are dropped, so a slow query can't replace the current view.
599 625 pub latest_similarity_request: Option<(String, bool)>,
600 - /// Whether the active similarity view is a near-duplicate search (vs. a
601 - /// feature-similarity search). Persisted so `refresh_contents` can re-run the
602 - /// correct query when the view is mutated (delete/move) in similarity mode.
603 - pub similarity_is_near_dup: bool,
604 - /// Display name of the source sample for the active similarity / duplicate
605 - /// search. Cached so the breadcrumb can render "Similar to: <name>" without
606 - /// a backend lookup on every frame.
607 - pub similarity_source_name: Option<String>,
608 626 /// Sidebar tag tree filter input.
609 627 pub tag_search: String,
610 628 }
611 629
630 + impl SearchUiState {
631 + /// The sample the visible results were computed from, or `None` when the
632 + /// list is an ordinary folder, search or collection view.
633 + pub fn similarity_anchor(&self) -> Option<&SimilarStep> {
634 + self.similarity_trail.last()
635 + }
636 +
637 + /// Whether the file list is showing similarity or near-duplicate results.
638 + pub fn in_similarity(&self) -> bool {
639 + !self.similarity_trail.is_empty()
640 + }
641 + }
642 +
612 643 /// Loose-files mode integrity tracking.
613 644 #[derive(Default)]
614 645 pub struct LooseFilesUiState {
@@ -388,7 +388,7 @@
388 388 // heading as not sortable instead, so the score order stays trustworthy.
389 389 // The mode says so where it lives, on the toolbar's "Similar to:" segment;
390 390 // a heading nobody has reason to point at is the wrong place to explain it.
391 - let sort_enabled = state.search.similarity_search_hash.is_none();
391 + let sort_enabled = !state.search.in_similarity();
392 392
393 393 let columns = describe(visible, sort_col, &sort_dir, sort_enabled);
394 394
@@ -82,7 +82,10 @@
82 82 ("j / Down".to_string(), "Move down"),
83 83 ("k / Up".to_string(), "Move up"),
84 84 ("Enter / Right".to_string(), "Open / preview"),
85 - ("Backspace / Left".to_string(), "Go up"),
85 + (
86 + "Backspace / Left".to_string(),
87 + "Go up / back one similarity step",
88 + ),
86 89 ("Space".to_string(), "Play / pause"),
87 90 ];
88 91 let selection: &[(String, &str)] = &[
@@ -438,24 +438,48 @@
438 438
439 439 ui.separator();
440 440
441 + // Iterate by reference, defer the mutation, the same way the folder crumbs
442 + // below do: walking back re-runs a query and the trail is borrowed here.
443 + let mut walk_back_to: Option<usize> = None;
444 +
441 445 // Similarity / duplicate search view: replace the folder path with a
442 - // "Similar to: <name>" segment so the breadcrumb reflects the active mode
446 + // trail of the samples walked through, so the breadcrumb reflects the
447 + // active mode instead of the folder the user happened to be in
443 448 // instead of the folder the user happened to be in when they triggered it.
444 - if state.search.similarity_search_hash.is_some() {
445 - let name = state
446 - .search
447 - .similarity_source_name
448 - .as_deref()
449 - .unwrap_or("sample");
449 + if state.search.in_similarity() {
450 450 ui.label("/");
451 - // Why the file list's headings stop responding in this mode. It used to
452 - // be a hover on the headings themselves, which meant the explanation
453 - // lived on a control the user had no reason to point at; it belongs
454 - // where the mode does.
455 - ui.label(widgets::accent_strong(format!("Similar to: {name}")))
456 - .on_hover_text(
457 - "Results are ranked by similarity, so column sort is off. Clear to sort again.",
458 - );
451 + // The trail, read left to right: every sample walked through, each one
452 + // a way back to its own results. The last is where you are, so it is a
453 + // label rather than a button, the same distinction the folder crumbs
454 + // make. Only the current step carries the hover, because the sentence
455 + // is about the list showing now.
456 + let last = state.search.similarity_trail.len().saturating_sub(1);
457 + for (at, step) in state.search.similarity_trail.iter().enumerate() {
458 + let name = step.name.as_deref().unwrap_or("sample");
459 + let says = if at == 0 {
460 + format!("Similar to: {name}")
461 + } else {
462 + name.to_owned()
463 + };
464 + if at == last {
465 + // Why the file list's headings stop responding in this mode. It
466 + // used to be a hover on the headings themselves, which meant the
467 + // explanation lived on a control the user had no reason to point
468 + // at; it belongs where the mode does.
469 + ui.label(widgets::accent_strong(says)).on_hover_text(
470 + "Results are ranked by similarity, so column sort is off. Clear to sort again.",
471 + );
472 + } else {
473 + if ui
474 + .small_button(says)
475 + .on_hover_text("Back to what this sample sounds like")
476 + .clicked()
477 + {
478 + walk_back_to = Some(at);
479 + }
480 + ui.label(">");
481 + }
482 + }
459 483 // M-9: Clear lives at the breadcrumb segment so the mode label and
460 484 // the exit affordance occupy one row, not two.
461 485 if ui
@@ -523,6 +547,10 @@
523 547 }
524 548 }
525 549
550 + if let Some(at) = walk_back_to {
551 + state.walk_back_to(at);
552 + }
553 +
526 554 // Import + Export buttons + Sync + theme selector (right-aligned)
527 555 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
528 556 let import_id = ui.make_persistent_id("import_menu");