Skip to content

Commit a141cc2

Browse files
committed
Search: span every indexed volume, not just the root drive
Filename search was root-only by construction: it loaded the root drive's index and pinned `SEARCH_VOLUME_ID = "root"`, so searching a scope on a NAS/SMB share or external drive silently returned "No files found" even when that volume's `index-<id>.db` held millions of entries. Search now routes a scope to its owning volume(s) and, unscoped, fans out across every volume with a persisted index, merging the ranked results. - **Per-volume arenas (`search/volumes.rs`).** Generalized the global `SEARCH_INDEX` singleton into a per-volume registry. `ensure_volume(id)` lazily loads and caches each volume's arena: root from the live `get_read_pool()`, a non-root volume read-only straight from `index-<id>.db` on disk — deliberately NOT via `INDEX_REGISTRY`, so an ejected/unmounted drive's index is still searchable. The mount root comes from the DB's `volume_path` meta (known offline). Dialog-scoped idle/backstop timers drop every arena at once to reclaim RAM; a long root pre-load stays cancelable. - **Honest coverage signal.** A scope pointing at a volume with no persisted index no longer reads as empty success: `run_blocking` collects those paths into the new typed `SearchResult.uncovered_scopes` (callers branch on emptiness, never string-match), and the UI/MCP render "Cmdr hasn't indexed X yet" instead of "no files found". Partial coverage still returns the covered volumes' hits alongside the note. - **Mount-relative path spaces.** A non-root index is rooted at its mount, so it stores mount-relative paths. Two mirror transforms bridge the gap: `engine::search_ranked` PREFIXES the mount root onto reconstructed paths (a NAS result reports `/Volumes/naspi/...` and opens in a pane), and `query::resolve_include_path_ids` STRIPS the mount root from scope include paths before `resolve_path`. - **Cross-volume merge.** The engine returns ranked entries carrying their `RankKey` (match-quality band + importance-boosted recency + id — all volume-independent), so `execute.rs` k-way-merges each volume's already-ranked slice with one global sort, sums per-volume totals, and fills directory sizes per volume from the owning pool. - **Per-volume importance weights.** Weights load per volume into a `WEIGHTS` map: root stays live via the recompute subscriber, a non-root volume takes a load-time snapshot. A volume with no `importance-<id>.db` degrades to match-quality + recency (the existing contract). - **Shared runner for dialog + MCP.** `commands/search.rs` and `mcp/executor/search.rs` both route through `execute::run_blocking`; the MCP `search`/`ai_search` handlers surface the uncovered-scope note. Removed the now-redundant per-caller load/postprocess paths. - **Supporting exposure.** Made `volume_id_for_local_path` `pub(crate)` (re-exported from `indexing`); un-gated `IndexStore::get_meta` (was test-only) so the loader can read `volume_path`. Tests: cross-volume merge ordering (exact match on one volume beats a prefix match on another), mount-path prefixing, the directory-size post-filter, scope→volume grouping, per-volume load from a persisted DB with mount root from meta, the missing-DB honesty signal, per-volume and missing importance weights, and recompute-notify weight refresh. `file-length-allowlist.json` is a check-run shrink-wrap: `query.rs` dropped below 800 lines after its dir-size helpers moved to `execute.rs`, so its entry was auto-removed.
1 parent 499027b commit a141cc2

22 files changed

Lines changed: 1478 additions & 930 deletions

File tree

apps/desktop/src-tauri/src/commands/search.rs

Lines changed: 41 additions & 187 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,15 @@
22
//!
33
//! Thin wrappers around `search` module functions, exposed to the frontend via Tauri commands.
44
5-
use std::sync::Arc;
6-
use std::sync::atomic::{AtomicBool, Ordering};
5+
use std::sync::atomic::Ordering;
76

87
use serde::Serialize;
98

109
use genai::chat::ChatOptions;
1110

1211
use crate::ai::AiTranslateError;
13-
use crate::indexing::get_read_pool;
14-
use crate::search::{
15-
self, DIALOG_OPEN, ParsedScope, SEARCH_INDEX, SearchIndexState, SearchQuery, SearchResult, drop_search_index,
16-
fill_directory_sizes, start_backstop_timer, start_idle_timer, touch_activity,
17-
};
18-
19-
use crate::indexing::writer::WRITER_GENERATION;
20-
use crate::pluralize::pluralize_with;
12+
use crate::search::{self, ParsedScope, SearchQuery, SearchResult, VolumeLoad};
13+
2114
use crate::search::ai::{self, query_builder as ai_query_builder};
2215
use crate::search::history::{self, HistoryEntry};
2316

@@ -37,103 +30,42 @@ pub struct SearchIndexReadyEvent {
3730
pub entry_count: u64,
3831
}
3932

40-
/// Called when the search dialog opens. Starts loading the index in the background.
41-
/// Returns immediately with `{ ready, entryCount }`.
33+
/// Called when the search dialog opens. Pre-loads the ROOT index in the background
34+
/// (the common case; scoped volumes load lazily on their first query). Returns
35+
/// immediately with `{ ready, entryCount }`; the dialog flips to ready on the
36+
/// emitted `search-index-ready` event.
4237
#[tauri::command]
4338
#[specta::specta]
4439
pub async fn prepare_search_index(app: tauri::AppHandle) -> Result<PrepareResult, String> {
45-
touch_activity();
46-
DIALOG_OPEN.store(true, Ordering::Relaxed);
47-
48-
// Check if already loaded and fresh
49-
{
50-
let mut guard = SEARCH_INDEX.lock().map_err(|e| format!("{e}"))?;
51-
if let Some(ref mut state) = *guard {
52-
let current_gen = WRITER_GENERATION.load(Ordering::Relaxed);
53-
if state.index.generation == current_gen {
54-
// Cancel any pending idle timer
55-
if let Some(ref h) = state.idle_timer {
56-
h.abort();
57-
}
58-
state.idle_timer = None;
59-
// Reset backstop timer; the previous session's timer may still
60-
// be ticking and could fire while the dialog is open.
61-
if let Some(ref h) = state.backstop_timer {
62-
h.abort();
63-
}
64-
state.backstop_timer = Some(start_backstop_timer());
65-
return Ok(PrepareResult {
66-
ready: true,
67-
entry_count: state.index.entries.len() as u64,
68-
});
69-
}
70-
// Stale: drop and reload below
71-
}
72-
}
73-
74-
// Drop stale index if any
75-
drop_search_index();
76-
77-
let pool = get_read_pool().ok_or_else(|| "Index not available".to_string())?;
78-
let cancel = Arc::new(AtomicBool::new(false));
79-
let cancel_clone = cancel.clone();
80-
81-
// Store a "loading" sentinel with the cancel flag BEFORE spawning the task.
82-
// This closes the race window where release_search_index can't cancel the
83-
// load between checking the lock and the background task starting.
84-
{
85-
let mut guard = SEARCH_INDEX.lock().map_err(|e| format!("{e}"))?;
86-
if guard.is_some() {
87-
return Ok(PrepareResult {
88-
ready: false,
89-
entry_count: 0,
90-
});
91-
}
92-
*guard = Some(SearchIndexState {
93-
index: Arc::new(search::SearchIndex::empty()),
94-
idle_timer: None,
95-
backstop_timer: None,
96-
load_cancel: Some(cancel.clone()),
40+
use crate::indexing::ROOT_VOLUME_ID;
41+
42+
search::touch_activity();
43+
search::DIALOG_OPEN.store(true, Ordering::Relaxed);
44+
search::cancel_idle_timer();
45+
46+
// Fast path: root already warm and fresh.
47+
if let Some(v) = search::get_loaded(ROOT_VOLUME_ID) {
48+
// A prior session's backstop timer may still be ticking; reset it so it
49+
// can't fire while the dialog is open.
50+
search::reset_backstop_timer();
51+
return Ok(PrepareResult {
52+
ready: true,
53+
entry_count: v.index.entries.len() as u64,
9754
});
9855
}
9956

100-
// Spawn the load in a background task
57+
// Load root in the background so the dialog doesn't block on a multi-second scan.
10158
let app_clone = app.clone();
10259
tauri::async_runtime::spawn(async move {
103-
let result = tokio::task::spawn_blocking(move || search::load_search_index(&pool, &cancel_clone)).await;
104-
105-
match result {
106-
Ok(Ok(index)) => {
107-
let entry_count = index.entries.len() as u64;
108-
let backstop = start_backstop_timer();
109-
let mut guard = match SEARCH_INDEX.lock() {
110-
Ok(g) => g,
111-
Err(e) => e.into_inner(),
112-
};
113-
*guard = Some(SearchIndexState {
114-
index: Arc::new(index),
115-
idle_timer: None,
116-
backstop_timer: Some(backstop),
117-
load_cancel: Some(cancel),
118-
});
119-
log::debug!(
120-
"Search index ready: {}",
121-
pluralize_with(entry_count, "entry", "entries")
122-
);
123-
// Emit event to frontend
60+
match tokio::task::spawn_blocking(|| search::ensure_volume(ROOT_VOLUME_ID)).await {
61+
Ok(VolumeLoad::Loaded(v)) => {
62+
let entry_count = v.index.entries.len() as u64;
12463
use tauri_specta::Event;
12564
let _ = SearchIndexReadyEvent { entry_count }.emit(&app_clone);
12665
}
127-
Ok(Err(e)) => {
128-
if e.contains("cancelled") {
129-
log::debug!("Search index load cancelled");
130-
} else {
131-
log::warn!("Search index load failed: {e}");
132-
}
133-
}
134-
Err(e) => {
135-
log::warn!("Search index load task panicked: {e}");
136-
}
66+
Ok(VolumeLoad::NotIndexed) => log::debug!("prepare_search_index: root index not available yet"),
67+
Ok(VolumeLoad::Failed(e)) => log::warn!("prepare_search_index: root load failed: {e}"),
68+
Err(e) => log::warn!("prepare_search_index: load task panicked: {e}"),
13769
}
13870
});
13971

@@ -143,106 +75,28 @@ pub async fn prepare_search_index(app: tauri::AppHandle) -> Result<PrepareResult
14375
})
14476
}
14577

146-
/// Search the in-memory index. Returns empty if not loaded yet.
78+
/// Search across the scoped volume(s), or every indexed volume when unscoped.
79+
/// Returns empty (no coverage gaps) when nothing is indexed yet.
14780
#[tauri::command]
14881
#[specta::specta]
149-
pub async fn search_files(mut query: SearchQuery) -> Result<SearchResult, String> {
150-
touch_activity();
151-
152-
let index = {
153-
let guard = SEARCH_INDEX.lock().map_err(|e| format!("{e}"))?;
154-
match guard.as_ref() {
155-
Some(state) => {
156-
// Cancel any idle timer since we're actively searching
157-
if let Some(ref h) = state.idle_timer {
158-
h.abort();
159-
}
160-
state.index.clone()
161-
}
162-
None => {
163-
return Ok(SearchResult {
164-
entries: Vec::new(),
165-
total_count: 0,
166-
});
167-
}
168-
}
169-
};
170-
171-
// Resolve include paths to entry IDs via SQLite (microseconds, not 20s)
172-
if query.include_paths.as_ref().is_some_and(|p| !p.is_empty())
173-
&& let Some(pool) = get_read_pool()
174-
{
175-
search::resolve_include_paths(&mut query, &pool);
176-
}
177-
178-
// Load the per-volume importance weight map (empty when unavailable, which
179-
// degrades ranking to match-quality + recency — today's behavior).
180-
let weights = search::importance_weights_snapshot();
82+
pub async fn search_files(query: SearchQuery) -> Result<SearchResult, String> {
83+
search::touch_activity();
84+
search::cancel_idle_timer();
18185

182-
// Run search on a blocking thread (rayon parallel scan)
183-
let query_clone = query.clone();
184-
let index_clone = index.clone();
185-
let mut result = tokio::task::spawn_blocking(move || search::search(&index_clone, &query_clone, &weights))
86+
// Route + load + scan + merge on a blocking thread (opens DBs, rayon scan).
87+
tokio::task::spawn_blocking(move || search::run_blocking(query))
18688
.await
187-
.map_err(|e| format!("Search task failed: {e}"))??;
188-
189-
// Fill directory sizes from the DB
190-
if result.entries.iter().any(|e| e.is_directory)
191-
&& let Some(pool) = get_read_pool()
192-
{
193-
fill_directory_sizes(&mut result, &pool);
194-
}
195-
196-
// Post-filter directories by size (their sizes come from dir_stats, filled
197-
// above, so engine::search couldn't apply the size filter to them).
198-
search::filter_directories_by_size(&mut result, &query);
199-
200-
// Truncate to the originally requested limit
201-
let limit = query.limit.min(1000) as usize;
202-
if result.entries.len() > limit {
203-
result.entries.truncate(limit);
204-
}
205-
206-
// Check generation staleness; trigger background reload if needed
207-
let current_gen = WRITER_GENERATION.load(Ordering::Relaxed);
208-
if index.generation != current_gen {
209-
log::debug!(
210-
"Search index stale (gen {} vs {}), will reload on next prepare",
211-
index.generation,
212-
current_gen
213-
);
214-
}
215-
216-
Ok(result)
89+
.map_err(|e| format!("Search task failed: {e}"))?
21790
}
21891

219-
/// Called when the search dialog closes. Starts the idle timer and
220-
/// cancels any in-progress load.
92+
/// Called when the search dialog closes. Starts the idle timer and cancels any
93+
/// in-progress index load.
22194
#[tauri::command]
22295
#[specta::specta]
22396
pub async fn release_search_index() -> Result<(), String> {
224-
DIALOG_OPEN.store(false, Ordering::Relaxed);
225-
let mut guard = SEARCH_INDEX.lock().map_err(|e| format!("{e}"))?;
226-
227-
// Set cancellation flag on any in-progress load
228-
if let Some(ref state) = *guard
229-
&& let Some(ref cancel) = state.load_cancel
230-
{
231-
cancel.store(true, Ordering::Relaxed);
232-
}
233-
234-
// Start idle timer
235-
if guard.is_some() {
236-
let idle_handle = start_idle_timer();
237-
if let Some(ref mut state) = *guard {
238-
// Cancel previous idle timer if any
239-
if let Some(ref h) = state.idle_timer {
240-
h.abort();
241-
}
242-
state.idle_timer = Some(idle_handle);
243-
}
244-
}
245-
97+
search::DIALOG_OPEN.store(false, Ordering::Relaxed);
98+
search::cancel_active_loads();
99+
search::start_idle_timer();
246100
Ok(())
247101
}
248102

apps/desktop/src-tauri/src/indexing/enrichment.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,10 @@ impl ReadPool {
7171
}
7272
}
7373

74-
/// The root volume's read pool. The fast handle for local-disk enrichment,
75-
/// search (D7 keeps search local-only), and IPC dir-stats. The root
76-
/// `IndexInstance` shares this same `Arc`, so registry and read path can't drift.
74+
/// The root volume's read pool. The fast handle for local-disk enrichment, root
75+
/// filename search, and IPC dir-stats. The root `IndexInstance` shares this same
76+
/// `Arc`, so registry and read path can't drift. (Non-root filename search opens
77+
/// its own read-only pool from the volume's `index-{id}.db`; see `search/volumes.rs`.)
7778
///
7879
/// Root is special-cased to this global (rather than read from the registry)
7980
/// for two reasons: search reads it on the hot path, and the indexing tests

apps/desktop/src-tauri/src/indexing/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ pub use queries::{
8585
get_debug_status, get_dir_stats, get_dir_stats_batch, get_status, get_volume_index_status,
8686
get_volume_index_status_for_path, list_dir_children,
8787
};
88-
pub(crate) use routing::{IndexPathSpace, index_read_path};
88+
pub(crate) use routing::{IndexPathSpace, index_read_path, volume_id_for_local_path};
8989
pub(crate) use state::ROOT_VOLUME_ID;
9090
pub(crate) use state::get_freshness;
9191
#[cfg(test)]

apps/desktop/src-tauri/src/indexing/routing.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ use super::store::{self, IndexStoreError};
4444
/// [`external_mount_volume_id_for_path`].
4545
/// - **Everything else** (the boot disk, and cloud-drive folders in the home dir
4646
/// that `root`'s index owns) → `root`.
47-
pub(super) fn volume_id_for_local_path(path: &str) -> VolumeId {
47+
pub(crate) fn volume_id_for_local_path(path: &str) -> VolumeId {
4848
#[cfg(any(target_os = "macos", target_os = "linux"))]
4949
if let Some(smb_id) = super::smb_index::smb_volume_id_for_path(path) {
5050
return smb_id;

apps/desktop/src-tauri/src/indexing/store/meta.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ impl IndexStore {
1919
Ok(())
2020
}
2121

22-
/// Get a single meta value by key.
23-
#[cfg(test)]
22+
/// Get a single meta value by key. Read by the search loader to recover a
23+
/// volume's `volume_path` (mount root) from its persisted index DB.
2424
pub fn get_meta(conn: &Connection, key: &str) -> Result<Option<String>, IndexStoreError> {
2525
Self::read_meta_value(conn, key)
2626
}

0 commit comments

Comments
 (0)