Improved config menus, rewrote directory walk to reduce memory use, GUI improvements.
This commit is contained in:
parent
e63706b4b5
commit
b85c99e277
29 changed files with 2001 additions and 920 deletions
106
Cargo.lock
generated
106
Cargo.lock
generated
|
|
@ -140,7 +140,7 @@ dependencies = [
|
|||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.12",
|
||||
"password-hash 0.5.0",
|
||||
"password-hash",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
|
@ -499,26 +499,6 @@ version = "1.6.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.13.0"
|
||||
|
|
@ -706,12 +686,6 @@ dependencies = [
|
|||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
|
|
@ -1706,15 +1680,6 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "home"
|
||||
version = "0.5.9"
|
||||
|
|
@ -2955,17 +2920,6 @@ dependencies = [
|
|||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
|
|
@ -2983,18 +2937,6 @@ version = "1.0.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
"password-hash 0.4.2",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdf-extract"
|
||||
version = "0.12.0"
|
||||
|
|
@ -3237,7 +3179,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "quicksearch-core"
|
||||
version = "0.8.0"
|
||||
version = "0.8.5"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"ctrlc",
|
||||
|
|
@ -3260,12 +3202,12 @@ dependencies = [
|
|||
"windows-sys 0.52.0",
|
||||
"zeroize",
|
||||
"zip",
|
||||
"zstd 0.13.3",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quicksearch-gui"
|
||||
version = "0.8.0"
|
||||
version = "0.8.5"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"eframe",
|
||||
|
|
@ -3669,17 +3611,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.12",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.8"
|
||||
|
|
@ -5373,27 +5304,10 @@ version = "0.6.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"byteorder",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"hmac",
|
||||
"pbkdf2",
|
||||
"sha1",
|
||||
"time",
|
||||
"zstd 0.11.2+zstd.1.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.11.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4"
|
||||
dependencies = [
|
||||
"zstd-safe 5.0.2+zstd.1.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5402,17 +5316,7 @@ version = "0.13.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe 7.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "5.0.2+zstd.1.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"zstd-sys",
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ members = [
|
|||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.5"
|
||||
version = "0.8.6"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
authors = ["Jeremy <jeremy@karsttech.com>"]
|
||||
|
|
|
|||
|
|
@ -118,7 +118,9 @@ the package is installed.
|
|||
copied in place. Matches in file contents show highlighted snippets.
|
||||
- **Manage Index**: full indexing status, Start/Stop/Automatic controls,
|
||||
indexed folder list, full-text extension filters, ignore patterns, and
|
||||
the indexing options.
|
||||
the indexing options. Stopping switches to manual mode and saves that
|
||||
(`indexing.auto_index`), so a stopped index stays stopped across
|
||||
restarts until you return to automatic.
|
||||
- **Duplicates**: files sharing a content hash, grouped.
|
||||
- **Logs**: the lines the app would have printed to a terminal — warnings
|
||||
from indexing, folder watching and opening files, newest last, with a
|
||||
|
|
@ -286,7 +288,9 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
|
|||
a polled `IndexingStatus`.
|
||||
- **Coordinator** (`coordinator.rs`): the object binaries construct.
|
||||
Owns the `IndexingService`, the debouncing filesystem watcher
|
||||
(`watcher.rs`), and the mode state machine (Auto / Manual). Watcher
|
||||
(`watcher.rs`), and the mode state machine (Auto / Manual, persisted as
|
||||
`indexing.auto_index` — the mode the app is left in is the mode it
|
||||
starts in, and a config carrying a different value switches it). Watcher
|
||||
events become single-file transactions (`incremental.rs`) that keep
|
||||
`files`, FTS, and the text sidecar consistent per commit; a full
|
||||
reindex runs on a configurable interval. Incremental writes defer while
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ indexing_paths = ["~"]
|
|||
database_path = "~/.local/share/quicksearch/index.sqlite"
|
||||
|
||||
[indexing]
|
||||
# Automatic mode: filesystem watchers apply changes as they happen and a
|
||||
# full reindex runs every reindex_interval_minutes. The watcher catches
|
||||
# changes as they happen, so this only needs to be often enough to cover
|
||||
# whatever the watcher missed.
|
||||
# The indexing mode. true = automatic: filesystem watchers apply changes as
|
||||
# they happen and a full reindex runs every reindex_interval_minutes (the
|
||||
# watcher catches changes as they happen, so that interval only needs to be
|
||||
# often enough to cover whatever the watcher missed). false = manual:
|
||||
# nothing is indexed until you ask for it. The Stop and Return to Automatic
|
||||
# buttons on the Manage Index tab write this value, so the mode you left the
|
||||
# app in is the mode it starts in.
|
||||
auto_index = true
|
||||
reindex_interval_minutes = 1440
|
||||
# Follow symbolic links during directory walks.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ zeroize = { version = "1", features = ["derive"] }
|
|||
getrandom = "0.2"
|
||||
sha2 = "0.10.8"
|
||||
walkdir = "2.5.0"
|
||||
zip = "0.6"
|
||||
# Default features pull in zstd 0.11, which cannot unify with our zstd 0.13 below
|
||||
# and so builds a second copy of the wrapper crates. We only read OOXML/ODF
|
||||
# containers (docx/xlsx/pptx/odt/ods/odp), whose entries are always deflate or
|
||||
# stored, so zstd/bzip2/aes-crypto are all dead weight here.
|
||||
zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.31"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
|
|
|
|||
|
|
@ -14,9 +14,14 @@
|
|||
//! ```
|
||||
//!
|
||||
//! The optional trailing number is the sampling interval in milliseconds
|
||||
//! (default 100). Drop it to single digits to name the file a spike happened
|
||||
//! on: at 100 ms the extractor has moved on by the time RSS is read, so the
|
||||
//! file the timeline shows beside a spike is only approximately the cause.
|
||||
//! (default 100). Finer sampling resolves the *shape* of a spike, not its
|
||||
//! cause: the file column is only as good as `RootProgress::current_file`,
|
||||
//! which [`crate::indexing`] publishes once per extraction batch, holding the
|
||||
//! last file of the batch that just finished. During a batch it therefore
|
||||
//! names a file that is already done, and no sampling rate fixes that. To
|
||||
//! attribute a spike to a file, narrow the root instead — index a directory
|
||||
//! holding only the candidates, which is what identified `pdf-extract` as the
|
||||
//! largest single consumer on this tree.
|
||||
//!
|
||||
//! `cold` deletes the database first: every file is new, so the walk hashes
|
||||
//! and extracts all of them and `existing_files` starts empty. `warm` re-runs
|
||||
|
|
@ -33,11 +38,13 @@
|
|||
//! peak to the walk or to extraction; a sampled peak far under VmHWM means
|
||||
//! the real spike was shorter than the sampling interval.
|
||||
//!
|
||||
//! RSS counts the page cache backing the mmap'd database, so the figure is a
|
||||
//! ceiling on what the process needs, not a floor on what it must have: those
|
||||
//! pages are evictable under pressure. `/usr/bin/time -v` on this binary
|
||||
//! reports the same VmHWM, as a cross-check that nothing here is fooling
|
||||
//! itself.
|
||||
//! Nothing here is evictable page cache. The connection sets no `mmap_size`,
|
||||
//! so SQLite reads the index through its own `malloc`'d page cache (`PRAGMA
|
||||
//! cache_size`, 10000 pages ≈ 40 MiB) rather than mapping the file, and the
|
||||
//! `by mapping` breakdown confirms it: the index never appears as a
|
||||
//! file-backed mapping. Every megabyte reported is memory the process
|
||||
//! actually holds. `/usr/bin/time -v` on this binary reports the same VmHWM,
|
||||
//! as a cross-check that nothing here is fooling itself.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@
|
|||
//! Both modes report files/sec. Run each twice: the first pass warms the page
|
||||
//! cache (or, on a share, the client's attribute cache), so the second is the
|
||||
//! one to compare.
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Instant, UNIX_EPOCH};
|
||||
|
|
@ -38,22 +37,30 @@ use std::time::{Instant, UNIX_EPOCH};
|
|||
use quicksearch_core::config::{Config, IgnoreSet};
|
||||
use quicksearch_core::extract::Registry;
|
||||
use quicksearch_core::file_handling::{
|
||||
classify_for_indexing, filtered_walk, prepare_file_record, ExistingFileEntry, FileIndexAction,
|
||||
classify_for_indexing, filtered_walk, prepare_file_record, DirRows, FileIndexAction,
|
||||
UnreadableDirs,
|
||||
};
|
||||
use quicksearch_core::walk::walk_indexable_files;
|
||||
use quicksearch_core::walk::{walk_indexable_files, WalkEvent};
|
||||
|
||||
fn main() {
|
||||
let root = std::env::args().nth(1).unwrap();
|
||||
let mode = std::env::args().nth(2).unwrap_or_else(|| "parallel".into());
|
||||
let config = Config::default();
|
||||
let existing: HashMap<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 (seen, prepared) = match mode.as_str() {
|
||||
"serial" => serial(&root, &config, &existing),
|
||||
_ => parallel(&root, &config, existing),
|
||||
_ => parallel(&root, &config, db.to_str().unwrap()),
|
||||
};
|
||||
let _ = std::fs::remove_file(&db);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
eprintln!(
|
||||
|
|
@ -63,11 +70,7 @@ fn main() {
|
|||
);
|
||||
}
|
||||
|
||||
fn serial(
|
||||
root: &str,
|
||||
config: &Config,
|
||||
existing: &HashMap<String, ExistingFileEntry>,
|
||||
) -> (usize, usize) {
|
||||
fn serial(root: &str, config: &Config, existing: &DirRows) -> (usize, usize) {
|
||||
let ignore = IgnoreSet::compile(&[]).unwrap();
|
||||
let registry = Registry::default_set();
|
||||
let (mut seen, mut prepared) = (0, 0);
|
||||
|
|
@ -90,7 +93,13 @@ fn serial(
|
|||
else {
|
||||
continue;
|
||||
};
|
||||
if classify_for_indexing(&path, mtime, existing) != FileIndexAction::Skip
|
||||
// Keyed by name within its directory, as the real walk now is; with
|
||||
// an empty index the answer is Insert either way.
|
||||
let name = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if classify_for_indexing(&name, mtime, existing) != FileIndexAction::Skip
|
||||
&& prepare_file_record(&path, &meta, config, ®istry).is_some()
|
||||
{
|
||||
prepared += 1;
|
||||
|
|
@ -99,24 +108,21 @@ fn serial(
|
|||
(seen, prepared)
|
||||
}
|
||||
|
||||
fn parallel(
|
||||
root: &str,
|
||||
config: &Config,
|
||||
existing: HashMap<String, ExistingFileEntry>,
|
||||
) -> (usize, usize) {
|
||||
fn parallel(root: &str, config: &Config, db_path: &str) -> (usize, usize) {
|
||||
let (mut seen, mut prepared) = (0, 0);
|
||||
for file in walk_indexable_files(
|
||||
for event in walk_indexable_files(
|
||||
&[root.to_string()],
|
||||
false,
|
||||
false,
|
||||
IgnoreSet::compile(&[]).unwrap(),
|
||||
Arc::new(existing),
|
||||
db_path,
|
||||
config.clone(),
|
||||
Arc::new(Registry::default_set()),
|
||||
Arc::new(Mutex::new(false)),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
4,
|
||||
) {
|
||||
let WalkEvent::File(file) = event else { continue };
|
||||
seen += 1;
|
||||
if file.record.is_some() {
|
||||
prepared += 1;
|
||||
|
|
|
|||
|
|
@ -48,8 +48,11 @@ pub struct PathConfig {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(default)]
|
||||
pub struct IndexingConfig {
|
||||
/// Start in automatic mode: filesystem watchers apply changes as they
|
||||
/// happen and a full reindex runs every `reindex_interval_minutes`.
|
||||
/// The indexing mode, written down: `true` is automatic — filesystem
|
||||
/// watchers apply changes as they happen and a full reindex runs every
|
||||
/// `reindex_interval_minutes` — and `false` is manual, where nothing
|
||||
/// runs until the user asks. The GUI's Stop / Return to Automatic
|
||||
/// controls save it as they switch, so the mode survives a restart.
|
||||
pub auto_index: bool,
|
||||
pub reindex_interval_minutes: u64,
|
||||
pub follow_symlinks: bool,
|
||||
|
|
@ -292,7 +295,7 @@ impl Default for Config {
|
|||
/// to catch it would also catch a user folder named `Windows`.
|
||||
/// `config_example.toml` documents it for people who add a drive root.
|
||||
fn default_ignore_patterns() -> Vec<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) {
|
||||
patterns.extend([
|
||||
"$RECYCLE.BIN",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@
|
|||
//! - **ManualRunning** — one user-forced full run; returns to
|
||||
//! `ManualStopped` when it finishes. (A forced run in Auto stays Auto.)
|
||||
//!
|
||||
//! `indexing.auto_index` is the persisted form of that mode: it picks the
|
||||
//! starting mode here, and every mode change keeps the coordinator's copy
|
||||
//! of the config in step with it. Writing the file back is the caller's
|
||||
//! job — the coordinator's config is a copy, not the source of truth.
|
||||
//!
|
||||
//! Single-writer guarantee: incremental writes are deferred while a full
|
||||
//! run is active — the coordinator's tick simply does nothing until the
|
||||
//! `IndexingService` reports idle, then drains its queue. Overflowing the
|
||||
|
|
@ -307,15 +312,22 @@ impl Inner {
|
|||
}
|
||||
}
|
||||
CoordCmd::ConfigChanged(new) => {
|
||||
let want_auto = new.indexing.auto_index;
|
||||
self.config = new;
|
||||
if let Err(e) = self.reload_filters() {
|
||||
crate::log_warn!("coordinator: {}", e);
|
||||
}
|
||||
// The write connection may point at an old database_path.
|
||||
self.write_conn = None;
|
||||
// Watched roots / symlink behavior may have changed; a
|
||||
// restart is cheap and unconditional beats a diff here.
|
||||
if self.mode == IndexMode::Auto {
|
||||
if want_auto && self.mode != IndexMode::Auto {
|
||||
// The mode lives in `auto_index`, so a config that
|
||||
// disagrees with the running mode *is* a mode change.
|
||||
self.enter_auto();
|
||||
} else if !want_auto && self.mode == IndexMode::Auto {
|
||||
self.enter_manual_stopped();
|
||||
} else if self.mode == IndexMode::Auto {
|
||||
// Watched roots / symlink behavior may have changed; a
|
||||
// restart is cheap and unconditional beats a diff here.
|
||||
self.start_watcher();
|
||||
}
|
||||
}
|
||||
|
|
@ -491,6 +503,9 @@ impl Inner {
|
|||
|
||||
fn enter_auto(&mut self) {
|
||||
self.mode = IndexMode::Auto;
|
||||
// Keep the config copy honest: `auto_index` is this mode written
|
||||
// down, and the caller persists it from its own copy.
|
||||
self.config.indexing.auto_index = true;
|
||||
self.start_watcher();
|
||||
if self.shared.lock().unwrap().last_full_index.is_none() {
|
||||
self.needs_full_run = true;
|
||||
|
|
@ -499,6 +514,7 @@ impl Inner {
|
|||
|
||||
fn enter_manual_stopped(&mut self) {
|
||||
self.mode = IndexMode::ManualStopped;
|
||||
self.config.indexing.auto_index = false;
|
||||
self.stop_watcher();
|
||||
self.pending.clear();
|
||||
let status = self.indexing.get_status();
|
||||
|
|
@ -899,6 +915,38 @@ mod tests {
|
|||
coord.shutdown();
|
||||
}
|
||||
|
||||
/// `auto_index` is the mode written down, so a config whose value
|
||||
/// disagrees with the running mode switches it. That is what lets the
|
||||
/// GUI persist a Stop click, and what makes a hand-edited config take
|
||||
/// effect without a restart.
|
||||
#[test]
|
||||
fn applying_a_config_switches_the_mode_to_match_auto_index() {
|
||||
let f = Fixture::new(true);
|
||||
let coord =
|
||||
IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap();
|
||||
wait_for("watcher active", Duration::from_secs(20), || {
|
||||
matches!(coord.state().watcher, WatcherStatus::Active { .. })
|
||||
});
|
||||
|
||||
let mut manual = f.config.clone();
|
||||
manual.indexing.auto_index = false;
|
||||
coord.apply_config(manual.clone());
|
||||
wait_for("manual mode", Duration::from_secs(10), || {
|
||||
let s = coord.state();
|
||||
s.mode == IndexMode::ManualStopped && s.watcher == WatcherStatus::Off
|
||||
});
|
||||
|
||||
let mut auto = manual.clone();
|
||||
auto.indexing.auto_index = true;
|
||||
coord.apply_config(auto);
|
||||
wait_for("automatic mode", Duration::from_secs(20), || {
|
||||
let s = coord.state();
|
||||
s.mode == IndexMode::Auto && matches!(s.watcher, WatcherStatus::Active { .. })
|
||||
});
|
||||
|
||||
coord.shutdown();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_config_with_new_root_then_reindex_indexes_it() {
|
||||
// The reported failure: add directories, apply, click "Start
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ use std::path::Path;
|
|||
use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
|
||||
|
||||
use super::schema::{
|
||||
effective_tokenizer, fts_create_sql, PRAGMAS_FAST, PRAGMAS_READONLY, SCHEMA_CURRENT,
|
||||
effective_tokenizer, fts_create_sql, PRAGMAS_FAST, PRAGMAS_READONLY, PRAGMAS_WALK_READER,
|
||||
SCHEMA_CURRENT,
|
||||
};
|
||||
use crate::security::IndexKey;
|
||||
|
||||
|
|
@ -99,10 +100,34 @@ pub fn open_existing(db_path: &str, write: bool) -> Result<Connection, String> {
|
|||
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(
|
||||
db_path: &str,
|
||||
write: bool,
|
||||
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> {
|
||||
let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX
|
||||
| if write {
|
||||
|
|
@ -113,7 +138,6 @@ pub(crate) fn open_existing_keyed(
|
|||
let conn = Connection::open_with_flags(db_path, flags)
|
||||
.map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?;
|
||||
key_and_probe(&conn, db_path, key)?;
|
||||
let pragmas = if write { PRAGMAS_FAST } else { PRAGMAS_READONLY };
|
||||
conn.execute_batch(pragmas)
|
||||
.map_err(|e| format!("Failed to apply pragmas: {}", e))?;
|
||||
|
||||
|
|
|
|||
|
|
@ -242,6 +242,87 @@ pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, Str
|
|||
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
|
||||
/// a given file id. Does not touch the `files` row itself. Idempotent — a
|
||||
/// missing row is fine.
|
||||
|
|
|
|||
|
|
@ -32,6 +32,25 @@ pub const PRAGMAS_READONLY: &str = "
|
|||
PRAGMA foreign_keys = ON;
|
||||
";
|
||||
|
||||
/// Pragmas for a walk's row-prefetch connection.
|
||||
///
|
||||
/// Identical to [`PRAGMAS_READONLY`] but for `cache_size`, and that one
|
||||
/// difference is the point. One of these connections exists per indexing
|
||||
/// root, so the 10000-page (~40 MiB) cache the other profiles take would
|
||||
/// cost ~200 MiB across five roots — more than the per-directory
|
||||
/// classification this connection exists to serve was meant to save.
|
||||
///
|
||||
/// 256 pages (~1 MiB) is enough to hold the upper levels of
|
||||
/// `idx_files_parent` hot, which is all these queries touch: each one is a
|
||||
/// single index range lookup, and the pages under it are read once and not
|
||||
/// revisited.
|
||||
pub const PRAGMAS_WALK_READER: &str = "
|
||||
PRAGMA busy_timeout = 5000;
|
||||
PRAGMA cache_size = 256;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA foreign_keys = ON;
|
||||
";
|
||||
|
||||
/// The full current schema. Applied by [`super::open::open_or_recreate`]
|
||||
/// when the DB is fresh or has just been wiped because it drifted from
|
||||
/// [`super::open::CURRENT_SCHEMA_VERSION`].
|
||||
|
|
|
|||
|
|
@ -19,32 +19,13 @@ use crate::extract::Registry;
|
|||
use crate::indexing::should_abort;
|
||||
use crate::mime::{guess_mime_from_head, mime_to_type, FileType};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExistingFileEntry {
|
||||
pub mtime: u64,
|
||||
}
|
||||
|
||||
/// Load path and mtime per row for incremental classification (hash/size loaded only when updating a file).
|
||||
pub fn load_existing_files(conn: &Connection) -> Result<HashMap<String, ExistingFileEntry>, rusqlite::Error> {
|
||||
let mut existing_files = HashMap::new();
|
||||
let mut stmt = conn.prepare("SELECT path, mtime FROM files")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
ExistingFileEntry {
|
||||
// SQLite stores i64; mtimes are non-negative in practice.
|
||||
mtime: row.get::<_, i64>(1)?.max(0) as u64,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (path, entry) = row?;
|
||||
existing_files.insert(path, entry);
|
||||
}
|
||||
|
||||
Ok(existing_files)
|
||||
}
|
||||
/// One directory's indexed files, as `name -> mtime`.
|
||||
///
|
||||
/// The unit classification works in. Names, not full paths: the parent is
|
||||
/// implied by which directory is being walked, and at millions of files the
|
||||
/// repeated directory prefix is the whole cost. Produced by
|
||||
/// [`crate::db::repo::dir_rows`].
|
||||
pub type DirRows = HashMap<String, u64>;
|
||||
|
||||
/// Derive (inode, device_id) from a `std::fs::Metadata` on platforms that
|
||||
/// expose them. Returns `(None, None)` on Windows and other non-Unix targets.
|
||||
|
|
@ -471,19 +452,32 @@ pub enum FileIndexAction {
|
|||
Insert,
|
||||
}
|
||||
|
||||
/// Decide what Phase 1 should do with a file, given the path spelling used
|
||||
/// as the `files.path` key and the file's mtime.
|
||||
/// Decide what Phase 1 should do with a file, given its name within the
|
||||
/// directory being walked and the file's mtime.
|
||||
///
|
||||
/// Pure: the caller supplies the `stat` result rather than this function
|
||||
/// going to disk for it, so the same `stat` serves classification and the
|
||||
/// record build, and this runs on any worker thread against a shared map.
|
||||
pub fn classify_for_indexing(
|
||||
path: &str,
|
||||
mtime: u64,
|
||||
existing_files: &HashMap<String, ExistingFileEntry>,
|
||||
) -> FileIndexAction {
|
||||
match existing_files.get(path) {
|
||||
Some(existing) if existing.mtime == mtime => FileIndexAction::Skip,
|
||||
/// Pure: the caller supplies both the `stat` result and the directory's rows
|
||||
/// rather than this function going to disk or to SQLite for them, so the same
|
||||
/// `stat` serves classification and the record build, and this runs on any
|
||||
/// worker thread against data the prefetcher already fetched.
|
||||
///
|
||||
/// Keyed by name against one directory rather than by full path against the
|
||||
/// whole index: see [`DirRows`]. A file the walk reaches under a spelling
|
||||
/// whose parent is *not* this directory — a resolved symlink target — must
|
||||
/// not be classified here; it would miss and read as [`FileIndexAction::Insert`],
|
||||
/// and `insert_file`'s `INSERT OR IGNORE` would then silently not update it.
|
||||
/// Use [`classify_by_mtime`] for those.
|
||||
pub fn classify_for_indexing(name: &str, mtime: u64, rows: &DirRows) -> FileIndexAction {
|
||||
classify_by_mtime(rows.get(name).copied(), mtime)
|
||||
}
|
||||
|
||||
/// The same decision from an already-resolved stored mtime.
|
||||
///
|
||||
/// The path [`classify_for_indexing`] funnels into, and the one the walk uses
|
||||
/// directly for resolved symlink targets, whose stored row is found by exact
|
||||
/// path instead of by name within a directory.
|
||||
pub fn classify_by_mtime(stored: Option<u64>, mtime: u64) -> FileIndexAction {
|
||||
match stored {
|
||||
Some(known) if known == mtime => FileIndexAction::Skip,
|
||||
Some(_) => FileIndexAction::Update,
|
||||
None => FileIndexAction::Insert,
|
||||
}
|
||||
|
|
@ -1268,26 +1262,35 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn classify_uses_mtime_against_the_existing_index() {
|
||||
let mut existing = HashMap::new();
|
||||
existing.insert("/a/known.txt".to_string(), ExistingFileEntry { mtime: 100 });
|
||||
let mut rows = DirRows::new();
|
||||
rows.insert("known.txt".to_string(), 100);
|
||||
|
||||
assert_eq!(
|
||||
classify_for_indexing("/a/new.txt", 100, &existing),
|
||||
classify_for_indexing("new.txt", 100, &rows),
|
||||
FileIndexAction::Insert,
|
||||
"a path absent from the index is new"
|
||||
"a name absent from the directory's rows is new"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_for_indexing("/a/known.txt", 100, &existing),
|
||||
classify_for_indexing("known.txt", 100, &rows),
|
||||
FileIndexAction::Skip,
|
||||
"same mtime means nothing to do"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_for_indexing("/a/known.txt", 101, &existing),
|
||||
classify_for_indexing("known.txt", 101, &rows),
|
||||
FileIndexAction::Update,
|
||||
"a changed mtime means re-read"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_by_mtime_matches_the_name_keyed_path() {
|
||||
// Resolved symlink targets take this route because their row is
|
||||
// found by exact path, not by name within the directory walked.
|
||||
assert_eq!(classify_by_mtime(None, 100), FileIndexAction::Insert);
|
||||
assert_eq!(classify_by_mtime(Some(100), 100), FileIndexAction::Skip);
|
||||
assert_eq!(classify_by_mtime(Some(99), 100), FileIndexAction::Update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_path_strips_windows_prefixes() {
|
||||
assert_eq!(path_to_db_string(Path::new("/plain/unix/path")), "/plain/unix/path");
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ use crate::file_handling::{
|
|||
extract_one_batch,
|
||||
extract_scope_prepare,
|
||||
fts_finalize_after_text_indexing,
|
||||
load_existing_files,
|
||||
process_batch_inserts,
|
||||
process_batch_updates,
|
||||
path_to_db_string,
|
||||
|
|
@ -21,8 +20,11 @@ use crate::file_handling::{
|
|||
OwnedNewFile,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WorkerStats};
|
||||
use crate::walk::{
|
||||
thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WalkEvent, WorkerStats,
|
||||
};
|
||||
use crate::db;
|
||||
use crate::db::repo;
|
||||
|
||||
/// Where one root's pipeline is in its life cycle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -91,6 +93,54 @@ pub enum IndexingCommand {
|
|||
Stop,
|
||||
}
|
||||
|
||||
/// Collect rows whose parent directory the walk never reached.
|
||||
///
|
||||
/// Per-directory reconciliation can only speak for directories it read, so a
|
||||
/// directory deleted wholesale — or newly excluded by an ignore pattern —
|
||||
/// leaves its rows unaccounted for. This finds them by scanning the distinct
|
||||
/// parents stored under `root` and keeping the ones absent from `seen_dirs`.
|
||||
///
|
||||
/// Two kinds of absence are *not* deletions and are filtered out:
|
||||
///
|
||||
/// - A parent under a directory the walk could not read. Its children were
|
||||
/// never discovered, so their absence proves nothing.
|
||||
/// - A path reached by resolving a symlink. Its row's parent may lie outside
|
||||
/// every root and so is never visited by construction; `aliased` is the
|
||||
/// record that the file itself was seen.
|
||||
///
|
||||
/// The parent scan streams (see [`repo::for_each_parent_in_range`]) so this
|
||||
/// costs memory proportional to the *unvisited* directories, not to the tree.
|
||||
fn sweep_unvisited_parents(
|
||||
conn_mutex: &Arc<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)]
|
||||
pub struct IndexingService {
|
||||
status: Arc<Mutex<IndexingStatus>>,
|
||||
|
|
@ -456,13 +506,10 @@ impl IndexingService {
|
|||
// the legacy single path did.
|
||||
Self::update_config(&conn, config, &roots.join("\n"))?;
|
||||
|
||||
// Load existing files from database for incremental indexing
|
||||
// Shared read-only with the walk threads, which classify against it.
|
||||
let existing_files = Arc::new({
|
||||
let conn_ref = &conn;
|
||||
load_existing_files(conn_ref)
|
||||
.map_err(|e| format!("Failed to load existing files: {}", e))?
|
||||
});
|
||||
// No up-front load of the whole `files` table: each walk's prefetcher
|
||||
// fetches one directory's rows at a time, so classification data is
|
||||
// never all resident at once and the walk starts immediately instead
|
||||
// of after a full table scan.
|
||||
|
||||
let conn_mutex = Arc::new(Mutex::new(conn));
|
||||
|
||||
|
|
@ -504,7 +551,7 @@ impl IndexingService {
|
|||
config.indexing.follow_symlinks,
|
||||
config.indexing.include_hidden,
|
||||
ignore,
|
||||
existing_files.clone(),
|
||||
db_path,
|
||||
config.clone(),
|
||||
registry.clone(),
|
||||
stop_flag.clone(),
|
||||
|
|
@ -583,7 +630,17 @@ impl IndexingService {
|
|||
// active roots get even quanta; read-bottlenecked, roots with
|
||||
// empty channels are skipped and the firehose roots get the
|
||||
// writer's full attention.
|
||||
let mut seen_paths: HashSet<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 stale_cleanup_ok = true;
|
||||
let mut cleanup_done = false;
|
||||
|
|
@ -604,17 +661,33 @@ impl IndexingService {
|
|||
let mut took = 0usize;
|
||||
while took < quantum {
|
||||
match p.walk.try_next() {
|
||||
TryNext::Item(file) => {
|
||||
TryNext::Item(WalkEvent::Stale(paths)) => {
|
||||
took += 1;
|
||||
// Applied at the end of the run, not here:
|
||||
// deleting mid-walk would break the "a
|
||||
// stopped run deletes nothing" guarantee,
|
||||
// and an aliased sighting that exempts a
|
||||
// path may still be ahead of us.
|
||||
stale_candidates.extend(paths);
|
||||
}
|
||||
TryNext::Item(WalkEvent::File(file)) => {
|
||||
took += 1;
|
||||
p.walked += 1;
|
||||
if p.walked % 64 == 0 {
|
||||
p.current_file = Some(file.path.clone());
|
||||
}
|
||||
// Membership decides what survives stale
|
||||
// cleanup: "the walk saw this", never
|
||||
// "processed successfully". Also dedupes
|
||||
// symlinked spellings across roots.
|
||||
if !seen_paths.insert(file.path.clone()) {
|
||||
if file.aliased {
|
||||
// Its row's parent is a directory this
|
||||
// walk may never visit, so the
|
||||
// vanished-directory sweep must not
|
||||
// treat that parent's absence as proof
|
||||
// the file is gone.
|
||||
aliased_paths.insert(file.path.clone());
|
||||
}
|
||||
// Dedupes a canonical file reachable
|
||||
// through several spellings, or from more
|
||||
// than one root.
|
||||
if !seen_paths.insert(file.digest) {
|
||||
continue;
|
||||
}
|
||||
let Some(rec) = file.record else { continue };
|
||||
|
|
@ -734,14 +807,27 @@ impl IndexingService {
|
|||
cleanup_done = true;
|
||||
let stopped = *stop_flag.lock().unwrap();
|
||||
if stale_cleanup_ok && !stopped {
|
||||
let stale_paths: Vec<String> = existing_files
|
||||
.keys()
|
||||
.filter(|path| !seen_paths.contains(*path))
|
||||
.filter(|path| {
|
||||
!pipelines.iter().any(|p| p.walk.unreadable().covers(path))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
// Directories that vanished entirely are never read, so
|
||||
// per-directory reconciliation never sees them; only a
|
||||
// scan of stored parents against the ones the walk
|
||||
// reached can find the rows beneath them.
|
||||
for p in &pipelines {
|
||||
sweep_unvisited_parents(
|
||||
&conn_mutex,
|
||||
&p.root,
|
||||
&p.walk.seen_dirs(),
|
||||
p.walk.unreadable(),
|
||||
&aliased_paths,
|
||||
&mut stale_candidates,
|
||||
)?;
|
||||
}
|
||||
// No unreadable-directory filter here: neither source of
|
||||
// candidates can produce one. `read_directory` returns
|
||||
// before reconciling a directory it could not read, and
|
||||
// the sweep skips parents beneath one. Re-checking here
|
||||
// as well would put the same rule in two places, free to
|
||||
// drift, and the tests could not tell which one held.
|
||||
let stale_paths: Vec<String> = stale_candidates.drain(..).collect();
|
||||
let unreadable_count: usize = pipelines
|
||||
.iter()
|
||||
.map(|p| p.walk.unreadable().paths().len())
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -387,6 +387,194 @@ fn two_roots_walk_extract_and_clean_independently() {
|
|||
std::fs::remove_dir_all(&db_dir).ok();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reconciliation without a global path set.
|
||||
//
|
||||
// Classification and stale detection are per-directory: a worker diffs one
|
||||
// directory's listing against that directory's index rows. These cover the
|
||||
// cases that arrangement cannot see from inside a single directory read.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A directory deleted wholesale is never read, so per-directory
|
||||
/// reconciliation never runs for it. Only the sweep over stored parents finds
|
||||
/// the rows underneath.
|
||||
#[test]
|
||||
fn a_deleted_directory_takes_its_whole_subtree_out_of_the_index() {
|
||||
let root = tmp_dir("gone-dir");
|
||||
let db_dir = tmp_dir("gone-dir-db");
|
||||
let db = db_dir.join("index.sqlite");
|
||||
let config = test_config();
|
||||
|
||||
touch(&root.join("keep.txt"), b"stays");
|
||||
touch(&root.join("doomed/a.txt"), b"goes");
|
||||
touch(&root.join("doomed/b.txt"), b"goes");
|
||||
// Nested, so the sweep has to reach a parent two levels below the root.
|
||||
touch(&root.join("doomed/deeper/c.txt"), b"goes too");
|
||||
|
||||
index_once(&root, &db, &config);
|
||||
assert_eq!(rows(&db).len(), 4, "all four indexed");
|
||||
|
||||
std::fs::remove_dir_all(root.join("doomed")).unwrap();
|
||||
index_once(&root, &db, &config);
|
||||
|
||||
let names: Vec<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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -169,11 +169,7 @@ impl QuickSearchApp {
|
|||
|
||||
/// Save + route an edited config to the running services.
|
||||
fn apply_new_config(&mut self, ctx: &egui::Context, mut new: Config) {
|
||||
// The security section is never edited through config drafts — it
|
||||
// changes only via the explicit flows in `handle_security_action`.
|
||||
// Pinning it here keeps a stale draft (taken before a security
|
||||
// change) from silently reverting protection or the salt.
|
||||
new.security = self.cfg.security.clone();
|
||||
pin_live_fields(&mut new, &self.cfg);
|
||||
if let Some((child, parent)) = nested_roots(&new.paths.indexing_paths).first() {
|
||||
self.config_error = Some(format!(
|
||||
"Not applied: indexed folder {} is nested under {}",
|
||||
|
|
@ -232,6 +228,27 @@ impl QuickSearchApp {
|
|||
self.cfg = new;
|
||||
}
|
||||
|
||||
/// Switch the indexing mode and write it to the config immediately.
|
||||
///
|
||||
/// The mode is a persisted setting (`indexing.auto_index`), not a
|
||||
/// per-session one: a manual stop must still be manual after a
|
||||
/// restart, or the next launch quietly resumes the indexing the user
|
||||
/// just stopped.
|
||||
fn set_index_mode(&mut self, auto: bool) {
|
||||
self.backend.coordinator.set_mode(if auto {
|
||||
IndexMode::Auto
|
||||
} else {
|
||||
IndexMode::ManualStopped
|
||||
});
|
||||
if self.cfg.indexing.auto_index == auto {
|
||||
return;
|
||||
}
|
||||
self.cfg.indexing.auto_index = auto;
|
||||
if let Err(e) = self.cfg.save() {
|
||||
self.config_error = Some(e);
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_events(&mut self) {
|
||||
// Streamed search results.
|
||||
loop {
|
||||
|
|
@ -333,13 +350,12 @@ impl QuickSearchApp {
|
|||
.unwrap_or(true);
|
||||
if stale {
|
||||
let db = self.cfg.resolved_database_path();
|
||||
let counts = index_counts(&db.to_string_lossy()).unwrap_or(
|
||||
IndexCounts {
|
||||
let counts =
|
||||
index_counts(&db.to_string_lossy()).unwrap_or(IndexCounts {
|
||||
files: 0,
|
||||
content_done: 0,
|
||||
content_pending: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
self.counts = Some((Instant::now(), counts));
|
||||
}
|
||||
let files = self.counts.map(|(_, c)| c.files).unwrap_or(0);
|
||||
|
|
@ -362,12 +378,8 @@ impl QuickSearchApp {
|
|||
ui.label(egui::RichText::new("Stopping indexing…").small());
|
||||
}
|
||||
IndexingStatus::Running { roots, .. } => {
|
||||
let done = roots
|
||||
.iter()
|
||||
.filter(|r| r.phase == RootPhase::Done)
|
||||
.count();
|
||||
let processed: usize =
|
||||
roots.iter().map(|r| r.walked + r.extracted).sum();
|
||||
let done = roots.iter().filter(|r| r.phase == RootPhase::Done).count();
|
||||
let processed: usize = roots.iter().map(|r| r.walked + r.extracted).sum();
|
||||
let totals_known = roots.iter().all(|r| r.walk_total.is_some());
|
||||
let denominator: usize = roots
|
||||
.iter()
|
||||
|
|
@ -375,8 +387,7 @@ impl QuickSearchApp {
|
|||
.sum();
|
||||
|
||||
let mut text = if totals_known && denominator > 0 {
|
||||
let frac =
|
||||
(processed as f64 / denominator as f64).min(1.0);
|
||||
let frac = (processed as f64 / denominator as f64).min(1.0);
|
||||
format!(
|
||||
"Indexing {} / {} ({:.0}%)",
|
||||
group_thousands(processed as u64),
|
||||
|
|
@ -384,10 +395,7 @@ impl QuickSearchApp {
|
|||
frac * 100.0
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Indexing · {} files",
|
||||
group_thousands(processed as u64)
|
||||
)
|
||||
format!("Indexing · {} files", group_thousands(processed as u64))
|
||||
};
|
||||
if roots.len() > 1 {
|
||||
text.push_str(&format!(" · {}/{} roots done", done, roots.len()));
|
||||
|
|
@ -396,15 +404,13 @@ impl QuickSearchApp {
|
|||
text.push_str(&format!(" · {}", crate::format::fmt_rate(rate)));
|
||||
}
|
||||
let active: usize = roots.iter().map(|r| r.active_workers).sum();
|
||||
let total_workers: usize =
|
||||
roots.iter().map(|r| r.total_workers).sum();
|
||||
let total_workers: usize = roots.iter().map(|r| r.total_workers).sum();
|
||||
if total_workers > 0 {
|
||||
text.push_str(&format!(" · {}/{} workers", active, total_workers));
|
||||
}
|
||||
ui.label(egui::RichText::new(text).small());
|
||||
if totals_known && denominator > 0 {
|
||||
let frac =
|
||||
(processed as f32 / denominator as f32).clamp(0.0, 1.0);
|
||||
let frac = (processed as f32 / denominator as f32).clamp(0.0, 1.0);
|
||||
ui.add(egui::ProgressBar::new(frac).desired_width(120.0));
|
||||
} else {
|
||||
ui.add(egui::Spinner::new().size(12.0));
|
||||
|
|
@ -424,7 +430,10 @@ impl QuickSearchApp {
|
|||
});
|
||||
|
||||
// Keep painting while anything is moving.
|
||||
if !matches!(state.activity, IndexingStatus::Idle | IndexingStatus::Error(_)) {
|
||||
if !matches!(
|
||||
state.activity,
|
||||
IndexingStatus::Idle | IndexingStatus::Error(_)
|
||||
) {
|
||||
ctx.request_repaint_after(Duration::from_millis(250));
|
||||
}
|
||||
// Watcher registration walks every root, so its verdict can land
|
||||
|
|
@ -458,9 +467,7 @@ impl QuickSearchApp {
|
|||
// newline-joined — side-by-side columns keep before and
|
||||
// after readable instead of one run-on arrow line.
|
||||
ui.columns(2, |cols| {
|
||||
cols[0].label(
|
||||
egui::RichText::new("index was built with").small().weak(),
|
||||
);
|
||||
cols[0].label(egui::RichText::new("index was built with").small().weak());
|
||||
cols[0].monospace(display_value(&change.stored));
|
||||
cols[1].label(egui::RichText::new("config now says").small().weak());
|
||||
cols[1].monospace(display_value(&change.current));
|
||||
|
|
@ -513,8 +520,7 @@ impl QuickSearchApp {
|
|||
if remember {
|
||||
match db::process_key_hex() {
|
||||
Some(hex) => {
|
||||
if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &hex)
|
||||
{
|
||||
if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &hex) {
|
||||
self.config_error = Some(e);
|
||||
return; // preference not saved either
|
||||
}
|
||||
|
|
@ -577,12 +583,16 @@ impl QuickSearchApp {
|
|||
.hint_text("Confirm password")
|
||||
.desired_width(240.0),
|
||||
);
|
||||
ui.checkbox(remember, "Remember on this device").on_hover_text(
|
||||
"Stores the derived key (not the password) in the OS \
|
||||
ui.checkbox(remember, "Remember on this device")
|
||||
.on_hover_text(
|
||||
"Stores the derived key (not the password) in the OS \
|
||||
keychain and skips the startup prompt.",
|
||||
);
|
||||
);
|
||||
if !pw1.is_empty() && !pw2.is_empty() && pw1 != pw2 {
|
||||
ui.colored_label(ui.visuals().error_fg_color, "Passwords do not match.");
|
||||
ui.colored_label(
|
||||
ui.visuals().error_fg_color,
|
||||
"Passwords do not match.",
|
||||
);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let ok = !pw1.is_empty() && pw1 == pw2;
|
||||
|
|
@ -619,34 +629,32 @@ impl QuickSearchApp {
|
|||
self.security_prompt = Some(SecurityPrompt::Deriving { rx });
|
||||
}
|
||||
}
|
||||
SecurityPrompt::Deriving { rx } => {
|
||||
match rx.try_recv() {
|
||||
Ok((new_security, key)) => {
|
||||
self.security_prompt = Some(SecurityPrompt::ConfirmRebuild {
|
||||
new_security,
|
||||
new_key: Some(key),
|
||||
});
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
egui::Window::new("Deriving key")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Deriving key…");
|
||||
});
|
||||
});
|
||||
ctx.request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.config_error = Some("key derivation thread died".to_string());
|
||||
self.security_prompt = None;
|
||||
}
|
||||
SecurityPrompt::Deriving { rx } => match rx.try_recv() {
|
||||
Ok((new_security, key)) => {
|
||||
self.security_prompt = Some(SecurityPrompt::ConfirmRebuild {
|
||||
new_security,
|
||||
new_key: Some(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
egui::Window::new("Deriving key")
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Deriving key…");
|
||||
});
|
||||
});
|
||||
ctx.request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.config_error = Some("key derivation thread died".to_string());
|
||||
self.security_prompt = None;
|
||||
}
|
||||
},
|
||||
SecurityPrompt::ConfirmRebuild {
|
||||
new_security,
|
||||
new_key,
|
||||
|
|
@ -701,7 +709,11 @@ impl QuickSearchApp {
|
|||
/// in that order, before the rebuild so the fresh index is created
|
||||
/// under the new key (or none).
|
||||
fn apply_security_change(&mut self, new_security: SecurityConfig, new_key: Option<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;
|
||||
if let Err(e) = self.cfg.save() {
|
||||
self.config_error = Some(e);
|
||||
|
|
@ -858,6 +870,11 @@ impl QuickSearchApp {
|
|||
)
|
||||
.clicked()
|
||||
{
|
||||
// Manual first, and persisted: clearing drops the
|
||||
// coordinator to manual so automatic mode cannot
|
||||
// resurrect what was just deleted, and the next
|
||||
// launch must not undo that either.
|
||||
self.set_index_mode(false);
|
||||
self.backend.coordinator.clear_index();
|
||||
self.counts = None;
|
||||
self.dups.state = DupState::NotLoaded;
|
||||
|
|
@ -874,6 +891,18 @@ impl QuickSearchApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Overwrite the fields a config draft must never carry back.
|
||||
///
|
||||
/// Both are live state the GUI changes through their own controls — the
|
||||
/// security flows in `handle_security_action`, the mode buttons in
|
||||
/// [`QuickSearchApp::set_index_mode`] — and both are saved the moment they
|
||||
/// change. A draft taken before one of those clicks still holds the old
|
||||
/// value, so applying it would silently revert protection, the salt, or
|
||||
/// the indexing mode.
|
||||
fn pin_live_fields(new: &mut Config, live: &Config) {
|
||||
new.security = live.security.clone();
|
||||
new.indexing.auto_index = live.indexing.auto_index;
|
||||
}
|
||||
|
||||
/// A stored/current config value for the rebuild prompt; list values are
|
||||
/// already newline-joined and render as-is, empty means unset.
|
||||
|
|
@ -966,10 +995,10 @@ impl eframe::App for QuickSearchApp {
|
|||
self.backend.coordinator.reindex_now();
|
||||
}
|
||||
if actions.stop {
|
||||
self.backend.coordinator.set_mode(IndexMode::ManualStopped);
|
||||
self.set_index_mode(false);
|
||||
}
|
||||
if actions.auto {
|
||||
self.backend.coordinator.set_mode(IndexMode::Auto);
|
||||
self.set_index_mode(true);
|
||||
}
|
||||
if actions.clear_index {
|
||||
self.clear_prompt = true;
|
||||
|
|
@ -1012,3 +1041,37 @@ impl eframe::App for QuickSearchApp {
|
|||
self.backend.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_stale_draft_cannot_revert_the_indexing_mode_or_security() {
|
||||
// The draft as it was when the editor last synced: automatic
|
||||
// indexing, no password — plus one real edit the user staged.
|
||||
let mut draft = Config::default();
|
||||
draft.indexing.auto_index = true;
|
||||
draft.indexing.reindex_interval_minutes = 60;
|
||||
|
||||
// Since then: Stop was clicked and protection was enabled.
|
||||
let mut live = Config::default();
|
||||
live.indexing.auto_index = false;
|
||||
live.security = SecurityConfig {
|
||||
password_protected: true,
|
||||
salt: Some("ab".repeat(16)),
|
||||
use_keychain: true,
|
||||
};
|
||||
|
||||
pin_live_fields(&mut draft, &live);
|
||||
assert!(
|
||||
!draft.indexing.auto_index,
|
||||
"applying the draft must not restart automatic indexing"
|
||||
);
|
||||
assert_eq!(draft.security, live.security);
|
||||
assert_eq!(
|
||||
draft.indexing.reindex_interval_minutes, 60,
|
||||
"the staged edit itself still applies"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,11 +49,8 @@ impl Backend {
|
|||
let (tx, rx) = mpsc::channel();
|
||||
let db = config.resolved_database_path();
|
||||
std::thread::spawn(move || {
|
||||
let result = quicksearch_core::search::find_duplicate_groups(
|
||||
&db.to_string_lossy(),
|
||||
500,
|
||||
0,
|
||||
);
|
||||
let result =
|
||||
quicksearch_core::search::find_duplicate_groups(&db.to_string_lossy(), 500, 0);
|
||||
let _ = tx.send(result);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,15 +77,13 @@ pub fn maybe_run_cli() -> Option<i32> {
|
|||
return Some(2);
|
||||
}
|
||||
},
|
||||
other if other.starts_with("--limit=") => {
|
||||
match other["--limit=".len()..].parse() {
|
||||
Ok(n) => limit = Some(n),
|
||||
Err(_) => {
|
||||
eprintln!("--limit requires a number\n\n{}", USAGE);
|
||||
return Some(2);
|
||||
}
|
||||
other if other.starts_with("--limit=") => match other["--limit=".len()..].parse() {
|
||||
Ok(n) => limit = Some(n),
|
||||
Err(_) => {
|
||||
eprintln!("--limit requires a number\n\n{}", USAGE);
|
||||
return Some(2);
|
||||
}
|
||||
}
|
||||
},
|
||||
other if other.starts_with('-') && terms.is_empty() => {
|
||||
// Unknown flags without a query fall through to the GUI
|
||||
// (they may be eframe/winit flags).
|
||||
|
|
@ -128,13 +126,15 @@ pub(crate) fn resolve_key(
|
|||
|
||||
if let Some(hex) = keychain_hex {
|
||||
match IndexKey::from_hex(&hex).map_err(|e| format!("keychain entry: {}", e)) {
|
||||
Ok(key) => match try_key(key) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => {
|
||||
eprintln!("warning: the key remembered in the OS keychain no longer opens this index");
|
||||
Ok(key) => {
|
||||
match try_key(key) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => {
|
||||
eprintln!("warning: the key remembered in the OS keychain no longer opens this index");
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
},
|
||||
}
|
||||
Err(e) => eprintln!("warning: {}", e),
|
||||
}
|
||||
}
|
||||
|
|
@ -474,8 +474,7 @@ mod tests {
|
|||
#[test]
|
||||
fn no_tty_no_sources_is_instructive() {
|
||||
let sec = protected();
|
||||
let err = resolve_key(&sec, false, None, None, || None, |_| Ok(()))
|
||||
.unwrap_err();
|
||||
let err = resolve_key(&sec, false, None, None, || None, |_| Ok(())).unwrap_err();
|
||||
assert!(err.contains(PASSWORD_ENV));
|
||||
assert!(err.contains("Remember on this device"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,10 @@ impl DuplicatesTab {
|
|||
|
||||
ui.horizontal(|ui| {
|
||||
let loading = matches!(self.state, DupState::Loading);
|
||||
if ui.add_enabled(!loading, egui::Button::new("Refresh")).clicked() {
|
||||
if ui
|
||||
.add_enabled(!loading, egui::Button::new("Refresh"))
|
||||
.clicked()
|
||||
{
|
||||
actions.refresh = true;
|
||||
}
|
||||
if loading {
|
||||
|
|
@ -62,48 +65,57 @@ impl DuplicatesTab {
|
|||
}
|
||||
if groups.len() == 500 {
|
||||
ui.label(
|
||||
egui::RichText::new("Showing the 500 largest groups.").small().weak(),
|
||||
egui::RichText::new("Showing the 500 largest groups.")
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| {
|
||||
for (i, group) in groups.iter().enumerate() {
|
||||
let name = group
|
||||
.members
|
||||
.first()
|
||||
.map(|m| m.1.as_str())
|
||||
.unwrap_or("(unknown)");
|
||||
let title = format!(
|
||||
"{} × {}: {} reclaimable ({} total)",
|
||||
group_thousands(group.count as u64),
|
||||
name,
|
||||
human_size(group.redundant_size.max(0) as u64),
|
||||
human_size(group.total_size.max(0) as u64),
|
||||
);
|
||||
egui::CollapsingHeader::new(title).id_salt(i).show(ui, |ui| {
|
||||
for (_, _, path, size, _) in &group.members {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(human_size(*size));
|
||||
let response = ui
|
||||
.add(egui::Label::new(egui::RichText::new(path).monospace())
|
||||
.sense(egui::Sense::click()));
|
||||
if response.double_clicked() {
|
||||
platform::open_file(path);
|
||||
let scroll = egui::ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
for (i, group) in groups.iter().enumerate() {
|
||||
let name = group
|
||||
.members
|
||||
.first()
|
||||
.map(|m| m.1.as_str())
|
||||
.unwrap_or("(unknown)");
|
||||
let title = format!(
|
||||
"{} × {}: {} reclaimable ({} total)",
|
||||
group_thousands(group.count as u64),
|
||||
name,
|
||||
human_size(group.redundant_size.max(0) as u64),
|
||||
human_size(group.total_size.max(0) as u64),
|
||||
);
|
||||
egui::CollapsingHeader::new(title)
|
||||
.id_salt(i)
|
||||
.show(ui, |ui| {
|
||||
for (_, _, path, size, _) in &group.members {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(human_size(*size));
|
||||
let response = ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(path).monospace(),
|
||||
)
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if response.double_clicked() {
|
||||
platform::open_file(path);
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
if ui.button("Open").clicked() {
|
||||
platform::open_file(path);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Open containing folder").clicked() {
|
||||
platform::reveal_in_folder(path);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
if ui.button("Open").clicked() {
|
||||
platform::open_file(path);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Open containing folder").clicked() {
|
||||
platform::reveal_in_folder(path);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
crate::ui_util::more_below_hint(ui, &scroll);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ pub fn ui(ui: &mut egui::Ui) {
|
|||
// would otherwise stretch every paragraph into one long line.
|
||||
ui.set_max_width(620.0);
|
||||
|
||||
ui.heading("Welcome to QuickSearch");
|
||||
ui.heading(egui::RichText::new("Welcome to QuickSearch").strong());
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
"QuickSearch keeps an index of the folders you choose and finds \
|
||||
|
|
@ -20,7 +20,7 @@ pub fn ui(ui: &mut egui::Ui) {
|
|||
);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.heading("Getting started");
|
||||
ui.heading(egui::RichText::new("Getting started").strong());
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
"1. The first time QuickSearch runs it starts indexing your home \
|
||||
|
|
@ -38,7 +38,7 @@ pub fn ui(ui: &mut egui::Ui) {
|
|||
);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.heading("Searching");
|
||||
ui.heading(egui::RichText::new("Searching").strong());
|
||||
ui.add_space(4.0);
|
||||
ui.label("Plain words match file names, file contents, and paths:");
|
||||
ui.monospace("quarterly budget");
|
||||
|
|
@ -47,9 +47,7 @@ pub fn ui(ui: &mut egui::Ui) {
|
|||
words:",
|
||||
);
|
||||
ui.monospace("type:Document modified:>=2024-01-01 report");
|
||||
ui.label(
|
||||
"The ? button next to the search box shows the full query syntax.",
|
||||
);
|
||||
ui.label("The ? button next to the search box shows the full query syntax.");
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
"• Tick Fuzzy to also find matches with typos in them, at some \
|
||||
|
|
@ -69,7 +67,7 @@ pub fn ui(ui: &mut egui::Ui) {
|
|||
);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.heading("The other tabs");
|
||||
ui.heading(egui::RichText::new("The other tabs").strong());
|
||||
ui.add_space(4.0);
|
||||
egui::Grid::new("help-tabs")
|
||||
.num_columns(2)
|
||||
|
|
@ -86,7 +84,11 @@ pub fn ui(ui: &mut egui::Ui) {
|
|||
"indexing status and controls, the indexed folder list, \
|
||||
and the filters that decide what is skipped",
|
||||
);
|
||||
row(ui, "Duplicates", "files whose contents are identical, grouped");
|
||||
row(
|
||||
ui,
|
||||
"Duplicates",
|
||||
"files whose contents are identical, grouped",
|
||||
);
|
||||
row(
|
||||
ui,
|
||||
"Logs",
|
||||
|
|
@ -97,7 +99,7 @@ pub fn ui(ui: &mut egui::Ui) {
|
|||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.heading("Terminal");
|
||||
ui.heading(egui::RichText::new("Terminal").strong());
|
||||
ui.add_space(4.0);
|
||||
ui.label("QuickSearch also searches straight from a terminal:");
|
||||
ui.monospace("quicksearch \"quarterly budget\"");
|
||||
|
|
|
|||
|
|
@ -155,11 +155,7 @@ impl LogsTab {
|
|||
for &i in &shown[range] {
|
||||
let line = &self.lines[i];
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(fmt_clock(line.at))
|
||||
.monospace()
|
||||
.weak(),
|
||||
);
|
||||
ui.label(egui::RichText::new(fmt_clock(line.at)).monospace().weak());
|
||||
let text = egui::RichText::new(&line.text).monospace();
|
||||
match line.level {
|
||||
Level::Warn => {
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ mod platform;
|
|||
mod query_highlight;
|
||||
mod search_tab;
|
||||
mod tracker;
|
||||
mod unlock;
|
||||
mod ui_util;
|
||||
mod unlock;
|
||||
|
||||
use quicksearch_core::config::Config;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ use quicksearch_core::coordinator::{IndexMode, IndexerState, WatcherStatus};
|
|||
use quicksearch_core::indexing::{IndexingStatus, RootPhase, RootProgress};
|
||||
|
||||
use crate::format::{fmt_interval, fmt_rate, group_thousands, middle_truncate};
|
||||
use crate::options::{config_editor_ui, Section};
|
||||
use crate::tracker::SpeedTracker;
|
||||
|
||||
/// What the tab asks the app to do after this frame.
|
||||
|
|
@ -17,7 +16,7 @@ pub struct ManageActions {
|
|||
pub auto: bool,
|
||||
/// Ask the app to confirm and delete the index.
|
||||
pub clear_index: bool,
|
||||
/// A full edited config to apply (roots / filters / indexing knobs).
|
||||
/// A full edited config to apply (roots / filters).
|
||||
pub apply_config: Option<Config>,
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +33,7 @@ pub struct ManageTab {
|
|||
/// The config the draft was last synced from. `None` forces a full
|
||||
/// resync (first frame, and right after our own Apply).
|
||||
baseline: Option<Config>,
|
||||
/// Draft of the roots/filters/indexing knobs edited in-place.
|
||||
/// Draft of the roots/filters edited in-place.
|
||||
draft: Option<Config>,
|
||||
}
|
||||
|
||||
|
|
@ -118,241 +117,270 @@ impl ManageTab {
|
|||
let mut actions = ManageActions::default();
|
||||
self.sync_editors(config);
|
||||
|
||||
let scroll = egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| {
|
||||
// --- Status ---------------------------------------------------
|
||||
ui.heading("Status");
|
||||
status_panel(ui, state, &self.speed);
|
||||
watch_panel(ui, state, config);
|
||||
ui.add_space(8.0);
|
||||
let scroll = egui::ScrollArea::vertical()
|
||||
.auto_shrink([false; 2])
|
||||
.show(ui, |ui| {
|
||||
// --- Status ---------------------------------------------------
|
||||
ui.heading(egui::RichText::new("Status").strong());
|
||||
status_panel(ui, state, &self.speed);
|
||||
watch_panel(ui, state, config);
|
||||
ui.add_space(8.0);
|
||||
|
||||
// --- Controls -------------------------------------------------
|
||||
ui.horizontal(|ui| {
|
||||
let running = !matches!(
|
||||
state.activity,
|
||||
IndexingStatus::Idle | IndexingStatus::Error(_)
|
||||
);
|
||||
if ui.add_enabled(!running, egui::Button::new("Start indexing now")).clicked() {
|
||||
actions.start_now = true;
|
||||
}
|
||||
if ui.add_enabled(running || state.mode == IndexMode::Auto, egui::Button::new("Stop")).clicked() {
|
||||
actions.stop = true;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(state.mode != IndexMode::Auto, egui::Button::new("Return to Automatic"))
|
||||
.clicked()
|
||||
{
|
||||
actions.auto = true;
|
||||
}
|
||||
let mode = match state.mode {
|
||||
IndexMode::Auto => "Automatic",
|
||||
IndexMode::ManualStopped => "Manual (stopped)",
|
||||
IndexMode::ManualRunning => "Manual (running)",
|
||||
};
|
||||
ui.label(egui::RichText::new(format!("Mode: {}", mode)).weak());
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(egui::RichText::new("Clear index…").color(ui.visuals().error_fg_color))
|
||||
.on_hover_text("Delete the index database (asks for confirmation)")
|
||||
.clicked()
|
||||
{
|
||||
actions.clear_index = true;
|
||||
}
|
||||
if state.queued_events > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} changes queued", state.queued_events))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// --- Indexed roots ---------------------------------------------
|
||||
ui.heading("Indexed folders");
|
||||
let draft = self.draft.as_mut().expect("synced");
|
||||
let mut remove: Option<usize> = None;
|
||||
for (i, root) in draft.paths.indexing_paths.clone().iter().enumerate() {
|
||||
// --- Controls -------------------------------------------------
|
||||
ui.horizontal(|ui| {
|
||||
// Controls claim the right edge first so a long path can
|
||||
// never push them out of view; the path truncates into
|
||||
// whatever width remains (full path on hover).
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("Remove").clicked() {
|
||||
remove = Some(i);
|
||||
}
|
||||
// Per-root walker override; 0 = auto (4 local / 16
|
||||
// network, detected per root). Applies on the next run.
|
||||
let mut workers =
|
||||
draft.indexing.root_workers.get(root).copied().unwrap_or(0);
|
||||
let response = ui
|
||||
.add(
|
||||
egui::DragValue::new(&mut workers)
|
||||
.range(0..=64)
|
||||
.custom_formatter(|n, _| {
|
||||
if n == 0.0 {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
format!("{:.0}", n)
|
||||
}
|
||||
})
|
||||
.custom_parser(|s| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || s.eq_ignore_ascii_case("auto") {
|
||||
Some(0.0)
|
||||
} else {
|
||||
s.parse().ok()
|
||||
}
|
||||
}),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Walker threads for this folder. auto = 4 on local \
|
||||
let running = !matches!(
|
||||
state.activity,
|
||||
IndexingStatus::Idle | IndexingStatus::Error(_)
|
||||
);
|
||||
if ui
|
||||
.add_enabled(!running, egui::Button::new("Start indexing now"))
|
||||
.clicked()
|
||||
{
|
||||
actions.start_now = true;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
running || state.mode == IndexMode::Auto,
|
||||
egui::Button::new("Stop"),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Stop indexing and switch to manual. Saved right away: it \
|
||||
stays manual on the next launch too.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.stop = true;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(
|
||||
state.mode != IndexMode::Auto,
|
||||
egui::Button::new("Return to Automatic"),
|
||||
)
|
||||
.on_hover_text(
|
||||
"Watch for changes and reindex periodically again. Also \
|
||||
saved, so this is how the app starts from now on.",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
actions.auto = true;
|
||||
}
|
||||
let mode = match state.mode {
|
||||
IndexMode::Auto => "Automatic",
|
||||
IndexMode::ManualStopped => "Manual (stopped)",
|
||||
IndexMode::ManualRunning => "Manual (running)",
|
||||
};
|
||||
ui.label(egui::RichText::new(format!("Mode: {}", mode)).weak());
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(
|
||||
egui::RichText::new("Clear index…").color(ui.visuals().error_fg_color),
|
||||
)
|
||||
.on_hover_text("Delete the index database (asks for confirmation)")
|
||||
.clicked()
|
||||
{
|
||||
actions.clear_index = true;
|
||||
}
|
||||
if state.queued_events > 0 {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} changes queued", state.queued_events))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// --- Indexed roots ---------------------------------------------
|
||||
ui.heading(egui::RichText::new("Indexed folders").strong());
|
||||
let draft = self.draft.as_mut().expect("synced");
|
||||
let mut remove: Option<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 \
|
||||
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() {
|
||||
cols[1].label(egui::RichText::new("No ignore patterns.").small().weak());
|
||||
if let Some(i) = remove {
|
||||
let removed = draft.paths.indexing_paths.remove(i);
|
||||
draft.indexing.root_workers.remove(&removed);
|
||||
}
|
||||
if let Some(i) = remove_pat {
|
||||
draft.indexing.ignore_patterns.remove(i);
|
||||
}
|
||||
cols[1].horizontal(|ui| {
|
||||
let (response, valid) = crate::ui_util::pattern_edit(
|
||||
ui,
|
||||
&mut self.new_ignore,
|
||||
180.0,
|
||||
"*.tmp or node_modules",
|
||||
);
|
||||
let submitted =
|
||||
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if ui.add_enabled(valid, egui::Button::new("Add")).clicked()
|
||||
|| (submitted && valid)
|
||||
{
|
||||
let pat = self.new_ignore.trim().to_string();
|
||||
if !draft.indexing.ignore_patterns.contains(&pat) {
|
||||
draft.indexing.ignore_patterns.push(pat);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Add folder…").clicked() {
|
||||
if let Some(dir) = rfd::FileDialog::new().pick_folder() {
|
||||
let path = dir.to_string_lossy().into_owned();
|
||||
try_add_root(draft, path, &mut self.root_error);
|
||||
}
|
||||
}
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.new_root)
|
||||
.desired_width(240.0)
|
||||
.hint_text("or type a path"),
|
||||
);
|
||||
if ui.button("Add").clicked() && !self.new_root.trim().is_empty() {
|
||||
let path = self.new_root.trim().to_string();
|
||||
if try_add_root(draft, path, &mut self.root_error) {
|
||||
self.new_root.clear();
|
||||
}
|
||||
self.new_ignore.clear();
|
||||
}
|
||||
});
|
||||
cols[1].label(
|
||||
if let Some(err) = &self.root_error {
|
||||
ui.colored_label(ui.visuals().error_fg_color, err);
|
||||
}
|
||||
ui.separator();
|
||||
|
||||
// --- Filters ---------------------------------------------------
|
||||
ui.heading(egui::RichText::new("Content filters").strong());
|
||||
ui.columns(2, |cols| {
|
||||
cols[0].label("Full-text extensions whitelist (empty = all supported):");
|
||||
cols[0].add(
|
||||
egui::TextEdit::multiline(&mut self.ext_filter_text)
|
||||
.desired_rows(4)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text("txt\nmd\npdf"),
|
||||
);
|
||||
cols[1].label("Ignore patterns (excluded entirely):");
|
||||
let mut remove_pat: Option<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(
|
||||
"Changes apply on Apply & Save (may trigger a rebuild). \
|
||||
Session-only filters are shown and removed on the Search tab.",
|
||||
"Reindex interval, symlinks, hidden files, tokenizer, and size \
|
||||
limits are in Options (⚙ in the toolbar).",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
|
||||
if ui
|
||||
.add(crate::ui_util::bordered_button(
|
||||
"Apply & Save",
|
||||
crate::ui_util::BLUE,
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
let mut new_config = draft.clone();
|
||||
new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text);
|
||||
let roots = new_config.paths.indexing_paths.clone();
|
||||
new_config
|
||||
.indexing
|
||||
.root_workers
|
||||
.retain(|root, _| roots.contains(root));
|
||||
actions.apply_config = Some(new_config);
|
||||
self.baseline = None;
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
||||
// --- Indexing options -------------------------------------------
|
||||
ui.heading("Indexing options");
|
||||
config_editor_ui(ui, draft, Section::Indexing);
|
||||
ui.add_space(4.0);
|
||||
config_editor_ui(ui, draft, Section::Processing);
|
||||
ui.add_space(8.0);
|
||||
|
||||
if ui
|
||||
.add(crate::ui_util::bordered_button(
|
||||
"Apply & Save",
|
||||
crate::ui_util::BLUE,
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
let mut new_config = draft.clone();
|
||||
new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text);
|
||||
let roots = new_config.paths.indexing_paths.clone();
|
||||
new_config
|
||||
.indexing
|
||||
.root_workers
|
||||
.retain(|root, _| roots.contains(root));
|
||||
actions.apply_config = Some(new_config);
|
||||
self.baseline = None;
|
||||
}
|
||||
});
|
||||
crate::ui_util::more_below_hint(ui, &scroll);
|
||||
|
||||
actions
|
||||
|
|
@ -490,7 +518,11 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) {
|
|||
group_thousands(r.walked as u64),
|
||||
workers
|
||||
));
|
||||
ui.add(egui::ProgressBar::new(0.0).animate(true).desired_width(160.0));
|
||||
ui.add(
|
||||
egui::ProgressBar::new(0.0)
|
||||
.animate(true)
|
||||
.desired_width(160.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -546,7 +578,12 @@ mod tests {
|
|||
let cfg = Config::default();
|
||||
let mut tab = synced_tab(&cfg);
|
||||
// Stage an edit, then sync against the unchanged config.
|
||||
tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.log".into());
|
||||
tab.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.indexing
|
||||
.ignore_patterns
|
||||
.push("*.log".into());
|
||||
tab.sync_editors(&cfg);
|
||||
assert!(tab
|
||||
.draft
|
||||
|
|
@ -587,7 +624,10 @@ mod tests {
|
|||
tab.sync_editors(&external);
|
||||
let draft = tab.draft.as_ref().unwrap();
|
||||
assert!(!draft.indexing.ignore_patterns.contains(&removed));
|
||||
assert!(draft.indexing.ignore_patterns.contains(&"*.log".to_string()));
|
||||
assert!(draft
|
||||
.indexing
|
||||
.ignore_patterns
|
||||
.contains(&"*.log".to_string()));
|
||||
assert_eq!(tab.baseline.as_ref().unwrap(), &external);
|
||||
}
|
||||
|
||||
|
|
@ -595,21 +635,34 @@ mod tests {
|
|||
fn dirty_draft_adopts_sections_owned_elsewhere() {
|
||||
let cfg = Config::default();
|
||||
let mut tab = synced_tab(&cfg);
|
||||
tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.bak".into());
|
||||
tab.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.indexing
|
||||
.ignore_patterns
|
||||
.push("*.bak".into());
|
||||
// The fuzzy toggle saves the config directly, outside this tab.
|
||||
let mut external = cfg.clone();
|
||||
external.search.fuzzy_default = !cfg.search.fuzzy_default;
|
||||
tab.sync_editors(&external);
|
||||
let draft = tab.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.search.fuzzy_default, external.search.fuzzy_default);
|
||||
assert!(draft.indexing.ignore_patterns.contains(&"*.bak".to_string()));
|
||||
assert!(draft
|
||||
.indexing
|
||||
.ignore_patterns
|
||||
.contains(&"*.bak".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_pattern_is_not_duplicated_into_a_draft_that_has_it() {
|
||||
let cfg = Config::default();
|
||||
let mut tab = synced_tab(&cfg);
|
||||
tab.draft.as_mut().unwrap().indexing.ignore_patterns.push("*.log".into());
|
||||
tab.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.indexing
|
||||
.ignore_patterns
|
||||
.push("*.log".into());
|
||||
let mut external = cfg.clone();
|
||||
external.indexing.ignore_patterns.push("*.log".into());
|
||||
tab.sync_editors(&external);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
//! window and the Manage Index tab. Edits happen on a draft; Apply
|
||||
//! validates, saves, and hands the new config to the app.
|
||||
|
||||
use crate::keychain;
|
||||
use quicksearch_core::config::Config;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -35,6 +36,10 @@ pub struct OptionsOutput {
|
|||
pub struct OptionsWindow {
|
||||
pub open: bool,
|
||||
draft: Option<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 {
|
||||
|
|
@ -42,12 +47,31 @@ impl OptionsWindow {
|
|||
OptionsWindow {
|
||||
open: false,
|
||||
draft: None,
|
||||
keychain_probed_for: None,
|
||||
keychain_active: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_with(&mut self, current: &Config) {
|
||||
self.open = true;
|
||||
self.draft = Some(current.clone());
|
||||
self.keychain_probed_for = None;
|
||||
}
|
||||
|
||||
/// True when this index's key really is in the OS keychain: the
|
||||
/// preference is on *and* the keychain answers with an entry (a dead
|
||||
/// daemon, a locked keyring or a denied prompt all read as "no", which
|
||||
/// is exactly when the startup prompt still appears). Probed when the
|
||||
/// window opens and whenever the preference changes — a keychain read
|
||||
/// is an IPC round trip, far too costly to repeat every frame.
|
||||
fn keychain_active(&mut self, current: &Config) -> bool {
|
||||
if self.keychain_probed_for != Some(current.security.use_keychain) {
|
||||
let db_path = current.resolved_database_path();
|
||||
self.keychain_active = current.security.use_keychain
|
||||
&& matches!(keychain::load_key(&db_path.to_string_lossy()), Ok(Some(_)));
|
||||
self.keychain_probed_for = Some(current.security.use_keychain);
|
||||
}
|
||||
self.keychain_active
|
||||
}
|
||||
|
||||
/// Render; reports an applied draft config and/or a security action.
|
||||
|
|
@ -61,6 +85,7 @@ impl OptionsWindow {
|
|||
}
|
||||
let mut out = OptionsOutput::default();
|
||||
let mut open = self.open;
|
||||
let keychain_active = self.keychain_active(current);
|
||||
let draft = self.draft.as_mut().unwrap();
|
||||
|
||||
egui::Window::new("Options")
|
||||
|
|
@ -68,8 +93,8 @@ impl OptionsWindow {
|
|||
.resizable(false)
|
||||
.default_width(420.0)
|
||||
.show(ctx, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| {
|
||||
ui.heading("Paths");
|
||||
let scroll = egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| {
|
||||
ui.heading(egui::RichText::new("Paths").strong());
|
||||
egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Database file");
|
||||
ui.add(
|
||||
|
|
@ -87,19 +112,27 @@ impl OptionsWindow {
|
|||
);
|
||||
ui.separator();
|
||||
|
||||
ui.heading("Indexing");
|
||||
ui.heading(egui::RichText::new("Indexing").strong());
|
||||
config_editor_ui(ui, draft, Section::Indexing);
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Automatic and manual indexing are switched on the \
|
||||
Manage Index tab.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.separator();
|
||||
|
||||
ui.heading("Processing");
|
||||
ui.heading(egui::RichText::new("Processing").strong());
|
||||
config_editor_ui(ui, draft, Section::Processing);
|
||||
ui.separator();
|
||||
|
||||
ui.heading("Search");
|
||||
ui.heading(egui::RichText::new("Search").strong());
|
||||
config_editor_ui(ui, draft, Section::Search);
|
||||
ui.separator();
|
||||
|
||||
ui.heading("Interface");
|
||||
ui.heading(egui::RichText::new("Interface").strong());
|
||||
egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| {
|
||||
ui.label("UI scale");
|
||||
ui.add(
|
||||
|
|
@ -120,9 +153,10 @@ impl OptionsWindow {
|
|||
// action opens its own confirmation flow immediately.
|
||||
// The KDF salt is deliberately never shown here (or
|
||||
// anywhere else in the GUI).
|
||||
ui.heading("Security");
|
||||
out.security = security_ui(ui, current);
|
||||
ui.heading(egui::RichText::new("Security").strong());
|
||||
out.security = security_ui(ui, current, keychain_active);
|
||||
});
|
||||
crate::ui_util::more_below_hint(ui, &scroll);
|
||||
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
|
|
@ -149,10 +183,21 @@ impl OptionsWindow {
|
|||
}
|
||||
|
||||
/// The Security block: status plus action buttons. Never renders the salt.
|
||||
fn security_ui(ui: &mut egui::Ui, current: &Config) -> Option<SecurityAction> {
|
||||
fn security_ui(
|
||||
ui: &mut egui::Ui,
|
||||
current: &Config,
|
||||
keychain_active: bool,
|
||||
) -> Option<SecurityAction> {
|
||||
let mut action = None;
|
||||
if current.security.password_protected {
|
||||
ui.label("The index is encrypted; a password is asked for at startup.");
|
||||
if keychain_active {
|
||||
ui.label(
|
||||
"The index is encrypted; its password is securely stored by \
|
||||
your Operating System.",
|
||||
);
|
||||
} else {
|
||||
ui.label("The index is encrypted; a password is required at startup.");
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Change password…").clicked() {
|
||||
action = Some(SecurityAction::ChangePassword);
|
||||
|
|
@ -195,10 +240,10 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section
|
|||
match section {
|
||||
Section::Indexing => {
|
||||
egui::Grid::new("cfg-indexing").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Automatic indexing");
|
||||
ui.checkbox(&mut config.indexing.auto_index, "watchers + periodic reindex");
|
||||
ui.end_row();
|
||||
|
||||
// Automatic vs manual is deliberately absent: it is live
|
||||
// state, switched (and saved) by the Stop / Return to
|
||||
// Automatic buttons on the Manage Index tab. A staged copy
|
||||
// of it here would fight those buttons.
|
||||
ui.label("Full reindex every");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
|
|
|
|||
|
|
@ -81,9 +81,7 @@ pub fn classify(text: &str) -> Vec<Seg> {
|
|||
let valid = if is_regex {
|
||||
let first = !regex_seen;
|
||||
regex_seen = true;
|
||||
first
|
||||
&& op == Op::Contains
|
||||
&& RegexQuery::new(&value).is_ok()
|
||||
first && op == Op::Contains && RegexQuery::new(&value).is_ok()
|
||||
} else {
|
||||
build_filter(word, op, &value, value_is_word).is_ok()
|
||||
};
|
||||
|
|
@ -406,7 +404,11 @@ mod tests {
|
|||
let segs = classify(text);
|
||||
let mut cursor = 0usize;
|
||||
for s in &segs {
|
||||
assert_eq!(s.range.start, cursor, "gap or overlap in {:?}: {:?}", text, segs);
|
||||
assert_eq!(
|
||||
s.range.start, cursor,
|
||||
"gap or overlap in {:?}: {:?}",
|
||||
text, segs
|
||||
);
|
||||
assert!(s.range.end > s.range.start, "empty seg in {:?}", text);
|
||||
cursor = s.range.end;
|
||||
}
|
||||
|
|
@ -416,7 +418,9 @@ mod tests {
|
|||
use Class::*;
|
||||
|
||||
fn owned(v: Vec<(&str, Class, bool)>) -> Vec<(String, Class, bool)> {
|
||||
v.into_iter().map(|(s, c, b)| (s.to_string(), c, b)).collect()
|
||||
v.into_iter()
|
||||
.map(|(s, c, b)| (s.to_string(), c, b))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -460,7 +464,9 @@ mod tests {
|
|||
assert_eq!(all[0].1, Keyword, "{:?}", input);
|
||||
assert_eq!(all[1].1, Operator, "{:?}", input);
|
||||
assert!(
|
||||
all[2..].iter().all(|(_, c, _)| *c == Argument || *c == Operator),
|
||||
all[2..]
|
||||
.iter()
|
||||
.all(|(_, c, _)| *c == Argument || *c == Operator),
|
||||
"{:?}: {:?}",
|
||||
input,
|
||||
all
|
||||
|
|
@ -514,10 +520,7 @@ mod tests {
|
|||
(r"C:\Users\me", Argument, true),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
segs(r"C:\data"),
|
||||
owned(vec![(r"C:\data", Plain, false)])
|
||||
);
|
||||
assert_eq!(segs(r"C:\data"), owned(vec![(r"C:\data", Plain, false)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -540,8 +543,9 @@ mod tests {
|
|||
])
|
||||
);
|
||||
// Quoted stars are literal — content stays plain.
|
||||
assert!(segs("\"a*b\"").iter().all(|(s, c, _)| s == "\""
|
||||
|| *c == Plain));
|
||||
assert!(segs("\"a*b\"")
|
||||
.iter()
|
||||
.all(|(s, c, _)| s == "\"" || *c == Plain));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -640,7 +644,12 @@ mod tests {
|
|||
fn invalid_arguments_go_error_uniformly() {
|
||||
// (`regex:(` is not here: `(` lexes as a paren, so that input is an
|
||||
// *incomplete* filter — bare-key optimism applies, not an error.)
|
||||
for input in ["type:NotAThing", "modified:>=tomorrow", "regex:[", "type:Doc*"] {
|
||||
for input in [
|
||||
"type:NotAThing",
|
||||
"modified:>=tomorrow",
|
||||
"regex:[",
|
||||
"type:Doc*",
|
||||
] {
|
||||
let all = segs(input);
|
||||
assert_eq!(all[0].1, Keyword, "{:?}", input);
|
||||
let last = all.last().unwrap();
|
||||
|
|
@ -703,11 +712,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn demoted_operators_stay_plain() {
|
||||
assert!(
|
||||
segs("(alpha AND beta) OR gamma")
|
||||
.iter()
|
||||
.all(|(_, c, chip)| *c == Plain && !chip)
|
||||
);
|
||||
assert!(segs("(alpha AND beta) OR gamma")
|
||||
.iter()
|
||||
.all(|(_, c, chip)| *c == Plain && !chip));
|
||||
// Dangling comparators are literal text.
|
||||
assert!(segs("a > b").iter().all(|(_, c, _)| *c == Plain));
|
||||
// Leading operator, nothing else.
|
||||
|
|
|
|||
|
|
@ -204,7 +204,10 @@ impl SearchTab {
|
|||
self.order.sort_by(|&a, &b| {
|
||||
let (a, b) = (&results[a as usize], &results[b as usize]);
|
||||
let ord = match key {
|
||||
SortKey::Rank => a.rank.partial_cmp(&b.rank).unwrap_or(std::cmp::Ordering::Equal),
|
||||
SortKey::Rank => a
|
||||
.rank
|
||||
.partial_cmp(&b.rank)
|
||||
.unwrap_or(std::cmp::Ordering::Equal),
|
||||
SortKey::Name => a.name.cmp(&b.name),
|
||||
SortKey::Path => a.path.cmp(&b.path),
|
||||
SortKey::Size => a.size.cmp(&b.size),
|
||||
|
|
@ -233,8 +236,7 @@ impl SearchTab {
|
|||
fn sort_header(&mut self, ui: &mut egui::Ui, key: SortKey, label: &str) {
|
||||
let (cur, asc) = self.sort;
|
||||
let selected = cur == key;
|
||||
let (rect, response) =
|
||||
ui.allocate_exact_size(ui.available_size(), egui::Sense::click());
|
||||
let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click());
|
||||
if ui.is_rect_visible(rect) {
|
||||
if response.hovered() {
|
||||
ui.painter()
|
||||
|
|
@ -344,7 +346,7 @@ impl SearchTab {
|
|||
let mut remove: Option<usize> = None;
|
||||
for (i, pattern) in self.session_ignores.iter().enumerate() {
|
||||
if ui
|
||||
.small_button(format!("{} ✕", pattern))
|
||||
.small_button(format!("{} 🗙", pattern))
|
||||
.on_hover_text("Remove this session filter")
|
||||
.clicked()
|
||||
{
|
||||
|
|
@ -385,11 +387,9 @@ impl SearchTab {
|
|||
// `animate_value_with_time` keeps requesting repaints until the
|
||||
// value settles.
|
||||
let fade_target = if self.swap_pending { 0.0 } else { 1.0 };
|
||||
let fade = ui.ctx().animate_value_with_time(
|
||||
egui::Id::new("qs-results-fade"),
|
||||
fade_target,
|
||||
0.25,
|
||||
);
|
||||
let fade =
|
||||
ui.ctx()
|
||||
.animate_value_with_time(egui::Id::new("qs-results-fade"), fade_target, 0.25);
|
||||
if self.swap_pending && fade <= 0.01 {
|
||||
self.results = std::mem::take(&mut self.staging);
|
||||
self.has_snippets = self.staging_has_snippets;
|
||||
|
|
@ -426,170 +426,172 @@ impl SearchTab {
|
|||
let mut open_ignore_dialog: Option<usize> = None;
|
||||
let mut hovered_now: Option<usize> = None;
|
||||
|
||||
let table_scroll = ui.push_id("results", |ui| {
|
||||
let mut table = TableBuilder::new(ui)
|
||||
.striped(true)
|
||||
.resizable(true)
|
||||
.sense(egui::Sense::click())
|
||||
.max_scroll_height(table_height)
|
||||
.min_scrolled_height(60.0)
|
||||
.column(Column::initial(220.0).at_least(80.0).clip(true)) // name
|
||||
.column(Column::remainder().at_least(120.0).clip(true)); // path
|
||||
if self.has_snippets {
|
||||
table = table.column(Column::remainder().at_least(120.0).clip(true));
|
||||
}
|
||||
table = table
|
||||
.column(Column::exact(72.0)) // size
|
||||
.column(Column::exact(110.0)) // modified
|
||||
.column(Column::exact(52.0)); // rank
|
||||
let table_scroll = ui
|
||||
.push_id("results", |ui| {
|
||||
let mut table = TableBuilder::new(ui)
|
||||
.striped(true)
|
||||
.resizable(true)
|
||||
.sense(egui::Sense::click())
|
||||
.max_scroll_height(table_height)
|
||||
.min_scrolled_height(60.0)
|
||||
.column(Column::initial(220.0).at_least(80.0).clip(true)) // name
|
||||
.column(Column::remainder().at_least(120.0).clip(true)); // path
|
||||
if self.has_snippets {
|
||||
table = table.column(Column::remainder().at_least(120.0).clip(true));
|
||||
}
|
||||
table = table
|
||||
.column(Column::exact(72.0)) // size
|
||||
.column(Column::exact(110.0)) // modified
|
||||
.column(Column::exact(52.0)); // rank
|
||||
|
||||
table
|
||||
.header(text_height + 4.0, |mut header| {
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Name, "Name"));
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Path, "Path"));
|
||||
if self.has_snippets {
|
||||
header.col(|ui| {
|
||||
ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new("Match").strong());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Size, "Size"));
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified"));
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank"));
|
||||
})
|
||||
.body(|body| {
|
||||
let order = self.order.clone();
|
||||
body.rows(text_height, order.len(), |mut row| {
|
||||
let display_ix = row.index();
|
||||
let result_ix = order[display_ix] as usize;
|
||||
let hit = &self.results[result_ix];
|
||||
row.set_selected(self.selected == Some(result_ix as u32));
|
||||
row.set_hovered(self.hovered_row == Some(display_ix));
|
||||
|
||||
// Labels stay selectable for copy-paste, which makes
|
||||
// them win egui's hit-test over the row. Collect their
|
||||
// responses and union them into the row's below so
|
||||
// clicks land even when the pointer is over glyphs.
|
||||
let mut cell_responses: Vec<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()));
|
||||
});
|
||||
table
|
||||
.header(text_height + 4.0, |mut header| {
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Name, "Name"));
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Path, "Path"));
|
||||
if self.has_snippets {
|
||||
let snippet = hit.snippet.clone();
|
||||
// Name and path matches show a whole field, so
|
||||
// they render bracketed: [matched field].
|
||||
let whole_field =
|
||||
hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9;
|
||||
row.col(|ui| {
|
||||
if let Some(snip) = &snippet {
|
||||
let width = ui.available_width();
|
||||
let job = centered_match_job(ui, snip, width, whole_field);
|
||||
let mut response = ui
|
||||
.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| ui.label(job),
|
||||
)
|
||||
.inner;
|
||||
if !snip.ranges.is_empty() {
|
||||
let hover = snip.clone();
|
||||
response = response.on_hover_ui(|ui| {
|
||||
ui.set_max_width(520.0);
|
||||
let job = snippet_job(ui, &hover, 10);
|
||||
ui.label(job);
|
||||
});
|
||||
}
|
||||
cell_responses.push(response);
|
||||
}
|
||||
header.col(|ui| {
|
||||
ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| {
|
||||
ui.label(egui::RichText::new("Match").strong());
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
row.col(|ui| {
|
||||
let response = ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| ui.label(human_size(hit.size)),
|
||||
);
|
||||
cell_responses.push(response.inner);
|
||||
});
|
||||
row.col(|ui| {
|
||||
let color = recency_color(ui, hit.mtime);
|
||||
let response = ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(fmt_mtime(hit.mtime)).color(color),
|
||||
)
|
||||
},
|
||||
);
|
||||
cell_responses.push(response.inner);
|
||||
});
|
||||
row.col(|ui| {
|
||||
let response = ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(" {:.2} ", hit.rank))
|
||||
.background_color(rank_tier_color(hit.stage))
|
||||
.color(egui::Color32::from_rgb(32, 32, 32)),
|
||||
)
|
||||
},
|
||||
);
|
||||
cell_responses.push(response.inner);
|
||||
});
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Size, "Size"));
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified"));
|
||||
header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank"));
|
||||
})
|
||||
.body(|body| {
|
||||
let order = self.order.clone();
|
||||
body.rows(text_height, order.len(), |mut row| {
|
||||
let display_ix = row.index();
|
||||
let result_ix = order[display_ix] as usize;
|
||||
let hit = &self.results[result_ix];
|
||||
row.set_selected(self.selected == Some(result_ix as u32));
|
||||
row.set_hovered(self.hovered_row == Some(display_ix));
|
||||
|
||||
let mut response = row.response();
|
||||
for r in cell_responses {
|
||||
response = response | r;
|
||||
}
|
||||
if response.contains_pointer() {
|
||||
hovered_now = Some(display_ix);
|
||||
}
|
||||
if response.clicked() || response.secondary_clicked() {
|
||||
self.selected = Some(result_ix as u32);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
platform::open_file(&self.results[result_ix].path);
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
let path = self.results[result_ix].path.clone();
|
||||
if ui.button("Open containing folder").clicked() {
|
||||
platform::reveal_in_folder(&path);
|
||||
ui.close();
|
||||
// Labels stay selectable for copy-paste, which makes
|
||||
// them win egui's hit-test over the row. Collect their
|
||||
// responses and union them into the row's below so
|
||||
// clicks land even when the pointer is over glyphs.
|
||||
let mut cell_responses: Vec<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 {
|
||||
let snippet = hit.snippet.clone();
|
||||
// Name and path matches show a whole field, so
|
||||
// they render bracketed: [matched field].
|
||||
let whole_field =
|
||||
hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9;
|
||||
row.col(|ui| {
|
||||
if let Some(snip) = &snippet {
|
||||
let width = ui.available_width();
|
||||
let job = centered_match_job(ui, snip, width, whole_field);
|
||||
let mut response = ui
|
||||
.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| ui.label(job),
|
||||
)
|
||||
.inner;
|
||||
if !snip.ranges.is_empty() {
|
||||
let hover = snip.clone();
|
||||
response = response.on_hover_ui(|ui| {
|
||||
ui.set_max_width(520.0);
|
||||
let job = snippet_job(ui, &hover, 10);
|
||||
ui.label(job);
|
||||
});
|
||||
}
|
||||
cell_responses.push(response);
|
||||
}
|
||||
});
|
||||
}
|
||||
if ui.button("Open").clicked() {
|
||||
platform::open_file(&path);
|
||||
ui.close();
|
||||
row.col(|ui| {
|
||||
let response = ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| ui.label(human_size(hit.size)),
|
||||
);
|
||||
cell_responses.push(response.inner);
|
||||
});
|
||||
row.col(|ui| {
|
||||
let color = recency_color(ui, hit.mtime);
|
||||
let response = ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(fmt_mtime(hit.mtime)).color(color),
|
||||
)
|
||||
},
|
||||
);
|
||||
cell_responses.push(response.inner);
|
||||
});
|
||||
row.col(|ui| {
|
||||
let response = ui.with_layout(
|
||||
egui::Layout::centered_and_justified(
|
||||
egui::Direction::LeftToRight,
|
||||
),
|
||||
|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(" {:.2} ", hit.rank))
|
||||
.background_color(rank_tier_color(hit.stage))
|
||||
.color(egui::Color32::from_rgb(32, 32, 32)),
|
||||
)
|
||||
},
|
||||
);
|
||||
cell_responses.push(response.inner);
|
||||
});
|
||||
|
||||
let mut response = row.response();
|
||||
for r in cell_responses {
|
||||
response = response | r;
|
||||
}
|
||||
if ui.button("Copy path").clicked() {
|
||||
ui.ctx().copy_text(path.clone());
|
||||
ui.close();
|
||||
if response.contains_pointer() {
|
||||
hovered_now = Some(display_ix);
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Build ignore filter…").clicked() {
|
||||
open_ignore_dialog = Some(result_ix);
|
||||
ui.close();
|
||||
if response.clicked() || response.secondary_clicked() {
|
||||
self.selected = Some(result_ix as u32);
|
||||
}
|
||||
if response.double_clicked() {
|
||||
platform::open_file(&self.results[result_ix].path);
|
||||
}
|
||||
response.context_menu(|ui| {
|
||||
let path = self.results[result_ix].path.clone();
|
||||
if ui.button("Open containing folder").clicked() {
|
||||
platform::reveal_in_folder(&path);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Open").clicked() {
|
||||
platform::open_file(&path);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Copy path").clicked() {
|
||||
ui.ctx().copy_text(path.clone());
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Build ignore filter…").clicked() {
|
||||
open_ignore_dialog = Some(result_ix);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
crate::ui_util::more_below_hint(ui, &table_scroll);
|
||||
self.hovered_row = hovered_now;
|
||||
|
||||
|
|
@ -642,25 +644,20 @@ impl SearchTab {
|
|||
ui.separator();
|
||||
|
||||
// --- Extension ---------------------------------------------
|
||||
ui.horizontal(|ui| {
|
||||
match &dialog.ext_pattern {
|
||||
Some(ext) => {
|
||||
ui.monospace(ext);
|
||||
ui.with_layout(
|
||||
egui::Layout::right_to_left(egui::Align::Center),
|
||||
|ui| {
|
||||
if ui
|
||||
.add(bordered_button("Ignore this extension", ORANGE))
|
||||
.clicked()
|
||||
{
|
||||
chosen = Some(ext.clone());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
None => {
|
||||
ui.label(egui::RichText::new("(no file extension)").weak());
|
||||
}
|
||||
ui.horizontal(|ui| match &dialog.ext_pattern {
|
||||
Some(ext) => {
|
||||
ui.monospace(ext);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.add(bordered_button("Ignore this extension", ORANGE))
|
||||
.clicked()
|
||||
{
|
||||
chosen = Some(ext.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
None => {
|
||||
ui.label(egui::RichText::new("(no file extension)").weak());
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
|
|
@ -771,8 +768,7 @@ impl SearchTab {
|
|||
ui,
|
||||
"regex:\"(foo|bar)\\d+\"",
|
||||
"regular expression, matched against names, contents, \
|
||||
and paths; case-insensitive — use (?-i:…) to override; \
|
||||
quote patterns containing spaces",
|
||||
and paths",
|
||||
);
|
||||
row(
|
||||
ui,
|
||||
|
|
@ -984,17 +980,17 @@ fn centered_match_job(
|
|||
/// tiers. Dark text on these pastels stays readable in both themes.
|
||||
fn rank_tier_color(stage: u8) -> egui::Color32 {
|
||||
match stage {
|
||||
1 => egui::Color32::from_rgb(255, 127, 127), // S
|
||||
2 => egui::Color32::from_rgb(255, 191, 127), // A
|
||||
3 => egui::Color32::from_rgb(255, 223, 127), // B
|
||||
4 => egui::Color32::from_rgb(255, 255, 127), // C
|
||||
5 => egui::Color32::from_rgb(191, 255, 127), // D
|
||||
6 => egui::Color32::from_rgb(127, 255, 127), // E
|
||||
7 => egui::Color32::from_rgb(127, 191, 255), // F
|
||||
8 => egui::Color32::from_rgb(191, 127, 255), // G
|
||||
9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case
|
||||
1 => egui::Color32::from_rgb(255, 127, 127), // S
|
||||
2 => egui::Color32::from_rgb(255, 191, 127), // A
|
||||
3 => egui::Color32::from_rgb(255, 223, 127), // B
|
||||
4 => egui::Color32::from_rgb(255, 255, 127), // C
|
||||
5 => egui::Color32::from_rgb(191, 255, 127), // D
|
||||
6 => egui::Color32::from_rgb(127, 255, 127), // E
|
||||
7 => egui::Color32::from_rgb(127, 191, 255), // F
|
||||
8 => egui::Color32::from_rgb(191, 127, 255), // G
|
||||
9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case
|
||||
10 => egui::Color32::from_rgb(239, 191, 239), // I — path, any case
|
||||
_ => egui::Color32::from_rgb(199, 199, 199), // J — fuzzy path
|
||||
_ => egui::Color32::from_rgb(199, 199, 199), // J — fuzzy path
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,9 +42,7 @@ impl SpeedTracker {
|
|||
self.points.push((now, files_processed));
|
||||
// Prune old points, but always keep at least two so a slow but
|
||||
// steady rate never becomes unmeasurable.
|
||||
while self.points.len() > 2
|
||||
&& now.duration_since(self.points[0].0) > HISTORY
|
||||
{
|
||||
while self.points.len() > 2 && now.duration_since(self.points[0].0) > HISTORY {
|
||||
self.points.remove(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -90,7 +88,9 @@ mod tests {
|
|||
for i in 0..4 {
|
||||
t.record_at(base + Duration::from_millis(2500 * i), 10 + i as usize);
|
||||
}
|
||||
let rate = t.files_per_sec_at(base + Duration::from_millis(7500)).unwrap();
|
||||
let rate = t
|
||||
.files_per_sec_at(base + Duration::from_millis(7500))
|
||||
.unwrap();
|
||||
assert!((rate - 0.4).abs() < 0.01, "expected ~0.4/s, got {}", rate);
|
||||
}
|
||||
|
||||
|
|
@ -113,7 +113,11 @@ mod tests {
|
|||
let just_after = t.files_per_sec_at(base + Duration::from_secs(1)).unwrap();
|
||||
let stalled = t.files_per_sec_at(base + Duration::from_secs(20)).unwrap();
|
||||
assert!(just_after > 90.0);
|
||||
assert!(stalled < 6.0, "estimate must decay during a stall: {}", stalled);
|
||||
assert!(
|
||||
stalled < 6.0,
|
||||
"estimate must decay during a stall: {}",
|
||||
stalled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -28,9 +28,23 @@ pub fn ignore_pattern_valid(pattern: &str) -> bool {
|
|||
!trimmed.is_empty() && IgnoreSet::compile(&[pattern.to_string()]).is_ok()
|
||||
}
|
||||
|
||||
/// Border color for a pattern editor holding `text`, or `None` to keep the
|
||||
/// theme's own border. A blank box is not wrong yet, just unfilled, so it
|
||||
/// stays neutral; only text the user actually typed is judged.
|
||||
fn pattern_border(text: &str) -> Option<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
|
||||
/// a valid pattern and a red one otherwise. Returns the response and the
|
||||
/// validity of the text as it stands after this frame's edits.
|
||||
/// a valid pattern, a red one while it is not, and the theme's neutral
|
||||
/// border while it is empty. Returns the response and the validity of the
|
||||
/// text as it stands after this frame's edits.
|
||||
pub fn pattern_edit(
|
||||
ui: &mut egui::Ui,
|
||||
text: &mut String,
|
||||
|
|
@ -38,16 +52,19 @@ pub fn pattern_edit(
|
|||
hint: &str,
|
||||
) -> (egui::Response, bool) {
|
||||
let mut valid = ignore_pattern_valid(text);
|
||||
let stroke = egui::Stroke::new(1.0, if valid { VALID_GREEN } else { INVALID_RED });
|
||||
let border = pattern_border(text);
|
||||
let response = ui
|
||||
.scope(|ui| {
|
||||
// TextEdit frames with widgets.*.bg_stroke when unfocused and
|
||||
// selection.stroke when focused; recolor all of them.
|
||||
let v = ui.visuals_mut();
|
||||
v.widgets.inactive.bg_stroke = stroke;
|
||||
v.widgets.hovered.bg_stroke = stroke;
|
||||
v.widgets.active.bg_stroke = stroke;
|
||||
v.selection.stroke = stroke;
|
||||
if let Some(color) = border {
|
||||
let stroke = egui::Stroke::new(1.0, color);
|
||||
let v = ui.visuals_mut();
|
||||
v.widgets.inactive.bg_stroke = stroke;
|
||||
v.widgets.hovered.bg_stroke = stroke;
|
||||
v.widgets.active.bg_stroke = stroke;
|
||||
v.selection.stroke = stroke;
|
||||
}
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(text)
|
||||
.desired_width(desired_width)
|
||||
|
|
@ -63,18 +80,20 @@ pub fn pattern_edit(
|
|||
}
|
||||
|
||||
/// Paint a semitransparent down-arrow near the bottom edge of a scroll
|
||||
/// area while more content lies below the fold. Painter-only on the
|
||||
/// foreground layer, so it can never swallow clicks. (The bundled fonts
|
||||
/// have no ▼ glyph — this is a shape, like the sort-header triangles.)
|
||||
/// area while more content lies below the fold. Painter-only, so it can
|
||||
/// never swallow clicks. (The bundled fonts have no ▼ glyph — this is a
|
||||
/// shape, like the sort-header triangles.)
|
||||
///
|
||||
/// The hint is painted on the caller's own layer, unclipped: last in that
|
||||
/// layer, so it sits above the scrolled content, but still below anything
|
||||
/// stacked over it — a tab's hint stays under the Options window rather
|
||||
/// than punching through it.
|
||||
pub fn more_below_hint<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;
|
||||
if !more_below {
|
||||
return;
|
||||
}
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(
|
||||
egui::Order::Foreground,
|
||||
egui::Id::new("qs-more-below-hint"),
|
||||
));
|
||||
let painter = ui.ctx().layer_painter(ui.layer_id());
|
||||
let cx = out.inner_rect.center().x;
|
||||
let tip = out.inner_rect.bottom() - 5.0;
|
||||
let (half_width, height) = (7.0, 6.0);
|
||||
|
|
@ -99,7 +118,7 @@ pub fn more_below_hint<R>(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutp
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ignore_pattern_valid;
|
||||
use super::{ignore_pattern_valid, pattern_border, INVALID_RED, VALID_GREEN};
|
||||
|
||||
#[test]
|
||||
fn blank_patterns_are_invalid() {
|
||||
|
|
@ -126,4 +145,22 @@ mod tests {
|
|||
assert!(ignore_pattern_valid("C:\\Windows\\Temp\\*"));
|
||||
assert!(ignore_pattern_valid("cache-??")); // wildcards
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_editor_keeps_the_theme_border() {
|
||||
// Nothing typed yet is not an error to flag.
|
||||
assert_eq!(pattern_border(""), None);
|
||||
assert_eq!(pattern_border(" "), None);
|
||||
assert_eq!(pattern_border("\t\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_text_is_judged() {
|
||||
assert_eq!(pattern_border("*.tmp"), Some(VALID_GREEN));
|
||||
assert_eq!(pattern_border(" node_modules "), Some(VALID_GREEN));
|
||||
assert_eq!(pattern_border("foo["), Some(INVALID_RED));
|
||||
// Typed, but trims away to nothing under the pattern rules — still
|
||||
// worth flagging, unlike a box the user simply has not filled in.
|
||||
assert_eq!(pattern_border("/"), Some(INVALID_RED));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue