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.
This commit is contained in:
Jamie Pine committed 2026-09-15 18:40:41 -07:00
1 parent fab0733048
commit 4bc442e43b
5 files changed
+96 -20

No files matched your search

+17 -2
View File
@@ -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
);
+51 -3
View File
@@ -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();
+3 -2
View File
@@ -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);
}
+18 -13
View File
@@ -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
+7
View File
@@ -144,6 +144,13 @@ pub async fn map_attached_volumes(library: &Arc<Library>, context: &Arc<CoreCont
if volume.parse_cloud_identity().is_some() {
continue;
}
// Volumes the platform hides from users either alias what a visible
// volume already covers or hold no user files. On macOS the sealed
// system volume mounts at `/`, and walking it would map the firmlinked
// data-volume tree a second time under its short spelling.
if !volume.is_user_visible {
continue;
}
let cache = context.ephemeral_cache();
cache.track_volume(volume.id, volume.mount_point.clone());