diff --git a/Cargo.lock b/Cargo.lock index 8007f42..44907f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,7 +140,7 @@ dependencies = [ "base64ct", "blake2", "cpufeatures 0.2.12", - "password-hash 0.5.0", + "password-hash", "zeroize", ] @@ -499,26 +499,6 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" -[[package]] -name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "calloop" version = "0.13.0" @@ -706,12 +686,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "constant_time_eq" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" - [[package]] name = "core-foundation" version = "0.9.4" @@ -1706,15 +1680,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "home" version = "0.5.9" @@ -2955,17 +2920,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "password-hash" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "password-hash" version = "0.5.0" @@ -2983,18 +2937,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pbkdf2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" -dependencies = [ - "digest", - "hmac", - "password-hash 0.4.2", - "sha2", -] - [[package]] name = "pdf-extract" version = "0.12.0" @@ -3237,7 +3179,7 @@ dependencies = [ [[package]] name = "quicksearch-core" -version = "0.8.0" +version = "0.8.5" dependencies = [ "argon2", "ctrlc", @@ -3260,12 +3202,12 @@ dependencies = [ "windows-sys 0.52.0", "zeroize", "zip", - "zstd 0.13.3", + "zstd", ] [[package]] name = "quicksearch-gui" -version = "0.8.0" +version = "0.8.5" dependencies = [ "chrono", "eframe", @@ -3669,17 +3611,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.12", - "digest", -] - [[package]] name = "sha2" version = "0.10.8" @@ -5373,27 +5304,10 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ - "aes", "byteorder", - "bzip2", - "constant_time_eq", "crc32fast", "crossbeam-utils", "flate2", - "hmac", - "pbkdf2", - "sha1", - "time", - "zstd 0.11.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.11.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" -dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", ] [[package]] @@ -5402,17 +5316,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe 7.2.4", -] - -[[package]] -name = "zstd-safe" -version = "5.0.2+zstd.1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" -dependencies = [ - "libc", - "zstd-sys", + "zstd-safe", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c967d1b..0411d92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "0.8.5" +version = "0.8.6" edition = "2021" license = "GPL-3.0-or-later" authors = ["Jeremy "] diff --git a/README.md b/README.md index 4af1a58..26604c3 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,9 @@ the package is installed. copied in place. Matches in file contents show highlighted snippets. - **Manage Index**: full indexing status, Start/Stop/Automatic controls, indexed folder list, full-text extension filters, ignore patterns, and - the indexing options. + the indexing options. Stopping switches to manual mode and saves that + (`indexing.auto_index`), so a stopped index stays stopped across + restarts until you return to automatic. - **Duplicates**: files sharing a content hash, grouped. - **Logs**: the lines the app would have printed to a terminal — warnings from indexing, folder watching and opening files, newest last, with a @@ -286,7 +288,9 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime. a polled `IndexingStatus`. - **Coordinator** (`coordinator.rs`): the object binaries construct. Owns the `IndexingService`, the debouncing filesystem watcher - (`watcher.rs`), and the mode state machine (Auto / Manual). Watcher + (`watcher.rs`), and the mode state machine (Auto / Manual, persisted as + `indexing.auto_index` — the mode the app is left in is the mode it + starts in, and a config carrying a different value switches it). Watcher events become single-file transactions (`incremental.rs`) that keep `files`, FTS, and the text sidecar consistent per commit; a full reindex runs on a configurable interval. Incremental writes defer while diff --git a/config_example.toml b/config_example.toml index 8e247c7..1c4487a 100644 --- a/config_example.toml +++ b/config_example.toml @@ -21,10 +21,13 @@ indexing_paths = ["~"] database_path = "~/.local/share/quicksearch/index.sqlite" [indexing] -# Automatic mode: filesystem watchers apply changes as they happen and a -# full reindex runs every reindex_interval_minutes. The watcher catches -# changes as they happen, so this only needs to be often enough to cover -# whatever the watcher missed. +# The indexing mode. true = automatic: filesystem watchers apply changes as +# they happen and a full reindex runs every reindex_interval_minutes (the +# watcher catches changes as they happen, so that interval only needs to be +# often enough to cover whatever the watcher missed). false = manual: +# nothing is indexed until you ask for it. The Stop and Return to Automatic +# buttons on the Manage Index tab write this value, so the mode you left the +# app in is the mode it starts in. auto_index = true reindex_interval_minutes = 1440 # Follow symbolic links during directory walks. diff --git a/crates/quicksearch-core/Cargo.toml b/crates/quicksearch-core/Cargo.toml index 69670e0..cb052ee 100644 --- a/crates/quicksearch-core/Cargo.toml +++ b/crates/quicksearch-core/Cargo.toml @@ -23,7 +23,11 @@ zeroize = { version = "1", features = ["derive"] } getrandom = "0.2" sha2 = "0.10.8" walkdir = "2.5.0" -zip = "0.6" +# Default features pull in zstd 0.11, which cannot unify with our zstd 0.13 below +# and so builds a second copy of the wrapper crates. We only read OOXML/ODF +# containers (docx/xlsx/pptx/odt/ods/odp), whose entries are always deflate or +# stored, so zstd/bzip2/aes-crypto are all dead weight here. +zip = { version = "0.6", default-features = false, features = ["deflate"] } quick-xml = "0.31" serde = { version = "1.0", features = ["derive"] } toml = "0.8" diff --git a/crates/quicksearch-core/examples/memprobe.rs b/crates/quicksearch-core/examples/memprobe.rs index 3059a3f..6b78693 100644 --- a/crates/quicksearch-core/examples/memprobe.rs +++ b/crates/quicksearch-core/examples/memprobe.rs @@ -14,9 +14,14 @@ //! ``` //! //! The optional trailing number is the sampling interval in milliseconds -//! (default 100). Drop it to single digits to name the file a spike happened -//! on: at 100 ms the extractor has moved on by the time RSS is read, so the -//! file the timeline shows beside a spike is only approximately the cause. +//! (default 100). Finer sampling resolves the *shape* of a spike, not its +//! cause: the file column is only as good as `RootProgress::current_file`, +//! which [`crate::indexing`] publishes once per extraction batch, holding the +//! last file of the batch that just finished. During a batch it therefore +//! names a file that is already done, and no sampling rate fixes that. To +//! attribute a spike to a file, narrow the root instead — index a directory +//! holding only the candidates, which is what identified `pdf-extract` as the +//! largest single consumer on this tree. //! //! `cold` deletes the database first: every file is new, so the walk hashes //! and extracts all of them and `existing_files` starts empty. `warm` re-runs @@ -33,11 +38,13 @@ //! peak to the walk or to extraction; a sampled peak far under VmHWM means //! the real spike was shorter than the sampling interval. //! -//! RSS counts the page cache backing the mmap'd database, so the figure is a -//! ceiling on what the process needs, not a floor on what it must have: those -//! pages are evictable under pressure. `/usr/bin/time -v` on this binary -//! reports the same VmHWM, as a cross-check that nothing here is fooling -//! itself. +//! Nothing here is evictable page cache. The connection sets no `mmap_size`, +//! so SQLite reads the index through its own `malloc`'d page cache (`PRAGMA +//! cache_size`, 10000 pages ≈ 40 MiB) rather than mapping the file, and the +//! `by mapping` breakdown confirms it: the index never appears as a +//! file-backed mapping. Every megabyte reported is memory the process +//! actually holds. `/usr/bin/time -v` on this binary reports the same VmHWM, +//! as a cross-check that nothing here is fooling itself. use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; diff --git a/crates/quicksearch-core/examples/walkprobe.rs b/crates/quicksearch-core/examples/walkprobe.rs index 8a517b1..2335e14 100644 --- a/crates/quicksearch-core/examples/walkprobe.rs +++ b/crates/quicksearch-core/examples/walkprobe.rs @@ -30,7 +30,6 @@ //! Both modes report files/sec. Run each twice: the first pass warms the page //! cache (or, on a share, the client's attribute cache), so the second is the //! one to compare. -use std::collections::HashMap; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use std::time::{Instant, UNIX_EPOCH}; @@ -38,22 +37,30 @@ use std::time::{Instant, UNIX_EPOCH}; use quicksearch_core::config::{Config, IgnoreSet}; use quicksearch_core::extract::Registry; use quicksearch_core::file_handling::{ - classify_for_indexing, filtered_walk, prepare_file_record, ExistingFileEntry, FileIndexAction, + classify_for_indexing, filtered_walk, prepare_file_record, DirRows, FileIndexAction, UnreadableDirs, }; -use quicksearch_core::walk::walk_indexable_files; +use quicksearch_core::walk::{walk_indexable_files, WalkEvent}; fn main() { let root = std::env::args().nth(1).unwrap(); let mode = std::env::args().nth(2).unwrap_or_else(|| "parallel".into()); let config = Config::default(); - let existing: HashMap = HashMap::new(); + // Phase 1 in isolation: an empty index, so every file classifies as new. + // The parallel walker reads its classification data from a database now, + // so it gets a scratch one rather than an empty map. + let db = std::env::temp_dir().join(format!("quicksearch-walkprobe-{}.sqlite", std::process::id())); + let _ = std::fs::remove_file(&db); + quicksearch_core::db::open_or_recreate(db.to_str().unwrap(), &config.processing.tokenize) + .expect("scratch index"); + let existing = DirRows::new(); let start = Instant::now(); let (seen, prepared) = match mode.as_str() { "serial" => serial(&root, &config, &existing), - _ => parallel(&root, &config, existing), + _ => parallel(&root, &config, db.to_str().unwrap()), }; + let _ = std::fs::remove_file(&db); let elapsed = start.elapsed(); eprintln!( @@ -63,11 +70,7 @@ fn main() { ); } -fn serial( - root: &str, - config: &Config, - existing: &HashMap, -) -> (usize, usize) { +fn serial(root: &str, config: &Config, existing: &DirRows) -> (usize, usize) { let ignore = IgnoreSet::compile(&[]).unwrap(); let registry = Registry::default_set(); let (mut seen, mut prepared) = (0, 0); @@ -90,7 +93,13 @@ fn serial( else { continue; }; - if classify_for_indexing(&path, mtime, existing) != FileIndexAction::Skip + // Keyed by name within its directory, as the real walk now is; with + // an empty index the answer is Insert either way. + let name = std::path::Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if classify_for_indexing(&name, mtime, existing) != FileIndexAction::Skip && prepare_file_record(&path, &meta, config, ®istry).is_some() { prepared += 1; @@ -99,24 +108,21 @@ fn serial( (seen, prepared) } -fn parallel( - root: &str, - config: &Config, - existing: HashMap, -) -> (usize, usize) { +fn parallel(root: &str, config: &Config, db_path: &str) -> (usize, usize) { let (mut seen, mut prepared) = (0, 0); - for file in walk_indexable_files( + for event in walk_indexable_files( &[root.to_string()], false, false, IgnoreSet::compile(&[]).unwrap(), - Arc::new(existing), + db_path, config.clone(), Arc::new(Registry::default_set()), Arc::new(Mutex::new(false)), Arc::new(AtomicBool::new(false)), 4, ) { + let WalkEvent::File(file) = event else { continue }; seen += 1; if file.record.is_some() { prepared += 1; diff --git a/crates/quicksearch-core/src/config.rs b/crates/quicksearch-core/src/config.rs index d461824..8f1e8e1 100644 --- a/crates/quicksearch-core/src/config.rs +++ b/crates/quicksearch-core/src/config.rs @@ -48,8 +48,11 @@ pub struct PathConfig { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(default)] pub struct IndexingConfig { - /// Start in automatic mode: filesystem watchers apply changes as they - /// happen and a full reindex runs every `reindex_interval_minutes`. + /// The indexing mode, written down: `true` is automatic — filesystem + /// watchers apply changes as they happen and a full reindex runs every + /// `reindex_interval_minutes` — and `false` is manual, where nothing + /// runs until the user asks. The GUI's Stop / Return to Automatic + /// controls save it as they switch, so the mode survives a restart. pub auto_index: bool, pub reindex_interval_minutes: u64, pub follow_symlinks: bool, @@ -292,7 +295,7 @@ impl Default for Config { /// to catch it would also catch a user folder named `Windows`. /// `config_example.toml` documents it for people who add a drive root. fn default_ignore_patterns() -> Vec { - let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv", "*.pdf"]; + let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv"]; if cfg!(windows) { patterns.extend([ "$RECYCLE.BIN", diff --git a/crates/quicksearch-core/src/coordinator.rs b/crates/quicksearch-core/src/coordinator.rs index 332ec63..c6c7027 100644 --- a/crates/quicksearch-core/src/coordinator.rs +++ b/crates/quicksearch-core/src/coordinator.rs @@ -12,6 +12,11 @@ //! - **ManualRunning** — one user-forced full run; returns to //! `ManualStopped` when it finishes. (A forced run in Auto stays Auto.) //! +//! `indexing.auto_index` is the persisted form of that mode: it picks the +//! starting mode here, and every mode change keeps the coordinator's copy +//! of the config in step with it. Writing the file back is the caller's +//! job — the coordinator's config is a copy, not the source of truth. +//! //! Single-writer guarantee: incremental writes are deferred while a full //! run is active — the coordinator's tick simply does nothing until the //! `IndexingService` reports idle, then drains its queue. Overflowing the @@ -307,15 +312,22 @@ impl Inner { } } CoordCmd::ConfigChanged(new) => { + let want_auto = new.indexing.auto_index; self.config = new; if let Err(e) = self.reload_filters() { crate::log_warn!("coordinator: {}", e); } // The write connection may point at an old database_path. self.write_conn = None; - // Watched roots / symlink behavior may have changed; a - // restart is cheap and unconditional beats a diff here. - if self.mode == IndexMode::Auto { + if want_auto && self.mode != IndexMode::Auto { + // The mode lives in `auto_index`, so a config that + // disagrees with the running mode *is* a mode change. + self.enter_auto(); + } else if !want_auto && self.mode == IndexMode::Auto { + self.enter_manual_stopped(); + } else if self.mode == IndexMode::Auto { + // Watched roots / symlink behavior may have changed; a + // restart is cheap and unconditional beats a diff here. self.start_watcher(); } } @@ -491,6 +503,9 @@ impl Inner { fn enter_auto(&mut self) { self.mode = IndexMode::Auto; + // Keep the config copy honest: `auto_index` is this mode written + // down, and the caller persists it from its own copy. + self.config.indexing.auto_index = true; self.start_watcher(); if self.shared.lock().unwrap().last_full_index.is_none() { self.needs_full_run = true; @@ -499,6 +514,7 @@ impl Inner { fn enter_manual_stopped(&mut self) { self.mode = IndexMode::ManualStopped; + self.config.indexing.auto_index = false; self.stop_watcher(); self.pending.clear(); let status = self.indexing.get_status(); @@ -899,6 +915,38 @@ mod tests { coord.shutdown(); } + /// `auto_index` is the mode written down, so a config whose value + /// disagrees with the running mode switches it. That is what lets the + /// GUI persist a Stop click, and what makes a hand-edited config take + /// effect without a restart. + #[test] + fn applying_a_config_switches_the_mode_to_match_auto_index() { + let f = Fixture::new(true); + let coord = + IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap(); + wait_for("watcher active", Duration::from_secs(20), || { + matches!(coord.state().watcher, WatcherStatus::Active { .. }) + }); + + let mut manual = f.config.clone(); + manual.indexing.auto_index = false; + coord.apply_config(manual.clone()); + wait_for("manual mode", Duration::from_secs(10), || { + let s = coord.state(); + s.mode == IndexMode::ManualStopped && s.watcher == WatcherStatus::Off + }); + + let mut auto = manual.clone(); + auto.indexing.auto_index = true; + coord.apply_config(auto); + wait_for("automatic mode", Duration::from_secs(20), || { + let s = coord.state(); + s.mode == IndexMode::Auto && matches!(s.watcher, WatcherStatus::Active { .. }) + }); + + coord.shutdown(); + } + #[test] fn apply_config_with_new_root_then_reindex_indexes_it() { // The reported failure: add directories, apply, click "Start diff --git a/crates/quicksearch-core/src/db/open.rs b/crates/quicksearch-core/src/db/open.rs index a8a3480..b248811 100644 --- a/crates/quicksearch-core/src/db/open.rs +++ b/crates/quicksearch-core/src/db/open.rs @@ -18,7 +18,8 @@ use std::path::Path; use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; use super::schema::{ - effective_tokenizer, fts_create_sql, PRAGMAS_FAST, PRAGMAS_READONLY, SCHEMA_CURRENT, + effective_tokenizer, fts_create_sql, PRAGMAS_FAST, PRAGMAS_READONLY, PRAGMAS_WALK_READER, + SCHEMA_CURRENT, }; use crate::security::IndexKey; @@ -99,10 +100,34 @@ pub fn open_existing(db_path: &str, write: bool) -> Result { open_existing_keyed(db_path, write, super::key::process_key().as_ref()) } +/// A read-only connection for one walk's row prefetcher. +/// +/// Identical to `open_existing(_, false)` except for the pragma profile: see +/// [`PRAGMAS_WALK_READER`] for why these connections must not take the +/// 40 MiB page cache the other profiles use. +pub fn open_walk_reader(db_path: &str) -> Result { + open_keyed_with_pragmas( + db_path, + false, + super::key::process_key().as_ref(), + PRAGMAS_WALK_READER, + ) +} + pub(crate) fn open_existing_keyed( db_path: &str, write: bool, key: Option<&IndexKey>, +) -> Result { + let pragmas = if write { PRAGMAS_FAST } else { PRAGMAS_READONLY }; + open_keyed_with_pragmas(db_path, write, key, pragmas) +} + +fn open_keyed_with_pragmas( + db_path: &str, + write: bool, + key: Option<&IndexKey>, + pragmas: &str, ) -> Result { let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX | if write { @@ -113,7 +138,6 @@ pub(crate) fn open_existing_keyed( let conn = Connection::open_with_flags(db_path, flags) .map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?; key_and_probe(&conn, db_path, key)?; - let pragmas = if write { PRAGMAS_FAST } else { PRAGMAS_READONLY }; conn.execute_batch(pragmas) .map_err(|e| format!("Failed to apply pragmas: {}", e))?; diff --git a/crates/quicksearch-core/src/db/repo.rs b/crates/quicksearch-core/src/db/repo.rs index 6b6a432..ae8654e 100644 --- a/crates/quicksearch-core/src/db/repo.rs +++ b/crates/quicksearch-core/src/db/repo.rs @@ -242,6 +242,87 @@ pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result mtime`. +/// +/// The walk's unit of classification. Keyed by name rather than full path +/// because the parent is implied — at millions of files, not storing the +/// directory prefix once per entry is the difference this whole path exists +/// to make. Served by `idx_files_parent`, so this is one index range lookup. +pub fn dir_rows( + conn: &Connection, + parent: &str, +) -> Result, String> { + let mut stmt = conn + .prepare_cached("SELECT name, mtime FROM files WHERE parent = ?1") + .map_err(|e| format!("prepare dir rows for {}: {}", parent, e))?; + let rows = stmt + .query_map(params![parent], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64)) + }) + .map_err(|e| format!("query dir rows for {}: {}", parent, e))?; + let mut out = std::collections::HashMap::new(); + for row in rows { + let (name, mtime) = row.map_err(|e| format!("read dir row under {}: {}", parent, e))?; + out.insert(name, mtime); + } + Ok(out) +} + +/// The stored mtime for one exact path, or `None` if it isn't indexed. +/// +/// For files the walk reaches by a spelling whose parent isn't the directory +/// being read — a resolved symlink target — where [`dir_rows`] would not +/// have them. +pub fn mtime_for_path(conn: &Connection, path: &str) -> Result, String> { + let mut stmt = conn + .prepare_cached("SELECT mtime FROM files WHERE path = ?1") + .map_err(|e| format!("prepare mtime lookup for {}: {}", path, e))?; + stmt.query_row(params![path], |r| r.get::<_, i64>(0)) + .optional() + .map(|o| o.map(|m| m.max(0) as u64)) + .map_err(|e| format!("mtime lookup for {}: {}", path, e)) +} + +/// Distinct `parent` values within the half-open path range `[lo, hi)`, +/// streamed to `f` rather than collected. +/// +/// Callers use this to find directories the walk never visited, so it must +/// not itself materialize a list proportional to the tree — the whole point +/// of the change that introduced it. `idx_files_parent` makes this an +/// index-only scan. +pub fn for_each_parent_in_range( + conn: &Connection, + lo: &str, + hi: &str, + mut f: F, +) -> Result<(), String> { + let mut stmt = conn + .prepare("SELECT DISTINCT parent FROM files WHERE parent >= ?1 AND parent < ?2") + .map_err(|e| format!("prepare parent scan: {}", e))?; + let rows = stmt + .query_map(params![lo, hi], |r| r.get::<_, String>(0)) + .map_err(|e| format!("parent scan: {}", e))?; + for row in rows { + f(row.map_err(|e| format!("read parent row: {}", e))?); + } + Ok(()) +} + +/// Paths of every file directly inside `parent`. +/// +/// The companion to [`for_each_parent_in_range`]: once a parent is known to +/// be unvisited, this is what its rows are. +pub fn paths_in_dir(conn: &Connection, parent: &str) -> Result, String> { + let mut stmt = conn + .prepare_cached("SELECT path FROM files WHERE parent = ?1") + .map_err(|e| format!("prepare paths in {}: {}", parent, e))?; + let rows = stmt + .query_map(params![parent], |r| r.get::<_, String>(0)) + .map_err(|e| format!("query paths in {}: {}", parent, e))?; + rows.collect::, _>>() + .map_err(|e| format!("read path under {}: {}", parent, e)) +} + /// Remove the FTS row, compressed text blob, and any `properties` rows for /// a given file id. Does not touch the `files` row itself. Idempotent — a /// missing row is fine. diff --git a/crates/quicksearch-core/src/db/schema.rs b/crates/quicksearch-core/src/db/schema.rs index afd96b1..dd66e7a 100644 --- a/crates/quicksearch-core/src/db/schema.rs +++ b/crates/quicksearch-core/src/db/schema.rs @@ -32,6 +32,25 @@ pub const PRAGMAS_READONLY: &str = " PRAGMA foreign_keys = ON; "; +/// Pragmas for a walk's row-prefetch connection. +/// +/// Identical to [`PRAGMAS_READONLY`] but for `cache_size`, and that one +/// difference is the point. One of these connections exists per indexing +/// root, so the 10000-page (~40 MiB) cache the other profiles take would +/// cost ~200 MiB across five roots — more than the per-directory +/// classification this connection exists to serve was meant to save. +/// +/// 256 pages (~1 MiB) is enough to hold the upper levels of +/// `idx_files_parent` hot, which is all these queries touch: each one is a +/// single index range lookup, and the pages under it are read once and not +/// revisited. +pub const PRAGMAS_WALK_READER: &str = " + PRAGMA busy_timeout = 5000; + PRAGMA cache_size = 256; + PRAGMA temp_store = MEMORY; + PRAGMA foreign_keys = ON; +"; + /// The full current schema. Applied by [`super::open::open_or_recreate`] /// when the DB is fresh or has just been wiped because it drifted from /// [`super::open::CURRENT_SCHEMA_VERSION`]. diff --git a/crates/quicksearch-core/src/file_handling.rs b/crates/quicksearch-core/src/file_handling.rs index 51dbcef..f6d1291 100644 --- a/crates/quicksearch-core/src/file_handling.rs +++ b/crates/quicksearch-core/src/file_handling.rs @@ -19,32 +19,13 @@ use crate::extract::Registry; use crate::indexing::should_abort; use crate::mime::{guess_mime_from_head, mime_to_type, FileType}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ExistingFileEntry { - pub mtime: u64, -} - -/// Load path and mtime per row for incremental classification (hash/size loaded only when updating a file). -pub fn load_existing_files(conn: &Connection) -> Result, rusqlite::Error> { - let mut existing_files = HashMap::new(); - let mut stmt = conn.prepare("SELECT path, mtime FROM files")?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - ExistingFileEntry { - // SQLite stores i64; mtimes are non-negative in practice. - mtime: row.get::<_, i64>(1)?.max(0) as u64, - }, - )) - })?; - - for row in rows { - let (path, entry) = row?; - existing_files.insert(path, entry); - } - - Ok(existing_files) -} +/// One directory's indexed files, as `name -> mtime`. +/// +/// The unit classification works in. Names, not full paths: the parent is +/// implied by which directory is being walked, and at millions of files the +/// repeated directory prefix is the whole cost. Produced by +/// [`crate::db::repo::dir_rows`]. +pub type DirRows = HashMap; /// Derive (inode, device_id) from a `std::fs::Metadata` on platforms that /// expose them. Returns `(None, None)` on Windows and other non-Unix targets. @@ -471,19 +452,32 @@ pub enum FileIndexAction { Insert, } -/// Decide what Phase 1 should do with a file, given the path spelling used -/// as the `files.path` key and the file's mtime. +/// Decide what Phase 1 should do with a file, given its name within the +/// directory being walked and the file's mtime. /// -/// Pure: the caller supplies the `stat` result rather than this function -/// going to disk for it, so the same `stat` serves classification and the -/// record build, and this runs on any worker thread against a shared map. -pub fn classify_for_indexing( - path: &str, - mtime: u64, - existing_files: &HashMap, -) -> FileIndexAction { - match existing_files.get(path) { - Some(existing) if existing.mtime == mtime => FileIndexAction::Skip, +/// Pure: the caller supplies both the `stat` result and the directory's rows +/// rather than this function going to disk or to SQLite for them, so the same +/// `stat` serves classification and the record build, and this runs on any +/// worker thread against data the prefetcher already fetched. +/// +/// Keyed by name against one directory rather than by full path against the +/// whole index: see [`DirRows`]. A file the walk reaches under a spelling +/// whose parent is *not* this directory — a resolved symlink target — must +/// not be classified here; it would miss and read as [`FileIndexAction::Insert`], +/// and `insert_file`'s `INSERT OR IGNORE` would then silently not update it. +/// Use [`classify_by_mtime`] for those. +pub fn classify_for_indexing(name: &str, mtime: u64, rows: &DirRows) -> FileIndexAction { + classify_by_mtime(rows.get(name).copied(), mtime) +} + +/// The same decision from an already-resolved stored mtime. +/// +/// The path [`classify_for_indexing`] funnels into, and the one the walk uses +/// directly for resolved symlink targets, whose stored row is found by exact +/// path instead of by name within a directory. +pub fn classify_by_mtime(stored: Option, mtime: u64) -> FileIndexAction { + match stored { + Some(known) if known == mtime => FileIndexAction::Skip, Some(_) => FileIndexAction::Update, None => FileIndexAction::Insert, } @@ -1268,26 +1262,35 @@ mod tests { #[test] fn classify_uses_mtime_against_the_existing_index() { - let mut existing = HashMap::new(); - existing.insert("/a/known.txt".to_string(), ExistingFileEntry { mtime: 100 }); + let mut rows = DirRows::new(); + rows.insert("known.txt".to_string(), 100); assert_eq!( - classify_for_indexing("/a/new.txt", 100, &existing), + classify_for_indexing("new.txt", 100, &rows), FileIndexAction::Insert, - "a path absent from the index is new" + "a name absent from the directory's rows is new" ); assert_eq!( - classify_for_indexing("/a/known.txt", 100, &existing), + classify_for_indexing("known.txt", 100, &rows), FileIndexAction::Skip, "same mtime means nothing to do" ); assert_eq!( - classify_for_indexing("/a/known.txt", 101, &existing), + classify_for_indexing("known.txt", 101, &rows), FileIndexAction::Update, "a changed mtime means re-read" ); } + #[test] + fn classify_by_mtime_matches_the_name_keyed_path() { + // Resolved symlink targets take this route because their row is + // found by exact path, not by name within the directory walked. + assert_eq!(classify_by_mtime(None, 100), FileIndexAction::Insert); + assert_eq!(classify_by_mtime(Some(100), 100), FileIndexAction::Skip); + assert_eq!(classify_by_mtime(Some(99), 100), FileIndexAction::Update); + } + #[test] fn db_path_strips_windows_prefixes() { assert_eq!(path_to_db_string(Path::new("/plain/unix/path")), "/plain/unix/path"); diff --git a/crates/quicksearch-core/src/indexing.rs b/crates/quicksearch-core/src/indexing.rs index 627daad..ae81e66 100644 --- a/crates/quicksearch-core/src/indexing.rs +++ b/crates/quicksearch-core/src/indexing.rs @@ -12,7 +12,6 @@ use crate::file_handling::{ extract_one_batch, extract_scope_prepare, fts_finalize_after_text_indexing, - load_existing_files, process_batch_inserts, process_batch_updates, path_to_db_string, @@ -21,8 +20,11 @@ use crate::file_handling::{ OwnedNewFile, }; use crate::config::Config; -use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WorkerStats}; +use crate::walk::{ + thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WalkEvent, WorkerStats, +}; use crate::db; +use crate::db::repo; /// Where one root's pipeline is in its life cycle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -91,6 +93,54 @@ pub enum IndexingCommand { Stop, } +/// Collect rows whose parent directory the walk never reached. +/// +/// Per-directory reconciliation can only speak for directories it read, so a +/// directory deleted wholesale — or newly excluded by an ignore pattern — +/// leaves its rows unaccounted for. This finds them by scanning the distinct +/// parents stored under `root` and keeping the ones absent from `seen_dirs`. +/// +/// Two kinds of absence are *not* deletions and are filtered out: +/// +/// - A parent under a directory the walk could not read. Its children were +/// never discovered, so their absence proves nothing. +/// - A path reached by resolving a symlink. Its row's parent may lie outside +/// every root and so is never visited by construction; `aliased` is the +/// record that the file itself was seen. +/// +/// The parent scan streams (see [`repo::for_each_parent_in_range`]) so this +/// costs memory proportional to the *unvisited* directories, not to the tree. +fn sweep_unvisited_parents( + conn_mutex: &Arc>, + root: &str, + seen_dirs: &HashSet, + unreadable: &crate::file_handling::UnreadableDirs, + aliased: &HashSet, + out: &mut Vec, +) -> Result<(), String> { + // Same keyset range the extraction cursor uses: `[root + "/", root + "0")`. + let range = ExtractCursor::for_root(root); + let conn = conn_mutex.lock().unwrap(); + + // Collected rather than streamed into the second query: both borrow the + // same connection, and the outer statement is still live while iterating. + let mut unvisited: Vec = Vec::new(); + repo::for_each_parent_in_range(&conn, &range.lo, &range.hi, |parent| { + if !seen_dirs.contains(&parent) && !unreadable.covers(&parent) { + unvisited.push(parent); + } + })?; + + for parent in unvisited { + for path in repo::paths_in_dir(&conn, &parent)? { + if !aliased.contains(&path) { + out.push(path); + } + } + } + Ok(()) +} + #[derive(Debug)] pub struct IndexingService { status: Arc>, @@ -456,13 +506,10 @@ impl IndexingService { // the legacy single path did. Self::update_config(&conn, config, &roots.join("\n"))?; - // Load existing files from database for incremental indexing - // Shared read-only with the walk threads, which classify against it. - let existing_files = Arc::new({ - let conn_ref = &conn; - load_existing_files(conn_ref) - .map_err(|e| format!("Failed to load existing files: {}", e))? - }); + // No up-front load of the whole `files` table: each walk's prefetcher + // fetches one directory's rows at a time, so classification data is + // never all resident at once and the walk starts immediately instead + // of after a full table scan. let conn_mutex = Arc::new(Mutex::new(conn)); @@ -504,7 +551,7 @@ impl IndexingService { config.indexing.follow_symlinks, config.indexing.include_hidden, ignore, - existing_files.clone(), + db_path, config.clone(), registry.clone(), stop_flag.clone(), @@ -583,7 +630,17 @@ impl IndexingService { // active roots get even quanta; read-bottlenecked, roots with // empty channels are skipped and the firehose roots get the // writer's full attention. - let mut seen_paths: HashSet = HashSet::new(); + // 128-bit path digests, not paths. Its only job is to drop a repeat + // visit, and at millions of files owning every path string again — + // on top of the rows SQLite already holds — was the single largest + // allocation in a run. See `walk::path_digest`. + let mut seen_paths: HashSet = HashSet::new(); + // Rows the per-directory reconciliation found no file behind, plus + // whatever the vanished-directory sweep adds once the walks end. + let mut stale_candidates: Vec = Vec::new(); + // Paths reached by resolving a symlink, whose row lives under a + // parent that may be outside every root. + let mut aliased_paths: HashSet = HashSet::new(); let mut aborted = false; let mut stale_cleanup_ok = true; let mut cleanup_done = false; @@ -604,17 +661,33 @@ impl IndexingService { let mut took = 0usize; while took < quantum { match p.walk.try_next() { - TryNext::Item(file) => { + TryNext::Item(WalkEvent::Stale(paths)) => { + took += 1; + // Applied at the end of the run, not here: + // deleting mid-walk would break the "a + // stopped run deletes nothing" guarantee, + // and an aliased sighting that exempts a + // path may still be ahead of us. + stale_candidates.extend(paths); + } + TryNext::Item(WalkEvent::File(file)) => { took += 1; p.walked += 1; if p.walked % 64 == 0 { p.current_file = Some(file.path.clone()); } - // Membership decides what survives stale - // cleanup: "the walk saw this", never - // "processed successfully". Also dedupes - // symlinked spellings across roots. - if !seen_paths.insert(file.path.clone()) { + if file.aliased { + // Its row's parent is a directory this + // walk may never visit, so the + // vanished-directory sweep must not + // treat that parent's absence as proof + // the file is gone. + aliased_paths.insert(file.path.clone()); + } + // Dedupes a canonical file reachable + // through several spellings, or from more + // than one root. + if !seen_paths.insert(file.digest) { continue; } let Some(rec) = file.record else { continue }; @@ -734,14 +807,27 @@ impl IndexingService { cleanup_done = true; let stopped = *stop_flag.lock().unwrap(); if stale_cleanup_ok && !stopped { - let stale_paths: Vec = existing_files - .keys() - .filter(|path| !seen_paths.contains(*path)) - .filter(|path| { - !pipelines.iter().any(|p| p.walk.unreadable().covers(path)) - }) - .cloned() - .collect(); + // Directories that vanished entirely are never read, so + // per-directory reconciliation never sees them; only a + // scan of stored parents against the ones the walk + // reached can find the rows beneath them. + for p in &pipelines { + sweep_unvisited_parents( + &conn_mutex, + &p.root, + &p.walk.seen_dirs(), + p.walk.unreadable(), + &aliased_paths, + &mut stale_candidates, + )?; + } + // No unreadable-directory filter here: neither source of + // candidates can produce one. `read_directory` returns + // before reconciling a directory it could not read, and + // the sweep skips parents beneath one. Re-checking here + // as well would put the same rule in two places, free to + // drift, and the tests could not tell which one held. + let stale_paths: Vec = stale_candidates.drain(..).collect(); let unreadable_count: usize = pipelines .iter() .map(|p| p.walk.unreadable().paths().len()) diff --git a/crates/quicksearch-core/src/walk.rs b/crates/quicksearch-core/src/walk.rs index 7cd431f..ea624a2 100644 --- a/crates/quicksearch-core/src/walk.rs +++ b/crates/quicksearch-core/src/walk.rs @@ -24,7 +24,7 @@ //! Deliberately std-only (`std::thread` + `std::sync::mpsc`), matching the //! house style set out in [`crate::watcher`]. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -32,12 +32,13 @@ use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread::{self, JoinHandle}; use std::time::UNIX_EPOCH; +use sha2::{Digest, Sha256}; + use crate::config::{Config, IgnoreSet}; use crate::extract::Registry; use crate::file_handling::{ - classify_for_indexing, path_to_db_string, prepare_file_record, warn_if_unrepresentable, - ExistingFileEntry, - FileIndexAction, OwnedNewFile, UnreadableDirs, + classify_by_mtime, classify_for_indexing, path_to_db_string, prepare_file_record, + warn_if_unrepresentable, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs, }; use crate::indexing::should_abort; @@ -65,27 +66,63 @@ const NETWORK_THREADS: usize = 16; #[derive(Debug)] pub struct WalkedFile { /// Canonical path, and the `files.path` key. - /// - /// Present for *every* file the walk saw, including unchanged ones and - /// ones that could not be read. The caller's "seen" set drives stale-row - /// deletion, so a path missing from this stream is a path whose index row - /// gets deleted. pub path: String, pub action: FileIndexAction, /// `None` when there is nothing to write: the file was unchanged, or its /// record could not be built. Never a reason to drop [`WalkedFile::path`]. pub record: Option, + /// 128-bit truncated SHA-256 of [`WalkedFile::path`], for the writer's + /// duplicate-visit set. Computed here so the cost lands on the walk pool + /// rather than on the single writer thread. + pub digest: u128, + /// True when this file was reached by resolving a symlink, so the + /// directory it was found in is *not* the directory its row belongs to. + /// + /// Such a row is invisible to the reconciliation of its real parent — + /// which may be a directory no walk ever visits — so the caller must + /// exempt it from the vanished-directory sweep. + pub aliased: bool, +} + +impl WalkedFile { + /// Seen, but with nothing to write. Distinct from not being emitted at + /// all: the row stays. + fn skipped(path: String, digest: u128, aliased: bool) -> Self { + WalkedFile { path, action: FileIndexAction::Skip, record: None, digest, aliased } + } +} + +/// What the walk emits. Files as they are classified, plus the per-directory +/// verdict on which index rows no longer have a file behind them. +#[derive(Debug)] +pub enum WalkEvent { + File(WalkedFile), + /// Paths whose row should be deleted: present in one directory's index + /// rows, absent from that directory's listing. Emitted once per directory + /// read, and only for directories that were read successfully. + Stale(Vec), } /// Work waiting for a thread. enum Job { - /// Read this directory and process its files. - Dir(PathBuf), + /// Read this directory and process its files. Carries the directory's + /// index rows, fetched by the prefetcher before the job became runnable. + Dir(PathBuf, Arc), /// Process this slice of one directory's files, split off because the /// directory was too wide for one worker to be worth serialising on. - Files(Vec), + /// Shares its directory's rows. + Files(Vec, Arc), + /// A resolved symlink target, with the stored mtime for its own path. + /// Classified against that rather than against any directory's rows. + Alias(PathBuf, Option), } +/// Directories fetched but not yet taken by a worker. Each holds its rows +/// live, so without a cap the prefetcher would run ahead of the pool and +/// materialise rows for thousands of directories at once — reintroducing in +/// one structure the memory this design exists to remove. +const PREFETCH_AHEAD: usize = 64; + #[derive(Default)] struct Queue { /// LIFO. A directory's children are processed close in time to the read @@ -93,16 +130,47 @@ struct Queue { /// keeps the live frontier depth-first-ish instead of holding an entire /// breadth-first level in memory. jobs: Vec, + /// Directories discovered but not yet given their rows, and symlink + /// targets awaiting an mtime lookup. The prefetcher drains both. + needs_rows: Vec, + needs_alias: Vec, + /// Set while the prefetcher is mid-query, holding work that is in neither + /// list. Part of the end-of-walk proof: see [`Shared::take`]. + prefetching: bool, + /// Fetched directories sitting in `jobs`, i.e. the ones actually holding + /// rows. What [`PREFETCH_AHEAD`] bounds. + /// + /// Counted rather than derived from `jobs.len()`: that also holds + /// `Job::Files` chunks, which carry no rows of their own, and a wide + /// directory pushes many of them. Bounding on the total would let file + /// chunks starve directory prefetching and leave the pool waiting on + /// rows that were never fetched. + dirs_ready: usize, /// Workers currently holding a job — that is, workers that may still push /// more. The walk is over when this is zero and `jobs` is empty. active: usize, /// Canonical directories already queued. Collapses overlapping roots and /// makes symlink cycles impossible: a cycle must revisit a canonical path, /// and every directory pushed here is canonical. + /// + /// Also the record of which directories the walk reached, which the + /// caller's vanished-directory sweep reads once the walk has finished. seen_dirs: HashSet, done: bool, } +impl Queue { + /// Whether any stage still holds work. The prefetch stage is invisible to + /// a `jobs`/`active` test, so it has to be named here explicitly. + fn idle(&self) -> bool { + self.jobs.is_empty() + && self.needs_rows.is_empty() + && self.needs_alias.is_empty() + && !self.prefetching + && self.active == 0 + } +} + struct Shared { queue: Mutex, idle: Condvar, @@ -155,9 +223,18 @@ impl Shared { } if let Some(job) = q.jobs.pop() { q.active += 1; + if matches!(job, Job::Dir(..)) { + q.dirs_ready -= 1; + } + // The prefetcher may have been parked behind PREFETCH_AHEAD. + self.idle.notify_all(); return Some((job, ActiveJob { shared: self, finished: false })); } - if q.active == 0 { + // Nothing runnable. Only "nobody anywhere holds work" proves the + // walk is over — a directory sitting in the prefetch stage still + // becomes a job, and declaring the walk finished with one parked + // there would hand reconciliation a partial file set. + if q.idle() { q.done = true; self.idle.notify_all(); return None; @@ -166,20 +243,96 @@ impl Shared { } } - /// Push discovered work and give the job slot back, under a single lock - /// acquisition. Doing both together is what makes the `active == 0` test - /// in [`Shared::take`] an end-of-walk proof rather than a race: a worker - /// that has popped the last job but not yet published its children must - /// never look idle. - fn publish(&self, found: Vec) { + /// Claim one unit of prefetch work, or `None` once the walk is over. + /// + /// Parks while the runnable queue is already `PREFETCH_AHEAD` deep, so + /// fetched-but-unclaimed rows stay bounded. + fn take_prefetch(&self) -> Option { let mut q = self.queue.lock().unwrap(); - for job in found { - if let Job::Dir(ref dir) = job { - if !q.seen_dirs.insert(dir.clone()) { - continue; + loop { + if q.done { + return None; + } + // Aliases are never throttled: they carry a single mtime, not a + // directory's rows, so they cost nothing to hold. + if let Some(path) = q.needs_alias.pop() { + q.prefetching = true; + return Some(PrefetchWork::Alias(path)); + } + if q.dirs_ready < PREFETCH_AHEAD { + if let Some(dir) = q.needs_rows.pop() { + q.prefetching = true; + return Some(PrefetchWork::Dir(dir)); } } - q.jobs.push(job); + if q.idle() { + q.done = true; + self.idle.notify_all(); + return None; + } + q = self.idle.wait(q).unwrap(); + } + } + + /// Publish a prefetched job and clear the in-flight flag together, under + /// one lock — the same indivisibility `publish` relies on, for the same + /// reason. + fn finish_prefetch(&self, job: Job) { + let mut q = self.queue.lock().unwrap(); + if matches!(job, Job::Dir(..)) { + q.dirs_ready += 1; + } + q.jobs.push(job); + q.prefetching = false; + self.idle.notify_all(); + } + + /// Give the prefetch slot back without producing a job (the query failed). + fn abandon_prefetch(&self) { + let mut q = self.queue.lock().unwrap(); + q.prefetching = false; + self.idle.notify_all(); + } +} + +/// One unit of work for the prefetcher. +enum PrefetchWork { + Dir(PathBuf), + Alias(PathBuf), +} + +/// What a worker discovered while reading a directory. +/// +/// Distinct from [`Job`] because most of it is not yet runnable: a newly +/// discovered directory has no rows, and a symlink target has no stored +/// mtime, until the prefetcher supplies them. +enum Found { + /// A subdirectory. Needs its rows before a worker can classify inside it. + Dir(PathBuf), + /// A resolved symlink target. Needs an exact-path mtime lookup. + Alias(PathBuf), + /// Overflow files from the directory just read, which already has rows. + Files(Vec, Arc), +} + +impl Shared { + /// Push discovered work and give the job slot back, under a single lock + /// acquisition. Doing both together is what makes the idle test in + /// [`Shared::take`] an end-of-walk proof rather than a race: a worker + /// that has popped the last job but not yet published its children must + /// never look idle. + fn publish(&self, found: Vec) { + let mut q = self.queue.lock().unwrap(); + for item in found { + match item { + Found::Dir(dir) => { + if q.seen_dirs.insert(dir.clone()) { + q.needs_rows.push(dir); + } + } + Found::Alias(path) => q.needs_alias.push(path), + Found::Files(files, rows) => q.jobs.push(Job::Files(files, rows)), + } } q.active -= 1; self.idle.notify_all(); @@ -201,7 +354,7 @@ struct ActiveJob<'a> { } impl ActiveJob<'_> { - fn finish(mut self, found: Vec) { + fn finish(mut self, found: Vec) { self.shared.publish(found); self.finished = true; } @@ -219,7 +372,6 @@ struct Ctx { follow_symlinks: bool, include_hidden: bool, ignore: IgnoreSet, - existing_files: Arc>, config: Config, /// Lets a worker finish small text files outright: the head it reads to /// hash them is already their entire contents, so an extractor that works @@ -233,7 +385,23 @@ struct Ctx { /// Read one directory, apply the hidden/ignore rules, and split the result: /// subdirectories and overflow file chunks go to `found` for the pool, the /// remaining files come back for this worker to handle immediately. -fn read_directory(dir: &Path, ctx: &Ctx, found: &mut Vec) -> Vec { +/// +/// Also reconciles the directory against its index rows. This is the right +/// place for it and the only cheap one: the *complete* filtered listing +/// exists here, before the directory is split across workers, so the diff +/// needs no per-directory completion count. `stale` receives the paths whose +/// row has no file behind it any more. +/// +/// A directory that cannot be read returns before reconciling, so nothing +/// under it is ever deleted — an unreadable directory must not read as an +/// empty one. +fn read_directory( + dir: &Path, + rows: &Arc, + ctx: &Ctx, + found: &mut Vec, + stale: &mut Vec, +) -> Vec { let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(e) => { @@ -243,6 +411,11 @@ fn read_directory(dir: &Path, ctx: &Ctx, found: &mut Vec) -> Vec { return Vec::new(); } }; + // Names surviving the filters, for the diff below. Only meaningful + // because every `continue` in the loop is a genuine "not indexable", + // matching what would have been absent from the old global seen set. + let mut present: HashSet = HashSet::new(); + let mut unreadable_entry = false; let mut files = Vec::new(); for entry in entries { @@ -251,6 +424,10 @@ fn read_directory(dir: &Path, ctx: &Ctx, found: &mut Vec) -> Vec { Err(e) => { crate::log_warn!("cannot read an entry of {}: {}", dir.display(), e); ctx.unreadable.record(dir.to_path_buf()); + // The listing is now incomplete, so it cannot be used to + // decide what is missing: an entry we failed to read would + // look identical to one that was deleted. + unreadable_entry = true; continue; } }; @@ -276,7 +453,10 @@ fn read_directory(dir: &Path, ctx: &Ctx, found: &mut Vec) -> Vec { // `file_type` is the cached `d_type` from the directory read, so // splitting directories from files here is free. match entry.file_type() { - Ok(ft) if ft.is_dir() => found.push(Job::Dir(path)), + // Directories hold no `files` row, so they are deliberately not + // marked present: a name that was a file last run and is a + // directory now *should* lose its row. + Ok(ft) if ft.is_dir() => found.push(Found::Dir(path)), Ok(ft) if ft.is_symlink() => { // Resolve aliases where they are found. The target's canonical // path is what the index stores, and pushing only canonical @@ -285,45 +465,94 @@ fn read_directory(dir: &Path, ctx: &Ctx, found: &mut Vec) -> Vec { match fs::metadata(&target) { Ok(m) if m.is_dir() => { if ctx.follow_symlinks { - found.push(Job::Dir(target)); + found.push(Found::Dir(target)); } } - Ok(_) => files.push(target), + // The row for a resolved target belongs to the + // target's own directory, not this one, so it is not + // marked present here and cannot be classified + // against these rows. + Ok(_) => found.push(Found::Alias(target)), Err(_) => {} } } } - Ok(_) => files.push(path), - Err(_) => {} + Ok(_) => { + present.insert(name.into_owned()); + files.push(path); + } + // Type unknown: the entry exists but we could not classify it. + // Mark it present so an existing row survives — seen, not deleted. + Err(_) => { + present.insert(name.into_owned()); + } + } + } + + if !unreadable_entry { + for name in rows.keys() { + if !present.contains(name.as_str()) { + // Rebuild the stored path the way `prepare` does, by joining + // onto the canonical directory, so separators and roots match + // the `files.path` spelling exactly. + stale.push(path_to_db_string(&dir.join(name))); + } } } // Spread a wide directory across the pool, keeping the tail for - // ourselves so the entries the read just warmed are handled now. + // ourselves so the entries the read just warmed are handled now. Each + // chunk shares this directory's rows: they are the same directory, and + // classifying them against anything else would read every file as new. while files.len() > FILES_PER_JOB { let chunk = files.split_off(files.len() - FILES_PER_JOB); - found.push(Job::Files(chunk)); + found.push(Found::Files(chunk, rows.clone())); } files } +/// How a file's stored mtime is to be found. +enum Known<'a> { + /// By name within the directory being walked — the ordinary case. + InDir(&'a DirRows), + /// Already resolved by exact path, for a symlink target whose row lives + /// under a different parent. + Exact(Option), +} + +/// 128-bit truncated SHA-256 of a path, for the writer's duplicate-visit set. +/// +/// Truncated rather than full: 16 bytes is ~4e-26 collision probability at +/// 7M paths, where 8 bytes would be ~1e-6 — and a collision here silently +/// drops a real file from the index. Cryptographic rather than fast because +/// filenames on a shared volume are attacker-supplied, so a cheap hash would +/// let a chosen pair hide one of the two files. +pub fn path_digest(path: &str) -> u128 { + let digest = Sha256::digest(path.as_bytes()); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest[..16]); + u128::from_be_bytes(bytes) +} + /// One `stat`, then classify; only files that are actually going to be /// written get opened, and small text files are finished outright. -fn prepare(path: PathBuf, ctx: &Ctx) -> WalkedFile { +fn prepare(path: PathBuf, known: Known<'_>, ctx: &Ctx) -> WalkedFile { let db_path = path_to_db_string(&path); + let digest = path_digest(&db_path); + let aliased = matches!(known, Known::Exact(_)); // A name that is not valid UTF-8 cannot be stored in `files.path` and read // back as the same file, so there is nothing to hash or text-index. `Skip` // rather than an early return with no entry: the caller reads a missing // path as "deleted", and this file was seen, not removed. if warn_if_unrepresentable(&path) { - return WalkedFile { path: db_path, action: FileIndexAction::Skip, record: None }; + return WalkedFile::skipped(db_path, digest, aliased); } let Ok(meta) = fs::metadata(&path) else { // Seen but unreadable. Emitting it anyway keeps its index row alive: // a transient stat failure must not read as "deleted". - return WalkedFile { path: db_path, action: FileIndexAction::Skip, record: None }; + return WalkedFile::skipped(db_path, digest, aliased); }; let Some(mtime) = meta .modified() @@ -331,10 +560,21 @@ fn prepare(path: PathBuf, ctx: &Ctx) -> WalkedFile { .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_secs()) else { - return WalkedFile { path: db_path, action: FileIndexAction::Skip, record: None }; + return WalkedFile::skipped(db_path, digest, aliased); }; - let action = classify_for_indexing(&db_path, mtime, &ctx.existing_files); + let action = match known { + Known::InDir(rows) => { + // The name is what these rows are keyed by; it is the last + // component of the same path `db_path` was built from. + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + classify_for_indexing(&name, mtime, rows) + } + Known::Exact(stored) => classify_by_mtime(stored, mtime), + }; let record = match action { // Unchanged: never opened, never hashed. This is nearly every file on // a re-index, and it is the case that has to stay at one syscall. @@ -344,10 +584,10 @@ fn prepare(path: PathBuf, ctx: &Ctx) -> WalkedFile { _ => prepare_file_record(&db_path, &meta, &ctx.config, &ctx.registry), }; - WalkedFile { path: db_path, action, record } + WalkedFile { path: db_path, action, record, digest, aliased } } -fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { +fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { while let Some((job, slot)) = shared.take() { shared.busy.fetch_add(1, Ordering::Relaxed); let _busy = BusyGuard(&shared.busy); @@ -357,9 +597,24 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { } let mut found = Vec::new(); - let files = match job { - Job::Dir(dir) => read_directory(&dir, ctx, &mut found), - Job::Files(files) => files, + let mut stale = Vec::new(); + // An alias is a single file with its mtime already resolved; the + // other two variants are a directory's worth classified against + // that directory's rows. + let (files, rows) = match job { + Job::Dir(dir, rows) => { + let files = read_directory(&dir, &rows, ctx, &mut found, &mut stale); + (files, rows) + } + Job::Files(files, rows) => (files, rows), + Job::Alias(path, stored) => { + slot.finish(found); + if tx.send(WalkEvent::File(prepare(path, Known::Exact(stored), ctx))).is_err() { + shared.shutdown(); + return; + } + continue; + } }; // Hand the subdirectories over before doing our own per-file work, so @@ -367,12 +622,17 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { // also confines the job slot to `read_directory`. slot.finish(found); + if !stale.is_empty() && tx.send(WalkEvent::Stale(stale)).is_err() { + shared.shutdown(); + return; + } + for path in files { if should_abort(&ctx.stop_flag, &ctx.suspend_flag) { shared.shutdown(); return; } - if tx.send(prepare(path, ctx)).is_err() { + if tx.send(WalkEvent::File(prepare(path, Known::InDir(&rows), ctx))).is_err() { // Receiver gone: the run was stopped or failed. Not an error. shared.shutdown(); return; @@ -381,11 +641,62 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { } } +/// Serves the pool's directory-row and symlink-mtime lookups from one +/// read-only connection. +/// +/// One per walk. The alternative — a connection per worker — would multiply +/// SQLite's page cache by the pool size; see [`PRAGMAS_WALK_READER`]. Every +/// query here is a single index lookup, so one thread stays far ahead of a +/// pool bound by `stat` latency. +/// +/// A failed query is not fatal: the job is abandoned rather than retried, and +/// the directory it was for simply goes unwalked, which reconciliation reads +/// as "not seen" and therefore deletes nothing. +fn prefetcher(shared: &Shared, db_path: &str) { + let conn = match crate::db::open::open_walk_reader(db_path) { + Ok(conn) => conn, + Err(e) => { + // Without rows nothing can be classified, so stopping the walk is + // the honest outcome: continuing would treat every file as new + // and every row as stale. + crate::log_warn!("walk reader: {}", e); + shared.shutdown(); + return; + } + }; + + while let Some(work) = shared.take_prefetch() { + match work { + PrefetchWork::Dir(dir) => { + match crate::db::repo::dir_rows(&conn, &path_to_db_string(&dir)) { + Ok(rows) => shared.finish_prefetch(Job::Dir(dir, Arc::new(rows))), + Err(e) => { + crate::log_warn!("{}", e); + shared.abandon_prefetch(); + } + } + } + PrefetchWork::Alias(path) => { + match crate::db::repo::mtime_for_path(&conn, &path_to_db_string(&path)) { + Ok(stored) => shared.finish_prefetch(Job::Alias(path, stored)), + Err(e) => { + crate::log_warn!("{}", e); + shared.abandon_prefetch(); + } + } + } + } + } +} + /// A running parallel walk. Iterating it drains finished files; dropping it /// stops the workers and joins them. pub struct ParallelWalk { - rx: Option>, + rx: Option>, handles: Vec>, + /// Joined by [`ParallelWalk::finish`] alongside the workers. Held + /// separately only so a failure to open its connection is attributable. + prefetch: Option>, shared: Arc, ctx: Arc, } @@ -397,6 +708,24 @@ impl ParallelWalk { &self.ctx.unreadable } + /// Every canonical directory the walk queued, in `files.parent` spelling. + /// + /// The caller's vanished-directory sweep needs this: a directory deleted + /// wholesale is never read, so nothing reconciles the rows beneath it, + /// and "was this parent reached at all" is the only way to find them. + /// + /// Only meaningful once the walk has finished. + pub fn seen_dirs(&self) -> HashSet { + self.shared + .queue + .lock() + .unwrap() + .seen_dirs + .iter() + .map(|d| path_to_db_string(d)) + .collect() + } + /// A cheap, cloneable handle for reading worker activity while the /// walk's iterator is mutably borrowed by a `for` loop. pub fn worker_stats(&self) -> WorkerStats { @@ -423,13 +752,23 @@ impl ParallelWalk { clean = false; } } + // After the workers, so a prefetcher parked waiting for the pool to + // drain below PREFETCH_AHEAD is already free to observe `done`. + if let Some(handle) = self.prefetch.take() { + // `shutdown` is what releases it; without that this would block + // until the queue emptied on its own. + self.shared.shutdown(); + if handle.join().is_err() { + clean = false; + } + } clean } } /// Result of a non-blocking pull from a walk. pub enum TryNext { - Item(WalkedFile), + Item(WalkEvent), /// Nothing ready right now; the walk is still running. Empty, /// The walk has ended (all workers exited, for any reason). @@ -452,9 +791,9 @@ impl ParallelWalk { } impl Iterator for ParallelWalk { - type Item = WalkedFile; + type Item = WalkEvent; - fn next(&mut self) -> Option { + fn next(&mut self) -> Option { self.rx.as_ref()?.recv().ok() } } @@ -473,13 +812,15 @@ impl Drop for ParallelWalk { /// `workers` is explicit so callers can honour per-root overrides; use /// [`thread_count_for`] for the storage-appropriate default. Clamped to /// 1..=64. +/// `db_path` is opened read-only by this walk's row prefetcher; the walk +/// itself never writes. #[allow(clippy::too_many_arguments)] pub fn walk_indexable_files( roots: &[String], follow_symlinks: bool, include_hidden: bool, ignore: IgnoreSet, - existing_files: Arc>, + db_path: &str, config: Config, registry: Arc, stop_flag: Arc>, @@ -501,7 +842,10 @@ pub fn walk_indexable_files( Ok(dir) => { let dir = PathBuf::from(path_to_db_string(&dir)); if queue.seen_dirs.insert(dir.clone()) { - queue.jobs.push(Job::Dir(dir)); + // Through the prefetcher like any other directory: a root + // needs its rows before anything inside it can be + // classified. + queue.needs_rows.push(dir); } } Err(e) => { @@ -524,7 +868,6 @@ pub fn walk_indexable_files( follow_symlinks, include_hidden, ignore, - existing_files, config, registry, unreadable: UnreadableDirs::default(), @@ -544,10 +887,16 @@ pub fn walk_indexable_files( }) .collect(); // The workers must hold the only senders, or `recv` never reports the end - // of the walk and phase 1 hangs forever. + // of the walk and phase 1 hangs forever. The prefetcher deliberately holds + // none: it produces jobs, not files. drop(tx); - ParallelWalk { rx: Some(rx), handles, shared, ctx } + let prefetch = { + let (shared, db_path) = (shared.clone(), db_path.to_string()); + thread::spawn(move || prefetcher(&shared, &db_path)) + }; + + ParallelWalk { rx: Some(rx), handles, prefetch: Some(prefetch), shared, ctx } } /// Pick a worker count for these roots. @@ -593,28 +942,86 @@ mod tests { fs::write(p, b"x").unwrap(); } - fn walk(root: &Path, existing: HashMap) -> Vec { - walk_with(root, existing, false, false) + /// A database seeded with `rows` as already-indexed files. + /// + /// Classification data now comes from SQLite rather than from a map the + /// caller passes in, so these tests build the state they are testing + /// against the same way the indexer does. + fn db_with(tag: &str, rows: &[(String, u64)]) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "quicksearch-pwalk-db-{}-{}-{}.sqlite", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let conn = crate::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + for (path, mtime) in rows { + let as_path = Path::new(path); + conn.execute( + "INSERT INTO files (name, path, parent, size, mtime, type, \ + basic_state, content_state) + VALUES (?1, ?2, ?3, 0, ?4, 0, 1, 3)", + rusqlite::params![ + as_path.file_name().unwrap().to_string_lossy(), + path, + as_path.parent().unwrap().to_string_lossy(), + *mtime as i64, + ], + ) + .unwrap(); + } + p + } + + fn empty_db(tag: &str) -> PathBuf { + db_with(tag, &[]) + } + + fn walk(root: &Path, db: &Path) -> Vec { + walk_with(root, db, false, false) } fn walk_with( root: &Path, - existing: HashMap, + db: &Path, follow_symlinks: bool, include_hidden: bool, ) -> Vec { - walk_indexable_files( + files_only(walk_indexable_files( &[root.to_string_lossy().into_owned()], follow_symlinks, include_hidden, IgnoreSet::compile(&[]).unwrap(), - Arc::new(existing), + db.to_str().unwrap(), Config::default(), Arc::new(Registry::default_set()), Arc::new(Mutex::new(false)), Arc::new(AtomicBool::new(false)), 4, - ) + )) + } + + /// Drop the reconciliation events; most tests are about which files the + /// walk reports. + fn files_only(walk: ParallelWalk) -> Vec { + walk.filter_map(|e| match e { + WalkEvent::File(f) => Some(f), + WalkEvent::Stale(_) => None, + }) + .collect() + } + + /// Paths the walk decided no longer have a file behind them. + fn stale_only(walk: ParallelWalk) -> Vec { + walk.filter_map(|e| match e { + WalkEvent::Stale(paths) => Some(paths), + WalkEvent::File(_) => None, + }) + .flatten() .collect() } @@ -635,7 +1042,7 @@ mod tests { touch(&root.join("sub/deep/c.txt")); touch(&root.join("other/d.txt")); - let files = walk(&root, HashMap::new()); + let files = walk(&root, &empty_db("nested")); assert_eq!(names(&files), vec!["a.txt", "b.txt", "c.txt", "d.txt"]); let unique: HashSet<&String> = files.iter().map(|f| &f.path).collect(); @@ -661,7 +1068,7 @@ mod tests { touch(&bad); assert!(bad.symlink_metadata().is_ok(), "the file really is on disk"); - let files = walk(&root, HashMap::new()); + let files = walk(&root, &empty_db("nonutf8")); // Both are yielded, so neither reads as deleted... assert_eq!(files.len(), 2, "the bad name is still reported as seen"); @@ -688,7 +1095,7 @@ mod tests { touch(&root.join(format!("f{:05}.txt", i))); } - let files = walk(&root, HashMap::new()); + let files = walk(&root, &empty_db("wide")); assert_eq!(files.len(), count, "every file is yielded exactly once"); let unique: HashSet<&String> = files.iter().map(|f| &f.path).collect(); assert_eq!(unique.len(), count, "and none is yielded twice"); @@ -700,7 +1107,7 @@ mod tests { // The "queue empty at t=0" corner: every worker must observe the walk // as finished rather than waiting for work that will never arrive. let root = tmp_tree("empty"); - assert!(walk(&root, HashMap::new()).is_empty()); + assert!(walk(&root, &empty_db("empty")).is_empty()); fs::remove_dir_all(&root).ok(); } @@ -712,18 +1119,16 @@ mod tests { touch(&root.join("a.txt")); touch(&root.join("sub/b.txt")); - let first = walk(&root, HashMap::new()); + let first = walk(&root, &empty_db("skip-first")); assert_eq!(first.len(), 2); assert!(first.iter().all(|f| f.action == FileIndexAction::Insert)); - let existing: HashMap = first + let indexed: Vec<(String, u64)> = first .iter() - .map(|f| { - (f.path.clone(), ExistingFileEntry { mtime: f.record.as_ref().unwrap().mtime }) - }) + .map(|f| (f.path.clone(), f.record.as_ref().unwrap().mtime)) .collect(); - let second = walk(&root, existing); + let second = walk(&root, &db_with("skip-second", &indexed)); assert_eq!(second.len(), 2, "unchanged files are still reported as seen"); for f in &second { assert_eq!(f.action, FileIndexAction::Skip); @@ -746,7 +1151,7 @@ mod tests { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&bad, fs::Permissions::from_mode(0o000)).unwrap(); - let files = walk(&root, HashMap::new()); + let files = walk(&root, &empty_db("unreadable-file")); fs::set_permissions(&bad, fs::Permissions::from_mode(0o644)).ok(); assert_eq!(names(&files), vec!["bad.txt", "fine.txt"]); @@ -773,14 +1178,20 @@ mod tests { false, false, IgnoreSet::compile(&[]).unwrap(), - Arc::new(HashMap::new()), + empty_db("unreadable-dir").to_str().unwrap(), Config::default(), Arc::new(Registry::default_set()), Arc::new(Mutex::new(false)), Arc::new(AtomicBool::new(false)), 4, ); - let files: Vec = w.by_ref().collect(); + let files: Vec = w + .by_ref() + .filter_map(|e| match e { + WalkEvent::File(f) => Some(f), + WalkEvent::Stale(_) => None, + }) + .collect(); let recorded = !w.unreadable().is_empty(); let covers = w.unreadable().covers(locked.join("inside.txt").to_str().unwrap()); @@ -802,7 +1213,7 @@ mod tests { touch(&root.join("real.txt")); std::os::unix::fs::symlink(&root, root.join("self_link")).unwrap(); - let files = walk_with(&root, HashMap::new(), true, false); + let files = walk_with(&root, &empty_db("loop"), true, false); assert_eq!(names(&files), vec!["real.txt"], "the cycle is visited once"); fs::remove_dir_all(&root).ok(); } @@ -829,7 +1240,7 @@ mod tests { ) .unwrap(); - let files = walk(&root, HashMap::new()); + let files = walk(&root, &empty_db("symlink-file")); let paths: HashSet<&String> = files.iter().map(|f| &f.path).collect(); assert_eq!(paths.len(), 1, "both routes report one canonical path"); @@ -853,22 +1264,21 @@ mod tests { "node_modules".to_string(), ]) .unwrap(); - let files: Vec = walk_indexable_files( + let files: Vec = files_only(walk_indexable_files( &[root.to_string_lossy().into_owned()], false, false, ignore, - Arc::new(HashMap::new()), + empty_db("prune").to_str().unwrap(), Config::default(), Arc::new(Registry::default_set()), Arc::new(Mutex::new(false)), Arc::new(AtomicBool::new(false)), 4, - ) - .collect(); + )); assert_eq!(names(&files), vec!["keep.txt", "keep2.txt"]); - let files = walk_with(&root, HashMap::new(), false, true); + let files = walk_with(&root, &empty_db("prune-hidden"), false, true); assert_eq!( names(&files), vec![".dotfile", "index.js", "inside.txt", "keep.txt", "keep2.txt", "skip.tmp"], @@ -877,6 +1287,77 @@ mod tests { fs::remove_dir_all(&root).ok(); } + #[test] + fn a_directory_reports_rows_with_no_file_behind_them() { + // The per-directory diff, at the level it is computed: one listing + // against one directory's rows, before any splitting. + let root = tmp_tree("reconcile"); + touch(&root.join("kept.txt")); + touch(&root.join("sub/nested.txt")); + + let gone = path_to_db_string(&root.join("removed.txt")); + let gone_nested = path_to_db_string(&root.join("sub/vanished.txt")); + let kept = path_to_db_string(&root.join("kept.txt")); + let db = db_with( + "reconcile", + &[(gone.clone(), 1), (gone_nested.clone(), 1), (kept.clone(), 1)], + ); + + let mut stale = stale_only(walk_indexable_files( + &[root.to_string_lossy().into_owned()], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + db.to_str().unwrap(), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + )); + stale.sort(); + + let mut want = vec![gone, gone_nested]; + want.sort(); + assert_eq!(stale, want, "exactly the rows with no file, from both directories"); + fs::remove_dir_all(&root).ok(); + } + + #[test] + #[cfg(unix)] + fn an_unreadable_directory_reports_nothing_stale() { + use std::os::unix::fs::PermissionsExt; + + // A failed read leaves an empty listing, which must never be diffed: + // every row under it would look deleted. + let root = tmp_tree("reconcile-locked"); + let locked = root.join("locked"); + touch(&locked.join("inside.txt")); + + let db = db_with( + "reconcile-locked", + &[(path_to_db_string(&locked.join("inside.txt")), 1)], + ); + + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap(); + let stale = stale_only(walk_indexable_files( + &[root.to_string_lossy().into_owned()], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + db.to_str().unwrap(), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + )); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).ok(); + + assert!(stale.is_empty(), "an unreadable directory is not an empty one"); + fs::remove_dir_all(&root).ok(); + } + #[test] fn hidden_root_is_still_walked() { // Roots are chosen explicitly, so the hidden rule must not silence one. @@ -884,7 +1365,7 @@ mod tests { let root = base.join(".config"); touch(&root.join("app.conf")); - let files = walk(&root, HashMap::new()); + let files = walk(&root, &empty_db("hidden-root")); assert_eq!(names(&files), vec!["app.conf"]); fs::remove_dir_all(&base).ok(); } @@ -897,19 +1378,18 @@ mod tests { } let stop = Arc::new(Mutex::new(true)); - let files: Vec = walk_indexable_files( + let files: Vec = files_only(walk_indexable_files( &[root.to_string_lossy().into_owned()], false, false, IgnoreSet::compile(&[]).unwrap(), - Arc::new(HashMap::new()), + empty_db("stop").to_str().unwrap(), Config::default(), Arc::new(Registry::default_set()), stop, Arc::new(AtomicBool::new(false)), 4, - ) - .collect(); + )); assert!(files.len() < 500, "an already-stopped walk does not run to completion"); fs::remove_dir_all(&root).ok(); @@ -929,7 +1409,7 @@ mod tests { false, false, IgnoreSet::compile(&[]).unwrap(), - Arc::new(HashMap::new()), + empty_db("early-drop").to_str().unwrap(), Config::default(), Arc::new(Registry::default_set()), Arc::new(Mutex::new(false)), @@ -947,7 +1427,7 @@ mod tests { let root = tmp_tree("overlap"); touch(&root.join("sub/a.txt")); - let files: Vec = walk_indexable_files( + let files: Vec = files_only(walk_indexable_files( &[ root.to_string_lossy().into_owned(), root.join("sub").to_string_lossy().into_owned(), @@ -955,14 +1435,13 @@ mod tests { false, false, IgnoreSet::compile(&[]).unwrap(), - Arc::new(HashMap::new()), + empty_db("overlap").to_str().unwrap(), Config::default(), Arc::new(Registry::default_set()), Arc::new(Mutex::new(false)), Arc::new(AtomicBool::new(false)), 4, - ) - .collect(); + )); assert_eq!(files.len(), 1, "the nested root must not double-index"); fs::remove_dir_all(&root).ok(); @@ -976,11 +1455,16 @@ mod tests { for i in 0..40 { touch(&root.join(format!("d{}/f{}.txt", i % 7, i))); } - let expected = names(&walk(&root, HashMap::new())); + let expected = names(&walk(&root, &empty_db("determinism-base"))); assert_eq!(expected.len(), 40); for run in 0..30 { - assert_eq!(names(&walk(&root, HashMap::new())), expected, "run {}", run); + assert_eq!( + names(&walk(&root, &empty_db(&format!("determinism-{}", run)))), + expected, + "run {}", + run + ); } fs::remove_dir_all(&root).ok(); } @@ -999,14 +1483,20 @@ mod tests { false, false, IgnoreSet::compile(&[]).unwrap(), - Arc::new(HashMap::new()), + empty_db("finish").to_str().unwrap(), Config::default(), Arc::new(Registry::default_set()), Arc::new(Mutex::new(false)), Arc::new(AtomicBool::new(false)), 4, ); - let files: Vec = w.by_ref().collect(); + let files: Vec = w + .by_ref() + .filter_map(|e| match e { + WalkEvent::File(f) => Some(f), + WalkEvent::Stale(_) => None, + }) + .collect(); assert_eq!(files.len(), 2); assert!(w.finish(), "no worker panicked"); // Drop calls it again; joining an already-drained handle list must be diff --git a/crates/quicksearch-core/tests/full_index.rs b/crates/quicksearch-core/tests/full_index.rs index 0267f26..c19ff64 100644 --- a/crates/quicksearch-core/tests/full_index.rs +++ b/crates/quicksearch-core/tests/full_index.rs @@ -387,6 +387,194 @@ fn two_roots_walk_extract_and_clean_independently() { std::fs::remove_dir_all(&db_dir).ok(); } +// --------------------------------------------------------------------------- +// Reconciliation without a global path set. +// +// Classification and stale detection are per-directory: a worker diffs one +// directory's listing against that directory's index rows. These cover the +// cases that arrangement cannot see from inside a single directory read. +// --------------------------------------------------------------------------- + +/// A directory deleted wholesale is never read, so per-directory +/// reconciliation never runs for it. Only the sweep over stored parents finds +/// the rows underneath. +#[test] +fn a_deleted_directory_takes_its_whole_subtree_out_of_the_index() { + let root = tmp_dir("gone-dir"); + let db_dir = tmp_dir("gone-dir-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + touch(&root.join("keep.txt"), b"stays"); + touch(&root.join("doomed/a.txt"), b"goes"); + touch(&root.join("doomed/b.txt"), b"goes"); + // Nested, so the sweep has to reach a parent two levels below the root. + touch(&root.join("doomed/deeper/c.txt"), b"goes too"); + + index_once(&root, &db, &config); + assert_eq!(rows(&db).len(), 4, "all four indexed"); + + std::fs::remove_dir_all(root.join("doomed")).unwrap(); + index_once(&root, &db, &config); + + let names: Vec = rows(&db) + .into_iter() + .map(|(p, _, _)| Path::new(&p).file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, vec!["keep.txt"], "the whole subtree is swept"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +/// A symlink target whose own directory the walk never enters. +/// +/// Two flavours, and only one of them exercises the alias exemption: +/// +/// - A target *outside* every root is already safe, because the sweep only +/// scans parents within a root's path range. +/// - A target inside the root but under a *pruned* directory — hidden here — +/// has a parent that is in range and legitimately absent from `seen_dirs`. +/// Nothing but the record that the file itself was seen distinguishes it +/// from a row whose directory was deleted. +#[test] +#[cfg(unix)] +fn a_symlink_target_in_an_unwalked_directory_survives_reindexing() { + let root = tmp_dir("alias-root"); + let outside = tmp_dir("alias-outside"); + let db_dir = tmp_dir("alias-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + touch(&root.join("normal.txt"), b"inside the root"); + + // In range, but under a hidden directory the walk prunes. + let hidden_target = root.join(".pruned/inner.txt"); + touch(&hidden_target, b"only reachable through the link"); + std::os::unix::fs::symlink(&hidden_target, root.join("hidden_link.txt")).unwrap(); + + // Out of range entirely. + let outer_target = outside.join("target.txt"); + touch(&outer_target, b"outside the root entirely"); + std::os::unix::fs::symlink(&outer_target, root.join("outside_link.txt")).unwrap(); + + index_once(&root, &db, &config); + let first = rows(&db); + assert_eq!(first.len(), 3, "both targets indexed under their own paths"); + assert!( + first.iter().any(|(p, _, _)| p.ends_with(".pruned/inner.txt")), + "the pruned-directory target is stored under its canonical path" + ); + + // The second run is where a sweep keyed only on "was this parent + // visited?" deletes the pruned-directory row. + index_once(&root, &db, &config); + assert_eq!(rows(&db), first, "an aliased row must survive a re-index"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&outside).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +/// A file reached only through a symlink must still be *updated* when it +/// changes. Classifying it against the linking directory's rows would miss, +/// read as Insert, and `INSERT OR IGNORE` would then silently do nothing. +#[test] +#[cfg(unix)] +fn a_modified_symlink_target_is_updated_not_silently_ignored() { + let root = tmp_dir("alias-mod-root"); + let outside = tmp_dir("alias-mod-outside"); + let db_dir = tmp_dir("alias-mod-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + let target = outside.join("target.txt"); + touch(&target, b"first body"); + std::os::unix::fs::symlink(&target, root.join("link.txt")).unwrap(); + + index_once(&root, &db, &config); + let before = rows(&db); + assert_eq!(before.len(), 1); + + std::fs::write(&target, b"second body, quite different").unwrap(); + filetime_set(&target, SystemTime::now() + Duration::from_secs(120)); + + index_once(&root, &db, &config); + let after = rows(&db); + assert_eq!(after.len(), 1, "still exactly one row"); + assert_eq!(after[0].0, before[0].0, "same path"); + assert_ne!(after[0].1, before[0].1, "mtime was refreshed, so it was re-read"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&outside).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +/// Overlapping roots reach the same files twice. The writer's digest set is +/// the only thing left that collapses those visits. +#[test] +fn overlapping_roots_index_each_file_exactly_once() { + let outer = tmp_dir("overlap-outer"); + let db_dir = tmp_dir("overlap-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + let inner = outer.join("inner"); + touch(&outer.join("top.txt"), b"in the outer root only"); + touch(&inner.join("shared.txt"), b"reachable from both roots"); + touch(&inner.join("also.txt"), b"likewise"); + + index_roots_once(&[&outer, &inner], &db, &config); + + let all = rows(&db); + assert_eq!(all.len(), 3, "three files, however many roots reach them"); + let shared: Vec<&(String, i64, i64)> = all + .iter() + .filter(|(p, _, _)| p.ends_with("shared.txt")) + .collect(); + assert_eq!(shared.len(), 1, "the doubly-reachable file has exactly one row"); + + // And the overlap must not make anything look stale on a second pass. + index_roots_once(&[&outer, &inner], &db, &config); + assert_eq!(rows(&db), all, "a second overlapping run changes nothing"); + + std::fs::remove_dir_all(&outer).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +/// A directory that becomes unreadable between runs must not read as empty. +/// Per-directory reconciliation returns before diffing when the read fails, +/// and the sweep skips parents beneath it. +#[test] +#[cfg(unix)] +fn a_directory_that_becomes_unreadable_deletes_nothing() { + use std::os::unix::fs::PermissionsExt; + + let root = tmp_dir("locked-later"); + let db_dir = tmp_dir("locked-later-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + touch(&root.join("open.txt"), b"always readable"); + let vault = root.join("vault"); + touch(&vault.join("secret.txt"), b"readable for now"); + touch(&vault.join("deeper/also.txt"), b"and this one"); + + index_once(&root, &db, &config); + let before = rows(&db); + assert_eq!(before.len(), 3, "all three indexed while readable"); + + std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o000)).unwrap(); + index_once(&root, &db, &config); + let after = rows(&db); + std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o755)).ok(); + + assert_eq!(after, before, "an unreadable directory is not an empty one"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + // --------------------------------------------------------------------------- // Inline extraction: the walk finishes files whose head is the whole file. // diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs index 6c76b76..6491bb5 100644 --- a/crates/quicksearch-gui/src/app.rs +++ b/crates/quicksearch-gui/src/app.rs @@ -169,11 +169,7 @@ impl QuickSearchApp { /// Save + route an edited config to the running services. fn apply_new_config(&mut self, ctx: &egui::Context, mut new: Config) { - // The security section is never edited through config drafts — it - // changes only via the explicit flows in `handle_security_action`. - // Pinning it here keeps a stale draft (taken before a security - // change) from silently reverting protection or the salt. - new.security = self.cfg.security.clone(); + pin_live_fields(&mut new, &self.cfg); if let Some((child, parent)) = nested_roots(&new.paths.indexing_paths).first() { self.config_error = Some(format!( "Not applied: indexed folder {} is nested under {}", @@ -232,6 +228,27 @@ impl QuickSearchApp { self.cfg = new; } + /// Switch the indexing mode and write it to the config immediately. + /// + /// The mode is a persisted setting (`indexing.auto_index`), not a + /// per-session one: a manual stop must still be manual after a + /// restart, or the next launch quietly resumes the indexing the user + /// just stopped. + fn set_index_mode(&mut self, auto: bool) { + self.backend.coordinator.set_mode(if auto { + IndexMode::Auto + } else { + IndexMode::ManualStopped + }); + if self.cfg.indexing.auto_index == auto { + return; + } + self.cfg.indexing.auto_index = auto; + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } + fn drain_events(&mut self) { // Streamed search results. loop { @@ -333,13 +350,12 @@ impl QuickSearchApp { .unwrap_or(true); if stale { let db = self.cfg.resolved_database_path(); - let counts = index_counts(&db.to_string_lossy()).unwrap_or( - IndexCounts { + let counts = + index_counts(&db.to_string_lossy()).unwrap_or(IndexCounts { files: 0, content_done: 0, content_pending: 0, - }, - ); + }); self.counts = Some((Instant::now(), counts)); } let files = self.counts.map(|(_, c)| c.files).unwrap_or(0); @@ -362,12 +378,8 @@ impl QuickSearchApp { ui.label(egui::RichText::new("Stopping indexing…").small()); } IndexingStatus::Running { roots, .. } => { - let done = roots - .iter() - .filter(|r| r.phase == RootPhase::Done) - .count(); - let processed: usize = - roots.iter().map(|r| r.walked + r.extracted).sum(); + let done = roots.iter().filter(|r| r.phase == RootPhase::Done).count(); + let processed: usize = roots.iter().map(|r| r.walked + r.extracted).sum(); let totals_known = roots.iter().all(|r| r.walk_total.is_some()); let denominator: usize = roots .iter() @@ -375,8 +387,7 @@ impl QuickSearchApp { .sum(); let mut text = if totals_known && denominator > 0 { - let frac = - (processed as f64 / denominator as f64).min(1.0); + let frac = (processed as f64 / denominator as f64).min(1.0); format!( "Indexing {} / {} ({:.0}%)", group_thousands(processed as u64), @@ -384,10 +395,7 @@ impl QuickSearchApp { frac * 100.0 ) } else { - format!( - "Indexing · {} files", - group_thousands(processed as u64) - ) + format!("Indexing · {} files", group_thousands(processed as u64)) }; if roots.len() > 1 { text.push_str(&format!(" · {}/{} roots done", done, roots.len())); @@ -396,15 +404,13 @@ impl QuickSearchApp { text.push_str(&format!(" · {}", crate::format::fmt_rate(rate))); } let active: usize = roots.iter().map(|r| r.active_workers).sum(); - let total_workers: usize = - roots.iter().map(|r| r.total_workers).sum(); + let total_workers: usize = roots.iter().map(|r| r.total_workers).sum(); if total_workers > 0 { text.push_str(&format!(" · {}/{} workers", active, total_workers)); } ui.label(egui::RichText::new(text).small()); if totals_known && denominator > 0 { - let frac = - (processed as f32 / denominator as f32).clamp(0.0, 1.0); + let frac = (processed as f32 / denominator as f32).clamp(0.0, 1.0); ui.add(egui::ProgressBar::new(frac).desired_width(120.0)); } else { ui.add(egui::Spinner::new().size(12.0)); @@ -424,7 +430,10 @@ impl QuickSearchApp { }); // Keep painting while anything is moving. - if !matches!(state.activity, IndexingStatus::Idle | IndexingStatus::Error(_)) { + if !matches!( + state.activity, + IndexingStatus::Idle | IndexingStatus::Error(_) + ) { ctx.request_repaint_after(Duration::from_millis(250)); } // Watcher registration walks every root, so its verdict can land @@ -458,9 +467,7 @@ impl QuickSearchApp { // newline-joined — side-by-side columns keep before and // after readable instead of one run-on arrow line. ui.columns(2, |cols| { - cols[0].label( - egui::RichText::new("index was built with").small().weak(), - ); + cols[0].label(egui::RichText::new("index was built with").small().weak()); cols[0].monospace(display_value(&change.stored)); cols[1].label(egui::RichText::new("config now says").small().weak()); cols[1].monospace(display_value(&change.current)); @@ -513,8 +520,7 @@ impl QuickSearchApp { if remember { match db::process_key_hex() { Some(hex) => { - if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &hex) - { + if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &hex) { self.config_error = Some(e); return; // preference not saved either } @@ -577,12 +583,16 @@ impl QuickSearchApp { .hint_text("Confirm password") .desired_width(240.0), ); - ui.checkbox(remember, "Remember on this device").on_hover_text( - "Stores the derived key (not the password) in the OS \ + ui.checkbox(remember, "Remember on this device") + .on_hover_text( + "Stores the derived key (not the password) in the OS \ keychain and skips the startup prompt.", - ); + ); if !pw1.is_empty() && !pw2.is_empty() && pw1 != pw2 { - ui.colored_label(ui.visuals().error_fg_color, "Passwords do not match."); + ui.colored_label( + ui.visuals().error_fg_color, + "Passwords do not match.", + ); } ui.horizontal(|ui| { let ok = !pw1.is_empty() && pw1 == pw2; @@ -619,34 +629,32 @@ impl QuickSearchApp { self.security_prompt = Some(SecurityPrompt::Deriving { rx }); } } - SecurityPrompt::Deriving { rx } => { - match rx.try_recv() { - Ok((new_security, key)) => { - self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { - new_security, - new_key: Some(key), - }); - } - Err(mpsc::TryRecvError::Empty) => { - egui::Window::new("Deriving key") - .collapsible(false) - .resizable(false) - .title_bar(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.spinner(); - ui.label("Deriving key…"); - }); - }); - ctx.request_repaint_after(Duration::from_millis(100)); - } - Err(mpsc::TryRecvError::Disconnected) => { - self.config_error = Some("key derivation thread died".to_string()); - self.security_prompt = None; - } + SecurityPrompt::Deriving { rx } => match rx.try_recv() { + Ok((new_security, key)) => { + self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { + new_security, + new_key: Some(key), + }); } - } + Err(mpsc::TryRecvError::Empty) => { + egui::Window::new("Deriving key") + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Deriving key…"); + }); + }); + ctx.request_repaint_after(Duration::from_millis(100)); + } + Err(mpsc::TryRecvError::Disconnected) => { + self.config_error = Some("key derivation thread died".to_string()); + self.security_prompt = None; + } + }, SecurityPrompt::ConfirmRebuild { new_security, new_key, @@ -701,7 +709,11 @@ impl QuickSearchApp { /// in that order, before the rebuild so the fresh index is created /// under the new key (or none). fn apply_security_change(&mut self, new_security: SecurityConfig, new_key: Option) { - let db_path = self.cfg.resolved_database_path().to_string_lossy().into_owned(); + let db_path = self + .cfg + .resolved_database_path() + .to_string_lossy() + .into_owned(); self.cfg.security = new_security; if let Err(e) = self.cfg.save() { self.config_error = Some(e); @@ -858,6 +870,11 @@ impl QuickSearchApp { ) .clicked() { + // Manual first, and persisted: clearing drops the + // coordinator to manual so automatic mode cannot + // resurrect what was just deleted, and the next + // launch must not undo that either. + self.set_index_mode(false); self.backend.coordinator.clear_index(); self.counts = None; self.dups.state = DupState::NotLoaded; @@ -874,6 +891,18 @@ impl QuickSearchApp { } } +/// Overwrite the fields a config draft must never carry back. +/// +/// Both are live state the GUI changes through their own controls — the +/// security flows in `handle_security_action`, the mode buttons in +/// [`QuickSearchApp::set_index_mode`] — and both are saved the moment they +/// change. A draft taken before one of those clicks still holds the old +/// value, so applying it would silently revert protection, the salt, or +/// the indexing mode. +fn pin_live_fields(new: &mut Config, live: &Config) { + new.security = live.security.clone(); + new.indexing.auto_index = live.indexing.auto_index; +} /// A stored/current config value for the rebuild prompt; list values are /// already newline-joined and render as-is, empty means unset. @@ -966,10 +995,10 @@ impl eframe::App for QuickSearchApp { self.backend.coordinator.reindex_now(); } if actions.stop { - self.backend.coordinator.set_mode(IndexMode::ManualStopped); + self.set_index_mode(false); } if actions.auto { - self.backend.coordinator.set_mode(IndexMode::Auto); + self.set_index_mode(true); } if actions.clear_index { self.clear_prompt = true; @@ -1012,3 +1041,37 @@ impl eframe::App for QuickSearchApp { self.backend.shutdown(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_stale_draft_cannot_revert_the_indexing_mode_or_security() { + // The draft as it was when the editor last synced: automatic + // indexing, no password — plus one real edit the user staged. + let mut draft = Config::default(); + draft.indexing.auto_index = true; + draft.indexing.reindex_interval_minutes = 60; + + // Since then: Stop was clicked and protection was enabled. + let mut live = Config::default(); + live.indexing.auto_index = false; + live.security = SecurityConfig { + password_protected: true, + salt: Some("ab".repeat(16)), + use_keychain: true, + }; + + pin_live_fields(&mut draft, &live); + assert!( + !draft.indexing.auto_index, + "applying the draft must not restart automatic indexing" + ); + assert_eq!(draft.security, live.security); + assert_eq!( + draft.indexing.reindex_interval_minutes, 60, + "the staged edit itself still applies" + ); + } +} diff --git a/crates/quicksearch-gui/src/backend.rs b/crates/quicksearch-gui/src/backend.rs index dffc4f2..cd83996 100644 --- a/crates/quicksearch-gui/src/backend.rs +++ b/crates/quicksearch-gui/src/backend.rs @@ -49,11 +49,8 @@ impl Backend { 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 result = + quicksearch_core::search::find_duplicate_groups(&db.to_string_lossy(), 500, 0); let _ = tx.send(result); ctx.request_repaint(); }); diff --git a/crates/quicksearch-gui/src/cli.rs b/crates/quicksearch-gui/src/cli.rs index b9c0e40..d94b023 100644 --- a/crates/quicksearch-gui/src/cli.rs +++ b/crates/quicksearch-gui/src/cli.rs @@ -77,15 +77,13 @@ pub fn maybe_run_cli() -> Option { return Some(2); } }, - other if other.starts_with("--limit=") => { - match other["--limit=".len()..].parse() { - Ok(n) => limit = Some(n), - Err(_) => { - eprintln!("--limit requires a number\n\n{}", USAGE); - return Some(2); - } + other if other.starts_with("--limit=") => match other["--limit=".len()..].parse() { + Ok(n) => limit = Some(n), + Err(_) => { + eprintln!("--limit requires a number\n\n{}", USAGE); + return Some(2); } - } + }, other if other.starts_with('-') && terms.is_empty() => { // Unknown flags without a query fall through to the GUI // (they may be eframe/winit flags). @@ -128,13 +126,15 @@ pub(crate) fn resolve_key( if let Some(hex) = keychain_hex { match IndexKey::from_hex(&hex).map_err(|e| format!("keychain entry: {}", e)) { - Ok(key) => match try_key(key) { - Ok(()) => return Ok(()), - Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => { - eprintln!("warning: the key remembered in the OS keychain no longer opens this index"); + Ok(key) => { + match try_key(key) { + Ok(()) => return Ok(()), + Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => { + eprintln!("warning: the key remembered in the OS keychain no longer opens this index"); + } + Err(e) => return Err(e), } - Err(e) => return Err(e), - }, + } Err(e) => eprintln!("warning: {}", e), } } @@ -474,8 +474,7 @@ mod tests { #[test] fn no_tty_no_sources_is_instructive() { let sec = protected(); - let err = resolve_key(&sec, false, None, None, || None, |_| Ok(())) - .unwrap_err(); + let err = resolve_key(&sec, false, None, None, || None, |_| Ok(())).unwrap_err(); assert!(err.contains(PASSWORD_ENV)); assert!(err.contains("Remember on this device")); } diff --git a/crates/quicksearch-gui/src/duplicates_tab.rs b/crates/quicksearch-gui/src/duplicates_tab.rs index c5167d9..dcf6b78 100644 --- a/crates/quicksearch-gui/src/duplicates_tab.rs +++ b/crates/quicksearch-gui/src/duplicates_tab.rs @@ -34,7 +34,10 @@ impl DuplicatesTab { ui.horizontal(|ui| { let loading = matches!(self.state, DupState::Loading); - if ui.add_enabled(!loading, egui::Button::new("Refresh")).clicked() { + if ui + .add_enabled(!loading, egui::Button::new("Refresh")) + .clicked() + { actions.refresh = true; } if loading { @@ -62,48 +65,57 @@ impl DuplicatesTab { } if groups.len() == 500 { ui.label( - egui::RichText::new("Showing the 500 largest groups.").small().weak(), + egui::RichText::new("Showing the 500 largest groups.") + .small() + .weak(), ); } - let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { - for (i, group) in groups.iter().enumerate() { - let name = group - .members - .first() - .map(|m| m.1.as_str()) - .unwrap_or("(unknown)"); - let title = format!( - "{} × {}: {} reclaimable ({} total)", - group_thousands(group.count as u64), - name, - human_size(group.redundant_size.max(0) as u64), - human_size(group.total_size.max(0) as u64), - ); - egui::CollapsingHeader::new(title).id_salt(i).show(ui, |ui| { - for (_, _, path, size, _) in &group.members { - ui.horizontal(|ui| { - ui.label(human_size(*size)); - let response = ui - .add(egui::Label::new(egui::RichText::new(path).monospace()) - .sense(egui::Sense::click())); - if response.double_clicked() { - platform::open_file(path); + let scroll = egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for (i, group) in groups.iter().enumerate() { + let name = group + .members + .first() + .map(|m| m.1.as_str()) + .unwrap_or("(unknown)"); + let title = format!( + "{} × {}: {} reclaimable ({} total)", + group_thousands(group.count as u64), + name, + human_size(group.redundant_size.max(0) as u64), + human_size(group.total_size.max(0) as u64), + ); + egui::CollapsingHeader::new(title) + .id_salt(i) + .show(ui, |ui| { + for (_, _, path, size, _) in &group.members { + ui.horizontal(|ui| { + ui.label(human_size(*size)); + let response = ui.add( + egui::Label::new( + egui::RichText::new(path).monospace(), + ) + .sense(egui::Sense::click()), + ); + if response.double_clicked() { + platform::open_file(path); + } + response.context_menu(|ui| { + if ui.button("Open").clicked() { + platform::open_file(path); + ui.close(); + } + if ui.button("Open containing folder").clicked() { + platform::reveal_in_folder(path); + ui.close(); + } + }); + }); } - response.context_menu(|ui| { - if ui.button("Open").clicked() { - platform::open_file(path); - ui.close(); - } - if ui.button("Open containing folder").clicked() { - platform::reveal_in_folder(path); - ui.close(); - } - }); }); - } - }); - } - }); + } + }); crate::ui_util::more_below_hint(ui, &scroll); } } diff --git a/crates/quicksearch-gui/src/help_tab.rs b/crates/quicksearch-gui/src/help_tab.rs index 658640c..c759fb6 100644 --- a/crates/quicksearch-gui/src/help_tab.rs +++ b/crates/quicksearch-gui/src/help_tab.rs @@ -12,7 +12,7 @@ pub fn ui(ui: &mut egui::Ui) { // would otherwise stretch every paragraph into one long line. ui.set_max_width(620.0); - ui.heading("Welcome to QuickSearch"); + ui.heading(egui::RichText::new("Welcome to QuickSearch").strong()); ui.add_space(4.0); ui.label( "QuickSearch keeps an index of the folders you choose and finds \ @@ -20,7 +20,7 @@ pub fn ui(ui: &mut egui::Ui) { ); ui.add_space(12.0); - ui.heading("Getting started"); + ui.heading(egui::RichText::new("Getting started").strong()); ui.add_space(4.0); ui.label( "1. The first time QuickSearch runs it starts indexing your home \ @@ -38,7 +38,7 @@ pub fn ui(ui: &mut egui::Ui) { ); ui.add_space(12.0); - ui.heading("Searching"); + ui.heading(egui::RichText::new("Searching").strong()); ui.add_space(4.0); ui.label("Plain words match file names, file contents, and paths:"); ui.monospace("quarterly budget"); @@ -47,9 +47,7 @@ pub fn ui(ui: &mut egui::Ui) { words:", ); ui.monospace("type:Document modified:>=2024-01-01 report"); - ui.label( - "The ? button next to the search box shows the full query syntax.", - ); + ui.label("The ? button next to the search box shows the full query syntax."); ui.add_space(6.0); ui.label( "• Tick Fuzzy to also find matches with typos in them, at some \ @@ -69,7 +67,7 @@ pub fn ui(ui: &mut egui::Ui) { ); ui.add_space(12.0); - ui.heading("The other tabs"); + ui.heading(egui::RichText::new("The other tabs").strong()); ui.add_space(4.0); egui::Grid::new("help-tabs") .num_columns(2) @@ -86,7 +84,11 @@ pub fn ui(ui: &mut egui::Ui) { "indexing status and controls, the indexed folder list, \ and the filters that decide what is skipped", ); - row(ui, "Duplicates", "files whose contents are identical, grouped"); + row( + ui, + "Duplicates", + "files whose contents are identical, grouped", + ); row( ui, "Logs", @@ -97,7 +99,7 @@ pub fn ui(ui: &mut egui::Ui) { }); ui.add_space(12.0); - ui.heading("Terminal"); + ui.heading(egui::RichText::new("Terminal").strong()); ui.add_space(4.0); ui.label("QuickSearch also searches straight from a terminal:"); ui.monospace("quicksearch \"quarterly budget\""); diff --git a/crates/quicksearch-gui/src/logs_tab.rs b/crates/quicksearch-gui/src/logs_tab.rs index 774a154..eca8b19 100644 --- a/crates/quicksearch-gui/src/logs_tab.rs +++ b/crates/quicksearch-gui/src/logs_tab.rs @@ -155,11 +155,7 @@ impl LogsTab { for &i in &shown[range] { let line = &self.lines[i]; ui.horizontal(|ui| { - ui.label( - egui::RichText::new(fmt_clock(line.at)) - .monospace() - .weak(), - ); + ui.label(egui::RichText::new(fmt_clock(line.at)).monospace().weak()); let text = egui::RichText::new(&line.text).monospace(); match line.level { Level::Warn => { diff --git a/crates/quicksearch-gui/src/main.rs b/crates/quicksearch-gui/src/main.rs index 0601230..cd49408 100644 --- a/crates/quicksearch-gui/src/main.rs +++ b/crates/quicksearch-gui/src/main.rs @@ -23,8 +23,8 @@ mod platform; mod query_highlight; mod search_tab; mod tracker; -mod unlock; mod ui_util; +mod unlock; use quicksearch_core::config::Config; diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index afcaf9f..833ff8c 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -6,7 +6,6 @@ use quicksearch_core::coordinator::{IndexMode, IndexerState, WatcherStatus}; use quicksearch_core::indexing::{IndexingStatus, RootPhase, RootProgress}; use crate::format::{fmt_interval, fmt_rate, group_thousands, middle_truncate}; -use crate::options::{config_editor_ui, Section}; use crate::tracker::SpeedTracker; /// What the tab asks the app to do after this frame. @@ -17,7 +16,7 @@ pub struct ManageActions { pub auto: bool, /// Ask the app to confirm and delete the index. pub clear_index: bool, - /// A full edited config to apply (roots / filters / indexing knobs). + /// A full edited config to apply (roots / filters). pub apply_config: Option, } @@ -34,7 +33,7 @@ pub struct ManageTab { /// The config the draft was last synced from. `None` forces a full /// resync (first frame, and right after our own Apply). baseline: Option, - /// Draft of the roots/filters/indexing knobs edited in-place. + /// Draft of the roots/filters edited in-place. draft: Option, } @@ -118,241 +117,270 @@ impl ManageTab { let mut actions = ManageActions::default(); self.sync_editors(config); - let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { - // --- Status --------------------------------------------------- - ui.heading("Status"); - status_panel(ui, state, &self.speed); - watch_panel(ui, state, config); - ui.add_space(8.0); + let scroll = egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + // --- Status --------------------------------------------------- + ui.heading(egui::RichText::new("Status").strong()); + status_panel(ui, state, &self.speed); + watch_panel(ui, state, config); + ui.add_space(8.0); - // --- Controls ------------------------------------------------- - ui.horizontal(|ui| { - let running = !matches!( - state.activity, - IndexingStatus::Idle | IndexingStatus::Error(_) - ); - if ui.add_enabled(!running, egui::Button::new("Start indexing now")).clicked() { - actions.start_now = true; - } - if ui.add_enabled(running || state.mode == IndexMode::Auto, egui::Button::new("Stop")).clicked() { - actions.stop = true; - } - if ui - .add_enabled(state.mode != IndexMode::Auto, egui::Button::new("Return to Automatic")) - .clicked() - { - actions.auto = true; - } - let mode = match state.mode { - IndexMode::Auto => "Automatic", - IndexMode::ManualStopped => "Manual (stopped)", - IndexMode::ManualRunning => "Manual (running)", - }; - ui.label(egui::RichText::new(format!("Mode: {}", mode)).weak()); - ui.separator(); - if ui - .button(egui::RichText::new("Clear index…").color(ui.visuals().error_fg_color)) - .on_hover_text("Delete the index database (asks for confirmation)") - .clicked() - { - actions.clear_index = true; - } - if state.queued_events > 0 { - ui.label( - egui::RichText::new(format!("{} changes queued", state.queued_events)) - .small() - .weak(), - ); - } - }); - ui.separator(); - - // --- Indexed roots --------------------------------------------- - ui.heading("Indexed folders"); - let draft = self.draft.as_mut().expect("synced"); - let mut remove: Option = None; - for (i, root) in draft.paths.indexing_paths.clone().iter().enumerate() { + // --- Controls ------------------------------------------------- ui.horizontal(|ui| { - // Controls claim the right edge first so a long path can - // never push them out of view; the path truncates into - // whatever width remains (full path on hover). - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Remove").clicked() { - remove = Some(i); - } - // Per-root walker override; 0 = auto (4 local / 16 - // network, detected per root). Applies on the next run. - let mut workers = - draft.indexing.root_workers.get(root).copied().unwrap_or(0); - let response = ui - .add( - egui::DragValue::new(&mut workers) - .range(0..=64) - .custom_formatter(|n, _| { - if n == 0.0 { - "auto".to_string() - } else { - format!("{:.0}", n) - } - }) - .custom_parser(|s| { - let s = s.trim(); - if s.is_empty() || s.eq_ignore_ascii_case("auto") { - Some(0.0) - } else { - s.parse().ok() - } - }), - ) - .on_hover_text( - "Walker threads for this folder. auto = 4 on local \ + let running = !matches!( + state.activity, + IndexingStatus::Idle | IndexingStatus::Error(_) + ); + if ui + .add_enabled(!running, egui::Button::new("Start indexing now")) + .clicked() + { + actions.start_now = true; + } + if ui + .add_enabled( + running || state.mode == IndexMode::Auto, + egui::Button::new("Stop"), + ) + .on_hover_text( + "Stop indexing and switch to manual. Saved right away: it \ + stays manual on the next launch too.", + ) + .clicked() + { + actions.stop = true; + } + if ui + .add_enabled( + state.mode != IndexMode::Auto, + egui::Button::new("Return to Automatic"), + ) + .on_hover_text( + "Watch for changes and reindex periodically again. Also \ + saved, so this is how the app starts from now on.", + ) + .clicked() + { + actions.auto = true; + } + let mode = match state.mode { + IndexMode::Auto => "Automatic", + IndexMode::ManualStopped => "Manual (stopped)", + IndexMode::ManualRunning => "Manual (running)", + }; + ui.label(egui::RichText::new(format!("Mode: {}", mode)).weak()); + ui.separator(); + if ui + .button( + egui::RichText::new("Clear index…").color(ui.visuals().error_fg_color), + ) + .on_hover_text("Delete the index database (asks for confirmation)") + .clicked() + { + actions.clear_index = true; + } + if state.queued_events > 0 { + ui.label( + egui::RichText::new(format!("{} changes queued", state.queued_events)) + .small() + .weak(), + ); + } + }); + ui.separator(); + + // --- Indexed roots --------------------------------------------- + ui.heading(egui::RichText::new("Indexed folders").strong()); + let draft = self.draft.as_mut().expect("synced"); + let mut remove: Option = None; + for (i, root) in draft.paths.indexing_paths.clone().iter().enumerate() { + ui.horizontal(|ui| { + // Controls claim the right edge first so a long path can + // never push them out of view; the path truncates into + // whatever width remains (full path on hover). + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Remove").clicked() { + remove = Some(i); + } + // Per-root walker override; 0 = auto (4 local / 16 + // network, detected per root). Applies on the next run. + let mut workers = + draft.indexing.root_workers.get(root).copied().unwrap_or(0); + let response = ui + .add( + egui::DragValue::new(&mut workers) + .range(0..=64) + .custom_formatter(|n, _| { + if n == 0.0 { + "auto".to_string() + } else { + format!("{:.0}", n) + } + }) + .custom_parser(|s| { + let s = s.trim(); + if s.is_empty() || s.eq_ignore_ascii_case("auto") { + Some(0.0) + } else { + s.parse().ok() + } + }), + ) + .on_hover_text( + "Walker threads for this folder. auto = 4 on local \ storage, 16 on network mounts. Takes effect on \ the next indexing run.", - ); - if response.changed() { - if workers == 0 { - draft.indexing.root_workers.remove(root); - } else { - draft.indexing.root_workers.insert(root.clone(), workers); - } - } - ui.label(egui::RichText::new("workers:").small().weak()); - - // Path label takes the leftover width, middle-truncated. - ui.with_layout( - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - let font_id = egui::TextStyle::Monospace.resolve(ui.style()); - let char_width = - ui.fonts(|f| f.glyph_width(&font_id, '0')).max(1.0); - let budget = - ((ui.available_width() / char_width) as usize).max(16); - ui.monospace(middle_truncate(root, budget)) - .on_hover_text(root); - }, - ); - }); - }); - } - if let Some(i) = remove { - let removed = draft.paths.indexing_paths.remove(i); - draft.indexing.root_workers.remove(&removed); - } - ui.horizontal(|ui| { - if ui.button("Add folder…").clicked() { - if let Some(dir) = rfd::FileDialog::new().pick_folder() { - let path = dir.to_string_lossy().into_owned(); - try_add_root(draft, path, &mut self.root_error); - } - } - ui.add( - egui::TextEdit::singleline(&mut self.new_root) - .desired_width(240.0) - .hint_text("or type a path"), - ); - if ui.button("Add").clicked() && !self.new_root.trim().is_empty() { - let path = self.new_root.trim().to_string(); - if try_add_root(draft, path, &mut self.root_error) { - self.new_root.clear(); - } - } - }); - if let Some(err) = &self.root_error { - ui.colored_label(ui.visuals().error_fg_color, err); - } - ui.separator(); - - // --- Filters --------------------------------------------------- - ui.heading("Content filters"); - ui.columns(2, |cols| { - cols[0].label("Full-text extensions (empty = all supported):"); - cols[0].add( - egui::TextEdit::multiline(&mut self.ext_filter_text) - .desired_rows(4) - .desired_width(f32::INFINITY) - .hint_text("txt\nmd\npdf"), - ); - cols[1].label("Ignore patterns (excluded entirely):"); - let mut remove_pat: Option = None; - for (i, pat) in draft.indexing.ignore_patterns.iter().enumerate() { - cols[1].horizontal(|ui| { - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui.small_button("Remove").clicked() { - remove_pat = Some(i); - } - ui.with_layout( - egui::Layout::left_to_right(egui::Align::Center), - |ui| { - ui.monospace(pat); - }, ); - }, - ); + if response.changed() { + if workers == 0 { + draft.indexing.root_workers.remove(root); + } else { + draft.indexing.root_workers.insert(root.clone(), workers); + } + } + ui.label(egui::RichText::new("workers:").small().weak()); + + // Path label takes the leftover width, middle-truncated. + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let font_id = egui::TextStyle::Monospace.resolve(ui.style()); + let char_width = + ui.fonts(|f| f.glyph_width(&font_id, '0')).max(1.0); + let budget = + ((ui.available_width() / char_width) as usize).max(16); + ui.monospace(middle_truncate(root, budget)) + .on_hover_text(root); + }, + ); + }); }); } - if draft.indexing.ignore_patterns.is_empty() { - cols[1].label(egui::RichText::new("No ignore patterns.").small().weak()); + if let Some(i) = remove { + let removed = draft.paths.indexing_paths.remove(i); + draft.indexing.root_workers.remove(&removed); } - if let Some(i) = remove_pat { - draft.indexing.ignore_patterns.remove(i); - } - cols[1].horizontal(|ui| { - let (response, valid) = crate::ui_util::pattern_edit( - ui, - &mut self.new_ignore, - 180.0, - "*.tmp or node_modules", - ); - let submitted = - response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - if ui.add_enabled(valid, egui::Button::new("Add")).clicked() - || (submitted && valid) - { - let pat = self.new_ignore.trim().to_string(); - if !draft.indexing.ignore_patterns.contains(&pat) { - draft.indexing.ignore_patterns.push(pat); + ui.horizontal(|ui| { + if ui.button("Add folder…").clicked() { + if let Some(dir) = rfd::FileDialog::new().pick_folder() { + let path = dir.to_string_lossy().into_owned(); + try_add_root(draft, path, &mut self.root_error); + } + } + ui.add( + egui::TextEdit::singleline(&mut self.new_root) + .desired_width(240.0) + .hint_text("or type a path"), + ); + if ui.button("Add").clicked() && !self.new_root.trim().is_empty() { + let path = self.new_root.trim().to_string(); + if try_add_root(draft, path, &mut self.root_error) { + self.new_root.clear(); } - self.new_ignore.clear(); } }); - cols[1].label( + if let Some(err) = &self.root_error { + ui.colored_label(ui.visuals().error_fg_color, err); + } + ui.separator(); + + // --- Filters --------------------------------------------------- + ui.heading(egui::RichText::new("Content filters").strong()); + ui.columns(2, |cols| { + cols[0].label("Full-text extensions whitelist (empty = all supported):"); + cols[0].add( + egui::TextEdit::multiline(&mut self.ext_filter_text) + .desired_rows(4) + .desired_width(f32::INFINITY) + .hint_text("txt\nmd\npdf"), + ); + cols[1].label("Ignore patterns (excluded entirely):"); + let mut remove_pat: Option = None; + for (i, pat) in draft.indexing.ignore_patterns.iter().enumerate() { + cols[1].horizontal(|ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.small_button("Remove").clicked() { + remove_pat = Some(i); + } + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + ui.monospace(pat); + }, + ); + }, + ); + }); + } + if draft.indexing.ignore_patterns.is_empty() { + cols[1].label(egui::RichText::new("No ignore patterns.").small().weak()); + } + if let Some(i) = remove_pat { + draft.indexing.ignore_patterns.remove(i); + } + cols[1].horizontal(|ui| { + let (response, valid) = crate::ui_util::pattern_edit( + ui, + &mut self.new_ignore, + 180.0, + "*.tmp or node_modules", + ); + let submitted = + response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if ui.add_enabled(valid, egui::Button::new("Add")).clicked() + || (submitted && valid) + { + let pat = self.new_ignore.trim().to_string(); + if !draft.indexing.ignore_patterns.contains(&pat) { + draft.indexing.ignore_patterns.push(pat); + } + self.new_ignore.clear(); + } + }); + cols[1].label( + egui::RichText::new( + "Changes apply on Apply & Save (may trigger index rebuild).", + ) + .small() + .weak(), + ); + }); + ui.separator(); + + // The indexing/processing knobs themselves live only in the + // Options window; this points at them so the tab does not look + // like the whole story. + ui.label( egui::RichText::new( - "Changes apply on Apply & Save (may trigger a rebuild). \ - Session-only filters are shown and removed on the Search tab.", + "Reindex interval, symlinks, hidden files, tokenizer, and size \ + limits are in Options (⚙ in the toolbar).", ) .small() .weak(), ); + ui.add_space(8.0); + + if ui + .add(crate::ui_util::bordered_button( + "Apply & Save", + crate::ui_util::BLUE, + )) + .clicked() + { + let mut new_config = draft.clone(); + new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text); + let roots = new_config.paths.indexing_paths.clone(); + new_config + .indexing + .root_workers + .retain(|root, _| roots.contains(root)); + actions.apply_config = Some(new_config); + self.baseline = None; + } }); - ui.separator(); - - // --- Indexing options ------------------------------------------- - ui.heading("Indexing options"); - config_editor_ui(ui, draft, Section::Indexing); - ui.add_space(4.0); - config_editor_ui(ui, draft, Section::Processing); - ui.add_space(8.0); - - if ui - .add(crate::ui_util::bordered_button( - "Apply & Save", - crate::ui_util::BLUE, - )) - .clicked() - { - let mut new_config = draft.clone(); - new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text); - let roots = new_config.paths.indexing_paths.clone(); - new_config - .indexing - .root_workers - .retain(|root, _| roots.contains(root)); - actions.apply_config = Some(new_config); - self.baseline = None; - } - }); crate::ui_util::more_below_hint(ui, &scroll); actions @@ -490,7 +518,11 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) { group_thousands(r.walked as u64), workers )); - ui.add(egui::ProgressBar::new(0.0).animate(true).desired_width(160.0)); + ui.add( + egui::ProgressBar::new(0.0) + .animate(true) + .desired_width(160.0), + ); } } } @@ -546,7 +578,12 @@ mod tests { let cfg = Config::default(); let mut tab = synced_tab(&cfg); // Stage an edit, then sync against the unchanged config. - tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.log".into()); + tab.draft + .as_mut() + .unwrap() + .indexing + .ignore_patterns + .push("*.log".into()); tab.sync_editors(&cfg); assert!(tab .draft @@ -587,7 +624,10 @@ mod tests { tab.sync_editors(&external); let draft = tab.draft.as_ref().unwrap(); assert!(!draft.indexing.ignore_patterns.contains(&removed)); - assert!(draft.indexing.ignore_patterns.contains(&"*.log".to_string())); + assert!(draft + .indexing + .ignore_patterns + .contains(&"*.log".to_string())); assert_eq!(tab.baseline.as_ref().unwrap(), &external); } @@ -595,21 +635,34 @@ mod tests { fn dirty_draft_adopts_sections_owned_elsewhere() { let cfg = Config::default(); let mut tab = synced_tab(&cfg); - tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.bak".into()); + tab.draft + .as_mut() + .unwrap() + .indexing + .ignore_patterns + .push("*.bak".into()); // The fuzzy toggle saves the config directly, outside this tab. let mut external = cfg.clone(); external.search.fuzzy_default = !cfg.search.fuzzy_default; tab.sync_editors(&external); let draft = tab.draft.as_ref().unwrap(); assert_eq!(draft.search.fuzzy_default, external.search.fuzzy_default); - assert!(draft.indexing.ignore_patterns.contains(&"*.bak".to_string())); + assert!(draft + .indexing + .ignore_patterns + .contains(&"*.bak".to_string())); } #[test] fn external_pattern_is_not_duplicated_into_a_draft_that_has_it() { let cfg = Config::default(); let mut tab = synced_tab(&cfg); - tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.log".into()); + tab.draft + .as_mut() + .unwrap() + .indexing + .ignore_patterns + .push("*.log".into()); let mut external = cfg.clone(); external.indexing.ignore_patterns.push("*.log".into()); tab.sync_editors(&external); diff --git a/crates/quicksearch-gui/src/options.rs b/crates/quicksearch-gui/src/options.rs index a353005..ceb5929 100644 --- a/crates/quicksearch-gui/src/options.rs +++ b/crates/quicksearch-gui/src/options.rs @@ -2,6 +2,7 @@ //! window and the Manage Index tab. Edits happen on a draft; Apply //! validates, saves, and hands the new config to the app. +use crate::keychain; use quicksearch_core::config::Config; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -35,6 +36,10 @@ pub struct OptionsOutput { pub struct OptionsWindow { pub open: bool, draft: Option, + /// Cached answer from [`OptionsWindow::keychain_active`], with the + /// `use_keychain` preference it was probed under. + keychain_probed_for: Option, + keychain_active: bool, } impl OptionsWindow { @@ -42,12 +47,31 @@ impl OptionsWindow { OptionsWindow { open: false, draft: None, + keychain_probed_for: None, + keychain_active: false, } } pub fn open_with(&mut self, current: &Config) { self.open = true; self.draft = Some(current.clone()); + self.keychain_probed_for = None; + } + + /// True when this index's key really is in the OS keychain: the + /// preference is on *and* the keychain answers with an entry (a dead + /// daemon, a locked keyring or a denied prompt all read as "no", which + /// is exactly when the startup prompt still appears). Probed when the + /// window opens and whenever the preference changes — a keychain read + /// is an IPC round trip, far too costly to repeat every frame. + fn keychain_active(&mut self, current: &Config) -> bool { + if self.keychain_probed_for != Some(current.security.use_keychain) { + let db_path = current.resolved_database_path(); + self.keychain_active = current.security.use_keychain + && matches!(keychain::load_key(&db_path.to_string_lossy()), Ok(Some(_))); + self.keychain_probed_for = Some(current.security.use_keychain); + } + self.keychain_active } /// Render; reports an applied draft config and/or a security action. @@ -61,6 +85,7 @@ impl OptionsWindow { } let mut out = OptionsOutput::default(); let mut open = self.open; + let keychain_active = self.keychain_active(current); let draft = self.draft.as_mut().unwrap(); egui::Window::new("Options") @@ -68,8 +93,8 @@ impl OptionsWindow { .resizable(false) .default_width(420.0) .show(ctx, |ui| { - egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| { - ui.heading("Paths"); + let scroll = egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| { + ui.heading(egui::RichText::new("Paths").strong()); egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| { ui.label("Database file"); ui.add( @@ -87,19 +112,27 @@ impl OptionsWindow { ); ui.separator(); - ui.heading("Indexing"); + ui.heading(egui::RichText::new("Indexing").strong()); config_editor_ui(ui, draft, Section::Indexing); + ui.label( + egui::RichText::new( + "Automatic and manual indexing are switched on the \ + Manage Index tab.", + ) + .small() + .weak(), + ); ui.separator(); - ui.heading("Processing"); + ui.heading(egui::RichText::new("Processing").strong()); config_editor_ui(ui, draft, Section::Processing); ui.separator(); - ui.heading("Search"); + ui.heading(egui::RichText::new("Search").strong()); config_editor_ui(ui, draft, Section::Search); ui.separator(); - ui.heading("Interface"); + ui.heading(egui::RichText::new("Interface").strong()); egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { ui.label("UI scale"); ui.add( @@ -120,9 +153,10 @@ impl OptionsWindow { // action opens its own confirmation flow immediately. // The KDF salt is deliberately never shown here (or // anywhere else in the GUI). - ui.heading("Security"); - out.security = security_ui(ui, current); + ui.heading(egui::RichText::new("Security").strong()); + out.security = security_ui(ui, current, keychain_active); }); + crate::ui_util::more_below_hint(ui, &scroll); ui.separator(); ui.horizontal(|ui| { @@ -149,10 +183,21 @@ impl OptionsWindow { } /// The Security block: status plus action buttons. Never renders the salt. -fn security_ui(ui: &mut egui::Ui, current: &Config) -> Option { +fn security_ui( + ui: &mut egui::Ui, + current: &Config, + keychain_active: bool, +) -> Option { let mut action = None; if current.security.password_protected { - ui.label("The index is encrypted; a password is asked for at startup."); + if keychain_active { + ui.label( + "The index is encrypted; its password is securely stored by \ + your Operating System.", + ); + } else { + ui.label("The index is encrypted; a password is required at startup."); + } ui.horizontal(|ui| { if ui.button("Change password…").clicked() { action = Some(SecurityAction::ChangePassword); @@ -195,10 +240,10 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section match section { Section::Indexing => { egui::Grid::new("cfg-indexing").num_columns(2).show(ui, |ui| { - ui.label("Automatic indexing"); - ui.checkbox(&mut config.indexing.auto_index, "watchers + periodic reindex"); - ui.end_row(); - + // Automatic vs manual is deliberately absent: it is live + // state, switched (and saved) by the Stop / Return to + // Automatic buttons on the Manage Index tab. A staged copy + // of it here would fight those buttons. ui.label("Full reindex every"); ui.horizontal(|ui| { ui.add( diff --git a/crates/quicksearch-gui/src/query_highlight.rs b/crates/quicksearch-gui/src/query_highlight.rs index a86ff0e..9d36d2f 100644 --- a/crates/quicksearch-gui/src/query_highlight.rs +++ b/crates/quicksearch-gui/src/query_highlight.rs @@ -81,9 +81,7 @@ pub fn classify(text: &str) -> Vec { let valid = if is_regex { let first = !regex_seen; regex_seen = true; - first - && op == Op::Contains - && RegexQuery::new(&value).is_ok() + first && op == Op::Contains && RegexQuery::new(&value).is_ok() } else { build_filter(word, op, &value, value_is_word).is_ok() }; @@ -406,7 +404,11 @@ mod tests { let segs = classify(text); let mut cursor = 0usize; for s in &segs { - assert_eq!(s.range.start, cursor, "gap or overlap in {:?}: {:?}", text, segs); + assert_eq!( + s.range.start, cursor, + "gap or overlap in {:?}: {:?}", + text, segs + ); assert!(s.range.end > s.range.start, "empty seg in {:?}", text); cursor = s.range.end; } @@ -416,7 +418,9 @@ mod tests { use Class::*; fn owned(v: Vec<(&str, Class, bool)>) -> Vec<(String, Class, bool)> { - v.into_iter().map(|(s, c, b)| (s.to_string(), c, b)).collect() + v.into_iter() + .map(|(s, c, b)| (s.to_string(), c, b)) + .collect() } #[test] @@ -460,7 +464,9 @@ mod tests { assert_eq!(all[0].1, Keyword, "{:?}", input); assert_eq!(all[1].1, Operator, "{:?}", input); assert!( - all[2..].iter().all(|(_, c, _)| *c == Argument || *c == Operator), + all[2..] + .iter() + .all(|(_, c, _)| *c == Argument || *c == Operator), "{:?}: {:?}", input, all @@ -514,10 +520,7 @@ mod tests { (r"C:\Users\me", Argument, true), ]) ); - assert_eq!( - segs(r"C:\data"), - owned(vec![(r"C:\data", Plain, false)]) - ); + assert_eq!(segs(r"C:\data"), owned(vec![(r"C:\data", Plain, false)])); } #[test] @@ -540,8 +543,9 @@ mod tests { ]) ); // Quoted stars are literal — content stays plain. - assert!(segs("\"a*b\"").iter().all(|(s, c, _)| s == "\"" - || *c == Plain)); + assert!(segs("\"a*b\"") + .iter() + .all(|(s, c, _)| s == "\"" || *c == Plain)); } #[test] @@ -640,7 +644,12 @@ mod tests { fn invalid_arguments_go_error_uniformly() { // (`regex:(` is not here: `(` lexes as a paren, so that input is an // *incomplete* filter — bare-key optimism applies, not an error.) - for input in ["type:NotAThing", "modified:>=tomorrow", "regex:[", "type:Doc*"] { + for input in [ + "type:NotAThing", + "modified:>=tomorrow", + "regex:[", + "type:Doc*", + ] { let all = segs(input); assert_eq!(all[0].1, Keyword, "{:?}", input); let last = all.last().unwrap(); @@ -703,11 +712,9 @@ mod tests { #[test] fn demoted_operators_stay_plain() { - assert!( - segs("(alpha AND beta) OR gamma") - .iter() - .all(|(_, c, chip)| *c == Plain && !chip) - ); + assert!(segs("(alpha AND beta) OR gamma") + .iter() + .all(|(_, c, chip)| *c == Plain && !chip)); // Dangling comparators are literal text. assert!(segs("a > b").iter().all(|(_, c, _)| *c == Plain)); // Leading operator, nothing else. diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index 6b27528..4268b38 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -204,7 +204,10 @@ impl SearchTab { self.order.sort_by(|&a, &b| { let (a, b) = (&results[a as usize], &results[b as usize]); let ord = match key { - SortKey::Rank => a.rank.partial_cmp(&b.rank).unwrap_or(std::cmp::Ordering::Equal), + SortKey::Rank => a + .rank + .partial_cmp(&b.rank) + .unwrap_or(std::cmp::Ordering::Equal), SortKey::Name => a.name.cmp(&b.name), SortKey::Path => a.path.cmp(&b.path), SortKey::Size => a.size.cmp(&b.size), @@ -233,8 +236,7 @@ impl SearchTab { fn sort_header(&mut self, ui: &mut egui::Ui, key: SortKey, label: &str) { let (cur, asc) = self.sort; let selected = cur == key; - let (rect, response) = - ui.allocate_exact_size(ui.available_size(), egui::Sense::click()); + let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click()); if ui.is_rect_visible(rect) { if response.hovered() { ui.painter() @@ -344,7 +346,7 @@ impl SearchTab { let mut remove: Option = None; for (i, pattern) in self.session_ignores.iter().enumerate() { if ui - .small_button(format!("{} ✕", pattern)) + .small_button(format!("{} 🗙", pattern)) .on_hover_text("Remove this session filter") .clicked() { @@ -385,11 +387,9 @@ impl SearchTab { // `animate_value_with_time` keeps requesting repaints until the // value settles. let fade_target = if self.swap_pending { 0.0 } else { 1.0 }; - let fade = ui.ctx().animate_value_with_time( - egui::Id::new("qs-results-fade"), - fade_target, - 0.25, - ); + let fade = + ui.ctx() + .animate_value_with_time(egui::Id::new("qs-results-fade"), fade_target, 0.25); if self.swap_pending && fade <= 0.01 { self.results = std::mem::take(&mut self.staging); self.has_snippets = self.staging_has_snippets; @@ -426,170 +426,172 @@ impl SearchTab { let mut open_ignore_dialog: Option = None; let mut hovered_now: Option = None; - let table_scroll = ui.push_id("results", |ui| { - let mut table = TableBuilder::new(ui) - .striped(true) - .resizable(true) - .sense(egui::Sense::click()) - .max_scroll_height(table_height) - .min_scrolled_height(60.0) - .column(Column::initial(220.0).at_least(80.0).clip(true)) // name - .column(Column::remainder().at_least(120.0).clip(true)); // path - if self.has_snippets { - table = table.column(Column::remainder().at_least(120.0).clip(true)); - } - table = table - .column(Column::exact(72.0)) // size - .column(Column::exact(110.0)) // modified - .column(Column::exact(52.0)); // rank + let table_scroll = ui + .push_id("results", |ui| { + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .max_scroll_height(table_height) + .min_scrolled_height(60.0) + .column(Column::initial(220.0).at_least(80.0).clip(true)) // name + .column(Column::remainder().at_least(120.0).clip(true)); // path + if self.has_snippets { + table = table.column(Column::remainder().at_least(120.0).clip(true)); + } + table = table + .column(Column::exact(72.0)) // size + .column(Column::exact(110.0)) // modified + .column(Column::exact(52.0)); // rank - table - .header(text_height + 4.0, |mut header| { - header.col(|ui| self.sort_header(ui, SortKey::Name, "Name")); - header.col(|ui| self.sort_header(ui, SortKey::Path, "Path")); - if self.has_snippets { - header.col(|ui| { - ui.with_layout( - egui::Layout::centered_and_justified( - egui::Direction::LeftToRight, - ), - |ui| { - ui.label(egui::RichText::new("Match").strong()); - }, - ); - }); - } - header.col(|ui| self.sort_header(ui, SortKey::Size, "Size")); - header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified")); - header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank")); - }) - .body(|body| { - let order = self.order.clone(); - body.rows(text_height, order.len(), |mut row| { - let display_ix = row.index(); - let result_ix = order[display_ix] as usize; - let hit = &self.results[result_ix]; - row.set_selected(self.selected == Some(result_ix as u32)); - row.set_hovered(self.hovered_row == Some(display_ix)); - - // Labels stay selectable for copy-paste, which makes - // them win egui's hit-test over the row. Collect their - // responses and union them into the row's below so - // clicks land even when the pointer is over glyphs. - let mut cell_responses: Vec = Vec::new(); - - row.col(|ui| { - cell_responses.push(ui.label(&hit.name)); - }); - row.col(|ui| { - cell_responses.push(ui.label(egui::RichText::new(&hit.path).weak())); - }); + table + .header(text_height + 4.0, |mut header| { + header.col(|ui| self.sort_header(ui, SortKey::Name, "Name")); + header.col(|ui| self.sort_header(ui, SortKey::Path, "Path")); if self.has_snippets { - let snippet = hit.snippet.clone(); - // Name and path matches show a whole field, so - // they render bracketed: [matched field]. - let whole_field = - hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9; - row.col(|ui| { - if let Some(snip) = &snippet { - let width = ui.available_width(); - let job = centered_match_job(ui, snip, width, whole_field); - let mut response = ui - .with_layout( - egui::Layout::centered_and_justified( - egui::Direction::LeftToRight, - ), - |ui| ui.label(job), - ) - .inner; - if !snip.ranges.is_empty() { - let hover = snip.clone(); - response = response.on_hover_ui(|ui| { - ui.set_max_width(520.0); - let job = snippet_job(ui, &hover, 10); - ui.label(job); - }); - } - cell_responses.push(response); - } + header.col(|ui| { + ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| { + ui.label(egui::RichText::new("Match").strong()); + }, + ); }); } - row.col(|ui| { - let response = ui.with_layout( - egui::Layout::centered_and_justified( - egui::Direction::LeftToRight, - ), - |ui| ui.label(human_size(hit.size)), - ); - cell_responses.push(response.inner); - }); - row.col(|ui| { - let color = recency_color(ui, hit.mtime); - let response = ui.with_layout( - egui::Layout::centered_and_justified( - egui::Direction::LeftToRight, - ), - |ui| { - ui.label( - egui::RichText::new(fmt_mtime(hit.mtime)).color(color), - ) - }, - ); - cell_responses.push(response.inner); - }); - row.col(|ui| { - let response = ui.with_layout( - egui::Layout::centered_and_justified( - egui::Direction::LeftToRight, - ), - |ui| { - ui.label( - egui::RichText::new(format!(" {:.2} ", hit.rank)) - .background_color(rank_tier_color(hit.stage)) - .color(egui::Color32::from_rgb(32, 32, 32)), - ) - }, - ); - cell_responses.push(response.inner); - }); + header.col(|ui| self.sort_header(ui, SortKey::Size, "Size")); + header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified")); + header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank")); + }) + .body(|body| { + let order = self.order.clone(); + body.rows(text_height, order.len(), |mut row| { + let display_ix = row.index(); + let result_ix = order[display_ix] as usize; + let hit = &self.results[result_ix]; + row.set_selected(self.selected == Some(result_ix as u32)); + row.set_hovered(self.hovered_row == Some(display_ix)); - let mut response = row.response(); - for r in cell_responses { - response = response | r; - } - if response.contains_pointer() { - hovered_now = Some(display_ix); - } - if response.clicked() || response.secondary_clicked() { - self.selected = Some(result_ix as u32); - } - if response.double_clicked() { - platform::open_file(&self.results[result_ix].path); - } - response.context_menu(|ui| { - let path = self.results[result_ix].path.clone(); - if ui.button("Open containing folder").clicked() { - platform::reveal_in_folder(&path); - ui.close(); + // Labels stay selectable for copy-paste, which makes + // them win egui's hit-test over the row. Collect their + // responses and union them into the row's below so + // clicks land even when the pointer is over glyphs. + let mut cell_responses: Vec = Vec::new(); + + row.col(|ui| { + cell_responses.push(ui.label(&hit.name)); + }); + row.col(|ui| { + cell_responses + .push(ui.label(egui::RichText::new(&hit.path).weak())); + }); + if self.has_snippets { + let snippet = hit.snippet.clone(); + // Name and path matches show a whole field, so + // they render bracketed: [matched field]. + let whole_field = + hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9; + row.col(|ui| { + if let Some(snip) = &snippet { + let width = ui.available_width(); + let job = centered_match_job(ui, snip, width, whole_field); + let mut response = ui + .with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| ui.label(job), + ) + .inner; + if !snip.ranges.is_empty() { + let hover = snip.clone(); + response = response.on_hover_ui(|ui| { + ui.set_max_width(520.0); + let job = snippet_job(ui, &hover, 10); + ui.label(job); + }); + } + cell_responses.push(response); + } + }); } - if ui.button("Open").clicked() { - platform::open_file(&path); - ui.close(); + row.col(|ui| { + let response = ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| ui.label(human_size(hit.size)), + ); + cell_responses.push(response.inner); + }); + row.col(|ui| { + let color = recency_color(ui, hit.mtime); + let response = ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| { + ui.label( + egui::RichText::new(fmt_mtime(hit.mtime)).color(color), + ) + }, + ); + cell_responses.push(response.inner); + }); + row.col(|ui| { + let response = ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| { + ui.label( + egui::RichText::new(format!(" {:.2} ", hit.rank)) + .background_color(rank_tier_color(hit.stage)) + .color(egui::Color32::from_rgb(32, 32, 32)), + ) + }, + ); + cell_responses.push(response.inner); + }); + + let mut response = row.response(); + for r in cell_responses { + response = response | r; } - if ui.button("Copy path").clicked() { - ui.ctx().copy_text(path.clone()); - ui.close(); + if response.contains_pointer() { + hovered_now = Some(display_ix); } - ui.separator(); - if ui.button("Build ignore filter…").clicked() { - open_ignore_dialog = Some(result_ix); - ui.close(); + if response.clicked() || response.secondary_clicked() { + self.selected = Some(result_ix as u32); } + if response.double_clicked() { + platform::open_file(&self.results[result_ix].path); + } + response.context_menu(|ui| { + let path = self.results[result_ix].path.clone(); + if ui.button("Open containing folder").clicked() { + platform::reveal_in_folder(&path); + ui.close(); + } + if ui.button("Open").clicked() { + platform::open_file(&path); + ui.close(); + } + if ui.button("Copy path").clicked() { + ui.ctx().copy_text(path.clone()); + ui.close(); + } + ui.separator(); + if ui.button("Build ignore filter…").clicked() { + open_ignore_dialog = Some(result_ix); + ui.close(); + } + }); }); - }); - }) - }) - .inner; + }) + }) + .inner; crate::ui_util::more_below_hint(ui, &table_scroll); self.hovered_row = hovered_now; @@ -642,25 +644,20 @@ impl SearchTab { ui.separator(); // --- Extension --------------------------------------------- - ui.horizontal(|ui| { - match &dialog.ext_pattern { - Some(ext) => { - ui.monospace(ext); - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui - .add(bordered_button("Ignore this extension", ORANGE)) - .clicked() - { - chosen = Some(ext.clone()); - } - }, - ); - } - None => { - ui.label(egui::RichText::new("(no file extension)").weak()); - } + ui.horizontal(|ui| match &dialog.ext_pattern { + Some(ext) => { + ui.monospace(ext); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add(bordered_button("Ignore this extension", ORANGE)) + .clicked() + { + chosen = Some(ext.clone()); + } + }); + } + None => { + ui.label(egui::RichText::new("(no file extension)").weak()); } }); ui.separator(); @@ -771,8 +768,7 @@ impl SearchTab { ui, "regex:\"(foo|bar)\\d+\"", "regular expression, matched against names, contents, \ - and paths; case-insensitive — use (?-i:…) to override; \ - quote patterns containing spaces", + and paths", ); row( ui, @@ -984,17 +980,17 @@ fn centered_match_job( /// tiers. Dark text on these pastels stays readable in both themes. fn rank_tier_color(stage: u8) -> egui::Color32 { match stage { - 1 => egui::Color32::from_rgb(255, 127, 127), // S - 2 => egui::Color32::from_rgb(255, 191, 127), // A - 3 => egui::Color32::from_rgb(255, 223, 127), // B - 4 => egui::Color32::from_rgb(255, 255, 127), // C - 5 => egui::Color32::from_rgb(191, 255, 127), // D - 6 => egui::Color32::from_rgb(127, 255, 127), // E - 7 => egui::Color32::from_rgb(127, 191, 255), // F - 8 => egui::Color32::from_rgb(191, 127, 255), // G - 9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case + 1 => egui::Color32::from_rgb(255, 127, 127), // S + 2 => egui::Color32::from_rgb(255, 191, 127), // A + 3 => egui::Color32::from_rgb(255, 223, 127), // B + 4 => egui::Color32::from_rgb(255, 255, 127), // C + 5 => egui::Color32::from_rgb(191, 255, 127), // D + 6 => egui::Color32::from_rgb(127, 255, 127), // E + 7 => egui::Color32::from_rgb(127, 191, 255), // F + 8 => egui::Color32::from_rgb(191, 127, 255), // G + 9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case 10 => egui::Color32::from_rgb(239, 191, 239), // I — path, any case - _ => egui::Color32::from_rgb(199, 199, 199), // J — fuzzy path + _ => egui::Color32::from_rgb(199, 199, 199), // J — fuzzy path } } diff --git a/crates/quicksearch-gui/src/tracker.rs b/crates/quicksearch-gui/src/tracker.rs index 0f612ba..09cf8ca 100644 --- a/crates/quicksearch-gui/src/tracker.rs +++ b/crates/quicksearch-gui/src/tracker.rs @@ -42,9 +42,7 @@ impl SpeedTracker { self.points.push((now, files_processed)); // Prune old points, but always keep at least two so a slow but // steady rate never becomes unmeasurable. - while self.points.len() > 2 - && now.duration_since(self.points[0].0) > HISTORY - { + while self.points.len() > 2 && now.duration_since(self.points[0].0) > HISTORY { self.points.remove(0); } } @@ -90,7 +88,9 @@ mod tests { for i in 0..4 { t.record_at(base + Duration::from_millis(2500 * i), 10 + i as usize); } - let rate = t.files_per_sec_at(base + Duration::from_millis(7500)).unwrap(); + let rate = t + .files_per_sec_at(base + Duration::from_millis(7500)) + .unwrap(); assert!((rate - 0.4).abs() < 0.01, "expected ~0.4/s, got {}", rate); } @@ -113,7 +113,11 @@ mod tests { let just_after = t.files_per_sec_at(base + Duration::from_secs(1)).unwrap(); let stalled = t.files_per_sec_at(base + Duration::from_secs(20)).unwrap(); assert!(just_after > 90.0); - assert!(stalled < 6.0, "estimate must decay during a stall: {}", stalled); + assert!( + stalled < 6.0, + "estimate must decay during a stall: {}", + stalled + ); } #[test] diff --git a/crates/quicksearch-gui/src/ui_util.rs b/crates/quicksearch-gui/src/ui_util.rs index c3a4cb3..00006a4 100644 --- a/crates/quicksearch-gui/src/ui_util.rs +++ b/crates/quicksearch-gui/src/ui_util.rs @@ -28,9 +28,23 @@ pub fn ignore_pattern_valid(pattern: &str) -> bool { !trimmed.is_empty() && IgnoreSet::compile(&[pattern.to_string()]).is_ok() } +/// Border color for a pattern editor holding `text`, or `None` to keep the +/// theme's own border. A blank box is not wrong yet, just unfilled, so it +/// stays neutral; only text the user actually typed is judged. +fn pattern_border(text: &str) -> Option { + if text.trim().is_empty() { + None + } else if ignore_pattern_valid(text) { + Some(VALID_GREEN) + } else { + Some(INVALID_RED) + } +} + /// Single-line ignore-pattern editor with a green border while the text is -/// a valid pattern and a red one otherwise. Returns the response and the -/// validity of the text as it stands after this frame's edits. +/// a valid pattern, a red one while it is not, and the theme's neutral +/// border while it is empty. Returns the response and the validity of the +/// text as it stands after this frame's edits. pub fn pattern_edit( ui: &mut egui::Ui, text: &mut String, @@ -38,16 +52,19 @@ pub fn pattern_edit( hint: &str, ) -> (egui::Response, bool) { let mut valid = ignore_pattern_valid(text); - let stroke = egui::Stroke::new(1.0, if valid { VALID_GREEN } else { INVALID_RED }); + let border = pattern_border(text); let response = ui .scope(|ui| { // TextEdit frames with widgets.*.bg_stroke when unfocused and // selection.stroke when focused; recolor all of them. - let v = ui.visuals_mut(); - v.widgets.inactive.bg_stroke = stroke; - v.widgets.hovered.bg_stroke = stroke; - v.widgets.active.bg_stroke = stroke; - v.selection.stroke = stroke; + if let Some(color) = border { + let stroke = egui::Stroke::new(1.0, color); + let v = ui.visuals_mut(); + v.widgets.inactive.bg_stroke = stroke; + v.widgets.hovered.bg_stroke = stroke; + v.widgets.active.bg_stroke = stroke; + v.selection.stroke = stroke; + } ui.add( egui::TextEdit::singleline(text) .desired_width(desired_width) @@ -63,18 +80,20 @@ pub fn pattern_edit( } /// Paint a semitransparent down-arrow near the bottom edge of a scroll -/// area while more content lies below the fold. Painter-only on the -/// foreground layer, so it can never swallow clicks. (The bundled fonts -/// have no ▼ glyph — this is a shape, like the sort-header triangles.) +/// area while more content lies below the fold. Painter-only, so it can +/// never swallow clicks. (The bundled fonts have no ▼ glyph — this is a +/// shape, like the sort-header triangles.) +/// +/// The hint is painted on the caller's own layer, unclipped: last in that +/// layer, so it sits above the scrolled content, but still below anything +/// stacked over it — a tab's hint stays under the Options window rather +/// than punching through it. pub fn more_below_hint(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutput) { let more_below = out.state.offset.y + out.inner_rect.height() < out.content_size.y - 1.0; if !more_below { return; } - let painter = ui.ctx().layer_painter(egui::LayerId::new( - egui::Order::Foreground, - egui::Id::new("qs-more-below-hint"), - )); + let painter = ui.ctx().layer_painter(ui.layer_id()); let cx = out.inner_rect.center().x; let tip = out.inner_rect.bottom() - 5.0; let (half_width, height) = (7.0, 6.0); @@ -99,7 +118,7 @@ pub fn more_below_hint(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutp #[cfg(test)] mod tests { - use super::ignore_pattern_valid; + use super::{ignore_pattern_valid, pattern_border, INVALID_RED, VALID_GREEN}; #[test] fn blank_patterns_are_invalid() { @@ -126,4 +145,22 @@ mod tests { assert!(ignore_pattern_valid("C:\\Windows\\Temp\\*")); assert!(ignore_pattern_valid("cache-??")); // wildcards } + + #[test] + fn empty_editor_keeps_the_theme_border() { + // Nothing typed yet is not an error to flag. + assert_eq!(pattern_border(""), None); + assert_eq!(pattern_border(" "), None); + assert_eq!(pattern_border("\t\n"), None); + } + + #[test] + fn typed_text_is_judged() { + assert_eq!(pattern_border("*.tmp"), Some(VALID_GREEN)); + assert_eq!(pattern_border(" node_modules "), Some(VALID_GREEN)); + assert_eq!(pattern_border("foo["), Some(INVALID_RED)); + // Typed, but trims away to nothing under the pattern rules — still + // worth flagging, unlike a box the user simply has not filled in. + assert_eq!(pattern_border("/"), Some(INVALID_RED)); + } }