Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb1500324f | |||
| 658e32159a | |||
| 22d52d5ac0 | |||
| 488038c080 |
48 changed files with 5419 additions and 661 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -3108,7 +3108,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quicksearch-core"
|
name = "quicksearch-core"
|
||||||
version = "1.1.7"
|
version = "1.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"cfb",
|
"cfb",
|
||||||
|
|
@ -3150,23 +3150,22 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quicksearch-gui"
|
name = "quicksearch-gui"
|
||||||
version = "1.1.7"
|
version = "1.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ashpd",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"egui_extras",
|
"egui_extras",
|
||||||
"futures-channel",
|
|
||||||
"futures-util",
|
|
||||||
"global-hotkey",
|
"global-hotkey",
|
||||||
"keyring",
|
"keyring",
|
||||||
"open",
|
"open",
|
||||||
"pollster",
|
|
||||||
"quicksearch-core",
|
"quicksearch-core",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"rfd",
|
"rfd",
|
||||||
"rpassword",
|
"rpassword",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ pdf-extract = { path = "vendor/pdf-extract" } # Patched unbounded reads which co
|
||||||
rtf-parser = { path = "vendor/rtf-parser" } # Patched a parsing error which occurs on UTF-16 characters
|
rtf-parser = { path = "vendor/rtf-parser" } # Patched a parsing error which occurs on UTF-16 characters
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.1.7"
|
version = "1.1.8"
|
||||||
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>"]
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,10 @@ tracking: https://github.com/DataScienceDIY/quick_search
|
||||||
- **Password protection** — optionally encrypt the index, since it contains
|
- **Password protection** — optionally encrypt the index, since it contains
|
||||||
the names and text of everything indexed. The password can be remembered
|
the names and text of everything indexed. The password can be remembered
|
||||||
in your system keychain.
|
in your system keychain.
|
||||||
- **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere
|
- **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere,
|
||||||
while QuickSearch is running, and `quicksearch --toggle` can be bound in
|
starting QuickSearch if it is closed. One click on the Settings tab binds
|
||||||
your desktop's keyboard settings to also start it.
|
it on KDE and GNOME; elsewhere, bind `quicksearch --toggle` in your
|
||||||
|
desktop's keyboard settings.
|
||||||
- **Terminal search** — `quicksearch <query>` prints ranked, pipe-friendly
|
- **Terminal search** — `quicksearch <query>` prints ranked, pipe-friendly
|
||||||
paths; `--long` adds sizes, dates, and highlighted snippets.
|
paths; `--long` adds sizes, dates, and highlighted snippets.
|
||||||
- **Portable mode** — keep the program, its config, and its index together
|
- **Portable mode** — keep the program, its config, and its index together
|
||||||
|
|
|
||||||
|
|
@ -94,8 +94,6 @@ batch_size = 500
|
||||||
# leave another root's walkers parked behind it. 0 gives each turn one
|
# leave another root's walkers parked behind it. 0 gives each turn one
|
||||||
# batch_size quantum and no more. Clamped to 0..10000 on load.
|
# batch_size quantum and no more. Clamped to 0..10000 on load.
|
||||||
writer_turn_slice_ms = 100
|
writer_turn_slice_ms = 100
|
||||||
# Files per transaction for incremental FTS updates.
|
|
||||||
fts_update_batch_size = 1000
|
|
||||||
# How large the write-ahead log (index.sqlite-wal) may grow during an
|
# How large the write-ahead log (index.sqlite-wal) may grow during an
|
||||||
# indexing run before the indexer forces a checkpoint (bytes); left
|
# indexing run before the indexer forces a checkpoint (bytes); left
|
||||||
# alone, the log grows for the whole run. Not a safety knob: on a volume
|
# alone, the log grows for the whole run. Not a safety knob: on a volume
|
||||||
|
|
@ -150,8 +148,9 @@ watch_cap_warned_roots = []
|
||||||
# The shortcut QuickSearch claims for itself while it is running: brings it to
|
# The shortcut QuickSearch claims for itself while it is running: brings it to
|
||||||
# the front, switches to the Search tab and selects whatever is in the search
|
# the front, switches to the Search tab and selects whatever is in the search
|
||||||
# box. Modifiers are Ctrl, Alt and Shift, joined to one key with "+". Leave it
|
# box. Modifiers are Ctrl, Alt and Shift, joined to one key with "+". Leave it
|
||||||
# empty ("") for no shortcut. To start QuickSearch when it is closed, bind
|
# empty ("") for no shortcut. To start QuickSearch when it is closed, use
|
||||||
# "quicksearch --toggle" in your desktop's keyboard settings.
|
# "Set up system shortcut" on the Settings tab, or bind "quicksearch
|
||||||
|
# --toggle" in your desktop's keyboard settings yourself.
|
||||||
search_hotkey = "Ctrl+Shift+F"
|
search_hotkey = "Ctrl+Shift+F"
|
||||||
# 'dark' or 'light'; anything other than 'light' is dark. Applied as soon
|
# 'dark' or 'light'; anything other than 'light' is dark. Applied as soon
|
||||||
# as it is changed on the Settings tab. Following the desktop's own
|
# as it is changed on the Settings tab. Following the desktop's own
|
||||||
|
|
@ -172,7 +171,10 @@ show_advanced_settings = false
|
||||||
tutorial_seen = false
|
tutorial_seen = false
|
||||||
|
|
||||||
[search]
|
[search]
|
||||||
# Start with the fuzzy passes enabled.
|
# Whether the Fuzzy box on the Search tab starts ticked. Off by default: the
|
||||||
|
# fuzzy passes cost noticeable time on every keystroke, and most searches do
|
||||||
|
# not need them. Tick it in the GUI whenever you want typo tolerance for a
|
||||||
|
# session, or set it here to start that way every time.
|
||||||
fuzzy_default = false
|
fuzzy_default = false
|
||||||
# Ceiling on the fuzzy stages' typo budget. The allowance grows with the
|
# Ceiling on the fuzzy stages' typo budget. The allowance grows with the
|
||||||
# search term, one edit per three characters, up to this value, so 2
|
# search term, one edit per three characters, up to this value, so 2
|
||||||
|
|
|
||||||
|
|
@ -136,3 +136,103 @@ mod mime_sniff {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// `repo::update_file_basic`'s state narrowing: an NA→NA update — the whole
|
||||||
|
/// of a re-run over a text-free tree — pays one statement instead of four.
|
||||||
|
/// The pair: `clear_always` reimplements the pre-narrowing shape (the control
|
||||||
|
/// that must not move between builds); `narrowed` is the shipped function.
|
||||||
|
/// NA→NA is idempotent, so iterations are stable without re-seeding.
|
||||||
|
/// DONE→PENDING is deliberately not benched: its statement count is identical
|
||||||
|
/// in both shapes (clearing that must happen either way), so there is nothing
|
||||||
|
/// to regress.
|
||||||
|
mod update_narrowing {
|
||||||
|
use super::*;
|
||||||
|
use quicksearch_core::db::repo::{self, NewFile};
|
||||||
|
use quicksearch_core::mime::FileType;
|
||||||
|
use quicksearch_core::testutil::Scratch;
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
|
||||||
|
const ROWS: usize = 1000;
|
||||||
|
|
||||||
|
fn new_file(name: &str) -> NewFile<'_> {
|
||||||
|
NewFile {
|
||||||
|
name,
|
||||||
|
parent: "/b/",
|
||||||
|
size: 7,
|
||||||
|
mtime: 7,
|
||||||
|
mime: None,
|
||||||
|
ftype: FileType::EMPTY,
|
||||||
|
hash: None,
|
||||||
|
needs_content: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seeded_na(tag: &str) -> (Scratch, Connection) {
|
||||||
|
let (dir, p) = Scratch::db(tag);
|
||||||
|
let mut conn =
|
||||||
|
quicksearch_core::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
|
||||||
|
for name in &names {
|
||||||
|
repo::insert_file(&tx, &new_file(name)).unwrap().unwrap();
|
||||||
|
}
|
||||||
|
tx.commit().unwrap();
|
||||||
|
(dir, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[divan::bench]
|
||||||
|
fn clear_always(bencher: Bencher) {
|
||||||
|
let (_dir, mut conn) = seeded_na("bench-update-old");
|
||||||
|
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
|
||||||
|
bencher.bench_local(move || {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
for name in &names {
|
||||||
|
let f = new_file(name);
|
||||||
|
let id: i64 = tx
|
||||||
|
.prepare_cached(
|
||||||
|
"UPDATE files
|
||||||
|
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
|
||||||
|
content_state = ?6
|
||||||
|
WHERE parent = ?7 AND name = ?8
|
||||||
|
RETURNING id",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.query_row(
|
||||||
|
params![
|
||||||
|
f.size as i64,
|
||||||
|
f.mtime as i64,
|
||||||
|
f.hash,
|
||||||
|
f.mime,
|
||||||
|
f.ftype.bits() as i64,
|
||||||
|
repo::STATE_NA,
|
||||||
|
f.parent,
|
||||||
|
f.name,
|
||||||
|
],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
repo::remove_content_for_id(&tx, id).unwrap();
|
||||||
|
tx.prepare_cached("DELETE FROM failed_files WHERE file_id = ?1")
|
||||||
|
.unwrap()
|
||||||
|
.execute([id])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
tx.commit().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[divan::bench]
|
||||||
|
fn narrowed(bencher: Bencher) {
|
||||||
|
let (_dir, mut conn) = seeded_na("bench-update-new");
|
||||||
|
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
|
||||||
|
bencher.bench_local(move || {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
for name in &names {
|
||||||
|
repo::update_file_basic(&tx, &new_file(name)).unwrap().unwrap();
|
||||||
|
}
|
||||||
|
tx.commit().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,16 @@ const CASES: &[Case] = &[
|
||||||
fuzzy: false,
|
fuzzy: false,
|
||||||
passes: "regex name + content, no prefilter possible",
|
passes: "regex name + content, no prefilter possible",
|
||||||
},
|
},
|
||||||
|
Case {
|
||||||
|
// The accept-predicate: a common term beside a regex whose path
|
||||||
|
// check misses everything, so every pass-A candidate fetches and
|
||||||
|
// decodes its stored body (or discovers its absence) through
|
||||||
|
// `Cx::regex_accepts` — per row, the shape `DocDecoder` exists for.
|
||||||
|
label: "term + regex",
|
||||||
|
query: r"content regex:zzznever\d",
|
||||||
|
fuzzy: false,
|
||||||
|
passes: "A hits many, regex accept-predicate decodes per candidate",
|
||||||
|
},
|
||||||
Case {
|
Case {
|
||||||
label: "common (capped)",
|
label: "common (capped)",
|
||||||
query: "content",
|
query: "content",
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,10 @@
|
||||||
//! What has to stay resident is the **`files` table**, not the FTS index.
|
//! What has to stay resident is the **`files` table**, not the FTS index.
|
||||||
//! `search/cascade/passes.rs` answers filename queries (ranks 1–4, 9–10) with
|
//! `search/cascade/passes.rs` answers filename queries (ranks 1–4, 9–10) with
|
||||||
//! `SELECT … FROM files f WHERE f.name LIKE '%…%'` — a full table scan, no FTS
|
//! `SELECT … FROM files f WHERE f.name LIKE '%…%'` — a full table scan, no FTS
|
||||||
//! at all — and the fuzzy pass scans it again with `WHERE 1=1`. So the working
|
//! at all — and the fuzzy pass scans it again (`LIKE`-narrowed for terms of
|
||||||
//! set scales with **file count**, not with document volume, and the corpus
|
//! 9+ characters, unpredicated below that). So the working set scales with
|
||||||
//! dimension below is what makes that visible.
|
//! **file count**, not with document volume, and the corpus dimension below
|
||||||
|
//! is what makes that visible.
|
||||||
//!
|
//!
|
||||||
//! The keyed rows are where an undersized cache hurts first: a page-cache miss
|
//! The keyed rows are where an undersized cache hurts first: a page-cache miss
|
||||||
//! costs an AES-CBC decrypt, not a `memcpy`.
|
//! costs an AES-CBC decrypt, not a `memcpy`.
|
||||||
|
|
@ -155,20 +156,63 @@ fn mib(bytes: u64) -> f64 {
|
||||||
|
|
||||||
/// Run one query; hits are counted, not kept — holding 200k `SearchHit`s
|
/// Run one query; hits are counted, not kept — holding 200k `SearchHit`s
|
||||||
/// would measure the allocator instead of the scan.
|
/// would measure the allocator instead of the scan.
|
||||||
fn time_query(conn: &Connection, query: &str) -> (Duration, usize) {
|
fn time_query(conn: &Connection, query: &str, options: &SearchOptions) -> (Duration, usize) {
|
||||||
let split = split_for_cascade(query).unwrap();
|
let split = split_for_cascade(query).unwrap();
|
||||||
let latest = std::sync::atomic::AtomicU64::new(1);
|
let latest = std::sync::atomic::AtomicU64::new(1);
|
||||||
let mut count = 0usize;
|
let mut count = 0usize;
|
||||||
let mut sink = |hits: Vec<SearchHit>| count += hits.len();
|
let mut sink = |hits: Vec<SearchHit>| count += hits.len();
|
||||||
let options = SearchOptions {
|
|
||||||
limit: 1000,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
cascade::run(conn, &split, &options, 1, &latest, &mut sink).unwrap();
|
cascade::run(conn, &split, options, 1, &latest, &mut sink).unwrap();
|
||||||
(start.elapsed(), count)
|
(start.elapsed(), count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The options every measurement here runs under: the sweep's, with `fuzzy`
|
||||||
|
/// the variable. Both `SearchOptions::default()` and the GUI default
|
||||||
|
/// (`[search] fuzzy_default`) have fuzzy **off** — the fuzzy rows below are
|
||||||
|
/// what a user opts into, not what a default install pays per keystroke.
|
||||||
|
fn options(fuzzy: bool) -> SearchOptions {
|
||||||
|
SearchOptions {
|
||||||
|
limit: 1000,
|
||||||
|
fuzzy,
|
||||||
|
..SearchOptions::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fuzzy pairing's keystroke sequence, per-term rather than averaged:
|
||||||
|
/// under the default `fuzzy_max_edits` of 2, `pigeonhole_chunks` needs
|
||||||
|
/// `3 × (k + 1)` characters, so terms under 9 characters keep the full scan
|
||||||
|
/// (`edit_budget` gives k=1 at 3–5 and k=2 from 6) and only the 9+ ones can
|
||||||
|
/// be prefiltered. Both shapes are in the sequence on purpose: the short
|
||||||
|
/// terms are the control a pass-C narrowing must not move, the long ones are
|
||||||
|
/// what it narrows. `quartzite` is `testutil::NEEDLE`, present in seeded
|
||||||
|
/// names, so the engaged rows return real hits.
|
||||||
|
const FUZZY_SEQUENCE: [&str; 4] = ["quar", "quartz", "quartzite", "quartzites"];
|
||||||
|
|
||||||
|
/// Warm per-term timings, fuzzy off against on, at the shipped cache — the
|
||||||
|
/// gate for the fuzzy filename pass's shape (`passes::pass_fuzzy_filename`).
|
||||||
|
/// The off column is the control: a pass-C change has no business moving it.
|
||||||
|
fn run_fuzzy_pairing(arm: &Arm) {
|
||||||
|
let conn = arm.open_search();
|
||||||
|
conn.execute_batch(&format!("PRAGMA cache_size = {};", SHIPPED_CACHE))
|
||||||
|
.unwrap();
|
||||||
|
// One settling pass per mode so every printed number is warm.
|
||||||
|
for fuzzy in [false, true] {
|
||||||
|
let opts = options(fuzzy);
|
||||||
|
for query in FUZZY_SEQUENCE {
|
||||||
|
time_query(&conn, query, &opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{:>12} {:>10} {:>10} {:>8} (fuzzy pairing, shipped cache, warm)",
|
||||||
|
"term", "off", "on", "hits"
|
||||||
|
);
|
||||||
|
for query in FUZZY_SEQUENCE {
|
||||||
|
let (off, _) = time_query(&conn, query, &options(false));
|
||||||
|
let (on, hits) = time_query(&conn, query, &options(true));
|
||||||
|
println!("{:>12} {:>9.1?} {:>9.1?} {:>8}", query, off, on, hits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One arm at one cache ceiling: the first keystroke on a fresh connection,
|
/// One arm at one cache ceiling: the first keystroke on a fresh connection,
|
||||||
/// then the steady state after a priming pass.
|
/// then the steady state after a priming pass.
|
||||||
///
|
///
|
||||||
|
|
@ -182,13 +226,17 @@ fn measure(arm: &Arm, cache_size: i64) -> (Duration, Duration, usize) {
|
||||||
conn.execute_batch(&format!("PRAGMA cache_size = {};", cache_size))
|
conn.execute_batch(&format!("PRAGMA cache_size = {};", cache_size))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let (cold, hits) = time_query(&conn, SEQUENCE[0]);
|
let opts = options(false);
|
||||||
|
let (cold, hits) = time_query(&conn, SEQUENCE[0], &opts);
|
||||||
// A priming pass, so "warm" is a settled session rather than the three
|
// A priming pass, so "warm" is a settled session rather than the three
|
||||||
// keystrokes after the first.
|
// keystrokes after the first.
|
||||||
for query in SEQUENCE {
|
for query in SEQUENCE {
|
||||||
time_query(&conn, query);
|
time_query(&conn, query, &opts);
|
||||||
}
|
}
|
||||||
let total: Duration = SEQUENCE.iter().map(|q| time_query(&conn, q).0).sum();
|
let total: Duration = SEQUENCE
|
||||||
|
.iter()
|
||||||
|
.map(|q| time_query(&conn, q, &opts).0)
|
||||||
|
.sum();
|
||||||
(cold, total / SEQUENCE.len() as u32, hits)
|
(cold, total / SEQUENCE.len() as u32, hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,7 +350,21 @@ fn main() {
|
||||||
std::env::temp_dir().display()
|
std::env::temp_dir().display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for files in CORPORA {
|
// `QSB_SEARCH_PERF=fuzzy` runs only the fuzzy pairing — the cache sweep
|
||||||
|
// is minutes per arm and a pass-C gate does not need it. `QSB_CORPORA`
|
||||||
|
// trims the corpus list the same way (comma-separated file counts).
|
||||||
|
let fuzzy_only = std::env::var("QSB_SEARCH_PERF").as_deref() == Ok("fuzzy");
|
||||||
|
let corpora: Vec<usize> = std::env::var("QSB_CORPORA")
|
||||||
|
.ok()
|
||||||
|
.map(|v| {
|
||||||
|
v.split(',')
|
||||||
|
.filter_map(|n| n.trim().parse().ok())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.filter(|v: &Vec<usize>| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| CORPORA.to_vec());
|
||||||
|
|
||||||
|
for files in corpora {
|
||||||
for keyed in [false, true] {
|
for keyed in [false, true] {
|
||||||
let arm = Arm::seed(
|
let arm = Arm::seed(
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -312,10 +374,31 @@ fn main() {
|
||||||
),
|
),
|
||||||
&format!("searchperf-{}-{}", files, keyed),
|
&format!("searchperf-{}-{}", files, keyed),
|
||||||
keyed,
|
keyed,
|
||||||
&spec(files),
|
&if fuzzy_only {
|
||||||
|
light_spec(files)
|
||||||
|
} else {
|
||||||
|
spec(files)
|
||||||
|
},
|
||||||
);
|
);
|
||||||
run_matrix(&arm, files);
|
if fuzzy_only {
|
||||||
|
println!("\n=== {} === seeded in {:.1?}", arm.what, arm.seeded_in);
|
||||||
|
} else {
|
||||||
|
run_matrix(&arm, files);
|
||||||
|
}
|
||||||
|
run_fuzzy_pairing(&arm);
|
||||||
arm.discard();
|
arm.discard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fuzzy-only corpus: `files` rows and nothing else. Pass C never reads a
|
||||||
|
/// document — its whole cost is the `files` scan plus the bitap — and 2 KB
|
||||||
|
/// bodies turn a minutes-long seed into hours on slow storage while adding
|
||||||
|
/// only pass-D noise to the number under test. One row in `usize::MAX` still
|
||||||
|
/// gets content, which keeps pass D exercised without costing anything.
|
||||||
|
fn light_spec(files: usize) -> SeedSpec {
|
||||||
|
SeedSpec {
|
||||||
|
content_every: usize::MAX,
|
||||||
|
..spec(files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
1154
crates/quicksearch-core/examples/contentprobe.rs
Normal file
1154
crates/quicksearch-core/examples/contentprobe.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,11 +23,24 @@
|
||||||
//! page read the rows, decide nothing
|
//! page read the rows, decide nothing
|
||||||
//! +cover ...and run the scope test per row
|
//! +cover ...and run the scope test per row
|
||||||
//! +files ...and delete the doomed `files` rows
|
//! +files ...and delete the doomed `files` rows
|
||||||
//! +fts(all) ...and tombstone every doomed id <- ships today
|
//! +fts(all) ...and tombstone every doomed id
|
||||||
//! +fts(done) ...tombstoning only ids that can have an FTS row
|
//! +fts(done) ...tombstoning only ids that can have an FTS row
|
||||||
//! +subtree ...range-deleting a doomed directory instead of paging it
|
//! +subtree ...range-deleting a doomed directory instead of paging it
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! **No stage is what ships any more.** `scope::advance` tombstones only the
|
||||||
|
//! ids that can hold a posting (`+fts(done)`), commits per slice rather than
|
||||||
|
//! per page, and turns FTS5's delete-merging off for the pass
|
||||||
|
//! (`file_handling::fts_begin_tombstone_burst`), so the `live` row below lands
|
||||||
|
//! under every stage rather than on one of them. The stages remain the
|
||||||
|
//! decomposition — they say where the time is — and `live` says what the sum
|
||||||
|
//! of the shipped decisions costs.
|
||||||
|
//!
|
||||||
|
//! Two of them were measured and **not** adopted, which is why they are still
|
||||||
|
//! here: `+subtree` bought nothing over `+fts(done)` (247 ms against 243 on the
|
||||||
|
//! 40k corpus, with all 8,080 doomed rows genuinely skipping the page loop), and
|
||||||
|
//! raising the page cache moved misses twelvefold while barely moving the clock.
|
||||||
|
//!
|
||||||
//! **Read the `commit` column, not `fts`.** FTS5 buffers a contentless delete
|
//! **Read the `commit` column, not `fts`.** FTS5 buffers a contentless delete
|
||||||
//! in memory and writes the tombstone pages when the transaction is flushed, so
|
//! in memory and writes the tombstone pages when the transaction is flushed, so
|
||||||
//! the `DELETE` statement itself times as nearly free and the cost lands in the
|
//! the `DELETE` statement itself times as nearly free and the cost lands in the
|
||||||
|
|
@ -38,18 +51,28 @@
|
||||||
//! Every stage runs against a byte-identical copy of one seeded index rather
|
//! Every stage runs against a byte-identical copy of one seeded index rather
|
||||||
//! than a fresh seed: FTS5 segment layout is most of what decides delete cost,
|
//! than a fresh seed: FTS5 segment layout is most of what decides delete cost,
|
||||||
//! and reseeding would let it drift between the rows of the table.
|
//! and reseeding would let it drift between the rows of the table.
|
||||||
|
//!
|
||||||
|
//! The final table prices the *other* bulk withdrawal, a completed run's stale
|
||||||
|
//! cleanup (`file_handling::cleanup_stale_index_entries`): the same doomed
|
||||||
|
//! rows, deleted by path the way that pass does. Its stages are spelled out in
|
||||||
|
//! probe code for the same reason as above — they are the fixed decomposition
|
||||||
|
//! — and its `live` row is the shipped function, which is the row that moves
|
||||||
|
//! when `file_handling::batch` is reshaped.
|
||||||
|
|
||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::{Connection, OptionalExtension};
|
||||||
|
|
||||||
use quicksearch_core::config::Config;
|
use quicksearch_core::config::Config;
|
||||||
use quicksearch_core::db::{self, repo};
|
use quicksearch_core::db::{self, repo};
|
||||||
use quicksearch_core::extract::Registry;
|
use quicksearch_core::extract::Registry;
|
||||||
use quicksearch_core::file_handling::ExtractCursor;
|
use quicksearch_core::file_handling::{
|
||||||
|
cleanup_stale_index_entries, fts_begin_tombstone_burst, fts_end_tombstone_burst,
|
||||||
|
fts_finalize_after_text_indexing, split_db_path, ExtractCursor,
|
||||||
|
};
|
||||||
use quicksearch_core::scope::{self, Scope, WorkCursor};
|
use quicksearch_core::scope::{self, Scope, WorkCursor};
|
||||||
use quicksearch_core::testutil::{self, Arm, SeedSpec};
|
use quicksearch_core::testutil::{self, Arm, SeedSpec};
|
||||||
|
|
||||||
|
|
@ -384,6 +407,165 @@ fn delete_range(tx: &Connection, lo: &str, hi: &str) -> (usize, Duration, Durati
|
||||||
(removed, fts, at.elapsed())
|
(removed, fts, at.elapsed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stale cleanup stages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Cumulative slices of a completed run's stale cleanup, which deletes by
|
||||||
|
/// *path* rather than deciding rows from a page it already read.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
enum StaleShape {
|
||||||
|
/// Per-path deletes, three statements each, FTS handed every id — the
|
||||||
|
/// pass as originally shipped. This is the control row: it reproduces
|
||||||
|
/// that shape in probe code, so it must not move when
|
||||||
|
/// `cleanup_stale_index_entries` is reshaped.
|
||||||
|
PerRow,
|
||||||
|
/// ...resolve `(id, content_state)` per path instead, then chunked
|
||||||
|
/// `IN (...)` deletes with FTS narrowed to ids that can hold a posting.
|
||||||
|
Ids,
|
||||||
|
/// ...and hold FTS5's delete-merging off for the whole pass.
|
||||||
|
Burst,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StaleShape {
|
||||||
|
const ALL: [StaleShape; 3] = [StaleShape::PerRow, StaleShape::Ids, StaleShape::Burst];
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StaleShape::PerRow => "per-row",
|
||||||
|
StaleShape::Ids => "+ids",
|
||||||
|
StaleShape::Burst => "+burst",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
StaleShape::PerRow => "row",
|
||||||
|
StaleShape::Ids => "ids",
|
||||||
|
StaleShape::Burst => "burst",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete `paths` the way stale cleanup does, one transaction per `PAGE`
|
||||||
|
/// chunk, timing each phase. Column mapping in the shared table: `cover` is
|
||||||
|
/// the id resolution, `files` the `files` deletes, `fts` the tombstones plus
|
||||||
|
/// the trailing consolidation.
|
||||||
|
fn run_stale(conn: &Connection, paths: &[String], shape: StaleShape) -> Timing {
|
||||||
|
let mut t = Timing::default();
|
||||||
|
let io_before = Io::read();
|
||||||
|
let (_, misses_before) = testutil::cache_stats(conn);
|
||||||
|
let started = Instant::now();
|
||||||
|
|
||||||
|
if shape == StaleShape::Burst {
|
||||||
|
fts_begin_tombstone_burst(conn);
|
||||||
|
}
|
||||||
|
for page in paths.chunks(PAGE as usize) {
|
||||||
|
let tx = conn.unchecked_transaction().expect("begin");
|
||||||
|
if shape == StaleShape::PerRow {
|
||||||
|
for path in page {
|
||||||
|
let Some((parent, name)) = split_db_path(path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let at = Instant::now();
|
||||||
|
let id: Option<i64> = tx
|
||||||
|
.prepare_cached(
|
||||||
|
"DELETE FROM files WHERE parent = ?1 AND name = ?2 RETURNING id",
|
||||||
|
)
|
||||||
|
.expect("prepare")
|
||||||
|
.query_row(rusqlite::params![parent, name], |r| r.get(0))
|
||||||
|
.optional()
|
||||||
|
.expect("delete row");
|
||||||
|
t.files += at.elapsed();
|
||||||
|
let Some(id) = id else { continue };
|
||||||
|
t.deleted += 1;
|
||||||
|
let at = Instant::now();
|
||||||
|
tx.prepare_cached("DELETE FROM searchabletext WHERE rowid = ?1")
|
||||||
|
.expect("prepare")
|
||||||
|
.execute([id])
|
||||||
|
.expect("tombstone");
|
||||||
|
t.fts += at.elapsed();
|
||||||
|
let at = Instant::now();
|
||||||
|
tx.prepare_cached("DELETE FROM documents_text WHERE file_id = ?1")
|
||||||
|
.expect("prepare")
|
||||||
|
.execute([id])
|
||||||
|
.expect("clear body");
|
||||||
|
t.files += at.elapsed();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let at = Instant::now();
|
||||||
|
let mut ids: Vec<i64> = Vec::new();
|
||||||
|
let mut with_postings: Vec<i64> = Vec::new();
|
||||||
|
{
|
||||||
|
let mut sel = tx
|
||||||
|
.prepare_cached(
|
||||||
|
"SELECT id, content_state FROM files WHERE parent = ?1 AND name = ?2",
|
||||||
|
)
|
||||||
|
.expect("prepare");
|
||||||
|
for path in page {
|
||||||
|
let Some((parent, name)) = split_db_path(path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let row: Option<(i64, i64)> = sel
|
||||||
|
.query_row(rusqlite::params![parent, name], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.expect("resolve");
|
||||||
|
if let Some((id, state)) = row {
|
||||||
|
ids.push(id);
|
||||||
|
if state == repo::STATE_DONE {
|
||||||
|
with_postings.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.cover += at.elapsed();
|
||||||
|
for chunk in with_postings.chunks(CHUNK) {
|
||||||
|
let at = Instant::now();
|
||||||
|
tx.execute(
|
||||||
|
&format!(
|
||||||
|
"DELETE FROM searchabletext WHERE rowid IN ({})",
|
||||||
|
placeholders(chunk.len())
|
||||||
|
),
|
||||||
|
rusqlite::params_from_iter(chunk.iter()),
|
||||||
|
)
|
||||||
|
.expect("tombstone");
|
||||||
|
t.fts += at.elapsed();
|
||||||
|
}
|
||||||
|
for chunk in ids.chunks(CHUNK) {
|
||||||
|
let at = Instant::now();
|
||||||
|
t.deleted += tx
|
||||||
|
.execute(
|
||||||
|
&format!(
|
||||||
|
"DELETE FROM files WHERE id IN ({})",
|
||||||
|
placeholders(chunk.len())
|
||||||
|
),
|
||||||
|
rusqlite::params_from_iter(chunk.iter()),
|
||||||
|
)
|
||||||
|
.expect("delete rows");
|
||||||
|
t.files += at.elapsed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let at = Instant::now();
|
||||||
|
tx.commit().expect("commit");
|
||||||
|
t.commit += at.elapsed();
|
||||||
|
}
|
||||||
|
let at = Instant::now();
|
||||||
|
if shape == StaleShape::Burst {
|
||||||
|
fts_end_tombstone_burst(conn);
|
||||||
|
} else {
|
||||||
|
fts_finalize_after_text_indexing(conn);
|
||||||
|
}
|
||||||
|
t.fts += at.elapsed();
|
||||||
|
|
||||||
|
t.total = started.elapsed();
|
||||||
|
t.io = Io::read().since(&io_before);
|
||||||
|
let (_, misses_after) = testutil::cache_stats(conn);
|
||||||
|
t.misses = misses_after - misses_before;
|
||||||
|
t
|
||||||
|
}
|
||||||
|
|
||||||
/// How much data FTS5 is holding — the only quiescence signal that works.
|
/// How much data FTS5 is holding — the only quiescence signal that works.
|
||||||
///
|
///
|
||||||
/// `sqlite3_changes()` after a `'merge'` does **not** report whether the merge
|
/// `sqlite3_changes()` after a `'merge'` does **not** report whether the merge
|
||||||
|
|
@ -824,7 +1006,8 @@ fn main() {
|
||||||
|
|
||||||
// The reference number every stage above is decomposing: the real
|
// The reference number every stage above is decomposing: the real
|
||||||
// `scope::advance`, driven to completion the way the coordinator drives
|
// `scope::advance`, driven to completion the way the coordinator drives
|
||||||
// it. It must land on `+fts(all)`.
|
// it. It lands *under* every stage — see the header for which decisions
|
||||||
|
// put it there.
|
||||||
{
|
{
|
||||||
let arm = clone_arm(&master, &format!("prune-{}-live", label));
|
let arm = clone_arm(&master, &format!("prune-{}-live", label));
|
||||||
let mut conn = open(&arm);
|
let mut conn = open(&arm);
|
||||||
|
|
@ -861,6 +1044,91 @@ fn main() {
|
||||||
arm.discard();
|
arm.discard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stale cleanup, the pass a completed run ends with. Same doomed rows
|
||||||
|
// as the tables above, but deleted by *path* — the walk hands
|
||||||
|
// `cleanup_stale_index_entries` a list of paths it did not see, in
|
||||||
|
// directory order. Runs on `PRAGMAS_FAST` via `open_existing(_, true)`
|
||||||
|
// because that is the run's own writer connection, the one the real
|
||||||
|
// pass executes on — `open` here would measure the reconcile's 4 MiB
|
||||||
|
// cache instead.
|
||||||
|
{
|
||||||
|
let stale: Vec<String> = {
|
||||||
|
let conn = open(&master);
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"SELECT parent || name FROM files WHERE parent LIKE ?1 \
|
||||||
|
ORDER BY parent, name",
|
||||||
|
)
|
||||||
|
.expect("prepare");
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([format!("%/{}/%", PRUNE_PATTERN)], |r| r.get(0))
|
||||||
|
.expect("query stale paths");
|
||||||
|
rows.collect::<Result<Vec<_>, _>>().expect("read stale paths")
|
||||||
|
};
|
||||||
|
println!("\n stale cleanup, {} doomed paths:", stale.len());
|
||||||
|
header();
|
||||||
|
for shape in StaleShape::ALL {
|
||||||
|
let arm = clone_arm(&master, &format!("prune-{}-stale-{}", label, shape.tag()));
|
||||||
|
let conn = arm.with_key(|| {
|
||||||
|
db::open::open_existing(&arm.path.to_string_lossy(), true)
|
||||||
|
.expect("open the copy")
|
||||||
|
});
|
||||||
|
let t = run_stale(&conn, &stale, shape);
|
||||||
|
row(shape.label(), &t);
|
||||||
|
let (took, rounds, before, after) = merge_to_quiescence(&conn);
|
||||||
|
println!(
|
||||||
|
" {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows",
|
||||||
|
"",
|
||||||
|
took.as_secs_f64() * 1000.0,
|
||||||
|
rounds,
|
||||||
|
before,
|
||||||
|
after
|
||||||
|
);
|
||||||
|
drop(conn);
|
||||||
|
arm.discard();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shipped function, whole. The row that moves when
|
||||||
|
// `file_handling::batch` is reshaped; the stages above must not.
|
||||||
|
let arm = clone_arm(&master, &format!("prune-{}-stale-live", label));
|
||||||
|
let conn = arm.with_key(|| {
|
||||||
|
db::open::open_existing(&arm.path.to_string_lossy(), true).expect("open the copy")
|
||||||
|
});
|
||||||
|
let (_, misses_before) = testutil::cache_stats(&conn);
|
||||||
|
let conn_mutex = std::sync::Arc::new(std::sync::Mutex::new(conn));
|
||||||
|
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let started = Instant::now();
|
||||||
|
let deleted = cleanup_stale_index_entries(&conn_mutex, &stale, &stop, &config)
|
||||||
|
.expect("cleanup stale");
|
||||||
|
let total = started.elapsed();
|
||||||
|
let conn = std::sync::Arc::try_unwrap(conn_mutex)
|
||||||
|
.map_err(|_| ())
|
||||||
|
.expect("sole owner")
|
||||||
|
.into_inner()
|
||||||
|
.expect("unpoisoned");
|
||||||
|
let (_, misses_after) = testutil::cache_stats(&conn);
|
||||||
|
row(
|
||||||
|
"live",
|
||||||
|
&Timing {
|
||||||
|
total,
|
||||||
|
deleted,
|
||||||
|
misses: misses_after - misses_before,
|
||||||
|
..Timing::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let (took, rounds, before, after) = merge_to_quiescence(&conn);
|
||||||
|
println!(
|
||||||
|
" {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows <- cleanup_stale_index_entries",
|
||||||
|
"",
|
||||||
|
took.as_secs_f64() * 1000.0,
|
||||||
|
rounds,
|
||||||
|
before,
|
||||||
|
after
|
||||||
|
);
|
||||||
|
drop(conn);
|
||||||
|
arm.discard();
|
||||||
|
}
|
||||||
|
|
||||||
master.discard();
|
master.discard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,6 @@ pub struct ProcessingConfig {
|
||||||
/// milliseconds — the bound on how long any one root can hold up the
|
/// milliseconds — the bound on how long any one root can hold up the
|
||||||
/// others. `0` gives each turn one `batch_size` quantum and no more.
|
/// others. `0` gives each turn one `batch_size` quantum and no more.
|
||||||
pub writer_turn_slice_ms: u64,
|
pub writer_turn_slice_ms: u64,
|
||||||
pub fts_update_batch_size: usize,
|
|
||||||
/// How large the WAL may grow during a run before the indexer forces a
|
/// How large the WAL may grow during a run before the indexer forces a
|
||||||
/// checkpoint, in bytes. `0` disables; else raised to [`MINIMUM_WAL_SIZE`].
|
/// checkpoint, in bytes. `0` disables; else raised to [`MINIMUM_WAL_SIZE`].
|
||||||
/// Needed because autocheckpoint can only *reset* the log when no reader
|
/// Needed because autocheckpoint can only *reset* the log when no reader
|
||||||
|
|
@ -240,7 +239,6 @@ impl Default for ProcessingConfig {
|
||||||
maximum_text_file_size: 1024 * 1024 * 2,
|
maximum_text_file_size: 1024 * 1024 * 2,
|
||||||
batch_size: 500,
|
batch_size: 500,
|
||||||
writer_turn_slice_ms: 100,
|
writer_turn_slice_ms: 100,
|
||||||
fts_update_batch_size: 1000,
|
|
||||||
maximum_wal_size: 1024 * 1024 * 1024 * 2,
|
maximum_wal_size: 1024 * 1024 * 1024 * 2,
|
||||||
tokenize: "trigram".to_string(),
|
tokenize: "trigram".to_string(),
|
||||||
store_text_for_snippets: true,
|
store_text_for_snippets: true,
|
||||||
|
|
|
||||||
|
|
@ -837,7 +837,7 @@ fn newer_fields_round_trip_and_default_when_absent() {
|
||||||
fs::write(
|
fs::write(
|
||||||
&path,
|
&path,
|
||||||
"[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n\
|
"[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n\
|
||||||
[ui]\nscale=1.25\n[search]\nfuzzy_default=true\ndisplay_limit=250\n",
|
[ui]\nscale=1.25\n[search]\nfuzzy_default=false\ndisplay_limit=250\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::load_from(&path).unwrap();
|
let cfg = Config::load_from(&path).unwrap();
|
||||||
|
|
@ -850,7 +850,8 @@ fn newer_fields_round_trip_and_default_when_absent() {
|
||||||
assert_eq!(cfg.ui.color_scheme, "dark");
|
assert_eq!(cfg.ui.color_scheme, "dark");
|
||||||
assert_eq!(cfg.search.fuzzy_max_edits, 2);
|
assert_eq!(cfg.search.fuzzy_max_edits, 2);
|
||||||
assert_eq!(cfg.ui.scale, 1.25, "existing ui keys still parse");
|
assert_eq!(cfg.ui.scale, 1.25, "existing ui keys still parse");
|
||||||
assert!(cfg.search.fuzzy_default, "existing search keys still parse");
|
// `false` is the non-default value, so this only passes if the key parsed.
|
||||||
|
assert!(!cfg.search.fuzzy_default, "existing search keys still parse");
|
||||||
assert_eq!(cfg.search.display_limit, 250);
|
assert_eq!(cfg.search.display_limit, 250);
|
||||||
|
|
||||||
// A value nobody recognises is not a broken config file.
|
// A value nobody recognises is not a broken config file.
|
||||||
|
|
|
||||||
|
|
@ -103,14 +103,26 @@ fn initial_content_state(f: &NewFile<'_>) -> i64 {
|
||||||
|
|
||||||
/// Update a file's metadata in place and reset its content state, clearing
|
/// Update a file's metadata in place and reset its content state, clearing
|
||||||
/// any extracted content. `None` if no row matches.
|
/// any extracted content. `None` if no row matches.
|
||||||
|
///
|
||||||
|
/// The clearing is narrowed by the row's *stored* state, the way
|
||||||
|
/// `scope::PagePlan` narrows: content rows exist exactly for `STATE_DONE`
|
||||||
|
/// (`repo_tests::leaving_done_always_takes_the_posting_with_it`) and
|
||||||
|
/// `failed_files` rows exactly for `STATE_FAILED`, so every other state has
|
||||||
|
/// nothing to clear and pays one statement instead of four — most changed
|
||||||
|
/// rows on a text-free tree are NA→NA, measured 1.24x on that shape
|
||||||
|
/// (`benches/index.rs`, group `update_narrowing`; DONE→PENDING is unchanged
|
||||||
|
/// by construction, the clearing happens either way).
|
||||||
|
/// `content_state` is deliberately **not
|
||||||
|
/// in the SET list**: `RETURNING` reports the post-update row, so assigning
|
||||||
|
/// it there would return the new state and the narrowing would read its own
|
||||||
|
/// write.
|
||||||
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
|
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
|
||||||
let id: Option<i64> = tx
|
let row: Option<(i64, i64)> = tx
|
||||||
.prepare_cached(
|
.prepare_cached(
|
||||||
"UPDATE files
|
"UPDATE files
|
||||||
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
|
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5
|
||||||
content_state = ?6
|
WHERE parent = ?6 AND name = ?7
|
||||||
WHERE parent = ?7 AND name = ?8
|
RETURNING id, content_state",
|
||||||
RETURNING id",
|
|
||||||
)
|
)
|
||||||
.and_then(|mut stmt| {
|
.and_then(|mut stmt| {
|
||||||
stmt.query_row(
|
stmt.query_row(
|
||||||
|
|
@ -120,27 +132,42 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
|
||||||
f.hash,
|
f.hash,
|
||||||
f.mime,
|
f.mime,
|
||||||
f.ftype.bits() as i64,
|
f.ftype.bits() as i64,
|
||||||
initial_content_state(f),
|
|
||||||
f.parent,
|
f.parent,
|
||||||
f.name,
|
f.name,
|
||||||
],
|
],
|
||||||
|r| r.get(0),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("update file {}: {}", f.path(), e))?;
|
.map_err(|e| format!("update file {}: {}", f.path(), e))?;
|
||||||
let Some(id) = id else {
|
let Some((id, old_state)) = row else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
remove_content_for_id(tx, id)?;
|
match old_state {
|
||||||
// Without this, a changed file that stops needing content keeps reading
|
STATE_DONE => remove_content_for_id(tx, id)?,
|
||||||
// as "failed" in `list-failed` forever.
|
// Without this, a changed file that stops needing content keeps
|
||||||
exec(
|
// reading as "failed" in `list-failed` forever.
|
||||||
tx,
|
STATE_FAILED => exec(
|
||||||
"DELETE FROM failed_files WHERE file_id = ?1",
|
tx,
|
||||||
params![id],
|
"DELETE FROM failed_files WHERE file_id = ?1",
|
||||||
|| format!("clear failed_files {}", id),
|
params![id],
|
||||||
)?;
|
|| format!("clear failed_files {}", id),
|
||||||
|
)
|
||||||
|
.map(|_| ())?,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
let new_state = initial_content_state(f);
|
||||||
|
if old_state != new_state {
|
||||||
|
// A plain assignment, not `set_state_clearing_failure`: the FAILED
|
||||||
|
// arm above already took the failure record, and no other state can
|
||||||
|
// hold one.
|
||||||
|
exec(
|
||||||
|
tx,
|
||||||
|
"UPDATE files SET content_state = ?1 WHERE id = ?2",
|
||||||
|
params![new_state, id],
|
||||||
|
|| format!("update reset content_state {}", id),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
Ok(Some(id))
|
Ok(Some(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,7 +345,23 @@ pub fn raw_text_len(blob: &[u8]) -> Option<u64> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a file's content extraction as failed. Keeps the basic row in place.
|
/// Mark a file's content extraction as failed. Keeps the basic row in place.
|
||||||
|
///
|
||||||
|
/// Clears any posting and stored body first, which matters twice over. It is
|
||||||
|
/// what a re-extraction that fails *owes* the reader: the text that is there
|
||||||
|
/// came out of an earlier version of a file that has since changed, so leaving
|
||||||
|
/// it serves hits for content the file no longer has. And it is what makes
|
||||||
|
/// "a `searchabletext` row exists exactly when `content_state` is
|
||||||
|
/// `STATE_DONE`" true of every transition rather than of most of them —
|
||||||
|
/// an equivalence `count_root` reports from and `delete_files_matching` now
|
||||||
|
/// narrows on, so a transition that quietly broke it would leave postings
|
||||||
|
/// behind for files that are no longer indexed.
|
||||||
|
///
|
||||||
|
/// Every path that reaches here today is already re-extracting a row it has
|
||||||
|
/// just reset (`update_file_basic`) or that was born pending, so the two
|
||||||
|
/// deletes are normally no-ops; extraction failures are rare enough that
|
||||||
|
/// paying for the guarantee is not worth measuring.
|
||||||
pub fn set_content_failed(tx: &Transaction<'_>, file_id: i64, reason: &str) -> Result<(), String> {
|
pub fn set_content_failed(tx: &Transaction<'_>, file_id: i64, reason: &str) -> Result<(), String> {
|
||||||
|
remove_content_for_id(tx, file_id)?;
|
||||||
let now = crate::log::now_unix() as i64;
|
let now = crate::log::now_unix() as i64;
|
||||||
exec(
|
exec(
|
||||||
tx,
|
tx,
|
||||||
|
|
@ -337,7 +380,15 @@ pub fn set_content_failed(tx: &Transaction<'_>, file_id: i64, reason: &str) -> R
|
||||||
|
|
||||||
/// Mark content extraction as not applicable; the row still serves filename
|
/// Mark content extraction as not applicable; the row still serves filename
|
||||||
/// search.
|
/// search.
|
||||||
|
///
|
||||||
|
/// Clears any posting and stored body first, for the reasons spelled out on
|
||||||
|
/// [`set_content_failed`] — a row arrives here because its content should no
|
||||||
|
/// longer be searchable, so leaving the old text behind contradicts the very
|
||||||
|
/// transition. Every caller already cleared first or had nothing to clear, so
|
||||||
|
/// this changes no behaviour; what it changes is that the invariant no longer
|
||||||
|
/// depends on all of them remembering.
|
||||||
pub fn set_content_na(tx: &Transaction<'_>, file_id: i64) -> Result<(), String> {
|
pub fn set_content_na(tx: &Transaction<'_>, file_id: i64) -> Result<(), String> {
|
||||||
|
remove_content_for_id(tx, file_id)?;
|
||||||
set_state_clearing_failure(tx, file_id, STATE_NA, "update NA")
|
set_state_clearing_failure(tx, file_id, STATE_NA, "update NA")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,6 +409,24 @@ pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, Str
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The stored id and `content_state` for one exact path, or `None` if the
|
||||||
|
/// path is not indexed.
|
||||||
|
///
|
||||||
|
/// Read it in the same transaction as the decision it feeds: the caller that
|
||||||
|
/// builds [`delete_ids`]'s `with_postings` from the state must see the state
|
||||||
|
/// *at delete time*, and only the transaction makes that simultaneous.
|
||||||
|
pub fn id_and_state_for_path(conn: &Connection, path: &str) -> Result<Option<(i64, i64)>, String> {
|
||||||
|
let Some((parent, name)) = crate::file_handling::split_db_path(path) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
conn.prepare_cached("SELECT id, content_state FROM files WHERE parent = ?1 AND name = ?2")
|
||||||
|
.and_then(|mut stmt| {
|
||||||
|
stmt.query_row(params![parent, name], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("look up {}: {}", path, e))
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete every row whose parent falls in `[lo, hi)`. Build the bounds with
|
/// Delete every row whose parent falls in `[lo, hi)`. Build the bounds with
|
||||||
/// [`crate::file_handling::ExtractCursor::for_root`], which makes them
|
/// [`crate::file_handling::ExtractCursor::for_root`], which makes them
|
||||||
/// separator-correct — the range covers the root's *own* files only because
|
/// separator-correct — the range covers the root's *own* files only because
|
||||||
|
|
@ -380,6 +449,16 @@ pub fn delete_subtree(tx: &Transaction<'_>, lo: &str, hi: &str) -> Result<usize,
|
||||||
/// and reconcile's `orphans()` sweep. `searchabletext` cannot cascade — an
|
/// and reconcile's `orphans()` sweep. `searchabletext` cannot cascade — an
|
||||||
/// FTS5 virtual table takes no foreign key — so its contentless delete must
|
/// FTS5 virtual table takes no foreign key — so its contentless delete must
|
||||||
/// stay explicit.
|
/// stay explicit.
|
||||||
|
///
|
||||||
|
/// **Deliberately *not* narrowed to `content_state = STATE_DONE`**, though the
|
||||||
|
/// rows it would exclude are provably the ones with nothing to tombstone (see
|
||||||
|
/// [`delete_ids`], which does narrow). The two are not the same trade: this one
|
||||||
|
/// works from a range rather than a list of decided rows, so reading
|
||||||
|
/// `content_state` costs a `files` row fetch per candidate — the very rows the
|
||||||
|
/// `DELETE` below is about to fetch anyway, but a second traversal of them all
|
||||||
|
/// the same. That is a certain cost against an uncertain saving, and the saving
|
||||||
|
/// it buys is one `%_docsize` seek into a b-tree far smaller than `files`.
|
||||||
|
/// `examples/pruneprobe.rs` prices the range form; narrow this when it says to.
|
||||||
fn delete_files_matching(
|
fn delete_files_matching(
|
||||||
tx: &Transaction<'_>,
|
tx: &Transaction<'_>,
|
||||||
files_where: &str,
|
files_where: &str,
|
||||||
|
|
@ -437,18 +516,42 @@ fn placeholders(n: usize) -> String {
|
||||||
|
|
||||||
/// Delete the given file ids and everything keyed to them. Returns how many
|
/// Delete the given file ids and everything keyed to them. Returns how many
|
||||||
/// `files` rows went. Dependent tables: see `delete_files_matching`.
|
/// `files` rows went. Dependent tables: see `delete_files_matching`.
|
||||||
pub fn delete_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<usize, String> {
|
///
|
||||||
let mut removed = 0;
|
/// `with_postings` is the subset of `ids` whose `content_state` was
|
||||||
for chunk in ids.chunks(DELETE_IDS_CHUNK) {
|
/// `STATE_DONE`, and so the only ones FTS5 can have anything to tombstone for —
|
||||||
let placeholders = placeholders(chunk.len());
|
/// `repo_tests::leaving_done_always_takes_the_posting_with_it` is what makes
|
||||||
|
/// that true of every transition. Handing FTS5 the rest is not free: each is a
|
||||||
|
/// `%_docsize` seek to discover an absence, and on an index where most rows
|
||||||
|
/// carry no text that is most of the list.
|
||||||
|
///
|
||||||
|
/// Taking it as a second argument rather than deriving it here is the point:
|
||||||
|
/// the caller decided these rows from a page it had already read, so it holds
|
||||||
|
/// `content_state` for nothing, where a `SELECT` back out of `files` would cost
|
||||||
|
/// a row fetch each (see `delete_files_matching`, which for that reason does
|
||||||
|
/// not narrow).
|
||||||
|
///
|
||||||
|
/// It must be a subset: an id left out keeps its posting after its `files` row
|
||||||
|
/// is gone, which surfaces as a hit for a file that is no longer indexed.
|
||||||
|
pub fn delete_ids(
|
||||||
|
tx: &Transaction<'_>,
|
||||||
|
ids: &[i64],
|
||||||
|
with_postings: &[i64],
|
||||||
|
) -> Result<usize, String> {
|
||||||
|
for chunk in with_postings.chunks(DELETE_IDS_CHUNK) {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"DELETE FROM searchabletext WHERE rowid IN ({})",
|
"DELETE FROM searchabletext WHERE rowid IN ({})",
|
||||||
placeholders
|
placeholders(chunk.len())
|
||||||
);
|
);
|
||||||
exec(tx, &sql, params_from_iter(chunk.iter()), || {
|
exec(tx, &sql, params_from_iter(chunk.iter()), || {
|
||||||
format!("delete searchabletext for {} ids", chunk.len())
|
format!("delete searchabletext for {} ids", chunk.len())
|
||||||
})?;
|
})?;
|
||||||
let sql = format!("DELETE FROM files WHERE id IN ({})", placeholders);
|
}
|
||||||
|
let mut removed = 0;
|
||||||
|
for chunk in ids.chunks(DELETE_IDS_CHUNK) {
|
||||||
|
let sql = format!(
|
||||||
|
"DELETE FROM files WHERE id IN ({})",
|
||||||
|
placeholders(chunk.len())
|
||||||
|
);
|
||||||
removed += exec(tx, &sql, params_from_iter(chunk.iter()), || {
|
removed += exec(tx, &sql, params_from_iter(chunk.iter()), || {
|
||||||
format!("delete {} file rows", chunk.len())
|
format!("delete {} file rows", chunk.len())
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -456,6 +559,77 @@ pub fn delete_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<usize, String> {
|
||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set `content_state` on many rows at once, leaving everything else alone.
|
||||||
|
///
|
||||||
|
/// The batch form of the `UPDATE` inside [`set_state_clearing_failure`], and
|
||||||
|
/// deliberately *without* its `failed_files` sweep: only a `STATE_FAILED` row
|
||||||
|
/// can hold such a record, so a caller that knows the stored states can clear
|
||||||
|
/// the few that need it with [`clear_failed_for_ids`] instead of paying a
|
||||||
|
/// delete per row. A caller that does not know them must call the per-row
|
||||||
|
/// helpers, which cannot get this wrong.
|
||||||
|
pub fn set_content_state(tx: &Transaction<'_>, ids: &[i64], state: i64) -> Result<usize, String> {
|
||||||
|
let mut changed = 0;
|
||||||
|
for chunk in ids.chunks(DELETE_IDS_CHUNK) {
|
||||||
|
let sql = format!(
|
||||||
|
"UPDATE files SET content_state = ?1 WHERE id IN ({})",
|
||||||
|
placeholders(chunk.len())
|
||||||
|
);
|
||||||
|
let params = params_from_iter(
|
||||||
|
std::iter::once(&state as &dyn rusqlite::ToSql)
|
||||||
|
.chain(chunk.iter().map(|id| id as &dyn rusqlite::ToSql)),
|
||||||
|
);
|
||||||
|
changed += exec(tx, &sql, params, || {
|
||||||
|
format!("set content_state {} on {} rows", state, chunk.len())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the FTS posting and the stored body of many rows at once, leaving
|
||||||
|
/// their `files` rows in place: the batch form of [`remove_content_for_id`].
|
||||||
|
///
|
||||||
|
/// Pass only ids whose stored `content_state` was `STATE_DONE`; the others
|
||||||
|
/// have neither, and asking is what costs (see `delete_files_matching`).
|
||||||
|
pub fn clear_content_for_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<(), String> {
|
||||||
|
for chunk in ids.chunks(DELETE_IDS_CHUNK) {
|
||||||
|
let list = placeholders(chunk.len());
|
||||||
|
for (what, sql) in [
|
||||||
|
(
|
||||||
|
"searchabletext",
|
||||||
|
format!("DELETE FROM searchabletext WHERE rowid IN ({})", list),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"documents_text",
|
||||||
|
format!("DELETE FROM documents_text WHERE file_id IN ({})", list),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
exec(tx, &sql, params_from_iter(chunk.iter()), || {
|
||||||
|
format!("clear {} for {} ids", what, chunk.len())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the failure records of many rows at once. `list-failed` reads
|
||||||
|
/// `failed_files` directly, so a stale entry keeps reporting a file broken —
|
||||||
|
/// this is the batch half of what [`set_state_clearing_failure`] does per row.
|
||||||
|
///
|
||||||
|
/// Pass only ids whose stored `content_state` was `STATE_FAILED`: nothing else
|
||||||
|
/// can hold a record here.
|
||||||
|
pub fn clear_failed_for_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<(), String> {
|
||||||
|
for chunk in ids.chunks(DELETE_IDS_CHUNK) {
|
||||||
|
let sql = format!(
|
||||||
|
"DELETE FROM failed_files WHERE file_id IN ({})",
|
||||||
|
placeholders(chunk.len())
|
||||||
|
);
|
||||||
|
exec(tx, &sql, params_from_iter(chunk.iter()), || {
|
||||||
|
format!("clear failed_files for {} ids", chunk.len())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Every indexed file directly inside `parent`, as `name -> mtime`. `parent`
|
/// Every indexed file directly inside `parent`, as `name -> mtime`. `parent`
|
||||||
/// must be in stored spelling — trailing separator and all; build it with
|
/// must be in stored spelling — trailing separator and all; build it with
|
||||||
/// [`crate::file_handling::dir_to_db_parent`].
|
/// [`crate::file_handling::dir_to_db_parent`].
|
||||||
|
|
|
||||||
|
|
@ -527,9 +527,12 @@ fn delete_ids_clears_every_dependent_table() {
|
||||||
}
|
}
|
||||||
|
|
||||||
let doomed = vec![ids["/t/b.log"], ids["/t/deep/c.log"]];
|
let doomed = vec![ids["/t/b.log"], ids["/t/deep/c.log"]];
|
||||||
|
// `c.log` is FAILED and so holds no posting — the distinction the second
|
||||||
|
// argument draws, and the whole reason it is a separate list.
|
||||||
|
let with_postings = vec![ids["/t/b.log"]];
|
||||||
let removed = {
|
let removed = {
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
let n = delete_ids(&tx, &doomed).unwrap();
|
let n = delete_ids(&tx, &doomed, &with_postings).unwrap();
|
||||||
tx.commit().unwrap();
|
tx.commit().unwrap();
|
||||||
n
|
n
|
||||||
};
|
};
|
||||||
|
|
@ -553,7 +556,7 @@ fn delete_ids_clears_every_dependent_table() {
|
||||||
|
|
||||||
// Empty input is a no-op, not a statement with an empty `IN ()`.
|
// Empty input is a no-op, not a statement with an empty `IN ()`.
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
assert_eq!(delete_ids(&tx, &[]).unwrap(), 0);
|
assert_eq!(delete_ids(&tx, &[], &[]).unwrap(), 0);
|
||||||
tx.commit().unwrap();
|
tx.commit().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -572,7 +575,8 @@ fn delete_ids_spans_chunk_boundaries() {
|
||||||
let keep = all.pop().unwrap();
|
let keep = all.pop().unwrap();
|
||||||
let removed = {
|
let removed = {
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
let n = delete_ids(&tx, &all).unwrap();
|
// `seeded` leaves every row DONE, so both lists span the boundary.
|
||||||
|
let n = delete_ids(&tx, &all, &all).unwrap();
|
||||||
tx.commit().unwrap();
|
tx.commit().unwrap();
|
||||||
n
|
n
|
||||||
};
|
};
|
||||||
|
|
@ -583,6 +587,166 @@ fn delete_ids_spans_chunk_boundaries() {
|
||||||
assert_eq!(left, keep);
|
assert_eq!(left, keep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `update_file_basic` narrows its clearing by the stored state: only DONE
|
||||||
|
/// holds content, only FAILED holds a failure record, and everything else
|
||||||
|
/// must pay a single statement. The dangerous direction is under-clearing —
|
||||||
|
/// a DONE row keeping its posting surfaces a hit whose text no longer
|
||||||
|
/// matches — so every holding state is exercised.
|
||||||
|
#[test]
|
||||||
|
fn updating_clears_content_exactly_for_the_states_that_hold_it() {
|
||||||
|
let (_dir, p) = tmp_path();
|
||||||
|
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
let ids = seeded(&mut conn, &["/t/done.txt", "/t/failed.txt", "/t/keep.txt"]);
|
||||||
|
|
||||||
|
let na_id = {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
// Clears failed.txt's posting too — the invariant the narrowing rests on.
|
||||||
|
set_content_failed(&tx, ids["/t/failed.txt"], "bad parse").unwrap();
|
||||||
|
let id = insert_file(
|
||||||
|
&tx,
|
||||||
|
&NewFile {
|
||||||
|
name: "na.bin",
|
||||||
|
parent: "/t/",
|
||||||
|
size: 1,
|
||||||
|
mtime: 1,
|
||||||
|
mime: None,
|
||||||
|
ftype: crate::mime::FileType::EMPTY,
|
||||||
|
hash: None,
|
||||||
|
needs_content: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.expect("unique path");
|
||||||
|
tx.commit().unwrap();
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
let update = |conn: &mut Connection, name: &str, needs_content: bool| {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let id = update_file_basic(
|
||||||
|
&tx,
|
||||||
|
&NewFile {
|
||||||
|
name,
|
||||||
|
parent: "/t/",
|
||||||
|
size: 9,
|
||||||
|
mtime: 9,
|
||||||
|
mime: Some("text/plain"),
|
||||||
|
ftype: crate::mime::FileType::TEXT,
|
||||||
|
hash: None,
|
||||||
|
needs_content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
id
|
||||||
|
};
|
||||||
|
let count = |conn: &Connection, sql: &str| -> i64 {
|
||||||
|
conn.query_row(sql, [], |r| r.get(0)).unwrap()
|
||||||
|
};
|
||||||
|
let state = |conn: &Connection, id: i64| -> i64 {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT content_state FROM files WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
// DONE → PENDING: the posting and the body go, in the same transaction.
|
||||||
|
assert_eq!(
|
||||||
|
update(&mut conn, "done.txt", true),
|
||||||
|
Some(ids["/t/done.txt"])
|
||||||
|
);
|
||||||
|
assert_eq!(state(&conn, ids["/t/done.txt"]), STATE_PENDING);
|
||||||
|
assert_eq!(
|
||||||
|
count(
|
||||||
|
&conn,
|
||||||
|
"SELECT COUNT(*) FROM searchabletext WHERE rowid IN \
|
||||||
|
(SELECT id FROM files WHERE name = 'done.txt')"
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// FAILED → PENDING: the failure record goes with the transition.
|
||||||
|
assert_eq!(
|
||||||
|
update(&mut conn, "failed.txt", true),
|
||||||
|
Some(ids["/t/failed.txt"])
|
||||||
|
);
|
||||||
|
assert_eq!(state(&conn, ids["/t/failed.txt"]), STATE_PENDING);
|
||||||
|
assert_eq!(count(&conn, "SELECT COUNT(*) FROM failed_files"), 0);
|
||||||
|
|
||||||
|
// NA → NA: nothing to clear, and the metadata still lands.
|
||||||
|
assert_eq!(update(&mut conn, "na.bin", false), Some(na_id));
|
||||||
|
assert_eq!(state(&conn, na_id), STATE_NA);
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row("SELECT mtime FROM files WHERE id = ?1", [na_id], |r| r
|
||||||
|
.get::<_, i64>(0))
|
||||||
|
.unwrap(),
|
||||||
|
9
|
||||||
|
);
|
||||||
|
|
||||||
|
// PENDING → NA: the state transition alone.
|
||||||
|
assert_eq!(
|
||||||
|
update(&mut conn, "done.txt", false),
|
||||||
|
Some(ids["/t/done.txt"])
|
||||||
|
);
|
||||||
|
assert_eq!(state(&conn, ids["/t/done.txt"]), STATE_NA);
|
||||||
|
|
||||||
|
// keep.txt, never updated, keeps its posting — and it is the only one.
|
||||||
|
assert_eq!(count(&conn, "SELECT COUNT(*) FROM searchabletext"), 1);
|
||||||
|
assert_eq!(state(&conn, ids["/t/keep.txt"]), STATE_DONE);
|
||||||
|
|
||||||
|
// No row: `None`, not an invented insert.
|
||||||
|
assert_eq!(update(&mut conn, "vanished.txt", true), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolver behind stale cleanup's `with_postings` decision: the state it
|
||||||
|
/// returns is what decides whether FTS gets handed the id, so it must be the
|
||||||
|
/// stored state, and the unresolvable inputs must be `None`, not errors — a
|
||||||
|
/// stale list can legitimately name paths another writer already removed.
|
||||||
|
#[test]
|
||||||
|
fn id_and_state_for_path_reads_the_stored_state() {
|
||||||
|
let (_dir, p) = tmp_path();
|
||||||
|
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
let ids = seeded(&mut conn, &["/t/done.txt"]);
|
||||||
|
|
||||||
|
let na_id = {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let id = insert_file(
|
||||||
|
&tx,
|
||||||
|
&NewFile {
|
||||||
|
name: "na.bin",
|
||||||
|
parent: "/t/",
|
||||||
|
size: 1,
|
||||||
|
mtime: 1,
|
||||||
|
mime: None,
|
||||||
|
ftype: crate::mime::FileType::EMPTY,
|
||||||
|
hash: None,
|
||||||
|
needs_content: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.expect("unique path");
|
||||||
|
tx.commit().unwrap();
|
||||||
|
id
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
id_and_state_for_path(&conn, "/t/done.txt").unwrap(),
|
||||||
|
Some((ids["/t/done.txt"], STATE_DONE))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
id_and_state_for_path(&conn, "/t/na.bin").unwrap(),
|
||||||
|
Some((na_id, STATE_NA))
|
||||||
|
);
|
||||||
|
assert_eq!(id_and_state_for_path(&conn, "/t/vanished.txt").unwrap(), None);
|
||||||
|
assert_eq!(
|
||||||
|
id_and_state_for_path(&conn, "no-separator").unwrap(),
|
||||||
|
None,
|
||||||
|
"an unsplittable path resolves to nothing rather than erroring"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Dropping stored text must cost the file its snippets and nothing else.
|
/// Dropping stored text must cost the file its snippets and nothing else.
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_stored_text_keeps_the_file_searchable() {
|
fn drop_stored_text_keeps_the_file_searchable() {
|
||||||
|
|
@ -1199,6 +1363,74 @@ fn fts_rows(conn: &Connection) -> i64 {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every transition *out* of `STATE_DONE` takes the posting and the stored
|
||||||
|
/// body with it, so "a `searchabletext` row exists exactly when `content_state`
|
||||||
|
/// is `STATE_DONE`" holds however a row got where it is.
|
||||||
|
///
|
||||||
|
/// `count_root_counts_the_fts_rows_it_says_it_does` below checks the same
|
||||||
|
/// equivalence over rows that were *never* DONE, which is the easy half — it
|
||||||
|
/// passes even for a transition that leaves a stale posting behind. This is the
|
||||||
|
/// half that does not, and `delete_files_matching` narrows its tombstone
|
||||||
|
/// statement on the strength of it: a row that kept a posting past `DONE` would
|
||||||
|
/// keep it past deletion too, and answer searches for a file that is gone.
|
||||||
|
#[test]
|
||||||
|
fn leaving_done_always_takes_the_posting_with_it() {
|
||||||
|
let (_dir, p) = tmp_path();
|
||||||
|
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
|
||||||
|
// Each row starts DONE — with a posting and a stored body — and then takes
|
||||||
|
// one of the four ways out.
|
||||||
|
let leave: [(&str, fn(&rusqlite::Transaction<'_>, i64)); 4] = [
|
||||||
|
("/t/failed.txt", |tx, id| {
|
||||||
|
set_content_failed(tx, id, "bad parse").unwrap()
|
||||||
|
}),
|
||||||
|
("/t/na.txt", |tx, id| set_content_na(tx, id).unwrap()),
|
||||||
|
("/t/pending.txt", |tx, id| {
|
||||||
|
reset_content_pending(tx, id).unwrap()
|
||||||
|
}),
|
||||||
|
("/t/rewritten.txt", |tx, id| {
|
||||||
|
// The shape a changed file takes: metadata updated in place.
|
||||||
|
update_file_basic(
|
||||||
|
tx,
|
||||||
|
&NewFile {
|
||||||
|
name: "rewritten.txt",
|
||||||
|
parent: "/t/",
|
||||||
|
size: 99,
|
||||||
|
mtime: 99,
|
||||||
|
mime: Some("text/plain"),
|
||||||
|
ftype: FileType::TEXT,
|
||||||
|
hash: None,
|
||||||
|
needs_content: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.expect("the row is there");
|
||||||
|
let _ = id;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (path, transition) in leave {
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let id = insert_at(&tx, path, true);
|
||||||
|
set_content_done(&tx, id, "body text", zstd_of("body text").as_deref()).unwrap();
|
||||||
|
transition(&tx, id);
|
||||||
|
tx.commit().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
||||||
|
assert_eq!(
|
||||||
|
count("SELECT COUNT(*) FROM files WHERE content_state = 1"),
|
||||||
|
0,
|
||||||
|
"every row left DONE"
|
||||||
|
);
|
||||||
|
assert_eq!(fts_rows(&conn), 0, "and none of them kept its posting");
|
||||||
|
assert_eq!(
|
||||||
|
count("SELECT COUNT(*) FROM documents_text"),
|
||||||
|
0,
|
||||||
|
"nor its stored body, which would outlive the file it was read from"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The premise of `count_root`: `content_state = DONE` exactly when a
|
/// The premise of `count_root`: `content_state = DONE` exactly when a
|
||||||
/// `searchabletext` row exists. Pinned against the FTS table itself, because
|
/// `searchabletext` row exists. Pinned against the FTS table itself, because
|
||||||
/// the equivalence is what breaks if a transition writes one without the
|
/// the equivalence is what breaks if a transition writes one without the
|
||||||
|
|
@ -1348,12 +1580,16 @@ fn the_parent_scan_reaches_the_roots_own_directory() {
|
||||||
fn deleting_a_file_row_cascades_the_fk_tables() {
|
fn deleting_a_file_row_cascades_the_fk_tables() {
|
||||||
let (_dir, path) = tmp_path();
|
let (_dir, path) = tmp_path();
|
||||||
let mut conn = open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
|
let mut conn = open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
|
||||||
let ids = seeded(&mut conn, &["/casc/a.txt", "/casc/b.txt"]);
|
seeded(&mut conn, &["/casc/a.txt", "/casc/b.txt"]);
|
||||||
let count =
|
let count =
|
||||||
|conn: &Connection, sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
|conn: &Connection, sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
||||||
{
|
{
|
||||||
|
// The failure goes on a row that never extracted, which is the only
|
||||||
|
// way a run reaches `set_content_failed` — and keeps the two searchable
|
||||||
|
// rows searchable, so the FTS figures below stay about the cascade.
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
set_content_failed(&tx, ids["/casc/b.txt"], "boom").unwrap();
|
let never = insert_at(&tx, "/casc/c.bin", true);
|
||||||
|
set_content_failed(&tx, never, "boom").unwrap();
|
||||||
tx.commit().unwrap();
|
tx.commit().unwrap();
|
||||||
}
|
}
|
||||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM documents_text"), 2);
|
assert_eq!(count(&conn, "SELECT COUNT(*) FROM documents_text"), 2);
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,11 @@ fn write_prepared_records(
|
||||||
records: &[OwnedNewFile],
|
records: &[OwnedNewFile],
|
||||||
stop_flag: &Arc<AtomicBool>,
|
stop_flag: &Arc<AtomicBool>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
chunk_size: usize,
|
|
||||||
write_row: impl Fn(&rusqlite::Transaction<'_>, &OwnedNewFile) -> Result<Option<i64>, String>,
|
write_row: impl Fn(&rusqlite::Transaction<'_>, &OwnedNewFile) -> Result<Option<i64>, String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// `.max(1)`: `chunks(0)` panics on the indexing thread, so a hand-edited
|
||||||
|
// `batch_size = 0` would wedge indexing while the UI reads "Running".
|
||||||
|
let chunk_size = config.processing.batch_size.max(1);
|
||||||
// One set of buffers for every chunk this call writes.
|
// One set of buffers for every chunk this call writes.
|
||||||
let mut bodies = Bodies::new()?;
|
let mut bodies = Bodies::new()?;
|
||||||
for batch in records.chunks(chunk_size) {
|
for batch in records.chunks(chunk_size) {
|
||||||
|
|
@ -134,17 +136,15 @@ pub fn process_batch_updates(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let fts_batch = config.processing.fts_update_batch_size.max(1);
|
// The row leaves this closure `_fresh`: `update_file_basic` cleared a
|
||||||
|
// DONE row's content in this same transaction — every other state
|
||||||
// The row leaves this closure `_fresh`: `update_file_basic` cleared its
|
// provably holds none (`repo_tests::leaving_done_always_takes_the_posting_with_it`)
|
||||||
// content in this same transaction, and the insert fallback created the
|
// — and the insert fallback created the row outright.
|
||||||
// row outright.
|
|
||||||
write_prepared_records(
|
write_prepared_records(
|
||||||
conn_mutex,
|
conn_mutex,
|
||||||
files_to_update,
|
files_to_update,
|
||||||
stop_flag,
|
stop_flag,
|
||||||
config,
|
config,
|
||||||
fts_batch,
|
|
||||||
|tx, rec| {
|
|tx, rec| {
|
||||||
let updated = repo::update_file_basic(tx, &rec.as_new_file()).map_err(|e| {
|
let updated = repo::update_file_basic(tx, &rec.as_new_file()).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
|
|
@ -182,9 +182,6 @@ pub fn process_batch_inserts(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// `.max(1)`: `chunks(0)` panics on the indexing thread, so a hand-edited
|
|
||||||
// `batch_size = 0` would wedge indexing while the UI reads "Running".
|
|
||||||
//
|
|
||||||
// The row leaves this closure `_fresh`: `insert_file` returned `Some`
|
// The row leaves this closure `_fresh`: `insert_file` returned `Some`
|
||||||
// only by creating it, so it cannot carry content from anywhere.
|
// only by creating it, so it cannot carry content from anywhere.
|
||||||
write_prepared_records(
|
write_prepared_records(
|
||||||
|
|
@ -192,7 +189,6 @@ pub fn process_batch_inserts(
|
||||||
files_to_insert,
|
files_to_insert,
|
||||||
stop_flag,
|
stop_flag,
|
||||||
config,
|
config,
|
||||||
config.processing.batch_size.max(1),
|
|
||||||
|tx, rec| {
|
|tx, rec| {
|
||||||
repo::insert_file(tx, &rec.as_new_file())
|
repo::insert_file(tx, &rec.as_new_file())
|
||||||
.map_err(|e| format!("Failed to insert file record: {}", e))
|
.map_err(|e| format!("Failed to insert file record: {}", e))
|
||||||
|
|
@ -203,6 +199,26 @@ pub fn process_batch_inserts(
|
||||||
/// Delete the rows a completed run found no file behind. Returns how many
|
/// Delete the rows a completed run found no file behind. Returns how many
|
||||||
/// went. A chunk either commits whole or is not begun, so a stop cannot
|
/// went. A chunk either commits whole or is not begun, so a stop cannot
|
||||||
/// leave the index half-reconciled.
|
/// leave the index half-reconciled.
|
||||||
|
///
|
||||||
|
/// FTS5's delete-merging is held off for the whole pass
|
||||||
|
/// ([`fts_begin_tombstone_burst`]) — this is a bulk withdrawal, the shape the
|
||||||
|
/// burst was measured on. A stop mid-pass returns without restoring it, which
|
||||||
|
/// is the established abandonment semantics: the next run's
|
||||||
|
/// [`fts_begin_bulk_write`] puts the threshold back.
|
||||||
|
///
|
||||||
|
/// `examples/pruneprobe.rs`'s stale table prices the shape (200k-row index,
|
||||||
|
/// 40k stale paths, 1 in 7 holding a posting): the per-path form this
|
||||||
|
/// replaced took 794 ms plain / 1,627 ms keyed; this takes 532 / 1,226 —
|
||||||
|
/// 1.5x and 1.3x, most of it the burst, the rest the batched deletes and the
|
||||||
|
/// `with_postings` narrowing. The gain is smaller than the reconcile's
|
||||||
|
/// because here only a seventh of the doomed rows hold a posting.
|
||||||
|
///
|
||||||
|
/// Each chunk resolves `(id, content_state)` **inside its own transaction**
|
||||||
|
/// and hands [`repo::delete_ids`] the `STATE_DONE` subset as `with_postings`.
|
||||||
|
/// The placement is load-bearing: extraction may still be running (its writes
|
||||||
|
/// serialize on this same connection mutex), so between chunks a doomed row
|
||||||
|
/// can flip PENDING→DONE — resolved any earlier, its posting would outlive
|
||||||
|
/// the row.
|
||||||
pub fn cleanup_stale_index_entries(
|
pub fn cleanup_stale_index_entries(
|
||||||
conn_mutex: &Arc<Mutex<Connection>>,
|
conn_mutex: &Arc<Mutex<Connection>>,
|
||||||
stale_paths: &[String],
|
stale_paths: &[String],
|
||||||
|
|
@ -215,6 +231,13 @@ pub fn cleanup_stale_index_entries(
|
||||||
let chunk = config.processing.batch_size.max(1);
|
let chunk = config.processing.batch_size.max(1);
|
||||||
let mut deleted_count = 0usize;
|
let mut deleted_count = 0usize;
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = crate::lock_ok(conn_mutex);
|
||||||
|
fts_begin_tombstone_burst(&conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ids: Vec<i64> = Vec::new();
|
||||||
|
let mut with_postings: Vec<i64> = Vec::new();
|
||||||
for batch in stale_paths.chunks(chunk) {
|
for batch in stale_paths.chunks(chunk) {
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
if stop_flag.load(Ordering::Relaxed) {
|
||||||
return Ok(deleted_count);
|
return Ok(deleted_count);
|
||||||
|
|
@ -223,26 +246,26 @@ pub fn cleanup_stale_index_entries(
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.unchecked_transaction()
|
.unchecked_transaction()
|
||||||
.map_err(|e| format!("Failed to begin stale cleanup transaction: {}", e))?;
|
.map_err(|e| format!("Failed to begin stale cleanup transaction: {}", e))?;
|
||||||
|
ids.clear();
|
||||||
|
with_postings.clear();
|
||||||
for path in batch {
|
for path in batch {
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
if let Some((id, state)) = repo::id_and_state_for_path(&tx, path)
|
||||||
break;
|
.map_err(|e| format!("Failed to resolve stale index entry: {}", e))?
|
||||||
}
|
|
||||||
if repo::delete_file_by_path(&tx, path)
|
|
||||||
.map_err(|e| format!("Failed to remove stale index entry for {}: {}", path, e))?
|
|
||||||
{
|
{
|
||||||
deleted_count += 1;
|
ids.push(id);
|
||||||
|
if state == repo::STATE_DONE {
|
||||||
|
with_postings.push(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
deleted_count += repo::delete_ids(&tx, &ids, &with_postings)?;
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.map_err(|e| format!("Failed to commit stale cleanup transaction: {}", e))?;
|
.map_err(|e| format!("Failed to commit stale cleanup transaction: {}", e))?;
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
|
||||||
return Ok(deleted_count);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if deleted_count > 0 && !stop_flag.load(Ordering::Relaxed) {
|
if !stop_flag.load(Ordering::Relaxed) {
|
||||||
let conn = crate::lock_ok(conn_mutex);
|
let conn = crate::lock_ok(conn_mutex);
|
||||||
fts_finalize_after_text_indexing(&conn);
|
fts_end_tombstone_burst(&conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(deleted_count)
|
Ok(deleted_count)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ pub(crate) use paths::{
|
||||||
};
|
};
|
||||||
pub use records::{
|
pub use records::{
|
||||||
classify_by_mtime, classify_for_indexing, content_extractable, decide_content,
|
classify_by_mtime, classify_for_indexing, content_extractable, decide_content,
|
||||||
extract_and_store, fts_begin_bulk_write, fts_finalize_after_text_indexing, fts_set_automerge,
|
extract_and_store, fts_begin_bulk_write, fts_begin_tombstone_burst, fts_end_tombstone_burst,
|
||||||
|
fts_finalize_after_text_indexing, fts_set_automerge, FTS_DELETEMERGE,
|
||||||
get_file_hash, hash_failure_counts, outcome_body, prepare_file_record,
|
get_file_hash, hash_failure_counts, outcome_body, prepare_file_record,
|
||||||
prepare_file_record_from_path, reset_run_warnings, store_content_outcome, ContentOutcome,
|
prepare_file_record_from_path, reset_run_warnings, store_content_outcome, ContentOutcome,
|
||||||
DirRows, FileIndexAction, OwnedNewFile,
|
DirRows, FileIndexAction, OwnedNewFile,
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,147 @@ pub fn fts_set_automerge(conn: &Connection, segments: u8) {
|
||||||
/// smaller, more build-stable index for no cost; 64 measured identically.
|
/// smaller, more build-stable index for no cost; 64 measured identically.
|
||||||
const WRITE_CRISISMERGE: u8 = 32;
|
const WRITE_CRISISMERGE: u8 = 32;
|
||||||
|
|
||||||
|
/// Percentage of a level's entries that must be tombstones before FTS5 rewrites
|
||||||
|
/// the level to reclaim them, and **the single most expensive setting a bulk
|
||||||
|
/// withdrawal of content runs into.** FTS5's own default, restored by
|
||||||
|
/// [`fts_begin_bulk_write`] and by [`fts_end_tombstone_burst`].
|
||||||
|
///
|
||||||
|
/// `0` disables delete-merging entirely — including from an explicit `'merge'`,
|
||||||
|
/// because `fts5IndexFindDeleteMerge` returns early on it — so it must never be
|
||||||
|
/// the resting value of an index. [`fts_begin_tombstone_burst`] is the only
|
||||||
|
/// thing that sets it, and always in a pair.
|
||||||
|
pub const FTS_DELETEMERGE: u8 = 10;
|
||||||
|
|
||||||
|
/// Set FTS5's delete-merge threshold. Best-effort; failure is logged.
|
||||||
|
fn fts_set_deletemerge(conn: &Connection, percent: u8) {
|
||||||
|
if let Err(e) = conn.execute(
|
||||||
|
"INSERT INTO searchabletext(searchabletext, rank) VALUES('deletemerge', ?1)",
|
||||||
|
[percent as i64],
|
||||||
|
) {
|
||||||
|
crate::log_warn!("FTS deletemerge failed (non-fatal): {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop FTS5 reclaiming tombstones *while* a pass is creating them, for a
|
||||||
|
/// caller that is about to delete a great many postings in one go and will call
|
||||||
|
/// [`fts_end_tombstone_burst`] when it is done.
|
||||||
|
///
|
||||||
|
/// # What it is worth
|
||||||
|
///
|
||||||
|
/// `examples/contentprobe.rs`, 40k rows of which 7,273 lose their content
|
||||||
|
/// (6,909 of them holding a posting), chunked deletes, committing per
|
||||||
|
/// `scope::SLICE`. Both arms, each pair from one run:
|
||||||
|
///
|
||||||
|
/// | arm | | fts | commit | pass | + merge | misses |
|
||||||
|
/// |---|---|---|---|---|---|---|
|
||||||
|
/// | plain | `deletemerge` 10 | 734 ms | 96 ms | **891 ms** | 892 ms | 97,130 |
|
||||||
|
/// | plain | `deletemerge` 0 | 86 ms | 11 ms | **141 ms** | 218 ms | 9,654 |
|
||||||
|
/// | keyed | `deletemerge` 10 | 1174 ms | 263 ms | **1576 ms** | 1579 ms | 96,181 |
|
||||||
|
/// | keyed | `deletemerge` 0 | 128 ms | 17 ms | **211 ms** | 373 ms | 9,476 |
|
||||||
|
///
|
||||||
|
/// 6.3x on the pass plain and 7.5x keyed; 4.1x and 4.2x once the trailing merge
|
||||||
|
/// is counted. Page-cache misses fall tenfold, which is why the keyed arm gains
|
||||||
|
/// more — every one of those pages was being decrypted and re-encrypted.
|
||||||
|
///
|
||||||
|
/// It also lands on a **smaller** index than the shipped path does — `%_data`
|
||||||
|
/// 1,753 rows against 1,886 — because one merge at the end consolidates better
|
||||||
|
/// than many mid-pass ones. That is the whole bargain: the mid-pass merges are
|
||||||
|
/// not just expensive, they are worse at the job.
|
||||||
|
///
|
||||||
|
/// End to end — `scope::advance` driven to completion the way the coordinator
|
||||||
|
/// drives it, on an idle machine — this and the two changes beside it are worth
|
||||||
|
/// **4.3x to 5.6x**, and the ratio *grows* with the index, because the levels
|
||||||
|
/// being needlessly rewritten grow with it:
|
||||||
|
///
|
||||||
|
/// | corpus | arm | before | after | |
|
||||||
|
/// |---|---|---|---|---|
|
||||||
|
/// | 40k | plain | 1,066 ms | **248 ms** | 4.3x |
|
||||||
|
/// | 40k | keyed | 1,814 ms | **417 ms** | 4.4x |
|
||||||
|
/// | 200k | plain | 4,600 ms | **925 ms** | 5.0x |
|
||||||
|
/// | 200k | keyed | 8,598 ms | **1,530 ms** | 5.6x |
|
||||||
|
///
|
||||||
|
/// The 40k pair is a true A/B: the same probe and corpus run against this tree
|
||||||
|
/// and against `HEAD` without these changes. Its *control* is what makes it a
|
||||||
|
/// measurement rather than two numbers from two binaries —
|
||||||
|
/// `contentprobe`'s `+clear(all)` stage reproduces the old shape in the probe's
|
||||||
|
/// own code, so it must not move between the builds, and it did not (1000 → 996
|
||||||
|
/// plain, 1801 → 1770 keyed). The 200k rows use that validated stage as the
|
||||||
|
/// "before", which is why they can come from a single run.
|
||||||
|
///
|
||||||
|
/// Measured and rejected alongside it: sorting each page's ids into rowid order
|
||||||
|
/// before deleting, which moved nothing (898 ms against 891 plain, 1565 against
|
||||||
|
/// 1576 keyed). FTS5 picks a tombstone page by *hashing* the rowid, so there is
|
||||||
|
/// no locality to restore.
|
||||||
|
///
|
||||||
|
/// # Why it is so large
|
||||||
|
///
|
||||||
|
/// Every contentless delete counts into the same write-counter that drives
|
||||||
|
/// `fts5IndexAutomerge`, and once a level passes this threshold
|
||||||
|
/// `fts5IndexFindDeleteMerge` picks it and rewrites the whole level — inline
|
||||||
|
/// with the scan, and then again as the pass keeps deleting. Rewriting the
|
||||||
|
/// full-text index is what building it was.
|
||||||
|
///
|
||||||
|
/// # The pairing is load-bearing
|
||||||
|
///
|
||||||
|
/// `deletemerge` is persisted in FTS5's `%_config` shadow table, so a value
|
||||||
|
/// left at `0` outlives the process and no later `'merge'` would ever reclaim a
|
||||||
|
/// tombstone again. [`fts_end_tombstone_burst`] restores it, and
|
||||||
|
/// [`fts_begin_bulk_write`] sets it unconditionally so that a crash between the
|
||||||
|
/// two is repaired by the next indexing run rather than being permanent.
|
||||||
|
pub fn fts_begin_tombstone_burst(conn: &Connection) {
|
||||||
|
fts_set_deletemerge(conn, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounds of [`fts_finalize_after_text_indexing`] a burst may spend
|
||||||
|
/// consolidating before it gives up and leaves the rest to the next run.
|
||||||
|
///
|
||||||
|
/// Three is what the corpus in [`fts_begin_tombstone_burst`] needed; the cap is
|
||||||
|
/// above that so the common case finishes, and exists only so a pathological
|
||||||
|
/// index cannot hold the coordinator thread indefinitely.
|
||||||
|
const BURST_MERGE_ROUNDS: u32 = 8;
|
||||||
|
|
||||||
|
/// Restore delete-merging and consolidate what the burst left behind. The
|
||||||
|
/// mirror of [`fts_begin_tombstone_burst`]; see there for the measurements.
|
||||||
|
///
|
||||||
|
/// The order matters twice: the merge would reclaim nothing with the threshold
|
||||||
|
/// still at `0`, and the threshold must go back even when there is nothing to
|
||||||
|
/// merge, because a value left there would outlive the process.
|
||||||
|
///
|
||||||
|
/// # Why this merges to quiescence and `fts_finalize_after_text_indexing`
|
||||||
|
/// does not
|
||||||
|
///
|
||||||
|
/// A single 1000-page `'merge'` is the right bargain at the end of an indexing
|
||||||
|
/// run — whatever it leaves, the next run's merge finishes, and it was never
|
||||||
|
/// far behind. A burst is a different bargain: it deliberately built up a
|
||||||
|
/// backlog several times larger than a run ever does (`%_data` 4,931 rows
|
||||||
|
/// against the 1,886 the shipped path leaves), and searching against that until
|
||||||
|
/// some future run is a cost this pass created and should pay. It takes 77 ms
|
||||||
|
/// plain and 162 ms keyed, against the ~750 ms and ~1,450 ms the burst saved.
|
||||||
|
///
|
||||||
|
/// The quiescence signal is the `%_data` row count, and it has to be: measured
|
||||||
|
/// in `examples/pruneprobe.rs`, `sqlite3_changes()` after a `'merge'` reports
|
||||||
|
/// non-zero forever, so the obvious `while changes() != 0` never terminates.
|
||||||
|
pub fn fts_end_tombstone_burst(conn: &Connection) {
|
||||||
|
fts_set_deletemerge(conn, FTS_DELETEMERGE);
|
||||||
|
let mut last = fts_data_rows(conn);
|
||||||
|
for _ in 0..BURST_MERGE_ROUNDS {
|
||||||
|
fts_finalize_after_text_indexing(conn);
|
||||||
|
let now = fts_data_rows(conn);
|
||||||
|
if now == last {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
last = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows in FTS5's `%_data` shadow table — how much the full-text index is
|
||||||
|
/// physically holding, tombstones and all. `None` if it cannot be read, which
|
||||||
|
/// stops [`fts_end_tombstone_burst`]'s loop rather than spinning it.
|
||||||
|
fn fts_data_rows(conn: &Connection) -> Option<i64> {
|
||||||
|
conn.query_row("SELECT COUNT(*) FROM searchabletext_data", [], |r| r.get(0))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply the write-side FTS5 settings, before a run starts writing.
|
/// Apply the write-side FTS5 settings, before a run starts writing.
|
||||||
///
|
///
|
||||||
/// `pgsz` is deliberately absent: it is not a per-run setting. Sweeping it
|
/// `pgsz` is deliberately absent: it is not a per-run setting. Sweeping it
|
||||||
|
|
@ -123,8 +264,13 @@ const WRITE_CRISISMERGE: u8 = 32;
|
||||||
/// default 4050 stands there. Keyed is the opposite — SQLCipher's page reserve
|
/// default 4050 stands there. Keyed is the opposite — SQLCipher's page reserve
|
||||||
/// makes 4050 a cliff — and that case is handled once at schema creation; see
|
/// makes 4050 a cliff — and that case is handled once at schema creation; see
|
||||||
/// [`crate::db::schema::FTS_PGSZ_ENCRYPTED`].
|
/// [`crate::db::schema::FTS_PGSZ_ENCRYPTED`].
|
||||||
|
///
|
||||||
|
/// `deletemerge` is set even though this never lowers it: it is how an index
|
||||||
|
/// whose reconcile was killed mid-burst gets its tombstone reclamation back.
|
||||||
|
/// See [`fts_begin_tombstone_burst`].
|
||||||
pub fn fts_begin_bulk_write(conn: &Connection) {
|
pub fn fts_begin_bulk_write(conn: &Connection) {
|
||||||
fts_set_automerge(conn, WRITE_AUTOMERGE);
|
fts_set_automerge(conn, WRITE_AUTOMERGE);
|
||||||
|
fts_set_deletemerge(conn, FTS_DELETEMERGE);
|
||||||
if let Err(e) = conn.execute(
|
if let Err(e) = conn.execute(
|
||||||
"INSERT INTO searchabletext(searchabletext, rank) VALUES('crisismerge', ?1)",
|
"INSERT INTO searchabletext(searchabletext, rank) VALUES('crisismerge', ?1)",
|
||||||
[WRITE_CRISISMERGE as i64],
|
[WRITE_CRISISMERGE as i64],
|
||||||
|
|
|
||||||
|
|
@ -600,3 +600,194 @@ fn a_symlinked_root_yields_no_directories_when_following_is_off() {
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
std::fs::remove_dir_all(&base).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A reconcile killed mid-burst leaves `deletemerge` at 0, where no later
|
||||||
|
/// `'merge'` would reclaim a tombstone again. The next indexing run repairs it.
|
||||||
|
///
|
||||||
|
/// This is the whole reason [`fts_begin_bulk_write`] writes a value it never
|
||||||
|
/// lowers: the pairing in `scope::advance` covers the orderly cases, and this
|
||||||
|
/// covers the process simply not coming back.
|
||||||
|
#[test]
|
||||||
|
fn a_bulk_write_repairs_a_delete_merge_threshold_left_off() {
|
||||||
|
let dir = crate::testutil::scratch_dir("deletemerge-repair");
|
||||||
|
let db = dir.join("index.sqlite");
|
||||||
|
let conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
|
||||||
|
let threshold = || -> Option<i64> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
};
|
||||||
|
|
||||||
|
// What a killed burst leaves behind.
|
||||||
|
fts_begin_tombstone_burst(&conn);
|
||||||
|
assert_eq!(threshold(), Some(0), "the burst is in effect");
|
||||||
|
|
||||||
|
fts_begin_bulk_write(&conn);
|
||||||
|
assert_eq!(
|
||||||
|
threshold(),
|
||||||
|
Some(i64::from(FTS_DELETEMERGE)),
|
||||||
|
"the next run puts tombstone reclamation back"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seed one row per content state under `/t/`, plus a survivor. Returns
|
||||||
|
/// `name -> id`. States: the two `done-*` rows hold postings, the others do
|
||||||
|
/// not — the distinction stale cleanup's `with_postings` narrowing draws.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn seed_stale_corpus(
|
||||||
|
conn: &mut rusqlite::Connection,
|
||||||
|
) -> std::collections::HashMap<&'static str, i64> {
|
||||||
|
use crate::db::repo::{self, NewFile};
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let mut ids = std::collections::HashMap::new();
|
||||||
|
for (name, needs_content) in [
|
||||||
|
("done-stale.txt", true),
|
||||||
|
("done-kept.txt", true),
|
||||||
|
("na-stale.bin", false),
|
||||||
|
("pending-stale.txt", true),
|
||||||
|
("failed-stale.txt", true),
|
||||||
|
] {
|
||||||
|
let id = repo::insert_file(
|
||||||
|
&tx,
|
||||||
|
&NewFile {
|
||||||
|
name,
|
||||||
|
parent: "/t/",
|
||||||
|
size: 1,
|
||||||
|
mtime: 1,
|
||||||
|
mime: Some("text/plain"),
|
||||||
|
ftype: crate::mime::FileType::TEXT,
|
||||||
|
hash: None,
|
||||||
|
needs_content,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.expect("unique path");
|
||||||
|
ids.insert(name, id);
|
||||||
|
}
|
||||||
|
repo::set_content_done(&tx, ids["done-stale.txt"], "body one", None).unwrap();
|
||||||
|
repo::set_content_done(&tx, ids["done-kept.txt"], "body two", None).unwrap();
|
||||||
|
repo::set_content_failed(&tx, ids["failed-stale.txt"], "bad parse").unwrap();
|
||||||
|
tx.commit().unwrap();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stale pass over rows in every content state: exactly the stale rows
|
||||||
|
/// go, a posting survives exactly for the surviving DONE row, and the
|
||||||
|
/// delete-merge threshold the pass held off is back afterwards. An orphan
|
||||||
|
/// posting would surface as a hit for a file no longer indexed, with no error
|
||||||
|
/// anywhere — the failure mode this pins.
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn stale_cleanup_deletes_exactly_the_stale_rows_and_their_postings() {
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let dir = crate::testutil::scratch_dir("stale-cleanup");
|
||||||
|
let db = dir.join("index.sqlite");
|
||||||
|
let mut conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
let ids = seed_stale_corpus(&mut conn);
|
||||||
|
|
||||||
|
// `vanished.txt` was never indexed: a stale list can legitimately name a
|
||||||
|
// path another writer already removed, and it must not fail the pass.
|
||||||
|
let stale: Vec<String> = [
|
||||||
|
"done-stale.txt",
|
||||||
|
"na-stale.bin",
|
||||||
|
"pending-stale.txt",
|
||||||
|
"failed-stale.txt",
|
||||||
|
"vanished.txt",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|n| format!("/t/{}", n))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let conn_mutex = Arc::new(Mutex::new(conn));
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let deleted =
|
||||||
|
cleanup_stale_index_entries(&conn_mutex, &stale, &stop, &crate::config::Config::default())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(deleted, 4);
|
||||||
|
|
||||||
|
let conn = Arc::try_unwrap(conn_mutex)
|
||||||
|
.map_err(|_| ())
|
||||||
|
.expect("sole owner")
|
||||||
|
.into_inner()
|
||||||
|
.unwrap();
|
||||||
|
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
||||||
|
assert_eq!(count("SELECT COUNT(*) FROM files"), 1);
|
||||||
|
assert_eq!(count("SELECT COUNT(*) FROM searchabletext"), 1);
|
||||||
|
assert_eq!(count("SELECT COUNT(*) FROM documents_text"), 0);
|
||||||
|
assert_eq!(count("SELECT COUNT(*) FROM failed_files"), 0);
|
||||||
|
let hit: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT rowid FROM searchabletext WHERE searchabletext MATCH 'body'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hit, ids["done-kept.txt"], "the survivor is still findable");
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
i64::from(FTS_DELETEMERGE),
|
||||||
|
"the pass restored the threshold it held off"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stop mid-pass returns without restoring `deletemerge` — the abandonment
|
||||||
|
/// semantics `a_bulk_write_repairs_a_delete_merge_threshold_left_off` exists
|
||||||
|
/// for. If this ever starts restoring it eagerly, that test loses its
|
||||||
|
/// subject; if it starts deleting under a stop, a stopped run reconciles.
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn a_stopped_stale_cleanup_deletes_nothing_and_leaves_the_burst() {
|
||||||
|
use std::sync::atomic::AtomicBool;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
let dir = crate::testutil::scratch_dir("stale-cleanup-stop");
|
||||||
|
let db = dir.join("index.sqlite");
|
||||||
|
let mut conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
seed_stale_corpus(&mut conn);
|
||||||
|
|
||||||
|
let conn_mutex = Arc::new(Mutex::new(conn));
|
||||||
|
let stop = Arc::new(AtomicBool::new(true));
|
||||||
|
let deleted = cleanup_stale_index_entries(
|
||||||
|
&conn_mutex,
|
||||||
|
&["/t/done-stale.txt".to_string()],
|
||||||
|
&stop,
|
||||||
|
&crate::config::Config::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(deleted, 0);
|
||||||
|
|
||||||
|
let conn = Arc::try_unwrap(conn_mutex)
|
||||||
|
.map_err(|_| ())
|
||||||
|
.expect("sole owner")
|
||||||
|
.into_inner()
|
||||||
|
.unwrap();
|
||||||
|
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
||||||
|
assert_eq!(count("SELECT COUNT(*) FROM files"), 5, "a stop deletes nothing");
|
||||||
|
assert_eq!(
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
|
||||||
|
[],
|
||||||
|
|r| r.get::<_, i64>(0),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
0,
|
||||||
|
"the burst is left for the next run's bulk write to repair"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,13 @@ use super::*;
|
||||||
/// wholesale — or newly excluded — is never read, so per-directory
|
/// wholesale — or newly excluded — is never read, so per-directory
|
||||||
/// reconciliation cannot see its rows. Not deletions: parents under an
|
/// reconciliation cannot see its rows. Not deletions: parents under an
|
||||||
/// unreadable directory, and paths reached via symlink (`aliased`).
|
/// unreadable directory, and paths reached via symlink (`aliased`).
|
||||||
|
///
|
||||||
|
/// Takes a read connection, not the writer's: this scans every stored parent
|
||||||
|
/// under the root, and roots still `Extracting` need the writer while it
|
||||||
|
/// runs. Read-your-writes is safe — every walk batch was committed before
|
||||||
|
/// the sweep starts. Same reasoning as `count_extract_scope`.
|
||||||
fn sweep_unvisited_parents(
|
fn sweep_unvisited_parents(
|
||||||
conn_mutex: &Arc<Mutex<Connection>>,
|
conn: &Connection,
|
||||||
root: &str,
|
root: &str,
|
||||||
seen_dirs: &HashSet<String>,
|
seen_dirs: &HashSet<String>,
|
||||||
unreadable: &crate::file_handling::UnreadableDirs,
|
unreadable: &crate::file_handling::UnreadableDirs,
|
||||||
|
|
@ -37,17 +42,16 @@ fn sweep_unvisited_parents(
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Same keyset range the extraction cursor uses: `[root + "/", root + "0")`.
|
// Same keyset range the extraction cursor uses: `[root + "/", root + "0")`.
|
||||||
let range = ExtractCursor::for_root(root);
|
let range = ExtractCursor::for_root(root);
|
||||||
let conn = crate::lock_ok(conn_mutex);
|
|
||||||
|
|
||||||
let mut unvisited: Vec<String> = Vec::new();
|
let mut unvisited: Vec<String> = Vec::new();
|
||||||
repo::for_each_parent_in_range(&conn, &range.lo, &range.hi, |parent| {
|
repo::for_each_parent_in_range(conn, &range.lo, &range.hi, |parent| {
|
||||||
if !seen_dirs.contains(&parent) && !unreadable.covers(&parent) {
|
if !seen_dirs.contains(&parent) && !unreadable.covers(&parent) {
|
||||||
unvisited.push(parent);
|
unvisited.push(parent);
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
for parent in unvisited {
|
for parent in unvisited {
|
||||||
for path in repo::paths_in_dir(&conn, &parent)? {
|
for path in repo::paths_in_dir(conn, &parent)? {
|
||||||
if !aliased.contains(&path) {
|
if !aliased.contains(&path) {
|
||||||
out.push(path);
|
out.push(path);
|
||||||
}
|
}
|
||||||
|
|
@ -742,9 +746,12 @@ fn cleanup_stale(pipelines: &[RootPipeline], cx: &mut RunCx<'_>) -> Result<(), S
|
||||||
// stored parent under every root, and the merge that ends the deletion is
|
// stored parent under every root, and the merge that ends the deletion is
|
||||||
// minutes of writer time on a big index.
|
// minutes of writer time on a big index.
|
||||||
let _maintaining = cx.maintaining(MaintenanceStep::RemovingStale);
|
let _maintaining = cx.maintaining(MaintenanceStep::RemovingStale);
|
||||||
|
// One read connection for the whole sweep, so the writer stays free for
|
||||||
|
// any root still extracting; see `sweep_unvisited_parents`.
|
||||||
|
let sweep_conn = crate::db::open::open_walk_reader(cx.db_path)?;
|
||||||
for p in pipelines.iter() {
|
for p in pipelines.iter() {
|
||||||
sweep_unvisited_parents(
|
sweep_unvisited_parents(
|
||||||
&cx.conn_mutex,
|
&sweep_conn,
|
||||||
&p.root,
|
&p.root,
|
||||||
&p.walk.seen_dirs(),
|
&p.walk.seen_dirs(),
|
||||||
p.walk.unreadable(),
|
p.walk.unreadable(),
|
||||||
|
|
@ -752,6 +759,7 @@ fn cleanup_stale(pipelines: &[RootPipeline], cx: &mut RunCx<'_>) -> Result<(), S
|
||||||
&mut cx.stale_candidates,
|
&mut cx.stale_candidates,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
drop(sweep_conn);
|
||||||
// The aliased filter applies to both sources: per-directory
|
// The aliased filter applies to both sources: per-directory
|
||||||
// reconciliation can flag a symlink target as stale while the alias route
|
// reconciliation can flag a symlink target as stale while the alias route
|
||||||
// inserted it — the row would be written and deleted on every run.
|
// inserted it — the row would be written and deleted on every run.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,34 @@
|
||||||
//! Brings a stored index back in line with a changed configuration without
|
//! Brings a stored index back in line with a changed configuration without
|
||||||
//! rebuilding it. Nothing here stamps the stored configuration — the caller
|
//! rebuilding it. Nothing here stamps the stored configuration — the caller
|
||||||
//! does, and only once the cursor reports finished; see [`outstanding_work`].
|
//! does, and only once the cursor reports finished; see [`outstanding_work`].
|
||||||
|
//!
|
||||||
|
//! # Where the time goes
|
||||||
|
//!
|
||||||
|
//! Almost entirely in FTS5: withdrawing content from a row tombstones its
|
||||||
|
//! posting, and that is 100x what deciding the row costs. `examples/contentprobe.rs`
|
||||||
|
//! and `examples/pruneprobe.rs` attribute a pass to a phase; the three
|
||||||
|
//! decisions that came out of them are [`SLICE`]-long transactions rather than
|
||||||
|
//! one per page, [`PagePlan`]'s chunked writes narrowed to the rows that can
|
||||||
|
//! actually hold what is being cleared, and
|
||||||
|
//! [`crate::file_handling::fts_begin_tombstone_burst`] — much the largest of
|
||||||
|
//! the three, and the place to read for why.
|
||||||
|
//!
|
||||||
|
//! Measured and **not** adopted, so they are not re-derived. Both looked
|
||||||
|
//! obvious; neither paid.
|
||||||
|
//!
|
||||||
|
//! - **A bigger page cache for the pass.** `PRAGMAS_INCREMENTAL`'s 4 MiB looks
|
||||||
|
//! far too small for a scan that rewrites across the whole index, and raising
|
||||||
|
//! it does exactly what it should to the miss count — 79,266 down to 6,499 at
|
||||||
|
//! 64 MiB — while the clock stays put (927 ms against 976). The misses were
|
||||||
|
//! never the expensive part; the tombstone writes behind them were.
|
||||||
|
//! - **Range-deleting a wholly-excluded directory** rather than paging through
|
||||||
|
//! it (`pruneprobe`'s `+subtree`). It does skip the page loop for every
|
||||||
|
//! doomed row, and costs 247 ms against `+fts(done)`'s 243.
|
||||||
|
//!
|
||||||
|
//! A third was never built, for the same reason: scanning by rowid instead of
|
||||||
|
//! by `(parent, name)` when `prune_scope` is off, which the root loop would
|
||||||
|
//! allow. Reading the pages is 24–30 ms of a ~950 ms pass — there is nothing
|
||||||
|
//! there to win.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
@ -11,7 +39,9 @@ use rusqlite::Connection;
|
||||||
use crate::config::{Config, IgnoreSet, IndexWork};
|
use crate::config::{Config, IgnoreSet, IndexWork};
|
||||||
use crate::db::repo;
|
use crate::db::repo;
|
||||||
use crate::extract::Registry;
|
use crate::extract::Registry;
|
||||||
use crate::file_handling::{content_extractable, fts_finalize_after_text_indexing, ExtractCursor};
|
use crate::file_handling::{
|
||||||
|
content_extractable, fts_begin_tombstone_burst, fts_end_tombstone_burst, ExtractCursor,
|
||||||
|
};
|
||||||
use crate::indexing::ReconcileProgress;
|
use crate::indexing::ReconcileProgress;
|
||||||
|
|
||||||
/// How long [`advance`] may work before handing control back. This bounds
|
/// How long [`advance`] may work before handing control back. This bounds
|
||||||
|
|
@ -180,6 +210,12 @@ impl WorkCursor {
|
||||||
self.finalized
|
self.finalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the row scan has reached the end of the last root. Distinct from
|
||||||
|
/// [`WorkCursor::done`], which also waits on the tombstone merge.
|
||||||
|
fn scan_done(&self) -> bool {
|
||||||
|
self.root_idx >= self.scope.roots.len()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn progress(&self) -> ReconcileProgress {
|
pub fn progress(&self) -> ReconcileProgress {
|
||||||
ReconcileProgress {
|
ReconcileProgress {
|
||||||
examined: self.examined,
|
examined: self.examined,
|
||||||
|
|
@ -227,6 +263,9 @@ pub fn advance(
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| format!("begin drop-root transaction: {}", e))?;
|
.map_err(|e| format!("begin drop-root transaction: {}", e))?;
|
||||||
|
// A de-configured root can take every posting under it with it, which
|
||||||
|
// is the same burst the row scan makes; `advance` restores and merges.
|
||||||
|
fts_begin_tombstone_burst(&tx);
|
||||||
let removed = repo::delete_subtree(&tx, &range.lo, &range.hi)?;
|
let removed = repo::delete_subtree(&tx, &range.lo, &range.hi)?;
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.map_err(|e| format!("commit drop-root transaction: {}", e))?;
|
.map_err(|e| format!("commit drop-root transaction: {}", e))?;
|
||||||
|
|
@ -253,6 +292,7 @@ pub fn advance(
|
||||||
let tx = conn
|
let tx = conn
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| format!("begin drop-alias transaction: {}", e))?;
|
.map_err(|e| format!("begin drop-alias transaction: {}", e))?;
|
||||||
|
fts_begin_tombstone_burst(&tx);
|
||||||
let removed = repo::delete_outside_ranges(&tx, &ranges)?;
|
let removed = repo::delete_outside_ranges(&tx, &ranges)?;
|
||||||
tx.commit()
|
tx.commit()
|
||||||
.map_err(|e| format!("commit drop-alias transaction: {}", e))?;
|
.map_err(|e| format!("commit drop-alias transaction: {}", e))?;
|
||||||
|
|
@ -270,29 +310,91 @@ pub fn advance(
|
||||||
}
|
}
|
||||||
cursor.total = Some(repo::row_count(conn)?);
|
cursor.total = Some(repo::row_count(conn)?);
|
||||||
}
|
}
|
||||||
let page = config.processing.batch_size.max(1) as i64;
|
scan_rows(conn, config, registry, cursor, deadline, cancel)?;
|
||||||
let mut covered = CoverCache::default();
|
if !cursor.scan_done() {
|
||||||
while cursor.root_idx < cursor.scope.roots.len() {
|
return Ok(());
|
||||||
if cancelled(cancel) {
|
}
|
||||||
return Ok(());
|
}
|
||||||
}
|
|
||||||
|
// Deletions leave FTS tombstones; restoring the threshold and merging
|
||||||
|
// collapses them. Skipping the merge costs only tidiness — the next run's
|
||||||
|
// does the same — but the threshold must go back whether anything was
|
||||||
|
// deleted or not, since `scan_rows` lowers it before it knows.
|
||||||
|
if cancelled(cancel) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
fts_end_tombstone_burst(conn);
|
||||||
|
cursor.finalized = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rows one transaction may cover before it commits, whatever the clock says.
|
||||||
|
///
|
||||||
|
/// A backstop, not the primary limit: [`SLICE`] normally ends a transaction
|
||||||
|
/// first. It exists so that a fast disk and a wide slice cannot build an
|
||||||
|
/// unbounded set of dirty pages before anything is durable.
|
||||||
|
const COMMIT_ROWS: usize = 20_000;
|
||||||
|
|
||||||
|
/// Page every configured root, deciding and writing rows until the deadline.
|
||||||
|
///
|
||||||
|
/// One transaction spans as much of the slice as it can rather than one per
|
||||||
|
/// page. The read runs inside it, so each page sees the previous one's writes;
|
||||||
|
/// the cursor advances with the reads, so a rollback leaves it ahead of the
|
||||||
|
/// database — which is safe only because every caller of a failed [`advance`]
|
||||||
|
/// discards the cursor and nothing is stamped until one finishes. Cancellation
|
||||||
|
/// commits what it has: the work is idempotent, so a resumed cursor redoing it
|
||||||
|
/// would be correct too, but there is no reason to throw it away.
|
||||||
|
///
|
||||||
|
/// **The deadline is tested only after a page has been applied**, so a call
|
||||||
|
/// always makes progress. Testing it on entry instead is a spin: the caller
|
||||||
|
/// loops until the cursor finishes, and one that hands over an already-expired
|
||||||
|
/// deadline — which `advance`'s own `row_count` can produce on a large index,
|
||||||
|
/// and which `scope_tests` does deliberately — would never advance it.
|
||||||
|
fn scan_rows(
|
||||||
|
conn: &mut Connection,
|
||||||
|
config: &Config,
|
||||||
|
registry: &Registry,
|
||||||
|
cursor: &mut WorkCursor,
|
||||||
|
deadline: Instant,
|
||||||
|
cancel: &AtomicBool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let page = config.processing.batch_size.max(1) as i64;
|
||||||
|
let mut covered = CoverCache::default();
|
||||||
|
let mut plan = PagePlan::default();
|
||||||
|
while !cursor.scan_done() {
|
||||||
|
if cancelled(cancel) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let tx = conn
|
||||||
|
.transaction()
|
||||||
|
.map_err(|e| format!("begin reconcile transaction: {}", e))?;
|
||||||
|
// Inside the transaction, so a rollback puts the threshold back with
|
||||||
|
// everything else; `advance` restores it for good when the pass ends.
|
||||||
|
// First thing in it, so the flush this implies has nothing to flush.
|
||||||
|
fts_begin_tombstone_burst(&tx);
|
||||||
|
let mut buffered = 0usize;
|
||||||
|
let mut spent = false;
|
||||||
|
while !cursor.scan_done() {
|
||||||
let root = &cursor.scope.roots[cursor.root_idx];
|
let root = &cursor.scope.roots[cursor.root_idx];
|
||||||
if cursor.after.0.is_empty() {
|
if cursor.after.0.is_empty() {
|
||||||
// `(lo, "")` sorts below every row in the range — no stored name is empty.
|
// `(lo, "")` sorts below every row in the range — no stored name is empty.
|
||||||
cursor.after = (root.lo.clone(), String::new());
|
cursor.after = (root.lo.clone(), String::new());
|
||||||
}
|
}
|
||||||
let rows =
|
let rows =
|
||||||
repo::rows_in_range_page(conn, &cursor.after.0, &cursor.after.1, &root.hi, page)?;
|
repo::rows_in_range_page(&tx, &cursor.after.0, &cursor.after.1, &root.hi, page)?;
|
||||||
let Some(last) = rows.last() else {
|
let Some(last) = rows.last() else {
|
||||||
|
// An exhausted root is not a page of work — moving to the next
|
||||||
|
// one must not be able to spend the slice on its own.
|
||||||
cursor.root_idx += 1;
|
cursor.root_idx += 1;
|
||||||
cursor.after = (String::new(), String::new());
|
cursor.after = (String::new(), String::new());
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
cursor.after = (last.parent.clone(), last.name.clone());
|
cursor.after = (last.parent.clone(), last.name.clone());
|
||||||
cursor.examined += rows.len();
|
cursor.examined += rows.len();
|
||||||
|
buffered += rows.len();
|
||||||
let root = cursor.scope.roots[cursor.root_idx].path.clone();
|
let root = cursor.scope.roots[cursor.root_idx].path.clone();
|
||||||
let (deleted, recontented) = apply_page(
|
let (deleted, recontented) = apply_page(
|
||||||
conn,
|
&tx,
|
||||||
config,
|
config,
|
||||||
registry,
|
registry,
|
||||||
&cursor.scope,
|
&cursor.scope,
|
||||||
|
|
@ -300,24 +402,21 @@ pub fn advance(
|
||||||
&root,
|
&root,
|
||||||
&rows,
|
&rows,
|
||||||
&mut covered,
|
&mut covered,
|
||||||
|
&mut plan,
|
||||||
)?;
|
)?;
|
||||||
cursor.deleted += deleted;
|
cursor.deleted += deleted;
|
||||||
cursor.recontented += recontented;
|
cursor.recontented += recontented;
|
||||||
if Instant::now() >= deadline {
|
if buffered >= COMMIT_ROWS || cancelled(cancel) || Instant::now() >= deadline {
|
||||||
return Ok(());
|
spent = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
tx.commit()
|
||||||
|
.map_err(|e| format!("commit reconcile transaction: {}", e))?;
|
||||||
|
if spent {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions leave FTS tombstones; a merge collapses them. Skipping it
|
|
||||||
// costs only tidiness — the next run's merge does the same.
|
|
||||||
if cancelled(cancel) {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if cursor.deleted > 0 || cursor.recontented > 0 {
|
|
||||||
fts_finalize_after_text_indexing(conn);
|
|
||||||
}
|
|
||||||
cursor.finalized = true;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,10 +424,85 @@ fn cancelled(cancel: &AtomicBool) -> bool {
|
||||||
cancel.load(Ordering::Relaxed)
|
cancel.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decide and write one page of rows. Returns `(deleted, recontented)`.
|
/// One page's decisions, bucketed so every write goes out as a chunked
|
||||||
|
/// `IN (...)` list rather than a bound statement per row.
|
||||||
|
///
|
||||||
|
/// The buckets are by *stored* state as well as by destination, because that is
|
||||||
|
/// what says which of the three dependent writes a row actually needs: a
|
||||||
|
/// posting and a stored body exist only for `STATE_DONE`, a failure record only
|
||||||
|
/// for `STATE_FAILED` (`repo::leaving_done_always_takes_the_posting_with_it`
|
||||||
|
/// pins both). A `STATE_PENDING` row leaving for `STATE_NA` needs one `UPDATE`
|
||||||
|
/// and nothing else, where the per-row form spent four statements discovering
|
||||||
|
/// that twice over.
|
||||||
|
///
|
||||||
|
/// Reused across pages — `clear` keeps the capacity — so a whole pass allocates
|
||||||
|
/// these once.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PagePlan {
|
||||||
|
/// Rows leaving the index entirely, and the subset of them that can hold a
|
||||||
|
/// posting (see [`repo::delete_ids`]).
|
||||||
|
doomed: Vec<i64>,
|
||||||
|
doomed_content: Vec<i64>,
|
||||||
|
/// Rows keeping their posting but losing the snippet source.
|
||||||
|
stale_text: Vec<i64>,
|
||||||
|
/// Surviving rows changing `content_state`, and — across both — those whose
|
||||||
|
/// stored state says they have content or a failure record to clear first.
|
||||||
|
to_pending: Vec<i64>,
|
||||||
|
to_na: Vec<i64>,
|
||||||
|
restated_content: Vec<i64>,
|
||||||
|
restated_failure: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PagePlan {
|
||||||
|
fn clear(&mut self) {
|
||||||
|
for list in [
|
||||||
|
&mut self.doomed,
|
||||||
|
&mut self.doomed_content,
|
||||||
|
&mut self.stale_text,
|
||||||
|
&mut self.to_pending,
|
||||||
|
&mut self.to_na,
|
||||||
|
&mut self.restated_content,
|
||||||
|
&mut self.restated_failure,
|
||||||
|
] {
|
||||||
|
list.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Note that a surviving row is changing state, and what it must shed first.
|
||||||
|
fn restate(&mut self, row: &repo::ScopeRow, to_pending: bool) {
|
||||||
|
if to_pending {
|
||||||
|
self.to_pending.push(row.id);
|
||||||
|
} else {
|
||||||
|
self.to_na.push(row.id);
|
||||||
|
}
|
||||||
|
match row.content_state {
|
||||||
|
repo::STATE_DONE => self.restated_content.push(row.id),
|
||||||
|
repo::STATE_FAILED => self.restated_failure.push(row.id),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `(deleted, recontented)`.
|
||||||
|
fn write(&self, tx: &rusqlite::Transaction<'_>) -> Result<(usize, usize), String> {
|
||||||
|
let deleted = if self.doomed.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
repo::delete_ids(tx, &self.doomed, &self.doomed_content)?
|
||||||
|
};
|
||||||
|
repo::drop_stored_text(tx, &self.stale_text)?;
|
||||||
|
repo::clear_content_for_ids(tx, &self.restated_content)?;
|
||||||
|
repo::clear_failed_for_ids(tx, &self.restated_failure)?;
|
||||||
|
repo::set_content_state(tx, &self.to_pending, repo::STATE_PENDING)?;
|
||||||
|
repo::set_content_state(tx, &self.to_na, repo::STATE_NA)?;
|
||||||
|
Ok((deleted, self.to_pending.len() + self.to_na.len()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide one page of rows into `plan`, then write it. Returns
|
||||||
|
/// `(deleted, recontented)`.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn apply_page(
|
fn apply_page(
|
||||||
conn: &mut Connection,
|
tx: &rusqlite::Transaction<'_>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
registry: &Registry,
|
registry: &Registry,
|
||||||
scope: &Scope,
|
scope: &Scope,
|
||||||
|
|
@ -336,20 +510,21 @@ fn apply_page(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
rows: &[repo::ScopeRow],
|
rows: &[repo::ScopeRow],
|
||||||
covered: &mut CoverCache,
|
covered: &mut CoverCache,
|
||||||
|
plan: &mut PagePlan,
|
||||||
) -> Result<(usize, usize), String> {
|
) -> Result<(usize, usize), String> {
|
||||||
let mut doomed: Vec<i64> = Vec::new();
|
plan.clear();
|
||||||
let mut stale_text: Vec<i64> = Vec::new();
|
|
||||||
let mut to_pending: Vec<i64> = Vec::new();
|
|
||||||
let mut to_na: Vec<i64> = Vec::new();
|
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let path = Path::new(&row.path);
|
let path = Path::new(&row.path);
|
||||||
if work.prune_scope && !scope.covers_cached(root, path, covered) {
|
if work.prune_scope && !scope.covers_cached(root, path, covered) {
|
||||||
doomed.push(row.id);
|
plan.doomed.push(row.id);
|
||||||
|
if row.content_state == repo::STATE_DONE {
|
||||||
|
plan.doomed_content.push(row.id);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if work.drop_text {
|
// Only a DONE row can have a `documents_text` body to drop.
|
||||||
stale_text.push(row.id);
|
if work.drop_text && row.content_state == repo::STATE_DONE {
|
||||||
|
plan.stale_text.push(row.id);
|
||||||
}
|
}
|
||||||
if work.reconcile_content || work.restore_text {
|
if work.reconcile_content || work.restore_text {
|
||||||
// The walker's decision, recomputed. Both directions run whenever
|
// The walker's decision, recomputed. Both directions run whenever
|
||||||
|
|
@ -357,37 +532,16 @@ fn apply_page(
|
||||||
let wants = row.size <= config.processing.maximum_text_file_size
|
let wants = row.size <= config.processing.maximum_text_file_size
|
||||||
&& content_extractable(path, row.mime.as_deref(), config, registry);
|
&& content_extractable(path, row.mime.as_deref(), config, registry);
|
||||||
if !wants && row.content_state != repo::STATE_NA {
|
if !wants && row.content_state != repo::STATE_NA {
|
||||||
to_na.push(row.id);
|
plan.restate(row, false);
|
||||||
} else if wants
|
} else if wants
|
||||||
&& (row.content_state == repo::STATE_NA
|
&& (row.content_state == repo::STATE_NA
|
||||||
|| (work.restore_text && row.content_state == repo::STATE_DONE))
|
|| (work.restore_text && row.content_state == repo::STATE_DONE))
|
||||||
{
|
{
|
||||||
to_pending.push(row.id);
|
plan.restate(row, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
plan.write(tx)
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.map_err(|e| format!("begin reconcile transaction: {}", e))?;
|
|
||||||
let deleted = if doomed.is_empty() {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
repo::delete_ids(&tx, &doomed)?
|
|
||||||
};
|
|
||||||
if !stale_text.is_empty() {
|
|
||||||
repo::drop_stored_text(&tx, &stale_text)?;
|
|
||||||
}
|
|
||||||
for id in &to_pending {
|
|
||||||
repo::reset_content_pending(&tx, *id)?;
|
|
||||||
}
|
|
||||||
for id in &to_na {
|
|
||||||
repo::remove_content_for_id(&tx, *id)?;
|
|
||||||
repo::set_content_na(&tx, *id)?;
|
|
||||||
}
|
|
||||||
tx.commit()
|
|
||||||
.map_err(|e| format!("commit reconcile transaction: {}", e))?;
|
|
||||||
Ok((deleted, to_pending.len() + to_na.len()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The configuration the index was last built with, as far as
|
/// The configuration the index was last built with, as far as
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::walk::{walk_indexable_files, WalkEvent};
|
use crate::walk::{walk_indexable_files, WalkEvent};
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -211,6 +212,97 @@ fn the_scan_reports_its_way_through_every_row() {
|
||||||
std::fs::remove_dir_all(&db_dir).ok();
|
std::fs::remove_dir_all(&db_dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FTS5's delete-merge threshold is turned off for the length of a pass and
|
||||||
|
/// must come back on at the end of it.
|
||||||
|
///
|
||||||
|
/// Both halves are asserted, and both matter. Without the first the pass is
|
||||||
|
/// several times slower than it needs to be
|
||||||
|
/// (`file_handling::fts_begin_tombstone_burst` carries the table). Without the
|
||||||
|
/// second the index is left in a state where **no** later `'merge'` ever
|
||||||
|
/// reclaims a tombstone again — `fts5IndexFindDeleteMerge` returns early on a
|
||||||
|
/// zero threshold — and because FTS5 persists the setting in its `%_config`
|
||||||
|
/// shadow table, that outlives the process. It is a silent, permanent
|
||||||
|
/// degradation, which is exactly the kind of thing that needs a test rather
|
||||||
|
/// than a comment.
|
||||||
|
#[test]
|
||||||
|
fn a_pass_turns_delete_merging_off_and_puts_it_back() {
|
||||||
|
let root = tmp_tree("burst");
|
||||||
|
for i in 0..6 {
|
||||||
|
touch(&root.join(format!("f{}.log", i)));
|
||||||
|
}
|
||||||
|
touch(&root.join("keep.txt"));
|
||||||
|
|
||||||
|
let db_dir = tmp_tree("burst-db");
|
||||||
|
let db = empty_db(&db_dir);
|
||||||
|
let mut conn = crate::db::open_existing(db.to_str().unwrap(), true).unwrap();
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()];
|
||||||
|
config.processing.batch_size = 1;
|
||||||
|
seed(&mut conn, &on_disk(&root));
|
||||||
|
|
||||||
|
// Read back out of FTS5's own `%_config`, not out of a value we remember:
|
||||||
|
// what outlives the process is what is written there.
|
||||||
|
let threshold = |conn: &Connection| -> Option<i64> {
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
threshold(&conn),
|
||||||
|
None,
|
||||||
|
"a fresh index leaves FTS5 on its own default and records nothing"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut narrowed = config.clone();
|
||||||
|
narrowed.indexing.ignore_patterns = vec!["*.log".into()];
|
||||||
|
let work = crate::config::diff_actions(&config, &narrowed).work;
|
||||||
|
let mut cursor = WorkCursor::new(work, &narrowed).unwrap();
|
||||||
|
let registry = Registry::default_set();
|
||||||
|
let run = AtomicBool::new(false);
|
||||||
|
|
||||||
|
// One slice, with a deadline already past: the pass is under way and has
|
||||||
|
// committed at least one page, so the threshold is off and durably so.
|
||||||
|
advance(
|
||||||
|
&mut conn,
|
||||||
|
&narrowed,
|
||||||
|
®istry,
|
||||||
|
&mut cursor,
|
||||||
|
Instant::now(),
|
||||||
|
&run,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!cursor.done(), "one page cannot have finished seven rows");
|
||||||
|
assert_eq!(
|
||||||
|
threshold(&conn),
|
||||||
|
Some(0),
|
||||||
|
"delete-merging is off while the pass is creating tombstones"
|
||||||
|
);
|
||||||
|
|
||||||
|
while !cursor.done() {
|
||||||
|
advance(
|
||||||
|
&mut conn,
|
||||||
|
&narrowed,
|
||||||
|
®istry,
|
||||||
|
&mut cursor,
|
||||||
|
Instant::now(),
|
||||||
|
&run,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
threshold(&conn),
|
||||||
|
Some(i64::from(crate::file_handling::FTS_DELETEMERGE)),
|
||||||
|
"a finished pass leaves tombstone reclamation working again"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
std::fs::remove_dir_all(&db_dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// The cursor is left un-finished, so nothing downstream records the config
|
/// The cursor is left un-finished, so nothing downstream records the config
|
||||||
/// as reconciled. Rows already reached stay gone: the pass is idempotent.
|
/// as reconciled. Rows already reached stay gone: the pass is idempotent.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ use crate::query::translator::{escape_like, quote_phrase};
|
||||||
use crate::snippet;
|
use crate::snippet;
|
||||||
|
|
||||||
use super::fuzzy::{edit_budget, pigeonhole_chunks, Bitap};
|
use super::fuzzy::{edit_budget, pigeonhole_chunks, Bitap};
|
||||||
use super::{SearchHit, SearchOptions};
|
use super::{prefilter, SearchHit, SearchOptions};
|
||||||
|
|
||||||
mod passes;
|
mod passes;
|
||||||
|
|
||||||
|
|
@ -137,6 +137,11 @@ pub fn run(
|
||||||
emitted: IdSet::default(),
|
emitted: IdSet::default(),
|
||||||
deferred_path: Deferred::default(),
|
deferred_path: Deferred::default(),
|
||||||
deferred_fuzzy_path: Deferred::default(),
|
deferred_fuzzy_path: Deferred::default(),
|
||||||
|
regex_doc: query
|
||||||
|
.regex
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| crate::db::repo::DocDecoder::new())
|
||||||
|
.transpose()?,
|
||||||
total: 0,
|
total: 0,
|
||||||
limited: false,
|
limited: false,
|
||||||
sink,
|
sink,
|
||||||
|
|
@ -310,6 +315,11 @@ struct Cx<'a> {
|
||||||
deferred_path: Deferred,
|
deferred_path: Deferred,
|
||||||
/// Rank 11, filled by pass C.
|
/// Rank 11, filled by pass C.
|
||||||
deferred_fuzzy_path: Deferred,
|
deferred_fuzzy_path: Deferred,
|
||||||
|
/// Decoder for [`Cx::regex_accepts`]'s content fetches, built once per
|
||||||
|
/// search and only when the query carries a regex — the predicate runs
|
||||||
|
/// per candidate row, exactly the shape [`crate::db::repo::DocDecoder`]
|
||||||
|
/// exists for.
|
||||||
|
regex_doc: Option<crate::db::repo::DocDecoder>,
|
||||||
total: usize,
|
total: usize,
|
||||||
limited: bool,
|
limited: bool,
|
||||||
sink: &'a mut dyn FnMut(Vec<SearchHit>),
|
sink: &'a mut dyn FnMut(Vec<SearchHit>),
|
||||||
|
|
@ -370,9 +380,25 @@ impl<'a> Cx<'a> {
|
||||||
|
|
||||||
/// The `regex:` accept-predicate when a regex accompanies a term. The
|
/// The `regex:` accept-predicate when a regex accompanies a term. The
|
||||||
/// path contains the name, so one path check covers both; content is
|
/// path contains the name, so one path check covers both; content is
|
||||||
/// fetched only for rows whose path missed.
|
/// fetched only for rows whose path missed — per candidate row, which is
|
||||||
fn regex_accepts(&self, file_id: i64, path: &str, text: Option<&str>) -> Result<bool, String> {
|
/// why the statement is cached and the decoder ([`Cx::regex_doc`]) is
|
||||||
let Some(re) = &self.query.regex else {
|
/// reused, and the blob is decoded borrowed rather than copied out.
|
||||||
|
/// Measured (`benches/search_alloc.rs`, case "term + regex"): 42.6 MiB
|
||||||
|
/// churned per keystroke → 4.3, and ~1.3x on the case's time, over the
|
||||||
|
/// `query_row` + `decode_all` shape this replaced.
|
||||||
|
fn regex_accepts(
|
||||||
|
&mut self,
|
||||||
|
file_id: i64,
|
||||||
|
path: &str,
|
||||||
|
text: Option<&str>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let Cx {
|
||||||
|
conn,
|
||||||
|
query,
|
||||||
|
regex_doc,
|
||||||
|
..
|
||||||
|
} = self;
|
||||||
|
let Some(re) = &query.regex else {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
};
|
};
|
||||||
if re.is_match(path) {
|
if re.is_match(path) {
|
||||||
|
|
@ -381,19 +407,23 @@ impl<'a> Cx<'a> {
|
||||||
if let Some(text) = text {
|
if let Some(text) = text {
|
||||||
return Ok(re.is_match(text));
|
return Ok(re.is_match(text));
|
||||||
}
|
}
|
||||||
let blob: Option<Vec<u8>> = self
|
let doc = regex_doc
|
||||||
.conn
|
.as_mut()
|
||||||
.query_row(
|
.expect("built in run() whenever the query carries a regex");
|
||||||
"SELECT text_zstd FROM documents_text WHERE file_id = ?1",
|
let accepted = conn
|
||||||
[file_id],
|
.prepare_cached("SELECT text_zstd FROM documents_text WHERE file_id = ?1")
|
||||||
|r| r.get(0),
|
.and_then(|mut stmt| {
|
||||||
)
|
stmt.query_row([file_id], |r| {
|
||||||
.optional()
|
let blob = r.get_ref(0)?.as_blob()?;
|
||||||
|
// Strict UTF-8 via `decode` is not a behaviour change:
|
||||||
|
// bodies are written from `&str` (`repo::set_content_done`),
|
||||||
|
// and `stored_text` already treats non-UTF-8 as absent.
|
||||||
|
Ok(doc.decode(blob).is_some_and(|t| re.is_match(t)))
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let Some(raw) = blob.and_then(|b| zstd::decode_all(b.as_slice()).ok()) else {
|
Ok(accepted.unwrap_or(false))
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
Ok(re.is_match(&String::from_utf8_lossy(&raw)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hand `buf` over mid-scan if it is due, leaving it empty when it goes.
|
/// Hand `buf` over mid-scan if it is due, leaving it empty when it goes.
|
||||||
|
|
|
||||||
|
|
@ -408,20 +408,42 @@ impl<'a> Cx<'a> {
|
||||||
Bitap::new(self.query.term.as_bytes(), k).map(|bitap| (k, bitap))
|
Bitap::new(self.query.term.as_bytes(), k).map(|bitap| (k, bitap))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pass C — rank 7 now, rank 11 deferred: one bitap sweep over every
|
/// Pass C — rank 7 now, rank 11 deferred: one bitap sweep over the
|
||||||
/// filename, falling back to the full path where the name misses.
|
/// filenames, falling back to the full path where the name misses.
|
||||||
|
///
|
||||||
|
/// Narrowed by the same pigeonhole split pass D uses: at least one of the
|
||||||
|
/// `k+1` chunks survives `≤k` edits verbatim ([`pigeonhole_chunks`]), and
|
||||||
|
/// a separator-free chunk cannot span the `parent‖name` join, so
|
||||||
|
/// `(name LIKE OR parent LIKE)` per chunk covers the name tier and the
|
||||||
|
/// path tier both ([`prefilter::Required::like_predicate`]). `LIKE` folds
|
||||||
|
/// ASCII case, a superset of the matcher's own folding — the direction
|
||||||
|
/// the one rule in `search/prefilter.rs` allows. Any `None` along the way
|
||||||
|
/// keeps the full scan — which, under the default edit cap of 2, is every
|
||||||
|
/// term shorter than 9 characters (`3 × (k + 1)` with k already 2 at 6).
|
||||||
|
///
|
||||||
|
/// Measured (`benches/search_perf.rs`, fuzzy pairing, 200k rows, warm):
|
||||||
|
/// `quartzite` 113 ms → 62 plain and 113 → 59 keyed; `quartzites`
|
||||||
|
/// 112 → 97 and 110 → 97 (its split ends in a weak 3-char chunk); the
|
||||||
|
/// short-term fallback rows did not move.
|
||||||
pub(super) fn pass_fuzzy_filename(&mut self) -> Result<bool, String> {
|
pub(super) fn pass_fuzzy_filename(&mut self) -> Result<bool, String> {
|
||||||
let Some((_, bitap)) = self.fuzzy_matcher() else {
|
let Some((k, bitap)) = self.fuzzy_matcher() else {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
};
|
};
|
||||||
let query = self.query;
|
let query = self.query;
|
||||||
let with_paths = path_tiers_enabled(&query.pattern);
|
let with_paths = path_tiers_enabled(&query.pattern);
|
||||||
|
|
||||||
|
let (predicate, terms) = pigeonhole_chunks(&query.term, k)
|
||||||
|
.and_then(|chunks| {
|
||||||
|
prefilter::Required::new(chunks.iter().map(|c| c.to_string()).collect())
|
||||||
|
})
|
||||||
|
.and_then(|req| req.like_predicate())
|
||||||
|
.unwrap_or_else(|| ("1=1".to_string(), Vec::new()));
|
||||||
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {} FROM files f WHERE 1=1{}",
|
"SELECT {} FROM files f WHERE {}{}",
|
||||||
HIT_COLUMNS, query.filter_sql
|
HIT_COLUMNS, predicate, query.filter_sql
|
||||||
);
|
);
|
||||||
let params = self.params_with_filters(Vec::new());
|
let params = self.params_with_filters(terms);
|
||||||
self.scan_pass(
|
self.scan_pass(
|
||||||
&sql,
|
&sql,
|
||||||
params,
|
params,
|
||||||
|
|
|
||||||
|
|
@ -286,8 +286,35 @@ pub struct SeedSpec {
|
||||||
/// file cannot be reopened without it. Ignored on a plain arm, which has
|
/// file cannot be reopened without it. Ignored on a plain arm, which has
|
||||||
/// no reserve.
|
/// no reserve.
|
||||||
pub hmac: Option<crate::db::schema::HmacMode>,
|
pub hmac: Option<crate::db::schema::HmacMode>,
|
||||||
|
/// `(extension, mime)` pairs cycled across the rows, deciding what a
|
||||||
|
/// `content_extensions` filter can select. [`EXT_PLAIN`] — one pair, so
|
||||||
|
/// every row is `.txt`/`text/plain` — is what every harness measuring
|
||||||
|
/// search or indexing wants, and it is the default so their corpora are
|
||||||
|
/// byte-identical to what they have always been.
|
||||||
|
///
|
||||||
|
/// It exists for `contentprobe`, where a filter that either takes the
|
||||||
|
/// whole index or none of it answers nothing. **A mix whose length shares
|
||||||
|
/// a factor with `content_every` puts every document behind the same few
|
||||||
|
/// extensions** — the degenerate-corpus trap `pruneprobe` documents
|
||||||
|
/// against its own strides — so a harness using this should assert the
|
||||||
|
/// fractions it ends up with rather than trusting the arithmetic.
|
||||||
|
pub ext_mix: &'static [(&'static str, &'static str)],
|
||||||
|
/// One row in every `pending_every` that *would* hold content is left in
|
||||||
|
/// the pending queue instead — born `STATE_PENDING` and never extracted,
|
||||||
|
/// which is the residue an interrupted content pass leaves behind. `0`
|
||||||
|
/// (the default) seeds none.
|
||||||
|
///
|
||||||
|
/// It exists because such a row is the case a re-decision can skip the
|
||||||
|
/// most work on: it is neither `STATE_NA` (so a narrowed filter must still
|
||||||
|
/// flip it) nor `STATE_DONE` (so it has no posting and no stored text to
|
||||||
|
/// clear). A corpus without any cannot tell whether clearing content for
|
||||||
|
/// rows that cannot hold it costs anything.
|
||||||
|
pub pending_every: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The single-extension corpus every harness but `contentprobe` seeds.
|
||||||
|
pub const EXT_PLAIN: &[(&str, &str)] = &[("txt", "text/plain")];
|
||||||
|
|
||||||
impl Default for SeedSpec {
|
impl Default for SeedSpec {
|
||||||
fn default() -> SeedSpec {
|
fn default() -> SeedSpec {
|
||||||
SeedSpec {
|
SeedSpec {
|
||||||
|
|
@ -306,6 +333,8 @@ impl Default for SeedSpec {
|
||||||
page_size: None,
|
page_size: None,
|
||||||
pgsz: None,
|
pgsz: None,
|
||||||
hmac: None,
|
hmac: None,
|
||||||
|
ext_mix: EXT_PLAIN,
|
||||||
|
pending_every: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +343,7 @@ impl Default for SeedSpec {
|
||||||
/// measurement harnesses so they all describe the same corpus.
|
/// measurement harnesses so they all describe the same corpus.
|
||||||
pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
|
pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
|
||||||
use crate::db::repo::{insert_file, set_content_done, NewFile};
|
use crate::db::repo::{insert_file, set_content_done, NewFile};
|
||||||
use crate::mime::FileType;
|
use crate::mime::mime_to_type;
|
||||||
|
|
||||||
// Before the open, not after: the profile decides how the file is
|
// Before the open, not after: the profile decides how the file is
|
||||||
// *created*, and on a keyed file it decides whether it can be read at all.
|
// *created*, and on a keyed file it decides whether it can be read at all.
|
||||||
|
|
@ -335,6 +364,19 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let mut rng = Lcg::new(0x5eed);
|
let mut rng = Lcg::new(0x5eed);
|
||||||
|
let ext_mix = if spec.ext_mix.is_empty() {
|
||||||
|
EXT_PLAIN
|
||||||
|
} else {
|
||||||
|
spec.ext_mix
|
||||||
|
};
|
||||||
|
// A row gets content only if an extractor would have claimed its MIME, so
|
||||||
|
// the seeded `content_state` is what a real run under an *unfiltered*
|
||||||
|
// config would have left. Without this a corpus of mixed extensions is
|
||||||
|
// born disagreeing with its own configuration, and the first reconcile
|
||||||
|
// against it spends its time repairing the seed rather than applying the
|
||||||
|
// edit. `EXT_PLAIN` is claimed by the plaintext extractor, so the
|
||||||
|
// single-extension corpus every other harness seeds is unchanged.
|
||||||
|
let registry = crate::extract::Registry::default_set();
|
||||||
// Spacing, not a random draw: a cluster at the front would let a pass
|
// Spacing, not a random draw: a cluster at the front would let a pass
|
||||||
// stop early and report a fraction of the work a real rare query costs.
|
// stop early and report a fraction of the work a real rare query costs.
|
||||||
let name_stride = spec.files / spec.needle_names.max(1);
|
let name_stride = spec.files / spec.needle_names.max(1);
|
||||||
|
|
@ -346,10 +388,14 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
|
||||||
for i in 0..spec.files {
|
for i in 0..spec.files {
|
||||||
let w1 = rng.pick(WORDS);
|
let w1 = rng.pick(WORDS);
|
||||||
let w2 = rng.pick(WORDS);
|
let w2 = rng.pick(WORDS);
|
||||||
|
// Extension and MIME move together: a row whose name says `.pdf` and
|
||||||
|
// whose MIME says `text/plain` would let `content_extractable`'s two
|
||||||
|
// halves disagree, which is exactly what a content filter is testing.
|
||||||
|
let (ext, mime) = ext_mix[i % ext_mix.len()];
|
||||||
let name = if spec.needle_names > 0 && i % name_stride.max(1) == 0 {
|
let name = if spec.needle_names > 0 && i % name_stride.max(1) == 0 {
|
||||||
format!("{}-{}-{:07}.txt", w1, NEEDLE, i)
|
format!("{}-{}-{:07}.{}", w1, NEEDLE, i, ext)
|
||||||
} else {
|
} else {
|
||||||
format!("{}-{}-{:07}.txt", w1, w2, i)
|
format!("{}-{}-{:07}.{}", w1, w2, i, ext)
|
||||||
};
|
};
|
||||||
// Stored parents always end in a separator; see `dir_to_db_parent`.
|
// Stored parents always end in a separator; see `dir_to_db_parent`.
|
||||||
// Deeper segments are derived from the directory index, not the file
|
// Deeper segments are derived from the directory index, not the file
|
||||||
|
|
@ -376,6 +422,11 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
|
||||||
}
|
}
|
||||||
bytes
|
bytes
|
||||||
});
|
});
|
||||||
|
// `needs_content` is what the row is *born* as — `insert_file` gives it
|
||||||
|
// `STATE_PENDING`. Skipping the `set_content_done` below is therefore
|
||||||
|
// all it takes to leave one behind in the queue.
|
||||||
|
let needs_content = i % spec.content_every.max(1) == 0 && registry.supports(mime);
|
||||||
|
let extracted = needs_content && (spec.pending_every == 0 || i % spec.pending_every != 0);
|
||||||
let id = insert_file(
|
let id = insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
|
|
@ -383,15 +434,15 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
|
||||||
parent: &dir,
|
parent: &dir,
|
||||||
size: 4096,
|
size: 4096,
|
||||||
mtime: 1_700_000_000 + i as u64,
|
mtime: 1_700_000_000 + i as u64,
|
||||||
mime: Some("text/plain"),
|
mime: Some(mime),
|
||||||
ftype: FileType::TEXT,
|
ftype: mime_to_type(mime),
|
||||||
hash: hash.as_ref().map(|h| h.as_slice()),
|
hash: hash.as_ref().map(|h| h.as_slice()),
|
||||||
needs_content: i % spec.content_every.max(1) == 0,
|
needs_content,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("unique path");
|
.expect("unique path");
|
||||||
if i % spec.content_every.max(1) == 0 {
|
if extracted {
|
||||||
let mut body: Vec<&str> = (0..spec.body_words).map(|_| *rng.pick(WORDS)).collect();
|
let mut body: Vec<&str> = (0..spec.body_words).map(|_| *rng.pick(WORDS)).collect();
|
||||||
if spec.needle_docs > 0 && i % doc_stride.max(1) == 0 {
|
if spec.needle_docs > 0 && i % doc_stride.max(1) == 0 {
|
||||||
// Mid-body, so a snippet window has to be cut around it.
|
// Mid-body, so a snippet window has to be cut around it.
|
||||||
|
|
|
||||||
|
|
@ -811,6 +811,93 @@ fn a_wildcard_prefilter_never_loses_a_hit_the_full_scan_finds() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pigeonhole `LIKE` prefilter the fuzzy filename pass gained must be a
|
||||||
|
/// superset of what the bitap accepts over name and path, or fuzzy hits
|
||||||
|
/// vanish silently. The reference set is computed with the same matcher the
|
||||||
|
/// pass uses, so the assertion is against the semantics, not a copy of the
|
||||||
|
/// SQL. `fuzzy_max_edits: 1` keeps `k` at 1, which "report" is long enough
|
||||||
|
/// to split for — at the default 2 the term is too short and the pass would
|
||||||
|
/// scan, exercising nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_fuzzy_prefilter_never_loses_a_hit_the_full_scan_finds() {
|
||||||
|
use quicksearch_core::search::fuzzy::{edit_budget, Bitap};
|
||||||
|
|
||||||
|
let (_dir, p) = Scratch::db("fuzzyprefilter");
|
||||||
|
let mut s = Seeder::new(&p, true);
|
||||||
|
let rows = [
|
||||||
|
("report.txt", "/data"), // exact
|
||||||
|
("REPORT.TXT", "/upper"), // ASCII case is free for LIKE and bitap both
|
||||||
|
("xeport.txt", "/data"), // first chunk broken; the OR reaches the second
|
||||||
|
("repXrt.txt", "/data"), // second chunk broken; the first carries it
|
||||||
|
("ort.txt", "/rep"), // name over budget, path-tier hit across the join
|
||||||
|
("summary.txt", "/data"), // miss
|
||||||
|
("rep\u{f6}rt.txt", "/data"), // non-ASCII: two byte edits, over a k of 1
|
||||||
|
];
|
||||||
|
let seeded: Vec<(i64, String)> = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, (name, dir))| {
|
||||||
|
let id = s.add(name, dir, i as u64 + 1, None);
|
||||||
|
(id, format!("{}/{}", dir, name))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let conn = s.done();
|
||||||
|
|
||||||
|
let opts = SearchOptions {
|
||||||
|
fuzzy: true,
|
||||||
|
fuzzy_max_edits: 1,
|
||||||
|
..SearchOptions::default()
|
||||||
|
};
|
||||||
|
let term = "report";
|
||||||
|
let (hits, _) = run_collect(&conn, term, &opts);
|
||||||
|
let mut got: Vec<i64> = hits.iter().map(|h| h.file_id).collect();
|
||||||
|
got.sort();
|
||||||
|
|
||||||
|
let k = edit_budget(term.len(), opts.fuzzy_max_edits).unwrap();
|
||||||
|
let bitap = Bitap::new(term.as_bytes(), k).unwrap();
|
||||||
|
let mut want: Vec<i64> = seeded
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, path)| {
|
||||||
|
let name = path.rsplit('/').next().unwrap();
|
||||||
|
bitap.best_distance_and_first(name.as_bytes()).is_some()
|
||||||
|
|| bitap.best_distance_and_first(path.as_bytes()).is_some()
|
||||||
|
})
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.collect();
|
||||||
|
want.sort();
|
||||||
|
assert_eq!(got, want);
|
||||||
|
assert!(
|
||||||
|
(5..seeded.len()).contains(&got.len()),
|
||||||
|
"the corpus must exercise both hits and misses: {:?}",
|
||||||
|
got
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A term carrying a separator disqualifies every chunk set
|
||||||
|
/// (`prefilter::Required::like_predicate`), so the fuzzy pass falls back to
|
||||||
|
/// the full scan — and must still find a hit whose only match spans the
|
||||||
|
/// `parent‖name` join.
|
||||||
|
#[test]
|
||||||
|
fn a_fuzzy_term_with_a_separator_still_scans_and_matches() {
|
||||||
|
let (_dir, p) = Scratch::db("fuzzysep");
|
||||||
|
let mut s = Seeder::new(&p, true);
|
||||||
|
let hit = s.add("orts.txt", "/a/rep", 1, None);
|
||||||
|
let _miss = s.add("plans.txt", "/a/sum", 2, None);
|
||||||
|
let conn = s.done();
|
||||||
|
|
||||||
|
let opts = SearchOptions {
|
||||||
|
fuzzy: true,
|
||||||
|
fuzzy_max_edits: 1,
|
||||||
|
..SearchOptions::default()
|
||||||
|
};
|
||||||
|
// Within one edit of "/a/rep/orts" read across the join.
|
||||||
|
let (hits, _) = run_collect(&conn, "rep/orts", &opts);
|
||||||
|
assert_eq!(
|
||||||
|
hits.iter().map(|h| h.file_id).collect::<Vec<_>>(),
|
||||||
|
vec![hit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A pattern every segment of which carries a separator has nothing to anchor
|
/// A pattern every segment of which carries a separator has nothing to anchor
|
||||||
/// on, so the pass falls back to scanning — and must still find its hits.
|
/// on, so the pass falls back to scanning — and must still find its hits.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -536,6 +536,191 @@ fn every_document_within_the_budget_is_found_and_nothing_outside_it_is() {
|
||||||
assert!(compared > 50, "only {} queries compared", compared);
|
assert!(compared > 50, "only {} queries compared", compared);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The filename pass
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Names and parents from the same hostile vocabulary as the bodies, plus
|
||||||
|
/// `LIKE`'s own metacharacters: a `%` or `_` cut into a term must reach the
|
||||||
|
/// filename prefilter escaped (`translator::escape_like`), or the pattern
|
||||||
|
/// stops meaning the literal it was cut from. Unique suffix per row.
|
||||||
|
fn file_paths() -> Vec<(String, String)> {
|
||||||
|
let words = [
|
||||||
|
"quartzite",
|
||||||
|
"Report",
|
||||||
|
"SUMMARY",
|
||||||
|
"café",
|
||||||
|
"naïve",
|
||||||
|
"Ünicode",
|
||||||
|
"日本語テキスト",
|
||||||
|
"emoji🙂here",
|
||||||
|
"wild*card",
|
||||||
|
"100%_done",
|
||||||
|
"under_score",
|
||||||
|
"dash-joined",
|
||||||
|
"mixedCaseWord",
|
||||||
|
"budget",
|
||||||
|
"revenue",
|
||||||
|
"planning",
|
||||||
|
"aaaaaaaaaa",
|
||||||
|
];
|
||||||
|
let mut lcg = Lcg::new(0xf11e);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for i in 0..DOCS {
|
||||||
|
let word = |lcg: &mut Lcg| words[lcg.next_u64() as usize % words.len()];
|
||||||
|
let depth = 1 + (lcg.next_u64() as usize % 3);
|
||||||
|
let mut parent = String::from("/fz");
|
||||||
|
for _ in 0..depth {
|
||||||
|
parent.push('/');
|
||||||
|
parent.push_str(word(&mut lcg));
|
||||||
|
}
|
||||||
|
parent.push('/');
|
||||||
|
let name = format!("{}-{}-{:03}.txt", word(&mut lcg), word(&mut lcg), i);
|
||||||
|
out.push((parent, name));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_files(path: &std::path::Path, rows: &[(String, String)]) -> Vec<i64> {
|
||||||
|
use quicksearch_core::db::repo::{insert_file, NewFile};
|
||||||
|
use quicksearch_core::mime::FileType;
|
||||||
|
|
||||||
|
let mut conn =
|
||||||
|
quicksearch_core::db::open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
|
||||||
|
let tx = conn.transaction().unwrap();
|
||||||
|
let mut ids = Vec::with_capacity(rows.len());
|
||||||
|
for (parent, name) in rows {
|
||||||
|
let id = insert_file(
|
||||||
|
&tx,
|
||||||
|
&NewFile {
|
||||||
|
name,
|
||||||
|
parent,
|
||||||
|
size: 1,
|
||||||
|
mtime: 1_700_000_000,
|
||||||
|
mime: None,
|
||||||
|
ftype: FileType::EMPTY,
|
||||||
|
hash: None,
|
||||||
|
// No content anywhere: a hit can only come from a filename
|
||||||
|
// or path tier, which is the pass under test.
|
||||||
|
needs_content: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.expect("unique path");
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
tx.commit().unwrap();
|
||||||
|
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filename analog of [`a_corrupted_substring_still_finds_the_document_it_came_from`]:
|
||||||
|
/// cut a substring from a stored name or full path, corrupt it within the
|
||||||
|
/// byte budget, and the row must still be found — the recall direction pass
|
||||||
|
/// C's pigeonhole `LIKE` prefilter can break with no symptom. Terms cut from
|
||||||
|
/// a path can carry a separator, which disqualifies the chunk set and
|
||||||
|
/// exercises the full-scan fallback in the same sweep.
|
||||||
|
#[test]
|
||||||
|
fn a_corrupted_path_substring_still_finds_the_file_it_came_from() {
|
||||||
|
let rows = file_paths();
|
||||||
|
let (_dir, db) = Scratch::db("fuzzprefilter-name");
|
||||||
|
let ids = seed_files(&db, &rows);
|
||||||
|
let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy())
|
||||||
|
.expect("open the seeded index");
|
||||||
|
|
||||||
|
let mut lcg = Lcg::new(0xf00d);
|
||||||
|
let mut checked = 0usize;
|
||||||
|
let mut via_path = 0usize;
|
||||||
|
|
||||||
|
for &cap in caps() {
|
||||||
|
for len in sweep_lengths() {
|
||||||
|
for _ in 0..iters_per_len() {
|
||||||
|
let at = lcg.next_u64() as usize % rows.len();
|
||||||
|
let (parent, name) = &rows[at];
|
||||||
|
let full = format!("{}{}", parent, name);
|
||||||
|
let source: &str = if lcg.next_u64() % 2 == 0 { name } else { &full };
|
||||||
|
let Some(original) = substring_of(source, len, &mut lcg) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut chars: Vec<char> = original.chars().collect();
|
||||||
|
let planned = edit_budget(original.len(), cap).unwrap_or(0);
|
||||||
|
let edits = if planned == 0 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
lcg.next_u64() as usize % (planned + 1)
|
||||||
|
};
|
||||||
|
for _ in 0..edits {
|
||||||
|
corrupt(&mut chars, &mut lcg);
|
||||||
|
}
|
||||||
|
let term: String = chars.into_iter().collect();
|
||||||
|
if term.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(split) = split_for_cascade(&term) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if split.pattern.is_wildcard() || split.regex.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let effective = split.term.as_str();
|
||||||
|
let Some(k) = edit_budget(effective.len(), cap) else {
|
||||||
|
let _ = search_ids(&conn, &term, cap);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if Bitap::new(effective.as_bytes(), k).is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pass verifies the name always, the path only at three
|
||||||
|
// *characters* of pattern (`cascade::path_tiers_enabled`) —
|
||||||
|
// a three-byte term can be one CJK character, under the tier
|
||||||
|
// floor while over the fuzzy one.
|
||||||
|
let name_hit = oracle_distance(effective.as_bytes(), name.as_bytes()) <= k;
|
||||||
|
let path_hit = effective.chars().count() >= 3
|
||||||
|
&& oracle_distance(effective.as_bytes(), full.as_bytes()) <= k;
|
||||||
|
if !name_hit && !path_hit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let found = search_ids(&conn, &term, cap);
|
||||||
|
assert!(
|
||||||
|
found.contains(&ids[at]),
|
||||||
|
"lost the file the term was cut from\n typed {:?}\n \
|
||||||
|
parsed {:?}\n original {:?}\n cap {} k {} edits {}\n \
|
||||||
|
chunks {:?}\n path {:?}",
|
||||||
|
term,
|
||||||
|
effective,
|
||||||
|
original,
|
||||||
|
cap,
|
||||||
|
k,
|
||||||
|
edits,
|
||||||
|
pigeonhole_chunks(effective, k),
|
||||||
|
full,
|
||||||
|
);
|
||||||
|
checked += 1;
|
||||||
|
via_path += usize::from(!name_hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
"filename prefilter fuzz: {} recall assertions, {} of them path-tier",
|
||||||
|
checked, via_path
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
checked > 200,
|
||||||
|
"only {} recall assertions ran; the generator is not producing \
|
||||||
|
in-budget terms",
|
||||||
|
checked
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
via_path > 10,
|
||||||
|
"only {} path-tier assertions; the path arm is not being exercised",
|
||||||
|
via_path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// The regex prefilter
|
// The regex prefilter
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,13 @@ chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
# This is the zero-setup path; `--toggle` covers the app not running.
|
# This is the zero-setup path; `--toggle` covers the app not running.
|
||||||
global-hotkey = "0.8"
|
global-hotkey = "0.8"
|
||||||
|
|
||||||
|
# The window handle behind both native raise paths — `activate::raise`'s X11
|
||||||
|
# `_NET_ACTIVE_WINDOW` message and its Win32 `SetForegroundWindow` — neither of
|
||||||
|
# which winit will do for us. Not target-gated: eframe depends on this
|
||||||
|
# unconditionally (it is what `HasWindowHandle for Frame` is written against),
|
||||||
|
# so naming it here adds no crate on any platform.
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
|
||||||
# Display backends, which only exist on Linux/BSD. `default-features = false`
|
# Display backends, which only exist on Linux/BSD. `default-features = false`
|
||||||
# has to be repeated: feature resolution unions the two stanzas, so a single
|
# has to be repeated: feature resolution unions the two stanzas, so a single
|
||||||
# permissive one would switch defaults back on for every target.
|
# permissive one would switch defaults back on for every target.
|
||||||
|
|
@ -68,25 +75,22 @@ eframe = { version = "0.32", default-features = false, features = [
|
||||||
"x11",
|
"x11",
|
||||||
] }
|
] }
|
||||||
|
|
||||||
# The Wayland half of the in-application shortcut:
|
|
||||||
# `org.freedesktop.portal.GlobalShortcuts`, the only way a Wayland application
|
|
||||||
# can be told about a key it does not own. `rfd` already pulls all four in (it
|
|
||||||
# uses the file-chooser portal), so the versions here are the ones already
|
|
||||||
# resolved and nothing extra is compiled. `default-features = false` matters:
|
|
||||||
# ashpd defaults to Tokio, which would add a second async runtime and switch
|
|
||||||
# `zbus` over to it underneath `rfd`.
|
|
||||||
ashpd = { version = "0.11", default-features = false, features = ["async-std"] }
|
|
||||||
futures-channel = "0.3"
|
|
||||||
futures-util = "0.3"
|
|
||||||
pollster = "0.4"
|
|
||||||
|
|
||||||
# Raising the window from either shortcut on X11, which winit cannot
|
# Raising the window from either shortcut on X11, which winit cannot
|
||||||
# do: it asks with a source indication of "application", and every mainstream
|
# do: it asks with a source indication of "application", and every mainstream
|
||||||
# window manager refuses that from a window that is not already focused. See
|
# window manager refuses that from a window that is not already focused. See
|
||||||
# `activate::raise`. Both are already in the tree (winit's own X11 backend,
|
# `activate::raise`. Already in the tree as winit's own X11 backend, so this
|
||||||
# and eframe's window handle), so neither adds a crate.
|
# adds no crate.
|
||||||
x11rb = "0.13"
|
x11rb = "0.13"
|
||||||
raw-window-handle = "0.6"
|
|
||||||
|
# The Wayland half of activate::raise: a guest connection over winit's own
|
||||||
|
# wl_display (wayland-backend's from_foreign_display), used to hand the
|
||||||
|
# compositor an xdg-activation token for our live surface. All three are
|
||||||
|
# already in the tree with these exact features — winit enables
|
||||||
|
# wayland-backend/client_system and wayland-protocols/staging, sctk enables
|
||||||
|
# wayland-protocols/client — so this compiles nothing new.
|
||||||
|
wayland-client = "0.31"
|
||||||
|
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||||
|
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||||
|
|
||||||
# XTEST, for `examples/raiseprobe.rs` only: it synthesises the global key
|
# XTEST, for `examples/raiseprobe.rs` only: it synthesises the global key
|
||||||
# press that proves the shortcut path end to end, which no command-line tool
|
# press that proves the shortcut path end to end, which no command-line tool
|
||||||
|
|
@ -109,4 +113,8 @@ windows-sys = { version = "0.59", features = [
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
"Win32_System_IO",
|
"Win32_System_IO",
|
||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
|
# The foreground handshake (`activate`) and native raise (`activate::raise`):
|
||||||
|
# GetCurrentProcessId, AllowSetForegroundWindow / SetForegroundWindow.
|
||||||
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_WindowsAndMessaging",
|
||||||
] }
|
] }
|
||||||
|
|
|
||||||
|
|
@ -7,20 +7,20 @@
|
||||||
//! box whether or not the app was started", because a shortcut an application
|
//! box whether or not the app was started", because a shortcut an application
|
||||||
//! registers for itself cannot fire while the application is not there.
|
//! registers for itself cannot fire while the application is not there.
|
||||||
//!
|
//!
|
||||||
//! The message carries nothing: "come forward" is the whole protocol, and
|
//! "Come forward" is nearly the whole protocol; what little each side says
|
||||||
//! the reply exists only so the sender can tell a live instance from a
|
//! is the permission the other needs. On unix the request line carries the
|
||||||
//! leftover socket. An xdg-activation token would be the natural thing to
|
//! sender's `XDG_ACTIVATION_TOKEN` (usually empty) — on Wayland that token
|
||||||
//! carry — it is what a compositor wants before letting a background client
|
//! is the compositor's leave for the running window to take focus, spent in
|
||||||
//! take focus — but nothing downstream can consume one: winit 0.30 applies a
|
//! [`raise`]'s `xdg_activation_v1` path — and the reply exists so the
|
||||||
//! token only in `WindowAttributes`, and egui's `ViewportBuilder` has no
|
//! sender can tell a live instance from a leftover socket. On Windows the
|
||||||
//! field for it, so eframe never plumbs one either. See [`raise`] for what
|
//! reply instead carries the server's PID, which the sender feeds to
|
||||||
//! that costs on Wayland.
|
//! `AllowSetForegroundWindow` so the running window may take the foreground
|
||||||
|
//! — see the `#[cfg(windows)]` module below.
|
||||||
|
|
||||||
pub mod raise;
|
pub mod raise;
|
||||||
|
|
||||||
pub use raise::raise;
|
pub use raise::raise;
|
||||||
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
|
@ -28,6 +28,27 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
/// the same thing as one.
|
/// the same thing as one.
|
||||||
static PENDING: AtomicBool = AtomicBool::new(false);
|
static PENDING: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// The xdg-activation token that came with the pending press, when a
|
||||||
|
/// `--toggle` sender forwarded one (see the unix module). Read together with
|
||||||
|
/// [`take_pending`]; a later press's token simply replaces an unread one,
|
||||||
|
/// matching the flag's "two presses mean one" rule.
|
||||||
|
static TOKEN: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||||
|
|
||||||
|
/// The most a PID reply can be: a 32-bit PID is at most ten digits, and the
|
||||||
|
/// newline ends it. Also the pipe's buffer size, so the server's write never
|
||||||
|
/// blocks on a client that reads nothing.
|
||||||
|
#[cfg_attr(not(windows), allow(dead_code))]
|
||||||
|
const PID_REPLY_CAP: usize = 16;
|
||||||
|
|
||||||
|
/// The server's PID out of its reply: ASCII decimal up to a newline.
|
||||||
|
/// Anything else — truncation, garbage, an empty read — is `None`, which
|
||||||
|
/// skips the foreground grant rather than failing the activation.
|
||||||
|
#[cfg_attr(not(windows), allow(dead_code))]
|
||||||
|
fn parse_pid_reply(reply: &[u8]) -> Option<u32> {
|
||||||
|
let line = reply.split(|&b| b == b'\n').next()?;
|
||||||
|
std::str::from_utf8(line).ok()?.parse().ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// The socket identifying the instance that `config_path` configures.
|
/// The socket identifying the instance that `config_path` configures.
|
||||||
///
|
///
|
||||||
/// **Keyed by the config file, not the index.** The index path is a setting
|
/// **Keyed by the config file, not the index.** The index path is a setting
|
||||||
|
|
@ -80,14 +101,34 @@ pub fn take_pending() -> bool {
|
||||||
PENDING.swap(false, Ordering::SeqCst)
|
PENDING.swap(false, Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What to tell the user to bind, as they would type it. The installed name
|
/// The token belonging to the press [`take_pending`] just returned; call
|
||||||
|
/// right after it, on the same thread's handling of the same frame.
|
||||||
|
pub fn take_token() -> Option<String> {
|
||||||
|
TOKEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to tell the user (or a desktop binding) to run. The installed name
|
||||||
/// when we are on the path under it, and the full path otherwise — a build
|
/// when we are on the path under it, and the full path otherwise — a build
|
||||||
/// run out of `target/` is the common case, and "quicksearch" would be wrong
|
/// run out of `target/` is the common case, and "quicksearch" would be wrong
|
||||||
/// advice there.
|
/// advice there.
|
||||||
|
///
|
||||||
|
/// An AppImage needs its own rule: `current_exe` there points into the
|
||||||
|
/// runtime's FUSE mount (`/tmp/.mount_…`), which vanishes with the process,
|
||||||
|
/// so a binding made from it dies the moment QuickSearch closes. The
|
||||||
|
/// runtime exports the durable path of the `.AppImage` file itself as
|
||||||
|
/// `$APPIMAGE`; that is the thing to run.
|
||||||
pub fn command_name() -> String {
|
pub fn command_name() -> String {
|
||||||
let Ok(exe) = std::env::current_exe() else {
|
let Ok(exe) = std::env::current_exe() else {
|
||||||
return "quicksearch".to_string();
|
return "quicksearch".to_string();
|
||||||
};
|
};
|
||||||
|
durable_command(std::env::var_os("APPIMAGE").map(std::path::PathBuf::from), exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rule itself, split from the environment for the tests.
|
||||||
|
fn durable_command(appimage: Option<PathBuf>, exe: PathBuf) -> String {
|
||||||
|
if let Some(appimage) = appimage.filter(|p| p.is_absolute()) {
|
||||||
|
return appimage.display().to_string();
|
||||||
|
}
|
||||||
let installed = exe
|
let installed = exe
|
||||||
.parent()
|
.parent()
|
||||||
.is_some_and(|dir| matches!(dir.to_str(), Some("/usr/bin") | Some("/usr/local/bin")));
|
.is_some_and(|dir| matches!(dir.to_str(), Some("/usr/bin") | Some("/usr/local/bin")));
|
||||||
|
|
@ -108,6 +149,13 @@ pub fn command_name() -> String {
|
||||||
/// application's own registration fires, so the two paths are identical from
|
/// application's own registration fires, so the two paths are identical from
|
||||||
/// the UI's point of view.
|
/// the UI's point of view.
|
||||||
pub(crate) fn fire(ctx: &egui::Context) {
|
pub(crate) fn fire(ctx: &egui::Context) {
|
||||||
|
fire_with_token(ctx, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`fire`], carrying the activation token a `--toggle` sender forwarded —
|
||||||
|
/// on Wayland it is the compositor's permission to take focus.
|
||||||
|
pub(crate) fn fire_with_token(ctx: &egui::Context, token: Option<String>) {
|
||||||
|
*TOKEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = token;
|
||||||
PENDING.store(true, Ordering::SeqCst);
|
PENDING.store(true, Ordering::SeqCst);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
@ -115,8 +163,16 @@ pub(crate) fn fire(ctx: &egui::Context) {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
// The socket is the only reader and writer here; Windows uses the Win32
|
||||||
|
// pipe calls rather than `std::io`.
|
||||||
|
use std::io::{Read, Write};
|
||||||
use std::os::unix::net::{UnixListener, UnixStream};
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
|
||||||
|
/// The most a request line may be. Real xdg-activation tokens run some
|
||||||
|
/// tens of characters; the cap is what keeps a hostile peer from feeding
|
||||||
|
/// the listener forever.
|
||||||
|
const REQUEST_CAP: usize = 1024;
|
||||||
|
|
||||||
/// Ask the instance configured by `config_path` to come forward.
|
/// Ask the instance configured by `config_path` to come forward.
|
||||||
///
|
///
|
||||||
/// `true` means a live instance accepted it. `false` means there is none
|
/// `true` means a live instance accepted it. `false` means there is none
|
||||||
|
|
@ -124,7 +180,18 @@ mod imp {
|
||||||
/// start the GUI itself. **A leftover socket file never counts as an
|
/// start the GUI itself. **A leftover socket file never counts as an
|
||||||
/// instance**: the same rule `IndexLock` follows, so a crash cannot
|
/// instance**: the same rule `IndexLock` follows, so a crash cannot
|
||||||
/// strand the user behind a file nobody is listening on.
|
/// strand the user behind a file nobody is listening on.
|
||||||
|
///
|
||||||
|
/// The request carries this process's `XDG_ACTIVATION_TOKEN`, when the
|
||||||
|
/// launcher behind the user's keypress minted one: on Wayland that token
|
||||||
|
/// is the compositor's permission to take focus, and the running
|
||||||
|
/// instance spends it on its own window (`raise`). Launched by hand
|
||||||
|
/// there is none, and the request is the empty line it always was.
|
||||||
pub fn signal(config_path: &Path) -> bool {
|
pub fn signal(config_path: &Path) -> bool {
|
||||||
|
let token = std::env::var("XDG_ACTIVATION_TOKEN").unwrap_or_default();
|
||||||
|
signal_with_token(config_path, &token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn signal_with_token(config_path: &Path, token: &str) -> bool {
|
||||||
let Ok(mut stream) = UnixStream::connect(path_for(config_path)) else {
|
let Ok(mut stream) = UnixStream::connect(path_for(config_path)) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -136,7 +203,18 @@ mod imp {
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if stream.write_all(b"\n").is_err() || stream.flush().is_err() {
|
// One line is the whole request. A token that could not cross intact
|
||||||
|
// (absurd length, a newline of its own) is dropped rather than sent
|
||||||
|
// mangled: the press still lands, the raise just loses its token.
|
||||||
|
let token = if token.len() < REQUEST_CAP && !token.contains('\n') {
|
||||||
|
token
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if stream.write_all(token.as_bytes()).is_err()
|
||||||
|
|| stream.write_all(b"\n").is_err()
|
||||||
|
|| stream.flush().is_err()
|
||||||
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// The reply is what distinguishes "delivered" from "wrote into a
|
// The reply is what distinguishes "delivered" from "wrote into a
|
||||||
|
|
@ -190,9 +268,15 @@ mod imp {
|
||||||
/// Runs until the process exits; a connection is one activation.
|
/// Runs until the process exits; a connection is one activation.
|
||||||
fn serve(ctx: &egui::Context, listener: UnixListener) {
|
fn serve(ctx: &egui::Context, listener: UnixListener) {
|
||||||
for stream in listener.incoming() {
|
for stream in listener.incoming() {
|
||||||
let Ok(stream) = stream else { continue };
|
let Ok(mut stream) = stream else { continue };
|
||||||
match answer(stream) {
|
match request(&mut stream) {
|
||||||
Ok(()) => fire(ctx),
|
// Fire *before* the ack: a client that saw the reply may act
|
||||||
|
// on "delivered", and delivered means the window was already
|
||||||
|
// asked to come forward.
|
||||||
|
Ok(token) => {
|
||||||
|
fire_with_token(ctx, (!token.is_empty()).then_some(token));
|
||||||
|
let _ = acknowledge(&mut stream);
|
||||||
|
}
|
||||||
// One stalled or truncated peer must not stop the loop, and
|
// One stalled or truncated peer must not stop the loop, and
|
||||||
// must not raise the window on a request it never finished.
|
// must not raise the window on a request it never finished.
|
||||||
Err(e) => quicksearch_core::log_warn!("a search shortcut request: {}", e),
|
Err(e) => quicksearch_core::log_warn!("a search shortcut request: {}", e),
|
||||||
|
|
@ -200,22 +284,45 @@ mod imp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the request and acknowledge it.
|
/// Read the request: one newline-terminated line carrying the sender's
|
||||||
|
/// activation token, usually empty.
|
||||||
///
|
///
|
||||||
/// Hostile input is the norm rather than the exception: any process of
|
/// Hostile input is the norm rather than the exception: any process of
|
||||||
/// this user can connect. The read is bounded in both bytes and time, so
|
/// this user can connect. The read is bounded in bytes ([`REQUEST_CAP`])
|
||||||
/// a peer that connects and stalls cannot wedge the one thread that
|
/// and time, so a peer that connects and stalls, or pours data without a
|
||||||
/// answers every activation.
|
/// newline, cannot wedge the one thread that answers every activation.
|
||||||
pub(super) fn answer(mut stream: UnixStream) -> std::io::Result<()> {
|
pub(super) fn request(stream: &mut UnixStream) -> std::io::Result<String> {
|
||||||
|
use std::io::{Error, ErrorKind};
|
||||||
|
|
||||||
let timeout = std::time::Duration::from_secs(5);
|
let timeout = std::time::Duration::from_secs(5);
|
||||||
stream.set_read_timeout(Some(timeout))?;
|
stream.set_read_timeout(Some(timeout))?;
|
||||||
stream.set_write_timeout(Some(timeout))?;
|
stream.set_write_timeout(Some(timeout))?;
|
||||||
|
|
||||||
// One byte is the whole request; the cap is what keeps a peer from
|
let mut buf = [0u8; REQUEST_CAP];
|
||||||
// holding this thread for as long as it cares to send.
|
let mut len = 0;
|
||||||
let mut scratch = [0u8; 1];
|
loop {
|
||||||
stream.read_exact(&mut scratch)?;
|
let n = stream.read(&mut buf[len..])?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(ErrorKind::UnexpectedEof.into());
|
||||||
|
}
|
||||||
|
len += n;
|
||||||
|
if let Some(newline) = buf[..len].iter().position(|&b| b == b'\n') {
|
||||||
|
let token = std::str::from_utf8(&buf[..newline])
|
||||||
|
.map_err(|e| Error::new(ErrorKind::InvalidData, e))?;
|
||||||
|
return Ok(token.to_string());
|
||||||
|
}
|
||||||
|
if len == buf.len() {
|
||||||
|
return Err(Error::new(
|
||||||
|
ErrorKind::InvalidData,
|
||||||
|
"no newline within the request cap",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reply that lets the sender tell a live instance from a leftover
|
||||||
|
/// socket file.
|
||||||
|
pub(super) fn acknowledge(stream: &mut UnixStream) -> std::io::Result<()> {
|
||||||
stream.write_all(b"\n")?;
|
stream.write_all(b"\n")?;
|
||||||
stream.flush()
|
stream.flush()
|
||||||
}
|
}
|
||||||
|
|
@ -224,11 +331,17 @@ mod imp {
|
||||||
/// The same handshake over a named pipe, which is what Windows has instead
|
/// The same handshake over a named pipe, which is what Windows has instead
|
||||||
/// of a unix socket.
|
/// of a unix socket.
|
||||||
///
|
///
|
||||||
/// **One deliberate difference: there is no reply.** A named pipe exists only
|
/// **One deliberate difference: the reply is the foreground grant, not a
|
||||||
/// while a server holds an instance open — there is no file left behind — so
|
/// liveness check.** A named pipe exists only while a server holds an
|
||||||
/// a successful `CreateFileW` already proves a live instance accepted us, and
|
/// instance open — there is no file left behind — so a successful
|
||||||
/// the reply the unix side needs to tell a listener from a leftover socket
|
/// `CreateFileW` already proves a live instance accepted us. What Windows
|
||||||
/// would be dead weight here.
|
/// *does* need is permission: `SetForegroundWindow` is refused to a process
|
||||||
|
/// the user did not just interact with, and the running instance is exactly
|
||||||
|
/// that. The `--toggle` process was launched by the user's keypress and so
|
||||||
|
/// holds the right — and may donate it with `AllowSetForegroundWindow`, given
|
||||||
|
/// the server's PID. The server therefore opens every connection by writing
|
||||||
|
/// its PID, and the sender grants before sending the request, so the grant
|
||||||
|
/// is always in force by the time the server raises.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod imp {
|
mod imp {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -247,6 +360,8 @@ mod imp {
|
||||||
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE,
|
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE,
|
||||||
PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
|
PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
|
||||||
};
|
};
|
||||||
|
use windows_sys::Win32::System::Threading::GetCurrentProcessId;
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::AllowSetForegroundWindow;
|
||||||
|
|
||||||
/// `\\.\pipe\quicksearch-<key>`, keyed exactly as the unix socket is, so
|
/// `\\.\pipe\quicksearch-<key>`, keyed exactly as the unix socket is, so
|
||||||
/// the two processes agree by the same rule on both platforms.
|
/// the two processes agree by the same rule on both platforms.
|
||||||
|
|
@ -300,6 +415,29 @@ mod imp {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let pipe = Handle(handle);
|
let pipe = Handle(handle);
|
||||||
|
// The server opens with its PID; hand it the foreground right we
|
||||||
|
// hold from the user's keypress *before* asking it to raise. A
|
||||||
|
// reply that does not parse skips the grant — the raise then
|
||||||
|
// degrades to a taskbar flash rather than the request being lost.
|
||||||
|
let mut reply = [0u8; PID_REPLY_CAP];
|
||||||
|
let mut got = 0u32;
|
||||||
|
// SAFETY: `reply` and `got` are live for the call; the buffer
|
||||||
|
// length passed is the buffer's real length.
|
||||||
|
let ok = unsafe {
|
||||||
|
ReadFile(
|
||||||
|
pipe.0,
|
||||||
|
reply.as_mut_ptr(),
|
||||||
|
reply.len() as u32,
|
||||||
|
&mut got,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok != 0 {
|
||||||
|
if let Some(pid) = parse_pid_reply(&reply[..got as usize]) {
|
||||||
|
// SAFETY: no pointers; any PID value is acceptable input.
|
||||||
|
unsafe { AllowSetForegroundWindow(pid) };
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut written = 0u32;
|
let mut written = 0u32;
|
||||||
// SAFETY: a one-byte buffer and an output slot, both live here.
|
// SAFETY: a one-byte buffer and an output slot, both live here.
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
|
|
@ -349,8 +487,8 @@ mod imp {
|
||||||
PIPE_ACCESS_DUPLEX,
|
PIPE_ACCESS_DUPLEX,
|
||||||
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
|
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
|
||||||
PIPE_UNLIMITED_INSTANCES,
|
PIPE_UNLIMITED_INSTANCES,
|
||||||
16,
|
PID_REPLY_CAP as u32,
|
||||||
16,
|
PID_REPLY_CAP as u32,
|
||||||
0,
|
0,
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
)
|
)
|
||||||
|
|
@ -368,6 +506,28 @@ mod imp {
|
||||||
// the read below is what decides, so it is not checked here.
|
// the read below is what decides, so it is not checked here.
|
||||||
let _ = connected;
|
let _ = connected;
|
||||||
|
|
||||||
|
// Our PID first, before reading anything: the client turns it
|
||||||
|
// into a foreground grant and only then sends the request, so
|
||||||
|
// the grant precedes the raise however the two threads interleave.
|
||||||
|
// SAFETY: reads this process's own id, always valid.
|
||||||
|
let pid = format!("{}\n", unsafe { GetCurrentProcessId() });
|
||||||
|
let mut written = 0u32;
|
||||||
|
// SAFETY: the buffer and output slot are live; the length passed
|
||||||
|
// is the buffer's real length.
|
||||||
|
let wrote = unsafe {
|
||||||
|
WriteFile(
|
||||||
|
pipe.0,
|
||||||
|
pid.as_ptr(),
|
||||||
|
pid.len() as u32,
|
||||||
|
&mut written,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if wrote != 0 {
|
||||||
|
// SAFETY: the handle is open for the length of this call.
|
||||||
|
unsafe { FlushFileBuffers(pipe.0) };
|
||||||
|
}
|
||||||
|
|
||||||
let mut scratch = [0u8; 1];
|
let mut scratch = [0u8; 1];
|
||||||
let mut read = 0u32;
|
let mut read = 0u32;
|
||||||
// SAFETY: a one-byte buffer and an output slot, both live here.
|
// SAFETY: a one-byte buffer and an output slot, both live here.
|
||||||
|
|
@ -380,12 +540,14 @@ mod imp {
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
// SAFETY: the handle is open for the length of this call.
|
|
||||||
unsafe { DisconnectNamedPipe(pipe.0) };
|
|
||||||
// A peer that connected and said nothing is not an activation.
|
// A peer that connected and said nothing is not an activation.
|
||||||
|
// Fired before the disconnect, so a client watching the pipe
|
||||||
|
// close can already rely on the window having been asked.
|
||||||
if ok != 0 && read == 1 {
|
if ok != 0 && read == 1 {
|
||||||
fire(ctx);
|
fire(ctx);
|
||||||
}
|
}
|
||||||
|
// SAFETY: the handle is open for the length of this call.
|
||||||
|
unsafe { DisconnectNamedPipe(pipe.0) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -436,6 +598,49 @@ mod tests {
|
||||||
assert_ne!(a, b);
|
assert_ne!(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An AppImage's mount path dies with the process; a binding must run
|
||||||
|
/// the image file itself. Everything else keeps the old rule.
|
||||||
|
#[test]
|
||||||
|
fn the_bound_command_survives_the_appimage_exiting() {
|
||||||
|
let mount = PathBuf::from("/tmp/.mount_quicksjBMkDo/usr/bin/quicksearch");
|
||||||
|
assert_eq!(
|
||||||
|
durable_command(Some(PathBuf::from("/home/u/Apps/QuickSearch.AppImage")), mount.clone()),
|
||||||
|
"/home/u/Apps/QuickSearch.AppImage"
|
||||||
|
);
|
||||||
|
// A relative or empty APPIMAGE is somebody playing games; ignored.
|
||||||
|
assert_eq!(
|
||||||
|
durable_command(Some(PathBuf::from("games")), mount.clone()),
|
||||||
|
mount.display().to_string()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
durable_command(None, PathBuf::from("/usr/bin/quicksearch")),
|
||||||
|
"quicksearch"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
durable_command(None, PathBuf::from("/opt/qs/quicksearch")),
|
||||||
|
"/opt/qs/quicksearch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Windows reply parser, which faces whatever a squatting process
|
||||||
|
/// cares to write into the well-known pipe name.
|
||||||
|
#[test]
|
||||||
|
fn the_pid_reply_parses_strictly() {
|
||||||
|
assert_eq!(parse_pid_reply(b"12345\n"), Some(12345));
|
||||||
|
assert_eq!(parse_pid_reply(b"1\nrest ignored"), Some(1));
|
||||||
|
for garbage in [
|
||||||
|
&b""[..],
|
||||||
|
b"\n",
|
||||||
|
b"-4\n",
|
||||||
|
b"12345678901234567890\n", // overflows a u32
|
||||||
|
b"abc\n",
|
||||||
|
b"12 34\n",
|
||||||
|
b"\xff\xfe\n",
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_pid_reply(garbage), None, "{:?}", garbage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pending_activation_is_consumed_once() {
|
fn a_pending_activation_is_consumed_once() {
|
||||||
let _serial = pending_guard();
|
let _serial = pending_guard();
|
||||||
|
|
@ -444,6 +649,23 @@ mod tests {
|
||||||
assert!(!take_pending(), "the flag is consumed");
|
assert!(!take_pending(), "the flag is consumed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The token rides the flag: consumed with it, and a fresh press without
|
||||||
|
/// one clears a stale token rather than resurrecting it.
|
||||||
|
#[test]
|
||||||
|
fn the_token_is_consumed_with_the_press() {
|
||||||
|
let _serial = pending_guard();
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
fire_with_token(&ctx, Some("tok".to_string()));
|
||||||
|
assert!(take_pending());
|
||||||
|
assert_eq!(take_token(), Some("tok".to_string()));
|
||||||
|
assert_eq!(take_token(), None, "the token is consumed");
|
||||||
|
|
||||||
|
fire_with_token(&ctx, Some("stale".to_string()));
|
||||||
|
fire(&ctx);
|
||||||
|
assert!(take_pending());
|
||||||
|
assert_eq!(take_token(), None, "a tokenless press cleared the older token");
|
||||||
|
}
|
||||||
|
|
||||||
/// A config path unique to this test, so the sockets these bind never
|
/// A config path unique to this test, so the sockets these bind never
|
||||||
/// collide: they run on one process, in parallel.
|
/// collide: they run on one process, in parallel.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|
@ -471,7 +693,8 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole point, end to end: a signal to a live listener is accepted,
|
/// The whole point, end to end: a signal to a live listener is accepted,
|
||||||
/// and the listener sees a well-formed request.
|
/// the listener sees a well-formed request, and the activation token it
|
||||||
|
/// carries crosses intact.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn a_signal_crosses_the_socket() {
|
fn a_signal_crosses_the_socket() {
|
||||||
|
|
@ -480,18 +703,71 @@ mod tests {
|
||||||
let db = scratch("roundtrip");
|
let db = scratch("roundtrip");
|
||||||
let listener = UnixListener::bind(path_for(&db)).expect("bind");
|
let listener = UnixListener::bind(path_for(&db)).expect("bind");
|
||||||
|
|
||||||
let sender = std::thread::spawn(move || signal(&db));
|
let sender =
|
||||||
|
std::thread::spawn(move || imp::signal_with_token(&db, "wayland-token-123"));
|
||||||
|
|
||||||
let stream = listener
|
let mut stream = listener
|
||||||
.incoming()
|
.incoming()
|
||||||
.next()
|
.next()
|
||||||
.expect("a connection")
|
.expect("a connection")
|
||||||
.expect("accepted");
|
.expect("accepted");
|
||||||
imp::answer(stream).expect("a well-formed request");
|
let token = imp::request(&mut stream).expect("a well-formed request");
|
||||||
|
assert_eq!(token, "wayland-token-123");
|
||||||
|
imp::acknowledge(&mut stream).expect("acknowledged");
|
||||||
|
|
||||||
assert!(sender.join().expect("sender"), "the client saw the reply");
|
assert!(sender.join().expect("sender"), "the client saw the reply");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A token that cannot cross as one line is dropped, not sent mangled:
|
||||||
|
/// the press still lands and only the raise loses its token.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn an_unsendable_token_degrades_to_an_empty_request() {
|
||||||
|
use std::os::unix::net::UnixListener;
|
||||||
|
|
||||||
|
for bad in ["with\nnewline".to_string(), "x".repeat(5000)] {
|
||||||
|
let db = scratch("badtoken");
|
||||||
|
let listener = UnixListener::bind(path_for(&db)).expect("bind");
|
||||||
|
let sender = std::thread::spawn(move || imp::signal_with_token(&db, &bad));
|
||||||
|
let mut stream = listener
|
||||||
|
.incoming()
|
||||||
|
.next()
|
||||||
|
.expect("a connection")
|
||||||
|
.expect("accepted");
|
||||||
|
assert_eq!(imp::request(&mut stream).expect("well-formed"), "");
|
||||||
|
imp::acknowledge(&mut stream).expect("acknowledged");
|
||||||
|
assert!(sender.join().expect("sender"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A peer pouring bytes with no newline is cut off at the cap rather
|
||||||
|
/// than fed forever.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn a_newline_less_flood_is_refused_at_the_cap() {
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
|
||||||
|
let db = scratch("flood");
|
||||||
|
let path = path_for(&db);
|
||||||
|
let listener = UnixListener::bind(&path).expect("bind");
|
||||||
|
let mut peer = UnixStream::connect(&path).expect("connect");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
// More than the cap, never a newline; ignore the write error the
|
||||||
|
// refusal causes.
|
||||||
|
let _ = peer.write_all(&[b'x'; 4096]);
|
||||||
|
});
|
||||||
|
let mut stream = listener
|
||||||
|
.incoming()
|
||||||
|
.next()
|
||||||
|
.expect("a connection")
|
||||||
|
.expect("accepted");
|
||||||
|
assert!(
|
||||||
|
imp::request(&mut stream).is_err(),
|
||||||
|
"a capless request was accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `listen` must replace a crashed predecessor's socket rather than
|
/// `listen` must replace a crashed predecessor's socket rather than
|
||||||
/// giving up on it.
|
/// giving up on it.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
|
|
@ -525,12 +801,15 @@ mod tests {
|
||||||
let peer = UnixStream::connect(&path).expect("connect");
|
let peer = UnixStream::connect(&path).expect("connect");
|
||||||
drop(peer);
|
drop(peer);
|
||||||
|
|
||||||
let stream = listener
|
let mut stream = listener
|
||||||
.incoming()
|
.incoming()
|
||||||
.next()
|
.next()
|
||||||
.expect("a connection")
|
.expect("a connection")
|
||||||
.expect("accepted");
|
.expect("accepted");
|
||||||
assert!(imp::answer(stream).is_err(), "an empty request is refused");
|
assert!(
|
||||||
|
imp::request(&mut stream).is_err(),
|
||||||
|
"an empty request is refused"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A config path unique to this test. Never opened as a file — only
|
/// A config path unique to this test. Never opened as a file — only
|
||||||
|
|
@ -585,6 +864,16 @@ mod tests {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
assert!(delivered, "the listener never answered");
|
assert!(delivered, "the listener never answered");
|
||||||
assert!(take_pending(), "and the window was asked to come forward");
|
// Unlike unix there is no ack after the fire: the server reads the
|
||||||
|
// request after the client's write returns, so give it a moment.
|
||||||
|
let mut fired = false;
|
||||||
|
for _ in 0..100 {
|
||||||
|
if take_pending() {
|
||||||
|
fired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
assert!(fired, "the window was never asked to come forward");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,28 +10,33 @@
|
||||||
//! Hence [`x11_activate`], which sends source indication 2 (EWMH's "direct
|
//! Hence [`x11_activate`], which sends source indication 2 (EWMH's "direct
|
||||||
//! user action"). Do not replace it with winit's version.
|
//! user action"). Do not replace it with winit's version.
|
||||||
//! * **Wayland**: a client cannot raise itself; the compositor only honours
|
//! * **Wayland**: a client cannot raise itself; the compositor only honours
|
||||||
//! an xdg-activation token, and winit 0.30 applies one in exactly one
|
//! an xdg-activation token. winit 0.30 applies one in exactly one place —
|
||||||
//! place — `WindowAttributes::with_activation_token`, at window creation —
|
//! window creation — and its `focus_window` is an empty body, so for a
|
||||||
//! which egui's `ViewportBuilder` has no field for and eframe therefore
|
//! *live* window we speak `xdg_activation_v1` ourselves ([`wayland`]): the
|
||||||
//! never sets. `focus_window` on Wayland is an empty function body. So a
|
//! `--toggle` sender forwards the `XDG_ACTIVATION_TOKEN` its launcher gave
|
||||||
//! `--toggle` that *starts* the app gets whatever focus the compositor
|
//! it over the socket, and this process hands it to the compositor for its
|
||||||
//! gives a newly mapped window, and one that finds it already running
|
//! own surface. Without a token (a manual binding whose launcher minted
|
||||||
//! cannot raise it at all: the most this path can do is ask for attention,
|
//! none) the fallback is a request for attention — a highlighted task
|
||||||
//! which shows up as a highlighted task entry. Closing that gap means
|
//! entry.
|
||||||
//! patching both winit (to activate a live surface) and eframe (to carry
|
//! * **Windows**: `SetForegroundWindow` is refused to background processes —
|
||||||
//! the token), the way `vendor/` already patches two other crates.
|
//! but the `--toggle` sender was launched by the user's keypress and
|
||||||
//! * **Windows**: `SetForegroundWindow` is refused to background processes,
|
//! donates its right over the pipe with `AllowSetForegroundWindow` (see
|
||||||
//! so the same limit applies to a process that did not just receive input.
|
//! `crate::activate`'s Windows module), after which [`win32_activate`]'s
|
||||||
|
//! `SetForegroundWindow` is honoured. The in-app hotkey path needs no
|
||||||
|
//! grant: the press was delivered to this process. Only a request with no
|
||||||
|
//! grant — e.g. some other process poking the pipe — degrades to a
|
||||||
|
//! taskbar flash.
|
||||||
|
|
||||||
/// Whether this is a Wayland session, where an already-open window cannot be
|
/// Whether this is a Wayland session, where raising needs an activation
|
||||||
/// raised. The Settings tab says so rather than letting the shortcut look
|
/// token; the Settings tab words its note accordingly. See the module docs.
|
||||||
/// broken; see the module docs.
|
|
||||||
pub fn is_wayland() -> bool {
|
pub fn is_wayland() -> bool {
|
||||||
cfg!(all(unix, not(target_os = "macos"))) && std::env::var_os("WAYLAND_DISPLAY").is_some()
|
cfg!(all(unix, not(target_os = "macos"))) && std::env::var_os("WAYLAND_DISPLAY").is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring the window to the front, restoring it if it was minimised.
|
/// Bring the window to the front, restoring it if it was minimised.
|
||||||
pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
|
/// `token` is an xdg-activation token relayed by a `--toggle` sender, the
|
||||||
|
/// compositor's permission to take focus; only Wayland consumes it.
|
||||||
|
pub fn raise(ctx: &egui::Context, frame: &eframe::Frame, token: Option<&str>) {
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
{
|
{
|
||||||
if x11_activate(frame) {
|
if x11_activate(frame) {
|
||||||
|
|
@ -41,18 +46,33 @@ pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Wayland: nothing below will raise the window, so make the one
|
|
||||||
// request the compositor does honour from a background client.
|
|
||||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||||
|
// With a token the compositor will move focus itself; the
|
||||||
|
// de-iconify still has to be asked for separately.
|
||||||
|
if let Some(token) = token.filter(|t| !t.is_empty()) {
|
||||||
|
if wayland::activate(frame, token) {
|
||||||
|
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No token, or the compositor refused: the one request honoured
|
||||||
|
// from a background client is a bid for attention.
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(
|
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(
|
||||||
egui::UserAttentionType::Informational,
|
egui::UserAttentionType::Informational,
|
||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let _ = frame;
|
let _ = token;
|
||||||
|
if win32_activate(frame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(any(all(unix, not(target_os = "macos")), windows)))]
|
||||||
|
{
|
||||||
|
let _ = (frame, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A window still minimised cannot take focus.
|
// A window still minimised cannot take focus.
|
||||||
|
|
@ -60,6 +80,56 @@ pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore and foreground our window with the Win32 calls themselves.
|
||||||
|
/// `false` falls back to winit's viewport commands.
|
||||||
|
///
|
||||||
|
/// Not `ViewportCommand::Focus`: winit's `focus_window` routes through the
|
||||||
|
/// same `SetForegroundWindow`, but only after the event loop wakes and with
|
||||||
|
/// its own preconditions, and it neither restores a minimised window nor
|
||||||
|
/// reports failure. Calling the API here keeps restore-then-foreground in
|
||||||
|
/// one place, immediately, while the `AllowSetForegroundWindow` grant from
|
||||||
|
/// the `--toggle` sender is fresh.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn win32_activate(frame: &eframe::Frame) -> bool {
|
||||||
|
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
|
IsIconic, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||||
|
};
|
||||||
|
|
||||||
|
let handle = match frame.window_handle() {
|
||||||
|
Ok(handle) => handle,
|
||||||
|
Err(e) => {
|
||||||
|
quicksearch_core::log_warn!("raising the window: no window handle: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let hwnd = match handle.as_raw() {
|
||||||
|
RawWindowHandle::Win32(win32) => win32.hwnd.get() as _,
|
||||||
|
other => {
|
||||||
|
quicksearch_core::log_warn!("raising the window: not a Win32 window: {:?}", other);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// SAFETY: `hwnd` is this process's live window for the whole call; these
|
||||||
|
// APIs accept any window handle and merely fail on a bad one.
|
||||||
|
unsafe {
|
||||||
|
if IsIconic(hwnd) != 0 {
|
||||||
|
ShowWindow(hwnd, SW_RESTORE);
|
||||||
|
}
|
||||||
|
if SetForegroundWindow(hwnd) == 0 {
|
||||||
|
// No grant in force (see the module docs): the most Windows
|
||||||
|
// allows from here is a taskbar flash, which the winit fallback
|
||||||
|
// produces. Logged so a shortcut that only flashes is traceable.
|
||||||
|
quicksearch_core::log_warn!(
|
||||||
|
"raising the window: SetForegroundWindow was refused; \
|
||||||
|
flashing the taskbar instead"
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
// The X connection used for activation, kept open across presses.
|
// The X connection used for activation, kept open across presses.
|
||||||
// Thread-local because `raise` only ever runs on the UI thread, and held
|
// Thread-local because `raise` only ever runs on the UI thread, and held
|
||||||
// rather than reconnected because a connect per keypress is both wasteful
|
// rather than reconnected because a connect per keypress is both wasteful
|
||||||
|
|
@ -227,3 +297,179 @@ fn x11_activate(frame: &eframe::Frame) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Wayland activation path. winit applies an activation token in exactly
|
||||||
|
/// one place — window creation — so for a live window we speak the protocol
|
||||||
|
/// ourselves, over winit's own connection: `from_foreign_display` wraps its
|
||||||
|
/// `wl_display` as a "guest" backend with a private libwayland event queue.
|
||||||
|
/// Roundtrips here dispatch only that queue; events for winit's objects stay
|
||||||
|
/// queued for winit, and dropping the guest never disconnects the display
|
||||||
|
/// (`owns_display: false`).
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
mod wayland {
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
use raw_window_handle::{
|
||||||
|
HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle,
|
||||||
|
};
|
||||||
|
use wayland_client::backend::{Backend, ObjectId};
|
||||||
|
use wayland_client::protocol::{wl_registry, wl_surface::WlSurface};
|
||||||
|
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||||
|
use wayland_protocols::xdg::activation::v1::client::xdg_activation_v1::XdgActivationV1;
|
||||||
|
|
||||||
|
// Kept open across presses like the X11 state above, and for one more
|
||||||
|
// reason: wl_registry has no destructor request, so a fresh connection
|
||||||
|
// per press would leak a server-side registry every time.
|
||||||
|
thread_local! {
|
||||||
|
static WAYLAND: RefCell<Option<WaylandState>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WaylandState {
|
||||||
|
/// The wl_display this guest connection wraps — winit's. Compared on
|
||||||
|
/// every press so a different display gets a fresh connection.
|
||||||
|
display_ptr: *mut c_void,
|
||||||
|
conn: Connection,
|
||||||
|
queue: EventQueue<ActivationGlobals>,
|
||||||
|
globals: ActivationGlobals,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActivationGlobals {
|
||||||
|
activation: Option<XdgActivationV1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dispatch<wl_registry::WlRegistry, ()> for ActivationGlobals {
|
||||||
|
fn event(
|
||||||
|
state: &mut Self,
|
||||||
|
registry: &wl_registry::WlRegistry,
|
||||||
|
event: wl_registry::Event,
|
||||||
|
_: &(),
|
||||||
|
_: &Connection,
|
||||||
|
qh: &QueueHandle<Self>,
|
||||||
|
) {
|
||||||
|
if let wl_registry::Event::Global {
|
||||||
|
name,
|
||||||
|
interface,
|
||||||
|
version: _,
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
if interface == "xdg_activation_v1" && state.activation.is_none() {
|
||||||
|
// We speak version 1; a compositor that advertises the
|
||||||
|
// global supports at least that.
|
||||||
|
state.activation =
|
||||||
|
Some(registry.bind::<XdgActivationV1, _, _>(name, 1, qh, ()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// xdg_activation_v1 has no events; `unreachable!()` if that ever changes.
|
||||||
|
wayland_client::delegate_noop!(ActivationGlobals: XdgActivationV1);
|
||||||
|
|
||||||
|
impl WaylandState {
|
||||||
|
fn connect(display_ptr: *mut c_void) -> Result<Self, Box<dyn Error>> {
|
||||||
|
// SAFETY: `display_ptr` is the wl_display of winit's live
|
||||||
|
// connection, which outlives every `Frame` we are handed. Guest
|
||||||
|
// mode: on drop only our private queue and our own proxies are
|
||||||
|
// destroyed, never the display itself.
|
||||||
|
let backend = unsafe { Backend::from_foreign_display(display_ptr.cast()) };
|
||||||
|
let conn = Connection::from_backend(backend);
|
||||||
|
let mut queue = conn.new_event_queue::<ActivationGlobals>();
|
||||||
|
let qh = queue.handle();
|
||||||
|
let _registry = conn.display().get_registry(&qh, ());
|
||||||
|
let mut globals = ActivationGlobals { activation: None };
|
||||||
|
// Blocks for one compositor round trip (local socket, well under
|
||||||
|
// a frame) and dispatches only this queue.
|
||||||
|
queue.roundtrip(&mut globals)?;
|
||||||
|
if globals.activation.is_none() {
|
||||||
|
return Err("the compositor does not support xdg_activation_v1".into());
|
||||||
|
}
|
||||||
|
Ok(WaylandState {
|
||||||
|
display_ptr,
|
||||||
|
conn,
|
||||||
|
queue,
|
||||||
|
globals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&mut self, surface_ptr: *mut c_void, token: &str) -> Result<(), Box<dyn Error>> {
|
||||||
|
// Drain registry chatter (global add/remove) accumulated since
|
||||||
|
// the last press so the queue buffer cannot grow over a session.
|
||||||
|
self.queue.dispatch_pending(&mut self.globals)?;
|
||||||
|
let activation = self
|
||||||
|
.globals
|
||||||
|
.activation
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("xdg_activation_v1 disappeared from the registry")?;
|
||||||
|
|
||||||
|
// SAFETY: `surface_ptr` is winit's live wl_surface for the very
|
||||||
|
// window we are raising; it outlives this call. `from_ptr`
|
||||||
|
// verifies the interface really is wl_surface.
|
||||||
|
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface_ptr.cast()) }?;
|
||||||
|
let surface = WlSurface::from_id(&self.conn, id)?;
|
||||||
|
|
||||||
|
activation.activate(token.to_owned(), &surface);
|
||||||
|
// A roundtrip rather than a bare flush: it proves the compositor
|
||||||
|
// consumed the request, so a connection that died since the last
|
||||||
|
// press surfaces as an error here (and the cache is dropped)
|
||||||
|
// instead of "succeeding" into a closed socket forever.
|
||||||
|
self.queue.roundtrip(&mut self.globals)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Activate our already-mapped window with an xdg-activation token minted
|
||||||
|
/// by the compositor for a real user action. `false` when this is not a
|
||||||
|
/// Wayland window, the compositor lacks `xdg_activation_v1`, or anything
|
||||||
|
/// on the way failed — the caller then falls back to a request for
|
||||||
|
/// attention. Note the compositor may still quietly downgrade a stale
|
||||||
|
/// token to that same attention hint; v1 has no error event to say so.
|
||||||
|
pub(super) fn activate(frame: &eframe::Frame, token: &str) -> bool {
|
||||||
|
let display_ptr = match frame.display_handle().map(|h| h.as_raw()) {
|
||||||
|
Ok(RawDisplayHandle::Wayland(w)) => w.display.as_ptr(),
|
||||||
|
Ok(other) => {
|
||||||
|
quicksearch_core::log_warn!("raising the window: not a Wayland display: {:?}", other);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
quicksearch_core::log_warn!("raising the window: no display handle: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let surface_ptr = match frame.window_handle().map(|h| h.as_raw()) {
|
||||||
|
Ok(RawWindowHandle::Wayland(w)) => w.surface.as_ptr(),
|
||||||
|
Ok(other) => {
|
||||||
|
quicksearch_core::log_warn!("raising the window: not a Wayland window: {:?}", other);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
quicksearch_core::log_warn!("raising the window: no window handle: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sent = WAYLAND.with(|slot| -> Result<(), Box<dyn Error>> {
|
||||||
|
let mut slot = slot.borrow_mut();
|
||||||
|
// A cached connection is only good for the display it wraps.
|
||||||
|
if slot.as_ref().is_some_and(|s| s.display_ptr != display_ptr) {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
let state = match slot.as_mut() {
|
||||||
|
Some(state) => state,
|
||||||
|
None => slot.insert(WaylandState::connect(display_ptr)?),
|
||||||
|
};
|
||||||
|
state.activate(surface_ptr, token)
|
||||||
|
});
|
||||||
|
match sent {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
quicksearch_core::log_warn!("raising the window: {}", e);
|
||||||
|
// Do not reuse a connection that failed mid-way; the next
|
||||||
|
// press reconnects.
|
||||||
|
WAYLAND.with(|slot| slot.borrow_mut().take());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -388,9 +388,11 @@ impl QuickSearchApp {
|
||||||
ctx.set_zoom_factor(clamp_scale(new.ui.scale));
|
ctx.set_zoom_factor(clamp_scale(new.ui.scale));
|
||||||
}
|
}
|
||||||
if new.ui.search_hotkey != self.cfg.ui.search_hotkey {
|
if new.ui.search_hotkey != self.cfg.ui.search_hotkey {
|
||||||
// Only when moved: on Wayland re-registering opens a new portal
|
// Only when moved: re-registering an unchanged key would still
|
||||||
// session, which some desktops confirm with the user.
|
// release and re-grab it, a window in which a press is lost.
|
||||||
crate::hotkey::apply(&new.ui.search_hotkey);
|
crate::hotkey::apply(&new.ui.search_hotkey);
|
||||||
|
// And the system-wide binding follows, where one is written.
|
||||||
|
crate::shortcut_setup::hotkey_changed(&new.ui.search_hotkey);
|
||||||
}
|
}
|
||||||
if new.ui.color_scheme != self.cfg.ui.color_scheme {
|
if new.ui.color_scheme != self.cfg.ui.color_scheme {
|
||||||
apply_theme(ctx, &new.ui.color_scheme);
|
apply_theme(ctx, &new.ui.color_scheme);
|
||||||
|
|
|
||||||
|
|
@ -213,20 +213,20 @@ impl QuickSearchApp {
|
||||||
if actions.focus_search {
|
if actions.focus_search {
|
||||||
self.search.request_focus();
|
self.search.request_focus();
|
||||||
}
|
}
|
||||||
// Live, like the Settings slider on Apply — but saved only when the
|
// Only ever emitted by the page's Apply button, so applying and
|
||||||
// drag ends, so crossing the slider does not rewrite the config file
|
// saving belong together — the same pair the Settings tab's Apply
|
||||||
// on every frame.
|
// performs.
|
||||||
if let Some(scale) = actions.set_scale {
|
if let Some(scale) = actions.set_scale {
|
||||||
self.cfg.ui.scale = scale;
|
self.cfg.ui.scale = scale;
|
||||||
ctx.set_zoom_factor(super::clamp_scale(scale));
|
ctx.set_zoom_factor(super::clamp_scale(scale));
|
||||||
if actions.save_scale {
|
self.save_cfg();
|
||||||
self.save_cfg();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Registered as it is captured, not on an Apply the tour has no
|
// Registered as it is captured, not on an Apply the tour has no
|
||||||
// button for — the page says it takes effect at once.
|
// button for — the page says it takes effect at once.
|
||||||
if let Some(hotkey) = actions.set_hotkey {
|
if let Some(hotkey) = actions.set_hotkey {
|
||||||
crate::hotkey::apply(&hotkey);
|
crate::hotkey::apply(&hotkey);
|
||||||
|
// The system-wide binding follows, where one is written.
|
||||||
|
crate::shortcut_setup::hotkey_changed(&hotkey);
|
||||||
self.cfg.ui.search_hotkey = hotkey;
|
self.cfg.ui.search_hotkey = hotkey;
|
||||||
self.save_cfg();
|
self.save_cfg();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use crate::tips::{self, Tipped};
|
||||||
use crate::ui_util::hint;
|
use crate::ui_util::hint;
|
||||||
|
|
||||||
impl QuickSearchApp {
|
impl QuickSearchApp {
|
||||||
|
|
@ -48,18 +49,19 @@ impl QuickSearchApp {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(match (r.total, r.fraction()) {
|
egui::RichText::new(match (r.total, r.fraction()) {
|
||||||
(Some(total), Some(frac)) => format!(
|
(Some(total), Some(frac)) => format!(
|
||||||
"Applying configuration change · {} / {} ({:.0}%)",
|
"Rebuilding FTS cache · {} / {} ({:.0}%)",
|
||||||
group_thousands(r.examined as u64),
|
group_thousands(r.examined as u64),
|
||||||
group_thousands(total as u64),
|
group_thousands(total as u64),
|
||||||
frac * 100.0
|
frac * 100.0
|
||||||
),
|
),
|
||||||
_ => format!(
|
_ => format!(
|
||||||
"Applying configuration change · {} entries",
|
"Rebuilding FTS cache · {} entries",
|
||||||
group_thousands(r.examined as u64)
|
group_thousands(r.examined as u64)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
.small(),
|
.small(),
|
||||||
);
|
)
|
||||||
|
.tip(&tips::REBUILDING_FTS);
|
||||||
progress_widget(ui, r.fraction());
|
progress_widget(ui, r.fraction());
|
||||||
}
|
}
|
||||||
ReconcileState::Finished(r) => {
|
ReconcileState::Finished(r) => {
|
||||||
|
|
@ -74,21 +76,28 @@ impl QuickSearchApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IndexingStatus::Preparing { start_time, step } => {
|
IndexingStatus::Preparing { start_time, step } => {
|
||||||
let (label, frac) = match step {
|
// Only the reconcile step has a wait worth explaining;
|
||||||
|
// the rest are over in a moment.
|
||||||
|
let (label, frac, tip) = match step {
|
||||||
PrepStep::PreviousRun => {
|
PrepStep::PreviousRun => {
|
||||||
("Finishing the previous run…".to_string(), None)
|
("Finishing the previous run…".to_string(), None, None)
|
||||||
|
}
|
||||||
|
PrepStep::OpeningIndex => {
|
||||||
|
("Opening the index…".to_string(), None, None)
|
||||||
|
}
|
||||||
|
PrepStep::Starting => {
|
||||||
|
("Getting the index ready…".to_string(), None, None)
|
||||||
}
|
}
|
||||||
PrepStep::OpeningIndex => ("Opening the index…".to_string(), None),
|
|
||||||
PrepStep::Starting => ("Getting the index ready…".to_string(), None),
|
|
||||||
PrepStep::Reconciling(r) => (
|
PrepStep::Reconciling(r) => (
|
||||||
format!(
|
format!(
|
||||||
"Applying configuration change · {} entries",
|
"Rebuilding FTS cache · {} entries",
|
||||||
group_thousands(r.examined as u64)
|
group_thousands(r.examined as u64)
|
||||||
),
|
),
|
||||||
r.fraction(),
|
r.fraction(),
|
||||||
|
Some(&tips::REBUILDING_FTS),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
ui.label(
|
let response = ui.label(
|
||||||
egui::RichText::new(format!(
|
egui::RichText::new(format!(
|
||||||
"{} · {}",
|
"{} · {}",
|
||||||
label,
|
label,
|
||||||
|
|
@ -96,6 +105,9 @@ impl QuickSearchApp {
|
||||||
))
|
))
|
||||||
.small(),
|
.small(),
|
||||||
);
|
);
|
||||||
|
if let Some(tip) = tip {
|
||||||
|
response.tip(tip);
|
||||||
|
}
|
||||||
progress_widget(ui, frac);
|
progress_widget(ui, frac);
|
||||||
}
|
}
|
||||||
IndexingStatus::Idle => {
|
IndexingStatus::Idle => {
|
||||||
|
|
|
||||||
|
|
@ -232,34 +232,50 @@ fn ranking_section(ui: &mut egui::Ui) {
|
||||||
.spacing([CELL_SPACING, 5.0])
|
.spacing([CELL_SPACING, 5.0])
|
||||||
.striped(true)
|
.striped(true)
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
let row = |ui: &mut egui::Ui, tier: &str, what: &str| {
|
// Each tier's chip wears the colour its results wear in the Rank
|
||||||
ui.strong(tier);
|
// column, keyed by the *first* cascade stage the collapsed tier
|
||||||
|
// covers (see the rank table in `search::cascade`): exact name
|
||||||
|
// 1–2, name contains 3–4, text inside 5–6, fuzzy 7–8, path 9–11.
|
||||||
|
let row = |ui: &mut egui::Ui, stage: u8, tier: &str, what: &str| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(" {} ", tier))
|
||||||
|
.strong()
|
||||||
|
.background_color(crate::color::rank_tier_color(stage))
|
||||||
|
// The same near-black the Search tab's chips carry,
|
||||||
|
// which every ramp colour holds contrast against.
|
||||||
|
.color(egui::Color32::from_rgb(32, 32, 32)),
|
||||||
|
);
|
||||||
cell(ui, prose, what);
|
cell(ui, prose, what);
|
||||||
ui.end_row();
|
ui.end_row();
|
||||||
};
|
};
|
||||||
row(
|
row(
|
||||||
ui,
|
ui,
|
||||||
|
1,
|
||||||
"Exact name",
|
"Exact name",
|
||||||
"the file is called exactly what you typed",
|
"the file is called exactly what you typed",
|
||||||
);
|
);
|
||||||
row(
|
row(
|
||||||
ui,
|
ui,
|
||||||
|
3,
|
||||||
"Name contains",
|
"Name contains",
|
||||||
"what you typed appears somewhere in the file's name",
|
"what you typed appears somewhere in the file's name",
|
||||||
);
|
);
|
||||||
row(
|
row(
|
||||||
ui,
|
ui,
|
||||||
|
5,
|
||||||
"Text inside",
|
"Text inside",
|
||||||
"the words are in the file's contents, most mentions first",
|
"the words are in the file's contents, most mentions first",
|
||||||
);
|
);
|
||||||
row(
|
row(
|
||||||
ui,
|
ui,
|
||||||
|
7,
|
||||||
"Close spelling",
|
"Close spelling",
|
||||||
"a name or some text within a typo or two of what you typed, \
|
"a name or some text within a typo or two of what you typed, \
|
||||||
only while Fuzzy is ticked",
|
only while Fuzzy is ticked",
|
||||||
);
|
);
|
||||||
row(
|
row(
|
||||||
ui,
|
ui,
|
||||||
|
9,
|
||||||
"Path only",
|
"Path only",
|
||||||
"nothing in the name or the text matched, but a folder along \
|
"nothing in the name or the text matched, but a folder along \
|
||||||
the way did",
|
the way did",
|
||||||
|
|
@ -502,7 +518,7 @@ const KEY_COL_WIDTH: f32 = 140.0;
|
||||||
const CELL_SPACING: f32 = 14.0;
|
const CELL_SPACING: f32 = 14.0;
|
||||||
|
|
||||||
/// A prose cell in one of this tab's tables, laid out in a child `Ui` of
|
/// A prose cell in one of this tab's tables, laid out in a child `Ui` of
|
||||||
/// exactly `width`.
|
/// exactly `width` and exactly as tall as the wrapped text came out.
|
||||||
///
|
///
|
||||||
/// Two things force the explicit width. Grid cells default to
|
/// Two things force the explicit width. Grid cells default to
|
||||||
/// `TextWrapMode::Extend`, which lays a long cell out past the panel and, far
|
/// `TextWrapMode::Extend`, which lays a long cell out past the panel and, far
|
||||||
|
|
@ -513,9 +529,23 @@ const CELL_SPACING: f32 = 14.0;
|
||||||
/// the column to that, squeezing prose into a two-word ribbon. A child `Ui`
|
/// the column to that, squeezing prose into a two-word ribbon. A child `Ui`
|
||||||
/// of a width we chose settles both. Pinned by
|
/// of a width we chose settles both. Pinned by
|
||||||
/// `tests::a_window_narrower_than_the_column_reflows_rather_than_clipping`.
|
/// `tests::a_window_narrower_than_the_column_reflows_rather_than_clipping`.
|
||||||
|
///
|
||||||
|
/// The height has to be measured rather than left at zero, which is why the
|
||||||
|
/// galley is laid out here instead of by the `Label`. `Grid` aligns a cell
|
||||||
|
/// `LEFT_CENTER` within its row, and centring a *zero-height* box lands it on
|
||||||
|
/// the row's midpoint; the text then runs downward from there, half a row
|
||||||
|
/// below the striped background painted behind it. Handing the measured
|
||||||
|
/// height in makes that centring a no-op. Pinned by
|
||||||
|
/// `tests::table_rows_line_up_with_their_stripes`.
|
||||||
fn cell(ui: &mut egui::Ui, width: f32, text: impl Into<egui::WidgetText>) {
|
fn cell(ui: &mut egui::Ui, width: f32, text: impl Into<egui::WidgetText>) {
|
||||||
ui.allocate_ui(egui::vec2(width, 0.0), |ui| {
|
let galley = text.into().into_galley(
|
||||||
ui.add(egui::Label::new(text).wrap());
|
ui,
|
||||||
|
Some(egui::TextWrapMode::Wrap),
|
||||||
|
width,
|
||||||
|
egui::TextStyle::Body,
|
||||||
|
);
|
||||||
|
ui.allocate_ui(egui::vec2(width, galley.size().y), |ui| {
|
||||||
|
ui.add(egui::Label::new(galley));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -601,8 +631,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn a_window_narrower_than_the_column_reflows_rather_than_clipping() {
|
fn a_window_narrower_than_the_column_reflows_rather_than_clipping() {
|
||||||
// 640 is the smallest window the app allows, and the UI scale
|
// 640 is the smallest window the app allows, and the UI scale
|
||||||
// divides it: 400 is roughly that window at 1.6x.
|
// divides it: 400 is roughly that window at 1.6x, and 250 is it at
|
||||||
for width in [400.0_f32, 480.0, 560.0, 620.0] {
|
// the 2.5x ceiling — the narrowest layout the app can produce.
|
||||||
|
for width in [250.0_f32, 320.0, 400.0, 480.0, 560.0, 620.0] {
|
||||||
let ctx = crate::test_ui::ctx();
|
let ctx = crate::test_ui::ctx();
|
||||||
let input = crate::test_ui::raw_input(egui::vec2(width, 6000.0), vec![]);
|
let input = crate::test_ui::raw_input(egui::vec2(width, 6000.0), vec![]);
|
||||||
let out = ctx.run(input, |ctx| {
|
let out = ctx.run(input, |ctx| {
|
||||||
|
|
@ -624,6 +655,130 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every tier chip wears the Rank column's own colour for its first
|
||||||
|
/// cascade stage — the chips exist to demonstrate the blue→red ramp the
|
||||||
|
/// paragraph under the table describes.
|
||||||
|
#[test]
|
||||||
|
fn the_ranking_tiers_wear_the_rank_colors() {
|
||||||
|
let ctx = crate::test_ui::ctx();
|
||||||
|
let input = crate::test_ui::raw_input(egui::vec2(1000.0, 4000.0), vec![]);
|
||||||
|
let out = ctx.run(input, |ctx| {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
super::ui(ui);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// A RichText background is a section format in the galley, not a
|
||||||
|
// separate rect shape.
|
||||||
|
let mut backgrounds = Vec::new();
|
||||||
|
for clipped in &out.shapes {
|
||||||
|
if let egui::epaint::Shape::Text(text) = &clipped.shape {
|
||||||
|
for section in &text.galley.job.sections {
|
||||||
|
backgrounds.push(section.format.background);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for stage in [1u8, 3, 5, 7, 9] {
|
||||||
|
let color = crate::color::rank_tier_color(stage);
|
||||||
|
assert!(
|
||||||
|
backgrounds.contains(&color),
|
||||||
|
"no chip painted in stage {}'s colour {:?}",
|
||||||
|
stage,
|
||||||
|
color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A table cell allocated with no height of its own is centred on its
|
||||||
|
/// row's midpoint by `Grid` and then laid out downward from there, which
|
||||||
|
/// leaves every prose column half a row below the striped background
|
||||||
|
/// painted behind it. Both halves of a row have to share a centre line,
|
||||||
|
/// and the text has to sit inside its own stripe.
|
||||||
|
#[test]
|
||||||
|
fn table_rows_line_up_with_their_stripes() {
|
||||||
|
let ctx = crate::test_ui::ctx();
|
||||||
|
let input = crate::test_ui::raw_input(egui::vec2(1000.0, 4000.0), vec![]);
|
||||||
|
let out = ctx.run(input, |ctx| {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
super::ui(ui);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let painted = crate::test_ui::painted(&out);
|
||||||
|
let find = |needle: &str| {
|
||||||
|
painted
|
||||||
|
.iter()
|
||||||
|
.find(|(text, _)| text == needle)
|
||||||
|
.unwrap_or_else(|| panic!("nothing painted for {:?}", needle))
|
||||||
|
.1
|
||||||
|
};
|
||||||
|
|
||||||
|
// One single-line and one wrapping row from each striped table: the
|
||||||
|
// offset is half the row's height, so a wrapping row misses by twice
|
||||||
|
// as much as a single-line one.
|
||||||
|
for (key, prose) in [
|
||||||
|
(" Exact name ", "the file is called exactly what you typed"),
|
||||||
|
(
|
||||||
|
" Close spelling ",
|
||||||
|
"a name or some text within a typo or two of what you typed, \
|
||||||
|
only while Fuzzy is ticked",
|
||||||
|
),
|
||||||
|
("*.jpg", "holiday.jpg, 1.jpg"),
|
||||||
|
(
|
||||||
|
"node_modules",
|
||||||
|
"a file or folder named exactly that, and all it holds",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let (key_rect, prose_rect) = (find(key), find(prose));
|
||||||
|
assert!(
|
||||||
|
(key_rect.center().y - prose_rect.center().y).abs() < 1.0,
|
||||||
|
"{:?} is centred at {} but {:?} at {}",
|
||||||
|
key,
|
||||||
|
key_rect.center().y,
|
||||||
|
prose,
|
||||||
|
prose_rect.center().y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stripes themselves, so the rows are checked against what the
|
||||||
|
// user actually sees behind them rather than only against each other.
|
||||||
|
// Only every other row carries one, so both of these are rows egui
|
||||||
|
// paints: the ranking table's fourth, the examples' second.
|
||||||
|
let faint = ctx.style().visuals.faint_bg_color;
|
||||||
|
fn walk(shape: &egui::epaint::Shape, faint: egui::Color32, into: &mut Vec<egui::Rect>) {
|
||||||
|
match shape {
|
||||||
|
egui::epaint::Shape::Rect(r) if r.fill == faint => into.push(r.rect),
|
||||||
|
egui::epaint::Shape::Vec(shapes) => {
|
||||||
|
for s in shapes {
|
||||||
|
walk(s, faint, into);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut stripes = Vec::new();
|
||||||
|
for clipped in &out.shapes {
|
||||||
|
walk(&clipped.shape, faint, &mut stripes);
|
||||||
|
}
|
||||||
|
assert!(!stripes.is_empty(), "no striped rows painted");
|
||||||
|
for prose in [
|
||||||
|
"a name or some text within a typo or two of what you typed, \
|
||||||
|
only while Fuzzy is ticked",
|
||||||
|
"a file or folder named exactly that, and all it holds",
|
||||||
|
] {
|
||||||
|
let rect = find(prose);
|
||||||
|
let stripe = stripes
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.contains(rect.center()))
|
||||||
|
.unwrap_or_else(|| panic!("{:?} sits on no stripe: {:#?}", prose, stripes));
|
||||||
|
assert!(
|
||||||
|
stripe.y_range().contains(rect.top()) && stripe.y_range().contains(rect.bottom()),
|
||||||
|
"{:?} spans {:?} but its stripe only {:?}",
|
||||||
|
prose,
|
||||||
|
rect.y_range(),
|
||||||
|
stripe.y_range()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The near-miss column is half of what the examples teach, so the
|
/// The near-miss column is half of what the examples teach, so the
|
||||||
/// narrow layout has to keep it rather than dropping to pattern-only.
|
/// narrow layout has to keep it rather than dropping to pattern-only.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! The one representation of a shortcut and its three spellings: the config
|
//! The one representation of a shortcut and its spellings: the config text
|
||||||
//! text (which is also the `global-hotkey` token), and the xkbcommon keysym
|
//! (which is also the `global-hotkey` token), the X11 keysym name (which
|
||||||
//! the XDG shortcuts spec wants for the Wayland portal. All three come out
|
//! doubles as the GTK keyval for GNOME bindings), and the Qt key code KDE's
|
||||||
//! of [`KEYS`], so a key cannot be spelled correctly for one backend and
|
//! KGlobalAccel takes. All of them come out of [`KEYS`] and [`qt_key`], so a
|
||||||
//! wrongly for the other.
|
//! key cannot be spelled correctly for one backend and wrongly for another.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
@ -156,18 +156,42 @@ impl Binding {
|
||||||
.expect("every Binding key comes from KEYS")
|
.expect("every Binding key comes from KEYS")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trigger in the XDG shortcuts spec's syntax: uppercase modifiers
|
/// The key combination as the integer `Qt::Key | Qt::Modifier` value
|
||||||
/// and an xkbcommon keysym, joined with `+`.
|
/// KGlobalAccel's DBus `setShortcut` takes. Covered like [`Self::row`]:
|
||||||
pub fn portal_trigger(&self) -> String {
|
/// `tests::every_key_has_a_qt_code` proves the mapping total over
|
||||||
|
/// [`KEYS`], so a new row cannot reach KDE as a panic.
|
||||||
|
///
|
||||||
|
/// This and the GTK spelling below exist for the desktops
|
||||||
|
/// `crate::shortcut_setup` writes to, all of which are unix; off unix
|
||||||
|
/// nothing calls them and the lint would say so.
|
||||||
|
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||||
|
pub fn qt_key_code(&self) -> u32 {
|
||||||
|
let mut code = qt_key(self.key);
|
||||||
|
for (held, bit) in [
|
||||||
|
(self.shift, 0x0200_0000),
|
||||||
|
(self.ctrl, 0x0400_0000),
|
||||||
|
(self.alt, 0x0800_0000),
|
||||||
|
] {
|
||||||
|
if held {
|
||||||
|
code |= bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accelerator in GTK's syntax — `<Ctrl><Shift>f` — which is what a
|
||||||
|
/// GNOME custom keybinding's `binding` key stores. GTK keyval names are
|
||||||
|
/// the X11 keysym names, so the keysym column serves both spellings.
|
||||||
|
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||||
|
pub fn gtk_accelerator(&self) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for (held, name) in [
|
for (held, name) in [
|
||||||
(self.ctrl, "CTRL"),
|
(self.ctrl, "<Ctrl>"),
|
||||||
(self.alt, "ALT"),
|
(self.alt, "<Alt>"),
|
||||||
(self.shift, "SHIFT"),
|
(self.shift, "<Shift>"),
|
||||||
] {
|
] {
|
||||||
if held {
|
if held {
|
||||||
out.push_str(name);
|
out.push_str(name);
|
||||||
out.push('+');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push_str(self.row().1);
|
out.push_str(self.row().1);
|
||||||
|
|
@ -232,6 +256,90 @@ impl FromStr for Binding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `Qt::Key` value for a bindable key. Printable keys are their ASCII
|
||||||
|
/// uppercase; the named keys are Qt's `0x0100_00xx` block. A fourth [`KEYS`]
|
||||||
|
/// column in all but layout: kept as a match so the table stays readable,
|
||||||
|
/// with `tests::every_key_has_a_qt_code` holding the two together.
|
||||||
|
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||||
|
fn qt_key(key: Key) -> u32 {
|
||||||
|
match key {
|
||||||
|
Key::A => 0x41,
|
||||||
|
Key::B => 0x42,
|
||||||
|
Key::C => 0x43,
|
||||||
|
Key::D => 0x44,
|
||||||
|
Key::E => 0x45,
|
||||||
|
Key::F => 0x46,
|
||||||
|
Key::G => 0x47,
|
||||||
|
Key::H => 0x48,
|
||||||
|
Key::I => 0x49,
|
||||||
|
Key::J => 0x4A,
|
||||||
|
Key::K => 0x4B,
|
||||||
|
Key::L => 0x4C,
|
||||||
|
Key::M => 0x4D,
|
||||||
|
Key::N => 0x4E,
|
||||||
|
Key::O => 0x4F,
|
||||||
|
Key::P => 0x50,
|
||||||
|
Key::Q => 0x51,
|
||||||
|
Key::R => 0x52,
|
||||||
|
Key::S => 0x53,
|
||||||
|
Key::T => 0x54,
|
||||||
|
Key::U => 0x55,
|
||||||
|
Key::V => 0x56,
|
||||||
|
Key::W => 0x57,
|
||||||
|
Key::X => 0x58,
|
||||||
|
Key::Y => 0x59,
|
||||||
|
Key::Z => 0x5A,
|
||||||
|
Key::Num0 => 0x30,
|
||||||
|
Key::Num1 => 0x31,
|
||||||
|
Key::Num2 => 0x32,
|
||||||
|
Key::Num3 => 0x33,
|
||||||
|
Key::Num4 => 0x34,
|
||||||
|
Key::Num5 => 0x35,
|
||||||
|
Key::Num6 => 0x36,
|
||||||
|
Key::Num7 => 0x37,
|
||||||
|
Key::Num8 => 0x38,
|
||||||
|
Key::Num9 => 0x39,
|
||||||
|
Key::F1 => 0x0100_0030,
|
||||||
|
Key::F2 => 0x0100_0031,
|
||||||
|
Key::F3 => 0x0100_0032,
|
||||||
|
Key::F4 => 0x0100_0033,
|
||||||
|
Key::F5 => 0x0100_0034,
|
||||||
|
Key::F6 => 0x0100_0035,
|
||||||
|
Key::F7 => 0x0100_0036,
|
||||||
|
Key::F8 => 0x0100_0037,
|
||||||
|
Key::F9 => 0x0100_0038,
|
||||||
|
Key::F10 => 0x0100_0039,
|
||||||
|
Key::F11 => 0x0100_003A,
|
||||||
|
Key::F12 => 0x0100_003B,
|
||||||
|
Key::Space => 0x20,
|
||||||
|
Key::Enter => 0x0100_0004,
|
||||||
|
Key::Tab => 0x0100_0001,
|
||||||
|
Key::Backspace => 0x0100_0003,
|
||||||
|
Key::Delete => 0x0100_0007,
|
||||||
|
Key::Insert => 0x0100_0006,
|
||||||
|
Key::Home => 0x0100_0010,
|
||||||
|
Key::End => 0x0100_0011,
|
||||||
|
Key::PageUp => 0x0100_0016,
|
||||||
|
Key::PageDown => 0x0100_0017,
|
||||||
|
Key::ArrowUp => 0x0100_0013,
|
||||||
|
Key::ArrowDown => 0x0100_0015,
|
||||||
|
Key::ArrowLeft => 0x0100_0012,
|
||||||
|
Key::ArrowRight => 0x0100_0014,
|
||||||
|
Key::Comma => 0x2C,
|
||||||
|
Key::Period => 0x2E,
|
||||||
|
Key::Slash => 0x2F,
|
||||||
|
Key::Backslash => 0x5C,
|
||||||
|
Key::Semicolon => 0x3B,
|
||||||
|
Key::Quote => 0x27,
|
||||||
|
Key::Backtick => 0x60,
|
||||||
|
Key::Minus => 0x2D,
|
||||||
|
Key::Equals => 0x3D,
|
||||||
|
Key::OpenBracket => 0x5B,
|
||||||
|
Key::CloseBracket => 0x5D,
|
||||||
|
other => unreachable!("{other:?} is not in KEYS; see every_key_has_a_qt_code"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Empty means "no shortcut" rather than an error.
|
/// Empty means "no shortcut" rather than an error.
|
||||||
pub fn parse_setting(setting: &str) -> Result<Option<Binding>, BindingError> {
|
pub fn parse_setting(setting: &str) -> Result<Option<Binding>, BindingError> {
|
||||||
if setting.trim().is_empty() {
|
if setting.trim().is_empty() {
|
||||||
|
|
@ -249,7 +357,40 @@ mod tests {
|
||||||
let cfg = quicksearch_core::config::UiConfig::default();
|
let cfg = quicksearch_core::config::UiConfig::default();
|
||||||
let binding: Binding = cfg.search_hotkey.parse().expect("the default is valid");
|
let binding: Binding = cfg.search_hotkey.parse().expect("the default is valid");
|
||||||
assert_eq!(binding.to_string(), "Ctrl+Shift+F");
|
assert_eq!(binding.to_string(), "Ctrl+Shift+F");
|
||||||
assert_eq!(binding.portal_trigger(), "CTRL+SHIFT+f");
|
assert_eq!(binding.gtk_accelerator(), "<Ctrl><Shift>f");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `qt_key`'s `unreachable!` is only sound while every row of [`KEYS`]
|
||||||
|
/// has an arm; this is what holds the match and the table together.
|
||||||
|
#[test]
|
||||||
|
fn every_key_has_a_qt_code() {
|
||||||
|
for (key, token, _) in KEYS {
|
||||||
|
let code = qt_key(*key);
|
||||||
|
assert_ne!(code, 0, "{token} has no Qt key code");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The values KGlobalAccel actually receives, spot-checked against
|
||||||
|
/// `Qt::Key`: the default binding (verified live against Plasma 6.6),
|
||||||
|
/// a named key, and a punctuation key.
|
||||||
|
#[test]
|
||||||
|
fn qt_key_codes_match_qt() {
|
||||||
|
let binding: Binding = "Ctrl+Shift+F".parse().unwrap();
|
||||||
|
assert_eq!(binding.qt_key_code(), 0x0600_0046);
|
||||||
|
let binding: Binding = "Alt+PageUp".parse().unwrap();
|
||||||
|
assert_eq!(binding.qt_key_code(), 0x0900_0016);
|
||||||
|
let binding: Binding = "Ctrl+Comma".parse().unwrap();
|
||||||
|
assert_eq!(binding.qt_key_code(), 0x0400_002C);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keysym column doubles as the GTK keyval, so a named key must come
|
||||||
|
/// out under GTK's name for it, not egui's.
|
||||||
|
#[test]
|
||||||
|
fn gtk_accelerators_use_keysym_names() {
|
||||||
|
let binding: Binding = "Ctrl+Alt+PageUp".parse().unwrap();
|
||||||
|
assert_eq!(binding.gtk_accelerator(), "<Ctrl><Alt>Prior");
|
||||||
|
let binding: Binding = "Shift+Enter".parse().unwrap();
|
||||||
|
assert_eq!(binding.gtk_accelerator(), "<Shift>Return");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -295,7 +436,7 @@ mod tests {
|
||||||
fn modifiers_are_ordered_and_case_insensitive() {
|
fn modifiers_are_ordered_and_case_insensitive() {
|
||||||
let binding: Binding = "shift+ALT+ctrl+f".parse().unwrap();
|
let binding: Binding = "shift+ALT+ctrl+f".parse().unwrap();
|
||||||
assert_eq!(binding.to_string(), "Ctrl+Alt+Shift+F");
|
assert_eq!(binding.to_string(), "Ctrl+Alt+Shift+F");
|
||||||
assert_eq!(binding.portal_trigger(), "CTRL+ALT+SHIFT+f");
|
assert_eq!(binding.gtk_accelerator(), "<Ctrl><Alt><Shift>f");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
" Ctrl + Shift + F ".parse::<Binding>(),
|
" Ctrl + Shift + F ".parse::<Binding>(),
|
||||||
Ok(binding_of("Ctrl+Shift+F"))
|
Ok(binding_of("Ctrl+Shift+F"))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
//! The in-application half of the search shortcut: the key QuickSearch
|
//! The in-application half of the search shortcut: the key QuickSearch
|
||||||
//! claims for itself while it is running. Windows and X11 grant that via
|
//! claims for itself while it is running. Windows and X11 grant that via
|
||||||
//! `global-hotkey` (`RegisterHotKey`/`XGrabKey`); Wayland refuses grabs by
|
//! `global-hotkey` (`RegisterHotKey`/`XGrabKey`). Wayland refuses grabs by
|
||||||
//! design, so it goes through the XDG portal and the *desktop* picks the key.
|
//! design and gets no in-app half at all ([`Status::DesktopOnly`]): the XDG
|
||||||
|
//! GlobalShortcuts portal was tried and removed — it prompted a dialog on
|
||||||
|
//! every launch, bound an action that only worked while the app ran, and
|
||||||
|
//! its persisted claim on the key blocked the desktop's own launch binding.
|
||||||
|
//! On Wayland the desktop's binding (`crate::shortcut_setup`, `--toggle`)
|
||||||
|
//! is the whole story.
|
||||||
//!
|
//!
|
||||||
//! This is the path that needs no setup at all, and it is why the Settings
|
//! This is the path that needs no setup at all, and it is why the Settings
|
||||||
//! tab can offer an arbitrary combination on every platform. It cannot fire
|
//! tab can offer an arbitrary combination on every platform. It cannot fire
|
||||||
|
|
@ -10,14 +15,17 @@
|
||||||
//! Both funnel into the same pending flag, so the window comes forward the
|
//! Both funnel into the same pending flag, so the window comes forward the
|
||||||
//! same way whichever one fired.
|
//! same way whichever one fired.
|
||||||
//!
|
//!
|
||||||
|
//! When a [`crate::shortcut_setup`] system binding is installed, this half
|
||||||
|
//! stands down entirely ([`Status::SystemOwned`]): the desktop delivers the
|
||||||
|
//! key whether QuickSearch is running or not, and holding our own claim as
|
||||||
|
//! well would fight it for the key.
|
||||||
|
//!
|
||||||
//! Held in a thread-local global rather than a field: the registration is
|
//! Held in a thread-local global rather than a field: the registration is
|
||||||
//! process-wide, the event handler is set-once, and on Windows
|
//! process-wide, the event handler is set-once, and on Windows
|
||||||
//! `GlobalHotKeyManager` is not `Send`. Every entry point is inert until
|
//! `GlobalHotKeyManager` is not `Send`. Every entry point is inert until
|
||||||
//! [`init`] runs, so headless UI tests never touch an OS registration.
|
//! [`init`] runs, so headless UI tests never touch an OS registration.
|
||||||
|
|
||||||
mod binding;
|
mod binding;
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
|
||||||
mod portal;
|
|
||||||
|
|
||||||
pub use binding::{parse_setting, Binding};
|
pub use binding::{parse_setting, Binding};
|
||||||
|
|
||||||
|
|
@ -35,30 +43,31 @@ thread_local! {
|
||||||
pub enum Status {
|
pub enum Status {
|
||||||
Disabled,
|
Disabled,
|
||||||
Active,
|
Active,
|
||||||
/// Asked for; the desktop has not answered yet.
|
/// Wayland without a system binding: the compositor refuses in-app
|
||||||
Pending,
|
/// grabs, so only the desktop's own binding can deliver the key.
|
||||||
/// Wayland: described in the desktop's own words, because the desktop,
|
DesktopOnly,
|
||||||
/// not the setting, decides the key.
|
/// A `crate::shortcut_setup` binding is installed, so the desktop owns
|
||||||
PortalBound(String),
|
/// the key and nothing is registered in-app: every press arrives through
|
||||||
|
/// `--toggle`, which also works while QuickSearch is closed. Registering
|
||||||
|
/// here as well would fight the desktop over the key.
|
||||||
|
SystemOwned,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Registry {
|
struct Registry {
|
||||||
backend: Backend,
|
backend: Backend,
|
||||||
/// Everything except the portal, which reports its own asynchronously.
|
|
||||||
status: Status,
|
status: Status,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Backend {
|
enum Backend {
|
||||||
/// Nothing registered: no shortcut set, or the backend never started.
|
/// Nothing registered: no shortcut set, a Wayland session (see the
|
||||||
|
/// module docs), or the backend never started.
|
||||||
Idle,
|
Idle,
|
||||||
/// Windows and X11.
|
/// Windows and X11.
|
||||||
Grab {
|
Grab {
|
||||||
manager: GlobalHotKeyManager,
|
manager: GlobalHotKeyManager,
|
||||||
registered: Option<HotKey>,
|
registered: Option<HotKey>,
|
||||||
},
|
},
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
|
||||||
Portal(portal::Portal),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the shortcut and register `setting`. Must be called on the
|
/// Start the shortcut and register `setting`. Must be called on the
|
||||||
|
|
@ -73,7 +82,7 @@ pub fn init(ctx: &egui::Context, setting: &str) {
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let backend = match choose_backend(ctx) {
|
let backend = match choose_backend() {
|
||||||
Ok(backend) => backend,
|
Ok(backend) => backend,
|
||||||
Err(message) => {
|
Err(message) => {
|
||||||
quicksearch_core::log_warn!("global shortcut: {}", message);
|
quicksearch_core::log_warn!("global shortcut: {}", message);
|
||||||
|
|
@ -103,6 +112,22 @@ pub fn apply(setting: &str) {
|
||||||
let Some(registry) = slot.as_mut() else {
|
let Some(registry) = slot.as_mut() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// The desktop's own binding outranks ours: release whatever is held
|
||||||
|
// and stand down (see `Status::SystemOwned`). Checked here rather
|
||||||
|
// than by the callers so init and every save agree; the probe is a
|
||||||
|
// file check on KDE and a one-off subprocess on GNOME, and `apply`
|
||||||
|
// only runs on saves.
|
||||||
|
if crate::shortcut_setup::installed() {
|
||||||
|
let _ = registry.backend.register(None);
|
||||||
|
registry.status = Status::SystemOwned;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Wayland with nothing installed: there is nothing to register —
|
||||||
|
// the compositor refuses grabs — so say where the key has to live.
|
||||||
|
if crate::activate::raise::is_wayland() {
|
||||||
|
registry.status = Status::DesktopOnly;
|
||||||
|
return;
|
||||||
|
}
|
||||||
let wanted = match parse_setting(setting) {
|
let wanted = match parse_setting(setting) {
|
||||||
Ok(binding) => binding,
|
Ok(binding) => binding,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -131,12 +156,6 @@ pub fn apply(setting: &str) {
|
||||||
pub fn status() -> Status {
|
pub fn status() -> Status {
|
||||||
REGISTRY.with_borrow(|slot| match slot.as_ref() {
|
REGISTRY.with_borrow(|slot| match slot.as_ref() {
|
||||||
None => Status::Disabled,
|
None => Status::Disabled,
|
||||||
// The portal answers on its own schedule and keeps its own status.
|
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
|
||||||
Some(Registry {
|
|
||||||
backend: Backend::Portal(portal),
|
|
||||||
..
|
|
||||||
}) => portal.status(),
|
|
||||||
Some(registry) => registry.status.clone(),
|
Some(registry) => registry.status.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -179,31 +198,21 @@ impl Backend {
|
||||||
*registered = Some(hotkey);
|
*registered = Some(hotkey);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
|
||||||
Backend::Portal(portal) => {
|
|
||||||
portal.bind(wanted.map(|b| b.portal_trigger()));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Wayland session gets the portal, everything else a grab. No falling
|
/// A Wayland session gets no backend at all — the desktop's binding is the
|
||||||
/// back: an X11 grab inside a Wayland session succeeds and then only fires
|
/// Wayland path; see the module docs. No X11 grab as a fallback either: it
|
||||||
/// while an XWayland window has focus — a broken-looking shortcut.
|
/// succeeds inside a Wayland session and then only fires while an XWayland
|
||||||
#[cfg(all(unix, not(target_os = "macos")))]
|
/// window has focus — a broken-looking shortcut.
|
||||||
fn choose_backend(ctx: &egui::Context) -> Result<Backend, String> {
|
fn choose_backend() -> Result<Backend, String> {
|
||||||
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
if crate::activate::raise::is_wayland() {
|
||||||
return Ok(Backend::Portal(portal::Portal::new(ctx)));
|
return Ok(Backend::Idle);
|
||||||
}
|
}
|
||||||
grab_backend()
|
grab_backend()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
|
||||||
fn choose_backend(_ctx: &egui::Context) -> Result<Backend, String> {
|
|
||||||
grab_backend()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn grab_backend() -> Result<Backend, String> {
|
fn grab_backend() -> Result<Backend, String> {
|
||||||
GlobalHotKeyManager::new()
|
GlobalHotKeyManager::new()
|
||||||
.map(|manager| Backend::Grab {
|
.map(|manager| Backend::Grab {
|
||||||
|
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
//! The Wayland half of the shortcut: `org.freedesktop.portal.GlobalShortcuts`.
|
|
||||||
//!
|
|
||||||
//! **The desktop owns the binding**: what we send is a `preferred_trigger`,
|
|
||||||
//! and the compositor may bind something else or ask the user; what it bound
|
|
||||||
//! comes back as a `trigger_description`, which the Settings tab shows.
|
|
||||||
//!
|
|
||||||
//! All of this lives on its own thread — a portal call is a D-Bus round trip
|
|
||||||
//! that can block as long as a dialog stays up. The session must stay open
|
|
||||||
//! for activations to keep arriving; dropping it is how a rebind starts over.
|
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use ashpd::desktop::global_shortcuts::{GlobalShortcuts, NewShortcut};
|
|
||||||
use ashpd::desktop::Session;
|
|
||||||
use futures_channel::mpsc;
|
|
||||||
use futures_util::future::{select, Either};
|
|
||||||
use futures_util::StreamExt;
|
|
||||||
|
|
||||||
use super::Status;
|
|
||||||
|
|
||||||
/// The portal keys activations by this id; the desktop lists the entry by it.
|
|
||||||
const SHORTCUT_ID: &str = "search";
|
|
||||||
|
|
||||||
/// Shown next to the key in the desktop's shortcut settings.
|
|
||||||
const SHORTCUT_DESCRIPTION: &str = "Focus the QuickSearch search box";
|
|
||||||
|
|
||||||
pub(super) struct Portal {
|
|
||||||
/// `Some(trigger)` binds, `None` unbinds. Unbounded: sends happen on
|
|
||||||
/// the UI thread and must never block it.
|
|
||||||
tx: mpsc::UnboundedSender<Option<String>>,
|
|
||||||
status: Arc<Mutex<Status>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Portal {
|
|
||||||
/// The thread runs until the process exits; nothing to shut down.
|
|
||||||
pub(super) fn new(ctx: &egui::Context) -> Portal {
|
|
||||||
let (tx, rx) = mpsc::unbounded();
|
|
||||||
let status = Arc::new(Mutex::new(Status::Pending));
|
|
||||||
let portal = Portal {
|
|
||||||
tx,
|
|
||||||
status: Arc::clone(&status),
|
|
||||||
};
|
|
||||||
let ctx = ctx.clone();
|
|
||||||
if let Err(e) = std::thread::Builder::new()
|
|
||||||
.name("quicksearch-hotkey-portal".to_string())
|
|
||||||
.spawn(move || pollster::block_on(run(ctx, status, rx)))
|
|
||||||
{
|
|
||||||
// The status must say so, or the Settings tab shows
|
|
||||||
// "Waiting for your desktop…" forever.
|
|
||||||
quicksearch_core::log_warn!("global shortcut portal thread: {}", e);
|
|
||||||
*lock_ok(&portal.status) =
|
|
||||||
Status::Error(format!("the shortcut thread could not be started: {}", e));
|
|
||||||
}
|
|
||||||
portal
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns immediately; the answer lands in [`Portal::status`]
|
|
||||||
/// whenever the desktop gets to it.
|
|
||||||
pub(super) fn bind(&self, trigger: Option<String>) {
|
|
||||||
*lock_ok(&self.status) = match trigger {
|
|
||||||
Some(_) => Status::Pending,
|
|
||||||
None => Status::Disabled,
|
|
||||||
};
|
|
||||||
let _ = self.tx.unbounded_send(trigger);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn status(&self) -> Status {
|
|
||||||
lock_ok(&self.status).clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ignore poisoning: a portal-thread panic must not take the UI thread too.
|
|
||||||
fn lock_ok<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
||||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run(
|
|
||||||
ctx: egui::Context,
|
|
||||||
status: Arc<Mutex<Status>>,
|
|
||||||
mut commands: mpsc::UnboundedReceiver<Option<String>>,
|
|
||||||
) {
|
|
||||||
let shortcuts: GlobalShortcuts<'static> = match GlobalShortcuts::new().await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => return fail(&ctx, &status, unavailable(&e)),
|
|
||||||
};
|
|
||||||
// A signal match on the interface, not a session: survives rebinds.
|
|
||||||
let activated = match shortcuts.receive_activated().await {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(e) => return fail(&ctx, &status, unavailable(&e)),
|
|
||||||
};
|
|
||||||
futures_util::pin_mut!(activated);
|
|
||||||
|
|
||||||
let mut session: Option<Session<'static, GlobalShortcuts<'static>>> = None;
|
|
||||||
loop {
|
|
||||||
match select(activated.next(), commands.next()).await {
|
|
||||||
Either::Left((Some(_), _)) => {
|
|
||||||
// No id check needed: this session has exactly one shortcut.
|
|
||||||
super::fire(&ctx);
|
|
||||||
}
|
|
||||||
// The portal went away; the session is already dead.
|
|
||||||
Either::Left((None, _)) => {
|
|
||||||
return fail(
|
|
||||||
&ctx,
|
|
||||||
&status,
|
|
||||||
"the desktop's global shortcuts service stopped".to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Either::Right((Some(trigger), _)) => {
|
|
||||||
// A rebind is a new session: the portal treats a session's
|
|
||||||
// shortcuts as fixed once bound.
|
|
||||||
if let Some(old) = session.take() {
|
|
||||||
let _ = old.close().await;
|
|
||||||
}
|
|
||||||
let next = match &trigger {
|
|
||||||
None => {
|
|
||||||
set(&ctx, &status, Status::Disabled);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Some(trigger) => match bind(&shortcuts, trigger).await {
|
|
||||||
Ok((session, description)) => {
|
|
||||||
set(&ctx, &status, Status::PortalBound(description));
|
|
||||||
Some(session)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
fail(&ctx, &status, unavailable(&e));
|
|
||||||
None
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
session = next;
|
|
||||||
}
|
|
||||||
// The registry dropped the sender: we are on the way out.
|
|
||||||
Either::Right((None, _)) => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind the trigger; returns the desktop's own wording for what it settled on.
|
|
||||||
async fn bind(
|
|
||||||
shortcuts: &GlobalShortcuts<'static>,
|
|
||||||
trigger: &str,
|
|
||||||
) -> Result<(Session<'static, GlobalShortcuts<'static>>, String), ashpd::Error> {
|
|
||||||
let session = shortcuts.create_session().await?;
|
|
||||||
let shortcut =
|
|
||||||
NewShortcut::new(SHORTCUT_ID, SHORTCUT_DESCRIPTION).preferred_trigger(Some(trigger));
|
|
||||||
let request = shortcuts
|
|
||||||
.bind_shortcuts(&session, &[shortcut], None)
|
|
||||||
.await?;
|
|
||||||
let bound = request.response()?;
|
|
||||||
// A blank description falls back to the preferred trigger.
|
|
||||||
let description = bound
|
|
||||||
.shortcuts()
|
|
||||||
.iter()
|
|
||||||
.find(|s| s.id() == SHORTCUT_ID)
|
|
||||||
.map(|s| s.trigger_description().to_string())
|
|
||||||
.filter(|d| !d.trim().is_empty())
|
|
||||||
.unwrap_or_else(|| trigger.to_string());
|
|
||||||
Ok((session, description))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unavailable(e: &ashpd::Error) -> String {
|
|
||||||
match e {
|
|
||||||
ashpd::Error::PortalNotFound(_) => {
|
|
||||||
"this desktop does not offer the global shortcuts portal".to_string()
|
|
||||||
}
|
|
||||||
ashpd::Error::RequiresVersion(required, found) => format!(
|
|
||||||
"this desktop's global shortcuts portal is version {}, and {} is needed",
|
|
||||||
found, required
|
|
||||||
),
|
|
||||||
ashpd::Error::Response(_) => "the desktop declined the shortcut".to_string(),
|
|
||||||
other => other.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set(ctx: &egui::Context, status: &Mutex<Status>, next: Status) {
|
|
||||||
*lock_ok(status) = next;
|
|
||||||
ctx.request_repaint();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fail(ctx: &egui::Context, status: &Mutex<Status>, message: String) {
|
|
||||||
quicksearch_core::log_warn!("global shortcut: {}", message);
|
|
||||||
set(ctx, status, Status::Error(message));
|
|
||||||
}
|
|
||||||
|
|
@ -30,6 +30,7 @@ mod platform;
|
||||||
mod query_highlight;
|
mod query_highlight;
|
||||||
mod search_tab;
|
mod search_tab;
|
||||||
mod settings_tab;
|
mod settings_tab;
|
||||||
|
mod shortcut_setup;
|
||||||
mod spotlight;
|
mod spotlight;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test_ui;
|
mod test_ui;
|
||||||
|
|
@ -118,8 +119,17 @@ fn main() {
|
||||||
// Losing the race to an instance that came up between the signal
|
// Losing the race to an instance that came up between the signal
|
||||||
// above and here, or a plain second launch: either way the user
|
// above and here, or a plain second launch: either way the user
|
||||||
// asked to see QuickSearch, and there is one to show them.
|
// asked to see QuickSearch, and there is one to show them.
|
||||||
if activate::signal(&Config::config_path()) {
|
//
|
||||||
return;
|
// Retried, not tried once: the winner holds the lock the moment
|
||||||
|
// `main` reaches it but only listens once eframe's creation
|
||||||
|
// closure has run, so a `--toggle` landing in that gap would see
|
||||||
|
// the lock held and no socket. Two seconds outlasts that gap by
|
||||||
|
// orders of magnitude; a wedged instance still gets the dialog.
|
||||||
|
for _ in 0..20 {
|
||||||
|
if activate::signal(&Config::config_path()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
let who = match pid {
|
let who = match pid {
|
||||||
Some(pid) => format!(" (process {})", pid),
|
Some(pid) => format!(" (process {})", pid),
|
||||||
|
|
|
||||||
|
|
@ -447,8 +447,8 @@ impl ManageTab {
|
||||||
egui::TextEdit::multiline(&mut self.ext_filter_text)
|
egui::TextEdit::multiline(&mut self.ext_filter_text)
|
||||||
.desired_rows(4)
|
.desired_rows(4)
|
||||||
.desired_width(f32::INFINITY)
|
.desired_width(f32::INFINITY)
|
||||||
.hint_text("#EXAMPLE WHITELISTED FILE EXTENSIONS FOR FULL-TEXT-SEARCH:\n#MOUSE \
|
.hint_text("#EXAMPLE WHITELISTED FILE EXTENSIONS FOR FULL-TEXT-SEARCH\n#MOUSE \
|
||||||
OVER FOR MORE INFO\n#------------------------------------------------\ntxt\nmd\n \
|
OVER FOR MORE INFO\ntxt\nmd\n \
|
||||||
pdf # comments allowed\n(none)"),
|
pdf # comments allowed\n(none)"),
|
||||||
)
|
)
|
||||||
.tip(&tips::EXT_WHITELIST);
|
.tip(&tips::EXT_WHITELIST);
|
||||||
|
|
@ -735,7 +735,7 @@ fn waiting_row(ui: &mut egui::Ui, label: &str, elapsed: Duration) {
|
||||||
/// `elapsed` is `Some` for a run's prologue; the between-runs pass has none.
|
/// `elapsed` is `Some` for a run's prologue; the between-runs pass has none.
|
||||||
fn reconcile_row(ui: &mut egui::Ui, r: &ReconcileProgress, elapsed: Option<Duration>) {
|
fn reconcile_row(ui: &mut egui::Ui, r: &ReconcileProgress, elapsed: Option<Duration>) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Applying configuration change");
|
ui.label("Rebuilding FTS cache").tip(&tips::REBUILDING_FTS);
|
||||||
ui.label(egui::RichText::new("|").weak());
|
ui.label(egui::RichText::new("|").weak());
|
||||||
match (r.total, r.fraction()) {
|
match (r.total, r.fraction()) {
|
||||||
(Some(total), Some(frac)) => {
|
(Some(total), Some(frac)) => {
|
||||||
|
|
|
||||||
|
|
@ -628,7 +628,7 @@ fn a_reconcile_reports_how_far_through_the_index_it_is() {
|
||||||
)
|
)
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
|
|
||||||
assert!(text.contains("Applying configuration change"), "{}", text);
|
assert!(text.contains("Rebuilding FTS cache"), "{}", text);
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("2,500,000 / 8,000,000 (31%) entries checked"),
|
text.contains("2,500,000 / 8,000,000 (31%) entries checked"),
|
||||||
"{}",
|
"{}",
|
||||||
|
|
@ -637,6 +637,41 @@ fn a_reconcile_reports_how_far_through_the_index_it_is() {
|
||||||
assert!(text.contains("1,204 entries removed"), "{}", text);
|
assert!(text.contains("1,204 entries removed"), "{}", text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pass can run for minutes with the window unresponsive, so the line
|
||||||
|
/// that names it has to be where the explanation is reachable from.
|
||||||
|
#[test]
|
||||||
|
fn the_reconcile_status_explains_the_wait_on_hover() {
|
||||||
|
let ctx = crate::test_ui::ctx();
|
||||||
|
ctx.style_mut(|s| {
|
||||||
|
s.interaction.tooltip_delay = 0.0;
|
||||||
|
s.interaction.show_tooltips_only_when_still = false;
|
||||||
|
});
|
||||||
|
let mut tab = ManageTab::new();
|
||||||
|
let cfg = cfg_with_root();
|
||||||
|
let state = preparing_state(PrepStep::Reconciling(ReconcileProgress::default()));
|
||||||
|
let mut run = |events: Vec<egui::Event>| {
|
||||||
|
ctx.run(raw_input(events), |ctx| {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
tab.ui(ui, &state, &cfg);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
run(vec![]);
|
||||||
|
let settled = run(vec![]);
|
||||||
|
let pos = crate::test_ui::painted_text_center(&settled, "Rebuilding FTS cache")
|
||||||
|
.expect("status line painted");
|
||||||
|
let opening: String = crate::tips::REBUILDING_FTS.body.chars().take(40).collect();
|
||||||
|
let mut out = run(vec![egui::Event::PointerMoved(pos)]);
|
||||||
|
for _ in 0..3 {
|
||||||
|
if painted_text(&out).join("\n").contains(&opening) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out = run(vec![]);
|
||||||
|
}
|
||||||
|
panic!("no tooltip on the status line: {:?}", painted_text(&out));
|
||||||
|
}
|
||||||
|
|
||||||
/// Whole-range deletions read no rows; the display must not invent a denominator.
|
/// Whole-range deletions read no rows; the display must not invent a denominator.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_reconcile_without_a_row_count_shows_no_denominator() {
|
fn a_reconcile_without_a_row_count_shows_no_denominator() {
|
||||||
|
|
@ -669,7 +704,7 @@ fn a_prune_between_runs_is_reported_instead_of_idle() {
|
||||||
|
|
||||||
let text = frame_text(&ctx, &mut tab, &state).join(" | ");
|
let text = frame_text(&ctx, &mut tab, &state).join(" | ");
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("Applying configuration change"),
|
text.contains("Rebuilding FTS cache"),
|
||||||
"the scan is invisible: {}",
|
"the scan is invisible: {}",
|
||||||
text
|
text
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,7 @@ impl SettingsTab {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey);
|
hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey);
|
||||||
shortcut_note(ui);
|
shortcut_note(ui, ¤t.ui.search_hotkey);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// Security acts on the live config, not the draft; the KDF
|
// Security acts on the live config, not the draft; the KDF
|
||||||
|
|
@ -378,7 +378,7 @@ pub(crate) fn hotkey_edit(
|
||||||
let p = crate::color::palette(ui.visuals().dark_mode);
|
let p = crate::color::palette(ui.visuals().dark_mode);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let label = if *capturing {
|
let label = if *capturing {
|
||||||
"Press a key combination...".to_string()
|
"Press a key combination…".to_string()
|
||||||
} else if setting.trim().is_empty() {
|
} else if setting.trim().is_empty() {
|
||||||
"None".to_string()
|
"None".to_string()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -446,32 +446,12 @@ fn read_capture(ui: &egui::Ui) -> Option<Option<crate::hotkey::Binding>> {
|
||||||
/// Silent while registered and working; a line appears only when what is on
|
/// Silent while registered and working; a line appears only when what is on
|
||||||
/// the button is not what is in force.
|
/// the button is not what is in force.
|
||||||
fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
|
fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
|
||||||
use crate::hotkey::Status;
|
let (text, is_error) = if draft.trim() != live.trim() {
|
||||||
let (text, color) = if draft.trim() != live.trim() {
|
("Not registered until Apply and Save.".to_string(), false)
|
||||||
("Not registered until Apply and Save.".to_string(), None)
|
|
||||||
} else {
|
} else {
|
||||||
match crate::hotkey::status() {
|
hotkey_status_line(crate::hotkey::status())
|
||||||
Status::Disabled | Status::Active => (String::new(), None),
|
|
||||||
Status::Pending => (
|
|
||||||
"Waiting for your desktop to accept the shortcut.".to_string(),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
Status::PortalBound(trigger) => (
|
|
||||||
format!(
|
|
||||||
"Your desktop registered this as {}. It has the final say; \
|
|
||||||
change it in its own keyboard settings. On Wayland it also \
|
|
||||||
decides whether the window comes forward, so a minimised \
|
|
||||||
window may stay minimised.",
|
|
||||||
trigger
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
Status::Error(why) => (
|
|
||||||
format!("The shortcut is not active: {}.", why),
|
|
||||||
Some(crate::color::palette(ui.visuals().dark_mode).orange),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
let color = is_error.then(|| crate::color::palette(ui.visuals().dark_mode).orange);
|
||||||
crate::ui_util::stable_section(ui, |ui| {
|
crate::ui_util::stable_section(ui, |ui| {
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return;
|
return;
|
||||||
|
|
@ -484,30 +464,179 @@ fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The sentence for a registration state, and whether it is an error worth
|
||||||
|
/// the warning colour. Split from the rendering so the states are testable.
|
||||||
|
fn hotkey_status_line(status: crate::hotkey::Status) -> (String, bool) {
|
||||||
|
use crate::hotkey::Status;
|
||||||
|
match status {
|
||||||
|
Status::Disabled | Status::Active => (String::new(), false),
|
||||||
|
Status::SystemOwned => (
|
||||||
|
"Your desktop delivers this key: it starts QuickSearch when \
|
||||||
|
closed and brings it forward when running."
|
||||||
|
.to_string(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Status::DesktopOnly => (
|
||||||
|
"On Wayland only your desktop can hold a global key. Use \
|
||||||
|
\"Set up system shortcut\" below, or bind the command shown \
|
||||||
|
there by hand."
|
||||||
|
.to_string(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
// On Windows a failed registration is the *expected* state whenever
|
||||||
|
// the Start-menu shortcut owns the same key — Explorer registers it
|
||||||
|
// at logon, wins, and every press then reaches us through the
|
||||||
|
// `--toggle` relay anyway. An error color would cry wolf on every
|
||||||
|
// installed copy.
|
||||||
|
Status::Error(why) if cfg!(windows) => (
|
||||||
|
format!(
|
||||||
|
"Another program holds this key ({}) — usually the Start \
|
||||||
|
menu shortcut that starts QuickSearch, which also brings \
|
||||||
|
it forward while it is running. If the key does nothing, \
|
||||||
|
pick a different combination.",
|
||||||
|
why
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
Status::Error(why) => (format!("The shortcut is not active: {}.", why), true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How to get a shortcut that also *starts* QuickSearch.
|
/// How to get a shortcut that also *starts* QuickSearch.
|
||||||
///
|
///
|
||||||
/// The shortcut above is ours and needs no setup, but it cannot fire while
|
/// The shortcut above is ours and needs no setup, but it cannot fire while
|
||||||
/// QuickSearch is not running — see `crate::activate`. Only the desktop can
|
/// QuickSearch is not running — see `crate::activate`. Only the desktop can
|
||||||
/// bind a key that launches something, so this says what to bind and opens
|
/// bind a key that launches something. Where the desktop's own configuration
|
||||||
/// the place to bind it. Writing the desktop's own configuration instead was
|
/// has a known home for that binding (`crate::shortcut_setup`), one button
|
||||||
/// considered and rejected: it differs per desktop and between versions of
|
/// writes it — into the place the desktop's settings UI lists and edits, so
|
||||||
/// the same one, and a shortcut we wrote and the user cannot see is worse
|
/// it stays the user's to see and change. Everywhere else this says what to
|
||||||
/// than one they created.
|
/// bind and opens the place to bind it.
|
||||||
|
///
|
||||||
|
/// `hotkey_setting` is the shortcut in force, which the one-click binding
|
||||||
|
/// mirrors system-wide.
|
||||||
///
|
///
|
||||||
/// Shared with the tour's shortcut page, which puts it under the same
|
/// Shared with the tour's shortcut page, which puts it under the same
|
||||||
/// shortcut button this sentence says is "above".
|
/// shortcut button this sentence says is "above".
|
||||||
pub(crate) fn shortcut_note(ui: &mut egui::Ui) {
|
pub(crate) fn shortcut_note(ui: &mut egui::Ui, hotkey_setting: &str) {
|
||||||
|
// Tests reach this through the tour's shortcut page; probing the real
|
||||||
|
// desktop there would make them depend on the machine they run on and
|
||||||
|
// spawn `gsettings`. Tests that want a desktop pin one through
|
||||||
|
// `shortcut_note_for`.
|
||||||
|
let desktop = if cfg!(test) {
|
||||||
|
crate::shortcut_setup::Desktop::Unsupported
|
||||||
|
} else {
|
||||||
|
crate::shortcut_setup::detect()
|
||||||
|
};
|
||||||
|
shortcut_note_for(ui, hotkey_setting, desktop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One frame's answer from [`crate::shortcut_setup`], cached: `installed`
|
||||||
|
/// probes the desktop with a subprocess, which must not run per frame.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SystemShortcutState {
|
||||||
|
installed: bool,
|
||||||
|
/// The last install/remove outcome, `(succeeded, what to say)`.
|
||||||
|
feedback: Option<(bool, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The desktop split out of the environment so tests can pick one.
|
||||||
|
fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::shortcut_setup::Desktop) {
|
||||||
let command = format!("{} --toggle", crate::activate::command_name());
|
let command = format!("{} --toggle", crate::activate::command_name());
|
||||||
crate::ui_util::stable_section(ui, |ui| {
|
crate::ui_util::stable_section(ui, |ui| {
|
||||||
ui.label(
|
// One-click only with a key to write: an unset or unparseable
|
||||||
egui::RichText::new(
|
// shortcut leaves nothing to bind system-wide.
|
||||||
"The shortcut above works while QuickSearch is open. To have a key \
|
let binding = crate::hotkey::parse_setting(hotkey_setting).ok().flatten();
|
||||||
start it as well, bind this command in your desktop's keyboard \
|
let one_click = binding.filter(|_| desktop != crate::shortcut_setup::Desktop::Unsupported);
|
||||||
settings:",
|
|
||||||
)
|
if let Some(binding) = one_click {
|
||||||
.small()
|
// One id for the settings tab and the tour: they show one fact.
|
||||||
.weak(),
|
let id = egui::Id::new("system-shortcut-state");
|
||||||
);
|
let mut state = ui
|
||||||
|
.data_mut(|d| d.get_temp::<SystemShortcutState>(id))
|
||||||
|
.unwrap_or_else(|| SystemShortcutState {
|
||||||
|
installed: crate::shortcut_setup::installed(),
|
||||||
|
feedback: None,
|
||||||
|
});
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"The shortcut above works while QuickSearch is open. Your \
|
||||||
|
desktop can also bind it to start QuickSearch when it is \
|
||||||
|
not:",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
if state.installed {
|
||||||
|
ui.label(egui::RichText::new("The system shortcut is set up.").small());
|
||||||
|
if ui.add(egui::Button::new("Remove").small()).clicked() {
|
||||||
|
match crate::shortcut_setup::remove() {
|
||||||
|
Ok(()) => {
|
||||||
|
state.installed = false;
|
||||||
|
state.feedback =
|
||||||
|
Some((true, "System shortcut removed.".to_string()));
|
||||||
|
// Take the key back in-app now, not at the
|
||||||
|
// next save.
|
||||||
|
crate::hotkey::apply(hotkey_setting);
|
||||||
|
}
|
||||||
|
Err(e) => state.feedback = Some((false, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ui
|
||||||
|
.add(egui::Button::new(format!("Set up {} system-wide", binding)).small())
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
match crate::shortcut_setup::install(&binding) {
|
||||||
|
Ok(when) => {
|
||||||
|
state.installed = true;
|
||||||
|
state.feedback = Some((
|
||||||
|
true,
|
||||||
|
match when {
|
||||||
|
crate::shortcut_setup::Installed::Immediately => {
|
||||||
|
"Added to your desktop's keyboard shortcuts."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
crate::shortcut_setup::Installed::AfterRelogin => {
|
||||||
|
"Added to your desktop's keyboard shortcuts; \
|
||||||
|
the key starts answering after you next log in."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
// Stand the in-app registration down at once so
|
||||||
|
// the desktop's binding is not fought for the key
|
||||||
|
// (see `hotkey::Status::SystemOwned`).
|
||||||
|
crate::hotkey::apply(hotkey_setting);
|
||||||
|
}
|
||||||
|
Err(e) => state.feedback = Some((false, e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some((ok, text)) = &state.feedback {
|
||||||
|
let rich = egui::RichText::new(text).small();
|
||||||
|
ui.label(if *ok {
|
||||||
|
rich.weak()
|
||||||
|
} else {
|
||||||
|
rich.color(crate::color::palette(ui.visuals().dark_mode).orange)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.data_mut(|d| d.insert_temp(id, state));
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("Or bind this command there yourself:")
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"The shortcut above works while QuickSearch is open. To have a key \
|
||||||
|
start it as well, bind this command in your desktop's keyboard \
|
||||||
|
settings:",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui.label(egui::RichText::new(&command).small().monospace());
|
ui.label(egui::RichText::new(&command).small().monospace());
|
||||||
if ui.add(egui::Button::new("Copy").small()).clicked() {
|
if ui.add(egui::Button::new("Copy").small()).clicked() {
|
||||||
|
|
@ -522,9 +651,11 @@ pub(crate) fn shortcut_note(ui: &mut egui::Ui) {
|
||||||
if crate::activate::raise::is_wayland() {
|
if crate::activate::raise::is_wayland() {
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(
|
egui::RichText::new(
|
||||||
"On Wayland a window that is already open cannot be raised by \
|
"On Wayland, focus follows the shortcut's activation token: \
|
||||||
another process, so the shortcut will highlight QuickSearch in \
|
the binding set up above passes one along, so QuickSearch \
|
||||||
the task bar rather than bring it to the front.",
|
comes to the front. A binding made by hand whose launcher \
|
||||||
|
provides no token can only highlight QuickSearch in the \
|
||||||
|
task bar.",
|
||||||
)
|
)
|
||||||
.small()
|
.small()
|
||||||
.weak(),
|
.weak(),
|
||||||
|
|
@ -580,30 +711,30 @@ fn security_ui(
|
||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui
|
if ui
|
||||||
.button("Change password…")
|
.button("Change password")
|
||||||
.tip(&tips::CHANGE_PASSWORD)
|
.tip(&tips::CHANGE_PASSWORD)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
action = Some(SecurityAction::ChangePassword);
|
action = Some(SecurityAction::ChangePassword);
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
.button("Disable protection…")
|
.button("Disable protection")
|
||||||
.tip(&tips::DISABLE_PASSWORD)
|
.tip(&tips::DISABLE_PASSWORD)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
action = Some(SecurityAction::Disable);
|
action = Some(SecurityAction::Disable);
|
||||||
}
|
}
|
||||||
|
// The raw key is for someone recovering the file by hand; the
|
||||||
|
// password controls beside it are for everyone.
|
||||||
|
if form.advanced
|
||||||
|
&& ui
|
||||||
|
.button("Show database key")
|
||||||
|
.tip(&tips::SHOW_KEY)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
action = Some(SecurityAction::ShowKey);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// The raw key is for someone recovering the file by hand; the password
|
|
||||||
// controls above it are for everyone.
|
|
||||||
if form.advanced
|
|
||||||
&& ui
|
|
||||||
.button("Show database key…")
|
|
||||||
.tip(&tips::SHOW_KEY)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
action = Some(SecurityAction::ShowKey);
|
|
||||||
}
|
|
||||||
let mut remember = current.security.use_keychain;
|
let mut remember = current.security.use_keychain;
|
||||||
if ui
|
if ui
|
||||||
.checkbox(&mut remember, "Remember on this device")
|
.checkbox(&mut remember, "Remember on this device")
|
||||||
|
|
@ -615,7 +746,7 @@ fn security_ui(
|
||||||
} else {
|
} else {
|
||||||
ui.label("The index is not encrypted.");
|
ui.label("The index is not encrypted.");
|
||||||
if ui
|
if ui
|
||||||
.button("Enable password protection…")
|
.button("Enable password protection")
|
||||||
.tip(&tips::ENABLE_PASSWORD)
|
.tip(&tips::ENABLE_PASSWORD)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -559,7 +559,7 @@ fn the_key_button_appears_only_while_the_index_is_encrypted() {
|
||||||
|
|
||||||
let (_, full) = run_security(&ctx, &cfg, vec![]);
|
let (_, full) = run_security(&ctx, &cfg, vec![]);
|
||||||
assert!(
|
assert!(
|
||||||
painted_text_center(&full, "Show database key…").is_none(),
|
painted_text_center(&full, "Show database key").is_none(),
|
||||||
"offered the key of an unencrypted index: {:?}",
|
"offered the key of an unencrypted index: {:?}",
|
||||||
painted_text(&full)
|
painted_text(&full)
|
||||||
);
|
);
|
||||||
|
|
@ -567,7 +567,7 @@ fn the_key_button_appears_only_while_the_index_is_encrypted() {
|
||||||
cfg.security.password_protected = true;
|
cfg.security.password_protected = true;
|
||||||
let (_, full) = run_security(&ctx, &cfg, vec![]);
|
let (_, full) = run_security(&ctx, &cfg, vec![]);
|
||||||
assert!(
|
assert!(
|
||||||
painted_text_center(&full, "Show database key…").is_some(),
|
painted_text_center(&full, "Show database key").is_some(),
|
||||||
"no key button while encrypted: {:?}",
|
"no key button while encrypted: {:?}",
|
||||||
painted_text(&full)
|
painted_text(&full)
|
||||||
);
|
);
|
||||||
|
|
@ -587,7 +587,7 @@ fn clicking_the_key_button_reports_show_key() {
|
||||||
|
|
||||||
let (quiet, full) = run_security(&ctx, &cfg, vec![]);
|
let (quiet, full) = run_security(&ctx, &cfg, vec![]);
|
||||||
assert!(quiet.is_none(), "reported an action nobody clicked");
|
assert!(quiet.is_none(), "reported an action nobody clicked");
|
||||||
let target = painted_text_center(&full, "Show database key…").expect("no key button");
|
let target = painted_text_center(&full, "Show database key").expect("no key button");
|
||||||
|
|
||||||
let (action, _) = run_security(&ctx, &cfg, click_at(target));
|
let (action, _) = run_security(&ctx, &cfg, click_at(target));
|
||||||
assert_eq!(action, Some(SecurityAction::ShowKey));
|
assert_eq!(action, Some(SecurityAction::ShowKey));
|
||||||
|
|
@ -729,14 +729,42 @@ fn showing_advanced_settings_is_not_an_unsaved_edit() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each registration state gets its sentence; only a real failure (and not
|
||||||
|
/// Windows' expected .lnk contention) earns the warning colour.
|
||||||
|
#[test]
|
||||||
|
fn the_hotkey_status_lines_match_their_states() {
|
||||||
|
use crate::hotkey::Status;
|
||||||
|
let (none, err) = super::hotkey_status_line(Status::Active);
|
||||||
|
assert!(none.is_empty() && !err);
|
||||||
|
let (owned, err) = super::hotkey_status_line(Status::SystemOwned);
|
||||||
|
assert!(
|
||||||
|
owned.contains("starts QuickSearch when closed"),
|
||||||
|
"{owned}"
|
||||||
|
);
|
||||||
|
assert!(!err, "SystemOwned is the working state, not a warning");
|
||||||
|
let (desktop, err) = super::hotkey_status_line(Status::DesktopOnly);
|
||||||
|
assert!(desktop.contains("Set up system shortcut"), "{desktop}");
|
||||||
|
assert!(!err, "DesktopOnly is a pointer, not a failure");
|
||||||
|
let (error, err) = super::hotkey_status_line(Status::Error("taken".to_string()));
|
||||||
|
assert!(error.contains("taken"));
|
||||||
|
assert_eq!(err, !cfg!(windows), "only non-Windows colours the failure");
|
||||||
|
}
|
||||||
|
|
||||||
/// The panel that tells a user how to get a shortcut that also starts
|
/// The panel that tells a user how to get a shortcut that also starts
|
||||||
/// QuickSearch has to actually show the command they must bind.
|
/// QuickSearch has to actually show the command they must bind — with or
|
||||||
|
/// without a one-click desktop to lean on.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_shortcut_note_names_the_command_to_bind() {
|
fn the_shortcut_note_names_the_command_to_bind() {
|
||||||
let ctx = crate::test_ui::ctx();
|
let ctx = crate::test_ui::ctx();
|
||||||
let input = crate::test_ui::raw_input(egui::vec2(700.0, 300.0), vec![]);
|
let input = crate::test_ui::raw_input(egui::vec2(700.0, 300.0), vec![]);
|
||||||
let out = ctx.run(input, |ctx| {
|
let out = ctx.run(input, |ctx| {
|
||||||
egui::CentralPanel::default().show(ctx, |ui| super::shortcut_note(ui));
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
super::shortcut_note_for(
|
||||||
|
ui,
|
||||||
|
"Ctrl+Shift+F",
|
||||||
|
crate::shortcut_setup::Desktop::Unsupported,
|
||||||
|
)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
let painted = painted_text(&out).join("\n");
|
let painted = painted_text(&out).join("\n");
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -745,3 +773,70 @@ fn the_shortcut_note_names_the_command_to_bind() {
|
||||||
);
|
);
|
||||||
assert!(painted.contains("Copy"), "no way to copy it: {painted}");
|
assert!(painted.contains("Copy"), "no way to copy it: {painted}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// On a desktop we can write, the note leads with the one-click button (the
|
||||||
|
/// probe is skipped by seeding the cached state, so the test stays
|
||||||
|
/// hermetic), and the manual command stays as the fallback.
|
||||||
|
#[test]
|
||||||
|
fn a_supported_desktop_gets_the_one_click_button() {
|
||||||
|
let ctx = crate::test_ui::ctx();
|
||||||
|
ctx.data_mut(|d| {
|
||||||
|
d.insert_temp(
|
||||||
|
egui::Id::new("system-shortcut-state"),
|
||||||
|
super::SystemShortcutState {
|
||||||
|
installed: false,
|
||||||
|
feedback: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let input = crate::test_ui::raw_input(egui::vec2(700.0, 300.0), vec![]);
|
||||||
|
let out = ctx.run(input, |ctx| {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
super::shortcut_note_for(ui, "Ctrl+Shift+F", crate::shortcut_setup::Desktop::Gnome)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let painted = painted_text(&out).join("\n");
|
||||||
|
assert!(
|
||||||
|
painted.contains("Set up Ctrl+Shift+F system-wide"),
|
||||||
|
"no one-click button: {painted}"
|
||||||
|
);
|
||||||
|
assert!(painted.contains("--toggle"), "the fallback vanished: {painted}");
|
||||||
|
|
||||||
|
// Already installed: the button flips to removal.
|
||||||
|
ctx.data_mut(|d| {
|
||||||
|
d.insert_temp(
|
||||||
|
egui::Id::new("system-shortcut-state"),
|
||||||
|
super::SystemShortcutState {
|
||||||
|
installed: true,
|
||||||
|
feedback: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let input = crate::test_ui::raw_input(egui::vec2(700.0, 300.0), vec![]);
|
||||||
|
let out = ctx.run(input, |ctx| {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
super::shortcut_note_for(ui, "Ctrl+Shift+F", crate::shortcut_setup::Desktop::Gnome)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let painted = painted_text(&out).join("\n");
|
||||||
|
assert!(painted.contains("Remove"), "no removal offered: {painted}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No usable binding means nothing to write: the one-click flow bows out
|
||||||
|
/// even on a supported desktop.
|
||||||
|
#[test]
|
||||||
|
fn no_binding_means_no_one_click_button() {
|
||||||
|
let ctx = crate::test_ui::ctx();
|
||||||
|
let input = crate::test_ui::raw_input(egui::vec2(700.0, 300.0), vec![]);
|
||||||
|
let out = ctx.run(input, |ctx| {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
super::shortcut_note_for(ui, "", crate::shortcut_setup::Desktop::Gnome)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let painted = painted_text(&out).join("\n");
|
||||||
|
assert!(
|
||||||
|
!painted.contains("system-wide"),
|
||||||
|
"offered to bind nothing: {painted}"
|
||||||
|
);
|
||||||
|
assert!(painted.contains("--toggle"), "the manual flow vanished: {painted}");
|
||||||
|
}
|
||||||
|
|
|
||||||
705
crates/quicksearch-gui/src/shortcut_setup.rs
Normal file
705
crates/quicksearch-gui/src/shortcut_setup.rs
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
//! Writing the system-wide search shortcut into the desktop's own keyboard
|
||||||
|
//! configuration, so "bind this command yourself" becomes one click.
|
||||||
|
//!
|
||||||
|
//! Auto-writing was once rejected here on the grounds that a shortcut the
|
||||||
|
//! user cannot see is worse than one they created. The answer is to write it
|
||||||
|
//! exactly where the desktop's own settings UI lists and edits it — GNOME's
|
||||||
|
//! custom shortcuts, KDE's global shortcuts — so the binding stays the
|
||||||
|
//! user's to inspect, change or delete. Desktops without a known home for a
|
||||||
|
//! binding keep the manual copy-the-command flow in
|
||||||
|
//! `crate::settings_tab::shortcut_note`.
|
||||||
|
//!
|
||||||
|
//! On Windows the closed-app binding lives in the Start-menu `.lnk` the
|
||||||
|
//! installer creates, so there is nothing to *create* from here; what this
|
||||||
|
//! module does is keep that `.lnk`'s hotkey in step with the in-app one via
|
||||||
|
//! [`hotkey_changed`].
|
||||||
|
|
||||||
|
use crate::hotkey::Binding;
|
||||||
|
|
||||||
|
/// Where a binding can be written. `Unsupported` hides the one-click button
|
||||||
|
/// and leaves the manual flow.
|
||||||
|
///
|
||||||
|
/// Off unix the two named desktops are unreachable — [`detect`] answers
|
||||||
|
/// `Unsupported` and the dispatchers below never name them — so the variants
|
||||||
|
/// exist there only to keep this one enum for every platform.
|
||||||
|
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Desktop {
|
||||||
|
Gnome,
|
||||||
|
Kde,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect() -> Desktop {
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
desktop_for(&std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default())
|
||||||
|
}
|
||||||
|
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||||
|
{
|
||||||
|
Desktop::Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mapping itself, split from the environment so it can be tested
|
||||||
|
/// without one; the same shape as `platform::keyboard_settings_for`.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn desktop_for(desktops: &str) -> Desktop {
|
||||||
|
for desktop in desktops.split(':') {
|
||||||
|
match desktop.to_ascii_uppercase().as_str() {
|
||||||
|
"GNOME" | "UNITY" => return Desktop::Gnome,
|
||||||
|
"KDE" => return Desktop::Kde,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Desktop::Unsupported
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the binding this module writes is currently present. Asks the
|
||||||
|
/// desktop, so callers should cache rather than poll every frame.
|
||||||
|
///
|
||||||
|
/// The three functions here are split by platform the same way [`detect`] is:
|
||||||
|
/// the desktops that have a home for a binding are all unix, and their modules
|
||||||
|
/// only exist there, so naming them off unix would not compile.
|
||||||
|
pub fn installed() -> bool {
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
match detect() {
|
||||||
|
Desktop::Gnome => gnome::installed(),
|
||||||
|
Desktop::Kde => kde::installed(),
|
||||||
|
Desktop::Unsupported => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A successful install, and when the key starts answering.
|
||||||
|
///
|
||||||
|
/// Off unix nothing installs, so nothing constructs these; the match arms
|
||||||
|
/// in the Settings tab still name them on every platform.
|
||||||
|
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Installed {
|
||||||
|
Immediately,
|
||||||
|
/// Durably written, but the desktop only picks it up at the next login
|
||||||
|
/// (KDE with no `kbuildsycoca` on the PATH).
|
||||||
|
AfterRelogin,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `binding` → `quicksearch --toggle` into the desktop's keyboard
|
||||||
|
/// configuration. Idempotent: a second install rewrites the same entry.
|
||||||
|
pub fn install(binding: &Binding) -> Result<Installed, String> {
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
let command = toggle_command();
|
||||||
|
match detect() {
|
||||||
|
Desktop::Gnome => gnome::install(binding, &command).map(|()| Installed::Immediately),
|
||||||
|
Desktop::Kde => kde::install(binding, &command),
|
||||||
|
Desktop::Unsupported => Err(UNSUPPORTED.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||||
|
{
|
||||||
|
let _ = binding;
|
||||||
|
Err(UNSUPPORTED.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What every platform without a home for a binding answers. On Windows the
|
||||||
|
/// closed-app binding is the installer's `.lnk`, so there is nothing here to
|
||||||
|
/// write and the Settings tab shows the manual note instead.
|
||||||
|
const UNSUPPORTED: &str = "this desktop is not supported";
|
||||||
|
|
||||||
|
/// The command line the key runs, quoted for a path with spaces in it —
|
||||||
|
/// both GNOME's `command` key and a desktop file's `Exec` split on
|
||||||
|
/// whitespace and honour double quotes.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn toggle_command() -> String {
|
||||||
|
let exe = crate::activate::command_name();
|
||||||
|
if exe.contains(char::is_whitespace) {
|
||||||
|
format!("\"{}\" --toggle", exe)
|
||||||
|
} else {
|
||||||
|
format!("{} --toggle", exe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the entry [`install`] wrote; a no-op if it is already gone.
|
||||||
|
pub fn remove() -> Result<(), String> {
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
match detect() {
|
||||||
|
Desktop::Gnome => gnome::remove(),
|
||||||
|
Desktop::Kde => kde::remove(),
|
||||||
|
Desktop::Unsupported => Err(UNSUPPORTED.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||||
|
{
|
||||||
|
Err(UNSUPPORTED.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The in-app shortcut was rebound: keep the system-wide binding in step.
|
||||||
|
/// Best-effort — a failure is logged, not surfaced, because the change the
|
||||||
|
/// user asked for (the in-app key) already succeeded.
|
||||||
|
pub fn hotkey_changed(setting: &str) {
|
||||||
|
let Ok(Some(binding)) = crate::hotkey::parse_setting(setting) else {
|
||||||
|
// Cleared or unparseable: leave the system binding alone rather than
|
||||||
|
// guess; the Settings tab's own controls are the way to remove it.
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Err(e) = lnk::update_hotkey(&binding) {
|
||||||
|
quicksearch_core::log_warn!("updating the Start menu shortcut key: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if installed() {
|
||||||
|
if let Err(e) = install(&binding) {
|
||||||
|
quicksearch_core::log_warn!("updating the system search shortcut: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a program to completion and fail loudly, with its stderr as the why.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn run(program: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
let out = std::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("running {}: {}", program, e))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
return Err(format!("{} failed: {}", program, stderr.trim()));
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GNOME: a custom keybinding under a path of our own. The fixed path is
|
||||||
|
/// what makes install idempotent and removal exact, and the entry shows up
|
||||||
|
/// in Settings → Keyboard → Custom Shortcuts under the name given here.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
mod gnome {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const LIST_SCHEMA: &str = "org.gnome.settings-daemon.plugins.media-keys";
|
||||||
|
const LIST_KEY: &str = "custom-keybindings";
|
||||||
|
const ENTRY_SCHEMA: &str = "org.gnome.settings-daemon.plugins.media-keys.custom-keybinding";
|
||||||
|
pub(super) const ENTRY_PATH: &str =
|
||||||
|
"/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/quicksearch-search/";
|
||||||
|
|
||||||
|
pub(super) fn installed() -> bool {
|
||||||
|
run("gsettings", &["get", LIST_SCHEMA, LIST_KEY])
|
||||||
|
.map(|list| parse_string_list(&list).iter().any(|p| p == ENTRY_PATH))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn install(binding: &Binding, command: &str) -> Result<(), String> {
|
||||||
|
let entry = format!("{}:{}", ENTRY_SCHEMA, ENTRY_PATH);
|
||||||
|
for (key, value) in [
|
||||||
|
("name", "QuickSearch".to_string()),
|
||||||
|
("command", command.to_string()),
|
||||||
|
("binding", binding.gtk_accelerator()),
|
||||||
|
] {
|
||||||
|
run("gsettings", &["set", &entry, key, &value])?;
|
||||||
|
}
|
||||||
|
// The entry only takes effect once its path is on the list; last, so
|
||||||
|
// a failure above cannot leave a listed entry with no command.
|
||||||
|
let list = run("gsettings", &["get", LIST_SCHEMA, LIST_KEY])?;
|
||||||
|
let mut paths = parse_string_list(&list);
|
||||||
|
if !paths.iter().any(|p| p == ENTRY_PATH) {
|
||||||
|
paths.push(ENTRY_PATH.to_string());
|
||||||
|
let list = format_string_list(&paths);
|
||||||
|
run("gsettings", &["set", LIST_SCHEMA, LIST_KEY, &list])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove() -> Result<(), String> {
|
||||||
|
let list = run("gsettings", &["get", LIST_SCHEMA, LIST_KEY])?;
|
||||||
|
let paths: Vec<String> = parse_string_list(&list)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|p| p != ENTRY_PATH)
|
||||||
|
.collect();
|
||||||
|
let list = format_string_list(&paths);
|
||||||
|
run("gsettings", &["set", LIST_SCHEMA, LIST_KEY, &list])?;
|
||||||
|
let entry = format!("{}:{}", ENTRY_SCHEMA, ENTRY_PATH);
|
||||||
|
run("gsettings", &["reset-recursively", &entry])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The GVariant `as` (array of strings) spelling `gsettings get` prints and
|
||||||
|
/// `gsettings set` accepts: `['a', 'b']`, or `@as []` when empty.
|
||||||
|
///
|
||||||
|
/// The parser accepts exactly what gsettings emits — single-quoted strings
|
||||||
|
/// with `\'` and `\\` escapes — and drops anything malformed rather than
|
||||||
|
/// guessing: a path we misread would be written back verbatim into the
|
||||||
|
/// user's configuration.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn parse_string_list(raw: &str) -> Vec<String> {
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
let mut current = None;
|
||||||
|
let mut escaped = false;
|
||||||
|
for ch in raw.chars() {
|
||||||
|
match current.as_mut() {
|
||||||
|
None => {
|
||||||
|
if ch == '\'' {
|
||||||
|
current = Some(String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(path) => {
|
||||||
|
if escaped {
|
||||||
|
path.push(ch);
|
||||||
|
escaped = false;
|
||||||
|
} else if ch == '\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if ch == '\'' {
|
||||||
|
paths.push(current.take().expect("current is Some in this arm"));
|
||||||
|
} else {
|
||||||
|
path.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
fn format_string_list(paths: &[String]) -> String {
|
||||||
|
if paths.is_empty() {
|
||||||
|
// A bare `[]` has no type; this is the empty list gsettings prints.
|
||||||
|
return "@as []".to_string();
|
||||||
|
}
|
||||||
|
let quoted: Vec<String> = paths
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("'{}'", p.replace('\\', "\\\\").replace('\'', "\\'")))
|
||||||
|
.collect();
|
||||||
|
format!("[{}]", quoted.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KDE: a desktop file carrying its own key, discovered through the service
|
||||||
|
/// database. Grounded in the kglobalacceld source (v6.6.5,
|
||||||
|
/// `globalshortcutsregistry.cpp`) after three approaches failed in the field:
|
||||||
|
///
|
||||||
|
/// * DBus `doRegister`+`setShortcut` records a mapping but can never *arm*
|
||||||
|
/// it: `getOrCreateComponent` deliberately skips `loadSettings`, and only
|
||||||
|
/// that path reaches `registerKey` (the actual grab). Worse, the claim is
|
||||||
|
/// bound to the registering connection and dropped when it exits.
|
||||||
|
/// * Editing `kglobalshortcutsrc` around a daemon restart loses a race on
|
||||||
|
/// any live desktop: KDE clients DBus-activate the daemon back within
|
||||||
|
/// milliseconds of `stop`, before the edit lands, and its debounced
|
||||||
|
/// `writeSettings` then erases the entry.
|
||||||
|
/// * The daemon's startup scan of `~/.local/share/kglobalaccel/` skips
|
||||||
|
/// `NoDisplay=true` files outright.
|
||||||
|
///
|
||||||
|
/// What does work, restart-free: `detectAppsWithShortcuts()` queries the
|
||||||
|
/// service database for applications whose desktop file carries
|
||||||
|
/// `X-KDE-Shortcuts=`, arming their `_launch` — and it runs both at daemon
|
||||||
|
/// startup and at runtime on every `KSycoca::databaseChanged`. So install
|
||||||
|
/// writes a hidden desktop entry with the key inside it into
|
||||||
|
/// `~/.local/share/applications/` and pokes `kbuildsycoca6`; the daemon
|
||||||
|
/// arms it live, and every later login re-arms it from the same detection.
|
||||||
|
/// The file is also this module's "installed" marker. `unregister` over
|
||||||
|
/// DBus disarms live (verified by synthesized keypress), so removal needs
|
||||||
|
/// no database round trip to take effect.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
mod kde {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// The service's storage id — its filename — which is also the
|
||||||
|
/// component name every DBus query answers with.
|
||||||
|
const COMPONENT: &str = "quicksearch-search.desktop";
|
||||||
|
const ACTION: &str = "_launch";
|
||||||
|
|
||||||
|
/// One `gdbus call` against the daemon. gdbus over qdbus because it
|
||||||
|
/// takes GVariant text for the list arguments, which qdbus cannot spell.
|
||||||
|
fn call(method: &str, args: &[&str]) -> Result<String, String> {
|
||||||
|
let method = format!("org.kde.KGlobalAccel.{}", method);
|
||||||
|
let mut argv = vec![
|
||||||
|
"call",
|
||||||
|
"--session",
|
||||||
|
"--dest",
|
||||||
|
"org.kde.kglobalaccel",
|
||||||
|
"--object-path",
|
||||||
|
"/kglobalaccel",
|
||||||
|
"--method",
|
||||||
|
&method,
|
||||||
|
];
|
||||||
|
argv.extend(args);
|
||||||
|
run("gdbus", &argv)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_dir() -> PathBuf {
|
||||||
|
std::env::var_os("XDG_DATA_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| p.is_absolute())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
|
||||||
|
.join(".local/share")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In the applications directory — the one place the service database
|
||||||
|
/// indexes, which is what `detectAppsWithShortcuts` queries.
|
||||||
|
pub(super) fn desktop_file() -> PathBuf {
|
||||||
|
data_dir().join("applications").join(COMPONENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an earlier build put the file; deleted on sight so upgrades
|
||||||
|
/// leave one binding, not two.
|
||||||
|
fn legacy_desktop_file() -> PathBuf {
|
||||||
|
data_dir().join("kglobalaccel").join(COMPONENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NoDisplay` keeps it out of menus next to the real QuickSearch entry
|
||||||
|
/// (the shortcut detection reads `X-KDE-Shortcuts` regardless);
|
||||||
|
/// `X-KDE-Shortcuts` is the key itself, in QKeySequence text — the
|
||||||
|
/// daemon arms `_launch` with it wherever the service turns up.
|
||||||
|
pub(super) fn desktop_entry(command: &str, binding: &Binding) -> String {
|
||||||
|
format!(
|
||||||
|
"[Desktop Entry]\nType=Application\nName=QuickSearch\nNoDisplay=true\n\
|
||||||
|
Exec={}\nX-KDE-Shortcuts={}\n",
|
||||||
|
command, binding
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn installed() -> bool {
|
||||||
|
desktop_file().is_file() || legacy_desktop_file().is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn install(binding: &Binding, command: &str) -> Result<super::Installed, String> {
|
||||||
|
let code = binding.qt_key_code();
|
||||||
|
// Evict any claim of *ours* on the key first — above all the
|
||||||
|
// popup-bound portal shortcut ("Focus the QuickSearch search box")
|
||||||
|
// that builds which still registered in-app on Wayland left behind:
|
||||||
|
// its persisted claim keeps the launch binding from ever firing.
|
||||||
|
// Looked up rather than guessed — the daemon says who holds the key
|
||||||
|
// — and a foreign application's claim is never touched.
|
||||||
|
for holder in holders_of(code) {
|
||||||
|
if holder.is_ours() && holder.component != COMPONENT {
|
||||||
|
let _ = call("unregister", &[&holder.component, &holder.action]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A key another application still claims is reported before anything
|
||||||
|
// is registered: the daemon happily records two claims and then
|
||||||
|
// delivers the press to neither, which read as "set up" and did
|
||||||
|
// nothing (the user's "View Full Screen Mode" collision).
|
||||||
|
if let Some(holder) = holders_of(code).iter().find(|h| !h.is_ours()) {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is taken by \"{}\" ({}); free it under System Settings → \
|
||||||
|
Keyboard → Shortcuts, or pick a different combination",
|
||||||
|
binding, holder.action_friendly, holder.component_friendly,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// A re-install must start from nothing. The daemon's service refresh
|
||||||
|
// only *drops* components whose file vanished — it never re-reads a
|
||||||
|
// changed one, and a component surviving `unregister` empty blocks
|
||||||
|
// re-detection by name — so an install over an existing binding
|
||||||
|
// would keep serving the old Exec line (fatal for an AppImage,
|
||||||
|
// whose old mount path died with the last run). Tear down like
|
||||||
|
// `remove` does and let the daemon notice before rebuilding.
|
||||||
|
if desktop_file().is_file() || legacy_desktop_file().is_file() {
|
||||||
|
let _ = call("unregister", &[COMPONENT, ACTION]);
|
||||||
|
let _ = std::fs::remove_file(desktop_file());
|
||||||
|
let _ = std::fs::remove_file(legacy_desktop_file());
|
||||||
|
if run("kbuildsycoca6", &[]).or_else(|_| run("kbuildsycoca5", &[])).is_ok() {
|
||||||
|
// Gone when getComponent stops answering; bounded, and a
|
||||||
|
// timeout just falls through to the rebuild below.
|
||||||
|
for _ in 0..10 {
|
||||||
|
if call("getComponent", &[COMPONENT]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No marker file, but an earlier claim may still be registered.
|
||||||
|
let _ = call("unregister", &[COMPONENT, ACTION]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file is the whole registration — key included — and the
|
||||||
|
// installed marker, so a failure below deletes it again.
|
||||||
|
let path = desktop_file();
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)
|
||||||
|
.map_err(|e| format!("creating {}: {}", dir.display(), e))?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, desktop_entry(command, binding))
|
||||||
|
.map_err(|e| format!("writing {}: {}", path.display(), e))?;
|
||||||
|
// Executable, matching the trust rules KIO applies to desktop files
|
||||||
|
// before launching what they name.
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
|
||||||
|
.map_err(|e| format!("marking {} executable: {}", path.display(), e))?;
|
||||||
|
|
||||||
|
// The service database rebuild is what tells the running daemon: it
|
||||||
|
// arms new X-KDE-Shortcuts services on every database-changed
|
||||||
|
// signal (see the module docs). Without the tool the file still
|
||||||
|
// arms at the next login, when the daemon re-detects on startup.
|
||||||
|
let rebuilt = run("kbuildsycoca6", &[])
|
||||||
|
.or_else(|_| run("kbuildsycoca5", &[]))
|
||||||
|
.is_ok();
|
||||||
|
if !rebuilt {
|
||||||
|
return Ok(super::Installed::AfterRelogin);
|
||||||
|
}
|
||||||
|
// The daemon's own routing table is the proof the key is armed;
|
||||||
|
// three earlier versions of this function reported success for keys
|
||||||
|
// that could never fire, and this check is what closes that class.
|
||||||
|
// Retried: the database-changed signal reaches the daemon
|
||||||
|
// asynchronously.
|
||||||
|
let mut armed = false;
|
||||||
|
for _ in 0..20 {
|
||||||
|
armed = call("action", &[&code.to_string()])
|
||||||
|
.map(|reply| parse_string_list(&reply).iter().any(|s| s == COMPONENT))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if armed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
}
|
||||||
|
if !armed {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let _ = run("kbuildsycoca6", &[]).or_else(|_| run("kbuildsycoca5", &[]));
|
||||||
|
return Err(format!(
|
||||||
|
"your desktop did not arm {}; if this keeps happening, add a \
|
||||||
|
shortcut for the command by hand in System Settings",
|
||||||
|
binding
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(super::Installed::Immediately)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove() -> Result<(), String> {
|
||||||
|
// Disarms live (verified by synthesized keypress); the file deletion
|
||||||
|
// plus database rebuild below is what keeps the next login from
|
||||||
|
// re-detecting it.
|
||||||
|
let _ = call("unregister", &[COMPONENT, ACTION]);
|
||||||
|
for path in [desktop_file(), legacy_desktop_file()] {
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(e) => return Err(format!("deleting {}: {}", path.display(), e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = run("kbuildsycoca6", &[]).or_else(|_| run("kbuildsycoca5", &[]));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One current owner of a key, out of `getGlobalShortcutsByKey`.
|
||||||
|
pub(super) struct Holder {
|
||||||
|
pub(super) action: String,
|
||||||
|
pub(super) action_friendly: String,
|
||||||
|
pub(super) component: String,
|
||||||
|
pub(super) component_friendly: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Holder {
|
||||||
|
/// Whether this claim is QuickSearch's own — under any of the names
|
||||||
|
/// a portal or an older build may have registered it as.
|
||||||
|
pub(super) fn is_ours(&self) -> bool {
|
||||||
|
[
|
||||||
|
&self.action,
|
||||||
|
&self.action_friendly,
|
||||||
|
&self.component,
|
||||||
|
&self.component_friendly,
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|name| name.to_ascii_lowercase().contains("quicksearch"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn holders_of(code: u32) -> Vec<Holder> {
|
||||||
|
call("getGlobalShortcutsByKey", &[&code.to_string()])
|
||||||
|
.map(|reply| parse_holders(&reply))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The holders out of a reply like
|
||||||
|
/// `([('action', 'Action', 'comp', 'Component', 'default',
|
||||||
|
/// 'Default Context', [100663366], @ai [])],)` — six strings then two
|
||||||
|
/// int lists per tuple, an order taken from the daemon itself (probed
|
||||||
|
/// live against Plasma 6.6). Short or hostile shapes yield fewer
|
||||||
|
/// holders, never a panic.
|
||||||
|
pub(super) fn parse_holders(reply: &str) -> Vec<Holder> {
|
||||||
|
super::parse_string_list(reply)
|
||||||
|
.chunks_exact(6)
|
||||||
|
.map(|names| Holder {
|
||||||
|
action: names[0].clone(),
|
||||||
|
action_friendly: names[1].clone(),
|
||||||
|
component: names[2].clone(),
|
||||||
|
component_friendly: names[3].clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Windows: the hotkey field of the Start-menu `.lnk` is the closed-app
|
||||||
|
/// binding (see `packaging/quicksearch.nsi`), rewritten through
|
||||||
|
/// `WScript.Shell` — whose `Hotkey` property takes exactly the
|
||||||
|
/// `Ctrl+Shift+F` spelling [`Binding`] displays — rather than through a COM
|
||||||
|
/// vtable of our own.
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod lnk {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn start_menu_lnk(env: &str) -> Option<PathBuf> {
|
||||||
|
let base = std::env::var_os(env)?;
|
||||||
|
let path = PathBuf::from(base).join(r"Microsoft\Windows\Start Menu\Programs\QuickSearch.lnk");
|
||||||
|
path.is_file().then_some(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_hotkey(binding: &Binding) -> Result<(), String> {
|
||||||
|
// The installer elevates and writes the all-users Start menu; a
|
||||||
|
// per-user one is checked first because that is the one this
|
||||||
|
// unelevated process can rewrite.
|
||||||
|
let lnk = start_menu_lnk("APPDATA")
|
||||||
|
.or_else(|| start_menu_lnk("ProgramData"))
|
||||||
|
.ok_or("no Start menu shortcut exists; re-run the installer")?;
|
||||||
|
let script = format!(
|
||||||
|
"$s = (New-Object -ComObject WScript.Shell).CreateShortcut('{}'); \
|
||||||
|
$s.Hotkey = '{}'; $s.Save()",
|
||||||
|
lnk.display().to_string().replace('\'', "''"),
|
||||||
|
binding,
|
||||||
|
);
|
||||||
|
let out = std::process::Command::new("powershell")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("running powershell: {}", e))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||||
|
// The common failure: the .lnk is the elevated installer's.
|
||||||
|
return Err(format!(
|
||||||
|
"could not rewrite {} ({}); if QuickSearch was installed for \
|
||||||
|
all users, re-run the installer to change the key",
|
||||||
|
lnk.display(),
|
||||||
|
stderr.trim(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn the_desktop_is_detected_from_the_list_case_insensitively() {
|
||||||
|
assert_eq!(desktop_for("GNOME"), Desktop::Gnome);
|
||||||
|
assert_eq!(desktop_for("ubuntu:GNOME"), Desktop::Gnome);
|
||||||
|
assert_eq!(desktop_for("kde"), Desktop::Kde);
|
||||||
|
assert_eq!(desktop_for("Unity"), Desktop::Gnome);
|
||||||
|
assert_eq!(desktop_for(""), Desktop::Unsupported);
|
||||||
|
assert_eq!(desktop_for("i3:sway"), Desktop::Unsupported);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round trip through the exact spellings gsettings prints.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn the_gvariant_list_round_trips() {
|
||||||
|
assert_eq!(parse_string_list("@as []"), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_string_list("[]"), Vec::<String>::new());
|
||||||
|
let two = parse_string_list("['/a/path/', '/b/path/']");
|
||||||
|
assert_eq!(two, ["/a/path/", "/b/path/"]);
|
||||||
|
assert_eq!(format_string_list(&two), "['/a/path/', '/b/path/']");
|
||||||
|
assert_eq!(format_string_list(&[]), "@as []");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A path with a quote in it must survive both directions, or the write
|
||||||
|
/// back would corrupt the user's other bindings.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn escaped_quotes_round_trip() {
|
||||||
|
let paths = vec!["/it's/".to_string(), "/back\\slash/".to_string()];
|
||||||
|
let formatted = format_string_list(&paths);
|
||||||
|
assert_eq!(parse_string_list(&formatted), paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Malicious or truncated gsettings output must never panic; at worst it
|
||||||
|
/// yields fewer paths.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn garbage_lists_parse_to_something_harmless() {
|
||||||
|
for garbage in ["", "[", "['unterminated", "not a list", "['a'", "\\"] {
|
||||||
|
let _ = parse_string_list(garbage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the KDE launch key runs: a well-formed desktop entry whose Exec
|
||||||
|
/// is the toggle command, hidden from menus.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn the_kde_desktop_entry_launches_the_toggle() {
|
||||||
|
let binding: Binding = "Ctrl+Shift+F".parse().unwrap();
|
||||||
|
let entry = kde::desktop_entry("/opt/qs/quicksearch --toggle", &binding);
|
||||||
|
assert!(entry.starts_with("[Desktop Entry]\n"));
|
||||||
|
assert!(entry.contains("Exec=/opt/qs/quicksearch --toggle\n"));
|
||||||
|
assert!(entry.contains("NoDisplay=true\n"));
|
||||||
|
// The key rides inside the file: it is what `detectAppsWithShortcuts`
|
||||||
|
// arms, with no config entry and no daemon restart.
|
||||||
|
assert!(entry.contains("X-KDE-Shortcuts=Ctrl+Shift+F\n"));
|
||||||
|
// The applications dir, because that is the one the service database
|
||||||
|
// indexes and the shortcut detection queries.
|
||||||
|
assert!(kde::desktop_file().ends_with("applications/quicksearch-search.desktop"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// The holder tuples come out in the daemon's own field order (captured
|
||||||
|
/// live from Plasma 6.6), and it is the portal's leftover entry that the
|
||||||
|
/// ours-test must recognise.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn key_holders_parse_and_ours_is_recognised() {
|
||||||
|
let reply = "([('search', 'Focus the QuickSearch search box', \
|
||||||
|
'some-portal-id', 'Portal App', 'default', \
|
||||||
|
'Default Context', [100663366], @ai [])],)";
|
||||||
|
let holders = kde::parse_holders(reply);
|
||||||
|
assert_eq!(holders.len(), 1);
|
||||||
|
assert_eq!(holders[0].action, "search");
|
||||||
|
assert_eq!(holders[0].component, "some-portal-id");
|
||||||
|
assert!(holders[0].is_ours(), "the portal leftover was not recognised");
|
||||||
|
|
||||||
|
let foreign = kde::parse_holders(
|
||||||
|
"([('copy', 'Copy Screenshot', 'org.kde.spectacle.desktop', \
|
||||||
|
'Spectacle', 'default', 'Default Context', [1], @ai [])],)",
|
||||||
|
);
|
||||||
|
assert!(!foreign[0].is_ours(), "a foreign claim must never be evicted");
|
||||||
|
|
||||||
|
assert!(kde::parse_holders("(@a(ssssssaiai) [],)").is_empty());
|
||||||
|
for garbage in ["", "([('a', 'b')],)", "no quotes at all"] {
|
||||||
|
let _ = kde::parse_holders(garbage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A path with a space would otherwise split into a broken Exec line.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn the_toggle_command_survives_spaces_in_the_path() {
|
||||||
|
// `toggle_command` reads the real exe path; both shapes it can
|
||||||
|
// produce must parse back to program + one flag.
|
||||||
|
let command = toggle_command();
|
||||||
|
assert!(command.ends_with(" --toggle"), "{command}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixed GNOME path is load-bearing twice over: idempotence and
|
||||||
|
/// exact removal both key on it.
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
#[test]
|
||||||
|
fn the_gnome_entry_path_is_fixed_and_well_formed() {
|
||||||
|
assert!(gnome::ENTRY_PATH.starts_with('/'));
|
||||||
|
assert!(gnome::ENTRY_PATH.ends_with('/'));
|
||||||
|
assert!(gnome::ENTRY_PATH.contains("quicksearch"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
//! Plain-language tooltips: every configuration control explains itself on hover.
|
//! Plain-language tooltips: every configuration control, and any status line
|
||||||
|
//! whose wait needs explaining, explains itself on hover.
|
||||||
|
|
||||||
const TIP_WIDTH: f32 = 420.0;
|
const TIP_WIDTH: f32 = 420.0;
|
||||||
|
|
||||||
|
|
@ -421,13 +422,11 @@ pub static SEARCH_HOTKEY: Tip = Tip {
|
||||||
start typing.\n\n\
|
start typing.\n\n\
|
||||||
Click the button and press the keys you want. Combine Ctrl, Alt \
|
Click the button and press the keys you want. Combine Ctrl, Alt \
|
||||||
and Shift with one other key. Clear switches the shortcut off.\n\n\
|
and Shift with one other key. Clear switches the shortcut off.\n\n\
|
||||||
This works whenever QuickSearch is running and needs no setting \
|
While QuickSearch is running it holds this key itself where the \
|
||||||
up, but it cannot start QuickSearch. For a shortcut that opens it \
|
system allows that (Windows and X11), with no setting up. A key \
|
||||||
too, bind the command shown below in your desktop's own keyboard \
|
that also starts QuickSearch has to be bound by the desktop: the \
|
||||||
settings.\n\n\
|
set-up button below does that where it can, and on Wayland the \
|
||||||
On Wayland your desktop registers the shortcut, so it may pick a \
|
desktop binding is the only kind there is.",
|
||||||
different key and owns it afterwards, and it decides whether the \
|
|
||||||
window comes forward.",
|
|
||||||
examples: &[
|
examples: &[
|
||||||
"Ctrl+Shift+F, the default, which few other programs use."
|
"Ctrl+Shift+F, the default, which few other programs use."
|
||||||
],
|
],
|
||||||
|
|
@ -506,9 +505,9 @@ pub static REMEMBER_KEYCHAIN: Tip = Tip {
|
||||||
body: "Hands the key to the password store your system already has, such \
|
body: "Hands the key to the password store your system already has, such \
|
||||||
as GNOME Keyring, KWallet, or Windows Credential Manager, so that \
|
as GNOME Keyring, KWallet, or Windows Credential Manager, so that \
|
||||||
QuickSearch can unlock the index without asking at startup.\n\n\
|
QuickSearch can unlock the index without asking at startup.\n\n\
|
||||||
The password itself is never stored, only the key worked out from \
|
The password itself is never stored, only key securely created from \
|
||||||
it, and only on this machine. Off, you type the password each time \
|
it, and only on this machine. When off you must type the password each \
|
||||||
QuickSearch starts.",
|
time QuickSearch starts.",
|
||||||
examples: &[],
|
examples: &[],
|
||||||
caution: None,
|
caution: None,
|
||||||
};
|
};
|
||||||
|
|
@ -559,6 +558,18 @@ pub static CLEAR_INDEX: Tip = Tip {
|
||||||
caution: Some("This cannot be undone: the index has to be built from scratch again."),
|
caution: Some("This cannot be undone: the index has to be built from scratch again."),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Index status --------------------------------------------------------
|
||||||
|
|
||||||
|
pub static REBUILDING_FTS: Tip = Tip {
|
||||||
|
title: "Rebuilding the full-text search cache",
|
||||||
|
body: "A setting changed that affects which text is searchable, so \
|
||||||
|
QuickSearch is modifying the search index. On a \
|
||||||
|
large index this can take several minutes, and the progress may look \
|
||||||
|
frozen while it runs. You can still search during this time.",
|
||||||
|
examples: &[],
|
||||||
|
caution: None,
|
||||||
|
};
|
||||||
|
|
||||||
// --- Manage Index tab: indexed folders -----------------------------------
|
// --- Manage Index tab: indexed folders -----------------------------------
|
||||||
|
|
||||||
pub static ADD_ROOT: Tip = Tip {
|
pub static ADD_ROOT: Tip = Tip {
|
||||||
|
|
@ -590,15 +601,12 @@ pub static REMOVE_ROOT: Tip = Tip {
|
||||||
pub static ROOT_WORKERS: Tip = Tip {
|
pub static ROOT_WORKERS: Tip = Tip {
|
||||||
title: "Workers",
|
title: "Workers",
|
||||||
body: "How many folders QuickSearch explores at once inside this indexed \
|
body: "How many folders QuickSearch explores at once inside this indexed \
|
||||||
folder. More of them finish sooner on storage that answers many \
|
folder. More workers finish sooner on faster storage or network shares, \
|
||||||
requests at a time, which network drives do especially well, but \
|
but they compete for the same disk and could slow your system.\n\n\
|
||||||
they compete for the same disk.\n\n\
|
|
||||||
auto reads 4 on local storage and 16 on a network mount. Takes \
|
auto reads 4 on local storage and 16 on a network mount. Takes \
|
||||||
effect on the next indexing run.",
|
effect on the next indexing run.",
|
||||||
examples: &[
|
examples: &[
|
||||||
"auto unless indexing is slower than you would expect.",
|
"You can override to 1-2 to keep indexing low-priority.",
|
||||||
"16 or more for a network share that is slow to answer each request.",
|
|
||||||
"2 to keep indexing out of the way on an older machine.",
|
|
||||||
],
|
],
|
||||||
caution: None,
|
caution: None,
|
||||||
};
|
};
|
||||||
|
|
@ -611,8 +619,7 @@ pub static ROOT_COUNTS: Tip = Tip {
|
||||||
files nothing could read text from (images, videos, archives, \
|
files nothing could read text from (images, videos, archives, \
|
||||||
program binaries) plus anything the extension whitelist \
|
program binaries) plus anything the extension whitelist \
|
||||||
excludes.\n\n\
|
excludes.\n\n\
|
||||||
Both are counted when an indexing run finishes, so they do not \
|
Both are updated when an indexing run finishes.",
|
||||||
move as live updates apply single changes in between.",
|
|
||||||
examples: &[],
|
examples: &[],
|
||||||
caution: None,
|
caution: None,
|
||||||
};
|
};
|
||||||
|
|
@ -621,17 +628,15 @@ pub static ROOT_COUNTS: Tip = Tip {
|
||||||
|
|
||||||
pub static EXT_WHITELIST: Tip = Tip {
|
pub static EXT_WHITELIST: Tip = Tip {
|
||||||
title: "Full-text extensions whitelist",
|
title: "Full-text extensions whitelist",
|
||||||
body: "Which kinds of file QuickSearch is allowed to read the text out \
|
body: "Which kinds of files QuickSearch is allowed to read the text out \
|
||||||
of. It limits contents only: every file is still indexed and still \
|
of. It limits contents only: every file is still indexed and still \
|
||||||
found by its name and its path, whatever you put here. A file left \
|
found by its name and its path, whatever you put here. A file left \
|
||||||
off the list simply cannot be found by the words inside it.\n\n\
|
off the list cannot be found by the text inside of it.\n\n\
|
||||||
Empty, the default, means every kind QuickSearch understands. To \
|
Empty, the default, means every kind of file QuickSearch understands. To \
|
||||||
narrow it, enter one extension per line, the leading dot optional. \
|
narrow it, enter one extension per line, the leading dot optional. \
|
||||||
A non-empty list also leaves out files with no extension at all, \
|
A non-empty list also leaves out files with no extension at all, \
|
||||||
such as Makefile or README, unless you add the line (none). \
|
such as Makefile or README, unless you add the line (none). \
|
||||||
Anything after a # is a comment.\n\n\
|
Anything after a # is a comment.",
|
||||||
Narrowing the list discards the text it now excludes; widening it \
|
|
||||||
reads those files again.",
|
|
||||||
examples: &[
|
examples: &[
|
||||||
"txt, md and pdf to keep the stored text small and focused on documents, \
|
"txt, md and pdf to keep the stored text small and focused on documents, \
|
||||||
with everything else still findable by name.",
|
with everything else still findable by name.",
|
||||||
|
|
@ -711,6 +716,7 @@ mod tests {
|
||||||
&STOP_INDEXING,
|
&STOP_INDEXING,
|
||||||
&RETURN_TO_AUTO,
|
&RETURN_TO_AUTO,
|
||||||
&CLEAR_INDEX,
|
&CLEAR_INDEX,
|
||||||
|
&REBUILDING_FTS,
|
||||||
&ADD_ROOT,
|
&ADD_ROOT,
|
||||||
&REMOVE_ROOT,
|
&REMOVE_ROOT,
|
||||||
&ROOT_WORKERS,
|
&ROOT_WORKERS,
|
||||||
|
|
|
||||||
|
|
@ -345,6 +345,9 @@ fn note(ui: &egui::Ui, text: impl Into<egui::RichText>) -> egui::RichText {
|
||||||
struct Live<'a> {
|
struct Live<'a> {
|
||||||
/// `ctx.zoom_factor()`, read before the window is laid out.
|
/// `ctx.zoom_factor()`, read before the window is laid out.
|
||||||
zoom: f32,
|
zoom: f32,
|
||||||
|
/// The slider's position while it differs from `zoom` — chosen but not
|
||||||
|
/// yet applied. `None` once Apply is clicked or nothing is pending.
|
||||||
|
staged_scale: &'a mut Option<f32>,
|
||||||
/// The shortcut in force, as the config spells it.
|
/// The shortcut in force, as the config spells it.
|
||||||
hotkey: &'a str,
|
hotkey: &'a str,
|
||||||
capturing_hotkey: &'a mut bool,
|
capturing_hotkey: &'a mut bool,
|
||||||
|
|
@ -357,27 +360,24 @@ fn extra_ui(ui: &mut egui::Ui, extra: Extra, live: &mut Live, actions: &mut Tour
|
||||||
ui.separator();
|
ui.separator();
|
||||||
match extra {
|
match extra {
|
||||||
Extra::Scale => {
|
Extra::Scale => {
|
||||||
let mut scale = live.zoom;
|
// Staged until Apply, like the Settings tab's slider: applying
|
||||||
|
// mid-drag rescales the slider under the pointer, so the handle
|
||||||
|
// chases its own tail and the value cannot be chosen.
|
||||||
|
let mut scale = live.staged_scale.unwrap_or(live.zoom);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("UI scale");
|
ui.label("UI scale");
|
||||||
// Scoped to this row, which is the whole of its use.
|
// Scoped to this row, which is the whole of its use.
|
||||||
ui.spacing_mut().slider_width = 220.0;
|
ui.spacing_mut().slider_width = 220.0;
|
||||||
let slider = ui.add(
|
ui.add(
|
||||||
egui::Slider::new(&mut scale, crate::app::SCALE_RANGE)
|
egui::Slider::new(&mut scale, crate::app::SCALE_RANGE)
|
||||||
.step_by(0.05)
|
.step_by(0.05)
|
||||||
.fixed_decimals(2),
|
.fixed_decimals(2),
|
||||||
);
|
);
|
||||||
// Applied on every frame it moves, saved once it settles: a
|
let pending = (scale - live.zoom).abs() > f32::EPSILON;
|
||||||
// drag would otherwise rewrite the config file dozens of
|
*live.staged_scale = pending.then_some(scale);
|
||||||
// times on its way across. The release frame is not itself a
|
if ui.add_enabled(pending, egui::Button::new("Apply")).clicked() {
|
||||||
// change — the value stopped moving — so it is asked about
|
|
||||||
// separately, and carries the value with it so the app has
|
|
||||||
// one thing to act on.
|
|
||||||
let moved = slider.changed();
|
|
||||||
let settled = slider.drag_stopped() || (moved && !slider.dragged());
|
|
||||||
if moved || settled {
|
|
||||||
actions.set_scale = Some(scale);
|
actions.set_scale = Some(scale);
|
||||||
actions.save_scale = settled;
|
*live.staged_scale = None;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.label(note(
|
ui.label(note(
|
||||||
|
|
@ -399,7 +399,7 @@ fn extra_ui(ui: &mut egui::Ui, extra: Extra, live: &mut Live, actions: &mut Tour
|
||||||
if setting != live.hotkey {
|
if setting != live.hotkey {
|
||||||
actions.set_hotkey = Some(setting);
|
actions.set_hotkey = Some(setting);
|
||||||
}
|
}
|
||||||
crate::settings_tab::shortcut_note(ui);
|
crate::settings_tab::shortcut_note(ui, live.hotkey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -453,10 +453,10 @@ pub struct TourActions {
|
||||||
pub goto_tab: Option<Tab>,
|
pub goto_tab: Option<Tab>,
|
||||||
pub set_query: Option<String>,
|
pub set_query: Option<String>,
|
||||||
pub focus_search: bool,
|
pub focus_search: bool,
|
||||||
/// A new UI scale from the welcome page's slider, to apply live.
|
/// A new UI scale from the welcome page's slider, applied and saved on
|
||||||
|
/// its Apply button — never mid-drag, which would rescale the slider
|
||||||
|
/// under the pointer.
|
||||||
pub set_scale: Option<f32>,
|
pub set_scale: Option<f32>,
|
||||||
/// The drag ended, so the scale above is worth writing to the config.
|
|
||||||
pub save_scale: bool,
|
|
||||||
/// A shortcut captured on the shortcut page, to register and save.
|
/// A shortcut captured on the shortcut page, to register and save.
|
||||||
pub set_hotkey: Option<String>,
|
pub set_hotkey: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -473,6 +473,8 @@ pub struct Tutorial {
|
||||||
/// The shortcut button is armed and the next key combination is the
|
/// The shortcut button is armed and the next key combination is the
|
||||||
/// answer — the Settings tab's own capture, and its own flag.
|
/// answer — the Settings tab's own capture, and its own flag.
|
||||||
capturing_hotkey: bool,
|
capturing_hotkey: bool,
|
||||||
|
/// The welcome page's scale slider, between moving and Apply.
|
||||||
|
staged_scale: Option<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Tutorial {
|
impl Tutorial {
|
||||||
|
|
@ -483,6 +485,7 @@ impl Tutorial {
|
||||||
typing: None,
|
typing: None,
|
||||||
moved: false,
|
moved: false,
|
||||||
capturing_hotkey: false,
|
capturing_hotkey: false,
|
||||||
|
staged_scale: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -526,6 +529,7 @@ impl Tutorial {
|
||||||
// Lifted out of `self` for the window's closure, and put back after:
|
// Lifted out of `self` for the window's closure, and put back after:
|
||||||
// the closure already holds the page and the actions.
|
// the closure already holds the page and the actions.
|
||||||
let mut capturing = self.capturing_hotkey;
|
let mut capturing = self.capturing_hotkey;
|
||||||
|
let mut staged_scale = self.staged_scale;
|
||||||
let mut dismissed = false;
|
let mut dismissed = false;
|
||||||
let mut window = egui::Window::new(page.title)
|
let mut window = egui::Window::new(page.title)
|
||||||
.id(egui::Id::new(WINDOW_ID))
|
.id(egui::Id::new(WINDOW_ID))
|
||||||
|
|
@ -555,6 +559,7 @@ impl Tutorial {
|
||||||
if let Some(extra) = page.extra {
|
if let Some(extra) = page.extra {
|
||||||
let mut live = Live {
|
let mut live = Live {
|
||||||
zoom,
|
zoom,
|
||||||
|
staged_scale: &mut staged_scale,
|
||||||
hotkey,
|
hotkey,
|
||||||
capturing_hotkey: &mut capturing,
|
capturing_hotkey: &mut capturing,
|
||||||
};
|
};
|
||||||
|
|
@ -604,6 +609,7 @@ impl Tutorial {
|
||||||
self.moved = true;
|
self.moved = true;
|
||||||
}
|
}
|
||||||
self.capturing_hotkey = capturing;
|
self.capturing_hotkey = capturing;
|
||||||
|
self.staged_scale = staged_scale;
|
||||||
|
|
||||||
// The widgets this page names, in the colours its keywords were given.
|
// The widgets this page names, in the colours its keywords were given.
|
||||||
let dark_mode = ctx.style().visuals.dark_mode;
|
let dark_mode = ctx.style().visuals.dark_mode;
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ fn at(page: usize) -> Tutorial {
|
||||||
typing: None,
|
typing: None,
|
||||||
moved: false,
|
moved: false,
|
||||||
capturing_hotkey: false,
|
capturing_hotkey: false,
|
||||||
|
staged_scale: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ fn entering(page: usize) -> Tutorial {
|
||||||
typing: None,
|
typing: None,
|
||||||
moved: false,
|
moved: false,
|
||||||
capturing_hotkey: false,
|
capturing_hotkey: false,
|
||||||
|
staged_scale: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +61,6 @@ fn merge(a: TourActions, b: TourActions) -> TourActions {
|
||||||
set_query: b.set_query.or(a.set_query),
|
set_query: b.set_query.or(a.set_query),
|
||||||
focus_search: a.focus_search || b.focus_search,
|
focus_search: a.focus_search || b.focus_search,
|
||||||
set_scale: b.set_scale.or(a.set_scale),
|
set_scale: b.set_scale.or(a.set_scale),
|
||||||
save_scale: a.save_scale || b.save_scale,
|
|
||||||
set_hotkey: b.set_hotkey.or(a.set_hotkey),
|
set_hotkey: b.set_hotkey.or(a.set_hotkey),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -302,9 +303,11 @@ fn drag(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The whole point of putting it on the first page: someone who cannot read
|
/// The whole point of putting it on the first page: someone who cannot read
|
||||||
/// the window can fix that without finding the Settings tab first.
|
/// the window can fix that without finding the Settings tab first. Staged
|
||||||
|
/// until Apply: applying mid-drag would rescale the slider under the
|
||||||
|
/// pointer, and the handle would chase its own tail.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_welcome_page_slider_sets_the_ui_scale() {
|
fn the_welcome_page_slider_applies_only_on_its_button() {
|
||||||
let ctx = crate::test_ui::ctx();
|
let ctx = crate::test_ui::ctx();
|
||||||
let scale_page = page_with(Extra::Scale);
|
let scale_page = page_with(Extra::Scale);
|
||||||
let mut tour = at(scale_page);
|
let mut tour = at(scale_page);
|
||||||
|
|
@ -316,28 +319,36 @@ fn the_welcome_page_slider_sets_the_ui_scale() {
|
||||||
.1;
|
.1;
|
||||||
// The rail runs to the right of its label, on the same row.
|
// The rail runs to the right of its label, on the same row.
|
||||||
let from = egui::pos2(label.right() + 30.0, label.center().y);
|
let from = egui::pos2(label.right() + 30.0, label.center().y);
|
||||||
let [_, moved, release] = drag(&ctx, &mut tour, from, from + egui::vec2(120.0, 0.0));
|
let [press, moved, release] = drag(&ctx, &mut tour, from, from + egui::vec2(120.0, 0.0));
|
||||||
|
for (what, actions) in [("press", &press), ("move", &moved), ("release", &release)] {
|
||||||
let dragged = moved
|
assert_eq!(
|
||||||
.set_scale
|
actions.set_scale, None,
|
||||||
.expect("dragging the slider changed nothing");
|
"the {what} applied the scale without Apply being clicked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let staged = tour.staged_scale.expect("the drag staged nothing");
|
||||||
assert!(
|
assert!(
|
||||||
crate::app::SCALE_RANGE.contains(&dragged),
|
crate::app::SCALE_RANGE.contains(&staged),
|
||||||
"{dragged} is outside the range the slider offers"
|
"{staged} is outside the range the slider offers"
|
||||||
);
|
);
|
||||||
assert_ne!(dragged, 1.0, "the drag did not move the value");
|
assert_ne!(staged, 1.0, "the drag did not move the value");
|
||||||
assert!(!moved.save_scale, "the config was written mid-drag");
|
|
||||||
assert_eq!(
|
|
||||||
release.set_scale,
|
|
||||||
Some(dragged),
|
|
||||||
"the release did not hand the settled value over to be saved"
|
|
||||||
);
|
|
||||||
assert!(release.save_scale, "the drag ended without being saved");
|
|
||||||
|
|
||||||
// And nothing happens on a frame nobody touched it.
|
// The staged value survives idle frames, then Apply hands it over once.
|
||||||
let (_, quiet) = pass(&ctx, &mut tour, Vec::new(), 2.0);
|
let (out, quiet) = pass(&ctx, &mut tour, Vec::new(), 2.0);
|
||||||
assert_eq!(quiet.set_scale, None);
|
assert_eq!(quiet.set_scale, None);
|
||||||
assert!(!quiet.save_scale);
|
assert_eq!(tour.staged_scale, Some(staged));
|
||||||
|
let apply = painted(&out)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(text, _)| text == "Apply")
|
||||||
|
.expect("no Apply button beside the slider")
|
||||||
|
.1;
|
||||||
|
let (_, applied) = pass(&ctx, &mut tour, click_at(apply.center()), 2.1);
|
||||||
|
assert_eq!(
|
||||||
|
applied.set_scale,
|
||||||
|
Some(staged),
|
||||||
|
"Apply did not hand the staged value over"
|
||||||
|
);
|
||||||
|
assert_eq!(tour.staged_scale, None, "Apply left the value staged");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The slider shows the size the window is already at — the config's, or
|
/// The slider shows the size the window is already at — the config's, or
|
||||||
|
|
|
||||||
|
|
@ -56,17 +56,24 @@ impl Gate {
|
||||||
/// it or a `--toggle` process relayed the desktop's. Handled here because
|
/// it or a `--toggle` process relayed the desktop's. Handled here because
|
||||||
/// while locked the unlock screen *is* the window.
|
/// while locked the unlock screen *is* the window.
|
||||||
fn handle_activation(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
|
fn handle_activation(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
|
||||||
if !crate::activate::take_pending() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let Gate::Running(app) = self {
|
if let Gate::Running(app) = self {
|
||||||
// The shortcut must not reshuffle the window under a key capture.
|
// The shortcut must not reshuffle the window under a key capture.
|
||||||
|
// The flag is left set, not consumed: capture ends on a key
|
||||||
|
// event, which repaints, and the press is acted on that frame.
|
||||||
if app.capturing_hotkey() {
|
if app.capturing_hotkey() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if !crate::activate::take_pending() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The token travels with the press: on Wayland it is the
|
||||||
|
// compositor's permission for the raise below to take focus.
|
||||||
|
let token = crate::activate::take_token();
|
||||||
|
if let Gate::Running(app) = self {
|
||||||
app.activate_search(ctx);
|
app.activate_search(ctx);
|
||||||
}
|
}
|
||||||
crate::activate::raise(ctx, frame);
|
crate::activate::raise(ctx, frame, token.as_deref());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@
|
||||||
# duplicates.png, query-highlight.png — all landing in packaging/captures/
|
# duplicates.png, query-highlight.png — all landing in packaging/captures/
|
||||||
# (gitignored). Needs a graphical session (X11 or Wayland — screenshots and
|
# (gitignored). Needs a graphical session (X11 or Wayland — screenshots and
|
||||||
# video frames are read back from the app's own framebuffer, so the display
|
# video frames are read back from the app's own framebuffer, so the display
|
||||||
# server does not matter) and ffmpeg with libx264rgb and libvpx-vp9.
|
# server does not matter) and ffmpeg with libx264rgb, libvpx-vp9 and libwebp.
|
||||||
|
#
|
||||||
|
# Alongside those masters it emits the derivatives the website actually ships:
|
||||||
|
# -700.webp thumbnails, -1400.webp for the lightbox, and a -poster.webp first
|
||||||
|
# frame per clip. The website only needs the .webm and .webp files; the PNG
|
||||||
|
# masters stay here.
|
||||||
#
|
#
|
||||||
# The app is built with the `capture` feature and drives itself through
|
# The app is built with the `capture` feature and drives itself through
|
||||||
# packaging/capture-scenario.txt (see crates/quicksearch-gui/src/capture.rs
|
# packaging/capture-scenario.txt (see crates/quicksearch-gui/src/capture.rs
|
||||||
|
|
@ -33,7 +38,7 @@ done
|
||||||
# The encoder list is captured first: `grep -q` closing the pipe early would
|
# The encoder list is captured first: `grep -q` closing the pipe early would
|
||||||
# make ffmpeg exit on SIGPIPE, which pipefail reports as failure.
|
# make ffmpeg exit on SIGPIPE, which pipefail reports as failure.
|
||||||
encoders="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
|
encoders="$(ffmpeg -hide_banner -encoders 2>/dev/null)"
|
||||||
for enc in libx264rgb libvpx-vp9; do
|
for enc in libx264rgb libvpx-vp9 libwebp; do
|
||||||
grep -q "$enc" <<< "$encoders" \
|
grep -q "$enc" <<< "$encoders" \
|
||||||
|| { echo "ffmpeg lacks the $enc encoder" >&2; exit 1; }
|
|| { echo "ffmpeg lacks the $enc encoder" >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
@ -132,6 +137,41 @@ for clip in manage-indexing search; do
|
||||||
done
|
done
|
||||||
mv "$work/tmp/query-highlight.png" "$work/tmp/duplicates.png" "$out/"
|
mv "$work/tmp/query-highlight.png" "$work/tmp/duplicates.png" "$out/"
|
||||||
|
|
||||||
|
# --- web derivatives --------------------------------------------------------
|
||||||
|
# The PNG masters are ~1400x980 and 350-700 KB each, but the website renders
|
||||||
|
# them in a ~320 CSS px column (~350 px full-width on a phone). Shipping the
|
||||||
|
# masters was costing about a megabyte for two thumbnails, so the site loads
|
||||||
|
# -700.webp inline and only fetches -1400.webp when the lightbox opens.
|
||||||
|
# QS_WEBP_Q is the inline-thumbnail quality; the lightbox copy is encoded
|
||||||
|
# higher because it is the one people zoom into to read UI text.
|
||||||
|
webp_q="${QS_WEBP_Q:-82}"
|
||||||
|
webp_q_full="${QS_WEBP_Q_FULL:-90}"
|
||||||
|
for shot in duplicates query-highlight; do
|
||||||
|
ffmpeg -y -hide_banner -loglevel warning -i "$out/$shot.png" \
|
||||||
|
-vf "scale=700:-2:flags=lanczos" \
|
||||||
|
-c:v libwebp -quality "$webp_q" -compression_level 6 \
|
||||||
|
"$out/$shot-700.webp"
|
||||||
|
ffmpeg -y -hide_banner -loglevel warning -i "$out/$shot.png" \
|
||||||
|
-c:v libwebp -quality "$webp_q_full" -compression_level 6 \
|
||||||
|
"$out/$shot-1400.webp"
|
||||||
|
done
|
||||||
|
# Poster frames: with the videos deferred behind an IntersectionObserver these
|
||||||
|
# are what the page paints on first load, so each is sized to the slot it
|
||||||
|
# actually renders in — search is the full-width hero, manage-indexing is one
|
||||||
|
# column of a three-up grid. The frame is taken from 80% through rather than
|
||||||
|
# frame 0: every clip opens on an empty results list, which is the worst
|
||||||
|
# possible still to hold on the hero while the video is still downloading.
|
||||||
|
for spec in "search:1200" "manage-indexing:700"; do
|
||||||
|
clip="${spec%:*}"; pw="${spec#*:}"
|
||||||
|
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 \
|
||||||
|
"$out/$clip.webm")
|
||||||
|
at=$(awk -v d="$dur" 'BEGIN{printf "%.2f", d*0.8}')
|
||||||
|
ffmpeg -y -hide_banner -loglevel warning -ss "$at" -i "$out/$clip.webm" \
|
||||||
|
-frames:v 1 -vf "scale=$pw:-2:flags=lanczos" \
|
||||||
|
-c:v libwebp -quality "$webp_q" -compression_level 6 \
|
||||||
|
"$out/$clip-poster.webp"
|
||||||
|
done
|
||||||
|
|
||||||
# --- verify -----------------------------------------------------------------
|
# --- verify -----------------------------------------------------------------
|
||||||
fail=0
|
fail=0
|
||||||
for f in "$out/search.webm" "$out/manage-indexing.webm"; do
|
for f in "$out/search.webm" "$out/manage-indexing.webm"; do
|
||||||
|
|
@ -148,10 +188,20 @@ for f in "$out/duplicates.png" "$out/query-highlight.png"; do
|
||||||
-show_entries stream=width,height -of csv=p=0 "$f")
|
-show_entries stream=width,height -of csv=p=0 "$f")
|
||||||
echo "OK: $f (${dims})"
|
echo "OK: $f (${dims})"
|
||||||
done
|
done
|
||||||
|
# The derivatives are what the website ships, so a silent encode failure here
|
||||||
|
# would ship broken <img> tags rather than merely large ones.
|
||||||
|
for f in "$out/duplicates-700.webp" "$out/duplicates-1400.webp" \
|
||||||
|
"$out/query-highlight-700.webp" "$out/query-highlight-1400.webp" \
|
||||||
|
"$out/search-poster.webp" "$out/manage-indexing-poster.webp"; do
|
||||||
|
[ -s "$f" ] || { echo "FAIL: missing or empty $f" >&2; fail=1; continue; }
|
||||||
|
dims=$(ffprobe -v error -select_streams v:0 \
|
||||||
|
-show_entries stream=width,height -of csv=p=0 "$f")
|
||||||
|
echo "OK: $f (${dims}, $(stat -c%s "$f") bytes)"
|
||||||
|
done
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|
||||||
# $work is kept for post-mortems (app stderr is on this terminal; the scratch
|
# $work is kept for post-mortems (app stderr is on this terminal; the scratch
|
||||||
# index and raw .cap.mkv files live there) and recreated fresh next run.
|
# index and raw .cap.mkv files live there) and recreated fresh next run.
|
||||||
echo
|
echo
|
||||||
echo "Assets:"
|
echo "Assets (the website needs the .webm and .webp files; PNGs stay here):"
|
||||||
ls -l "$out"/*.webm "$out"/*.png
|
ls -l "$out"/*.webm "$out"/*.png "$out"/*.webp
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,10 @@ binding runs so that a key can start it as well.
|
||||||
The activation reaches the running instance over a unix socket in
|
The activation reaches the running instance over a unix socket in
|
||||||
.IR $XDG_RUNTIME_DIR ,
|
.IR $XDG_RUNTIME_DIR ,
|
||||||
named after the configuration file; when nothing answers, this process becomes
|
named after the configuration file; when nothing answers, this process becomes
|
||||||
the application. On Wayland an already\-open window cannot be raised by another
|
the application. On Wayland the window comes forward when the desktop supplied
|
||||||
process, so it is highlighted in the task bar instead of coming to the front.
|
an activation token for the launch (a binding made with the Settings tab's
|
||||||
|
set\-up button does); run by hand with no token, the window is highlighted in
|
||||||
|
the task bar instead.
|
||||||
.TP
|
.TP
|
||||||
.B \-\-fuzzy
|
.B \-\-fuzzy
|
||||||
Also run the fuzzy filename and full\-text passes, which tolerate spelling
|
Also run the fuzzy filename and full\-text passes, which tolerate spelling
|
||||||
|
|
|
||||||
|
|
@ -174,21 +174,27 @@ Section "Start Menu shortcut" SecStartMenu
|
||||||
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" "$INSTDIR\quicksearch.ico"
|
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" "$INSTDIR\quicksearch.ico"
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
Section "Search hotkey (Ctrl+Alt+F)" SecHotkey
|
Section "Search hotkey (Ctrl+Shift+F)" SecHotkey
|
||||||
; The .lnk "shortcut key" field is the only thing on Windows that binds a
|
; The .lnk "shortcut key" field is the only thing on Windows that binds a
|
||||||
; key to a command, and it is what makes the shortcut work while
|
; key to a command, and it is what makes the shortcut work while
|
||||||
; QuickSearch is closed - nothing an application registers for itself can
|
; QuickSearch is closed - nothing an application registers for itself can
|
||||||
; fire when it is not running. Windows only honours the field on a
|
; fire when it is not running. Windows only honours the field on a
|
||||||
; shortcut in the Start menu or on the desktop, and only for combinations
|
; shortcut in the Start menu or on the desktop. Modifier combinations
|
||||||
; including Ctrl+Alt, which is why this is Ctrl+Alt+F and not the
|
; like Ctrl+Shift are accepted as-is; only a bare key gets Ctrl+Alt added
|
||||||
; Ctrl+Shift+F the Settings tab offers. The in-application shortcut takes
|
; for it. Ctrl+Shift+F matches the in-application default, which answers
|
||||||
; any combination but only answers while the window is already open, so
|
; the key instantly while the window is open; this .lnk binding is the
|
||||||
; the two are complementary rather than duplicates.
|
; slower launch path Explorer takes when it is not. Changing the shortcut
|
||||||
|
; on the Settings tab rewrites this .lnk to match (per-user installs
|
||||||
|
; only; this all-users file needs elevation).
|
||||||
|
;
|
||||||
|
; Explorer registers this key at logon, so the app's own RegisterHotKey
|
||||||
|
; for the same combination loses; either way the press works — directly,
|
||||||
|
; or by Explorer launching `--toggle`, which relays to a running window.
|
||||||
;
|
;
|
||||||
; Rewrites the same shortcut the section above creates: NSIS cannot add a
|
; Rewrites the same shortcut the section above creates: NSIS cannot add a
|
||||||
; hotkey to an existing .lnk, and creating it twice is harmless.
|
; hotkey to an existing .lnk, and creating it twice is harmless.
|
||||||
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" \
|
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" \
|
||||||
"$INSTDIR\quicksearch.ico" 0 SW_SHOWNORMAL ALT|CONTROL|F \
|
"$INSTDIR\quicksearch.ico" 0 SW_SHOWNORMAL CONTROL|SHIFT|F \
|
||||||
"Search your files with ${APP}"
|
"Search your files with ${APP}"
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
|
|
@ -212,9 +218,8 @@ SectionEnd
|
||||||
!insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} \
|
!insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} \
|
||||||
"Add ${APP} to the Start menu for all users."
|
"Add ${APP} to the Start menu for all users."
|
||||||
!insertmacro MUI_DESCRIPTION_TEXT ${SecHotkey} \
|
!insertmacro MUI_DESCRIPTION_TEXT ${SecHotkey} \
|
||||||
"Press Ctrl+Alt+F anywhere to search, starting ${APP} if it is not \
|
"Press Ctrl+Shift+F anywhere to search, starting ${APP} if it is not \
|
||||||
already running. Windows allows this only on Ctrl+Alt combinations; \
|
already running. The Settings tab can rebind it."
|
||||||
the Settings tab has one that takes any keys while ${APP} is open."
|
|
||||||
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
|
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
|
||||||
"Add a ${APP} shortcut to the desktop."
|
"Add a ${APP} shortcut to the desktop."
|
||||||
!insertmacro MUI_FUNCTION_DESCRIPTION_END
|
!insertmacro MUI_FUNCTION_DESCRIPTION_END
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue