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