Watch selected folders and reconcile bounded changes with media freshness guards

This commit is contained in:
karl committed 2026-10-13 02:30:38 +02:00
1 parent 99748bd99f
commit 657de3de86
22 files changed
+2022 -149

No files matched your search

+100 -5
View File
@@ -8,6 +8,7 @@ mod data_directory;
mod model_download;
mod semantic_commands;
mod settings;
mod watching;
use ai_commands::{generate_ai, prepare_ai_source, AiSession};
use searchi_core::metrics::{MetricsMode, SearchMetrics, SearchResponse, TraceCollector};
@@ -38,6 +39,8 @@ struct AppState {
settings: Arc<Mutex<SettingsStore>>,
shortcut_status: String,
semantic: Arc<SemanticState>,
watching: Arc<watching::WatchService>,
index_options: searchi_core::IndexOptions,
}
#[derive(Serialize)]
@@ -85,6 +88,7 @@ struct DesktopStatus {
settings: Settings,
indexing: bool,
shortcut_status: String,
watching: Vec<watching::WatchStatus>,
}
#[tauri::command]
@@ -98,6 +102,7 @@ fn desktop_status(state: State<'_, AppState>) -> Result<DesktopStatus, String> {
.clone(),
indexing: state.indexing.load(Ordering::Acquire),
shortcut_status: state.shortcut_status.clone(),
watching: state.watching.status(),
})
}
@@ -362,15 +367,18 @@ async fn index_folder(root: String, state: State<'_, AppState>) -> Result<IndexR
let database = state.database.clone();
let settings = state.settings.clone();
let lexical = state.lexical.clone();
let watching = state.watching.clone();
let options = state.index_options.clone();
tauri::async_runtime::spawn_blocking(move || {
let _guard = guard;
settings
.lock()
.map_err(|_| "Settings are unavailable.")?
.add_root(std::path::Path::new(&root))?;
watching.add_root(std::fs::canonicalize(&root).map_err(|error| error.to_string())?)?;
let mut index = SearchIndex::open(database).map_err(|error| error.to_string())?;
let report = index
.index_folder(root)
.index_folder_with_options(root, &options)
.map_err(|error| error.to_string())?;
index.prewarm().map_err(|error| error.to_string())?;
*lexical
@@ -382,6 +390,66 @@ async fn index_folder(root: String, state: State<'_, AppState>) -> Result<IndexR
.map_err(|e| e.to_string())?
}
/// Each reconciliation owns its connection; retained readers stay usable while
/// extraction runs. The watcher retains dirty work when the shared gate is busy.
fn start_folder_watching(
app: tauri::AppHandle,
database: PathBuf,
lexical: Arc<Mutex<SearchIndex>>,
indexing: Arc<AtomicBool>,
roots: Vec<PathBuf>,
options: searchi_core::IndexOptions,
) -> Result<watching::WatchService, String> {
watching::WatchService::start(roots, options.excluded_paths.clone(), move |request| {
if indexing
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_err()
{
return watching::ScanOutcome::Busy;
}
let guard = IndexGuard(indexing.clone());
let _ = app.emit("folder-indexing-changed", ());
let result = (|| -> Result<bool, String> {
let mut index = SearchIndex::open(&database).map_err(|error| error.to_string())?;
let report = match request.paths {
Some(paths) => index.index_paths_cancellable(
&request.root,
&paths,
&options,
&request.cancelled,
),
None if request.media_hint => index.index_paths_cancellable(
&request.root,
std::slice::from_ref(&request.root),
&options,
&request.cancelled,
),
None => index.index_folder_cancellable(&request.root, &options, &request.cancelled),
}
.map_err(|error| error.to_string())?;
let changed = report.indexed > 0 || report.removed > 0;
if changed && !request.cancelled.load(Ordering::Acquire) {
index.prewarm().map_err(|error| error.to_string())?;
*lexical
.lock()
.map_err(|_| "Keyword engine is unavailable.")? = index;
}
Ok(changed)
})();
drop(guard);
let _ = app.emit("folder-indexing-changed", ());
match result {
Ok(changed) => {
if changed {
let _ = app.emit("search-index-changed", ());
}
watching::ScanOutcome::Completed
}
Err(error) => watching::ScanOutcome::Failed(error),
}
})
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
@@ -455,7 +523,25 @@ fn main() {
"The media decoder is missing from this installation. Repair or reinstall searchi.".into()
})
})();
let semantic = Arc::new(SemanticState::new(model_directory, worker, media_tools));
let mut ignored = vec![directory.clone(), model_directory.clone()];
if let Some(parent) = worker.parent().filter(|path| path.is_absolute()) {
ignored.push(parent.to_path_buf());
}
let index_options = searchi_core::IndexOptions {
excluded_paths: ignored.iter()
.map(|path| data_directory::canonical_exclusion(path))
.collect::<std::io::Result<Vec<_>>>()?,
..Default::default()
};
let indexing = Arc::new(AtomicBool::new(false));
let watching = Arc::new(start_folder_watching(
app.handle().clone(), database.clone(), lexical.clone(), indexing.clone(),
settings.value.roots.clone(), index_options.clone(),
).map_err(std::io::Error::other)?);
let semantic = Arc::new(
SemanticState::new(model_directory, worker, media_tools)
.with_media_scratch(directory.join("MediaScratch")),
);
semantic.begin_automatic_setup(database.clone(), settings.value.cpu_threads);
#[cfg(target_os = "macos")]
let shortcut = "Shift+Super+Space";
@@ -475,10 +561,12 @@ fn main() {
ai: Arc::new(AiSession::default()),
database,
lexical,
indexing: Arc::new(AtomicBool::new(false)),
indexing,
settings: Arc::new(Mutex::new(settings)),
shortcut_status,
semantic,
watching,
index_options,
});
Ok(())
})
@@ -500,8 +588,15 @@ fn main() {
hybrid_search,
hybrid_search_measured
])
.run(tauri::generate_context!())
.expect("Searchi desktop could not start");
.build(tauri::generate_context!())
.expect("Searchi desktop could not start")
.run(|app, event| {
if matches!(event, tauri::RunEvent::Exit) {
// Release the worker-held AppHandle and owned watcher before
// application state is torn down.
app.state::<AppState>().watching.stop();
}
});
}
#[cfg(test)]