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