Compare commits

...

4 commits

Author SHA1 Message Date
eb1500324f Fixed system shortcuts for appimage version
All checks were successful
CI / linux (push) Successful in 12m9s
CI / windows-cross (push) Successful in 4m43s
CI / release (push) Successful in 13s
2026-09-07 00:12:36 -04:00
658e32159a Some optimizations and another fix for the shortcut system.
All checks were successful
CI / linux (push) Successful in 11m53s
CI / windows-cross (push) Successful in 4m50s
CI / release (push) Successful in 13s
2026-09-06 19:43:57 -04:00
22d52d5ac0 Fixed a Windows CI issue.
Some checks failed
CI / linux (push) Failing after 9m45s
CI / windows-cross (push) Successful in 8m3s
CI / release (push) Has been skipped
2026-09-05 03:09:12 -04:00
488038c080 Improved keybind functionality, help menu, index prune speed.
Some checks failed
CI / linux (push) Successful in 14m54s
CI / windows-cross (push) Failing after 6m11s
CI / release (push) Has been skipped
2026-09-05 01:55:59 -04:00
48 changed files with 5419 additions and 661 deletions

11
Cargo.lock generated
View file

@ -3108,7 +3108,7 @@ dependencies = [
[[package]]
name = "quicksearch-core"
version = "1.1.7"
version = "1.1.8"
dependencies = [
"argon2",
"cfb",
@ -3150,23 +3150,22 @@ dependencies = [
[[package]]
name = "quicksearch-gui"
version = "1.1.7"
version = "1.1.8"
dependencies = [
"ashpd",
"chrono",
"eframe",
"egui",
"egui_extras",
"futures-channel",
"futures-util",
"global-hotkey",
"keyring",
"open",
"pollster",
"quicksearch-core",
"raw-window-handle",
"rfd",
"rpassword",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"windows-sys 0.59.0",
"x11rb",
"zeroize",

View file

@ -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
[workspace.package]
version = "1.1.7"
version = "1.1.8"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"]

View file

@ -38,9 +38,10 @@ tracking: https://github.com/DataScienceDIY/quick_search
- **Password protection** — optionally encrypt the index, since it contains
the names and text of everything indexed. The password can be remembered
in your system keychain.
- **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere
while QuickSearch is running, and `quicksearch --toggle` can be bound in
your desktop's keyboard settings to also start it.
- **Global shortcut** — Ctrl+Shift+F brings up the window from anywhere,
starting QuickSearch if it is closed. One click on the Settings tab binds
it on KDE and GNOME; elsewhere, bind `quicksearch --toggle` in your
desktop's keyboard settings.
- **Terminal search**`quicksearch <query>` prints ranked, pipe-friendly
paths; `--long` adds sizes, dates, and highlighted snippets.
- **Portable mode** — keep the program, its config, and its index together

View file

@ -94,8 +94,6 @@ batch_size = 500
# 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.
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
# 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
@ -150,8 +148,9 @@ watch_cap_warned_roots = []
# 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
# 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
# "quicksearch --toggle" in your desktop's keyboard settings.
# empty ("") for no shortcut. To start QuickSearch when it is closed, use
# "Set up system shortcut" on the Settings tab, or bind "quicksearch
# --toggle" in your desktop's keyboard settings yourself.
search_hotkey = "Ctrl+Shift+F"
# '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
@ -172,7 +171,10 @@ show_advanced_settings = false
tutorial_seen = false
[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
# 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

View file

@ -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();
});
}
}

View file

@ -254,6 +254,16 @@ const CASES: &[Case] = &[
fuzzy: false,
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 {
label: "common (capped)",
query: "content",

View file

@ -12,9 +12,10 @@
//! What has to stay resident is the **`files` table**, not the FTS index.
//! `search/cascade/passes.rs` answers filename queries (ranks 14, 910) with
//! `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
//! set scales with **file count**, not with document volume, and the corpus
//! dimension below is what makes that visible.
//! at all — and the fuzzy pass scans it again (`LIKE`-narrowed for terms of
//! 9+ characters, unpredicated below that). So the working set scales with
//! **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
//! 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
/// 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 latest = std::sync::atomic::AtomicU64::new(1);
let mut count = 0usize;
let mut sink = |hits: Vec<SearchHit>| count += hits.len();
let options = SearchOptions {
limit: 1000,
..SearchOptions::default()
};
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)
}
/// 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 35 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,
/// 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))
.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
// keystrokes after the first.
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)
}
@ -302,7 +350,21 @@ fn main() {
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] {
let arm = Arm::seed(
format!(
@ -312,10 +374,31 @@ fn main() {
),
&format!("searchperf-{}-{}", files, 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();
}
}
}
/// 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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -23,11 +23,24 @@
//! page read the rows, decide nothing
//! +cover ...and run the scope test per row
//! +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
//! +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
//! 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
@ -38,18 +51,28 @@
//! 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,
//! 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;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use quicksearch_core::config::Config;
use quicksearch_core::db::{self, repo};
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::testutil::{self, Arm, SeedSpec};
@ -384,6 +407,165 @@ fn delete_range(tx: &Connection, lo: &str, hi: &str) -> (usize, Duration, Durati
(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.
///
/// `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
// `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 mut conn = open(&arm);
@ -861,6 +1044,91 @@ fn main() {
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();
}
}

View file

@ -84,7 +84,6 @@ pub struct ProcessingConfig {
/// 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.
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
/// checkpoint, in bytes. `0` disables; else raised to [`MINIMUM_WAL_SIZE`].
/// 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,
batch_size: 500,
writer_turn_slice_ms: 100,
fts_update_batch_size: 1000,
maximum_wal_size: 1024 * 1024 * 1024 * 2,
tokenize: "trigram".to_string(),
store_text_for_snippets: true,

View file

@ -837,7 +837,7 @@ fn newer_fields_round_trip_and_default_when_absent() {
fs::write(
&path,
"[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();
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.search.fuzzy_max_edits, 2);
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);
// A value nobody recognises is not a broken config file.

View 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
/// 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> {
let id: Option<i64> = tx
let row: Option<(i64, 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",
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5
WHERE parent = ?6 AND name = ?7
RETURNING id, content_state",
)
.and_then(|mut stmt| {
stmt.query_row(
@ -120,27 +132,42 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
f.hash,
f.mime,
f.ftype.bits() as i64,
initial_content_state(f),
f.parent,
f.name,
],
|r| r.get(0),
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
})
.map_err(|e| format!("update file {}: {}", f.path(), e))?;
let Some(id) = id else {
let Some((id, old_state)) = row else {
return Ok(None);
};
remove_content_for_id(tx, id)?;
// Without this, a changed file that stops needing content keeps reading
// as "failed" in `list-failed` forever.
exec(
tx,
"DELETE FROM failed_files WHERE file_id = ?1",
params![id],
|| format!("clear failed_files {}", id),
)?;
match old_state {
STATE_DONE => remove_content_for_id(tx, id)?,
// Without this, a changed file that stops needing content keeps
// reading as "failed" in `list-failed` forever.
STATE_FAILED => exec(
tx,
"DELETE FROM failed_files WHERE file_id = ?1",
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))
}
@ -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.
///
/// 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> {
remove_content_for_id(tx, file_id)?;
let now = crate::log::now_unix() as i64;
exec(
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
/// 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> {
remove_content_for_id(tx, file_id)?;
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)
}
/// 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
/// [`crate::file_handling::ExtractCursor::for_root`], which makes them
/// 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
/// FTS5 virtual table takes no foreign key — so its contentless delete must
/// 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(
tx: &Transaction<'_>,
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
/// `files` rows went. Dependent tables: see `delete_files_matching`.
pub fn delete_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<usize, String> {
let mut removed = 0;
for chunk in ids.chunks(DELETE_IDS_CHUNK) {
let placeholders = placeholders(chunk.len());
///
/// `with_postings` is the subset of `ids` whose `content_state` was
/// `STATE_DONE`, and so the only ones FTS5 can have anything to tombstone for —
/// `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!(
"DELETE FROM searchabletext WHERE rowid IN ({})",
placeholders
placeholders(chunk.len())
);
exec(tx, &sql, params_from_iter(chunk.iter()), || {
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()), || {
format!("delete {} file rows", chunk.len())
})?;
@ -456,6 +559,77 @@ pub fn delete_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<usize, String> {
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`
/// must be in stored spelling — trailing separator and all; build it with
/// [`crate::file_handling::dir_to_db_parent`].

View file

@ -527,9 +527,12 @@ fn delete_ids_clears_every_dependent_table() {
}
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 tx = conn.transaction().unwrap();
let n = delete_ids(&tx, &doomed).unwrap();
let n = delete_ids(&tx, &doomed, &with_postings).unwrap();
tx.commit().unwrap();
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 ()`.
let tx = conn.transaction().unwrap();
assert_eq!(delete_ids(&tx, &[]).unwrap(), 0);
assert_eq!(delete_ids(&tx, &[], &[]).unwrap(), 0);
tx.commit().unwrap();
}
@ -572,7 +575,8 @@ fn delete_ids_spans_chunk_boundaries() {
let keep = all.pop().unwrap();
let removed = {
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();
n
};
@ -583,6 +587,166 @@ fn delete_ids_spans_chunk_boundaries() {
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.
#[test]
fn drop_stored_text_keeps_the_file_searchable() {
@ -1199,6 +1363,74 @@ fn fts_rows(conn: &Connection) -> i64 {
.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
/// `searchabletext` row exists. Pinned against the FTS table itself, because
/// 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() {
let (_dir, path) = tmp_path();
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 =
|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();
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();
}
assert_eq!(count(&conn, "SELECT COUNT(*) FROM documents_text"), 2);

View file

@ -84,9 +84,11 @@ fn write_prepared_records(
records: &[OwnedNewFile],
stop_flag: &Arc<AtomicBool>,
config: &Config,
chunk_size: usize,
write_row: impl Fn(&rusqlite::Transaction<'_>, &OwnedNewFile) -> Result<Option<i64>, 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.
let mut bodies = Bodies::new()?;
for batch in records.chunks(chunk_size) {
@ -134,17 +136,15 @@ pub fn process_batch_updates(
return Ok(());
}
let fts_batch = config.processing.fts_update_batch_size.max(1);
// The row leaves this closure `_fresh`: `update_file_basic` cleared its
// content in this same transaction, and the insert fallback created the
// row outright.
// The row leaves this closure `_fresh`: `update_file_basic` cleared a
// DONE row's content in this same transaction — every other state
// provably holds none (`repo_tests::leaving_done_always_takes_the_posting_with_it`)
// — and the insert fallback created the row outright.
write_prepared_records(
conn_mutex,
files_to_update,
stop_flag,
config,
fts_batch,
|tx, rec| {
let updated = repo::update_file_basic(tx, &rec.as_new_file()).map_err(|e| {
format!(
@ -182,9 +182,6 @@ pub fn process_batch_inserts(
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`
// only by creating it, so it cannot carry content from anywhere.
write_prepared_records(
@ -192,7 +189,6 @@ pub fn process_batch_inserts(
files_to_insert,
stop_flag,
config,
config.processing.batch_size.max(1),
|tx, rec| {
repo::insert_file(tx, &rec.as_new_file())
.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
/// went. A chunk either commits whole or is not begun, so a stop cannot
/// 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(
conn_mutex: &Arc<Mutex<Connection>>,
stale_paths: &[String],
@ -215,6 +231,13 @@ pub fn cleanup_stale_index_entries(
let chunk = config.processing.batch_size.max(1);
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) {
if stop_flag.load(Ordering::Relaxed) {
return Ok(deleted_count);
@ -223,26 +246,26 @@ pub fn cleanup_stale_index_entries(
let tx = conn
.unchecked_transaction()
.map_err(|e| format!("Failed to begin stale cleanup transaction: {}", e))?;
ids.clear();
with_postings.clear();
for path in batch {
if stop_flag.load(Ordering::Relaxed) {
break;
}
if repo::delete_file_by_path(&tx, path)
.map_err(|e| format!("Failed to remove stale index entry for {}: {}", path, e))?
if let Some((id, state)) = repo::id_and_state_for_path(&tx, path)
.map_err(|e| format!("Failed to resolve stale index entry: {}", 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()
.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);
fts_finalize_after_text_indexing(&conn);
fts_end_tombstone_burst(&conn);
}
Ok(deleted_count)

View file

@ -27,7 +27,8 @@ pub(crate) use paths::{
};
pub use records::{
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,
prepare_file_record_from_path, reset_run_warnings, store_content_outcome, ContentOutcome,
DirRows, FileIndexAction, OwnedNewFile,

View file

@ -116,6 +116,147 @@ pub fn fts_set_automerge(conn: &Connection, segments: u8) {
/// smaller, more build-stable index for no cost; 64 measured identically.
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.
///
/// `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
/// makes 4050 a cliff — and that case is handled once at schema creation; see
/// [`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) {
fts_set_automerge(conn, WRITE_AUTOMERGE);
fts_set_deletemerge(conn, FTS_DELETEMERGE);
if let Err(e) = conn.execute(
"INSERT INTO searchabletext(searchabletext, rank) VALUES('crisismerge', ?1)",
[WRITE_CRISISMERGE as i64],

View file

@ -600,3 +600,194 @@ fn a_symlinked_root_yields_no_directories_when_following_is_off() {
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();
}

View file

@ -27,8 +27,13 @@ use super::*;
/// wholesale — or newly excluded — is never read, so per-directory
/// reconciliation cannot see its rows. Not deletions: parents under an
/// 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(
conn_mutex: &Arc<Mutex<Connection>>,
conn: &Connection,
root: &str,
seen_dirs: &HashSet<String>,
unreadable: &crate::file_handling::UnreadableDirs,
@ -37,17 +42,16 @@ fn sweep_unvisited_parents(
) -> Result<(), String> {
// Same keyset range the extraction cursor uses: `[root + "/", root + "0")`.
let range = ExtractCursor::for_root(root);
let conn = crate::lock_ok(conn_mutex);
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) {
unvisited.push(parent);
}
})?;
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) {
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
// minutes of writer time on a big index.
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() {
sweep_unvisited_parents(
&cx.conn_mutex,
&sweep_conn,
&p.root,
&p.walk.seen_dirs(),
p.walk.unreadable(),
@ -752,6 +759,7 @@ fn cleanup_stale(pipelines: &[RootPipeline], cx: &mut RunCx<'_>) -> Result<(), S
&mut cx.stale_candidates,
)?;
}
drop(sweep_conn);
// The aliased filter applies to both sources: per-directory
// reconciliation can flag a symlink target as stale while the alias route
// inserted it — the row would be written and deleted on every run.

View file

@ -1,6 +1,34 @@
//! Brings a stored index back in line with a changed configuration without
//! rebuilding it. Nothing here stamps the stored configuration — the caller
//! 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 2430 ms of a ~950 ms pass — there is nothing
//! there to win.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
@ -11,7 +39,9 @@ use rusqlite::Connection;
use crate::config::{Config, IgnoreSet, IndexWork};
use crate::db::repo;
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;
/// How long [`advance`] may work before handing control back. This bounds
@ -180,6 +210,12 @@ impl WorkCursor {
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 {
ReconcileProgress {
examined: self.examined,
@ -227,6 +263,9 @@ pub fn advance(
let tx = conn
.transaction()
.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)?;
tx.commit()
.map_err(|e| format!("commit drop-root transaction: {}", e))?;
@ -253,6 +292,7 @@ pub fn advance(
let tx = conn
.transaction()
.map_err(|e| format!("begin drop-alias transaction: {}", e))?;
fts_begin_tombstone_burst(&tx);
let removed = repo::delete_outside_ranges(&tx, &ranges)?;
tx.commit()
.map_err(|e| format!("commit drop-alias transaction: {}", e))?;
@ -270,29 +310,91 @@ pub fn advance(
}
cursor.total = Some(repo::row_count(conn)?);
}
let page = config.processing.batch_size.max(1) as i64;
let mut covered = CoverCache::default();
while cursor.root_idx < cursor.scope.roots.len() {
if cancelled(cancel) {
return Ok(());
}
scan_rows(conn, config, registry, cursor, deadline, cancel)?;
if !cursor.scan_done() {
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];
if cursor.after.0.is_empty() {
// `(lo, "")` sorts below every row in the range — no stored name is empty.
cursor.after = (root.lo.clone(), String::new());
}
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 {
// 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.after = (String::new(), String::new());
continue;
};
cursor.after = (last.parent.clone(), last.name.clone());
cursor.examined += rows.len();
buffered += rows.len();
let root = cursor.scope.roots[cursor.root_idx].path.clone();
let (deleted, recontented) = apply_page(
conn,
&tx,
config,
registry,
&cursor.scope,
@ -300,24 +402,21 @@ pub fn advance(
&root,
&rows,
&mut covered,
&mut plan,
)?;
cursor.deleted += deleted;
cursor.recontented += recontented;
if Instant::now() >= deadline {
return Ok(());
if buffered >= COMMIT_ROWS || cancelled(cancel) || Instant::now() >= deadline {
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(())
}
@ -325,10 +424,85 @@ fn cancelled(cancel: &AtomicBool) -> bool {
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)]
fn apply_page(
conn: &mut Connection,
tx: &rusqlite::Transaction<'_>,
config: &Config,
registry: &Registry,
scope: &Scope,
@ -336,20 +510,21 @@ fn apply_page(
root: &Path,
rows: &[repo::ScopeRow],
covered: &mut CoverCache,
plan: &mut PagePlan,
) -> Result<(usize, usize), String> {
let mut doomed: Vec<i64> = Vec::new();
let mut stale_text: Vec<i64> = Vec::new();
let mut to_pending: Vec<i64> = Vec::new();
let mut to_na: Vec<i64> = Vec::new();
plan.clear();
for row in rows {
let path = Path::new(&row.path);
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;
}
if work.drop_text {
stale_text.push(row.id);
// Only a DONE row can have a `documents_text` body to drop.
if work.drop_text && row.content_state == repo::STATE_DONE {
plan.stale_text.push(row.id);
}
if work.reconcile_content || work.restore_text {
// 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
&& content_extractable(path, row.mime.as_deref(), config, registry);
if !wants && row.content_state != repo::STATE_NA {
to_na.push(row.id);
plan.restate(row, false);
} else if wants
&& (row.content_state == repo::STATE_NA
|| (work.restore_text && row.content_state == repo::STATE_DONE))
{
to_pending.push(row.id);
plan.restate(row, true);
}
}
}
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()))
plan.write(tx)
}
/// The configuration the index was last built with, as far as

View file

@ -1,5 +1,6 @@
use super::*;
use crate::walk::{walk_indexable_files, WalkEvent};
use rusqlite::OptionalExtension;
use std::collections::HashSet;
use std::sync::atomic::AtomicBool;
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();
}
/// 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,
&registry,
&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,
&registry,
&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
/// as reconciled. Rows already reached stay gone: the pass is idempotent.
#[test]

View file

@ -39,7 +39,7 @@ use crate::query::translator::{escape_like, quote_phrase};
use crate::snippet;
use super::fuzzy::{edit_budget, pigeonhole_chunks, Bitap};
use super::{SearchHit, SearchOptions};
use super::{prefilter, SearchHit, SearchOptions};
mod passes;
@ -137,6 +137,11 @@ pub fn run(
emitted: IdSet::default(),
deferred_path: Deferred::default(),
deferred_fuzzy_path: Deferred::default(),
regex_doc: query
.regex
.as_ref()
.map(|_| crate::db::repo::DocDecoder::new())
.transpose()?,
total: 0,
limited: false,
sink,
@ -310,6 +315,11 @@ struct Cx<'a> {
deferred_path: Deferred,
/// Rank 11, filled by pass C.
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,
limited: bool,
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
/// path contains the name, so one path check covers both; content is
/// fetched only for rows whose path missed.
fn regex_accepts(&self, file_id: i64, path: &str, text: Option<&str>) -> Result<bool, String> {
let Some(re) = &self.query.regex else {
/// fetched only for rows whose path missed — per candidate row, which is
/// why the statement is cached and the decoder ([`Cx::regex_doc`]) is
/// 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);
};
if re.is_match(path) {
@ -381,19 +407,23 @@ impl<'a> Cx<'a> {
if let Some(text) = text {
return Ok(re.is_match(text));
}
let blob: Option<Vec<u8>> = self
.conn
.query_row(
"SELECT text_zstd FROM documents_text WHERE file_id = ?1",
[file_id],
|r| r.get(0),
)
.optional()
let doc = regex_doc
.as_mut()
.expect("built in run() whenever the query carries a regex");
let accepted = conn
.prepare_cached("SELECT text_zstd FROM documents_text WHERE file_id = ?1")
.and_then(|mut stmt| {
stmt.query_row([file_id], |r| {
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())?;
let Some(raw) = blob.and_then(|b| zstd::decode_all(b.as_slice()).ok()) else {
return Ok(false);
};
Ok(re.is_match(&String::from_utf8_lossy(&raw)))
Ok(accepted.unwrap_or(false))
}
/// Hand `buf` over mid-scan if it is due, leaving it empty when it goes.

View file

@ -408,20 +408,42 @@ impl<'a> Cx<'a> {
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
/// filename, falling back to the full path where the name misses.
/// Pass C — rank 7 now, rank 11 deferred: one bitap sweep over the
/// 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> {
let Some((_, bitap)) = self.fuzzy_matcher() else {
let Some((k, bitap)) = self.fuzzy_matcher() else {
return Ok(true);
};
let query = self.query;
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!(
"SELECT {} FROM files f WHERE 1=1{}",
HIT_COLUMNS, query.filter_sql
"SELECT {} FROM files f WHERE {}{}",
HIT_COLUMNS, predicate, query.filter_sql
);
let params = self.params_with_filters(Vec::new());
let params = self.params_with_filters(terms);
self.scan_pass(
&sql,
params,

View file

@ -286,8 +286,35 @@ pub struct SeedSpec {
/// file cannot be reopened without it. Ignored on a plain arm, which has
/// no reserve.
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 {
fn default() -> SeedSpec {
SeedSpec {
@ -306,6 +333,8 @@ impl Default for SeedSpec {
page_size: None,
pgsz: 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.
pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
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
// *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();
}
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
// stop early and report a fraction of the work a real rare query costs.
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 {
let w1 = 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 {
format!("{}-{}-{:07}.txt", w1, NEEDLE, i)
format!("{}-{}-{:07}.{}", w1, NEEDLE, i, ext)
} 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`.
// 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
});
// `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(
&tx,
&NewFile {
@ -383,15 +434,15 @@ pub fn seed_index(path: &std::path::Path, spec: &SeedSpec) {
parent: &dir,
size: 4096,
mtime: 1_700_000_000 + i as u64,
mime: Some("text/plain"),
ftype: FileType::TEXT,
mime: Some(mime),
ftype: mime_to_type(mime),
hash: hash.as_ref().map(|h| h.as_slice()),
needs_content: i % spec.content_every.max(1) == 0,
needs_content,
},
)
.unwrap()
.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();
if spec.needle_docs > 0 && i % doc_stride.max(1) == 0 {
// Mid-body, so a snippet window has to be cut around it.

View file

@ -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
/// on, so the pass falls back to scanning — and must still find its hits.
#[test]

View file

@ -536,6 +536,191 @@ fn every_document_within_the_budget_is_found_and_nothing_outside_it_is() {
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
// ---------------------------------------------------------------------------

View file

@ -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.
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`
# has to be repeated: feature resolution unions the two stanzas, so a single
# permissive one would switch defaults back on for every target.
@ -68,25 +75,22 @@ eframe = { version = "0.32", default-features = false, features = [
"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
# 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
# `activate::raise`. Both are already in the tree (winit's own X11 backend,
# and eframe's window handle), so neither adds a crate.
# `activate::raise`. Already in the tree as winit's own X11 backend, so this
# adds no crate.
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
# 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_System_IO",
"Win32_Security",
# The foreground handshake (`activate`) and native raise (`activate::raise`):
# GetCurrentProcessId, AllowSetForegroundWindow / SetForegroundWindow.
"Win32_System_Threading",
"Win32_UI_WindowsAndMessaging",
] }

View file

@ -7,20 +7,20 @@
//! box whether or not the app was started", because a shortcut an application
//! registers for itself cannot fire while the application is not there.
//!
//! The message carries nothing: "come forward" is the whole protocol, and
//! the reply exists only so the sender can tell a live instance from a
//! leftover socket. An xdg-activation token would be the natural thing to
//! carry — it is what a compositor wants before letting a background client
//! take focus — but nothing downstream can consume one: winit 0.30 applies a
//! token only in `WindowAttributes`, and egui's `ViewportBuilder` has no
//! field for it, so eframe never plumbs one either. See [`raise`] for what
//! that costs on Wayland.
//! "Come forward" is nearly the whole protocol; what little each side says
//! is the permission the other needs. On unix the request line carries the
//! sender's `XDG_ACTIVATION_TOKEN` (usually empty) — on Wayland that token
//! is the compositor's leave for the running window to take focus, spent in
//! [`raise`]'s `xdg_activation_v1` path — and the reply exists so the
//! sender can tell a live instance from a leftover socket. On Windows the
//! reply instead carries the server's PID, which the sender feeds to
//! `AllowSetForegroundWindow` so the running window may take the foreground
//! — see the `#[cfg(windows)]` module below.
pub mod raise;
pub use raise::raise;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
@ -28,6 +28,27 @@ use std::sync::atomic::{AtomicBool, Ordering};
/// the same thing as one.
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.
///
/// **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)
}
/// 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
/// run out of `target/` is the common case, and "quicksearch" would be wrong
/// 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 {
let Ok(exe) = std::env::current_exe() else {
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
.parent()
.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
/// the UI's point of view.
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);
ctx.request_repaint();
}
@ -115,8 +163,16 @@ pub(crate) fn fire(ctx: &egui::Context) {
#[cfg(unix)]
mod imp {
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};
/// 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.
///
/// `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
/// instance**: the same rule `IndexLock` follows, so a crash cannot
/// 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 {
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 {
return false;
};
@ -136,7 +203,18 @@ mod imp {
{
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;
}
// 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.
fn serve(ctx: &egui::Context, listener: UnixListener) {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
match answer(stream) {
Ok(()) => fire(ctx),
let Ok(mut stream) = stream else { continue };
match request(&mut stream) {
// 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
// must not raise the window on a request it never finished.
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
/// this user can connect. The read is bounded in both bytes and time, so
/// a peer that connects and stalls cannot wedge the one thread that
/// answers every activation.
pub(super) fn answer(mut stream: UnixStream) -> std::io::Result<()> {
/// this user can connect. The read is bounded in bytes ([`REQUEST_CAP`])
/// and time, so a peer that connects and stalls, or pours data without a
/// newline, cannot wedge the one thread that answers every activation.
pub(super) fn request(stream: &mut UnixStream) -> std::io::Result<String> {
use std::io::{Error, ErrorKind};
let timeout = std::time::Duration::from_secs(5);
stream.set_read_timeout(Some(timeout))?;
stream.set_write_timeout(Some(timeout))?;
// One byte is the whole request; the cap is what keeps a peer from
// holding this thread for as long as it cares to send.
let mut scratch = [0u8; 1];
stream.read_exact(&mut scratch)?;
let mut buf = [0u8; REQUEST_CAP];
let mut len = 0;
loop {
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.flush()
}
@ -224,11 +331,17 @@ mod imp {
/// The same handshake over a named pipe, which is what Windows has instead
/// of a unix socket.
///
/// **One deliberate difference: there is no reply.** A named pipe exists only
/// while a server holds an instance open — there is no file left behind — so
/// a successful `CreateFileW` already proves a live instance accepted us, and
/// the reply the unix side needs to tell a listener from a leftover socket
/// would be dead weight here.
/// **One deliberate difference: the reply is the foreground grant, not a
/// liveness check.** A named pipe exists only while a server holds an
/// instance open — there is no file left behind — so a successful
/// `CreateFileW` already proves a live instance accepted us. What Windows
/// *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)]
mod imp {
use super::*;
@ -247,6 +360,8 @@ mod imp {
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE,
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
/// the two processes agree by the same rule on both platforms.
@ -300,6 +415,29 @@ mod imp {
return false;
}
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;
// SAFETY: a one-byte buffer and an output slot, both live here.
let ok = unsafe {
@ -349,8 +487,8 @@ mod imp {
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
16,
16,
PID_REPLY_CAP as u32,
PID_REPLY_CAP as u32,
0,
std::ptr::null(),
)
@ -368,6 +506,28 @@ mod imp {
// the read below is what decides, so it is not checked here.
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 read = 0u32;
// SAFETY: a one-byte buffer and an output slot, both live here.
@ -380,12 +540,14 @@ mod imp {
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.
// 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 {
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);
}
/// 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]
fn a_pending_activation_is_consumed_once() {
let _serial = pending_guard();
@ -444,6 +649,23 @@ mod tests {
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
/// collide: they run on one process, in parallel.
#[cfg(unix)]
@ -471,7 +693,8 @@ mod tests {
}
/// 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)]
#[test]
fn a_signal_crosses_the_socket() {
@ -480,18 +703,71 @@ mod tests {
let db = scratch("roundtrip");
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()
.next()
.expect("a connection")
.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");
}
/// 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
/// giving up on it.
#[cfg(unix)]
@ -525,12 +801,15 @@ mod tests {
let peer = UnixStream::connect(&path).expect("connect");
drop(peer);
let stream = listener
let mut stream = listener
.incoming()
.next()
.expect("a connection")
.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
@ -585,6 +864,16 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(20));
}
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");
}
}

View file

@ -10,28 +10,33 @@
//! Hence [`x11_activate`], which sends source indication 2 (EWMH's "direct
//! user action"). Do not replace it with winit's version.
//! * **Wayland**: a client cannot raise itself; the compositor only honours
//! an xdg-activation token, and winit 0.30 applies one in exactly one
//! place — `WindowAttributes::with_activation_token`, at window creation —
//! which egui's `ViewportBuilder` has no field for and eframe therefore
//! never sets. `focus_window` on Wayland is an empty function body. So a
//! `--toggle` that *starts* the app gets whatever focus the compositor
//! gives a newly mapped window, and one that finds it already running
//! cannot raise it at all: the most this path can do is ask for attention,
//! which shows up as a highlighted task entry. Closing that gap means
//! patching both winit (to activate a live surface) and eframe (to carry
//! the token), the way `vendor/` already patches two other crates.
//! * **Windows**: `SetForegroundWindow` is refused to background processes,
//! so the same limit applies to a process that did not just receive input.
//! an xdg-activation token. winit 0.30 applies one in exactly one place —
//! window creation — and its `focus_window` is an empty body, so for a
//! *live* window we speak `xdg_activation_v1` ourselves ([`wayland`]): the
//! `--toggle` sender forwards the `XDG_ACTIVATION_TOKEN` its launcher gave
//! it over the socket, and this process hands it to the compositor for its
//! own surface. Without a token (a manual binding whose launcher minted
//! none) the fallback is a request for attention — a highlighted task
//! entry.
//! * **Windows**: `SetForegroundWindow` is refused to background processes —
//! but the `--toggle` sender was launched by the user's keypress and
//! donates its right over the pipe with `AllowSetForegroundWindow` (see
//! `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
/// raised. The Settings tab says so rather than letting the shortcut look
/// broken; see the module docs.
/// Whether this is a Wayland session, where raising needs an activation
/// token; the Settings tab words its note accordingly. See the module docs.
pub fn is_wayland() -> bool {
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.
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")))]
{
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));
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() {
// 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(
egui::UserAttentionType::Informational,
));
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.
@ -60,6 +80,56 @@ pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
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.
// Thread-local because `raise` only ever runs on the UI thread, and held
// 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
}
}
}
}

View file

@ -388,9 +388,11 @@ impl QuickSearchApp {
ctx.set_zoom_factor(clamp_scale(new.ui.scale));
}
if new.ui.search_hotkey != self.cfg.ui.search_hotkey {
// Only when moved: on Wayland re-registering opens a new portal
// session, which some desktops confirm with the user.
// Only when moved: re-registering an unchanged key would still
// release and re-grab it, a window in which a press is lost.
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 {
apply_theme(ctx, &new.ui.color_scheme);

View file

@ -213,20 +213,20 @@ impl QuickSearchApp {
if actions.focus_search {
self.search.request_focus();
}
// Live, like the Settings slider on Apply — but saved only when the
// drag ends, so crossing the slider does not rewrite the config file
// on every frame.
// Only ever emitted by the page's Apply button, so applying and
// saving belong together — the same pair the Settings tab's Apply
// performs.
if let Some(scale) = actions.set_scale {
self.cfg.ui.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
// button for — the page says it takes effect at once.
if let Some(hotkey) = actions.set_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.save_cfg();
}

View file

@ -2,6 +2,7 @@
use super::*;
use crate::tips::{self, Tipped};
use crate::ui_util::hint;
impl QuickSearchApp {
@ -48,18 +49,19 @@ impl QuickSearchApp {
ui.label(
egui::RichText::new(match (r.total, r.fraction()) {
(Some(total), Some(frac)) => format!(
"Applying configuration change · {} / {} ({:.0}%)",
"Rebuilding FTS cache · {} / {} ({:.0}%)",
group_thousands(r.examined as u64),
group_thousands(total as u64),
frac * 100.0
),
_ => format!(
"Applying configuration change · {} entries",
"Rebuilding FTS cache · {} entries",
group_thousands(r.examined as u64)
),
})
.small(),
);
)
.tip(&tips::REBUILDING_FTS);
progress_widget(ui, r.fraction());
}
ReconcileState::Finished(r) => {
@ -74,21 +76,28 @@ impl QuickSearchApp {
}
}
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 => {
("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) => (
format!(
"Applying configuration change · {} entries",
"Rebuilding FTS cache · {} entries",
group_thousands(r.examined as u64)
),
r.fraction(),
Some(&tips::REBUILDING_FTS),
),
};
ui.label(
let response = ui.label(
egui::RichText::new(format!(
"{} · {}",
label,
@ -96,6 +105,9 @@ impl QuickSearchApp {
))
.small(),
);
if let Some(tip) = tip {
response.tip(tip);
}
progress_widget(ui, frac);
}
IndexingStatus::Idle => {

View file

@ -232,34 +232,50 @@ fn ranking_section(ui: &mut egui::Ui) {
.spacing([CELL_SPACING, 5.0])
.striped(true)
.show(ui, |ui| {
let row = |ui: &mut egui::Ui, tier: &str, what: &str| {
ui.strong(tier);
// Each tier's chip wears the colour its results wear in the Rank
// column, keyed by the *first* cascade stage the collapsed tier
// covers (see the rank table in `search::cascade`): exact name
// 12, name contains 34, text inside 56, fuzzy 78, path 911.
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);
ui.end_row();
};
row(
ui,
1,
"Exact name",
"the file is called exactly what you typed",
);
row(
ui,
3,
"Name contains",
"what you typed appears somewhere in the file's name",
);
row(
ui,
5,
"Text inside",
"the words are in the file's contents, most mentions first",
);
row(
ui,
7,
"Close spelling",
"a name or some text within a typo or two of what you typed, \
only while Fuzzy is ticked",
);
row(
ui,
9,
"Path only",
"nothing in the name or the text matched, but a folder along \
the way did",
@ -502,7 +518,7 @@ const KEY_COL_WIDTH: f32 = 140.0;
const CELL_SPACING: f32 = 14.0;
/// 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
/// `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`
/// of a width we chose settles both. Pinned by
/// `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>) {
ui.allocate_ui(egui::vec2(width, 0.0), |ui| {
ui.add(egui::Label::new(text).wrap());
let galley = text.into().into_galley(
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]
fn a_window_narrower_than_the_column_reflows_rather_than_clipping() {
// 640 is the smallest window the app allows, and the UI scale
// divides it: 400 is roughly that window at 1.6x.
for width in [400.0_f32, 480.0, 560.0, 620.0] {
// divides it: 400 is roughly that window at 1.6x, and 250 is it at
// 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 input = crate::test_ui::raw_input(egui::vec2(width, 6000.0), vec![]);
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
/// narrow layout has to keep it rather than dropping to pattern-only.
#[test]

View file

@ -1,8 +1,8 @@
//! The one representation of a shortcut and its three spellings: the config
//! text (which is also the `global-hotkey` token), and the xkbcommon keysym
//! the XDG shortcuts spec wants for the Wayland portal. All three come out
//! of [`KEYS`], so a key cannot be spelled correctly for one backend and
//! wrongly for the other.
//! The one representation of a shortcut and its spellings: the config text
//! (which is also the `global-hotkey` token), the X11 keysym name (which
//! doubles as the GTK keyval for GNOME bindings), and the Qt key code KDE's
//! KGlobalAccel takes. All of them come out of [`KEYS`] and [`qt_key`], so a
//! key cannot be spelled correctly for one backend and wrongly for another.
use std::fmt;
use std::str::FromStr;
@ -156,18 +156,42 @@ impl Binding {
.expect("every Binding key comes from KEYS")
}
/// The trigger in the XDG shortcuts spec's syntax: uppercase modifiers
/// and an xkbcommon keysym, joined with `+`.
pub fn portal_trigger(&self) -> String {
/// The key combination as the integer `Qt::Key | Qt::Modifier` value
/// KGlobalAccel's DBus `setShortcut` takes. Covered like [`Self::row`]:
/// `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();
for (held, name) in [
(self.ctrl, "CTRL"),
(self.alt, "ALT"),
(self.shift, "SHIFT"),
(self.ctrl, "<Ctrl>"),
(self.alt, "<Alt>"),
(self.shift, "<Shift>"),
] {
if held {
out.push_str(name);
out.push('+');
}
}
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.
pub fn parse_setting(setting: &str) -> Result<Option<Binding>, BindingError> {
if setting.trim().is_empty() {
@ -249,7 +357,40 @@ mod tests {
let cfg = quicksearch_core::config::UiConfig::default();
let binding: Binding = cfg.search_hotkey.parse().expect("the default is valid");
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]
@ -295,7 +436,7 @@ mod tests {
fn modifiers_are_ordered_and_case_insensitive() {
let binding: Binding = "shift+ALT+ctrl+f".parse().unwrap();
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!(
" Ctrl + Shift + F ".parse::<Binding>(),
Ok(binding_of("Ctrl+Shift+F"))

View file

@ -1,7 +1,12 @@
//! The in-application half of the search shortcut: the key QuickSearch
//! claims for itself while it is running. Windows and X11 grant that via
//! `global-hotkey` (`RegisterHotKey`/`XGrabKey`); Wayland refuses grabs by
//! design, so it goes through the XDG portal and the *desktop* picks the key.
//! `global-hotkey` (`RegisterHotKey`/`XGrabKey`). Wayland refuses grabs by
//! 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
//! 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
//! 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
//! process-wide, the event handler is set-once, and on Windows
//! `GlobalHotKeyManager` is not `Send`. Every entry point is inert until
//! [`init`] runs, so headless UI tests never touch an OS registration.
mod binding;
#[cfg(all(unix, not(target_os = "macos")))]
mod portal;
pub use binding::{parse_setting, Binding};
@ -35,30 +43,31 @@ thread_local! {
pub enum Status {
Disabled,
Active,
/// Asked for; the desktop has not answered yet.
Pending,
/// Wayland: described in the desktop's own words, because the desktop,
/// not the setting, decides the key.
PortalBound(String),
/// Wayland without a system binding: the compositor refuses in-app
/// grabs, so only the desktop's own binding can deliver the key.
DesktopOnly,
/// A `crate::shortcut_setup` binding is installed, so the desktop owns
/// 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),
}
struct Registry {
backend: Backend,
/// Everything except the portal, which reports its own asynchronously.
status: Status,
}
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,
/// Windows and X11.
Grab {
manager: GlobalHotKeyManager,
registered: Option<HotKey>,
},
#[cfg(all(unix, not(target_os = "macos")))]
Portal(portal::Portal),
}
/// 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,
Err(message) => {
quicksearch_core::log_warn!("global shortcut: {}", message);
@ -103,6 +112,22 @@ pub fn apply(setting: &str) {
let Some(registry) = slot.as_mut() else {
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) {
Ok(binding) => binding,
Err(e) => {
@ -131,12 +156,6 @@ pub fn apply(setting: &str) {
pub fn status() -> Status {
REGISTRY.with_borrow(|slot| match slot.as_ref() {
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(),
})
}
@ -179,31 +198,21 @@ impl Backend {
*registered = Some(hotkey);
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
/// back: an X11 grab inside a Wayland session succeeds and then only fires
/// while an XWayland window has focus — a broken-looking shortcut.
#[cfg(all(unix, not(target_os = "macos")))]
fn choose_backend(ctx: &egui::Context) -> Result<Backend, String> {
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
return Ok(Backend::Portal(portal::Portal::new(ctx)));
/// A Wayland session gets no backend at all — the desktop's binding is the
/// Wayland path; see the module docs. No X11 grab as a fallback either: it
/// succeeds inside a Wayland session and then only fires while an XWayland
/// window has focus — a broken-looking shortcut.
fn choose_backend() -> Result<Backend, String> {
if crate::activate::raise::is_wayland() {
return Ok(Backend::Idle);
}
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> {
GlobalHotKeyManager::new()
.map(|manager| Backend::Grab {

View file

@ -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));
}

View file

@ -30,6 +30,7 @@ mod platform;
mod query_highlight;
mod search_tab;
mod settings_tab;
mod shortcut_setup;
mod spotlight;
#[cfg(test)]
mod test_ui;
@ -118,8 +119,17 @@ fn main() {
// Losing the race to an instance that came up between the signal
// above and here, or a plain second launch: either way the user
// 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 {
Some(pid) => format!(" (process {})", pid),

View file

@ -447,8 +447,8 @@ impl ManageTab {
egui::TextEdit::multiline(&mut self.ext_filter_text)
.desired_rows(4)
.desired_width(f32::INFINITY)
.hint_text("#EXAMPLE WHITELISTED FILE EXTENSIONS FOR FULL-TEXT-SEARCH:\n#MOUSE \
OVER FOR MORE INFO\n#------------------------------------------------\ntxt\nmd\n \
.hint_text("#EXAMPLE WHITELISTED FILE EXTENSIONS FOR FULL-TEXT-SEARCH\n#MOUSE \
OVER FOR MORE INFO\ntxt\nmd\n \
pdf # comments allowed\n(none)"),
)
.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.
fn reconcile_row(ui: &mut egui::Ui, r: &ReconcileProgress, elapsed: Option<Duration>) {
ui.horizontal(|ui| {
ui.label("Applying configuration change");
ui.label("Rebuilding FTS cache").tip(&tips::REBUILDING_FTS);
ui.label(egui::RichText::new("|").weak());
match (r.total, r.fraction()) {
(Some(total), Some(frac)) => {

View file

@ -628,7 +628,7 @@ fn a_reconcile_reports_how_far_through_the_index_it_is() {
)
.join(" | ");
assert!(text.contains("Applying configuration change"), "{}", text);
assert!(text.contains("Rebuilding FTS cache"), "{}", text);
assert!(
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);
}
/// 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.
#[test]
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(" | ");
assert!(
text.contains("Applying configuration change"),
text.contains("Rebuilding FTS cache"),
"the scan is invisible: {}",
text
);

View file

@ -293,7 +293,7 @@ impl SettingsTab {
);
});
hotkey_note(ui, &draft.ui.search_hotkey, &current.ui.search_hotkey);
shortcut_note(ui);
shortcut_note(ui, &current.ui.search_hotkey);
ui.separator();
// 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);
ui.horizontal(|ui| {
let label = if *capturing {
"Press a key combination...".to_string()
"Press a key combination".to_string()
} else if setting.trim().is_empty() {
"None".to_string()
} 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
/// the button is not what is in force.
fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
use crate::hotkey::Status;
let (text, color) = if draft.trim() != live.trim() {
("Not registered until Apply and Save.".to_string(), None)
let (text, is_error) = if draft.trim() != live.trim() {
("Not registered until Apply and Save.".to_string(), false)
} else {
match 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),
),
}
hotkey_status_line(crate::hotkey::status())
};
let color = is_error.then(|| crate::color::palette(ui.visuals().dark_mode).orange);
crate::ui_util::stable_section(ui, |ui| {
if text.is_empty() {
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.
///
/// 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
/// bind a key that launches something, so this says what to bind and opens
/// the place to bind it. Writing the desktop's own configuration instead was
/// considered and rejected: it differs per desktop and between versions of
/// the same one, and a shortcut we wrote and the user cannot see is worse
/// than one they created.
/// bind a key that launches something. Where the desktop's own configuration
/// has a known home for that binding (`crate::shortcut_setup`), one button
/// writes it — into the place the desktop's settings UI lists and edits, so
/// it stays the user's to see and change. Everywhere else this says what to
/// 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
/// 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());
crate::ui_util::stable_section(ui, |ui| {
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(),
);
// One-click only with a key to write: an unset or unparseable
// shortcut leaves nothing to bind system-wide.
let binding = crate::hotkey::parse_setting(hotkey_setting).ok().flatten();
let one_click = binding.filter(|_| desktop != crate::shortcut_setup::Desktop::Unsupported);
if let Some(binding) = one_click {
// One id for the settings tab and the tour: they show one fact.
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.label(egui::RichText::new(&command).small().monospace());
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() {
ui.label(
egui::RichText::new(
"On Wayland a window that is already open cannot be raised by \
another process, so the shortcut will highlight QuickSearch in \
the task bar rather than bring it to the front.",
"On Wayland, focus follows the shortcut's activation token: \
the binding set up above passes one along, so QuickSearch \
comes to the front. A binding made by hand whose launcher \
provides no token can only highlight QuickSearch in the \
task bar.",
)
.small()
.weak(),
@ -580,30 +711,30 @@ fn security_ui(
}
ui.horizontal(|ui| {
if ui
.button("Change password")
.button("Change password")
.tip(&tips::CHANGE_PASSWORD)
.clicked()
{
action = Some(SecurityAction::ChangePassword);
}
if ui
.button("Disable protection")
.button("Disable protection")
.tip(&tips::DISABLE_PASSWORD)
.clicked()
{
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;
if ui
.checkbox(&mut remember, "Remember on this device")
@ -615,7 +746,7 @@ fn security_ui(
} else {
ui.label("The index is not encrypted.");
if ui
.button("Enable password protection")
.button("Enable password protection")
.tip(&tips::ENABLE_PASSWORD)
.clicked()
{

View file

@ -559,7 +559,7 @@ fn the_key_button_appears_only_while_the_index_is_encrypted() {
let (_, full) = run_security(&ctx, &cfg, vec![]);
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: {:?}",
painted_text(&full)
);
@ -567,7 +567,7 @@ fn the_key_button_appears_only_while_the_index_is_encrypted() {
cfg.security.password_protected = true;
let (_, full) = run_security(&ctx, &cfg, vec![]);
assert!(
painted_text_center(&full, "Show database key").is_some(),
painted_text_center(&full, "Show database key").is_some(),
"no key button while encrypted: {:?}",
painted_text(&full)
);
@ -587,7 +587,7 @@ fn clicking_the_key_button_reports_show_key() {
let (quiet, full) = run_security(&ctx, &cfg, vec![]);
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));
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
/// 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]
fn the_shortcut_note_names_the_command_to_bind() {
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(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");
assert!(
@ -745,3 +773,70 @@ fn the_shortcut_note_names_the_command_to_bind() {
);
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}");
}

View 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"));
}
}

View file

@ -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;
@ -421,13 +422,11 @@ pub static SEARCH_HOTKEY: Tip = Tip {
start typing.\n\n\
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\
This works whenever QuickSearch is running and needs no setting \
up, but it cannot start QuickSearch. For a shortcut that opens it \
too, bind the command shown below in your desktop's own keyboard \
settings.\n\n\
On Wayland your desktop registers the shortcut, so it may pick a \
different key and owns it afterwards, and it decides whether the \
window comes forward.",
While QuickSearch is running it holds this key itself where the \
system allows that (Windows and X11), with no setting up. A key \
that also starts QuickSearch has to be bound by the desktop: the \
set-up button below does that where it can, and on Wayland the \
desktop binding is the only kind there is.",
examples: &[
"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 \
as GNOME Keyring, KWallet, or Windows Credential Manager, so that \
QuickSearch can unlock the index without asking at startup.\n\n\
The password itself is never stored, only the key worked out from \
it, and only on this machine. Off, you type the password each time \
QuickSearch starts.",
The password itself is never stored, only key securely created from \
it, and only on this machine. When off you must type the password each \
time QuickSearch starts.",
examples: &[],
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."),
};
// --- 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 -----------------------------------
pub static ADD_ROOT: Tip = Tip {
@ -590,15 +601,12 @@ pub static REMOVE_ROOT: Tip = Tip {
pub static ROOT_WORKERS: Tip = Tip {
title: "Workers",
body: "How many folders QuickSearch explores at once inside this indexed \
folder. More of them finish sooner on storage that answers many \
requests at a time, which network drives do especially well, but \
they compete for the same disk.\n\n\
folder. More workers finish sooner on faster storage or network shares, \
but they compete for the same disk and could slow your system.\n\n\
auto reads 4 on local storage and 16 on a network mount. Takes \
effect on the next indexing run.",
examples: &[
"auto unless indexing is slower than you would expect.",
"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.",
"You can override to 1-2 to keep indexing low-priority.",
],
caution: None,
};
@ -611,8 +619,7 @@ pub static ROOT_COUNTS: Tip = Tip {
files nothing could read text from (images, videos, archives, \
program binaries) plus anything the extension whitelist \
excludes.\n\n\
Both are counted when an indexing run finishes, so they do not \
move as live updates apply single changes in between.",
Both are updated when an indexing run finishes.",
examples: &[],
caution: None,
};
@ -621,17 +628,15 @@ pub static ROOT_COUNTS: Tip = Tip {
pub static EXT_WHITELIST: Tip = Tip {
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 \
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\
Empty, the default, means every kind QuickSearch understands. To \
off the list cannot be found by the text inside of it.\n\n\
Empty, the default, means every kind of file QuickSearch understands. To \
narrow it, enter one extension per line, the leading dot optional. \
A non-empty list also leaves out files with no extension at all, \
such as Makefile or README, unless you add the line (none). \
Anything after a # is a comment.\n\n\
Narrowing the list discards the text it now excludes; widening it \
reads those files again.",
Anything after a # is a comment.",
examples: &[
"txt, md and pdf to keep the stored text small and focused on documents, \
with everything else still findable by name.",
@ -711,6 +716,7 @@ mod tests {
&STOP_INDEXING,
&RETURN_TO_AUTO,
&CLEAR_INDEX,
&REBUILDING_FTS,
&ADD_ROOT,
&REMOVE_ROOT,
&ROOT_WORKERS,

View file

@ -345,6 +345,9 @@ fn note(ui: &egui::Ui, text: impl Into<egui::RichText>) -> egui::RichText {
struct Live<'a> {
/// `ctx.zoom_factor()`, read before the window is laid out.
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.
hotkey: &'a str,
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();
match extra {
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.label("UI scale");
// Scoped to this row, which is the whole of its use.
ui.spacing_mut().slider_width = 220.0;
let slider = ui.add(
ui.add(
egui::Slider::new(&mut scale, crate::app::SCALE_RANGE)
.step_by(0.05)
.fixed_decimals(2),
);
// Applied on every frame it moves, saved once it settles: a
// drag would otherwise rewrite the config file dozens of
// times on its way across. The release frame is not itself a
// 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 {
let pending = (scale - live.zoom).abs() > f32::EPSILON;
*live.staged_scale = pending.then_some(scale);
if ui.add_enabled(pending, egui::Button::new("Apply")).clicked() {
actions.set_scale = Some(scale);
actions.save_scale = settled;
*live.staged_scale = None;
}
});
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 {
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 set_query: Option<String>,
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>,
/// 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.
pub set_hotkey: Option<String>,
}
@ -473,6 +473,8 @@ pub struct Tutorial {
/// The shortcut button is armed and the next key combination is the
/// answer — the Settings tab's own capture, and its own flag.
capturing_hotkey: bool,
/// The welcome page's scale slider, between moving and Apply.
staged_scale: Option<f32>,
}
impl Tutorial {
@ -483,6 +485,7 @@ impl Tutorial {
typing: None,
moved: 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:
// the closure already holds the page and the actions.
let mut capturing = self.capturing_hotkey;
let mut staged_scale = self.staged_scale;
let mut dismissed = false;
let mut window = egui::Window::new(page.title)
.id(egui::Id::new(WINDOW_ID))
@ -555,6 +559,7 @@ impl Tutorial {
if let Some(extra) = page.extra {
let mut live = Live {
zoom,
staged_scale: &mut staged_scale,
hotkey,
capturing_hotkey: &mut capturing,
};
@ -604,6 +609,7 @@ impl Tutorial {
self.moved = true;
}
self.capturing_hotkey = capturing;
self.staged_scale = staged_scale;
// The widgets this page names, in the colours its keywords were given.
let dark_mode = ctx.style().visuals.dark_mode;

View file

@ -22,6 +22,7 @@ fn at(page: usize) -> Tutorial {
typing: None,
moved: false,
capturing_hotkey: false,
staged_scale: None,
}
}
@ -34,6 +35,7 @@ fn entering(page: usize) -> Tutorial {
typing: None,
moved: 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),
focus_search: a.focus_search || b.focus_search,
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),
}
}
@ -302,9 +303,11 @@ fn drag(
}
/// 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]
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 scale_page = page_with(Extra::Scale);
let mut tour = at(scale_page);
@ -316,28 +319,36 @@ fn the_welcome_page_slider_sets_the_ui_scale() {
.1;
// 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 [_, moved, release] = drag(&ctx, &mut tour, from, from + egui::vec2(120.0, 0.0));
let dragged = moved
.set_scale
.expect("dragging the slider changed nothing");
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)] {
assert_eq!(
actions.set_scale, None,
"the {what} applied the scale without Apply being clicked"
);
}
let staged = tour.staged_scale.expect("the drag staged nothing");
assert!(
crate::app::SCALE_RANGE.contains(&dragged),
"{dragged} is outside the range the slider offers"
crate::app::SCALE_RANGE.contains(&staged),
"{staged} is outside the range the slider offers"
);
assert_ne!(dragged, 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");
assert_ne!(staged, 1.0, "the drag did not move the value");
// And nothing happens on a frame nobody touched it.
let (_, quiet) = pass(&ctx, &mut tour, Vec::new(), 2.0);
// The staged value survives idle frames, then Apply hands it over once.
let (out, quiet) = pass(&ctx, &mut tour, Vec::new(), 2.0);
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

View file

@ -56,17 +56,24 @@ impl Gate {
/// it or a `--toggle` process relayed the desktop's. Handled here because
/// while locked the unlock screen *is* the window.
fn handle_activation(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
if !crate::activate::take_pending() {
return;
}
if let Gate::Running(app) = self {
// 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() {
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);
}
crate::activate::raise(ctx, frame);
crate::activate::raise(ctx, frame, token.as_deref());
}
}

View file

@ -3,7 +3,12 @@
# duplicates.png, query-highlight.png — all landing in packaging/captures/
# (gitignored). Needs a graphical session (X11 or Wayland — screenshots and
# 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
# 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
# make ffmpeg exit on SIGPIPE, which pipefail reports as failure.
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" \
|| { echo "ffmpeg lacks the $enc encoder" >&2; exit 1; }
done
@ -132,6 +137,41 @@ for clip in manage-indexing search; do
done
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 -----------------------------------------------------------------
fail=0
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")
echo "OK: $f (${dims})"
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 ]
# $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.
echo
echo "Assets:"
ls -l "$out"/*.webm "$out"/*.png
echo "Assets (the website needs the .webm and .webp files; PNGs stay here):"
ls -l "$out"/*.webm "$out"/*.png "$out"/*.webp

View file

@ -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
.IR $XDG_RUNTIME_DIR ,
named after the configuration file; when nothing answers, this process becomes
the application. On Wayland an already\-open window cannot be raised by another
process, so it is highlighted in the task bar instead of coming to the front.
the application. On Wayland the window comes forward when the desktop supplied
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
.B \-\-fuzzy
Also run the fuzzy filename and full\-text passes, which tolerate spelling

View file

@ -174,21 +174,27 @@ Section "Start Menu shortcut" SecStartMenu
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" "$INSTDIR\quicksearch.ico"
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
; key to a command, and it is what makes the shortcut work while
; QuickSearch is closed - nothing an application registers for itself can
; 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
; including Ctrl+Alt, which is why this is Ctrl+Alt+F and not the
; Ctrl+Shift+F the Settings tab offers. The in-application shortcut takes
; any combination but only answers while the window is already open, so
; the two are complementary rather than duplicates.
; shortcut in the Start menu or on the desktop. Modifier combinations
; like Ctrl+Shift are accepted as-is; only a bare key gets Ctrl+Alt added
; for it. Ctrl+Shift+F matches the in-application default, which answers
; the key instantly while the window is open; this .lnk binding is the
; 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
; hotkey to an existing .lnk, and creating it twice is harmless.
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}"
SectionEnd
@ -212,9 +218,8 @@ SectionEnd
!insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} \
"Add ${APP} to the Start menu for all users."
!insertmacro MUI_DESCRIPTION_TEXT ${SecHotkey} \
"Press Ctrl+Alt+F anywhere to search, starting ${APP} if it is not \
already running. Windows allows this only on Ctrl+Alt combinations; \
the Settings tab has one that takes any keys while ${APP} is open."
"Press Ctrl+Shift+F anywhere to search, starting ${APP} if it is not \
already running. The Settings tab can rebind it."
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
"Add a ${APP} shortcut to the desktop."
!insertmacro MUI_FUNCTION_DESCRIPTION_END