From 4bc442e43bc050e8470bf0605182d2a96d0ca825 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 15 Sep 2026 18:40:41 -0700 Subject: [PATCH] Make ephemeral search case-insensitive and stop indexing files twice Name-registry keys are now Unicode-lowercased at insert, remove, and snapshot restore, matching the query that search already lowercases; arena nodes keep the original name for display. Matching drops the exact/prefix/contains tiering: substring subsumes both, and scoring already ranks exact hits first, so an exact name no longer hides everything containing it. The volume map no longer walks volumes the platform hides from users. On macOS the sealed system volume at / was mapped as a second partition and its walk descended /Users, duplicating the firmlinked data-volume tree under the short spelling. Directory browse and path-scoped search now route their paths through locate_path the way sources.track already does, so every ephemeral-index writer and reader agrees on the volume's spelling of a path. Verified live: previously duplicated files return once, mixed-case names match lowercase queries, and an exact-name query returns the full result set with the exact hit ranked first. --- core/src/ops/files/query/directory_listing.rs | 19 ++++++- core/src/ops/indexing/ephemeral/index.rs | 54 +++++++++++++++++-- core/src/ops/indexing/ephemeral/snapshot.rs | 5 +- core/src/ops/search/ephemeral_search.rs | 31 ++++++----- core/src/ops/volumes/index/map.rs | 7 +++ 5 files changed, 96 insertions(+), 20 deletions(-) diff --git a/core/src/ops/files/query/directory_listing.rs b/core/src/ops/files/query/directory_listing.rs index 775b1e496..f8e8d4c0d 100644 --- a/core/src/ops/files/query/directory_listing.rs +++ b/core/src/ops/files/query/directory_listing.rs @@ -181,6 +181,16 @@ impl DirectoryListingQuery { } }; + // The volume decides how the path is written. A folder browsed as + // /Users/me is the same directory as /System/Volumes/Data/Users/me, + // and only the volume's spelling lands in the partition the volume + // map owns; taking the caller's spelling would grow a second tree of + // files the map already holds. + let local_path = match context.volume_manager.locate_path(&local_path).await { + Some((_, spelled)) => spelled, + None => local_path, + }; + let cache = context.ephemeral_cache(); // A registered source that hasn't been touched this session restores @@ -281,9 +291,14 @@ impl DirectoryListingQuery { ); } - // Create ephemeral indexer job for this directory (shallow, current scope only) + // Create ephemeral indexer job for this directory (shallow, current + // scope only). The job walks the volume's spelling of the path so + // every entry it inserts lands in the partition resolved above. let mut config = IndexerJobConfig::ephemeral_browse( - self.input.path.clone(), + SdPath::Physical { + device_slug: crate::device::get_current_device_slug(), + path: local_path.clone(), + }, IndexScope::Current, // Only current directory, not recursive false, // Directory browsing, not volume indexing ); diff --git a/core/src/ops/indexing/ephemeral/index.rs b/core/src/ops/indexing/ephemeral/index.rs index c8fd7b31a..cfdf55cf2 100644 --- a/core/src/ops/indexing/ephemeral/index.rs +++ b/core/src/ops/indexing/ephemeral/index.rs @@ -169,7 +169,7 @@ impl EphemeralIndex { self.content_kinds.remove(&id); self.collection_flags.remove(&id); if let Some(name) = path.file_name().and_then(|name| name.to_str()) { - self.registry.remove(name, id); + self.registry.remove(&name.to_lowercase(), id); } self.arena.vacate(id); self.stubs.remove(&id); @@ -245,12 +245,25 @@ impl EphemeralIndex { self.path_index.insert(path.to_path_buf(), id); self.id_to_path.insert(id, path.to_path_buf()); - self.registry.insert(name, id); + let search_key = Self::search_key(&self.cache, name); + self.registry.insert(search_key, id); self.mark_dirty(); Ok(id) } + /// The registry key for a name: Unicode lowercase, interned. Search + /// lowercases its query, so candidate selection stays case-insensitive + /// while arena nodes keep the original name for display. + fn search_key<'cache>(cache: &'cache NameCache, name: &'cache str) -> &'cache str { + let folded = name.to_lowercase(); + if folded == name { + name + } else { + cache.intern(&folded) + } + } + /// Adds an entry to the index, returning its content kind if successful. /// /// Content kind is identified by file extension (no I/O needed), which is @@ -381,7 +394,8 @@ impl EphemeralIndex { self.path_index.insert(path.clone(), id); self.id_to_path.insert(id, path.clone()); - self.registry.insert(name, id); + let search_key = Self::search_key(&self.cache, name); + self.registry.insert(search_key, id); // Non-directories contribute their size, and themselves, to every // ancestor's rollups. @@ -1502,6 +1516,40 @@ mod rollup_tests { ); } + /// Search lowercases its query before hitting the registry, so a name with + /// uppercase letters is only findable if registry keys are folded too. The + /// arena node keeps the original casing for display. + #[test] + fn mixed_case_names_match_lowercase_queries() { + let mut index = EphemeralIndex::new().unwrap(); + let root = PathBuf::from("/vol"); + let file = root.join("Dangerous Woman.mp3"); + + index + .add_entry( + file.clone(), + Uuid::now_v7(), + meta(&file, EntryKind::File, 1), + ) + .unwrap(); + + assert_eq!( + index.find_by_name("dangerous woman.mp3"), + vec![file.clone()] + ); + assert_eq!(index.find_by_prefix("danger"), vec![file.clone()]); + assert_eq!(index.find_containing("woman"), vec![file.clone()]); + + // The reported path preserves the on-disk casing. + assert!(index.get_entry(&file).is_some()); + + index.remove_entry(&file); + assert!( + index.find_by_prefix("danger").is_empty(), + "removal folds the name the same way insertion did" + ); + } + #[test] fn mutations_mark_the_index_dirty() { let mut index = EphemeralIndex::new().unwrap(); diff --git a/core/src/ops/indexing/ephemeral/snapshot.rs b/core/src/ops/indexing/ephemeral/snapshot.rs index 6bd13ff81..deb0b911e 100644 --- a/core/src/ops/indexing/ephemeral/snapshot.rs +++ b/core/src/ops/indexing/ephemeral/snapshot.rs @@ -280,10 +280,11 @@ pub(super) fn load_snapshot_impl( cache.intern(name); } - // Rebuild name registry + // Rebuild name registry. Keys are folded on insert so snapshots written + // before registry keys were lowercased come back searchable. let mut registry = NameRegistry::new(); for (name, ids) in &snapshot.name_registry_map { - let interned = cache.intern(name); + let interned = cache.intern(&name.to_lowercase()); for &id in ids { registry.insert(interned, id); } diff --git a/core/src/ops/search/ephemeral_search.rs b/core/src/ops/search/ephemeral_search.rs index 2971c15a8..22eb68dc7 100644 --- a/core/src/ops/search/ephemeral_search.rs +++ b/core/src/ops/search/ephemeral_search.rs @@ -63,18 +63,27 @@ pub async fn search_ephemeral_index( .await; } + // The volume decides how the scope is written: the index holds the + // volume's spelling, so a scope reached through an alias such as + // /Users/me has to be rewritten before it can select a partition or + // filter its paths. + let local_path = match context.volume_manager.locate_path(local_path).await { + Some((_, spelled)) => spelled, + None => local_path.clone(), + }; + // A registered source that has not been touched this session restores from // its snapshot here, including detached drives, whose indexes serve // read-only. - cache.ensure_restored(local_path).await; + cache.ensure_restored(&local_path).await; - let Some(index_arc) = cache.get_for_search(local_path) else { + let Some(index_arc) = cache.get_for_search(&local_path) else { return Ok(Vec::new()); }; let matching_paths = { let index = index_arc.read().await; - matches_in(&index, query, Some(local_path)) + matches_in(&index, query, Some(&local_path)) }; collect_results( @@ -154,10 +163,12 @@ pub async fn search_every_index( Ok(results) } -/// Paths in one partition whose name matches, narrowed to a scope if given. +/// Paths in one partition whose name contains the query, narrowed to a scope +/// if given. /// -/// Exact, then prefix, then substring: the first tier that answers wins, so a -/// query that names a file exactly is not buried under everything containing it. +/// Substring matching subsumes exact and prefix hits, and scoring already +/// ranks them above it, so an exact query surfaces its file first without +/// hiding everything else that contains the term. fn matches_in( index: &crate::ops::indexing::ephemeral::EphemeralIndex, query: &str, @@ -171,13 +182,7 @@ fn matches_in( } let query = query.to_lowercase(); - let mut paths = index.find_by_name(&query); - if paths.is_empty() { - paths = index.find_by_prefix(&query); - } - if paths.is_empty() { - paths = index.find_containing(&query); - } + let paths = index.find_containing(&query); match scope { Some(root) => paths diff --git a/core/src/ops/volumes/index/map.rs b/core/src/ops/volumes/index/map.rs index 16c21ad5c..972318ab3 100644 --- a/core/src/ops/volumes/index/map.rs +++ b/core/src/ops/volumes/index/map.rs @@ -144,6 +144,13 @@ pub async fn map_attached_volumes(library: &Arc, context: &Arc