Skip to main content

max / audiofiles

Collect several samples and browse what is near all of them The walking interaction was one anchor at a time: a step named a sample and the results were that sample's neighbours. Three steps in, "these two are the sound I want, find me more of it" was not a question the app could be asked. The basket is an ordered set of samples beside the trail, not inside it. The trail is where you have been and the basket is what you are looking for; they are filled in the same gesture, which is exactly why conflating them would be wrong -- taking a step off the trail would take a choice out of the basket. It is drawn on the bar in every mode, each anchor a link that takes it out, and `leave_similarity_mode` is the one place that decides what leaving means, since six navigations used to say `similarity_trail.clear()` on their own. The ranking minimises the worst distance to any anchor, which is the literal reading of near-all. A sample missing from one anchor's neighbourhood is scored from the last row that anchor did return, so the number is a lower bound rather than a guess, and a sample present in every neighbourhood outranks one present in half. Every anchor is excluded from the results, not merely the one being looked up. The one correctness trap is that a distance means something only in the ranges it was normalized under, and the stored graph's ranges are not a fresh tree's. So the basket is one worker command rather than N: `neighbourhoods` takes the graph for every anchor or one tree for every anchor, never a mixture. The single-anchor search now goes through the same lookup and the same merge, which a test pins -- a basket of one is the query that shipped, same rows, same order. Each row shows which anchors it came back for, as badges in a Near column that exists only while a basket answer does. That is the decided part rather than a nice-to-have: a sample near one anchor and a sample near all four are different answers and must not look alike. It needed no new vocabulary -- a run of tokens in a cell is what the tags column already is. Not built: negatives ("away from this") and centroid queries, both ruled out.
Author: Max Johnson <me@maxj.phd> · 2026-08-25 13:13 UTC
Signed with PGP, not checked
Commit: b30e7af68bcaaf04d378729a06d53630f9f6fcbe
Parent: 8f9be0a
16 files changed, +1572 insertions, -78 deletions
M Cargo.lock +17 -17
@@ -1272,7 +1272,7 @@
1272 1272 "libc",
1273 1273 "option-ext",
1274 1274 "redox_users",
1275 - "windows-sys 0.59.0",
1275 + "windows-sys 0.61.2",
1276 1276 ]
1277 1277
1278 1278 [[package]]
@@ -1617,7 +1617,7 @@
1617 1617 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1618 1618 dependencies = [
1619 1619 "libc",
1620 - "windows-sys 0.59.0",
1620 + "windows-sys 0.61.2",
1621 1621 ]
1622 1622
1623 1623 [[package]]
@@ -3224,7 +3224,7 @@
3224 3224 "once_cell",
3225 3225 "png",
3226 3226 "thiserror 2.0.20",
3227 - "windows-sys 0.60.2",
3227 + "windows-sys 0.61.2",
3228 3228 ]
3229 3229
3230 3230 [[package]]
@@ -3309,7 +3309,7 @@
3309 3309 source = "registry+https://github.com/rust-lang/crates.io-index"
3310 3310 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3311 3311 dependencies = [
3312 - "windows-sys 0.59.0",
3312 + "windows-sys 0.61.2",
3313 3313 ]
3314 3314
3315 3315 [[package]]
@@ -4665,7 +4665,7 @@
4665 4665 "errno",
4666 4666 "libc",
4667 4667 "linux-raw-sys 0.12.1",
4668 - "windows-sys 0.59.0",
4668 + "windows-sys 0.61.2",
4669 4669 ]
4670 4670
4671 4671 [[package]]
@@ -4722,7 +4722,7 @@
4722 4722 "security-framework",
4723 4723 "security-framework-sys",
4724 4724 "webpki-root-certs",
4725 - "windows-sys 0.59.0",
4725 + "windows-sys 0.61.2",
4726 4726 ]
4727 4727
4728 4728 [[package]]
@@ -5118,7 +5118,7 @@
5118 5118 checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
5119 5119 dependencies = [
5120 5120 "libc",
5121 - "windows-sys 0.60.2",
5121 + "windows-sys 0.61.2",
5122 5122 ]
5123 5123
5124 5124 [[package]]
@@ -5612,7 +5612,7 @@
5612 5612
5613 5613 [[package]]
5614 5614 name = "synckit-client"
5615 - version = "0.9.0"
5615 + version = "0.9.1"
5616 5616 dependencies = [
5617 5617 "apple-native-keyring-store",
5618 5618 "argon2",
@@ -5712,10 +5712,10 @@
5712 5712 checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
5713 5713 dependencies = [
5714 5714 "fastrand",
5715 - "getrandom 0.3.4",
5715 + "getrandom 0.4.3",
5716 5716 "once_cell",
5717 5717 "rustix 1.1.4",
5718 - "windows-sys 0.59.0",
5718 + "windows-sys 0.61.2",
5719 5719 ]
5720 5720
5721 5721 [[package]]
@@ -6122,7 +6122,7 @@
6122 6122 "once_cell",
6123 6123 "png",
6124 6124 "thiserror 2.0.20",
6125 - "windows-sys 0.60.2",
6125 + "windows-sys 0.61.2",
6126 6126 ]
6127 6127
6128 6128 [[package]]
@@ -6154,7 +6154,7 @@
6154 6154 dependencies = [
6155 6155 "memoffset",
6156 6156 "tempfile",
6157 - "windows-sys 0.60.2",
6157 + "windows-sys 0.61.2",
6158 6158 ]
6159 6159
6160 6160 [[package]]
@@ -6746,7 +6746,7 @@
6746 6746 source = "registry+https://github.com/rust-lang/crates.io-index"
6747 6747 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
6748 6748 dependencies = [
6749 - "windows-sys 0.59.0",
6749 + "windows-sys 0.61.2",
6750 6750 ]
6751 6751
6752 6752 [[package]]
@@ -7554,10 +7554,6 @@
7554 7554 "winnow 1.0.4",
7555 7555 ]
7556 7556
7557 - [[patch.unused]]
7558 - name = "quasi-type"
7559 - version = "0.1.0"
7560 -
7561 7557 [[patch.unused]]
7562 7558 name = "kberg"
7563 7559 version = "0.1.0"
@@ -7597,3 +7593,7 @@
7597 7593 [[patch.unused]]
7598 7594 name = "quasi-webview"
7599 7595 version = "0.56.0"
7596 +
7597 + [[patch.unused]]
7598 + name = "quasi-type"
7599 + version = "0.1.0"
@@ -5,6 +5,7 @@
5 5 use crate::vp_tree::VpTree;
6 6 use rusqlite::{Connection, params};
7 7 use serde::{Deserialize, Serialize};
8 + use std::collections::{HashMap, HashSet};
8 9 use tracing::instrument;
9 10
10 11 /// Audio feature vector for similarity comparison.
@@ -931,12 +932,295 @@
931 932 }
932 933 }
933 934
935 + /// One row of a basket answer: a sample, which anchors it came back for, and how
936 + /// near it is to the whole basket.
937 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
938 + pub struct BasketHit {
939 + /// The sample found.
940 + pub hash: String,
941 + /// The anchors whose neighbourhoods it appeared in, in basket order.
942 + ///
943 + /// The accounting rather than a count: a sample near one anchor and a sample
944 + /// near all four are different answers, and a merged ranking that showed
945 + /// only a number would be one the reader has to take on trust.
946 + pub matched: Vec<String>,
947 + /// How far it is from the anchor it is furthest from. Smaller is nearer.
948 + pub score: f64,
949 + }
950 +
951 + /// Merge several anchors' neighbourhoods into one "near all of these" ranking.
952 + ///
953 + /// # The distances have to mean the same thing
954 + ///
955 + /// Every distance here is weighted Euclidean over a min-max-normalized vector,
956 + /// so it is a number only in the ranges it was normalized under. Two anchors
957 + /// answered under different ranges produce numbers that cannot be compared, and
958 + /// merging them would rank on an artefact. **This function cannot check that**,
959 + /// because a `Vec<SimilarResult>` does not carry the ranges it was computed
960 + /// under; the caller has to have read every neighbourhood from one source. The
961 + /// worker does: it takes the stored graph for every anchor or the tree for every
962 + /// anchor, never a mixture.
963 + ///
964 + /// # Minimise the worst distance to any anchor
965 + ///
966 + /// The literal reading of "near all of these": a sample's score is its distance
967 + /// from whichever anchor it is furthest from, and the ranking minimises that.
968 + /// A sample near three anchors and far from the fourth is not near the basket.
969 + ///
970 + /// An anchor's neighbourhood is its nearest `k`, so a sample missing from one is
971 + /// not at a known distance from it, only further than the last row that came
972 + /// back. That last row's distance is what stands in, which makes the score a
973 + /// lower bound on the true worst distance rather than a guess: it can rank a
974 + /// sample better than it deserves, never worse. The effect is what it should be
975 + /// anyway, since a sample present in every neighbourhood outranks one present in
976 + /// half.
977 + ///
978 + /// # The basket is never in its own results
979 + ///
980 + /// Every anchor is excluded, not merely the one being looked up. A per-anchor
981 + /// query drops itself, so a basket of four would otherwise hand back its own
982 + /// members as its top answers.
983 + pub fn merge_neighbourhoods(
984 + neighbourhoods: &[(String, Vec<SimilarResult>)],
985 + limit: usize,
986 + ) -> Vec<BasketHit> {
987 + let anchors: HashSet<&str> = neighbourhoods
988 + .iter()
989 + .map(|(anchor, _)| anchor.as_str())
990 + .collect();
991 +
992 + // What stands in for "not in this anchor's neighbourhood": the distance of
993 + // the last row it did return. An empty neighbourhood answers nothing about
994 + // anything, so it contributes no penalty rather than an infinite one, which
995 + // would make every result unrankable the moment one anchor came back cold.
996 + let penalties: Vec<Option<f64>> = neighbourhoods
997 + .iter()
998 + .map(|(_, rows)| {
999 + rows.iter()
1000 + .map(|row| row.distance)
1001 + .fold(None, |worst: Option<f64>, d| {
1002 + Some(worst.map_or(d, |w| w.max(d)))
1003 + })
1004 + })
1005 + .collect();
1006 +
1007 + // Distance per anchor per candidate, in basket order, so the accounting
1008 + // below reads out in the order the reader put the anchors in.
1009 + let mut found: HashMap<&str, Vec<Option<f64>>> = HashMap::new();
1010 + for (at, (_, rows)) in neighbourhoods.iter().enumerate() {
1011 + for row in rows {
1012 + if anchors.contains(row.hash.as_str()) {
1013 + continue;
1014 + }
1015 + let seen = found
1016 + .entry(row.hash.as_str())
1017 + .or_insert_with(|| vec![None; neighbourhoods.len()]);
1018 + // A neighbourhood should not list a sample twice; if one does, the
1019 + // nearer reading is the one to keep rather than whichever came last.
1020 + seen[at] = Some(seen[at].map_or(row.distance, |d: f64| d.min(row.distance)));
1021 + }
1022 + }
1023 +
1024 + let mut hits: Vec<BasketHit> = found
1025 + .into_iter()
1026 + .map(|(hash, per_anchor)| {
1027 + let mut matched = Vec::new();
1028 + let mut score: f64 = 0.0;
1029 + for (at, distance) in per_anchor.iter().enumerate() {
1030 + match distance {
1031 + Some(d) => {
1032 + matched.push(neighbourhoods[at].0.clone());
1033 + score = score.max(*d);
1034 + }
1035 + None => {
1036 + if let Some(penalty) = penalties[at] {
1037 + score = score.max(penalty);
1038 + }
1039 + }
1040 + }
1041 + }
1042 + BasketHit {
1043 + hash: hash.to_owned(),
1044 + matched,
1045 + score,
1046 + }
1047 + })
1048 + .collect();
1049 +
1050 + // Score first, which is the ranking. The two tiebreaks are for a stable
1051 + // answer rather than a better one: a basket whose rows shuffle between
1052 + // identical queries is a list the reader cannot keep their place in.
1053 + hits.sort_by(|a, b| {
1054 + a.score
1055 + .total_cmp(&b.score)
1056 + .then_with(|| b.matched.len().cmp(&a.matched.len()))
1057 + .then_with(|| a.hash.cmp(&b.hash))
1058 + });
1059 + hits.truncate(limit);
1060 + hits
1061 + }
1062 +
934 1063 #[cfg(test)]
935 1064 mod tests {
936 1065 use super::*;
937 1066 use crate::analysis::{self, AnalysisResult};
938 1067 use crate::test_helpers::insert_fake_sample;
939 1068
1069 + /// `(anchor, neighbours)` from a list of `(hash, distance)` pairs.
1070 + fn hood(anchor: &str, rows: &[(&str, f64)]) -> (String, Vec<SimilarResult>) {
1071 + (
1072 + anchor.to_owned(),
1073 + rows.iter()
1074 + .map(|(hash, distance)| SimilarResult {
1075 + hash: (*hash).to_owned(),
1076 + distance: *distance,
1077 + })
1078 + .collect(),
1079 + )
1080 + }
1081 +
1082 + fn ranked(hits: &[BasketHit]) -> Vec<&str> {
1083 + hits.iter().map(|hit| hit.hash.as_str()).collect()
1084 + }
1085 +
1086 + /// The contract the basket rests on: one anchor merged is the one-anchor
1087 + /// query, same rows in the same order. If this ever fails, a basket of one
1088 + /// has become a second kind of search rather than a special case of this one.
1089 + #[test]
1090 + fn a_basket_of_one_is_the_single_anchor_query() {
1091 + let rows = [("b", 0.1), ("c", 0.4), ("d", 0.2)];
1092 + let hits = merge_neighbourhoods(&[hood("a", &rows)], 10);
1093 +
1094 + let mut expected: Vec<(&str, f64)> = rows.to_vec();
1095 + expected.sort_by(|a, b| a.1.total_cmp(&b.1));
1096 + assert_eq!(
1097 + ranked(&hits),
1098 + expected.iter().map(|(h, _)| *h).collect::<Vec<_>>()
1099 + );
1100 + for hit in &hits {
1101 + assert_eq!(hit.matched, vec!["a".to_owned()]);
1102 + }
1103 + }
1104 +
1105 + /// "Near all of these" is the worst distance, not the sum and not the best.
1106 + /// `near_both` is further from the first anchor than `near_one` is, and still
1107 + /// wins, because `near_one` is not near the second anchor at all.
1108 + #[test]
1109 + fn the_ranking_minimises_the_worst_distance_to_any_anchor() {
1110 + let hits = merge_neighbourhoods(
1111 + &[
1112 + hood(
1113 + "a",
1114 + &[("near_one", 0.05), ("near_both", 0.30), ("far", 0.90)],
1115 + ),
1116 + hood("b", &[("near_both", 0.20), ("far", 0.80)]),
1117 + ],
1118 + 10,
1119 + );
1120 +
1121 + assert_eq!(ranked(&hits), vec!["near_both", "near_one", "far"]);
1122 + let near_both = &hits[0];
1123 + assert!(
1124 + (near_both.score - 0.30).abs() < f64::EPSILON,
1125 + "{near_both:?}"
1126 + );
1127 + assert_eq!(near_both.matched, vec!["a".to_owned(), "b".to_owned()]);
1128 + }
1129 +
1130 + /// A sample missing from an anchor's neighbourhood is not at a known
1131 + /// distance from it. The last row that anchor did return stands in, so the
1132 + /// score is a lower bound: `near_one` scores 0.80 -- b's worst -- rather than
1133 + /// its own 0.05.
1134 + #[test]
1135 + fn a_sample_missing_from_a_neighbourhood_is_scored_from_that_anchors_last_row() {
1136 + let hits = merge_neighbourhoods(
1137 + &[
1138 + hood("a", &[("near_one", 0.05)]),
1139 + hood("b", &[("other", 0.80)]),
1140 + ],
1141 + 10,
1142 + );
1143 +
1144 + let near_one = hits.iter().find(|hit| hit.hash == "near_one").unwrap();
1145 + assert!((near_one.score - 0.80).abs() < f64::EPSILON, "{near_one:?}");
1146 + assert_eq!(near_one.matched, vec!["a".to_owned()]);
1147 + }
1148 +
1149 + /// The accounting is the decided part, and it reads out in basket order
1150 + /// however the anchors happened to answer.
1151 + #[test]
1152 + fn each_hit_names_the_anchors_it_answered_to_in_basket_order() {
1153 + let hits = merge_neighbourhoods(
1154 + &[
1155 + hood("a", &[("x", 0.1)]),
1156 + hood("b", &[]),
1157 + hood("c", &[("x", 0.2)]),
1158 + ],
1159 + 10,
1160 + );
1161 +
1162 + let x = hits.iter().find(|hit| hit.hash == "x").unwrap();
1163 + assert_eq!(x.matched, vec!["a".to_owned(), "c".to_owned()]);
1164 + }
1165 +
1166 + /// Every anchor, not merely the one being looked up. A per-anchor query drops
1167 + /// itself, so without this a basket of two hands back its own members.
1168 + #[test]
1169 + fn no_anchor_comes_back_as_its_own_result() {
1170 + let hits = merge_neighbourhoods(
1171 + &[
1172 + hood("a", &[("b", 0.01), ("x", 0.5)]),
1173 + hood("b", &[("a", 0.01), ("x", 0.6)]),
1174 + ],
1175 + 10,
1176 + );
1177 +
1178 + assert_eq!(ranked(&hits), vec!["x"]);
1179 + }
1180 +
1181 + /// An anchor that came back cold says nothing about anything, so it must not
1182 + /// make every other anchor's answers unrankable.
1183 + #[test]
1184 + fn an_anchor_with_no_neighbours_penalises_nothing() {
1185 + let hits = merge_neighbourhoods(&[hood("a", &[("x", 0.2)]), hood("b", &[])], 10);
1186 +
1187 + let x = hits.iter().find(|hit| hit.hash == "x").unwrap();
1188 + assert!((x.score - 0.2).abs() < f64::EPSILON, "{x:?}");
1189 + }
1190 +
1191 + /// Two queries over an unchanged library give the same list in the same
1192 + /// order, including where scores tie. A ranking that shuffles is one the
1193 + /// reader cannot keep their place in.
1194 + #[test]
1195 + fn the_ranking_is_stable_where_scores_tie() {
1196 + let tied = [
1197 + hood("a", &[("q", 0.5), ("p", 0.5), ("r", 0.5)]),
1198 + hood("b", &[("r", 0.5), ("q", 0.5), ("p", 0.5)]),
1199 + ];
1200 + assert_eq!(
1201 + ranked(&merge_neighbourhoods(&tied, 10)),
1202 + vec!["p", "q", "r"]
1203 + );
1204 + assert_eq!(
1205 + merge_neighbourhoods(&tied, 10),
1206 + merge_neighbourhoods(&tied, 10)
1207 + );
1208 + }
1209 +
1210 + #[test]
1211 + fn an_empty_basket_answers_nothing() {
1212 + assert!(merge_neighbourhoods(&[], 10).is_empty());
1213 + }
1214 +
1215 + #[test]
1216 + fn the_limit_takes_the_nearest_rather_than_the_first_found() {
1217 + let hits = merge_neighbourhoods(
1218 + &[hood("a", &[("far", 0.9), ("near", 0.1), ("mid", 0.5)])],
1219 + 2,
1220 + );
1221 + assert_eq!(ranked(&hits), vec!["near", "mid"]);
1222 + }
1223 +
940 1224 /// `feature_distance` is documented as a true weighted-Euclidean metric, the
941 1225 /// VP-tree's triangle-inequality prune depends on it (a pseudometric could
942 1226 /// drop a genuine nearest neighbour). Prove the property rather than assert it
@@ -187,8 +187,10 @@
187 187
188 188 // Search events (similarity / near-duplicate VP-tree build + query run on a
189 189 // worker thread; the GUI resolves the ranked result hashes to nodes when they
190 - // land). Only the hashes cross the boundary, distance/score is unused by the
191 - // list view, so the result structs needn't be serde/Debug.
190 + // land). For the two single-anchor searches only the hashes cross: the list
191 + // view shows a ranking and says nothing about distance. The basket is the
192 + // exception -- it shows which anchors each row came back for -- so
193 + // `BasketHit` is serde where `SimilarResult` is not.
192 194 SimilarResults {
193 195 source: String,
194 196 hashes: Vec<String>,
@@ -197,6 +199,15 @@
197 199 source: String,
198 200 hashes: Vec<String>,
199 201 },
202 + /// Ranked basket hits, each naming the anchors it answered to.
203 + ///
204 + /// The one search result whose distance does cross the boundary, in
205 + /// `BasketHit::matched`: the list view shows which anchors a row came back
206 + /// for, so unlike the two above it is not only the hashes that are wanted.
207 + BasketResults {
208 + sources: Vec<String>,
209 + hits: Vec<audiofiles_core::similarity::BasketHit>,
210 + },
200 211 SearchError {
201 212 error: String,
202 213 },
@@ -682,6 +693,14 @@
682 693 /// Start a near-duplicate search for `hash` (fingerprint comparison). Results
683 694 /// arrive via [`BackendEvent::NearDuplicateResults`] (or `SearchError`).
684 695 fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()>;
696 +
697 + /// Start a basket search: the samples nearest to every one of `hashes`.
698 + /// Results arrive via [`BackendEvent::BasketResults`] (or `SearchError`).
699 + ///
700 + /// One call rather than one per anchor, because every anchor has to be
701 + /// answered under one set of normalization ranges for the distances to be
702 + /// comparable. The worker's `neighbourhoods` is where that is enforced.
703 + fn start_find_similar_to_all(&self, hashes: &[String], limit: usize) -> BackendResult<()>;
685 704 }
686 705
687 706 /// Content-addressed store: import, sample paths, trash, and loose-file maintenance.
@@ -28,6 +28,16 @@
28 28 pub enum SearchCommand {
29 29 /// Find samples similar to `hash` (builds the similarity index if needed).
30 30 FindSimilar { hash: String, limit: usize },
31 + /// Find the samples nearest to *all* of `hashes` at once: the basket query.
32 + ///
33 + /// One command rather than N `FindSimilar` calls, and not for the round
34 + /// trips. Distances are only comparable within the ranges they were
35 + /// normalized under, so every anchor has to be answered from one source --
36 + /// the stored graph for all of them, or one freshly built tree for all of
37 + /// them. Split across N commands the worker could take the graph for some
38 + /// and the tree for others, and the merged ranking would be an artefact of
39 + /// which anchor happened to be queued for update.
40 + FindSimilarToAll { hashes: Vec<String>, limit: usize },
31 41 /// Find near-duplicates of `hash` by fingerprint (builds the index if needed).
32 42 FindNearDuplicates { hash: String, limit: usize },
33 43 /// Drop both cached indexes so the next query rebuilds from fresh data, and
@@ -46,6 +56,14 @@
46 56 SimilarResults { source: String, hashes: Vec<String> },
47 57 /// Ranked near-duplicate hashes for `source`.
48 58 NearDuplicateResults { source: String, hashes: Vec<String> },
59 + /// Ranked basket hits for `sources`, each carrying the anchors it answered
60 + /// to. `sources` echoes the basket the query was run for, so a stale answer
61 + /// can be told apart from the current one the way the single-anchor path
62 + /// tells them apart by `source`.
63 + BasketResults {
64 + sources: Vec<String>,
65 + hits: Vec<similarity::BasketHit>,
66 + },
49 67 /// A search failed (DB open / load / build error).
50 68 Error { error: String },
51 69 }
@@ -96,6 +114,95 @@
96 114 )
97 115 }
98 116
117 + /// Every anchor's neighbourhood, all of them read from one source.
118 + ///
119 + /// **This is where the basket's one correctness trap is closed.** A distance is
120 + /// weighted Euclidean over a min-max-normalized vector, so it means something
121 + /// only in the ranges it was normalized under: the stored graph's ranges are
122 + /// whatever the last build used (`neighbour_graph_meta.ranges`), and a freshly
123 + /// built tree's are computed from the library as it is now. Merging a number
124 + /// from one with a number from the other ranks on an artefact.
125 + ///
126 + /// So the choice is made once for the whole basket rather than per anchor. The
127 + /// graph answers every anchor or none of them: it declines for a stale graph and
128 + /// for any sample queued for update, and one anchor declining sends the whole
129 + /// query to the tree. That is why the basket is one command and not N.
130 + ///
131 + /// The tree is built once and every anchor queried through it, so a basket costs
132 + /// one build rather than one per anchor.
133 + ///
134 + /// An anchor whose features cannot be loaded contributes an empty neighbourhood
135 + /// instead of failing the query -- one unanalysed sample in a basket of four
136 + /// should narrow the answer, not refuse it. When *every* anchor fails that way
137 + /// the error is returned, which is what keeps the one-anchor case reporting a
138 + /// missing sample exactly as it did before.
139 + fn neighbourhoods(
140 + state: &mut SearchState,
141 + hashes: &[String],
142 + limit: usize,
143 + ) -> Result<Vec<(String, Vec<similarity::SimilarResult>)>, audiofiles_core::error::CoreError> {
144 + if hashes.is_empty() {
145 + return Ok(Vec::new());
146 + }
147 +
148 + // The stored graph first: for a library that has not changed this is a table
149 + // read per anchor and no VP-tree is built at all, which is the point of the
150 + // graph. A miss on any anchor abandons the whole attempt.
151 + let mut stored = Vec::with_capacity(hashes.len());
152 + for hash in hashes {
153 + match similarity::NeighbourGraph::neighbours(&state.db, hash, limit) {
154 + Ok(Some(rows)) => stored.push((hash.clone(), rows)),
155 + Ok(None) => break,
156 + Err(e) => {
157 + tracing::warn!("neighbour graph read failed, using the tree: {e}");
158 + break;
159 + }
160 + }
161 + }
162 + if stored.len() == hashes.len() {
163 + return Ok(stored);
164 + }
165 +
166 + // Evict the other index: at most one VP-tree resident at a time.
167 + state.fingerprint_index = None;
168 + if state.similarity_index.is_none() {
169 + let data = similarity::SimilarityIndex::load_data(&state.db)?;
170 + state.similarity_index = Some(similarity::SimilarityIndex::build_from_data(data));
171 + }
172 + let built = state.similarity_index.as_ref().expect("set above");
173 +
174 + let mut hoods = Vec::with_capacity(hashes.len());
175 + let mut last_error = None;
176 + for hash in hashes {
177 + match similarity::load_features(&state.db, hash) {
178 + Ok(features) => hoods.push((hash.clone(), built.find_similar(hash, &features, limit))),
179 + Err(e) => {
180 + last_error = Some(e);
181 + hoods.push((hash.clone(), Vec::new()));
182 + }
183 + }
184 + }
185 + match last_error {
186 + Some(e) if hoods.iter().all(|(_, rows)| rows.is_empty()) => Err(e),
187 + _ => Ok(hoods),
188 + }
189 + }
190 +
191 + /// Bring the stored graph up to date on the tree that is now resident.
192 + ///
193 + /// Called after the answer is emitted, never before: the reader waits on the
194 + /// query, not on the bookkeeping. Best-effort and a no-op when no tree was
195 + /// built, since the graph answering is exactly the case where none was. The GUI
196 + /// connection may hold the write lock, and a refresh that loses that race just
197 + /// happens on the next query.
198 + fn catch_the_graph_up(state: &SearchState) {
199 + if let Some(index) = state.similarity_index.as_ref()
200 + && let Err(e) = similarity::NeighbourGraph::refresh(&state.db, index)
201 + {
202 + tracing::warn!("neighbour graph refresh failed: {e}");
203 + }
204 + }
205 +
99 206 fn search_step(state: &mut SearchState, cmd: SearchCommand, ctx: &WorkerCtx<SearchEvent>) {
100 207 match cmd {
101 208 SearchCommand::Invalidate { changed } => {
@@ -117,57 +224,34 @@
117 224 }
118 225 }
119 226 SearchCommand::FindSimilar { hash, limit } => {
120 - // A malformed feature row that panics is caught by the runtime and
121 - // reported as Error; the worker survives for the next query.
122 - // The stored graph answers first: for a library that has not changed
123 - // this is a table read and no VP-tree is built at all, which is the
124 - // point of the graph. Everything below runs only on a miss.
125 - match similarity::NeighbourGraph::neighbours(&state.db, &hash, limit) {
126 - Ok(Some(rows)) => {
127 - ctx.emit(SearchEvent::SimilarResults {
128 - source: hash,
129 - hashes: rows.into_iter().map(|r| r.hash).collect(),
130 - });
131 - return;
132 - }
133 - Ok(None) => {}
134 - Err(e) => tracing::warn!("neighbour graph read failed, using the tree: {e}"),
135 - }
136 -
137 - // Evict the other index: at most one VP-tree resident at a time.
138 - state.fingerprint_index = None;
139 - let result: Result<Vec<String>, audiofiles_core::error::CoreError> = (|| {
140 - if state.similarity_index.is_none() {
141 - let data = similarity::SimilarityIndex::load_data(&state.db)?;
142 - state.similarity_index =
143 - Some(similarity::SimilarityIndex::build_from_data(data));
144 - }
145 - let built = state.similarity_index.as_ref().expect("set above");
146 - let features = similarity::load_features(&state.db, &hash)?;
147 - Ok(built
148 - .find_similar(&hash, &features, limit)
149 - .into_iter()
150 - .map(|r| r.hash)
151 - .collect())
152 - })();
153 - ctx.emit(match result {
154 - Ok(hashes) => SearchEvent::SimilarResults {
227 + // One anchor is a basket of one, down to the same merge: see
228 + // `neighbourhoods`. The event shape differs because this query is a
229 + // step on the trail and the basket is not, but the answer does not.
230 + match neighbourhoods(state, std::slice::from_ref(&hash), limit) {
231 + Ok(hoods) => ctx.emit(SearchEvent::SimilarResults {
155 232 source: hash,
156 - hashes,
157 - },
158 - Err(e) => SearchEvent::Error {
233 + hashes: similarity::merge_neighbourhoods(&hoods, limit)
234 + .into_iter()
235 + .map(|hit| hit.hash)
236 + .collect(),
237 + }),
238 + Err(e) => ctx.emit(SearchEvent::Error {
159 239 error: e.to_string(),
160 - },
161 - });
162 -
163 - // Answer first, then catch the graph up on the tree that is now
164 - // resident. Best-effort: the GUI connection may hold the write lock,
165 - // and a refresh that loses that race just happens on the next query.
166 - if let Some(index) = state.similarity_index.as_ref()
167 - && let Err(e) = similarity::NeighbourGraph::refresh(&state.db, index)
168 - {
169 - tracing::warn!("neighbour graph refresh failed: {e}");
240 + }),
170 241 }
242 + catch_the_graph_up(state);
243 + }
244 + SearchCommand::FindSimilarToAll { hashes, limit } => {
245 + match neighbourhoods(state, &hashes, limit) {
246 + Ok(hoods) => ctx.emit(SearchEvent::BasketResults {
247 + hits: similarity::merge_neighbourhoods(&hoods, limit),
248 + sources: hashes,
249 + }),
250 + Err(e) => ctx.emit(SearchEvent::Error {
251 + error: e.to_string(),
252 + }),
253 + }
254 + catch_the_graph_up(state);
171 255 }
172 256 SearchCommand::FindNearDuplicates { hash, limit } => {
173 257 // Evict the other index: at most one VP-tree resident at a time.
@@ -238,7 +322,9 @@
238 322 assert!(hashes.is_empty());
239 323 }
240 324 SearchEvent::Error { .. } => {} // acceptable: no features for the hash
241 - SearchEvent::NearDuplicateResults { .. } => panic!("wrong event kind"),
325 + SearchEvent::NearDuplicateResults { .. } | SearchEvent::BasketResults { .. } => {
326 + panic!("wrong event kind")
327 + }
242 328 }
243 329
244 330 // Invalidate + a second query still respond (worker survived).
@@ -117,6 +117,10 @@
117 117 const PEAK: &str = "Peak dB";
118 118 const TAGS: &str = "Tags";
119 119 const PLAY: &str = "Play";
120 + /// The anchors a row answered to. Not in the sort routes' vocabulary and not
121 + /// sortable: a basket answer is already ranked, exactly as a one-anchor answer
122 + /// is, and the toolbar says so.
123 + const NEAR: &str = "Near";
120 124
121 125 /// What the Add to Collection act asks for, and what its handler reads back.
122 126 const COLLECTION: &str = "collection";
@@ -136,6 +140,7 @@
136 140 .post("/files/{id}/path/copy", copy_path)
137 141 .post("/files/{id}/reveal", reveal)
138 142 .post("/files/{id}/similar", find_similar)
143 + .post("/files/{id}/basket", basket)
139 144 .post("/files/{id}/duplicates", find_duplicates)
140 145 .post("/files/{id}/edit", edit)
141 146 .post("/files/{id}/instrument", instrument)
@@ -204,6 +209,17 @@
204 209 Ok(screen(state).into())
205 210 }
206 211
212 + /// `POST /files/{id}/basket`
213 + ///
214 + /// One address for in and out. The row shows which it is, so the reader is
215 + /// answering "is this part of what I am looking for" rather than choosing
216 + /// between two verbs that differ by a word.
217 + fn basket(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
218 + let id = id_of(&request)?;
219 + state.files.toggle_basket(id);
220 + Ok(screen(state).into())
221 + }
222 +
207 223 /// `POST /files/{id}/duplicates`
208 224 fn find_duplicates(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
209 225 let id = id_of(&request)?;
@@ -366,7 +382,16 @@
366 382 columns: columns(state, shown),
367 383 rows: samples
368 384 .iter()
369 - .map(|sample| row(sample, shown, current, collection, &collections))
385 + .map(|sample| {
386 + row(
387 + sample,
388 + shown,
389 + current,
390 + collection,
391 + &collections,
392 + state.bar.basket_showing(),
393 + )
394 + })
370 395 .collect(),
371 396 // Everything the app has loaded and filtered is here. Windowing
372 397 // rows it already holds is a renderer's job, which is the note in
@@ -407,6 +432,17 @@
407 432 };
408 433
409 434 let mut columns = vec![data(NAME, Priority::Essential)];
435 + // Only while a basket answer is on screen, because it is the only time the
436 + // question it answers ("which of them is this near?") can be asked. Second
437 + // rather than last: it is why the row is in the list at all, and a reason
438 + // shown after the tempo and the key reads as a detail about the row instead.
439 + if state.bar.basket_showing() {
440 + columns.push(
441 + Column::new(NEAR)
442 + .width(Width::Fixed)
443 + .priority(Priority::Secondary),
444 + );
445 + }
410 446 if shown.duration {
411 447 columns.push(data(DUR, Priority::Secondary));
412 448 }
@@ -435,8 +471,24 @@
435 471 current: Option<i64>,
436 472 collection: bool,
437 473 collections: &[Collection],
474 + basket_showing: bool,
438 475 ) -> Cells {
439 476 let mut values = vec![Cell::new(&sample.name)];
477 + if basket_showing {
478 + // A badge per anchor, in basket order, the way the tags cell holds a run
479 + // of tags: each anchor keeps its own edges rather than becoming a comma
480 + // in a sentence. Three badges beside one badge is the whole point --
481 + // "near all of these" and "near one of these" are different answers and
482 + // must not look alike.
483 + let mut cell = match sample.matched.first() {
484 + Some(first) => Cell::tag(Tag::badge(first.clone())),
485 + None => Cell::new(""),
486 + };
487 + for anchor in sample.matched.iter().skip(1) {
488 + cell = cell.token(Tag::badge(anchor.clone()));
489 + }
490 + values.push(cell);
491 + }
440 492 if shown.duration {
441 493 values.push(Cell::new(seconds(sample.duration)));
442 494 }
@@ -565,6 +617,20 @@
565 617 // one screen does not teach a different chord for the same act.
566 618 acts.push(Act::new("Find Similar", at("similar")).key("shift+f"));
567 619 acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d"));
620 + // The basket, which is the two above asked of several samples at once. One
621 + // entry whose words say which way it goes, because the reader is answering
622 + // one question about this row rather than picking between two verbs.
623 + acts.push(
624 + Act::new(
625 + if sample.basket {
626 + "Take out of the basket"
627 + } else {
628 + "Add to the basket"
629 + },
630 + at("basket"),
631 + )
632 + .key("b"),
633 + );
568 634
569 635 // A collection to put it in, if there is one. Offered for a cloud-only
570 636 // sample too: membership is a fact about the sample rather than about the
@@ -557,6 +557,19 @@
557 557 /// acts that need the file on disk. A sample nobody has fetched can still be
558 558 /// listed, named and tagged, so this is not the same fact as absence.
559 559 pub cloud_only: bool,
560 + /// Whether this sample is in the basket -- one of the samples being looked
561 + /// *for*, rather than one of the answers.
562 + ///
563 + /// The row's own control reads it, because add and remove are one question
564 + /// asked of one row: "is this part of what I am looking for".
565 + pub basket: bool,
566 + /// Which basket anchors this row came back for, by name, in basket order.
567 + ///
568 + /// Empty unless a basket answer is on screen. The accounting the merged
569 + /// ranking rests on: a sample near one anchor and a sample near all four are
570 + /// different answers, and a ranking that showed only its own order would be
571 + /// one the reader has to take on trust.
572 + pub matched: Vec<String>,
560 573 }
561 574
562 575 /// Which columns the file list is showing.
@@ -644,6 +657,9 @@
644 657 /// being told. Adding does not, and the collection is what the act asked the
645 658 /// user for.
646 659 fn add_to_collection(&self, id: i64, collection: i64);
660 +
661 + /// Put this sample in the basket, or take it out if it is already in.
662 + fn toggle_basket(&self, id: i64);
647 663 }
648 664
649 665 /// What a described screen asked the app to do to itself.
@@ -746,6 +762,14 @@
746 762 Leave,
747 763 /// Go back to this step of the similarity trail.
748 764 WalkBack(usize),
765 + /// Put a sample in the basket, or take it out of it.
766 + ToggleBasket(i64),
767 + /// Take the anchor at this position out of the basket.
768 + RemoveFromBasket(usize),
769 + /// Show what is near every sample in the basket.
770 + ShowBasket,
771 + /// Empty the basket.
772 + ClearBasket,
749 773 /// Switch to this vault.
750 774 OpenVault(i64),
751 775 /// Delete this vault and everything in it.
@@ -988,6 +1012,27 @@
988 1012 audiofiles_core::vfs::NodeType::Directory
989 1013 ),
990 1014 cloud_only: node.cloud_only,
1015 + basket: node
1016 + .node
1017 + .sample_hash
1018 + .as_deref()
1019 + .is_some_and(|hash| self.state.search.basket_position(hash).is_some()),
1020 + // Anchor hashes are what the state holds, because a hash is what
1021 + // a query is about. A name is what a row can show, so the
1022 + // translation happens here, at the edge, rather than being
1023 + // carried through the worker.
1024 + matched: node
1025 + .node
1026 + .sample_hash
1027 + .as_deref()
1028 + .and_then(|hash| self.state.search.basket_matches.get(hash))
1029 + .map(|anchors| {
1030 + anchors
1031 + .iter()
1032 + .map(|anchor| self.state.search.anchor_name(anchor))
1033 + .collect()
1034 + })
1035 + .unwrap_or_default(),
991 1036 })
992 1037 .collect()
993 1038 }
@@ -1067,6 +1112,10 @@
1067 1112 .push(Intent::RemoveFromCollection(id));
1068 1113 }
1069 1114
1115 + fn toggle_basket(&self, id: i64) {
1116 + self.intents.borrow_mut().push(Intent::ToggleBasket(id));
1117 + }
1118 +
1070 1119 fn add_to_collection(&self, id: i64, collection: i64) {
1071 1120 self.intents
1072 1121 .borrow_mut()
@@ -2507,6 +2556,17 @@
2507 2556 /// the end of it.
2508 2557 trail: Vec<Walked>,
2509 2558 },
2559 + /// Samples near every one of several at once: the basket's answer.
2560 + ///
2561 + /// A count rather than the anchors themselves, and that is not an omission.
2562 + /// The basket is drawn beside this by `toolbar::holding` because it exists
2563 + /// in every mode, including none, and it is where an anchor can be taken
2564 + /// out. Naming them twice on one bar would give the reader two lists to
2565 + /// reconcile and one of them no controls.
2566 + Basket {
2567 + /// How many samples the answer is near.
2568 + anchors: usize,
2569 + },
2510 2570 }
2511 2571
2512 2572 /// One step of a similarity walk.
@@ -2675,6 +2735,26 @@
2675 2735
2676 2736 /// Go back to this step of the similarity trail, this far along it.
2677 2737 fn walk_back(&self, at: usize);
2738 +
2739 + /// What is in the basket, by name, in the order it was put there.
2740 + ///
2741 + /// On the toolbar rather than on [`Files`] because the basket is a fact
2742 + /// about the whole screen: the rows are what it *answered*, and one of the
2743 + /// states it has is holding samples with no answer on screen at all.
2744 + fn basket(&self) -> Vec<String>;
2745 +
2746 + /// Whether the rows are the basket's answer rather than a folder, a search
2747 + /// or one sample's neighbours.
2748 + fn basket_showing(&self) -> bool;
2749 +
2750 + /// Take the anchor at this position out of the basket.
2751 + fn drop_from_basket(&self, at: usize);
2752 +
2753 + /// Show what is near every sample in the basket.
2754 + fn show_basket(&self);
2755 +
2756 + /// Empty the basket, leaving the walk alone.
2757 + fn empty_basket(&self);
2678 2758 }
2679 2759
2680 2760 /// The app's toolbar, as the narrow thing a described screen borrows.
@@ -2687,6 +2767,14 @@
2687 2767
2688 2768 impl Bar for FromBar<'_> {
2689 2769 fn place(&self) -> Where {
2770 + // Before the trail, because both are usually true at once: a basket is
2771 + // filled while walking, so the trail is non-empty underneath it. What
2772 + // the list *is* is the basket's answer.
2773 + if self.state.search.basket_showing {
2774 + return Where::Basket {
2775 + anchors: self.state.search.basket.len(),
2776 + };
2777 + }
2690 2778 if self.state.search.in_similarity() {
2691 2779 return Where::Similar {
2692 2780 trail: self
@@ -2800,6 +2888,31 @@
2800 2888 fn walk_back(&self, at: usize) {
2801 2889 self.push(Intent::WalkBack(at));
2802 2890 }
2891 +
2892 + fn basket(&self) -> Vec<String> {
2893 + self.state
2894 + .search
2895 + .basket
2896 + .iter()
2897 + .map(|anchor| anchor.name.clone().unwrap_or_else(|| "sample".to_owned()))
2898 + .collect()
2899 + }
2900 +
2901 + fn basket_showing(&self) -> bool {
2902 + self.state.search.basket_showing
2903 + }
2904 +
2905 + fn drop_from_basket(&self, at: usize) {
2906 + self.push(Intent::RemoveFromBasket(at));
2907 + }
2908 +
2909 + fn show_basket(&self) {
2910 + self.push(Intent::ShowBasket);
2911 + }
2912 +
2913 + fn empty_basket(&self) {
2914 + self.push(Intent::ClearBasket);
2915 + }
2803 2916 }
2804 2917
2805 2918 impl FromBar<'_> {
@@ -836,6 +836,23 @@
836 836 // sample and returning to it re-runs that sample's query, which is
837 837 // the same call the trail was built out of.
838 838 Intent::WalkBack(at) => state.walk_back_to(at),
839 + // The basket. Adding is by row id because the row is what the reader
840 + // pressed; every other member is positional because the basket is an
841 + // ordered list the toolbar draws, and a position is what a control
842 + // in it can carry.
843 + Intent::ToggleBasket(id) => {
844 + if let Some(hash) = hash_of(state, id) {
845 + state.toggle_in_basket(&hash);
846 + }
847 + }
848 + Intent::RemoveFromBasket(at) => state.remove_from_basket(at),
849 + Intent::ShowBasket => state.find_similar_to_basket(),
850 + // Not `clear_similarity_search`: emptying the basket leaves the walk
851 + // alone, and the reader is still where they were on it.
852 + Intent::ClearBasket => {
853 + state.clear_basket();
854 + state.refresh_contents();
855 + }
839 856 // The sidebar. Two of these hand an already-agreed decision to the
840 857 // app's own executor: the described control asked with
841 858 // `Act::confirm`, the runtime answered `Step::Ask`, the user said
@@ -1477,6 +1494,20 @@
1477 1494 }
1478 1495 }
1479 1496
1497 + /// The hash of the row with this id, where it is a sample.
1498 + ///
1499 + /// By id rather than through the selection, unlike [`selected_hash`]: the basket
1500 + /// control is on the row, so the row the reader pressed is the one it acts on
1501 + /// whether or not it is the one in focus.
1502 + fn hash_of(state: &BrowserState, id: i64) -> Option<String> {
1503 + state
1504 + .nav
1505 + .contents
1506 + .iter()
1507 + .find(|node| node.node.id.as_i64() == id)
1508 + .and_then(|node| node.node.sample_hash.as_ref().map(ToString::to_string))
1509 + }
1510 +
1480 1511 /// The hash of whatever is selected, where it is a sample.
1481 1512 fn selected_hash(state: &BrowserState) -> Option<String> {
1482 1513 state
@@ -145,6 +145,9 @@
145 145 fn remove_from_collection(&self, id: i64) {
146 146 self.asked.borrow_mut().push(format!("uncollect:{id}"));
147 147 }
148 + fn toggle_basket(&self, id: i64) {
149 + self.asked.borrow_mut().push(format!("basket:{id}"));
150 + }
148 151 fn add_to_collection(&self, id: i64, collection: i64) {
149 152 self.asked
150 153 .borrow_mut()
@@ -366,6 +369,8 @@
366 369 tags: vec!["drums".to_owned(), "loop".to_owned()],
367 370 directory: false,
368 371 cloud_only: false,
372 + basket: false,
373 + matched: Vec::new(),
369 374 }
370 375 }
371 376
@@ -392,6 +397,18 @@
392 397
393 398 /// A router call against this file list.
394 399 fn listing(files: &FakeFiles, request: Request) -> Result<Response, quasi_router::RouteError> {
400 + listing_under(files, &Still, request)
401 + }
402 +
403 + /// The same call with a toolbar of your choosing, which is what the basket's
404 + /// half of the file list needs: whether a basket answer is showing is a fact
405 + /// about the screen, and the file list reads it off the bar rather than
406 + /// inferring it from the rows.
407 + fn listing_under(
408 + files: &FakeFiles,
409 + bar: &dyn Bar,
410 + request: Request,
411 + ) -> Result<Response, quasi_router::RouteError> {
395 412 let store = Store::default();
396 413 let sync = Offline;
397 414 let themes = themes();
@@ -400,7 +417,7 @@
400 417 bulk: &Unchosen,
401 418 shell: &Quiet,
402 419 library: &Empty,
403 - bar: &Still,
420 + bar,
404 421 config: &store,
405 422 sync: &sync,
406 423 files,
@@ -1827,6 +1844,7 @@
1827 1844 crate::ui::file_list_menus::reveal_label(),
1828 1845 "Find Similar",
1829 1846 "Find Duplicates",
1847 + "Add to the basket",
1830 1848 "Edit...",
1831 1849 "Play as Instrument",
1832 1850 "Export...",
@@ -1884,6 +1902,90 @@
1884 1902 );
1885 1903 }
1886 1904
1905 + /// One address for in and out, and the row says which it is. The reader is
1906 + /// answering "is this part of what I am looking for" rather than choosing
1907 + /// between two verbs that differ by a word.
1908 + #[test]
1909 + fn one_row_control_says_which_way_it_goes() {
1910 + let out = FakeFiles::with(vec![sample(7, "kick.wav")]);
1911 + let response = listing(&out, Request::get("/files")).expect("answered");
1912 + let (_, rows) = table_of(screen_of(&response));
1913 + assert!(
1914 + rows[0]
1915 + .menu
1916 + .iter()
1917 + .any(|act| act.label == "Add to the basket")
1918 + );
1919 +
1920 + let mut held = sample(7, "kick.wav");
1921 + held.basket = true;
1922 + let inside = FakeFiles::with(vec![held]);
1923 + let response = listing(&inside, Request::get("/files")).expect("answered");
1924 + let (_, rows) = table_of(screen_of(&response));
1925 + assert!(
1926 + rows[0]
1927 + .menu
1928 + .iter()
1929 + .any(|act| act.label == "Take out of the basket")
1930 + );
1931 +
1932 + listing(&inside, Request::post("/files/7/basket")).expect("answered");
1933 + assert_eq!(inside.asked(), ["basket:7"]);
1934 + }
1935 +
1936 + /// The accounting, which is the decided part of the basket and not a
1937 + /// nice-to-have. A sample near one anchor and a sample near all three are
1938 + /// different answers and must not look alike.
1939 + #[test]
1940 + fn a_basket_answer_shows_which_anchors_each_row_came_back_for() {
1941 + let mut both = sample(7, "kick.wav");
1942 + both.matched = vec!["808.wav".to_owned(), "clap.wav".to_owned()];
1943 + let mut one = sample(8, "snare.wav");
1944 + one.matched = vec!["808.wav".to_owned()];
1945 + let files = FakeFiles::with(vec![both, one]);
1946 + let bar = with_basket(&["808.wav", "clap.wav"], true);
1947 +
1948 + let response = listing_under(&files, &bar, Request::get("/files")).expect("answered");
1949 + let (columns, rows) = table_of(screen_of(&response));
1950 +
1951 + // Second, not last: it is why the row is in the list at all, and a reason
1952 + // shown after the tempo and the key reads as a detail about the row instead.
1953 + assert_eq!(columns[1].name, "Near");
1954 + assert!(
1955 + columns[1].sorted.is_none() && columns[1].reorder.is_none(),
1956 + "a basket answer is already ranked, so the column does not offer a sort"
1957 + );
1958 +
1959 + let tokens = |row: &quasi_router::Cells| -> Vec<String> {
1960 + row.values[1]
1961 + .parts
1962 + .iter()
1963 + .filter_map(|part| match part {
1964 + Node::Token(tag) => Some(tag.label.clone()),
1965 + _ => None,
1966 + })
1967 + .collect()
1968 + };
1969 + assert_eq!(tokens(&rows[0]), ["808.wav", "clap.wav"]);
1970 + assert_eq!(tokens(&rows[1]), ["808.wav"]);
1971 + }
1972 +
1973 + /// Only while a basket answer is on screen, because it is the only time the
1974 + /// question it answers can be asked at all.
1975 + #[test]
1976 + fn the_near_column_is_absent_when_the_list_is_not_a_basket_answer() {
1977 + let files = FakeFiles::with(vec![sample(7, "kick.wav")]);
1978 + let response = listing(&files, Request::get("/files")).expect("answered");
1979 + let (columns, rows) = table_of(screen_of(&response));
1980 +
1981 + assert!(columns.iter().all(|column| column.name != "Near"));
1982 + assert_eq!(
1983 + rows[0].values.len(),
1984 + columns.len(),
1985 + "cells are positional against the columns"
1986 + );
1987 + }
1988 +
1887 1989 #[test]
1888 1990 fn a_cloud_only_row_offers_the_fetch_and_withholds_what_needs_the_bytes() {
1889 1991 // The third branch, and the one a description could get quietly wrong: the
@@ -1901,6 +2003,11 @@
1901 2003 "Copy Path",
1902 2004 "Find Similar",
1903 2005 "Find Duplicates",
2006 + // Offered for a sample nobody has fetched, on the same grounds
2007 + // collection membership is: what a basket asks about is the
2008 + // analysis, which is a fact about the sample and not about whether
2009 + // its bytes are on this disk.
2010 + "Add to the basket",
1904 2011 "Delete",
1905 2012 ]
1906 2013 );
@@ -4635,9 +4742,21 @@
4635 4742 false
4636 4743 }
4637 4744
4745 + // A fresh app is looking for nothing in particular.
4746 + fn basket(&self) -> Vec<String> {
4747 + Vec::new()
4748 + }
4749 +
4750 + fn basket_showing(&self) -> bool {
4751 + false
4752 + }
4753 +
4638 4754 fn search(&self, _query: &str) {}
4639 4755 fn set_scope(&self, _everywhere: bool) {}
4640 4756 fn save_collection(&self, _name: &str) {}
4757 + fn drop_from_basket(&self, _at: usize) {}
4758 + fn show_basket(&self) {}
4759 + fn empty_basket(&self) {}
4641 4760 fn undo(&self) {}
4642 4761 fn toggle(&self, _panel: Panel) {}
4643 4762 fn go_root(&self) {}
@@ -4652,6 +4771,7 @@
4652 4771 searching: Searching,
4653 4772 showing: Vec<Panel>,
4654 4773 undoable: bool,
4774 + basket: Vec<String>,
4655 4775 asked: RefCell<Vec<String>>,
4656 4776 }
4657 4777
@@ -4672,6 +4792,7 @@
4672 4792 // one; see `Still`.
4673 4793 showing: vec![Panel::Sidebar],
4674 4794 undoable: false,
4795 + basket: Vec::new(),
4675 4796 asked: RefCell::new(Vec::new()),
4676 4797 }
4677 4798 }
@@ -4730,6 +4851,26 @@
4730 4851 self.undoable
4731 4852 }
4732 4853
4854 + fn basket(&self) -> Vec<String> {
4855 + self.basket.clone()
4856 + }
4857 +
4858 + fn basket_showing(&self) -> bool {
4859 + matches!(self.place, Where::Basket { .. })
4860 + }
4861 +
4862 + fn drop_from_basket(&self, at: usize) {
4863 + self.note(format!("drop {at}"));
4864 + }
4865 +
4866 + fn show_basket(&self) {
4867 + self.note("show basket");
4868 + }
4869 +
4870 + fn empty_basket(&self) {
4871 + self.note("empty basket");
4872 + }
4873 +
4733 4874 fn search(&self, query: &str) {
4734 4875 self.note(format!("search {query}"));
4735 4876 }
@@ -4860,6 +5001,122 @@
4860 5001 assert!(barred(&bar, Request::post("/here/7/deep")).is_err());
4861 5002 }
4862 5003
5004 + /// A `FakeBar` holding samples, optionally already showing their answer.
5005 + fn with_basket(held: &[&str], showing: bool) -> FakeBar {
5006 + let mut bar = FakeBar::at(if showing {
5007 + Where::Basket {
5008 + anchors: held.len(),
5009 + }
5010 + } else {
5011 + Where::Folder { trail: Vec::new() }
5012 + });
5013 + bar.basket = held.iter().map(|name| (*name).to_owned()).collect();
5014 + bar
5015 + }
5016 +
5017 + /// The basket exists in every mode, including none. A set of samples the reader
5018 + /// is collecting that vanished when they stepped into a folder would be a set
5019 + /// they could not trust.
5020 + #[test]
5021 + fn the_basket_is_on_the_bar_in_a_folder_as_much_as_in_a_walk() {
5022 + let bar = with_basket(&["kick.wav", "snare.wav"], false);
5023 + let screen = topped(&bar);
5024 +
5025 + assert!(said(&screen).contains("Looking for:"));
5026 + // The anchors are links rather than prose, because taking one out is the
5027 + // only thing you can do to it.
5028 + let named: Vec<String> = links(&screen).into_iter().map(|(text, _)| text).collect();
5029 + assert!(named.contains(&"kick.wav".to_owned()), "{named:?}");
5030 + assert!(named.contains(&"snare.wav".to_owned()), "{named:?}");
5031 + }
5032 +
5033 + /// An empty basket is not a query with no results, it is no query. A control for
5034 + /// a question nobody has asked is noise on a bar that already sheds to fit.
5035 + #[test]
5036 + fn an_empty_basket_says_nothing_at_all() {
5037 + let bar = with_basket(&[], false);
5038 + let screen = topped(&bar);
5039 +
5040 + assert!(!said(&screen).contains("Looking for:"));
5041 + assert!(!acts(&screen).iter().any(|label| label.contains("basket")));
5042 + assert!(
5043 + !links(&screen)
5044 + .iter()
5045 + .any(|(_, action)| action.starts_with("/basket/"))
5046 + );
5047 + }
5048 +
5049 + /// Each anchor is a link that takes it out: the trail's shape turned around. A
5050 + /// step is somewhere to go back to; an anchor is something to stop asking about.
5051 + #[test]
5052 + fn each_anchor_is_the_control_that_takes_it_out() {
5053 + let bar = with_basket(&["kick.wav", "snare.wav"], false);
5054 + let screen = topped(&bar);
5055 +
5056 + let out: Vec<(String, String)> = links(&screen)
5057 + .into_iter()
5058 + .filter(|(_, action)| action.starts_with("/basket/"))
5059 + .collect();
5060 + assert_eq!(
5061 + out,
5062 + [
5063 + ("kick.wav".to_owned(), "/basket/0/remove".to_owned()),
5064 + ("snare.wav".to_owned(), "/basket/1/remove".to_owned()),
5065 + ]
5066 + );
5067 +
5068 + barred(&bar, Request::post("/basket/1/remove")).expect("answered");
5069 + assert_eq!(bar.asked(), ["drop 1"]);
5070 + assert!(barred(&bar, Request::post("/basket/one/remove")).is_err());
5071 + }
5072 +
5073 + /// The count is in the words, because "near all 4" and "near all 2" are
5074 + /// different promises and the reader is about to judge the answer against one.
5075 + #[test]
5076 + fn asking_for_the_answer_says_how_many_it_will_be_near() {
5077 + assert!(
5078 + acts(&topped(&with_basket(&["kick.wav"], false)))
5079 + .contains(&"Show what is near it".to_owned()),
5080 + "one sample is not \"all 1\""
5081 + );
5082 + assert!(
5083 + acts(&topped(&with_basket(&["kick.wav", "snare.wav"], false)))
5084 + .contains(&"Show what is near all 2".to_owned())
5085 + );
5086 +
5087 + let bar = with_basket(&["kick.wav"], false);
5088 + barred(&bar, Request::post("/basket/show")).expect("answered");
5089 + barred(&bar, Request::post("/basket/clear")).expect("answered");
5090 + assert_eq!(bar.asked(), ["show basket", "empty basket"]);
5091 + }
5092 +
5093 + /// Not offered while its answer is what the list is. A control that re-asks an
5094 + /// answered question reads as one that would ask a different one -- and taking
5095 + /// an anchor out has to stay reachable, which is why the chips do not go with it.
5096 + #[test]
5097 + fn the_ask_goes_away_once_its_answer_is_showing_and_the_anchors_do_not() {
5098 + let screen = topped(&with_basket(&["kick.wav", "snare.wav"], true));
5099 +
5100 + assert!(
5101 + !acts(&screen)
5102 + .iter()
5103 + .any(|label| label.starts_with("Show what is near"))
5104 + );
5105 + assert!(
5106 + links(&screen)
5107 + .iter()
5108 + .any(|(_, action)| action == "/basket/0/remove"),
5109 + "removing an anchor is how the answer gets re-asked"
5110 + );
5111 + assert!(
5112 + acts(&screen)
5113 + .iter()
5114 + .any(|label| label == "Back to browsing"),
5115 + "a mode has a way out"
5116 + );
5117 + assert!(said(&screen).contains("Near all 2"));
5118 + }
5119 +
4863 5120 #[test]
4864 5121 fn a_mode_offers_a_way_out_rather_than_a_shorter_path() {
4865 5122 for place in [
@@ -117,6 +117,9 @@
117 117 .post("/here/{id}/{depth}", go)
118 118 .post("/here/leave", leave)
119 119 .post("/here/similar/{at}", walk_back)
120 + .post("/basket/show", show_basket)
121 + .post("/basket/clear", clear_basket)
122 + .post("/basket/{at}/remove", drop_from_basket)
120 123 }
121 124
122 125 /// `POST /search`
@@ -252,6 +255,32 @@
252 255 Ok(super::shell::screen(state).into())
253 256 }
254 257
258 + /// `POST /basket/show`
259 + fn show_basket(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
260 + state.bar.show_basket();
261 + Ok(super::shell::screen(state).into())
262 + }
263 +
264 + /// `POST /basket/clear`
265 + fn clear_basket(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
266 + state.bar.empty_basket();
267 + Ok(super::shell::screen(state).into())
268 + }
269 +
270 + /// `POST /basket/{at}/remove`
271 + ///
272 + /// A position, for [`walk_back`]'s reason: an anchor is only ever a place in
273 + /// this basket, and the sample it names is what the app already holds there.
274 + fn drop_from_basket(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
275 + let at: usize = request
276 + .captures
277 + .require("at")?
278 + .parse()
279 + .map_err(|_| RouteError::not_found("no such anchor in the basket"))?;
280 + state.bar.drop_from_basket(at);
281 + Ok(super::shell::screen(state).into())
282 + }
283 +
255 284 /// The toolbar, as a region something else holds.
256 285 pub fn body(state: &Panels<'_>) -> Slot {
257 286 // A row, said as one. The members were going into the band's body, which is
@@ -266,6 +295,7 @@
266 295 // everything would leave those ranks saying nothing.
267 296 let bar = Slot::new(BAR, RegionKind::Band).across(layout::Fallback::Shed);
268 297 let bar = here(bar, state);
298 + let bar = holding(bar, state);
269 299 let bar = looking(bar, state);
270 300 panels(bar, state)
271 301 }
@@ -366,9 +396,81 @@
366 396 Priority::Optional,
367 397 )
368 398 }
399 + // A mode with no trail inside it. You did not arrive here from one
400 + // sample, so there is nowhere in it to go back to; the way out is the
401 + // only way, and the basket itself is beside this on the same bar.
402 + Where::Basket { anchors } => leaving(
403 + bar,
404 + format!("Near all {anchors}"),
405 + "Back to browsing",
406 + )
407 + .beside(
408 + Node::text("Results are ranked by how near they are to the whole basket, so column sort is off."),
409 + Priority::Optional,
410 + ),
369 411 }
370 412 }
371 413
414 + /// What you are looking *for*, which is beside where you are rather than part of
415 + /// it.
416 + ///
417 + /// Drawn whenever the basket holds anything, in a folder as much as in a walk:
418 + /// the basket is filled while walking but it is not the walk, and a set of
419 + /// samples the reader is collecting that vanished when they stepped into a
420 + /// folder would be a set they could not trust.
421 + ///
422 + /// Nothing at all when it is empty. An empty basket is not a query with no
423 + /// results, it is no query, and a control for a question nobody has asked is
424 + /// noise on a bar that already sheds members to fit.
425 + fn holding(bar: Slot, state: &Panels<'_>) -> Slot {
426 + let held = state.bar.basket();
427 + if held.is_empty() {
428 + return bar;
429 + }
430 +
431 + let mut bar = bar.beside(
432 + Node::Text {
433 + text: "Looking for:".to_owned(),
434 + tone: Tone::Info,
435 + },
436 + Priority::Essential,
437 + );
438 + // Each anchor is a link that takes it out, which is the trail's shape turned
439 + // around: a step is somewhere to go back to, and an anchor is something to
440 + // stop asking about. Both are the only thing you can do to one.
441 + for (at, name) in held.iter().enumerate() {
442 + bar = bar.beside(
443 + Node::Link {
444 + text: name.clone(),
445 + action: Action::post(format!("/basket/{at}/remove")),
446 + },
447 + Priority::Optional,
448 + );
449 + }
450 +
451 + // Not offered while it is already showing: the answer on screen is the
452 + // answer to this, and a control that re-asks a question already answered
453 + // reads as one that would ask a different one.
454 + if !state.bar.basket_showing() {
455 + bar = bar.beside(
456 + Node::Act(Act::new(
457 + if held.len() == 1 {
458 + "Show what is near it".to_owned()
459 + } else {
460 + format!("Show what is near all {}", held.len())
461 + },
462 + Action::post("/basket/show"),
463 + )),
464 + Priority::Essential,
465 + );
466 + }
467 +
468 + bar.beside(
469 + Node::Act(Act::new("Empty the basket", Action::post("/basket/clear"))),
470 + Priority::Optional,
471 + )
472 + }
473 +
372 474 /// A mode you are in, and the way out of it.
373 475 fn leaving(bar: Slot, says: String, out: &str) -> Slot {
374 476 bar.beside(
@@ -307,6 +307,7 @@
307 307 | BackendEvent::ForgeError { .. } => self.handle_forge_event(event),
308 308 BackendEvent::SimilarResults { .. }
309 309 | BackendEvent::NearDuplicateResults { .. }
310 + | BackendEvent::BasketResults { .. }
310 311 | BackendEvent::SearchError { .. } => self.handle_search_event(event),
311 312 BackendEvent::ClassifierJobDone(_) | BackendEvent::ClassifierJobFailed(_) => {
312 313 self.handle_classifier_event(event)
@@ -688,10 +689,14 @@
688 689 let refs: Vec<&str> = hashes.iter().map(String::as_str).collect();
689 690 self.apply_similarity_results(&source, &refs, "near-duplicates", true);
690 691 }
692 + BackendEvent::BasketResults { sources, hits } => {
693 + self.apply_basket_results(&sources, hits);
694 + }
691 695 BackendEvent::SearchError { error } => {
692 696 // Clear the in-flight request so the repaint-while-busy guard
693 697 // stops; a failed search is terminal just like a result.
694 698 self.search.latest_similarity_request = None;
699 + self.search.basket_request = None;
695 700 self.status = format!("Search failed: {error}");
696 701 }
697 702 _ => unreachable!("handle_search_event: unrelated event"),
@@ -265,7 +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_trail.clear();
268 + self.leave_similarity_mode();
269 269 self.nav.selection.clear();
270 270 self.refresh_contents();
271 271 }
@@ -302,7 +302,7 @@
302 302 self.nav.contents = ContentsRef::new(nodes);
303 303 self.collections_ui.active_collection = Some(id);
304 304 self.nav.current_dir = None; // a collection view has no parent ".." row
305 - self.search.similarity_trail.clear();
305 + self.leave_similarity_mode();
306 306 self.nav.selection.clear();
307 307 self.status = format!("{count} samples in collection");
308 308 }
@@ -384,6 +384,12 @@
384 384 // The in-flight request resolved, clear it so the repaint-while-busy
385 385 // guard stops and a later search starts from a clean slate.
386 386 self.search.latest_similarity_request = None;
387 + // A trail step is what the list is now, so the basket's answer is not.
388 + // The basket itself stays: it is what the reader is collecting, and
389 + // walking on is how they collect it.
390 + self.search.basket_showing = false;
391 + self.search.basket_matches.clear();
392 + self.search.basket_request = None;
387 393 let name = self.backend.sample_original_name(source_hash).ok();
388 394 self.record_similarity_step(source_hash, name, is_near_dup);
389 395 self.nav.selection.clear();
@@ -448,12 +454,195 @@
448 454 }
449 455
450 456 /// Clear similarity search mode and return to normal browsing.
457 + ///
458 + /// Empties the basket with the trail. The samples in it are the question the
459 + /// reader was asking, and there is no question left once the list is a
460 + /// folder again; keeping them would leave a query armed behind a screen that
461 + /// no longer mentions it.
451 462 pub fn clear_similarity_search(&mut self) {
452 - self.search.similarity_trail.clear();
463 + self.leave_similarity_mode();
453 464 self.search.latest_similarity_request = None;
454 465 self.refresh_contents();
455 466 }
456 467
468 + /// Drop the trail and the basket together, without refreshing.
469 + ///
470 + /// One place decides what leaving means, for
471 + /// [`record_similarity_step`](Self::record_similarity_step)'s reason: every
472 + /// navigation out of similarity mode used to say `similarity_trail.clear()`
473 + /// and there are six of them, so a basket that only some of them emptied
474 + /// would come back armed in a folder that never mentioned it.
475 + pub fn leave_similarity_mode(&mut self) {
476 + self.search.similarity_trail.clear();
477 + self.clear_basket();
478 + }
479 +
480 + // --- The basket ---
481 +
482 + /// Put a sample in the basket, or take it out if it is already in.
483 + ///
484 + /// One control rather than two, because the row that offers it is the row
485 + /// that shows whether it is in: the reader is answering "is this part of
486 + /// what I am looking for", and that is one question.
487 + ///
488 + /// The name is resolved here, once, for the toolbar to draw every frame.
489 + pub fn toggle_in_basket(&mut self, hash: &str) {
490 + match self.search.basket_position(hash) {
491 + Some(at) => self.remove_from_basket(at),
492 + None => {
493 + let name = self.backend.sample_original_name(hash).ok();
494 + self.search.basket.push(crate::state::ui::BasketAnchor {
495 + hash: hash.to_string(),
496 + name,
497 + });
498 + self.status = format!("{} in the basket", self.search.basket.len());
499 + self.requery_basket();
500 + }
501 + }
502 + }
503 +
504 + /// Take the anchor at `at` out of the basket.
505 + pub fn remove_from_basket(&mut self, at: usize) {
506 + if at >= self.search.basket.len() {
507 + return;
508 + }
509 + self.search.basket.remove(at);
510 + self.status = if self.search.basket.is_empty() {
511 + "The basket is empty".to_string()
512 + } else {
513 + format!("{} in the basket", self.search.basket.len())
514 + };
515 + self.requery_basket();
516 + }
517 +
518 + /// Empty the basket, and stop showing its answer if it was showing.
519 + pub fn clear_basket(&mut self) {
520 + self.search.basket.clear();
521 + self.search.basket_showing = false;
522 + self.search.basket_matches.clear();
523 + self.search.basket_request = None;
524 + }
525 +
526 + /// Re-run the basket query, but only if its answer is what is on screen.
527 + ///
528 + /// Adding to the basket while walking must not yank the list out from under
529 + /// the reader: the basket is filled *from* the results they are reading.
530 + /// Once they have asked to see the basket's answer, though, changing the
531 + /// basket has to change the answer -- an anchor removed and the same rows
532 + /// still showing would be a lie about what the list is.
533 + ///
534 + /// An emptied basket falls back to whatever the trail is on, or to the
535 + /// folder when there is no trail: an empty basket is not a query with no
536 + /// results, it is no query.
537 + fn requery_basket(&mut self) {
538 + // Whatever is in flight was asked about a basket that no longer exists.
539 + // Abandoning it here rather than letting the guard in
540 + // `apply_basket_results` catch it is the difference between a stale
541 + // answer being dropped and a stale answer never having been awaited.
542 + self.search.basket_request = None;
543 + if !self.search.basket_showing {
544 + return;
545 + }
546 + if self.search.basket.is_empty() {
547 + self.search.basket_showing = false;
548 + self.search.basket_matches.clear();
549 + self.search.basket_request = None;
550 + match self.search.similarity_trail.len() {
551 + 0 => self.refresh_contents(),
552 + len => self.walk_back_to(len - 1),
553 + }
554 + return;
555 + }
556 + self.find_similar_to_basket();
557 + }
558 +
559 + /// Ask for the samples nearest to every anchor in the basket.
560 + ///
561 + /// The limit is `GRAPH_K`, [`find_similar`](Self::find_similar)'s reason
562 + /// exactly: past it the stored graph has no edge to answer with, and here it
563 + /// is per anchor, so asking for more would send every basket to a tree build.
564 + pub fn find_similar_to_basket(&mut self) {
565 + if self.search.basket.is_empty() {
566 + self.status = "The basket is empty".to_string();
567 + return;
568 + }
569 + let hashes: Vec<String> = self
570 + .search
571 + .basket
572 + .iter()
573 + .map(|held| held.hash.clone())
574 + .collect();
575 + match self
576 + .backend
577 + .start_find_similar_to_all(&hashes, audiofiles_core::similarity::GRAPH_K)
578 + {
579 + Ok(()) => {
580 + self.search.basket_request = Some(hashes);
581 + self.status = "Searching for what is near all of them...".to_string();
582 + }
583 + Err(e) => {
584 + self.status = format!("Similarity search failed: {e}");
585 + }
586 + }
587 + }
588 +
589 + /// Apply a basket answer to the file list.
590 + ///
591 + /// The single-anchor path's shape, with the accounting kept: the rows are
592 + /// resolved to nodes in rank order the same way, and `basket_matches` holds
593 + /// which anchors each row came back for so the list can show it.
594 + ///
595 + /// Nothing is recorded on the trail. A basket answer is not a step: you did
596 + /// not arrive at it from one sample and there is nothing to walk back to
597 + /// inside it -- the way out is the basket itself, which is on screen.
598 + pub fn apply_basket_results(
599 + &mut self,
600 + sources: &[String],
601 + hits: Vec<audiofiles_core::similarity::BasketHit>,
602 + ) {
603 + // Drop a stale answer, exactly as the single-anchor path does: the
604 + // reader may have changed the basket while this was in flight.
605 + //
606 + // Two conditions, and the second is not redundant. A query has to be in
607 + // flight, or a late duplicate would re-show an answer the reader has
608 + // left. And the answer has to be about the basket as it is *now* --
609 + // `sources` is what the worker was asked, which is not necessarily what
610 + // the reader is holding by the time it replies.
611 + if self.search.basket_request.as_deref() != Some(sources) {
612 + return;
613 + }
614 + if !self
615 + .search
616 + .basket
617 + .iter()
618 + .map(|anchor| anchor.hash.as_str())
619 + .eq(sources.iter().map(String::as_str))
620 + {
621 + return;
622 + }
623 + let Some(vfs_id) = self.current_vfs_id() else {
624 + return;
625 + };
626 + let hashes: Vec<&str> = hits.iter().map(|hit| hit.hash.as_str()).collect();
627 + let nodes = self
628 + .backend
629 + .find_nodes_by_hashes(vfs_id, &hashes)
630 + .unwrap_or_default();
631 + let count = nodes.len();
632 + self.search.basket_matches = hits
633 + .into_iter()
634 + .map(|hit| (hit.hash, hit.matched))
635 + .collect();
636 + self.nav.contents = ContentsRef::new(nodes);
637 + // Results replace the folder view, so there is no parent ".." row.
638 + self.nav.current_dir = None;
639 + self.search.basket_request = None;
640 + self.search.basket_showing = true;
641 + self.nav.selection.clear();
642 + let anchors = self.search.basket.len();
643 + self.status = format!("Found {count} near all {anchors}");
644 + }
645 +
457 646 // --- Column config ---
458 647
459 648 /// Load column config from the user_config table.