quick_search/crates/quicksearch-gui/src/backend.rs
= 9760503a3f
Some checks failed
CI / linux (push) Failing after 5m46s
CI / windows-cross (push) Successful in 5m40s
CI / release (push) Has been skipped
Release v1.1: live results, duplicate verification, and a Settings tab.
Search results now track the disk as it changes (live.rs), duplicate
groups can be confirmed byte-for-byte before deletion (verify.rs), the
Options window becomes a Settings tab with configurable/sortable result
columns, and a first-start tutorial explains the basics.
2026-08-17 19:26:18 -04:00

176 lines
6.5 KiB
Rust

//! Wiring between the egui thread and the core services.
//!
//! All communication is non-blocking from the UI's point of view: searches
//! stream over an mpsc receiver drained each frame, indexing state is
//! polled, and the duplicates query runs on a throwaway worker thread.
//! Every core thread wakes the UI through `ctx.request_repaint()`, which is
//! what makes polling enough.
//!
//! The duplicates scan and the byte-for-byte verification of one of its
//! groups are the throwaway threads, and both fire on a user action rather
//! than a timer: a thread per refresh opens its own connection — a page cache
//! and an allocator arena glibc never gives back. The verification opens no
//! connection at all, but it can hold a large group's worth of file handles,
//! so it carries a cancel flag and shutdown raises it.
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use quicksearch_core::config::Config;
use quicksearch_core::coordinator::IndexCoordinator;
use quicksearch_core::live::{LiveUpdate, LiveWatcher};
use quicksearch_core::search::{DuplicateGroup, SearchService, SearchUpdate};
use quicksearch_core::shutdown;
use quicksearch_core::verify::{verify_identical, VerifyUpdate};
/// A duplicate group being read through. The thread is detached and owns
/// nothing the app needs back, so cancelling is just raising the flag: the
/// worker notices between chunks and drops the receiver's other end.
pub struct VerifyJob {
pub rx: mpsc::Receiver<VerifyUpdate>,
cancel: Arc<AtomicBool>,
}
impl VerifyJob {
pub fn cancel(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
pub struct Backend {
pub coordinator: Arc<IndexCoordinator>,
pub search: Option<SearchService>,
pub search_rx: mpsc::Receiver<SearchUpdate>,
pub dup_job: Option<mpsc::Receiver<Result<Vec<DuplicateGroup>, String>>>,
pub verify_job: Option<VerifyJob>,
/// Watches the results currently on screen; see [`quicksearch_core::live`].
/// `None` only after [`Backend::shutdown`].
pub live: Option<LiveWatcher>,
pub live_rx: mpsc::Receiver<LiveUpdate>,
}
impl Backend {
pub fn start(config: &Config, ctx: egui::Context) -> Result<Backend, String> {
// eframe is reactive: a run the coordinator schedules on its own
// would sit unseen behind a settled window until the pointer moved.
// It calls this on the edge into work, not on a cadence.
let coord_ctx = ctx.clone();
let coordinator = Arc::new(IndexCoordinator::start(
config.clone(),
Arc::new(move || coord_ctx.request_repaint()),
)?);
if let Err(e) = shutdown::install_signal_handler(coordinator.clone()) {
quicksearch_core::log_warn!("failed to install signal handler: {}", e);
}
let repaint_ctx = ctx.clone();
let (search, search_rx) = SearchService::new(
config.resolved_database_path(),
Arc::new(move || repaint_ctx.request_repaint()),
);
let live_ctx = ctx.clone();
let (live, live_rx) = LiveWatcher::start(Arc::new(move || live_ctx.request_repaint()));
Ok(Backend {
coordinator,
search: Some(search),
search_rx,
dup_job: None,
verify_job: None,
live: Some(live),
live_rx,
})
}
/// Point the live watcher at the rows currently on screen, or clear it
/// with an empty `targets`.
pub fn watch_live(
&self,
query: &str,
targets: Vec<quicksearch_core::live::Target>,
config: &Config,
) {
let Some(live) = &self.live else { return };
if targets.is_empty() {
live.clear();
} else {
live.watch(query, targets, config);
}
}
/// Ask the coordinator to bring the index in line with these paths — the
/// files the live watcher has just read from disk on the frontend's
/// behalf, so the index does not drift from what is on screen.
pub fn reindex_live_paths(&self, paths: Vec<PathBuf>) {
self.coordinator.update_paths(paths);
}
pub fn clear_live(&self) {
if let Some(live) = &self.live {
live.clear();
}
}
/// `None` only after [`Backend::shutdown`], i.e. during teardown frames.
pub fn search(&self) -> Option<&SearchService> {
self.search.as_ref()
}
/// Kick off (or restart) the duplicates listing on a worker thread.
pub fn start_duplicates(&mut self, config: &Config, ctx: egui::Context) {
let (tx, rx) = mpsc::channel();
let db = config.resolved_database_path();
std::thread::spawn(move || {
let result =
quicksearch_core::search::find_duplicate_groups(&db.to_string_lossy(), 500, 0);
let _ = tx.send(result);
ctx.request_repaint();
});
self.dup_job = Some(rx);
}
/// Read a duplicate group through on a worker thread, comparing every
/// member against the first byte for byte. Replaces any run already going.
pub fn start_verify(&mut self, paths: Vec<PathBuf>, ctx: egui::Context) {
if let Some(job) = &self.verify_job {
job.cancel();
}
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = cancel.clone();
std::thread::spawn(move || {
verify_identical(&paths, &worker_cancel, &mut |update| {
// A closed receiver means the app moved on; the cancel flag
// is what stops the work, so there is nothing to do here.
let _ = tx.send(update);
ctx.request_repaint();
});
});
self.verify_job = Some(VerifyJob { rx, cancel });
}
/// Stop a verification and forget it. The worker sees the flag between
/// chunks and exits on its own.
pub fn cancel_verify(&mut self) {
if let Some(job) = self.verify_job.take() {
job.cancel();
}
}
/// Join the search worker and stop the coordinator. Called once from
/// `on_exit`.
pub fn shutdown(&mut self) {
// Detached and holding open file handles: the flag is what makes a
// verification of a slow, large group let go on the way out.
self.cancel_verify();
if let Some(search) = self.search.take() {
search.shutdown();
}
if let Some(mut live) = self.live.take() {
live.stop();
}
self.coordinator.shutdown();
}
}