quick_search/crates/quicksearch-core/src/live.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

734 lines
28 KiB
Rust

//! Watching the search results a frontend is actually showing.
//!
//! What a row shows is read from the *file*, never from the index. That is
//! what lets this work with indexing stopped, with a file outside every
//! indexed root, or against a row the indexer has not caught up with yet —
//! and it is the whole point of the feature: the list on screen describes the
//! disk, not a snapshot of it.
//!
//! Nothing here writes to the index. Keeping it so is what lets this run
//! alongside the indexer without a second writer; a frontend that wants the
//! index brought back in line with what it just displayed hands the paths to
//! [`crate::coordinator::IndexCoordinator::update_paths`], which does the
//! write on its own thread.
//!
//! # Why directories, not files
//!
//! The obvious design is a watch per result file. It does not work. Editors
//! save by writing a temporary file and renaming it over the target, so the
//! event lands on the *directory* and the old inode — the one a file watch is
//! attached to — is simply orphaned. A file watch also cannot report the new
//! name of a rename. Watching the deduplicated set of parent directories
//! `NonRecursive` sees both, on inotify and on `ReadDirectoryChangesW` alike.
//!
//! # Why this is not [`crate::watcher`]
//!
//! That module exists to cover *subtrees*: it walks each root registering
//! every directory beneath it, adds directories that appear later, and backs a
//! 128k budget with an all-or-nothing guarantee. Pointing it at a result's
//! parent would register that parent's whole tree. This is a flat, fixed, tiny
//! set with no growth and no budget, and its timings are a tenth of that one's
//! — the indexer can afford to coalesce for thirty seconds, a cursor blinking
//! next to a stale filename cannot.
//!
//! # What an event turns into
//!
//! A rename is applied from the event itself. A content change is answered by
//! reading the file: `metadata` for size and modified time, and — for a row
//! whose cell shows body text — the same MIME sniffing and extractors the
//! indexer uses, re-cut through the same [`crate::search::cascade::text_snippet`]
//! (or, for a fuzzy hit, [`crate::search::cascade::fuzzy_snippet`]) the
//! search itself uses.
//!
//! Arming also sweeps every target once, comparing the file on disk against
//! the size and modified time the row is *currently displaying*. Since a fresh
//! result carries what the index said, that sweep is exactly a check of the
//! index against the disk, and it is what makes a row corrected while it was
//! scrolled out of view right itself the moment it comes back. It is also the
//! only thing that works on a filesystem the platform reports no events for.
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use notify::{
Config as NotifyConfig, Event as NotifyEvent, EventKind, RecommendedWatcher, RecursiveMode,
Watcher as NotifyWatcher,
};
use crate::config::Config;
use crate::extract::Registry;
use crate::query::split::split_for_cascade;
use crate::search::fuzzy::{edit_budget, Bitap};
use crate::search::ContentTier;
use crate::snippet::Snippet;
/// How long events for one path are pooled before being acted on, so the
/// several notify emits behind a single save collapse into one update.
const SETTLE: Duration = Duration::from_millis(150);
/// Floor on how often any one path may produce an update. A file being written
/// in a loop — a log, a build artifact — cannot spin the UI.
const MIN_INTERVAL: Duration = Duration::from_millis(750);
/// How often the loop wakes while it has work pending. It blocks outright when
/// it has none, so an idle QuickSearch does not tick at all.
const TICK: Duration = Duration::from_millis(50);
/// Ceiling on updates emitted per tick, so a directory-wide change (an
/// unpack, a `chmod -R`) drains over several frames instead of one.
const MAX_PER_TICK: usize = 4;
/// Most watches to register. Results cluster hard — a query's hits usually
/// share a handful of directories — so this is generous for the visible rows
/// while staying negligible against the indexer's 128k budget.
const MAX_DIRS: usize = 64;
/// Most rows to track, whatever the frontend asks for.
const MAX_TARGETS: usize = 256;
/// One row the frontend is showing and wants kept current.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Target {
/// The row's path, spelled exactly as the search returned it. Event paths
/// are compared against this byte for byte — see the note in [`watch`].
///
/// [`watch`]: LiveWatcher::watch
pub path: String,
/// `Some` when this row displays text from the file's *body*, and so
/// needs its snippet re-cut when the file changes — and how, since an
/// exact-tier window is cut around the literal term and a fuzzy one
/// around a bitap match the literal is usually absent from. `None` for a
/// filename or path match, which costs one `metadata` call per change and
/// never opens the file.
pub text: Option<ContentTier>,
/// The size the row is displaying. The arm-time sweep compares the file
/// against this, so on a fresh result — where it is whatever the index
/// said — the sweep doubles as a check of the index against the disk.
pub size: u64,
/// The modified time the row is displaying; see [`Target::size`].
pub mtime: i64,
}
/// What a change did to a row's Content Match window.
///
/// Three states, not an `Option`, because "I did not look" and "I looked and
/// it is not there any more" have to reach the frontend as different answers.
/// Blanking a cell because the file was too large to re-read would lose a
/// window the search legitimately found.
#[derive(Debug, Clone, PartialEq)]
pub enum WindowUpdate {
/// Nothing to say: not a body-text row, or its body could not be re-read
/// (too large, no extractor, unreadable). The cell keeps what it has.
Unchanged,
/// Re-cut from the file as it is now.
Cut(Snippet),
/// The body was read and the query is no longer in it. The cell has
/// nothing to show and falls back to its dash.
NoMatch,
}
/// A ready-to-apply change to one row on screen.
#[derive(Debug, Clone, PartialEq)]
pub enum LiveUpdate {
/// The file moved. `path` is the row's old path — the frontend's key.
Renamed {
path: String,
to: String,
name: String,
},
/// The file's contents changed, as read from the file itself.
Changed {
path: String,
size: u64,
mtime: i64,
window: WindowUpdate,
},
/// The file is no longer there. Reversible: the directory watch stays, so
/// a file recreated at the same path reports [`LiveUpdate::Changed`].
Gone { path: String },
}
impl LiveUpdate {
/// The row this update is keyed by — the path the frontend knows it as.
pub fn path(&self) -> &str {
match self {
LiveUpdate::Renamed { path, .. }
| LiveUpdate::Changed { path, .. }
| LiveUpdate::Gone { path } => path,
}
}
}
/// What one settled event window decided about a path, before the filesystem
/// is consulted.
#[derive(Debug, Clone, PartialEq)]
enum Op {
Changed,
Renamed(PathBuf),
/// Provisional: on Linux the `From` half of a rename arrives before the
/// paired event that names the destination, so this may still be upgraded
/// to [`Op::Renamed`] inside the same window.
Gone,
}
/// Commands and events share one channel so the loop can block on `recv()`
/// whenever nothing is pending.
enum Msg {
Event(NotifyEvent),
Watch {
query: String,
targets: Vec<Target>,
/// Boxed: this is by far the largest variant, and a `Watch` is rare
/// next to the events sharing the channel with it.
config: Box<Config>,
},
Clear,
Stop,
}
/// Handle on the watcher thread. Dropping it stops the thread.
pub struct LiveWatcher {
tx: mpsc::Sender<Msg>,
handle: Option<JoinHandle<()>>,
}
impl LiveWatcher {
/// Spawn the watcher. `notify` is called after every update is queued, so
/// an egui frontend can `request_repaint`; pass a no-op for headless use.
pub fn start(notify: Arc<dyn Fn() + Send + Sync>) -> (LiveWatcher, mpsc::Receiver<LiveUpdate>) {
let (tx, rx) = mpsc::channel::<Msg>();
let (update_tx, update_rx) = mpsc::channel::<LiveUpdate>();
let event_tx = tx.clone();
let handle = thread::Builder::new()
.name("qs-live".into())
.spawn(move || {
Loop {
rx,
event_tx,
update_tx,
notify,
watcher: None,
targets: HashMap::new(),
pending: HashMap::new(),
last_emit: HashMap::new(),
orphan_to: Vec::new(),
settle_at: None,
query: None,
fuzzy: None,
config: None,
registry: Registry::default_set(),
}
.run()
})
.expect("spawn live watcher");
(
LiveWatcher {
tx,
handle: Some(handle),
},
update_rx,
)
}
/// Replace the watched set wholesale.
///
/// `query` is the search these rows came from; it is what a re-cut snippet
/// is marked against. `config` supplies the extraction limits and filters,
/// so a snippet cut here is the text the indexer would have stored.
/// Registration happens on the watcher's own thread, so this never blocks
/// the caller on a spun-down disk or a stale mount.
pub fn watch(&self, query: &str, targets: Vec<Target>, config: &Config) {
let _ = self.tx.send(Msg::Watch {
query: query.to_string(),
targets,
config: Box::new(config.clone()),
});
}
/// Drop every watch and forget every pending update.
pub fn clear(&self) {
let _ = self.tx.send(Msg::Clear);
}
/// Stop the thread and join it. Idempotent; [`Drop`] calls it.
pub fn stop(&mut self) {
let _ = self.tx.send(Msg::Stop);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for LiveWatcher {
fn drop(&mut self) {
self.stop();
}
}
struct Loop {
rx: mpsc::Receiver<Msg>,
event_tx: mpsc::Sender<Msg>,
update_tx: mpsc::Sender<LiveUpdate>,
notify: Arc<dyn Fn() + Send + Sync>,
watcher: Option<RecommendedWatcher>,
targets: HashMap<String, Target>,
pending: HashMap<String, Op>,
last_emit: HashMap<String, Instant>,
/// Rename destinations seen in this window whose source is not a target —
/// the other half of a Windows rename, which carries no pairing cookie.
orphan_to: Vec<PathBuf>,
settle_at: Option<Instant>,
query: Option<crate::query::split::CascadeQuery>,
/// The fuzzy matcher for `query`, built at arm time — building it is the
/// cost, running it is cheap — for re-cutting [`ContentTier::Fuzzy`]
/// rows. `None` when the term does not fuzz (too short, wildcarded, or a
/// zero edit budget), which is when the fuzzy pass would not have run.
fuzzy: Option<Bitap>,
config: Option<Box<Config>>,
/// Built once and reused: the extractors are stateless, and the frontend
/// re-arms often enough that rebuilding the table per arm would be waste.
registry: Registry,
}
impl Loop {
fn run(mut self) {
loop {
// Block outright when there is nothing to time out on: an idle
// window costs no wakeups at all.
let msg = match self.settle_at {
None => match self.rx.recv() {
Ok(msg) => Some(msg),
Err(_) => return,
},
Some(deadline) => {
let wait = deadline.saturating_duration_since(Instant::now());
match self.rx.recv_timeout(wait.min(TICK)) {
Ok(msg) => Some(msg),
Err(mpsc::RecvTimeoutError::Timeout) => None,
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
};
match msg {
Some(Msg::Stop) => return,
Some(Msg::Clear) => self.reset(),
Some(Msg::Watch {
query,
targets,
config,
}) => self.rearm(&query, targets, config),
Some(Msg::Event(event)) => {
classify(
&event,
&self.targets,
&mut self.pending,
&mut self.orphan_to,
);
if !self.pending.is_empty() && self.settle_at.is_none() {
self.settle_at = Some(Instant::now() + SETTLE);
}
}
None => {}
}
self.flush_settled();
}
}
fn reset(&mut self) {
self.watcher = None;
self.targets.clear();
self.pending.clear();
self.last_emit.clear();
self.orphan_to.clear();
self.settle_at = None;
self.query = None;
self.fuzzy = None;
self.config = None;
}
/// Point the watcher at a new set of rows, dropping everything about the
/// old one, then check each one against the disk. Registration failures
/// are per-directory and silent beyond the log: this is a cosmetic
/// feature, and a modal about a missing highlight would be worse than the
/// missing highlight.
fn rearm(&mut self, query: &str, targets: Vec<Target>, config: Box<Config>) {
self.reset();
if targets.is_empty() {
return;
}
self.query = split_for_cascade(query).ok();
// The same construction the fuzzy pass makes for a scan, so a fuzzy
// row is re-cut against exactly what matched it.
self.fuzzy = self.query.as_ref().and_then(|q| {
if q.pattern.is_wildcard() {
return None;
}
let folded = q.term.to_ascii_lowercase();
let k = edit_budget(folded.len(), config.search.fuzzy_max_edits)?;
Bitap::new(folded.as_bytes(), k)
});
self.config = Some(config);
let mut watcher = {
let tx = self.event_tx.clone();
let sink = move |res: notify::Result<NotifyEvent>| {
if let Ok(event) = res {
let _ = tx.send(Msg::Event(event));
}
};
match RecommendedWatcher::new(sink, NotifyConfig::default()) {
Ok(w) => w,
Err(e) => {
crate::log_warn!("live results: no watcher available: {}", e);
return;
}
}
};
let mut dirs: Vec<PathBuf> = Vec::new();
for target in targets.into_iter().take(MAX_TARGETS) {
// Derived from the row's own path, never canonicalized: notify
// builds each event path as `watched_dir.join(name)`, so leaving
// this spelled as the index spells it is what lets event paths be
// compared to `Target::path` as plain strings.
let Some(dir) = Path::new(&target.path).parent().map(Path::to_path_buf) else {
continue;
};
if !dirs.contains(&dir) {
if dirs.len() >= MAX_DIRS {
continue;
}
if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
// Deliberately not the indexer's all-or-nothing: partial
// coverage of a display nicety is fine. Rows under this
// directory are dropped rather than swept — a row nothing
// can follow is better left alone than corrected once and
// then silently frozen.
crate::log_warn!("live results: not watching {}: {}", dir.display(), e);
continue;
}
dirs.push(dir);
}
self.targets.insert(target.path.clone(), target);
}
if self.targets.is_empty() {
return;
}
self.watcher = Some(watcher);
self.sweep();
}
/// Compare every target against the disk once and emit what disagrees.
///
/// Nothing here waits for an event, which is the point: a row whose file
/// changed while it was scrolled out of view — or one whose index row was
/// simply out of date when the search returned it — is corrected the
/// moment it is watched.
fn sweep(&mut self) {
let mut updates: Vec<LiveUpdate> = Vec::new();
for target in self.targets.values() {
match std::fs::metadata(&target.path) {
Ok(meta) if meta.is_file() => {
let (size, mtime) = (meta.len(), mtime_of(&meta));
if size == target.size && mtime == target.mtime {
continue;
}
updates.push(self.changed_update(&target.path, target.text, size, mtime));
}
// Unreadable counts as gone: the row cannot be shown as
// current when we cannot see the file at all.
_ => updates.push(LiveUpdate::Gone {
path: target.path.clone(),
}),
}
}
let now = Instant::now();
for update in updates {
// Recorded so an event arriving right behind the sweep — a save
// still in flight when the row came on screen — does not repeat
// the same answer a moment later.
self.last_emit.insert(update.path().to_string(), now);
self.note_emitted(&update);
self.send(update);
}
}
/// Turn the settled window's decisions into updates.
fn flush_settled(&mut self) {
let Some(at) = self.settle_at else { return };
if Instant::now() < at {
return;
}
self.settle_at = None;
// Windows never reports the two halves of a rename as one event and
// gives no cookie to pair them by. When exactly one target went away
// and exactly one unclaimed destination appeared in the same window,
// they are the same file; anything more ambiguous resolves to the
// truthful "gone".
let orphans = std::mem::take(&mut self.orphan_to);
let gone: Vec<String> = self
.pending
.iter()
.filter(|(_, op)| **op == Op::Gone)
.map(|(path, _)| path.clone())
.collect();
if gone.len() == 1 && orphans.len() == 1 {
self.pending
.insert(gone[0].clone(), Op::Renamed(orphans[0].clone()));
}
let now = Instant::now();
let ready: Vec<(String, Op)> = self
.pending
.iter()
.filter(|(path, _)| {
self.last_emit
.get(*path)
.is_none_or(|t| now.duration_since(*t) >= MIN_INTERVAL)
})
.take(MAX_PER_TICK)
.map(|(path, op)| (path.clone(), op.clone()))
.collect();
for (path, op) in ready {
self.pending.remove(&path);
self.last_emit.insert(path.clone(), now);
self.apply(path, op);
}
if !self.pending.is_empty() {
self.settle_at = Some(now + SETTLE);
}
}
fn apply(&mut self, path: String, op: Op) {
let update = match op {
Op::Gone => LiveUpdate::Gone { path },
Op::Renamed(to) => {
let name = to
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
LiveUpdate::Renamed {
path,
to: to.to_string_lossy().into_owned(),
name,
}
}
Op::Changed => {
// Read from the file, not from the index: this has to land
// whatever the indexer is doing, or not doing.
match std::fs::metadata(&path) {
Ok(meta) if meta.is_file() => {
let text = self.targets.get(&path).and_then(|t| t.text);
self.changed_update(&path, text, meta.len(), mtime_of(&meta))
}
_ => LiveUpdate::Gone { path },
}
}
};
self.note_emitted(&update);
self.send(update);
}
/// What the file at `path` implies for the row showing it.
fn changed_update(
&self,
path: &str,
text: Option<ContentTier>,
size: u64,
mtime: i64,
) -> LiveUpdate {
LiveUpdate::Changed {
path: path.to_string(),
size,
mtime,
window: text.map_or(WindowUpdate::Unchanged, |tier| {
self.window_from_disk(path, size, tier)
}),
}
}
/// Re-cut this row's Content Match window from the file on disk.
///
/// Deliberately the indexer's own path — [`crate::mime::guess_mime_from_head`]
/// then [`crate::file_handling::decide_content`], which already applies
/// `content_extensions` and the `maximum_text_size` truncation — so the
/// text a window is cut from is the text the index would have stored, and
/// a refreshed row cannot disagree with a re-run search about anything but
/// timing. Then the tier's own matcher, for the same reason.
fn window_from_disk(&self, path: &str, size: u64, tier: ContentTier) -> WindowUpdate {
let (Some(config), Some(query)) = (self.config.as_deref(), self.query.as_ref()) else {
return WindowUpdate::Unchanged;
};
// The indexer would not have stored text for a file this large, so
// neither does the row — reading it would stall this thread over a
// window nobody can see the whole of anyway.
if size > config.processing.maximum_text_file_size {
return WindowUpdate::Unchanged;
}
let file = Path::new(path);
let Some(head) = read_head(file, config.processing.hash_length) else {
return WindowUpdate::Unchanged;
};
let mime = crate::mime::guess_mime_from_head(file, &head);
// Extractors run third-party parsers over whatever the file now
// holds. One that panics must not take this thread — and with it every
// live row for the rest of the session — down with it.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
crate::file_handling::decide_content(path, mime.as_deref(), &self.registry, config)
}));
let outcome = match outcome {
Ok(outcome) => outcome,
Err(_) => {
crate::log_warn!("live results: extracting {} panicked", path);
return WindowUpdate::Unchanged;
}
};
let Some(text) = crate::file_handling::outcome_body(&outcome) else {
return WindowUpdate::Unchanged;
};
let folded = text.to_ascii_lowercase();
let cut = match tier {
ContentTier::Exact => {
// A literal term always yields a window, marked or not,
// because the passes only ever call this for a body FTS
// already matched. Here the body may genuinely have stopped
// matching, and an unmarked window is how that reads.
crate::search::cascade::text_snippet(&query.pattern, text, &folded)
.filter(|snip| !snip.ranges.is_empty())
}
ContentTier::Fuzzy => match &self.fuzzy {
Some(bitap) => {
crate::search::cascade::fuzzy_snippet(bitap, text, &folded).map(|(_, s)| s)
}
// The term does not fuzz, so a fuzzy row cannot be re-judged;
// leaving it is the honest reading.
None => return WindowUpdate::Unchanged,
},
};
match cut {
Some(snip) => WindowUpdate::Cut(snip),
None => WindowUpdate::NoMatch,
}
}
/// Keep the target's baseline in step with what the frontend was just
/// told, so a later sweep over the same arm does not repeat itself.
fn note_emitted(&mut self, update: &LiveUpdate) {
let LiveUpdate::Changed {
path, size, mtime, ..
} = update
else {
return;
};
if let Some(target) = self.targets.get_mut(path) {
target.size = *size;
target.mtime = *mtime;
}
}
fn send(&self, update: LiveUpdate) {
if self.update_tx.send(update).is_ok() {
(self.notify)();
}
}
}
/// The first `limit` bytes of a file, for MIME sniffing. A short read is the
/// whole file and is not an error; an unreadable file simply has no MIME.
fn read_head(path: &Path, limit: usize) -> Option<Vec<u8>> {
let file = std::fs::File::open(path).ok()?;
let mut head = Vec::new();
file.take(limit as u64).read_to_end(&mut head).ok()?;
Some(head)
}
fn mtime_of(meta: &std::fs::Metadata) -> i64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Fold one notify event into the pending decisions for this window.
///
/// Pure and filesystem-free, which is what makes the platform differences
/// testable: every shape below is a real emission from `notify` 6.1 on one
/// platform or the other, and the coalescing window is what reconciles them.
fn classify(
event: &NotifyEvent,
targets: &HashMap<String, Target>,
pending: &mut HashMap<String, Op>,
orphan_to: &mut Vec<PathBuf>,
) {
use notify::event::{ModifyKind, RenameMode};
let key = |p: &PathBuf| p.to_string_lossy().into_owned();
let is_target = |p: &PathBuf| targets.contains_key(&key(p));
match event.kind {
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
// Linux pairs the halves and emits this *after* the From/To pair,
// so it lands in the same window and overwrites the provisional
// Gone recorded below.
let (Some(from), Some(to)) = (event.paths.first(), event.paths.get(1)) else {
return;
};
if is_target(from) {
pending.insert(key(from), Op::Renamed(to.clone()));
} else if is_target(to) {
// The atomic-save shape: a temporary file renamed over a row
// we are watching. The row did not move; its contents changed.
pending.insert(key(to), Op::Changed);
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
for path in &event.paths {
if is_target(path) {
pending.insert(key(path), Op::Changed);
} else {
orphan_to.push(path.clone());
}
}
}
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
for path in &event.paths {
if is_target(path) {
// Provisional; a Both in this same window upgrades it.
pending.entry(key(path)).or_insert(Op::Gone);
}
}
}
EventKind::Remove(_) => {
for path in &event.paths {
if is_target(path) {
pending.insert(key(path), Op::Gone);
}
}
}
EventKind::Create(_) | EventKind::Modify(_) => {
for path in &event.paths {
if is_target(path) {
// A Create at a watched path un-deletes the row.
pending.insert(key(path), Op::Changed);
}
}
}
// Access events, and anything for a path we are not showing. Live
// results never *add* rows: we have no way to know an unrelated new
// file matches the query, and guessing would be a second, unranked
// search wearing the first one's clothes.
_ => {}
}
}
#[cfg(test)]
#[path = "live_tests.rs"]
mod tests;