Improved config menus, rewrote directory walk to reduce memory use, GUI improvements.

This commit is contained in:
= 2026-08-02 22:21:39 -04:00
parent e63706b4b5
commit b85c99e277
29 changed files with 2001 additions and 920 deletions

106
Cargo.lock generated
View file

@ -140,7 +140,7 @@ dependencies = [
"base64ct", "base64ct",
"blake2", "blake2",
"cpufeatures 0.2.12", "cpufeatures 0.2.12",
"password-hash 0.5.0", "password-hash",
"zeroize", "zeroize",
] ]
@ -499,26 +499,6 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" 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]] [[package]]
name = "calloop" name = "calloop"
version = "0.13.0" version = "0.13.0"
@ -706,12 +686,6 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@ -1706,15 +1680,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "home" name = "home"
version = "0.5.9" version = "0.5.9"
@ -2955,17 +2920,6 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "password-hash" name = "password-hash"
version = "0.5.0" version = "0.5.0"
@ -2983,18 +2937,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 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]] [[package]]
name = "pdf-extract" name = "pdf-extract"
version = "0.12.0" version = "0.12.0"
@ -3237,7 +3179,7 @@ dependencies = [
[[package]] [[package]]
name = "quicksearch-core" name = "quicksearch-core"
version = "0.8.0" version = "0.8.5"
dependencies = [ dependencies = [
"argon2", "argon2",
"ctrlc", "ctrlc",
@ -3260,12 +3202,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
"zeroize", "zeroize",
"zip", "zip",
"zstd 0.13.3", "zstd",
] ]
[[package]] [[package]]
name = "quicksearch-gui" name = "quicksearch-gui"
version = "0.8.0" version = "0.8.5"
dependencies = [ dependencies = [
"chrono", "chrono",
"eframe", "eframe",
@ -3669,17 +3611,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.8" version = "0.10.8"
@ -5373,27 +5304,10 @@ version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
dependencies = [ dependencies = [
"aes",
"byteorder", "byteorder",
"bzip2",
"constant_time_eq",
"crc32fast", "crc32fast",
"crossbeam-utils", "crossbeam-utils",
"flate2", "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]] [[package]]
@ -5402,17 +5316,7 @@ version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [ dependencies = [
"zstd-safe 7.2.4", "zstd-safe",
]
[[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",
] ]
[[package]] [[package]]

View file

@ -6,7 +6,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.8.5" version = "0.8.6"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"] authors = ["Jeremy <jeremy@karsttech.com>"]

View file

@ -118,7 +118,9 @@ the package is installed.
copied in place. Matches in file contents show highlighted snippets. copied in place. Matches in file contents show highlighted snippets.
- **Manage Index**: full indexing status, Start/Stop/Automatic controls, - **Manage Index**: full indexing status, Start/Stop/Automatic controls,
indexed folder list, full-text extension filters, ignore patterns, and 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. - **Duplicates**: files sharing a content hash, grouped.
- **Logs**: the lines the app would have printed to a terminal — warnings - **Logs**: the lines the app would have printed to a terminal — warnings
from indexing, folder watching and opening files, newest last, with a 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`. a polled `IndexingStatus`.
- **Coordinator** (`coordinator.rs`): the object binaries construct. - **Coordinator** (`coordinator.rs`): the object binaries construct.
Owns the `IndexingService`, the debouncing filesystem watcher 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 events become single-file transactions (`incremental.rs`) that keep
`files`, FTS, and the text sidecar consistent per commit; a full `files`, FTS, and the text sidecar consistent per commit; a full
reindex runs on a configurable interval. Incremental writes defer while reindex runs on a configurable interval. Incremental writes defer while

View file

@ -21,10 +21,13 @@ indexing_paths = ["~"]
database_path = "~/.local/share/quicksearch/index.sqlite" database_path = "~/.local/share/quicksearch/index.sqlite"
[indexing] [indexing]
# Automatic mode: filesystem watchers apply changes as they happen and a # The indexing mode. true = automatic: filesystem watchers apply changes as
# full reindex runs every reindex_interval_minutes. The watcher catches # they happen and a full reindex runs every reindex_interval_minutes (the
# changes as they happen, so this only needs to be often enough to cover # watcher catches changes as they happen, so that interval only needs to be
# whatever the watcher missed. # 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 auto_index = true
reindex_interval_minutes = 1440 reindex_interval_minutes = 1440
# Follow symbolic links during directory walks. # Follow symbolic links during directory walks.

View file

@ -23,7 +23,11 @@ zeroize = { version = "1", features = ["derive"] }
getrandom = "0.2" getrandom = "0.2"
sha2 = "0.10.8" sha2 = "0.10.8"
walkdir = "2.5.0" 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" quick-xml = "0.31"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
toml = "0.8" toml = "0.8"

View file

@ -14,9 +14,14 @@
//! ``` //! ```
//! //!
//! The optional trailing number is the sampling interval in milliseconds //! The optional trailing number is the sampling interval in milliseconds
//! (default 100). Drop it to single digits to name the file a spike happened //! (default 100). Finer sampling resolves the *shape* of a spike, not its
//! on: at 100 ms the extractor has moved on by the time RSS is read, so the //! cause: the file column is only as good as `RootProgress::current_file`,
//! file the timeline shows beside a spike is only approximately the cause. //! 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 //! `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 //! 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 //! peak to the walk or to extraction; a sampled peak far under VmHWM means
//! the real spike was shorter than the sampling interval. //! the real spike was shorter than the sampling interval.
//! //!
//! RSS counts the page cache backing the mmap'd database, so the figure is a //! Nothing here is evictable page cache. The connection sets no `mmap_size`,
//! ceiling on what the process needs, not a floor on what it must have: those //! so SQLite reads the index through its own `malloc`'d page cache (`PRAGMA
//! pages are evictable under pressure. `/usr/bin/time -v` on this binary //! cache_size`, 10000 pages ≈ 40 MiB) rather than mapping the file, and the
//! reports the same VmHWM, as a cross-check that nothing here is fooling //! `by mapping` breakdown confirms it: the index never appears as a
//! itself. //! 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::path::{Path, PathBuf};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};

View file

@ -30,7 +30,6 @@
//! Both modes report files/sec. Run each twice: the first pass warms the page //! 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 //! cache (or, on a share, the client's attribute cache), so the second is the
//! one to compare. //! one to compare.
use std::collections::HashMap;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Instant, UNIX_EPOCH}; 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::config::{Config, IgnoreSet};
use quicksearch_core::extract::Registry; use quicksearch_core::extract::Registry;
use quicksearch_core::file_handling::{ 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, UnreadableDirs,
}; };
use quicksearch_core::walk::walk_indexable_files; use quicksearch_core::walk::{walk_indexable_files, WalkEvent};
fn main() { fn main() {
let root = std::env::args().nth(1).unwrap(); let root = std::env::args().nth(1).unwrap();
let mode = std::env::args().nth(2).unwrap_or_else(|| "parallel".into()); let mode = std::env::args().nth(2).unwrap_or_else(|| "parallel".into());
let config = Config::default(); let config = Config::default();
let existing: HashMap<String, ExistingFileEntry> = 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 start = Instant::now();
let (seen, prepared) = match mode.as_str() { let (seen, prepared) = match mode.as_str() {
"serial" => serial(&root, &config, &existing), "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(); let elapsed = start.elapsed();
eprintln!( eprintln!(
@ -63,11 +70,7 @@ fn main() {
); );
} }
fn serial( fn serial(root: &str, config: &Config, existing: &DirRows) -> (usize, usize) {
root: &str,
config: &Config,
existing: &HashMap<String, ExistingFileEntry>,
) -> (usize, usize) {
let ignore = IgnoreSet::compile(&[]).unwrap(); let ignore = IgnoreSet::compile(&[]).unwrap();
let registry = Registry::default_set(); let registry = Registry::default_set();
let (mut seen, mut prepared) = (0, 0); let (mut seen, mut prepared) = (0, 0);
@ -90,7 +93,13 @@ fn serial(
else { else {
continue; 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, &registry).is_some() && prepare_file_record(&path, &meta, config, &registry).is_some()
{ {
prepared += 1; prepared += 1;
@ -99,24 +108,21 @@ fn serial(
(seen, prepared) (seen, prepared)
} }
fn parallel( fn parallel(root: &str, config: &Config, db_path: &str) -> (usize, usize) {
root: &str,
config: &Config,
existing: HashMap<String, ExistingFileEntry>,
) -> (usize, usize) {
let (mut seen, mut prepared) = (0, 0); let (mut seen, mut prepared) = (0, 0);
for file in walk_indexable_files( for event in walk_indexable_files(
&[root.to_string()], &[root.to_string()],
false, false,
false, false,
IgnoreSet::compile(&[]).unwrap(), IgnoreSet::compile(&[]).unwrap(),
Arc::new(existing), db_path,
config.clone(), config.clone(),
Arc::new(Registry::default_set()), Arc::new(Registry::default_set()),
Arc::new(Mutex::new(false)), Arc::new(Mutex::new(false)),
Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)),
4, 4,
) { ) {
let WalkEvent::File(file) = event else { continue };
seen += 1; seen += 1;
if file.record.is_some() { if file.record.is_some() {
prepared += 1; prepared += 1;

View file

@ -48,8 +48,11 @@ pub struct PathConfig {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)] #[serde(default)]
pub struct IndexingConfig { pub struct IndexingConfig {
/// Start in automatic mode: filesystem watchers apply changes as they /// The indexing mode, written down: `true` is automatic — filesystem
/// happen and a full reindex runs every `reindex_interval_minutes`. /// 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 auto_index: bool,
pub reindex_interval_minutes: u64, pub reindex_interval_minutes: u64,
pub follow_symlinks: bool, pub follow_symlinks: bool,
@ -292,7 +295,7 @@ impl Default for Config {
/// to catch it would also catch a user folder named `Windows`. /// to catch it would also catch a user folder named `Windows`.
/// `config_example.toml` documents it for people who add a drive root. /// `config_example.toml` documents it for people who add a drive root.
fn default_ignore_patterns() -> Vec<String> { fn default_ignore_patterns() -> Vec<String> {
let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv", "*.pdf"]; let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv"];
if cfg!(windows) { if cfg!(windows) {
patterns.extend([ patterns.extend([
"$RECYCLE.BIN", "$RECYCLE.BIN",

View file

@ -12,6 +12,11 @@
//! - **ManualRunning** — one user-forced full run; returns to //! - **ManualRunning** — one user-forced full run; returns to
//! `ManualStopped` when it finishes. (A forced run in Auto stays Auto.) //! `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 //! Single-writer guarantee: incremental writes are deferred while a full
//! run is active — the coordinator's tick simply does nothing until the //! run is active — the coordinator's tick simply does nothing until the
//! `IndexingService` reports idle, then drains its queue. Overflowing the //! `IndexingService` reports idle, then drains its queue. Overflowing the
@ -307,15 +312,22 @@ impl Inner {
} }
} }
CoordCmd::ConfigChanged(new) => { CoordCmd::ConfigChanged(new) => {
let want_auto = new.indexing.auto_index;
self.config = new; self.config = new;
if let Err(e) = self.reload_filters() { if let Err(e) = self.reload_filters() {
crate::log_warn!("coordinator: {}", e); crate::log_warn!("coordinator: {}", e);
} }
// The write connection may point at an old database_path. // The write connection may point at an old database_path.
self.write_conn = None; self.write_conn = None;
// Watched roots / symlink behavior may have changed; a if want_auto && self.mode != IndexMode::Auto {
// restart is cheap and unconditional beats a diff here. // The mode lives in `auto_index`, so a config that
if self.mode == IndexMode::Auto { // 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(); self.start_watcher();
} }
} }
@ -491,6 +503,9 @@ impl Inner {
fn enter_auto(&mut self) { fn enter_auto(&mut self) {
self.mode = IndexMode::Auto; 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(); self.start_watcher();
if self.shared.lock().unwrap().last_full_index.is_none() { if self.shared.lock().unwrap().last_full_index.is_none() {
self.needs_full_run = true; self.needs_full_run = true;
@ -499,6 +514,7 @@ impl Inner {
fn enter_manual_stopped(&mut self) { fn enter_manual_stopped(&mut self) {
self.mode = IndexMode::ManualStopped; self.mode = IndexMode::ManualStopped;
self.config.indexing.auto_index = false;
self.stop_watcher(); self.stop_watcher();
self.pending.clear(); self.pending.clear();
let status = self.indexing.get_status(); let status = self.indexing.get_status();
@ -899,6 +915,38 @@ mod tests {
coord.shutdown(); 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] #[test]
fn apply_config_with_new_root_then_reindex_indexes_it() { fn apply_config_with_new_root_then_reindex_indexes_it() {
// The reported failure: add directories, apply, click "Start // The reported failure: add directories, apply, click "Start

View file

@ -18,7 +18,8 @@ use std::path::Path;
use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
use super::schema::{ 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; use crate::security::IndexKey;
@ -99,10 +100,34 @@ pub fn open_existing(db_path: &str, write: bool) -> Result<Connection, String> {
open_existing_keyed(db_path, write, super::key::process_key().as_ref()) 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<Connection, String> {
open_keyed_with_pragmas(
db_path,
false,
super::key::process_key().as_ref(),
PRAGMAS_WALK_READER,
)
}
pub(crate) fn open_existing_keyed( pub(crate) fn open_existing_keyed(
db_path: &str, db_path: &str,
write: bool, write: bool,
key: Option<&IndexKey>, key: Option<&IndexKey>,
) -> Result<Connection, String> {
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<Connection, String> { ) -> Result<Connection, String> {
let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
| if write { | if write {
@ -113,7 +138,6 @@ pub(crate) fn open_existing_keyed(
let conn = Connection::open_with_flags(db_path, flags) let conn = Connection::open_with_flags(db_path, flags)
.map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?; .map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?;
key_and_probe(&conn, db_path, key)?; key_and_probe(&conn, db_path, key)?;
let pragmas = if write { PRAGMAS_FAST } else { PRAGMAS_READONLY };
conn.execute_batch(pragmas) conn.execute_batch(pragmas)
.map_err(|e| format!("Failed to apply pragmas: {}", e))?; .map_err(|e| format!("Failed to apply pragmas: {}", e))?;

View file

@ -242,6 +242,87 @@ pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, Str
Ok(true) Ok(true)
} }
/// Every indexed file directly inside `parent`, as `name -> 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<std::collections::HashMap<String, u64>, 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<Option<u64>, 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<F: FnMut(String)>(
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<Vec<String>, 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::<Result<Vec<_>, _>>()
.map_err(|e| format!("read path under {}: {}", parent, e))
}
/// Remove the FTS row, compressed text blob, and any `properties` rows for /// 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 /// a given file id. Does not touch the `files` row itself. Idempotent — a
/// missing row is fine. /// missing row is fine.

View file

@ -32,6 +32,25 @@ pub const PRAGMAS_READONLY: &str = "
PRAGMA foreign_keys = ON; 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`] /// 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 /// when the DB is fresh or has just been wiped because it drifted from
/// [`super::open::CURRENT_SCHEMA_VERSION`]. /// [`super::open::CURRENT_SCHEMA_VERSION`].

View file

@ -19,32 +19,13 @@ use crate::extract::Registry;
use crate::indexing::should_abort; use crate::indexing::should_abort;
use crate::mime::{guess_mime_from_head, mime_to_type, FileType}; use crate::mime::{guess_mime_from_head, mime_to_type, FileType};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] /// One directory's indexed files, as `name -> mtime`.
pub struct ExistingFileEntry { ///
pub mtime: u64, /// 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
/// Load path and mtime per row for incremental classification (hash/size loaded only when updating a file). /// [`crate::db::repo::dir_rows`].
pub fn load_existing_files(conn: &Connection) -> Result<HashMap<String, ExistingFileEntry>, rusqlite::Error> { pub type DirRows = HashMap<String, u64>;
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)
}
/// Derive (inode, device_id) from a `std::fs::Metadata` on platforms that /// Derive (inode, device_id) from a `std::fs::Metadata` on platforms that
/// expose them. Returns `(None, None)` on Windows and other non-Unix targets. /// expose them. Returns `(None, None)` on Windows and other non-Unix targets.
@ -471,19 +452,32 @@ pub enum FileIndexAction {
Insert, Insert,
} }
/// Decide what Phase 1 should do with a file, given the path spelling used /// Decide what Phase 1 should do with a file, given its name within the
/// as the `files.path` key and the file's mtime. /// directory being walked and the file's mtime.
/// ///
/// Pure: the caller supplies the `stat` result rather than this function /// Pure: the caller supplies both the `stat` result and the directory's rows
/// going to disk for it, so the same `stat` serves classification and the /// rather than this function going to disk or to SQLite for them, so the same
/// record build, and this runs on any worker thread against a shared map. /// `stat` serves classification and the record build, and this runs on any
pub fn classify_for_indexing( /// worker thread against data the prefetcher already fetched.
path: &str, ///
mtime: u64, /// Keyed by name against one directory rather than by full path against the
existing_files: &HashMap<String, ExistingFileEntry>, /// whole index: see [`DirRows`]. A file the walk reaches under a spelling
) -> FileIndexAction { /// whose parent is *not* this directory — a resolved symlink target — must
match existing_files.get(path) { /// not be classified here; it would miss and read as [`FileIndexAction::Insert`],
Some(existing) if existing.mtime == mtime => FileIndexAction::Skip, /// 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<u64>, mtime: u64) -> FileIndexAction {
match stored {
Some(known) if known == mtime => FileIndexAction::Skip,
Some(_) => FileIndexAction::Update, Some(_) => FileIndexAction::Update,
None => FileIndexAction::Insert, None => FileIndexAction::Insert,
} }
@ -1268,26 +1262,35 @@ mod tests {
#[test] #[test]
fn classify_uses_mtime_against_the_existing_index() { fn classify_uses_mtime_against_the_existing_index() {
let mut existing = HashMap::new(); let mut rows = DirRows::new();
existing.insert("/a/known.txt".to_string(), ExistingFileEntry { mtime: 100 }); rows.insert("known.txt".to_string(), 100);
assert_eq!( assert_eq!(
classify_for_indexing("/a/new.txt", 100, &existing), classify_for_indexing("new.txt", 100, &rows),
FileIndexAction::Insert, FileIndexAction::Insert,
"a path absent from the index is new" "a name absent from the directory's rows is new"
); );
assert_eq!( assert_eq!(
classify_for_indexing("/a/known.txt", 100, &existing), classify_for_indexing("known.txt", 100, &rows),
FileIndexAction::Skip, FileIndexAction::Skip,
"same mtime means nothing to do" "same mtime means nothing to do"
); );
assert_eq!( assert_eq!(
classify_for_indexing("/a/known.txt", 101, &existing), classify_for_indexing("known.txt", 101, &rows),
FileIndexAction::Update, FileIndexAction::Update,
"a changed mtime means re-read" "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] #[test]
fn db_path_strips_windows_prefixes() { fn db_path_strips_windows_prefixes() {
assert_eq!(path_to_db_string(Path::new("/plain/unix/path")), "/plain/unix/path"); assert_eq!(path_to_db_string(Path::new("/plain/unix/path")), "/plain/unix/path");

View file

@ -12,7 +12,6 @@ use crate::file_handling::{
extract_one_batch, extract_one_batch,
extract_scope_prepare, extract_scope_prepare,
fts_finalize_after_text_indexing, fts_finalize_after_text_indexing,
load_existing_files,
process_batch_inserts, process_batch_inserts,
process_batch_updates, process_batch_updates,
path_to_db_string, path_to_db_string,
@ -21,8 +20,11 @@ use crate::file_handling::{
OwnedNewFile, OwnedNewFile,
}; };
use crate::config::Config; 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;
use crate::db::repo;
/// Where one root's pipeline is in its life cycle. /// Where one root's pipeline is in its life cycle.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -91,6 +93,54 @@ pub enum IndexingCommand {
Stop, 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<Mutex<Connection>>,
root: &str,
seen_dirs: &HashSet<String>,
unreadable: &crate::file_handling::UnreadableDirs,
aliased: &HashSet<String>,
out: &mut Vec<String>,
) -> 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<String> = 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)] #[derive(Debug)]
pub struct IndexingService { pub struct IndexingService {
status: Arc<Mutex<IndexingStatus>>, status: Arc<Mutex<IndexingStatus>>,
@ -456,13 +506,10 @@ impl IndexingService {
// the legacy single path did. // the legacy single path did.
Self::update_config(&conn, config, &roots.join("\n"))?; Self::update_config(&conn, config, &roots.join("\n"))?;
// Load existing files from database for incremental indexing // No up-front load of the whole `files` table: each walk's prefetcher
// Shared read-only with the walk threads, which classify against it. // fetches one directory's rows at a time, so classification data is
let existing_files = Arc::new({ // never all resident at once and the walk starts immediately instead
let conn_ref = &conn; // of after a full table scan.
load_existing_files(conn_ref)
.map_err(|e| format!("Failed to load existing files: {}", e))?
});
let conn_mutex = Arc::new(Mutex::new(conn)); let conn_mutex = Arc::new(Mutex::new(conn));
@ -504,7 +551,7 @@ impl IndexingService {
config.indexing.follow_symlinks, config.indexing.follow_symlinks,
config.indexing.include_hidden, config.indexing.include_hidden,
ignore, ignore,
existing_files.clone(), db_path,
config.clone(), config.clone(),
registry.clone(), registry.clone(),
stop_flag.clone(), stop_flag.clone(),
@ -583,7 +630,17 @@ impl IndexingService {
// active roots get even quanta; read-bottlenecked, roots with // active roots get even quanta; read-bottlenecked, roots with
// empty channels are skipped and the firehose roots get the // empty channels are skipped and the firehose roots get the
// writer's full attention. // writer's full attention.
let mut seen_paths: HashSet<String> = 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<u128> = 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<String> = 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<String> = HashSet::new();
let mut aborted = false; let mut aborted = false;
let mut stale_cleanup_ok = true; let mut stale_cleanup_ok = true;
let mut cleanup_done = false; let mut cleanup_done = false;
@ -604,17 +661,33 @@ impl IndexingService {
let mut took = 0usize; let mut took = 0usize;
while took < quantum { while took < quantum {
match p.walk.try_next() { 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; took += 1;
p.walked += 1; p.walked += 1;
if p.walked % 64 == 0 { if p.walked % 64 == 0 {
p.current_file = Some(file.path.clone()); p.current_file = Some(file.path.clone());
} }
// Membership decides what survives stale if file.aliased {
// cleanup: "the walk saw this", never // Its row's parent is a directory this
// "processed successfully". Also dedupes // walk may never visit, so the
// symlinked spellings across roots. // vanished-directory sweep must not
if !seen_paths.insert(file.path.clone()) { // 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; continue;
} }
let Some(rec) = file.record else { continue }; let Some(rec) = file.record else { continue };
@ -734,14 +807,27 @@ impl IndexingService {
cleanup_done = true; cleanup_done = true;
let stopped = *stop_flag.lock().unwrap(); let stopped = *stop_flag.lock().unwrap();
if stale_cleanup_ok && !stopped { if stale_cleanup_ok && !stopped {
let stale_paths: Vec<String> = existing_files // Directories that vanished entirely are never read, so
.keys() // per-directory reconciliation never sees them; only a
.filter(|path| !seen_paths.contains(*path)) // scan of stored parents against the ones the walk
.filter(|path| { // reached can find the rows beneath them.
!pipelines.iter().any(|p| p.walk.unreadable().covers(path)) for p in &pipelines {
}) sweep_unvisited_parents(
.cloned() &conn_mutex,
.collect(); &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<String> = stale_candidates.drain(..).collect();
let unreadable_count: usize = pipelines let unreadable_count: usize = pipelines
.iter() .iter()
.map(|p| p.walk.unreadable().paths().len()) .map(|p| p.walk.unreadable().paths().len())

File diff suppressed because it is too large Load diff

View file

@ -387,6 +387,194 @@ fn two_roots_walk_extract_and_clean_independently() {
std::fs::remove_dir_all(&db_dir).ok(); 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<String> = 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. // Inline extraction: the walk finishes files whose head is the whole file.
// //

View file

@ -169,11 +169,7 @@ impl QuickSearchApp {
/// Save + route an edited config to the running services. /// Save + route an edited config to the running services.
fn apply_new_config(&mut self, ctx: &egui::Context, mut new: Config) { fn apply_new_config(&mut self, ctx: &egui::Context, mut new: Config) {
// The security section is never edited through config drafts — it pin_live_fields(&mut new, &self.cfg);
// 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();
if let Some((child, parent)) = nested_roots(&new.paths.indexing_paths).first() { if let Some((child, parent)) = nested_roots(&new.paths.indexing_paths).first() {
self.config_error = Some(format!( self.config_error = Some(format!(
"Not applied: indexed folder {} is nested under {}", "Not applied: indexed folder {} is nested under {}",
@ -232,6 +228,27 @@ impl QuickSearchApp {
self.cfg = new; 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) { fn drain_events(&mut self) {
// Streamed search results. // Streamed search results.
loop { loop {
@ -333,13 +350,12 @@ impl QuickSearchApp {
.unwrap_or(true); .unwrap_or(true);
if stale { if stale {
let db = self.cfg.resolved_database_path(); let db = self.cfg.resolved_database_path();
let counts = index_counts(&db.to_string_lossy()).unwrap_or( let counts =
IndexCounts { index_counts(&db.to_string_lossy()).unwrap_or(IndexCounts {
files: 0, files: 0,
content_done: 0, content_done: 0,
content_pending: 0, content_pending: 0,
}, });
);
self.counts = Some((Instant::now(), counts)); self.counts = Some((Instant::now(), counts));
} }
let files = self.counts.map(|(_, c)| c.files).unwrap_or(0); 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()); ui.label(egui::RichText::new("Stopping indexing…").small());
} }
IndexingStatus::Running { roots, .. } => { IndexingStatus::Running { roots, .. } => {
let done = roots let done = roots.iter().filter(|r| r.phase == RootPhase::Done).count();
.iter() let processed: usize = roots.iter().map(|r| r.walked + r.extracted).sum();
.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 totals_known = roots.iter().all(|r| r.walk_total.is_some());
let denominator: usize = roots let denominator: usize = roots
.iter() .iter()
@ -375,8 +387,7 @@ impl QuickSearchApp {
.sum(); .sum();
let mut text = if totals_known && denominator > 0 { let mut text = if totals_known && denominator > 0 {
let frac = let frac = (processed as f64 / denominator as f64).min(1.0);
(processed as f64 / denominator as f64).min(1.0);
format!( format!(
"Indexing {} / {} ({:.0}%)", "Indexing {} / {} ({:.0}%)",
group_thousands(processed as u64), group_thousands(processed as u64),
@ -384,10 +395,7 @@ impl QuickSearchApp {
frac * 100.0 frac * 100.0
) )
} else { } else {
format!( format!("Indexing · {} files", group_thousands(processed as u64))
"Indexing · {} files",
group_thousands(processed as u64)
)
}; };
if roots.len() > 1 { if roots.len() > 1 {
text.push_str(&format!(" · {}/{} roots done", done, roots.len())); text.push_str(&format!(" · {}/{} roots done", done, roots.len()));
@ -396,15 +404,13 @@ impl QuickSearchApp {
text.push_str(&format!(" · {}", crate::format::fmt_rate(rate))); text.push_str(&format!(" · {}", crate::format::fmt_rate(rate)));
} }
let active: usize = roots.iter().map(|r| r.active_workers).sum(); let active: usize = roots.iter().map(|r| r.active_workers).sum();
let total_workers: usize = let total_workers: usize = roots.iter().map(|r| r.total_workers).sum();
roots.iter().map(|r| r.total_workers).sum();
if total_workers > 0 { if total_workers > 0 {
text.push_str(&format!(" · {}/{} workers", active, total_workers)); text.push_str(&format!(" · {}/{} workers", active, total_workers));
} }
ui.label(egui::RichText::new(text).small()); ui.label(egui::RichText::new(text).small());
if totals_known && denominator > 0 { if totals_known && denominator > 0 {
let frac = let frac = (processed as f32 / denominator as f32).clamp(0.0, 1.0);
(processed as f32 / denominator as f32).clamp(0.0, 1.0);
ui.add(egui::ProgressBar::new(frac).desired_width(120.0)); ui.add(egui::ProgressBar::new(frac).desired_width(120.0));
} else { } else {
ui.add(egui::Spinner::new().size(12.0)); ui.add(egui::Spinner::new().size(12.0));
@ -424,7 +430,10 @@ impl QuickSearchApp {
}); });
// Keep painting while anything is moving. // 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)); ctx.request_repaint_after(Duration::from_millis(250));
} }
// Watcher registration walks every root, so its verdict can land // 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 // newline-joined — side-by-side columns keep before and
// after readable instead of one run-on arrow line. // after readable instead of one run-on arrow line.
ui.columns(2, |cols| { ui.columns(2, |cols| {
cols[0].label( cols[0].label(egui::RichText::new("index was built with").small().weak());
egui::RichText::new("index was built with").small().weak(),
);
cols[0].monospace(display_value(&change.stored)); cols[0].monospace(display_value(&change.stored));
cols[1].label(egui::RichText::new("config now says").small().weak()); cols[1].label(egui::RichText::new("config now says").small().weak());
cols[1].monospace(display_value(&change.current)); cols[1].monospace(display_value(&change.current));
@ -513,8 +520,7 @@ impl QuickSearchApp {
if remember { if remember {
match db::process_key_hex() { match db::process_key_hex() {
Some(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); self.config_error = Some(e);
return; // preference not saved either return; // preference not saved either
} }
@ -577,12 +583,16 @@ impl QuickSearchApp {
.hint_text("Confirm password") .hint_text("Confirm password")
.desired_width(240.0), .desired_width(240.0),
); );
ui.checkbox(remember, "Remember on this device").on_hover_text( ui.checkbox(remember, "Remember on this device")
"Stores the derived key (not the password) in the OS \ .on_hover_text(
"Stores the derived key (not the password) in the OS \
keychain and skips the startup prompt.", keychain and skips the startup prompt.",
); );
if !pw1.is_empty() && !pw2.is_empty() && pw1 != pw2 { 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| { ui.horizontal(|ui| {
let ok = !pw1.is_empty() && pw1 == pw2; let ok = !pw1.is_empty() && pw1 == pw2;
@ -619,34 +629,32 @@ impl QuickSearchApp {
self.security_prompt = Some(SecurityPrompt::Deriving { rx }); self.security_prompt = Some(SecurityPrompt::Deriving { rx });
} }
} }
SecurityPrompt::Deriving { rx } => { SecurityPrompt::Deriving { rx } => match rx.try_recv() {
match rx.try_recv() { Ok((new_security, key)) => {
Ok((new_security, key)) => { self.security_prompt = Some(SecurityPrompt::ConfirmRebuild {
self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { new_security,
new_security, new_key: Some(key),
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;
}
} }
} 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 { SecurityPrompt::ConfirmRebuild {
new_security, new_security,
new_key, new_key,
@ -701,7 +709,11 @@ impl QuickSearchApp {
/// in that order, before the rebuild so the fresh index is created /// in that order, before the rebuild so the fresh index is created
/// under the new key (or none). /// under the new key (or none).
fn apply_security_change(&mut self, new_security: SecurityConfig, new_key: Option<IndexKey>) { fn apply_security_change(&mut self, new_security: SecurityConfig, new_key: Option<IndexKey>) {
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; self.cfg.security = new_security;
if let Err(e) = self.cfg.save() { if let Err(e) = self.cfg.save() {
self.config_error = Some(e); self.config_error = Some(e);
@ -858,6 +870,11 @@ impl QuickSearchApp {
) )
.clicked() .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.backend.coordinator.clear_index();
self.counts = None; self.counts = None;
self.dups.state = DupState::NotLoaded; 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 /// A stored/current config value for the rebuild prompt; list values are
/// already newline-joined and render as-is, empty means unset. /// already newline-joined and render as-is, empty means unset.
@ -966,10 +995,10 @@ impl eframe::App for QuickSearchApp {
self.backend.coordinator.reindex_now(); self.backend.coordinator.reindex_now();
} }
if actions.stop { if actions.stop {
self.backend.coordinator.set_mode(IndexMode::ManualStopped); self.set_index_mode(false);
} }
if actions.auto { if actions.auto {
self.backend.coordinator.set_mode(IndexMode::Auto); self.set_index_mode(true);
} }
if actions.clear_index { if actions.clear_index {
self.clear_prompt = true; self.clear_prompt = true;
@ -1012,3 +1041,37 @@ impl eframe::App for QuickSearchApp {
self.backend.shutdown(); 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"
);
}
}

View file

@ -49,11 +49,8 @@ impl Backend {
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let db = config.resolved_database_path(); let db = config.resolved_database_path();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = quicksearch_core::search::find_duplicate_groups( let result =
&db.to_string_lossy(), quicksearch_core::search::find_duplicate_groups(&db.to_string_lossy(), 500, 0);
500,
0,
);
let _ = tx.send(result); let _ = tx.send(result);
ctx.request_repaint(); ctx.request_repaint();
}); });

View file

@ -77,15 +77,13 @@ pub fn maybe_run_cli() -> Option<i32> {
return Some(2); return Some(2);
} }
}, },
other if other.starts_with("--limit=") => { other if other.starts_with("--limit=") => match other["--limit=".len()..].parse() {
match other["--limit=".len()..].parse() { Ok(n) => limit = Some(n),
Ok(n) => limit = Some(n), Err(_) => {
Err(_) => { eprintln!("--limit requires a number\n\n{}", USAGE);
eprintln!("--limit requires a number\n\n{}", USAGE); return Some(2);
return Some(2);
}
} }
} },
other if other.starts_with('-') && terms.is_empty() => { other if other.starts_with('-') && terms.is_empty() => {
// Unknown flags without a query fall through to the GUI // Unknown flags without a query fall through to the GUI
// (they may be eframe/winit flags). // (they may be eframe/winit flags).
@ -128,13 +126,15 @@ pub(crate) fn resolve_key(
if let Some(hex) = keychain_hex { if let Some(hex) = keychain_hex {
match IndexKey::from_hex(&hex).map_err(|e| format!("keychain entry: {}", e)) { match IndexKey::from_hex(&hex).map_err(|e| format!("keychain entry: {}", e)) {
Ok(key) => match try_key(key) { Ok(key) => {
Ok(()) => return Ok(()), match try_key(key) {
Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => { Ok(()) => return Ok(()),
eprintln!("warning: the key remembered in the OS keychain no longer opens this index"); 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), Err(e) => eprintln!("warning: {}", e),
} }
} }
@ -474,8 +474,7 @@ mod tests {
#[test] #[test]
fn no_tty_no_sources_is_instructive() { fn no_tty_no_sources_is_instructive() {
let sec = protected(); let sec = protected();
let err = resolve_key(&sec, false, None, None, || None, |_| Ok(())) let err = resolve_key(&sec, false, None, None, || None, |_| Ok(())).unwrap_err();
.unwrap_err();
assert!(err.contains(PASSWORD_ENV)); assert!(err.contains(PASSWORD_ENV));
assert!(err.contains("Remember on this device")); assert!(err.contains("Remember on this device"));
} }

View file

@ -34,7 +34,10 @@ impl DuplicatesTab {
ui.horizontal(|ui| { ui.horizontal(|ui| {
let loading = matches!(self.state, DupState::Loading); 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; actions.refresh = true;
} }
if loading { if loading {
@ -62,48 +65,57 @@ impl DuplicatesTab {
} }
if groups.len() == 500 { if groups.len() == 500 {
ui.label( 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| { let scroll = egui::ScrollArea::vertical()
for (i, group) in groups.iter().enumerate() { .auto_shrink([false; 2])
let name = group .show(ui, |ui| {
.members for (i, group) in groups.iter().enumerate() {
.first() let name = group
.map(|m| m.1.as_str()) .members
.unwrap_or("(unknown)"); .first()
let title = format!( .map(|m| m.1.as_str())
"{} × {}: {} reclaimable ({} total)", .unwrap_or("(unknown)");
group_thousands(group.count as u64), let title = format!(
name, "{} × {}: {} reclaimable ({} total)",
human_size(group.redundant_size.max(0) as u64), group_thousands(group.count as u64),
human_size(group.total_size.max(0) as u64), name,
); human_size(group.redundant_size.max(0) as u64),
egui::CollapsingHeader::new(title).id_salt(i).show(ui, |ui| { human_size(group.total_size.max(0) as u64),
for (_, _, path, size, _) in &group.members { );
ui.horizontal(|ui| { egui::CollapsingHeader::new(title)
ui.label(human_size(*size)); .id_salt(i)
let response = ui .show(ui, |ui| {
.add(egui::Label::new(egui::RichText::new(path).monospace()) for (_, _, path, size, _) in &group.members {
.sense(egui::Sense::click())); ui.horizontal(|ui| {
if response.double_clicked() { ui.label(human_size(*size));
platform::open_file(path); 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); crate::ui_util::more_below_hint(ui, &scroll);
} }
} }

View file

@ -12,7 +12,7 @@ pub fn ui(ui: &mut egui::Ui) {
// would otherwise stretch every paragraph into one long line. // would otherwise stretch every paragraph into one long line.
ui.set_max_width(620.0); 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.add_space(4.0);
ui.label( ui.label(
"QuickSearch keeps an index of the folders you choose and finds \ "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.add_space(12.0);
ui.heading("Getting started"); ui.heading(egui::RichText::new("Getting started").strong());
ui.add_space(4.0); ui.add_space(4.0);
ui.label( ui.label(
"1. The first time QuickSearch runs it starts indexing your home \ "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.add_space(12.0);
ui.heading("Searching"); ui.heading(egui::RichText::new("Searching").strong());
ui.add_space(4.0); ui.add_space(4.0);
ui.label("Plain words match file names, file contents, and paths:"); ui.label("Plain words match file names, file contents, and paths:");
ui.monospace("quarterly budget"); ui.monospace("quarterly budget");
@ -47,9 +47,7 @@ pub fn ui(ui: &mut egui::Ui) {
words:", words:",
); );
ui.monospace("type:Document modified:>=2024-01-01 report"); ui.monospace("type:Document modified:>=2024-01-01 report");
ui.label( ui.label("The ? button next to the search box shows the full query syntax.");
"The ? button next to the search box shows the full query syntax.",
);
ui.add_space(6.0); ui.add_space(6.0);
ui.label( ui.label(
"• Tick Fuzzy to also find matches with typos in them, at some \ "• 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.add_space(12.0);
ui.heading("The other tabs"); ui.heading(egui::RichText::new("The other tabs").strong());
ui.add_space(4.0); ui.add_space(4.0);
egui::Grid::new("help-tabs") egui::Grid::new("help-tabs")
.num_columns(2) .num_columns(2)
@ -86,7 +84,11 @@ pub fn ui(ui: &mut egui::Ui) {
"indexing status and controls, the indexed folder list, \ "indexing status and controls, the indexed folder list, \
and the filters that decide what is skipped", 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( row(
ui, ui,
"Logs", "Logs",
@ -97,7 +99,7 @@ pub fn ui(ui: &mut egui::Ui) {
}); });
ui.add_space(12.0); ui.add_space(12.0);
ui.heading("Terminal"); ui.heading(egui::RichText::new("Terminal").strong());
ui.add_space(4.0); ui.add_space(4.0);
ui.label("QuickSearch also searches straight from a terminal:"); ui.label("QuickSearch also searches straight from a terminal:");
ui.monospace("quicksearch \"quarterly budget\""); ui.monospace("quicksearch \"quarterly budget\"");

View file

@ -155,11 +155,7 @@ impl LogsTab {
for &i in &shown[range] { for &i in &shown[range] {
let line = &self.lines[i]; let line = &self.lines[i];
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( ui.label(egui::RichText::new(fmt_clock(line.at)).monospace().weak());
egui::RichText::new(fmt_clock(line.at))
.monospace()
.weak(),
);
let text = egui::RichText::new(&line.text).monospace(); let text = egui::RichText::new(&line.text).monospace();
match line.level { match line.level {
Level::Warn => { Level::Warn => {

View file

@ -23,8 +23,8 @@ mod platform;
mod query_highlight; mod query_highlight;
mod search_tab; mod search_tab;
mod tracker; mod tracker;
mod unlock;
mod ui_util; mod ui_util;
mod unlock;
use quicksearch_core::config::Config; use quicksearch_core::config::Config;

View file

@ -6,7 +6,6 @@ use quicksearch_core::coordinator::{IndexMode, IndexerState, WatcherStatus};
use quicksearch_core::indexing::{IndexingStatus, RootPhase, RootProgress}; use quicksearch_core::indexing::{IndexingStatus, RootPhase, RootProgress};
use crate::format::{fmt_interval, fmt_rate, group_thousands, middle_truncate}; use crate::format::{fmt_interval, fmt_rate, group_thousands, middle_truncate};
use crate::options::{config_editor_ui, Section};
use crate::tracker::SpeedTracker; use crate::tracker::SpeedTracker;
/// What the tab asks the app to do after this frame. /// What the tab asks the app to do after this frame.
@ -17,7 +16,7 @@ pub struct ManageActions {
pub auto: bool, pub auto: bool,
/// Ask the app to confirm and delete the index. /// Ask the app to confirm and delete the index.
pub clear_index: bool, 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<Config>, pub apply_config: Option<Config>,
} }
@ -34,7 +33,7 @@ pub struct ManageTab {
/// The config the draft was last synced from. `None` forces a full /// The config the draft was last synced from. `None` forces a full
/// resync (first frame, and right after our own Apply). /// resync (first frame, and right after our own Apply).
baseline: Option<Config>, baseline: Option<Config>,
/// Draft of the roots/filters/indexing knobs edited in-place. /// Draft of the roots/filters edited in-place.
draft: Option<Config>, draft: Option<Config>,
} }
@ -118,241 +117,270 @@ impl ManageTab {
let mut actions = ManageActions::default(); let mut actions = ManageActions::default();
self.sync_editors(config); self.sync_editors(config);
let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { let scroll = egui::ScrollArea::vertical()
// --- Status --------------------------------------------------- .auto_shrink([false; 2])
ui.heading("Status"); .show(ui, |ui| {
status_panel(ui, state, &self.speed); // --- Status ---------------------------------------------------
watch_panel(ui, state, config); ui.heading(egui::RichText::new("Status").strong());
ui.add_space(8.0); status_panel(ui, state, &self.speed);
watch_panel(ui, state, config);
ui.add_space(8.0);
// --- Controls ------------------------------------------------- // --- 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<usize> = None;
for (i, root) in draft.paths.indexing_paths.clone().iter().enumerate() {
ui.horizontal(|ui| { ui.horizontal(|ui| {
// Controls claim the right edge first so a long path can let running = !matches!(
// never push them out of view; the path truncates into state.activity,
// whatever width remains (full path on hover). IndexingStatus::Idle | IndexingStatus::Error(_)
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { );
if ui.small_button("Remove").clicked() { if ui
remove = Some(i); .add_enabled(!running, egui::Button::new("Start indexing now"))
} .clicked()
// Per-root walker override; 0 = auto (4 local / 16 {
// network, detected per root). Applies on the next run. actions.start_now = true;
let mut workers = }
draft.indexing.root_workers.get(root).copied().unwrap_or(0); if ui
let response = ui .add_enabled(
.add( running || state.mode == IndexMode::Auto,
egui::DragValue::new(&mut workers) egui::Button::new("Stop"),
.range(0..=64) )
.custom_formatter(|n, _| { .on_hover_text(
if n == 0.0 { "Stop indexing and switch to manual. Saved right away: it \
"auto".to_string() stays manual on the next launch too.",
} else { )
format!("{:.0}", n) .clicked()
} {
}) actions.stop = true;
.custom_parser(|s| { }
let s = s.trim(); if ui
if s.is_empty() || s.eq_ignore_ascii_case("auto") { .add_enabled(
Some(0.0) state.mode != IndexMode::Auto,
} else { egui::Button::new("Return to Automatic"),
s.parse().ok() )
} .on_hover_text(
}), "Watch for changes and reindex periodically again. Also \
) saved, so this is how the app starts from now on.",
.on_hover_text( )
"Walker threads for this folder. auto = 4 on local \ .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<usize> = 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 \ storage, 16 on network mounts. Takes effect on \
the next indexing run.", 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<usize> = 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() { if let Some(i) = remove {
cols[1].label(egui::RichText::new("No ignore patterns.").small().weak()); let removed = draft.paths.indexing_paths.remove(i);
draft.indexing.root_workers.remove(&removed);
} }
if let Some(i) = remove_pat { ui.horizontal(|ui| {
draft.indexing.ignore_patterns.remove(i); if ui.button("Add folder…").clicked() {
} if let Some(dir) = rfd::FileDialog::new().pick_folder() {
cols[1].horizontal(|ui| { let path = dir.to_string_lossy().into_owned();
let (response, valid) = crate::ui_util::pattern_edit( try_add_root(draft, path, &mut self.root_error);
ui, }
&mut self.new_ignore, }
180.0, ui.add(
"*.tmp or node_modules", egui::TextEdit::singleline(&mut self.new_root)
); .desired_width(240.0)
let submitted = .hint_text("or type a path"),
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); );
if ui.add_enabled(valid, egui::Button::new("Add")).clicked() if ui.button("Add").clicked() && !self.new_root.trim().is_empty() {
|| (submitted && valid) let path = self.new_root.trim().to_string();
{ if try_add_root(draft, path, &mut self.root_error) {
let pat = self.new_ignore.trim().to_string(); self.new_root.clear();
if !draft.indexing.ignore_patterns.contains(&pat) {
draft.indexing.ignore_patterns.push(pat);
} }
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<usize> = 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( egui::RichText::new(
"Changes apply on Apply & Save (may trigger a rebuild). \ "Reindex interval, symlinks, hidden files, tokenizer, and size \
Session-only filters are shown and removed on the Search tab.", limits are in Options ( in the toolbar).",
) )
.small() .small()
.weak(), .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); crate::ui_util::more_below_hint(ui, &scroll);
actions actions
@ -490,7 +518,11 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) {
group_thousands(r.walked as u64), group_thousands(r.walked as u64),
workers 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 cfg = Config::default();
let mut tab = synced_tab(&cfg); let mut tab = synced_tab(&cfg);
// Stage an edit, then sync against the unchanged config. // 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); tab.sync_editors(&cfg);
assert!(tab assert!(tab
.draft .draft
@ -587,7 +624,10 @@ mod tests {
tab.sync_editors(&external); tab.sync_editors(&external);
let draft = tab.draft.as_ref().unwrap(); let draft = tab.draft.as_ref().unwrap();
assert!(!draft.indexing.ignore_patterns.contains(&removed)); 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); assert_eq!(tab.baseline.as_ref().unwrap(), &external);
} }
@ -595,21 +635,34 @@ mod tests {
fn dirty_draft_adopts_sections_owned_elsewhere() { fn dirty_draft_adopts_sections_owned_elsewhere() {
let cfg = Config::default(); let cfg = Config::default();
let mut tab = synced_tab(&cfg); 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. // The fuzzy toggle saves the config directly, outside this tab.
let mut external = cfg.clone(); let mut external = cfg.clone();
external.search.fuzzy_default = !cfg.search.fuzzy_default; external.search.fuzzy_default = !cfg.search.fuzzy_default;
tab.sync_editors(&external); tab.sync_editors(&external);
let draft = tab.draft.as_ref().unwrap(); let draft = tab.draft.as_ref().unwrap();
assert_eq!(draft.search.fuzzy_default, external.search.fuzzy_default); 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] #[test]
fn external_pattern_is_not_duplicated_into_a_draft_that_has_it() { fn external_pattern_is_not_duplicated_into_a_draft_that_has_it() {
let cfg = Config::default(); let cfg = Config::default();
let mut tab = synced_tab(&cfg); 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(); let mut external = cfg.clone();
external.indexing.ignore_patterns.push("*.log".into()); external.indexing.ignore_patterns.push("*.log".into());
tab.sync_editors(&external); tab.sync_editors(&external);

View file

@ -2,6 +2,7 @@
//! window and the Manage Index tab. Edits happen on a draft; Apply //! window and the Manage Index tab. Edits happen on a draft; Apply
//! validates, saves, and hands the new config to the app. //! validates, saves, and hands the new config to the app.
use crate::keychain;
use quicksearch_core::config::Config; use quicksearch_core::config::Config;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -35,6 +36,10 @@ pub struct OptionsOutput {
pub struct OptionsWindow { pub struct OptionsWindow {
pub open: bool, pub open: bool,
draft: Option<Config>, draft: Option<Config>,
/// Cached answer from [`OptionsWindow::keychain_active`], with the
/// `use_keychain` preference it was probed under.
keychain_probed_for: Option<bool>,
keychain_active: bool,
} }
impl OptionsWindow { impl OptionsWindow {
@ -42,12 +47,31 @@ impl OptionsWindow {
OptionsWindow { OptionsWindow {
open: false, open: false,
draft: None, draft: None,
keychain_probed_for: None,
keychain_active: false,
} }
} }
pub fn open_with(&mut self, current: &Config) { pub fn open_with(&mut self, current: &Config) {
self.open = true; self.open = true;
self.draft = Some(current.clone()); 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. /// Render; reports an applied draft config and/or a security action.
@ -61,6 +85,7 @@ impl OptionsWindow {
} }
let mut out = OptionsOutput::default(); let mut out = OptionsOutput::default();
let mut open = self.open; let mut open = self.open;
let keychain_active = self.keychain_active(current);
let draft = self.draft.as_mut().unwrap(); let draft = self.draft.as_mut().unwrap();
egui::Window::new("Options") egui::Window::new("Options")
@ -68,8 +93,8 @@ impl OptionsWindow {
.resizable(false) .resizable(false)
.default_width(420.0) .default_width(420.0)
.show(ctx, |ui| { .show(ctx, |ui| {
egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| { let scroll = egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| {
ui.heading("Paths"); ui.heading(egui::RichText::new("Paths").strong());
egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| { egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| {
ui.label("Database file"); ui.label("Database file");
ui.add( ui.add(
@ -87,19 +112,27 @@ impl OptionsWindow {
); );
ui.separator(); ui.separator();
ui.heading("Indexing"); ui.heading(egui::RichText::new("Indexing").strong());
config_editor_ui(ui, draft, Section::Indexing); 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.separator();
ui.heading("Processing"); ui.heading(egui::RichText::new("Processing").strong());
config_editor_ui(ui, draft, Section::Processing); config_editor_ui(ui, draft, Section::Processing);
ui.separator(); ui.separator();
ui.heading("Search"); ui.heading(egui::RichText::new("Search").strong());
config_editor_ui(ui, draft, Section::Search); config_editor_ui(ui, draft, Section::Search);
ui.separator(); ui.separator();
ui.heading("Interface"); ui.heading(egui::RichText::new("Interface").strong());
egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| {
ui.label("UI scale"); ui.label("UI scale");
ui.add( ui.add(
@ -120,9 +153,10 @@ impl OptionsWindow {
// action opens its own confirmation flow immediately. // action opens its own confirmation flow immediately.
// The KDF salt is deliberately never shown here (or // The KDF salt is deliberately never shown here (or
// anywhere else in the GUI). // anywhere else in the GUI).
ui.heading("Security"); ui.heading(egui::RichText::new("Security").strong());
out.security = security_ui(ui, current); out.security = security_ui(ui, current, keychain_active);
}); });
crate::ui_util::more_below_hint(ui, &scroll);
ui.separator(); ui.separator();
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -149,10 +183,21 @@ impl OptionsWindow {
} }
/// The Security block: status plus action buttons. Never renders the salt. /// The Security block: status plus action buttons. Never renders the salt.
fn security_ui(ui: &mut egui::Ui, current: &Config) -> Option<SecurityAction> { fn security_ui(
ui: &mut egui::Ui,
current: &Config,
keychain_active: bool,
) -> Option<SecurityAction> {
let mut action = None; let mut action = None;
if current.security.password_protected { 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| { ui.horizontal(|ui| {
if ui.button("Change password…").clicked() { if ui.button("Change password…").clicked() {
action = Some(SecurityAction::ChangePassword); action = Some(SecurityAction::ChangePassword);
@ -195,10 +240,10 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section
match section { match section {
Section::Indexing => { Section::Indexing => {
egui::Grid::new("cfg-indexing").num_columns(2).show(ui, |ui| { egui::Grid::new("cfg-indexing").num_columns(2).show(ui, |ui| {
ui.label("Automatic indexing"); // Automatic vs manual is deliberately absent: it is live
ui.checkbox(&mut config.indexing.auto_index, "watchers + periodic reindex"); // state, switched (and saved) by the Stop / Return to
ui.end_row(); // Automatic buttons on the Manage Index tab. A staged copy
// of it here would fight those buttons.
ui.label("Full reindex every"); ui.label("Full reindex every");
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.add( ui.add(

View file

@ -81,9 +81,7 @@ pub fn classify(text: &str) -> Vec<Seg> {
let valid = if is_regex { let valid = if is_regex {
let first = !regex_seen; let first = !regex_seen;
regex_seen = true; regex_seen = true;
first first && op == Op::Contains && RegexQuery::new(&value).is_ok()
&& op == Op::Contains
&& RegexQuery::new(&value).is_ok()
} else { } else {
build_filter(word, op, &value, value_is_word).is_ok() build_filter(word, op, &value, value_is_word).is_ok()
}; };
@ -406,7 +404,11 @@ mod tests {
let segs = classify(text); let segs = classify(text);
let mut cursor = 0usize; let mut cursor = 0usize;
for s in &segs { 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); assert!(s.range.end > s.range.start, "empty seg in {:?}", text);
cursor = s.range.end; cursor = s.range.end;
} }
@ -416,7 +418,9 @@ mod tests {
use Class::*; use Class::*;
fn owned(v: Vec<(&str, Class, bool)>) -> Vec<(String, Class, bool)> { 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] #[test]
@ -460,7 +464,9 @@ mod tests {
assert_eq!(all[0].1, Keyword, "{:?}", input); assert_eq!(all[0].1, Keyword, "{:?}", input);
assert_eq!(all[1].1, Operator, "{:?}", input); assert_eq!(all[1].1, Operator, "{:?}", input);
assert!( assert!(
all[2..].iter().all(|(_, c, _)| *c == Argument || *c == Operator), all[2..]
.iter()
.all(|(_, c, _)| *c == Argument || *c == Operator),
"{:?}: {:?}", "{:?}: {:?}",
input, input,
all all
@ -514,10 +520,7 @@ mod tests {
(r"C:\Users\me", Argument, true), (r"C:\Users\me", Argument, true),
]) ])
); );
assert_eq!( assert_eq!(segs(r"C:\data"), owned(vec![(r"C:\data", Plain, false)]));
segs(r"C:\data"),
owned(vec![(r"C:\data", Plain, false)])
);
} }
#[test] #[test]
@ -540,8 +543,9 @@ mod tests {
]) ])
); );
// Quoted stars are literal — content stays plain. // Quoted stars are literal — content stays plain.
assert!(segs("\"a*b\"").iter().all(|(s, c, _)| s == "\"" assert!(segs("\"a*b\"")
|| *c == Plain)); .iter()
.all(|(s, c, _)| s == "\"" || *c == Plain));
} }
#[test] #[test]
@ -640,7 +644,12 @@ mod tests {
fn invalid_arguments_go_error_uniformly() { fn invalid_arguments_go_error_uniformly() {
// (`regex:(` is not here: `(` lexes as a paren, so that input is an // (`regex:(` is not here: `(` lexes as a paren, so that input is an
// *incomplete* filter — bare-key optimism applies, not an error.) // *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); let all = segs(input);
assert_eq!(all[0].1, Keyword, "{:?}", input); assert_eq!(all[0].1, Keyword, "{:?}", input);
let last = all.last().unwrap(); let last = all.last().unwrap();
@ -703,11 +712,9 @@ mod tests {
#[test] #[test]
fn demoted_operators_stay_plain() { fn demoted_operators_stay_plain() {
assert!( assert!(segs("(alpha AND beta) OR gamma")
segs("(alpha AND beta) OR gamma") .iter()
.iter() .all(|(_, c, chip)| *c == Plain && !chip));
.all(|(_, c, chip)| *c == Plain && !chip)
);
// Dangling comparators are literal text. // Dangling comparators are literal text.
assert!(segs("a > b").iter().all(|(_, c, _)| *c == Plain)); assert!(segs("a > b").iter().all(|(_, c, _)| *c == Plain));
// Leading operator, nothing else. // Leading operator, nothing else.

View file

@ -204,7 +204,10 @@ impl SearchTab {
self.order.sort_by(|&a, &b| { self.order.sort_by(|&a, &b| {
let (a, b) = (&results[a as usize], &results[b as usize]); let (a, b) = (&results[a as usize], &results[b as usize]);
let ord = match key { 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::Name => a.name.cmp(&b.name),
SortKey::Path => a.path.cmp(&b.path), SortKey::Path => a.path.cmp(&b.path),
SortKey::Size => a.size.cmp(&b.size), 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) { fn sort_header(&mut self, ui: &mut egui::Ui, key: SortKey, label: &str) {
let (cur, asc) = self.sort; let (cur, asc) = self.sort;
let selected = cur == key; let selected = cur == key;
let (rect, response) = let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click());
ui.allocate_exact_size(ui.available_size(), egui::Sense::click());
if ui.is_rect_visible(rect) { if ui.is_rect_visible(rect) {
if response.hovered() { if response.hovered() {
ui.painter() ui.painter()
@ -344,7 +346,7 @@ impl SearchTab {
let mut remove: Option<usize> = None; let mut remove: Option<usize> = None;
for (i, pattern) in self.session_ignores.iter().enumerate() { for (i, pattern) in self.session_ignores.iter().enumerate() {
if ui if ui
.small_button(format!("{} ", pattern)) .small_button(format!("{} 🗙", pattern))
.on_hover_text("Remove this session filter") .on_hover_text("Remove this session filter")
.clicked() .clicked()
{ {
@ -385,11 +387,9 @@ impl SearchTab {
// `animate_value_with_time` keeps requesting repaints until the // `animate_value_with_time` keeps requesting repaints until the
// value settles. // value settles.
let fade_target = if self.swap_pending { 0.0 } else { 1.0 }; let fade_target = if self.swap_pending { 0.0 } else { 1.0 };
let fade = ui.ctx().animate_value_with_time( let fade =
egui::Id::new("qs-results-fade"), ui.ctx()
fade_target, .animate_value_with_time(egui::Id::new("qs-results-fade"), fade_target, 0.25);
0.25,
);
if self.swap_pending && fade <= 0.01 { if self.swap_pending && fade <= 0.01 {
self.results = std::mem::take(&mut self.staging); self.results = std::mem::take(&mut self.staging);
self.has_snippets = self.staging_has_snippets; self.has_snippets = self.staging_has_snippets;
@ -426,170 +426,172 @@ impl SearchTab {
let mut open_ignore_dialog: Option<usize> = None; let mut open_ignore_dialog: Option<usize> = None;
let mut hovered_now: Option<usize> = None; let mut hovered_now: Option<usize> = None;
let table_scroll = ui.push_id("results", |ui| { let table_scroll = ui
let mut table = TableBuilder::new(ui) .push_id("results", |ui| {
.striped(true) let mut table = TableBuilder::new(ui)
.resizable(true) .striped(true)
.sense(egui::Sense::click()) .resizable(true)
.max_scroll_height(table_height) .sense(egui::Sense::click())
.min_scrolled_height(60.0) .max_scroll_height(table_height)
.column(Column::initial(220.0).at_least(80.0).clip(true)) // name .min_scrolled_height(60.0)
.column(Column::remainder().at_least(120.0).clip(true)); // path .column(Column::initial(220.0).at_least(80.0).clip(true)) // name
if self.has_snippets { .column(Column::remainder().at_least(120.0).clip(true)); // path
table = table.column(Column::remainder().at_least(120.0).clip(true)); if self.has_snippets {
} table = table.column(Column::remainder().at_least(120.0).clip(true));
table = table }
.column(Column::exact(72.0)) // size table = table
.column(Column::exact(110.0)) // modified .column(Column::exact(72.0)) // size
.column(Column::exact(52.0)); // rank .column(Column::exact(110.0)) // modified
.column(Column::exact(52.0)); // rank
table table
.header(text_height + 4.0, |mut header| { .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::Name, "Name"));
header.col(|ui| self.sort_header(ui, SortKey::Path, "Path")); 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<egui::Response> = 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 { if self.has_snippets {
let snippet = hit.snippet.clone(); header.col(|ui| {
// Name and path matches show a whole field, so ui.with_layout(
// they render bracketed: [matched field]. egui::Layout::centered_and_justified(
let whole_field = egui::Direction::LeftToRight,
hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9; ),
row.col(|ui| { |ui| {
if let Some(snip) = &snippet { ui.label(egui::RichText::new("Match").strong());
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);
}
}); });
} }
row.col(|ui| { header.col(|ui| self.sort_header(ui, SortKey::Size, "Size"));
let response = ui.with_layout( header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified"));
egui::Layout::centered_and_justified( header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank"));
egui::Direction::LeftToRight, })
), .body(|body| {
|ui| ui.label(human_size(hit.size)), let order = self.order.clone();
); body.rows(text_height, order.len(), |mut row| {
cell_responses.push(response.inner); let display_ix = row.index();
}); let result_ix = order[display_ix] as usize;
row.col(|ui| { let hit = &self.results[result_ix];
let color = recency_color(ui, hit.mtime); row.set_selected(self.selected == Some(result_ix as u32));
let response = ui.with_layout( row.set_hovered(self.hovered_row == Some(display_ix));
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(); // Labels stay selectable for copy-paste, which makes
for r in cell_responses { // them win egui's hit-test over the row. Collect their
response = response | r; // responses and union them into the row's below so
} // clicks land even when the pointer is over glyphs.
if response.contains_pointer() { let mut cell_responses: Vec<egui::Response> = Vec::new();
hovered_now = Some(display_ix);
} row.col(|ui| {
if response.clicked() || response.secondary_clicked() { cell_responses.push(ui.label(&hit.name));
self.selected = Some(result_ix as u32); });
} row.col(|ui| {
if response.double_clicked() { cell_responses
platform::open_file(&self.results[result_ix].path); .push(ui.label(egui::RichText::new(&hit.path).weak()));
} });
response.context_menu(|ui| { if self.has_snippets {
let path = self.results[result_ix].path.clone(); let snippet = hit.snippet.clone();
if ui.button("Open containing folder").clicked() { // Name and path matches show a whole field, so
platform::reveal_in_folder(&path); // they render bracketed: [matched field].
ui.close(); 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() { row.col(|ui| {
platform::open_file(&path); let response = ui.with_layout(
ui.close(); 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() { if response.contains_pointer() {
ui.ctx().copy_text(path.clone()); hovered_now = Some(display_ix);
ui.close();
} }
ui.separator(); if response.clicked() || response.secondary_clicked() {
if ui.button("Build ignore filter…").clicked() { self.selected = Some(result_ix as u32);
open_ignore_dialog = Some(result_ix);
ui.close();
} }
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); crate::ui_util::more_below_hint(ui, &table_scroll);
self.hovered_row = hovered_now; self.hovered_row = hovered_now;
@ -642,25 +644,20 @@ impl SearchTab {
ui.separator(); ui.separator();
// --- Extension --------------------------------------------- // --- Extension ---------------------------------------------
ui.horizontal(|ui| { ui.horizontal(|ui| match &dialog.ext_pattern {
match &dialog.ext_pattern { Some(ext) => {
Some(ext) => { ui.monospace(ext);
ui.monospace(ext); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.with_layout( if ui
egui::Layout::right_to_left(egui::Align::Center), .add(bordered_button("Ignore this extension", ORANGE))
|ui| { .clicked()
if ui {
.add(bordered_button("Ignore this extension", ORANGE)) chosen = Some(ext.clone());
.clicked() }
{ });
chosen = Some(ext.clone()); }
} None => {
}, ui.label(egui::RichText::new("(no file extension)").weak());
);
}
None => {
ui.label(egui::RichText::new("(no file extension)").weak());
}
} }
}); });
ui.separator(); ui.separator();
@ -771,8 +768,7 @@ impl SearchTab {
ui, ui,
"regex:\"(foo|bar)\\d+\"", "regex:\"(foo|bar)\\d+\"",
"regular expression, matched against names, contents, \ "regular expression, matched against names, contents, \
and paths; case-insensitive use (?-i:) to override; \ and paths",
quote patterns containing spaces",
); );
row( row(
ui, ui,
@ -984,17 +980,17 @@ fn centered_match_job(
/// tiers. Dark text on these pastels stays readable in both themes. /// tiers. Dark text on these pastels stays readable in both themes.
fn rank_tier_color(stage: u8) -> egui::Color32 { fn rank_tier_color(stage: u8) -> egui::Color32 {
match stage { match stage {
1 => egui::Color32::from_rgb(255, 127, 127), // S 1 => egui::Color32::from_rgb(255, 127, 127), // S
2 => egui::Color32::from_rgb(255, 191, 127), // A 2 => egui::Color32::from_rgb(255, 191, 127), // A
3 => egui::Color32::from_rgb(255, 223, 127), // B 3 => egui::Color32::from_rgb(255, 223, 127), // B
4 => egui::Color32::from_rgb(255, 255, 127), // C 4 => egui::Color32::from_rgb(255, 255, 127), // C
5 => egui::Color32::from_rgb(191, 255, 127), // D 5 => egui::Color32::from_rgb(191, 255, 127), // D
6 => egui::Color32::from_rgb(127, 255, 127), // E 6 => egui::Color32::from_rgb(127, 255, 127), // E
7 => egui::Color32::from_rgb(127, 191, 255), // F 7 => egui::Color32::from_rgb(127, 191, 255), // F
8 => egui::Color32::from_rgb(191, 127, 255), // G 8 => egui::Color32::from_rgb(191, 127, 255), // G
9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case 9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case
10 => egui::Color32::from_rgb(239, 191, 239), // I — path, any 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
} }
} }

View file

@ -42,9 +42,7 @@ impl SpeedTracker {
self.points.push((now, files_processed)); self.points.push((now, files_processed));
// Prune old points, but always keep at least two so a slow but // Prune old points, but always keep at least two so a slow but
// steady rate never becomes unmeasurable. // steady rate never becomes unmeasurable.
while self.points.len() > 2 while self.points.len() > 2 && now.duration_since(self.points[0].0) > HISTORY {
&& now.duration_since(self.points[0].0) > HISTORY
{
self.points.remove(0); self.points.remove(0);
} }
} }
@ -90,7 +88,9 @@ mod tests {
for i in 0..4 { for i in 0..4 {
t.record_at(base + Duration::from_millis(2500 * i), 10 + i as usize); 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); 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 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(); let stalled = t.files_per_sec_at(base + Duration::from_secs(20)).unwrap();
assert!(just_after > 90.0); 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] #[test]

View file

@ -28,9 +28,23 @@ pub fn ignore_pattern_valid(pattern: &str) -> bool {
!trimmed.is_empty() && IgnoreSet::compile(&[pattern.to_string()]).is_ok() !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<egui::Color32> {
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 /// 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 /// a valid pattern, a red one while it is not, and the theme's neutral
/// validity of the text as it stands after this frame's edits. /// 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( pub fn pattern_edit(
ui: &mut egui::Ui, ui: &mut egui::Ui,
text: &mut String, text: &mut String,
@ -38,16 +52,19 @@ pub fn pattern_edit(
hint: &str, hint: &str,
) -> (egui::Response, bool) { ) -> (egui::Response, bool) {
let mut valid = ignore_pattern_valid(text); 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 let response = ui
.scope(|ui| { .scope(|ui| {
// TextEdit frames with widgets.*.bg_stroke when unfocused and // TextEdit frames with widgets.*.bg_stroke when unfocused and
// selection.stroke when focused; recolor all of them. // selection.stroke when focused; recolor all of them.
let v = ui.visuals_mut(); if let Some(color) = border {
v.widgets.inactive.bg_stroke = stroke; let stroke = egui::Stroke::new(1.0, color);
v.widgets.hovered.bg_stroke = stroke; let v = ui.visuals_mut();
v.widgets.active.bg_stroke = stroke; v.widgets.inactive.bg_stroke = stroke;
v.selection.stroke = stroke; v.widgets.hovered.bg_stroke = stroke;
v.widgets.active.bg_stroke = stroke;
v.selection.stroke = stroke;
}
ui.add( ui.add(
egui::TextEdit::singleline(text) egui::TextEdit::singleline(text)
.desired_width(desired_width) .desired_width(desired_width)
@ -63,18 +80,20 @@ pub fn pattern_edit(
} }
/// Paint a semitransparent down-arrow near the bottom edge of a scroll /// Paint a semitransparent down-arrow near the bottom edge of a scroll
/// area while more content lies below the fold. Painter-only on the /// area while more content lies below the fold. Painter-only, so it can
/// foreground layer, so it can never swallow clicks. (The bundled fonts /// never swallow clicks. (The bundled fonts have no ▼ glyph — this is a
/// have no ▼ glyph — this is a shape, like the sort-header triangles.) /// 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<R>(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutput<R>) { pub fn more_below_hint<R>(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutput<R>) {
let more_below = out.state.offset.y + out.inner_rect.height() < out.content_size.y - 1.0; let more_below = out.state.offset.y + out.inner_rect.height() < out.content_size.y - 1.0;
if !more_below { if !more_below {
return; return;
} }
let painter = ui.ctx().layer_painter(egui::LayerId::new( let painter = ui.ctx().layer_painter(ui.layer_id());
egui::Order::Foreground,
egui::Id::new("qs-more-below-hint"),
));
let cx = out.inner_rect.center().x; let cx = out.inner_rect.center().x;
let tip = out.inner_rect.bottom() - 5.0; let tip = out.inner_rect.bottom() - 5.0;
let (half_width, height) = (7.0, 6.0); let (half_width, height) = (7.0, 6.0);
@ -99,7 +118,7 @@ pub fn more_below_hint<R>(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutp
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ignore_pattern_valid; use super::{ignore_pattern_valid, pattern_border, INVALID_RED, VALID_GREEN};
#[test] #[test]
fn blank_patterns_are_invalid() { fn blank_patterns_are_invalid() {
@ -126,4 +145,22 @@ mod tests {
assert!(ignore_pattern_valid("C:\\Windows\\Temp\\*")); assert!(ignore_pattern_valid("C:\\Windows\\Temp\\*"));
assert!(ignore_pattern_valid("cache-??")); // wildcards 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));
}
} }