Some optimizations and another fix for the shortcut system.
This commit is contained in:
parent
22d52d5ac0
commit
658e32159a
32 changed files with 2181 additions and 572 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -3152,21 +3152,20 @@ dependencies = [
|
|||
name = "quicksearch-gui"
|
||||
version = "1.1.7"
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -136,3 +136,103 @@ mod mime_sniff {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/// `repo::update_file_basic`'s state narrowing: an NA→NA update — the whole
|
||||
/// of a re-run over a text-free tree — pays one statement instead of four.
|
||||
/// The pair: `clear_always` reimplements the pre-narrowing shape (the control
|
||||
/// that must not move between builds); `narrowed` is the shipped function.
|
||||
/// NA→NA is idempotent, so iterations are stable without re-seeding.
|
||||
/// DONE→PENDING is deliberately not benched: its statement count is identical
|
||||
/// in both shapes (clearing that must happen either way), so there is nothing
|
||||
/// to regress.
|
||||
mod update_narrowing {
|
||||
use super::*;
|
||||
use quicksearch_core::db::repo::{self, NewFile};
|
||||
use quicksearch_core::mime::FileType;
|
||||
use quicksearch_core::testutil::Scratch;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
const ROWS: usize = 1000;
|
||||
|
||||
fn new_file(name: &str) -> NewFile<'_> {
|
||||
NewFile {
|
||||
name,
|
||||
parent: "/b/",
|
||||
size: 7,
|
||||
mtime: 7,
|
||||
mime: None,
|
||||
ftype: FileType::EMPTY,
|
||||
hash: None,
|
||||
needs_content: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn seeded_na(tag: &str) -> (Scratch, Connection) {
|
||||
let (dir, p) = Scratch::db(tag);
|
||||
let mut conn =
|
||||
quicksearch_core::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||
let tx = conn.transaction().unwrap();
|
||||
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
|
||||
for name in &names {
|
||||
repo::insert_file(&tx, &new_file(name)).unwrap().unwrap();
|
||||
}
|
||||
tx.commit().unwrap();
|
||||
(dir, conn)
|
||||
}
|
||||
|
||||
#[divan::bench]
|
||||
fn clear_always(bencher: Bencher) {
|
||||
let (_dir, mut conn) = seeded_na("bench-update-old");
|
||||
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
|
||||
bencher.bench_local(move || {
|
||||
let tx = conn.transaction().unwrap();
|
||||
for name in &names {
|
||||
let f = new_file(name);
|
||||
let id: i64 = tx
|
||||
.prepare_cached(
|
||||
"UPDATE files
|
||||
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
|
||||
content_state = ?6
|
||||
WHERE parent = ?7 AND name = ?8
|
||||
RETURNING id",
|
||||
)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
params![
|
||||
f.size as i64,
|
||||
f.mtime as i64,
|
||||
f.hash,
|
||||
f.mime,
|
||||
f.ftype.bits() as i64,
|
||||
repo::STATE_NA,
|
||||
f.parent,
|
||||
f.name,
|
||||
],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
repo::remove_content_for_id(&tx, id).unwrap();
|
||||
tx.prepare_cached("DELETE FROM failed_files WHERE file_id = ?1")
|
||||
.unwrap()
|
||||
.execute([id])
|
||||
.unwrap();
|
||||
}
|
||||
tx.commit().unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[divan::bench]
|
||||
fn narrowed(bencher: Bencher) {
|
||||
let (_dir, mut conn) = seeded_na("bench-update-new");
|
||||
let names: Vec<String> = (0..ROWS).map(|i| format!("f{:04}.txt", i)).collect();
|
||||
bencher.bench_local(move || {
|
||||
let tx = conn.transaction().unwrap();
|
||||
for name in &names {
|
||||
repo::update_file_basic(&tx, &new_file(name)).unwrap().unwrap();
|
||||
}
|
||||
tx.commit().unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -254,6 +254,16 @@ const CASES: &[Case] = &[
|
|||
fuzzy: false,
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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 1–4, 9–10) 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 3–5 and k=2 from 6) and only the 9+ ones can
|
||||
/// be prefiltered. Both shapes are in the sequence on purpose: the short
|
||||
/// terms are the control a pass-C narrowing must not move, the long ones are
|
||||
/// what it narrows. `quartzite` is `testutil::NEEDLE`, present in seeded
|
||||
/// names, so the engaged rows return real hits.
|
||||
const FUZZY_SEQUENCE: [&str; 4] = ["quar", "quartz", "quartzite", "quartzites"];
|
||||
|
||||
/// Warm per-term timings, fuzzy off against on, at the shipped cache — the
|
||||
/// gate for the fuzzy filename pass's shape (`passes::pass_fuzzy_filename`).
|
||||
/// The off column is the control: a pass-C change has no business moving it.
|
||||
fn run_fuzzy_pairing(arm: &Arm) {
|
||||
let conn = arm.open_search();
|
||||
conn.execute_batch(&format!("PRAGMA cache_size = {};", SHIPPED_CACHE))
|
||||
.unwrap();
|
||||
// One settling pass per mode so every printed number is warm.
|
||||
for fuzzy in [false, true] {
|
||||
let opts = options(fuzzy);
|
||||
for query in FUZZY_SEQUENCE {
|
||||
time_query(&conn, query, &opts);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"{:>12} {:>10} {:>10} {:>8} (fuzzy pairing, shipped cache, warm)",
|
||||
"term", "off", "on", "hits"
|
||||
);
|
||||
for query in FUZZY_SEQUENCE {
|
||||
let (off, _) = time_query(&conn, query, &options(false));
|
||||
let (on, hits) = time_query(&conn, query, &options(true));
|
||||
println!("{:>12} {:>9.1?} {:>9.1?} {:>8}", query, off, on, hits);
|
||||
}
|
||||
}
|
||||
|
||||
/// One arm at one cache ceiling: the first keystroke on a fresh connection,
|
||||
/// 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)
|
||||
},
|
||||
);
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,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};
|
||||
|
||||
|
|
@ -397,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
|
||||
|
|
@ -875,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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -251,7 +249,7 @@ impl Default for ProcessingConfig {
|
|||
impl Default for SearchConfig {
|
||||
fn default() -> Self {
|
||||
SearchConfig {
|
||||
fuzzy_default: true,
|
||||
fuzzy_default: false,
|
||||
fuzzy_max_edits: 2,
|
||||
display_limit: 1000,
|
||||
results_per_page: 100,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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))
|
||||
}
|
||||
|
||||
|
|
@ -382,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
|
||||
|
|
|
|||
|
|
@ -587,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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -635,3 +635,159 @@ fn a_bulk_write_repairs_a_delete_merge_threshold_left_off() {
|
|||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Seed one row per content state under `/t/`, plus a survivor. Returns
|
||||
/// `name -> id`. States: the two `done-*` rows hold postings, the others do
|
||||
/// not — the distinction stale cleanup's `with_postings` narrowing draws.
|
||||
#[cfg(unix)]
|
||||
fn seed_stale_corpus(
|
||||
conn: &mut rusqlite::Connection,
|
||||
) -> std::collections::HashMap<&'static str, i64> {
|
||||
use crate::db::repo::{self, NewFile};
|
||||
let tx = conn.transaction().unwrap();
|
||||
let mut ids = std::collections::HashMap::new();
|
||||
for (name, needs_content) in [
|
||||
("done-stale.txt", true),
|
||||
("done-kept.txt", true),
|
||||
("na-stale.bin", false),
|
||||
("pending-stale.txt", true),
|
||||
("failed-stale.txt", true),
|
||||
] {
|
||||
let id = repo::insert_file(
|
||||
&tx,
|
||||
&NewFile {
|
||||
name,
|
||||
parent: "/t/",
|
||||
size: 1,
|
||||
mtime: 1,
|
||||
mime: Some("text/plain"),
|
||||
ftype: crate::mime::FileType::TEXT,
|
||||
hash: None,
|
||||
needs_content,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.expect("unique path");
|
||||
ids.insert(name, id);
|
||||
}
|
||||
repo::set_content_done(&tx, ids["done-stale.txt"], "body one", None).unwrap();
|
||||
repo::set_content_done(&tx, ids["done-kept.txt"], "body two", None).unwrap();
|
||||
repo::set_content_failed(&tx, ids["failed-stale.txt"], "bad parse").unwrap();
|
||||
tx.commit().unwrap();
|
||||
ids
|
||||
}
|
||||
|
||||
/// The stale pass over rows in every content state: exactly the stale rows
|
||||
/// go, a posting survives exactly for the surviving DONE row, and the
|
||||
/// delete-merge threshold the pass held off is back afterwards. An orphan
|
||||
/// posting would surface as a hit for a file no longer indexed, with no error
|
||||
/// anywhere — the failure mode this pins.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn stale_cleanup_deletes_exactly_the_stale_rows_and_their_postings() {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let dir = crate::testutil::scratch_dir("stale-cleanup");
|
||||
let db = dir.join("index.sqlite");
|
||||
let mut conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
|
||||
let ids = seed_stale_corpus(&mut conn);
|
||||
|
||||
// `vanished.txt` was never indexed: a stale list can legitimately name a
|
||||
// path another writer already removed, and it must not fail the pass.
|
||||
let stale: Vec<String> = [
|
||||
"done-stale.txt",
|
||||
"na-stale.bin",
|
||||
"pending-stale.txt",
|
||||
"failed-stale.txt",
|
||||
"vanished.txt",
|
||||
]
|
||||
.iter()
|
||||
.map(|n| format!("/t/{}", n))
|
||||
.collect();
|
||||
|
||||
let conn_mutex = Arc::new(Mutex::new(conn));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let deleted =
|
||||
cleanup_stale_index_entries(&conn_mutex, &stale, &stop, &crate::config::Config::default())
|
||||
.unwrap();
|
||||
assert_eq!(deleted, 4);
|
||||
|
||||
let conn = Arc::try_unwrap(conn_mutex)
|
||||
.map_err(|_| ())
|
||||
.expect("sole owner")
|
||||
.into_inner()
|
||||
.unwrap();
|
||||
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
||||
assert_eq!(count("SELECT COUNT(*) FROM files"), 1);
|
||||
assert_eq!(count("SELECT COUNT(*) FROM searchabletext"), 1);
|
||||
assert_eq!(count("SELECT COUNT(*) FROM documents_text"), 0);
|
||||
assert_eq!(count("SELECT COUNT(*) FROM failed_files"), 0);
|
||||
let hit: i64 = conn
|
||||
.query_row(
|
||||
"SELECT rowid FROM searchabletext WHERE searchabletext MATCH 'body'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hit, ids["done-kept.txt"], "the survivor is still findable");
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
i64::from(FTS_DELETEMERGE),
|
||||
"the pass restored the threshold it held off"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// A stop mid-pass returns without restoring `deletemerge` — the abandonment
|
||||
/// semantics `a_bulk_write_repairs_a_delete_merge_threshold_left_off` exists
|
||||
/// for. If this ever starts restoring it eagerly, that test loses its
|
||||
/// subject; if it starts deleting under a stop, a stopped run reconciles.
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn a_stopped_stale_cleanup_deletes_nothing_and_leaves_the_burst() {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
let dir = crate::testutil::scratch_dir("stale-cleanup-stop");
|
||||
let db = dir.join("index.sqlite");
|
||||
let mut conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
|
||||
seed_stale_corpus(&mut conn);
|
||||
|
||||
let conn_mutex = Arc::new(Mutex::new(conn));
|
||||
let stop = Arc::new(AtomicBool::new(true));
|
||||
let deleted = cleanup_stale_index_entries(
|
||||
&conn_mutex,
|
||||
&["/t/done-stale.txt".to_string()],
|
||||
&stop,
|
||||
&crate::config::Config::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(deleted, 0);
|
||||
|
||||
let conn = Arc::try_unwrap(conn_mutex)
|
||||
.map_err(|_| ())
|
||||
.expect("sole owner")
|
||||
.into_inner()
|
||||
.unwrap();
|
||||
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
|
||||
assert_eq!(count("SELECT COUNT(*) FROM files"), 5, "a stop deletes nothing");
|
||||
assert_eq!(
|
||||
conn.query_row(
|
||||
"SELECT v FROM searchabletext_config WHERE k = 'deletemerge'",
|
||||
[],
|
||||
|r| r.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0,
|
||||
"the burst is left for the next run's bulk write to repair"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,13 @@ use super::*;
|
|||
/// wholesale — or newly excluded — is never read, so per-directory
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -75,18 +75,6 @@ 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
|
||||
|
|
@ -94,6 +82,16 @@ pollster = "0.4"
|
|||
# adds no crate.
|
||||
x11rb = "0.13"
|
||||
|
||||
# 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
|
||||
# here can do. A dev-dependency feature, so the shipped binary never gets it —
|
||||
|
|
|
|||
|
|
@ -7,17 +7,15 @@
|
|||
//! 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. On
|
||||
//! unix the reply exists only so the sender can tell a live instance from a
|
||||
//! leftover socket; on Windows it instead carries the server's PID, which
|
||||
//! the sender feeds to `AllowSetForegroundWindow` so the running window may
|
||||
//! actually take the foreground — see the `#[cfg(windows)]` module below.
|
||||
//! 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;
|
||||
|
||||
|
|
@ -30,6 +28,12 @@ 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.
|
||||
|
|
@ -97,6 +101,12 @@ pub fn take_pending() -> bool {
|
|||
PENDING.swap(false, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 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 to bind, as they would type it. 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
|
||||
|
|
@ -125,6 +135,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();
|
||||
}
|
||||
|
|
@ -137,6 +154,11 @@ mod imp {
|
|||
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
|
||||
|
|
@ -144,7 +166,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;
|
||||
};
|
||||
|
|
@ -156,7 +189,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
|
||||
|
|
@ -215,8 +259,8 @@ mod imp {
|
|||
// 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(()) => {
|
||||
fire(ctx);
|
||||
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
|
||||
|
|
@ -226,21 +270,40 @@ mod imp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Read the request.
|
||||
/// 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 request(stream: &mut 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
|
||||
|
|
@ -548,6 +611,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)]
|
||||
|
|
@ -575,7 +655,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() {
|
||||
|
|
@ -584,19 +665,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 mut stream = listener
|
||||
.incoming()
|
||||
.next()
|
||||
.expect("a connection")
|
||||
.expect("accepted");
|
||||
imp::request(&mut 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)]
|
||||
|
|
|
|||
|
|
@ -10,34 +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.
|
||||
//! 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 the portal-less
|
||||
//! case with no grant — e.g. some other process poking the pipe — degrades
|
||||
//! to a taskbar flash.
|
||||
//! 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) {
|
||||
|
|
@ -47,9 +46,17 @@ 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,
|
||||
));
|
||||
|
|
@ -58,13 +65,14 @@ pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
|
|||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = token;
|
||||
if win32_activate(frame) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(all(unix, not(target_os = "macos")), windows)))]
|
||||
{
|
||||
let _ = frame;
|
||||
let _ = (frame, token);
|
||||
}
|
||||
|
||||
// A window still minimised cannot take focus.
|
||||
|
|
@ -289,3 +297,179 @@ fn x11_activate(frame: &eframe::Frame) -> bool {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Wayland activation path. winit applies an activation token in exactly
|
||||
/// one place — window creation — so for a live window we speak the protocol
|
||||
/// ourselves, over winit's own connection: `from_foreign_display` wraps its
|
||||
/// `wl_display` as a "guest" backend with a private libwayland event queue.
|
||||
/// Roundtrips here dispatch only that queue; events for winit's objects stay
|
||||
/// queued for winit, and dropping the guest never disconnects the display
|
||||
/// (`owns_display: false`).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
mod wayland {
|
||||
use std::cell::RefCell;
|
||||
use std::error::Error;
|
||||
use std::ffi::c_void;
|
||||
|
||||
use raw_window_handle::{
|
||||
HasDisplayHandle, HasWindowHandle, RawDisplayHandle, RawWindowHandle,
|
||||
};
|
||||
use wayland_client::backend::{Backend, ObjectId};
|
||||
use wayland_client::protocol::{wl_registry, wl_surface::WlSurface};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle};
|
||||
use wayland_protocols::xdg::activation::v1::client::xdg_activation_v1::XdgActivationV1;
|
||||
|
||||
// Kept open across presses like the X11 state above, and for one more
|
||||
// reason: wl_registry has no destructor request, so a fresh connection
|
||||
// per press would leak a server-side registry every time.
|
||||
thread_local! {
|
||||
static WAYLAND: RefCell<Option<WaylandState>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct WaylandState {
|
||||
/// The wl_display this guest connection wraps — winit's. Compared on
|
||||
/// every press so a different display gets a fresh connection.
|
||||
display_ptr: *mut c_void,
|
||||
conn: Connection,
|
||||
queue: EventQueue<ActivationGlobals>,
|
||||
globals: ActivationGlobals,
|
||||
}
|
||||
|
||||
struct ActivationGlobals {
|
||||
activation: Option<XdgActivationV1>,
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, ()> for ActivationGlobals {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version: _,
|
||||
} = event
|
||||
{
|
||||
if interface == "xdg_activation_v1" && state.activation.is_none() {
|
||||
// We speak version 1; a compositor that advertises the
|
||||
// global supports at least that.
|
||||
state.activation =
|
||||
Some(registry.bind::<XdgActivationV1, _, _>(name, 1, qh, ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xdg_activation_v1 has no events; `unreachable!()` if that ever changes.
|
||||
wayland_client::delegate_noop!(ActivationGlobals: XdgActivationV1);
|
||||
|
||||
impl WaylandState {
|
||||
fn connect(display_ptr: *mut c_void) -> Result<Self, Box<dyn Error>> {
|
||||
// SAFETY: `display_ptr` is the wl_display of winit's live
|
||||
// connection, which outlives every `Frame` we are handed. Guest
|
||||
// mode: on drop only our private queue and our own proxies are
|
||||
// destroyed, never the display itself.
|
||||
let backend = unsafe { Backend::from_foreign_display(display_ptr.cast()) };
|
||||
let conn = Connection::from_backend(backend);
|
||||
let mut queue = conn.new_event_queue::<ActivationGlobals>();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
let mut globals = ActivationGlobals { activation: None };
|
||||
// Blocks for one compositor round trip (local socket, well under
|
||||
// a frame) and dispatches only this queue.
|
||||
queue.roundtrip(&mut globals)?;
|
||||
if globals.activation.is_none() {
|
||||
return Err("the compositor does not support xdg_activation_v1".into());
|
||||
}
|
||||
Ok(WaylandState {
|
||||
display_ptr,
|
||||
conn,
|
||||
queue,
|
||||
globals,
|
||||
})
|
||||
}
|
||||
|
||||
fn activate(&mut self, surface_ptr: *mut c_void, token: &str) -> Result<(), Box<dyn Error>> {
|
||||
// Drain registry chatter (global add/remove) accumulated since
|
||||
// the last press so the queue buffer cannot grow over a session.
|
||||
self.queue.dispatch_pending(&mut self.globals)?;
|
||||
let activation = self
|
||||
.globals
|
||||
.activation
|
||||
.as_ref()
|
||||
.ok_or("xdg_activation_v1 disappeared from the registry")?;
|
||||
|
||||
// SAFETY: `surface_ptr` is winit's live wl_surface for the very
|
||||
// window we are raising; it outlives this call. `from_ptr`
|
||||
// verifies the interface really is wl_surface.
|
||||
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface_ptr.cast()) }?;
|
||||
let surface = WlSurface::from_id(&self.conn, id)?;
|
||||
|
||||
activation.activate(token.to_owned(), &surface);
|
||||
// A roundtrip rather than a bare flush: it proves the compositor
|
||||
// consumed the request, so a connection that died since the last
|
||||
// press surfaces as an error here (and the cache is dropped)
|
||||
// instead of "succeeding" into a closed socket forever.
|
||||
self.queue.roundtrip(&mut self.globals)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Activate our already-mapped window with an xdg-activation token minted
|
||||
/// by the compositor for a real user action. `false` when this is not a
|
||||
/// Wayland window, the compositor lacks `xdg_activation_v1`, or anything
|
||||
/// on the way failed — the caller then falls back to a request for
|
||||
/// attention. Note the compositor may still quietly downgrade a stale
|
||||
/// token to that same attention hint; v1 has no error event to say so.
|
||||
pub(super) fn activate(frame: &eframe::Frame, token: &str) -> bool {
|
||||
let display_ptr = match frame.display_handle().map(|h| h.as_raw()) {
|
||||
Ok(RawDisplayHandle::Wayland(w)) => w.display.as_ptr(),
|
||||
Ok(other) => {
|
||||
quicksearch_core::log_warn!("raising the window: not a Wayland display: {:?}", other);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
quicksearch_core::log_warn!("raising the window: no display handle: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let surface_ptr = match frame.window_handle().map(|h| h.as_raw()) {
|
||||
Ok(RawWindowHandle::Wayland(w)) => w.surface.as_ptr(),
|
||||
Ok(other) => {
|
||||
quicksearch_core::log_warn!("raising the window: not a Wayland window: {:?}", other);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
quicksearch_core::log_warn!("raising the window: no window handle: {}", e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let sent = WAYLAND.with(|slot| -> Result<(), Box<dyn Error>> {
|
||||
let mut slot = slot.borrow_mut();
|
||||
// A cached connection is only good for the display it wraps.
|
||||
if slot.as_ref().is_some_and(|s| s.display_ptr != display_ptr) {
|
||||
*slot = None;
|
||||
}
|
||||
let state = match slot.as_mut() {
|
||||
Some(state) => state,
|
||||
None => slot.insert(WaylandState::connect(display_ptr)?),
|
||||
};
|
||||
state.activate(surface_ptr, token)
|
||||
});
|
||||
match sent {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
quicksearch_core::log_warn!("raising the window: {}", e);
|
||||
// Do not reuse a connection that failed mid-way; the next
|
||||
// press reconnects.
|
||||
WAYLAND.with(|slot| slot.borrow_mut().take());
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -388,8 +388,8 @@ 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);
|
||||
|
|
|
|||
|
|
@ -518,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
|
||||
|
|
@ -529,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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -674,6 +688,97 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -161,9 +161,9 @@ impl Binding {
|
|||
/// `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 two spellings below exist for the desktops
|
||||
/// `crate::shortcut_setup` and [`super::portal`] write to, all of which
|
||||
/// are unix; off unix nothing calls them and the lint would say so.
|
||||
/// 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);
|
||||
|
|
@ -197,25 +197,6 @@ impl Binding {
|
|||
out.push_str(self.row().1);
|
||||
out
|
||||
}
|
||||
|
||||
/// The trigger in the XDG shortcuts spec's syntax: uppercase modifiers
|
||||
/// and an xkbcommon keysym, joined with `+`.
|
||||
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||
pub fn portal_trigger(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for (held, name) in [
|
||||
(self.ctrl, "CTRL"),
|
||||
(self.alt, "ALT"),
|
||||
(self.shift, "SHIFT"),
|
||||
] {
|
||||
if held {
|
||||
out.push_str(name);
|
||||
out.push('+');
|
||||
}
|
||||
}
|
||||
out.push_str(self.row().1);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Binding {
|
||||
|
|
@ -376,7 +357,6 @@ 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");
|
||||
}
|
||||
|
||||
|
|
@ -456,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"))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -446,47 +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,
|
||||
),
|
||||
// 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
|
||||
),
|
||||
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;
|
||||
|
|
@ -499,6 +464,44 @@ 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
|
||||
|
|
@ -572,6 +575,9 @@ fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::sh
|
|||
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)),
|
||||
}
|
||||
|
|
@ -581,12 +587,26 @@ fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::sh
|
|||
.clicked()
|
||||
{
|
||||
match crate::shortcut_setup::install(&binding) {
|
||||
Ok(()) => {
|
||||
Ok(when) => {
|
||||
state.installed = true;
|
||||
state.feedback = Some((
|
||||
true,
|
||||
"Added to your desktop's keyboard shortcuts.".to_string(),
|
||||
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)),
|
||||
}
|
||||
|
|
@ -631,9 +651,11 @@ fn shortcut_note_for(ui: &mut egui::Ui, hotkey_setting: &str, desktop: crate::sh
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -729,6 +729,27 @@ 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 — with or
|
||||
/// without a one-click desktop to lean on.
|
||||
|
|
|
|||
|
|
@ -76,14 +76,27 @@ pub fn installed() -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<(), String> {
|
||||
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),
|
||||
Desktop::Gnome => gnome::install(binding, &command).map(|()| Installed::Immediately),
|
||||
Desktop::Kde => kde::install(binding, &command),
|
||||
Desktop::Unsupported => Err(UNSUPPORTED.to_string()),
|
||||
}
|
||||
|
|
@ -271,40 +284,40 @@ fn format_string_list(paths: &[String]) -> String {
|
|||
format!("[{}]", quoted.join(", "))
|
||||
}
|
||||
|
||||
/// KDE: registration with the kglobalaccel daemon over DBus, which is the
|
||||
/// only writer `kglobalshortcutsrc` has — the daemon rewrites that file at
|
||||
/// will and *drops* groups it did not create, so editing it directly (the
|
||||
/// first version of this module) produced an entry that neither fired nor
|
||||
/// survived. `setShortcut` takes effect immediately and the daemon does its
|
||||
/// own persisting.
|
||||
/// 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:
|
||||
///
|
||||
/// What the key *runs* is a small desktop file in
|
||||
/// `~/.local/share/kglobalaccel/`, the directory Plasma itself uses for
|
||||
/// custom command shortcuts: a component whose name ends in `.desktop`
|
||||
/// resolves to that file, and its `_launch` action runs the `Exec` line.
|
||||
/// The file's presence is also this module's "installed" marker — written
|
||||
/// last on install, deleted on remove, and free of a per-frame DBus call.
|
||||
/// * 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.
|
||||
///
|
||||
/// Verified live against Plasma 6.6: register → the key launches a closed
|
||||
/// QuickSearch; `unregister` → the daemon drops the entry from its config.
|
||||
/// 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;
|
||||
|
||||
/// Ends in `.desktop`: what makes the daemon treat the component as a
|
||||
/// service it can launch rather than an app that must be running.
|
||||
/// 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";
|
||||
/// KGlobalAccel's NoAutoloading flag: set the key now, not merely as a
|
||||
/// default for the next load.
|
||||
const SET_NOW: &str = "4";
|
||||
|
||||
/// The action id every `org.kde.KGlobalAccel` call takes, in GVariant
|
||||
/// text: `[component, action, component friendly, action friendly]`.
|
||||
pub(super) fn action_id() -> String {
|
||||
format!("['{}', '{}', 'QuickSearch', 'QuickSearch']", COMPONENT, ACTION)
|
||||
}
|
||||
|
||||
/// One `gdbus call` against the daemon. gdbus over qdbus because it
|
||||
/// takes GVariant text for the list arguments, which qdbus cannot spell.
|
||||
|
|
@ -324,81 +337,187 @@ mod kde {
|
|||
run("gdbus", &argv)
|
||||
}
|
||||
|
||||
/// Where the launch target lives; the daemon looks here by name.
|
||||
pub(super) fn desktop_file() -> PathBuf {
|
||||
let data = std::env::var_os("XDG_DATA_HOME")
|
||||
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")
|
||||
});
|
||||
data.join("kglobalaccel").join(COMPONENT)
|
||||
})
|
||||
}
|
||||
|
||||
/// `NoDisplay`: the entry exists to be launched by a key, not to appear
|
||||
/// in menus next to the real QuickSearch entry.
|
||||
pub(super) fn desktop_entry(command: &str) -> String {
|
||||
/// 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\nExec={}\n",
|
||||
command
|
||||
"[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()
|
||||
desktop_file().is_file() || legacy_desktop_file().is_file()
|
||||
}
|
||||
|
||||
pub(super) fn install(binding: &Binding, command: &str) -> Result<(), String> {
|
||||
let id = action_id();
|
||||
call("doRegister", &[&id])?;
|
||||
let keys = format!("[{}]", binding.qt_key_code());
|
||||
let reply = call("setShortcut", &[&id, &keys, SET_NOW])?;
|
||||
// The daemon answers with the keys now in force; ours missing means
|
||||
// it kept something else — the key is taken.
|
||||
if !reply_ints(&reply).contains(&binding.qt_key_code()) {
|
||||
let _ = call("unregister", &[COMPONENT, ACTION]);
|
||||
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!(
|
||||
"your desktop refused {} — probably already in use",
|
||||
binding
|
||||
"{} is taken by \"{}\" ({}); free it under System Settings → \
|
||||
Keyboard → Shortcuts, or pick a different combination",
|
||||
binding, holder.action_friendly, holder.component_friendly,
|
||||
));
|
||||
}
|
||||
// The file last: it is the installed marker, so nothing marks this
|
||||
// installed until the key is actually in force.
|
||||
// An earlier build's file in the old location would leave a second
|
||||
// component claiming a key; gone before the new one appears.
|
||||
let _ = std::fs::remove_file(legacy_desktop_file());
|
||||
|
||||
// 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::create_dir_all(dir)
|
||||
.map_err(|e| format!("creating {}: {}", dir.display(), e))?;
|
||||
}
|
||||
std::fs::write(&path, desktop_entry(command))
|
||||
std::fs::write(&path, desktop_entry(command, binding))
|
||||
.map_err(|e| format!("writing {}: {}", path.display(), e))?;
|
||||
// Executable, or KConfig refuses to trust the file's Exec line
|
||||
// ("not owned by root and executable flag not set") — the same bit
|
||||
// Plasma's own Shortcuts page sets on the files it creates here.
|
||||
// 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))?;
|
||||
Ok(())
|
||||
|
||||
// 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> {
|
||||
call("unregister", &[COMPONENT, ACTION])?;
|
||||
let path = desktop_file();
|
||||
// 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(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("deleting {}: {}", path.display(), e)),
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The integers out of a gdbus reply like `([100663366],)`. Wrong or
|
||||
/// hostile shapes yield fewer integers, never a panic.
|
||||
pub(super) fn reply_ints(reply: &str) -> Vec<u32> {
|
||||
reply
|
||||
.split(|c: char| !c.is_ascii_digit())
|
||||
.filter(|s| !s.is_empty())
|
||||
.filter_map(|s| s.parse().ok())
|
||||
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
|
||||
|
|
@ -500,36 +619,47 @@ mod tests {
|
|||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn the_kde_desktop_entry_launches_the_toggle() {
|
||||
let entry = kde::desktop_entry("/opt/qs/quicksearch --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"));
|
||||
assert!(kde::desktop_file().ends_with("kglobalaccel/quicksearch-search.desktop"));
|
||||
// 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 GVariant action id every KGlobalAccel call names; `_launch` is
|
||||
/// the action that runs a `.desktop` component's Exec.
|
||||
|
||||
/// 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 the_kde_action_id_names_the_launch_action() {
|
||||
assert_eq!(
|
||||
kde::action_id(),
|
||||
"['quicksearch-search.desktop', '_launch', 'QuickSearch', 'QuickSearch']"
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// gdbus replies, including hostile ones, must parse to integers or to
|
||||
/// nothing — never panic.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn gdbus_replies_parse_to_integers_or_nothing() {
|
||||
assert_eq!(kde::reply_ints("([100663366],)"), [100663366]);
|
||||
assert_eq!(kde::reply_ints("([1, 2],)"), [1, 2]);
|
||||
assert_eq!(kde::reply_ints("([],)"), Vec::<u32>::new());
|
||||
for garbage in ["", "(true,)", "nonsense", "([99999999999999999999],)"] {
|
||||
let _ = kde::reply_ints(garbage);
|
||||
}
|
||||
}
|
||||
|
||||
/// A path with a space would otherwise split into a broken Exec line.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
|
|
|
|||
|
|
@ -422,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."
|
||||
],
|
||||
|
|
@ -567,7 +565,7 @@ pub static REBUILDING_FTS: Tip = Tip {
|
|||
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!",
|
||||
frozen while it runs. You can still search during this time.",
|
||||
examples: &[],
|
||||
caution: None,
|
||||
};
|
||||
|
|
@ -603,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,
|
||||
};
|
||||
|
|
@ -624,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,
|
||||
};
|
||||
|
|
@ -634,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.",
|
||||
|
|
|
|||
|
|
@ -67,10 +67,13 @@ impl Gate {
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue