Added system-wide shortcut to launch app and search even while not running on both Windows and Linux.
Some checks failed
CI / windows-cross (push) Has been cancelled
CI / release (push) Has been cancelled
CI / linux (push) Has been cancelled

This commit is contained in:
Jeremy Karst 2026-09-04 21:39:54 -04:00
parent a9b23bc7a6
commit fe832109d7
52 changed files with 5059 additions and 1175 deletions

View file

@ -5,6 +5,9 @@ name: CI
# DEFAULT_ACTIONS_URL, which points at data.forgejo.org, so nothing here reaches # DEFAULT_ACTIONS_URL, which points at data.forgejo.org, so nothing here reaches
# out to github.com. # out to github.com.
# Release branches and tags only. Master is deliberately not a trigger: nothing
# runs on a push to it or on a pull request against it, so a merge is unverified
# until a release is cut or someone runs this by hand from the Actions tab.
on: on:
push: push:
# Pushing a branch whose name starts with Release cuts a release: the version # Pushing a branch whose name starts with Release cuts a release: the version
@ -13,15 +16,16 @@ on:
# there because branch names are case-sensitive and a silent no-op would be a # there because branch names are case-sensitive and a silent no-op would be a
# miserable thing to debug, as is the fact that * does not match / in these # miserable thing to debug, as is the fact that * does not match / in these
# filters - Release/0.9.1 needs the ** form to be seen at all. # filters - Release/0.9.1 needs the ** form to be seen at all.
branches: [master, 'Release*', 'Release/**', 'release*', 'release/**'] branches: ['Release*', 'Release/**', 'release*', 'release/**']
tags: ['v*'] tags: ['v*']
pull_request:
branches: [master]
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }} # Every run that reaches this workflow can publish a release, so none of them
# are safe to cancel partway through - an interrupted run can leave a release
# created with only some of its assets uploaded.
cancel-in-progress: false
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@ -205,10 +209,12 @@ jobs:
# Everything from here down produces release assets, so it runs only # Everything from here down produces release assets, so it runs only
# where a release can actually come out: a v* tag or a Release* branch. # where a release can actually come out: a v* tag or a Release* branch.
# On master and pull requests the job stops after Build and Test, which # The gate still earns its place now that those are the only push
# is what those runs are for — the packaging that used to follow built a # triggers, because workflow_dispatch is not restricted to them: running
# .deb, an AppImage, a tarball and a 14-day artifact upload that nothing # this by hand against master is the way to build and test that branch,
# would ever download, because the release job is skipped there anyway. # and it stops after Build and Test rather than packaging a .deb, an
# AppImage, a tarball and a 14-day artifact upload that nothing would
# ever download, since the release job is skipped on that ref anyway.
# #
# The condition is repeated rather than hoisted into an env var: Actions # The condition is repeated rather than hoisted into an env var: Actions
# has no workflow-level expression alias, YAML anchors are not supported, # has no workflow-level expression alias, YAML anchors are not supported,
@ -296,8 +302,9 @@ jobs:
# node16, which current Forgejo runner images no longer ship; the # node16, which current Forgejo runner images no longer ship; the
# -node20 tags are Forgejo's builds for precisely this combination. # -node20 tags are Forgejo's builds for precisely this combination.
- uses: actions/upload-artifact@v3-node20 - uses: actions/upload-artifact@v3-node20
# `if-no-files-found: error` below would fail every master push once the # `if-no-files-found: error` below would fail any hand-dispatched run
# packaging steps above are gated, so this carries the same gate. # off a release ref, where the packaging steps above are skipped and
# dist/ stays empty, so this carries the same gate.
if: >- if: >-
startsWith(github.ref, 'refs/tags/v') startsWith(github.ref, 'refs/tags/v')
|| startsWith(github.ref, 'refs/heads/Release') || startsWith(github.ref, 'refs/heads/Release')
@ -396,8 +403,8 @@ jobs:
# As in the linux job, the asset-producing steps run only where a release # As in the linux job, the asset-producing steps run only where a release
# can come out. The DLL check above deliberately stays ungated: it # can come out. The DLL check above deliberately stays ungated: it
# validates the .exe itself rather than packaging it, costs an objdump, # validates the .exe itself rather than packaging it, costs an objdump,
# and is exactly the kind of regression worth catching on master rather # and is exactly the kind of regression worth catching on a hand-run
# than at release time. # master build rather than at release time.
- name: Build the installer - name: Build the installer
if: >- if: >-
startsWith(github.ref, 'refs/tags/v') startsWith(github.ref, 'refs/tags/v')

4
Cargo.lock generated
View file

@ -3108,7 +3108,7 @@ dependencies = [
[[package]] [[package]]
name = "quicksearch-core" name = "quicksearch-core"
version = "1.1.6" version = "1.1.7"
dependencies = [ dependencies = [
"argon2", "argon2",
"cfb", "cfb",
@ -3150,7 +3150,7 @@ dependencies = [
[[package]] [[package]]
name = "quicksearch-gui" name = "quicksearch-gui"
version = "1.1.6" version = "1.1.7"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"chrono", "chrono",

View file

@ -12,7 +12,7 @@ pdf-extract = { path = "vendor/pdf-extract" } # Patched unbounded reads which co
rtf-parser = { path = "vendor/rtf-parser" } # Patched a parsing error which occurs on UTF-16 characters rtf-parser = { path = "vendor/rtf-parser" } # Patched a parsing error which occurs on UTF-16 characters
[workspace.package] [workspace.package]
version = "1.1.6" version = "1.1.7"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"] authors = ["Jeremy <jeremy@karsttech.com>"]

100
DESIGN.md Normal file
View file

@ -0,0 +1,100 @@
# QuickSearch design notes
A brief record of the design choices and the reasoning behind them. Exact
behavior is specified by the code and its comments. This document covers architecture and design.
## Shape of codebase
`quicksearch-core` is the library (indexing, storage, search)
`quicksearch-gui` is the app binary (an immediate-mode egui desktop)
The built binary doubles as the GUI and a terminal search tool.
The backend is synchronous Rust: plain threads and mpsc channels,
no async runtime. The workloads are a small, fixed set of long-lived
workers, which threads express more simply than a runtime would.
## Optimization
A great deal of effort has gone into measuring and improving on
performance and efficiency of the software. A benchmark suite which
includes cpu time, peak memory, memory churn, and indexing / search speed
is included for measuring and tracking these aspects.
## Storage
The index is a single SQLite database: file metadata, an FTS5 full-text
index over document contents, and the extracted text itself stored
zstd-compressed for snippets and ranking. The database runs in WAL mode
with exactly one writer at a time, so read-only searches are never blocked
by indexing. Schema changes wipe and rebuild the index rather than
migrating. The index is a cache of the filesystem, so rebuilding is always
safe, and only the indexer is allowed to do it. FTS5 is used in trigram mode
for efficient full text search and also used to accelerate regex
and fuzzy searches. Careful managment and tuning of this SQLite database
is central to the indexing and search performance of QuickSearch.
## The search path never stalls
Searching answers from the index, verifies cheaply, and repairs
asynchronously. Searches don't wait on the filesystem or the indexer. Each
keystroke interrupts the previous query, results stream out in rank order,
and weaker matches only ever append to the bottom of the list, so what you
have already seen never reshuffles (when sorting by rank as the default).
After the index results are retrieved, they are verified against the disk.
When the screen and the disk disagree, a small watcher re-checks the visible
rows against the disk and feeds corrections back to the indexer, which is
what keeps results honest even with indexing stopped or in periodic mode.
## Indexing
Each indexed root gets its own directory walker and content-extraction
pool, but all database writes funnel through one thread and one connection.
That writer serves the walkers first and slices extraction work, so one
root's large documents never stall another root's progress. Changing what is
indexed does not throw the index away: the configuration difference is
turned into a plan to delete what fell out of scope, and walk what is newly
covered; then that plan is applied while search or indexing keep running.
## Freshness
Filesystem watchers turn changes into small incremental updates. When
indexed roots are too large for filesystem watchers to scale well, a full
reindex runs on a configurable interval as the backstop. There is no
background daemon and no "start with your session" option: QuickSearch
starts and indexes in moments, and a search tool has no business running
when it is not being used.
## Security
QuickSearch has no special permissions compared to other user-space
programs, however it concentrates risk of data theft by both consolidating
and making easy to search what it can access. Accordingly, the index
can optionally be encrypted on disk with SQLCipher, keyed by
Argon2id-derived material from a password, and which the OS keychain can
remember. SQLCipher's per-page HMAC is deliberately disabled: it only
detects tampering (not reading) by someone who could already read
the indexed files directly, and it costs significant search performance.
The threat model which encryption protects against is a stolen disk, a
synced backup, or a cloned index; not against software already running
with user permissions, but such software could read the original files
anyway.
## Deliberate omissions
- Files with names that are not valid UTF-8 are silently skipped. A
lossily-converted path must never become a database key, and the corner
case does not justify UI.
- Duplicate groups are a strong suspicion, not a certainty: grouping hashes
only each file's size and head. Fast detection relies on iterating sorted
hashes of the start of each file (which are prefetched on dir walk and
cost minimal indexing time). The duplicate tool provides an on-demand
byte-for-byte comparison, and the tool never deletes or modifies files —
it tells you where to look.
- Only one instance may open an index for writing; a second launch refuses
rather than risking corruption. The guard is a kernel lock, so a crash
never leaves you locked out.
## Where the detail lives
Tests, benchmarks, and profiling probes live in each crate (see
`cargo test`/`cargo bench` in the crate directories). Release and CI
mechanics are documented in `.forgejo/workflows/ci.yml` and the scripts
under `packaging/`.

767
README.md
View file

@ -1,20 +1,53 @@
# QuickSearch # QuickSearch
A fast local file indexer and search tool. QuickSearch walks your chosen A fast local file search tool. QuickSearch indexes the folders you choose,
folders into a compact SQLite index (FTS5 full-text + zstd-compressed text keeps that index fresh automatically. It finds files by name or by text
sidecar), keeps it fresh automatically with filesystem watchers and content as fast as you type.
periodic reindexing, and serves ranked search-as-you-type results in a
compact egui desktop app, or straight to your terminal.
## AI Disclaimer ## AI Disclaimer
QuickSearch has a core designed by it's developer and built by hand, however the majority of it's codebase including it's GUI was designed by a human and built using AI agents with human review, improvements, and testing.
## GitHub Mirror QuickSearch has a core designed by its developer and built by hand, however
the majority of its codebase including its GUI was designed by a human and
built using AI agents with human review, improvements, and testing.
## Home & GitHub Mirror
The primary home of this software is: The primary home of this software is:
https://code.karsttech.com/jeremy/quick_search
The code is also mirrored to GitHub for easier bug reporting and issue tracking: - https://quicksearch.karsttech.com
https://github.com/DataScienceDIY/quick_search - https://code.karsttech.com/jeremy/quick_search
The code is also mirrored to GitHub for easier bug reporting and issue
tracking: https://github.com/DataScienceDIY/quick_search
## Features
- **Search as you type** — ranked results appear instantly and update with
every keystroke, matching file names, folder paths, and file contents.
- **Typo tolerance** — an optional fuzzy mode finds `repot` when you meant
`report`.
- **Query filters** — narrow a search by kind, date, or location, e.g.
`budget type:Document modified:>=2024-01-01`, with wildcards and regular
expressions for power users. The in-app "?" popup documents the syntax.
- **Live results** — results on screen notice renames, edits, and deletions
within a second, even while indexing is paused.
- **Duplicate finder** — groups files that appear identical, and can verify
any group byte for byte on request. It only reports; it never deletes.
- **Always fresh** — folders are watched for changes and reindexed
periodically, or switch to manual mode and index only when you say so.
- **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.
- **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
in one folder that can be moved between devices.
The in-app Help tab and first-start tour cover usage; every setting is
explained on hover in the Settings tab.
## Build & run ## Build & run
@ -23,696 +56,36 @@ https://github.com/DataScienceDIY/quick_search
build.bat # Windows build.bat # Windows
``` ```
These take a fresh machine to a running app: install missing build These take a fresh machine to a running app: they install missing build
dependencies, build release, launch the GUI. Stages already satisfied are dependencies (with your permission), build a release binary, and launch it.
skipped, so a normal run costs one `cargo build`. `build.sh` installs
system packages via `sudo` (apt/dnf/pacman/zypper) and the Rust toolchain
with rustup; `build.bat` uses winget and rustup. Both take `--check` to
report dependency status without installing or building, `--no-run` to
stop after the build, and `--` to pass the rest to the binary. `build.sh`
also takes `--installer`, which builds the Windows installer instead of
launching anything — see [Install (Windows)](#install-windows).
Building by hand needs a Rust toolchain plus, on every platform, a C
toolchain and Perl: SQLCipher, zstd and OpenSSL are compiled from bundled
C sources, and OpenSSL's `Configure` is a Perl script.
`rust-toolchain.toml` pins the compiler version and the cross-compilation
targets, so rustup installs the right ones on the first `cargo` command.
- Linux: working OpenGL 3.3 drivers; `xdg-desktop-portal` (present on all
mainstream desktops) provides the native folder picker. On minimal
images you may need `build-essential perl pkg-config`. No X11, Wayland or
xkbcommon `-dev` packages are needed: winit dlopens the display stack at
run time, so only the runtime libraries matter.
- Windows: Visual Studio 2022 Build Tools with the "Desktop development
with C++" workload (MSVC v143 plus a Windows SDK), and Strawberry Perl;
NASM is optional. The GNU target needs only a mingw-w64 toolchain and
cross-compiles from Linux (`cargo build --release -p quicksearch-gui
--target x86_64-pc-windows-gnu`), which is how CI produces the Windows
binaries. Windows ships only a software OpenGL 1.1 driver, so a bare VM
or an RDP session without a vendor GPU driver cannot create a context
and the window will fail to open.
- macOS: Xcode command line tools (`build.sh` does not auto-install these).
```sh ```sh
cargo build --release -p quicksearch-gui # binaries: target/release/quicksearch{,-cli} cargo build --release -p quicksearch-gui # binaries: target/release/quicksearch{,-cli}
cargo run -p quicksearch-gui # or just run it cargo run -p quicksearch-gui # or just run it
cargo test -p quicksearch-core # backend test suite cargo test -p quicksearch-core # test suite
``` ```
Two binaries are produced. `quicksearch` is the desktop app; on Windows it ## Install
is built as a window-subsystem app so no console appears behind it.
`quicksearch-cli` is terminal search — a console app, so pipes, redirection - **Debian / Ubuntu**: `./packaging/build-deb.sh`, then
and exit codes behave normally. On Unix `quicksearch` also does both, and `sudo apt install ./dist/quicksearch_<version>_amd64.deb`.
`quicksearch-cli` is simply the same tool under a clearer name. - **Other Linux**: download the AppImage from the release page, make it
executable, and run it. If your distribution lacks FUSE, run it with
## Install (Debian / Ubuntu) `APPIMAGE_EXTRACT_AND_RUN=1`.
- **Windows**: run the setup `.exe` from the release page, or unpack the
```sh `.zip` anywhere for an install-free copy.
./packaging/build-deb.sh
sudo apt install ./dist/quicksearch_<version>_amd64.deb ## Configuration
```
Settings live in `~/.config/quicksearch/config.toml` (Windows:
Substitute the current release version for `<version>`. `%APPDATA%\quicksearch\config.toml`), created on first run and edited live
by the GUI. [config_example.toml](config_example.toml) is the full
The script builds the release binary, strips it, and assembles a `.deb` with reference for every key. A `config.toml` placed next to the binary enables
`dpkg-deb`. It needs no `cargo-deb`, no `debhelper` and no SVG rasteriser — portable mode.
only `dpkg-deb` and `desktop-file-utils`, both standard on Debian and Ubuntu.
Useful flags: `--no-build` to package a binary you already built, `--no-strip` ## More
to keep debug symbols, `-o DIR` to write elsewhere. `DEB_MAINTAINER` overrides
the packaging maintainer. Design choices and the reasoning behind them are described in
[DESIGN.md](DESIGN.md); the code and its comments are the detailed
The package installs: reference. QuickSearch is open source under the terms in
[LICENSE](LICENSE).
| Path | Contents |
| --- | --- |
| `/usr/bin/quicksearch` | the desktop app, which also does terminal search |
| `/usr/bin/quicksearch-cli` | terminal search only |
| `/usr/share/applications/quicksearch.desktop` | menu entry, so QuickSearch appears in the app launcher |
| `/usr/share/icons/hicolor/{16,22,24,32,48,64,128,256}x*/apps/` | icons at each size |
| `/usr/share/icons/hicolor/scalable/apps/quicksearch.svg` | the source icon |
| `/usr/share/metainfo/com.karsttech.quicksearch.metainfo.xml` | AppStream data, so software centres show a real listing |
| `/usr/share/man/man1/quicksearch{,-cli}.1.gz` | `man quicksearch`; the `-cli` page is a `.so` stub pointing at it |
| `/usr/share/doc/quicksearch/` | copyright, changelog, README, `config_example.toml` |
Installing registers the menu entry and the icon: dpkg triggers owned by
`desktop-file-utils` and `hicolor-icon-theme` refresh both caches, so no
maintainer scripts are involved and `apt remove` reverses it cleanly.
No `config.toml` is installed. One placed next to the executable would put
every user into portable mode (see [Configuration](#configuration)); instead
the app writes `~/.config/quicksearch/config.toml` on first run.
### Icons
`crates/quicksearch-gui/assets/icons/` holds `quicksearch_icon.svg` and the
PNGs rasterised from it. The PNGs are committed, so an ordinary `cargo
build` needs no image tooling — the 256px one is compiled into the binary
with `include_bytes!` and becomes the window icon. Editing the SVG means
re-rendering the PNGs; `packaging/build-deb.sh` documents how in a comment
at the top.
X11 takes the window icon from the embedded PNG. Wayland ignores it and
matches the app id (`quicksearch`) against the installed
`quicksearch.desktop`, so there the titlebar icon appears only once the
package is installed. `quicksearch.ico` bundles the 16256px PNGs
unchanged for the Windows installer; regenerate it from the PNGs with
Pillow (`append_images` keeps the committed pixels unresampled).
## Install (AppImage)
For anything that is not Debian or Ubuntu. Download
`quicksearch-<version>-x86_64.AppImage` from the release page (substitute
the current release version), make it executable and run it:
```sh
chmod +x quicksearch-<version>-x86_64.AppImage
./quicksearch-<version>-x86_64.AppImage
```
If it fails to start with a FUSE error — some distributions no longer install
FUSE by default — either install the distribution's FUSE package or run it
unpacked:
```sh
APPIMAGE_EXTRACT_AND_RUN=1 ./quicksearch-<version>-x86_64.AppImage
```
To build one, `./packaging/build-appimage.sh` takes the same flags as
`build-deb.sh` (`--no-build`, `--no-strip`, `-o DIR`). It downloads
`appimagetool` and the AppImage runtime, both pinned by sha256 and cached under
`~/.cache/quicksearch`, and needs `zsync` and `appstream` installed for
`zsyncmake` and `appstreamcli`. `APPIMAGETOOL` points it at a copy you already
have. It needs no FUSE itself, which is what lets CI build one in a container.
## Install (Windows)
Download `quicksearch-<version>-windows-x86_64-setup.exe` from the release
page (substitute the current release version) and run it, or build it on a
Linux machine:
```sh
./build.sh --installer # installs the two extra packages first
./packaging/build-installer.sh # or straight to the build
```
That cross-compiles for `x86_64-pc-windows-gnu` and compiles the installer
with NSIS, which runs on Linux — no Windows machine is involved. It needs
`nsis` and `gcc-mingw-w64-x86-64` (`mingw32-nsis` and `mingw64-gcc` on
Fedora, `nsis` and `mingw-w64-gcc` on Arch; on openSUSE both come from the
`windows:mingw` OBS project). The same flags as `build-deb.sh` apply:
`--no-build`, `--no-strip`, `-o DIR`; after `--`, `build.sh --installer`
passes them straight through.
The install is per-machine and asks for elevation. Into
`C:\Program Files\QuickSearch` go:
| File | Contents |
| --- | --- |
| `quicksearch.exe` | the desktop app |
| `quicksearch-cli.exe` | terminal search |
| `quicksearch.ico` | icon for the shortcuts and Add/Remove Programs |
| `README.md`, `LICENSE.txt`, `config_example.toml` | documentation |
| `uninstall.exe` | written by the installer; Add/Remove Programs runs it |
The components page offers a Start menu shortcut (on) and a desktop shortcut
(off); both are created for all users. No `config.toml` is installed — one
next to the binaries is portable mode (see [Configuration](#configuration))
and would override the personal config of every account; the app writes
`%APPDATA%\quicksearch\config.toml` on first run instead. Installing over
an older version reuses that version's install directory, taken from its
registry entry, and both the installer and the uninstaller stop with a
message if QuickSearch is still running.
Uninstalling removes what was installed and nothing else. The index in
`%LOCALAPPDATA%\quicksearch` and the config in `%APPDATA%\quicksearch` stay,
so reinstalling picks up the existing index; the program directory is removed
only if empty, which leaves a portable-mode `config.toml` and its index alone.
`PATH` is untouched — add `C:\Program Files\QuickSearch` to it yourself if
you want `quicksearch-cli` on every prompt. It is an NSIS installer, so it
takes `/S` for a silent install and `/D=` for the directory (last argument,
unquoted):
```bat
quicksearch-<version>-windows-x86_64-setup.exe /S /D=C:\Tools\QuickSearch
```
The `.zip` on the release page is the alternative to all of this: the same two
binaries, no registry entries and nothing to uninstall. Unpack it anywhere,
and drop a `config.toml` next to the binaries to keep the config and index
inside that folder.
## Usage
### GUI
`quicksearch` with no query arguments opens the app. **One window at a
time**: a second launch reports that QuickSearch is already running and
exits, because two processes indexing one database corrupt it. Terminal
search (`quicksearch <query>`) only reads and keeps working while the
window is open. The guard is a kernel lock on `<database_path>.lock`, so a
crash or a power cut releases it and the leftover file never locks you
out. The lock follows `database_path`: point Settings at a different index
and it moves with you. An index locked by another instance, or a SQLite
database belonging to some other program, is refused with an error rather
than written; the file there is created when missing and replaced only
when it is an index from an older layout of QuickSearch's own.
- **Search**: results appear as you type; every keystroke cancels the
previous search. One checkbox enables the two fuzzy passes, and once a
search has finished a button inside the right of the search box re-runs
it. Click a column heading to sort by it; **right-click any heading to
choose which columns are shown** — the path is always there, and size
and modified date start hidden. The choice is saved (`[search.columns]`,
also in Settings → Search) and applies immediately. Sorting by a column
you then hide falls back to rank. Double-click a result to open it;
right-click it to reveal it in the file manager, open it, copy its
path, or build an ignore filter from it (session-only by default,
optionally persisted to the config). Result text can be selected and
copied in place. A match in a file's **name** or **path** is
highlighted in that column; a match in its **contents** shows a
highlighted snippet in the Content Match column, with more of the
surrounding text on hover. Rows matched on name or path show a dash
there instead. With `[search] live_results` on (the default) the rows
on screen are watched and checked against the disk, so a rename,
deletion or edit shows within a second whether or not indexing is
running — see `config_example.toml`.
- **Manage Index**: full indexing status, Start/Stop/Automatic controls,
indexed folder list, full-text extension filters, ignore patterns, and
the indexing options. Stopping switches to manual mode and saves that
(`indexing.auto_index`), so a stopped index stays stopped across
restarts until you return to automatic. The index size beside the
status heading totals the database and its `-wal`/`-shm` sidecars,
refreshed every ten seconds; hovering it lists the ways to make it
smaller. Each folder in the list shows how many files it holds and how
many of those had text extracted, counted as each indexing run finishes
and stored with the index; a folder nothing has finished indexing reads
"not yet indexed".
- **Duplicates**: files sharing a content hash, grouped. The hash covers
each file's size and its first `processing.hash_length` bytes and
nothing else, so a group is a strong suspicion, not a certainty.
Right-click a group, or any file in one, to settle it: every member is
read through and compared byte for byte, with progress and a Cancel
button in a modal that then names each file as identical, differing at
a given byte, a different size, or unreadable. Nothing is deleted or
changed either way; the point is to know before you delete something
yourself. **Sort by** lists the groups either by reclaimable space or by
file extension, for working through one file type at a time. It reorders
what the scan already returned rather than asking for a different set, so
which groups are listed never changes: they are always the 500 wasting the
most space, said as much in the line above the list. The scan runs on the
first visit to the tab and the listing then stays put, so coming back to it
is instant; **Refresh** re-runs it, and so does anything that moves the
index underneath it — a finished indexing run, or pointing the app at a
different index.
- **Logs**: the lines the app would have printed to a terminal — warnings
from indexing, folder watching and opening files, newest last, with a
filter box and Copy button. Launched from a desktop launcher (or on
Windows, where the app has no console at all) this is the only place
they are visible.
- **Help**: an in-app quickstart — first indexing run, example queries,
what each tab does — pointing here for everything technical. A brand-new
installation is shown a short click-through introduction covering the
same ground on its first launch; the Help tab brings it back. Upgrading
into this version does not raise it (see `[ui] tutorial_seen`). The
introduction walks the app rather than describing it: each page switches
to the tab it is about, colours the keywords in its own prose, and pulses
the widget each keyword names in the same colour — the query box, the
Rank column, the status bar, the Fuzzy tick, a tab. One page types a
search into the box to show results arriving. Its window is draggable and
not modal, so it can be moved off whatever it is pointing at and the app
used underneath it.
- **Settings**: every configuration control in one place — the database
path, indexing and processing limits, search behaviour, the interface
(scale, shortcut, color scheme) and password protection. Each row
explains itself on hover. Edits are staged and applied together by
**Apply & Save**; leaving the tab with unapplied edits asks first. The
column choices and the password controls are the exceptions, acting the
moment they are used. The indexed folder list and the indexing mode
live on Manage Index instead, next to the controls that act on them.
**Ctrl+Shift+F from anywhere** brings QuickSearch to the front, restoring
it if it was minimized, and puts the cursor in the search box with the
previous search selected. The Settings tab's Interface section rebinds it
— click the button and press the keys — or switches it off. It is a
system-wide shortcut, registered with Windows or with the X server, so it
works while another application has focus. Wayland does not let an
application claim a key, so there the shortcut is registered with your
desktop through the XDG desktop portal; your desktop then has the final
say over which key it is, and the Settings tab says which key it settled
on. Wayland likewise gives no application a way to raise itself, so under
it the shortcut selects the Search tab and the search box but leaves
raising the window to the desktop; on X11 and Windows it raises and
restores the window itself.
The bottom status bar always shows what the indexer is doing (phase,
percent, files/sec) or the total indexed file count when idle. Applying a
settings change to the index counts as something the indexer is doing: it
reports its progress there and in the Manage Index tab, and says what it
removed for a few seconds after it finishes.
Quitting while a settings change is still being applied asks first. Leaving
is never refused — the work stops promptly and the index stays consistent —
but it stops part-way, so entries you excluded can still turn up in search
results until indexing runs again. The next launch says so, with a button to
start that run; in automatic mode the periodic reindex does it for you.
### Terminal
```sh
quicksearch report type:Document modified:">=2024-01-01"
quicksearch --long --limit 20 "quarterly budget"
quicksearch --fuzzy repot # tolerates typos
```
Prints rank-ordered paths (pipe-friendly); `--long` adds rank, size,
mtime, and highlighted snippets. `quicksearch --help` shows all flags.
On Windows use `quicksearch-cli` for all of the above — `quicksearch.exe`
opens the app, and any query given to it seeds the search box instead of
printing. Colour in `--long` output needs a console with virtual-terminal
processing; Windows Terminal has it, and older consoles get plain text.
### Password protection
The index contains the names and (by default) the full text of everything
it indexes — for most setups, your entire home directory. That is a lot of
concentrated risk in one file. **Settings → Security → Enable password
protection** encrypts the index on disk with SQLCipher; from then on
QuickSearch asks for the password every time it starts, in the GUI (an
unlock screen before anything opens the index) and in the terminal (a
hidden prompt). Enabling, disabling, or changing the password deletes and
rebuilds the index — there is no in-place conversion.
- The key is derived as `Argon2id(password, salt)`; the salt is written to
`config.toml` when the password is set (it is unique, not secret, and
required — keep it with the config if you copy a protected setup).
- Pages are AES-256-CBC at an 8192-byte page size, with SQLCipher's
per-page HMAC **deliberately disabled** — it only detects tampering by
someone who could already read the indexed files directly, and it costs
1.78x on search. Confidentiality is unchanged.
- **Remember on this device** stores the derived key (never the password)
in the OS keychain — Secret Service/KWallet on Linux, Credential Manager
on Windows — and skips the prompt. Without a keychain daemon the option
quietly falls back to prompting.
- **Show database key** asks for the password, then shows the raw SQLCipher
key as `0x…` (64 hex digits) with a copy button, alongside the page size
and HMAC setting another tool has to be given — on SQLCipher's defaults
the index decrypts to noise and every tool calls a correct key wrong.
That key alone reads the index, so treat a copy of it as carefully as
the password.
- Scripts can set `QUICKSEARCH_PASSWORD` for non-interactive terminal
search. Environment variables are readable by other processes of the
same user (`/proc/<pid>/environ`) — prefer the keychain.
- Forgot the password? The unlock screen can delete the index and disable
protection; your files are untouched and re-indexing rebuilds it.
This protects the index itself and for attacks like data theft.
Anything malicious running with user permissions could bypass this protection,
but anything with user permissions can also access all of the same files.
### Query syntax
This section is the complete reference (the in-app "?" popup shows a
condensed version of the same rules). Everything that isn't a filter is
matched as one phrase, in order. Filters combine freely with the search
text:
| Syntax | Meaning |
|---|---|
| `budget report` | names, contents, and paths containing the phrase `budget report` |
| `"exact phrase"` | quotes keep spaces, stars, and filter-like words literal; `""` escapes a quote |
| `bud*port` | `*` matches any run of characters (it stays on one line of content); `%` and `_` are always literal |
| `regex:"(foo|bar)\d+"` | regular expression matched against names, contents, and paths; case-insensitive by default (`(?-i:…)` overrides); quote patterns containing spaces or `( ) : = < > "` |
| `type:Audio` | one of Audio, Image, Video, Document, Text, Archive, Spreadsheet, Presentation, Folder |
| `modified:>=2024-01-01` | also `<`, `<=`, `>`, `=` (dates are `yyyy-mm-dd`; `mtime:` is an alias) |
| `path:/home/me/docs` | restrict to a folder and its subfolders (`folder:` and `includefolder:` are aliases); `*` is literal here |
| `path:C:\Users\me\docs` | the same on Windows — drive letters and backslashes need no quoting |
| `mime:application/pdf` | exact MIME type |
| `name:re*.txt` | filename contains (as a filter, unranked; `filename:` is an alias); unquoted `*` globs |
Unrecognized `key:value` text (like `12:30`) stays part of the search
phrase, and a half-typed quote never errors while you type. `AND`, `OR`
and parentheses are treated as plain words. A term of only stars matches
nothing, and a regex that could match the empty string is rejected rather
than matching every file. `regex:` bypasses the trigram index entirely
and combines with filters; alongside search text it acts as an extra
requirement on those results.
The search box highlights this syntax as you type: recognized filter
keywords in red, their arguments in blue, syntax characters (operators,
quotes, live wildcards) in green, on a tinted chip per complete filter.
An argument the engine would reject — unknown `type:` name, bad date,
invalid regex — switches to the error color immediately.
Results are ranked: exact filename matches (case-sensitive first), then
filename substrings, then full-text matches ordered by occurrence count,
then fuzzy filename/full-text matches when enabled, and last the files
matched somewhere else in their path. Later, weaker matches only ever
append to the bottom of the list. Wildcard terms rank through the same
tiers (an "exact" match means the whole name matches the pattern) but
skip the fuzzy passes; regex-only queries reuse the substring, full-text,
and path tiers. Path matching needs at least three characters, and terms
may span separators (`docs/report`). Full-text matching also needs at
least three characters of literal text (the trigram floor). The fuzzy
passes tolerate typos with a budget of one edit per three characters,
capped by `[search] fuzzy_max_edits` (default 2; 0 turns fuzzy off).
### Configuration
`config.toml` lives at `~/.config/quicksearch/config.toml` (Windows:
`%APPDATA%\quicksearch\config.toml`) and is created on first run; the
default index goes to `~/.local/share/quicksearch/index.sqlite`
(Windows: `%LOCALAPPDATA%\quicksearch\index.sqlite`).
`config_example.toml` is the full reference — every key with its default,
valid range, and caveats. It ships in the repository root, in the `.deb`
under `/usr/share/doc/quicksearch/`, and in the Windows install
directory. In brief: `[paths]` says what to index and where the index
lives; `[indexing]` sets the mode, reindex interval, symlink and
hidden-file policy, `content_extensions` and `ignore_patterns`;
`[processing]` sets extraction and storage limits (`hash_length`, text
size caps, batching, `maximum_wal_size`, `tokenize`,
`store_text_for_snippets`); `[security]` covers password protection;
`[ui]` covers scale, `search_hotkey` and `color_scheme`; `[search]`
covers the fuzzy settings, result limits, `live_results` and the visible
columns.
The GUI edits the config live; external edits apply on next start. A
hand-edited value outside a setting's working range is clamped with a
warning, never rejected — a typo in a text file must not stop the app
starting.
**Portable mode**: a `config.toml` sitting next to the `quicksearch`
binary overrides the user config entirely, and relative paths inside any
config resolve against the config file's own directory, so a folder
containing the binary, its config, and its index can be moved wholesale.
**Changing what is indexed** does not throw the index away: narrowing the
scope deletes exactly the entries that fell out of scope, in place, and
widening it schedules a reindex to find what is newly in scope — both
automatically, in automatic and manual mode alike. Only three settings
still delete and rebuild the index, because nothing stored survives them:
`processing.tokenize`, `processing.hash_length`, and turning password
protection on or off or changing the password. In manual mode those ask
for confirmation first.
## Engineering overview
Two crates:
```
crates/quicksearch-core library: indexing, storage, search
crates/quicksearch-gui binary "quicksearch": egui app + terminal mode
```
### Backend (`quicksearch-core`)
Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
- **Storage** (`db/`): SQLite via rusqlite (bundled SQLCipher build —
identical to stock SQLite until a key is applied), WAL mode so the
single writer never blocks streaming read-only searches. A run forces a
`wal_checkpoint(TRUNCATE)` every `processing.maximum_wal_size` bytes,
checkpoints sooner when free space is short, and stops with an error
before the disk fills (see `config_example.toml`). The index's own
files are never walked into: opening one to hash it would cancel the
process's POSIX advisory locks on that inode, SQLite's documented
corruption hazard. `files` holds metadata (name, path, size, mtime,
hash, MIME/type bitmask, content state); `searchabletext` is a
*contentless* FTS5 table over the document body (postings only,
trigram tokenizer by default) — filename ranks come from scanning
`files.name`; canonical extracted text lives zstd-compressed in
`documents_text`, powering snippets, occurrence ranking, and fuzzy
full-text search. Schema changes wipe and rebuild by policy: only the
indexer (`open_or_recreate`) may do that, and every consumer uses
`open_existing`, which treats drift as an error, never data loss. With
password protection on, every open applies the Argon2id-derived raw key
(`security.rs`, process-global in `db/key.rs`) first; a wrong key is a
tagged `KEY_MISMATCH` error, structurally distinct from schema drift,
so it can never destroy an intact index. Each connection kind takes a
page cache sized for its job and lifetime — `db/schema.rs` sets six
profiles and explains each — released, heap included
(`platform::release_free_heap`), when the search worker or idle writer
lets go.
- **Indexing** (`indexing.rs`, `file_handling.rs`): full runs walk each
root (`filtered_walk` prunes hidden/ignored subtrees before
descending), classify files by mtime into insert/update/skip,
batch-write metadata, sweep stale rows, then extract content for FTS —
plaintext, RTF, Office (the OOXML/ODF zip formats and the pre-2007
binary OLE2 formats, `extract/ole.rs`), PDF (parsed once per file),
audio tags; see `extract/`. Images are claimed by no extractor, which
keeps the content pass from opening every image on disk. Files whose
extension no MIME table knows — including extensionless ones like
`README` — are sniffed from their head bytes and indexed as text only
when that head is provably text: valid UTF-8, or BOM-marked
(`mime.rs`, `textenc.rs`). Legacy charsets are decoded via chardetng
and stored as UTF-8, but only for files something *else* typed as text
(a bare sniff would adopt any binary lacking NUL bytes);
`indexing.content_extensions` is the throttle. Files no larger than
`processing.hash_length` skip the content pass: the head the walk
reads to hash them is already their whole content. Every root runs its
own walker and extraction pools, but all writes go through one thread
and one connection (`indexing/pipeline.rs`) — the thread where FTS5
tokenizes, the run's dominant cost. Its loop keeps the walk first:
each round serves every walking root, then one extracting root, and no
turn runs past a 100 ms slice, so one root's big documents never park
another root's walkers. Every run ends — completed or stopped — with
an optimize pass: checkpoint, VACUUM if the file has at least 20%
slack, `PRAGMA optimize`, checkpoint again. Progress streams through a
polled `IndexingStatus` (`Optimizing` during that pass, `Preparing`
for everything before the first file is walked). The upkeep a run does
*between* files — WAL checkpoints, the stale-row sweep, FTS merges,
the per-root recount — blocks the writer for as long as it takes, so
each announces itself as a `MaintenanceStep` on the published run
rather than leaving the per-file counters frozen and reading as a
hang.
- **Scope reconciliation** (`scope.rs`): the index is a cache of what a
walk under the configured roots would produce, so a configuration
change is a difference between the two, not a reason to start over.
`config::diff_actions` turns old-versus-new into an `IndexWork` plan:
roots to delete by path range, rows to re-test against the walker's
own rules (`Scope::covers` mirrors `read_directory` exactly), stored
text to re-decide, and whether a walk must follow. The coordinator
applies it in 250 ms slices; every run applies it once more against
the `config_validation` fingerprint, so a config hand-edited while the
app was closed behaves like one edited live. A finished pass records
what it reconciled against; an abandoned one leaves the record so the
next run resumes the work, a completed one stops later runs from
re-deriving the plan, and `scope::outstanding_work` is how the GUI
knows to remind you at the next launch. Both paths report live
progress and can be abandoned mid-statement (`sqlite3_interrupt`).
- **Coordinator** (`coordinator.rs`): the object binaries construct.
Owns the `IndexingService`, the debouncing filesystem watcher
(`watcher.rs`), and the Auto/Manual mode state machine (persisted as
`indexing.auto_index`). Watcher events become single-file transactions
(`incremental.rs`) that keep `files`, FTS, and the text sidecar
consistent per commit; a full reindex runs on a configurable interval.
Incremental writes and scope reconciliation defer while a full run is
active, so there is exactly one writer at a time. Watch registration
follows the platform: inotify takes one watch per surviving directory
(skipping `.git`, `node_modules` and hidden subtrees, which keeps the
count affordable); `ReadDirectoryChangesW` covers a whole tree from
one handle. Either way a tree too large to watch degrades to periodic
reindexing instead of going silently stale.
- **Live results** (`live.rs`): a second, much smaller watcher, owned by
the frontend, pointed at the parent directories of the result rows
currently on screen — directories, not files, because editors save by
renaming a temporary file over the target. What a row shows is read
from the **file**, never from the index (metadata from `stat`, content
re-extracted and re-cut through the same `cascade::text_snippet` the
search uses), which is what makes it work with indexing stopped.
Arming also checks each row once against the disk, and the paths just
read go to `IndexCoordinator::update_paths`, applied on the
coordinator's own thread — the single-writer rule stays intact. Caps
at 64 directories and 256 rows, rate-limited per path, and dropped
wholesale the moment the query is edited.
- **Search** (`search/`): `SearchService` runs one worker thread; each
query is a *generation*. New queries interrupt the in-flight SQLite
statement (`InterruptHandle`) and stale generations stop cooperatively,
so typing never waits. The worker keeps its connection across requests
and drops it once searching stops, so a typing session runs against a
warm page cache; an index generation counter (`db::index_epoch`) tells
the held connection to reopen when a rebuild puts a new file at the
same path. The cascade streams rank-ordered batches: one `files` scan
classifies exact/case/substring filename matches (ranks 14) and sets
aside full-path matches from the same rows (ranks 910); one FTS
phrase probe verified against the decompressed text yields full-text
ranks 56 ordered by occurrence count; the opt-in fuzzy passes run a
bitap (WuManber) matcher over filenames (rank 7), document text
(rank 8) and paths (rank 11). The deferred path tiers flush last, so
weaker matches only ever append. All SQL is parameterized; structured
filters from the query language (`query/`) are ANDed onto every pass.
The passes that read document text share one reusable decompressor and
output buffer per scan — see `DocDecoder` in `db/repo.rs` for the
allocation rules that keep that path cheap.
- **Duplicate listing** (`search/duplicates.rs`): three queries,first a
sorted `idx_files_hash`, iterating the sorted hashes to find duplicates,
then hydrating them with size and path information, keeping the largest.
- **Duplicate verification** (`verify.rs`): the second opinion on a group
from `search/duplicates.rs`, which groups by `sha256(size ‖ head)` and
so cannot tell apart files that differ only past the head. One
lockstep byte-for-byte pass — not a hash; the head hash already gave
the probabilistic answer. Members whose length disagrees are dropped
unread; the rest are compared span by span against the first member
that *opened*, so one unreadable file costs its own verdict and nobody
else's, and a file truncated mid-run degrades to a short comparison.
The read buffers share a fixed 8 MiB however many members a group has.
- **Baloo compatibility** (`cli.rs`, `mime.rs`): the read API this repo's
parent consumes — `status_for_path`, `list_failed`,
`index_size_breakdown`, `pending_content_count`, `clear_path` — plus a
Baloo-shaped type model. Only `index_counts` has a caller inside this
repository; the rest are a compatibility surface for the parent's
`balooctl` layer and are not dead code.
- **Logging** (`log.rs`): background reporting goes through `log_info!` /
`log_warn!`. Each writes its line to
stderr *and* appends it to a bounded in-memory ring (newest 5000 lines,
with a count of what was dropped) that the GUI's Logs tab reads, so a
windowed run with no terminal still surfaces them. Command output —
search hits, usage, the error a command exits with — stays on stdio.
- **Platform differences** (`platform.rs`): the single home for `#[cfg]`.
Home directory lookup, what counts as a hidden entry (dot-prefix, plus
the Hidden attribute on Windows — System deliberately excluded, since
cloud sync roots set it to get a folder icon), network-filesystem
detection (`/proc/mounts` against `GetDriveTypeW`), path collation, and
the watch-registration strategy all live here, so the rest of the crate
asks questions instead of testing `cfg` targets. Anything decidable from a
string alone is split out so its tests run on every platform.
### Frontend (`quicksearch-gui`)
Immediate-mode egui/eframe app, one UI thread:
```
UI thread ──SearchRequest──▶ search worker ──SearchUpdate (mpsc)──▶ drained per frame
UI thread ──commands──────▶ IndexCoordinator ──state──▶ polled per frame
core threads ─────────────▶ ctx.request_repaint() (wake the UI)
```
Modules map one-to-one onto what you see: `app.rs` (shell and config
routing, with `app/` submodules for the status bar, the security flow, the
confirmation modals and the duplicate-verification modal — the one place a
worker's progress is shown in a window, not the status bar),
`search_tab.rs` (query strip and virtualized
results table; snippet rendering via `LayoutJob` byte ranges, the ignore
dialog and the syntax help live in `search_tab/`), `manage_tab.rs` (status
detail + `tracker.rs` rate estimation, roots and filter editors),
`duplicates_tab.rs`, `logs_tab.rs` (a virtualized view of the core log
ring), `settings_tab.rs` (the draft-based config editor, the second of the
two tabs that stage their edits behind an Apply & Save), `tutorial.rs` (the
first-start tour) with `spotlight.rs` (the pass-scoped registry of where the
widgets it points at were drawn — written by the tabs as they lay out, read
by the tour at the end of the same pass, and inert whenever the tour is
closed), `platform.rs`
(open / reveal-in-file-manager, and the
Windows stdio setup a window-subsystem process needs before anything
prints), `hotkey/` (the system-wide search shortcut: one key table feeding
both a `RegisterHotKey` / `XGrabKey` registration and, on Wayland, an XDG
portal session on its own thread), `cli.rs` (terminal mode, shared with the
`quicksearch-cli` binary). There is no pagination: the table is
virtualized, so a single scroll list capped at `display_limit` renders in
microseconds regardless of row count.
## Development
- `cargo test -p quicksearch-core`: unit + integration suites — cascade
ranking, cancellation, incremental indexing, coordinator modes, config
resolution, fuzzy matcher vs. brute-force oracle, `verify.rs`'s
byte-for-byte comparison, and `live.rs`'s event classification (the
platform-specific rename and atomic-save shapes are synthesized, so
they are checked on every platform).
- `cargo test -p quicksearch-gui`: formatter/tracker/CLI-parsing units
plus headless egui tests that drive the real widgets — building an
input frame, synthesizing clicks and reading back the painted text
(`test_ui.rs`) — over the search, manage and settings tabs, the unlock
gate, the logs and duplicates tabs, the first-start tour, and query
highlighting.
- `cargo bench -p quicksearch-core --bench search` and `--bench index`:
divan microbenchmarks over the two hot paths, A/B-ing the current code
against a considered change on one seeded corpus (`benches/corpus/`).
This is the harness to extend when a hot path is in question.
- `QSB_SEARCH_PERF=1 cargo bench -p quicksearch-core --bench search_perf`:
what a warm page cache is worth to search, swept across cache ceilings,
encrypted and not. Read it before changing `PRAGMAS_SEARCH`.
- `QSB_SEARCH_ALLOC=1 cargo bench -p quicksearch-core --bench search_alloc`:
what a search moves through the allocator, per query shape.
- Memory probes, all under `crates/quicksearch-core/examples/`:
`memprobe <cold|warm> <root> <db>` reports an indexing run's peak and
what it settles at once idle; `rssprobe <pid> [duration_s]` attributes
a running process's footprint, distinguishing retention from live data;
`indexprobe` and `walkprobe` answer "how fast", not "how much".
All of them read the resident set and none can see allocator churn, so
measure allocations separately before concluding a path is cheap.
- `.forgejo/workflows/ci.yml`: builds and tests both platforms on every
push to `master` and every pull request; packaging and release
publication run only on a `v*` tag or a `Release...` branch. The
release mechanics — version guards, tagging, asset naming, the `.zsync`
update URL — are documented in that file and in
`packaging/build-appimage.sh`.
- New extractors: implement `extract::Extractor` and register it in
`Registry::default_set()` — order matters, the first extractor whose
`supports` accepts a MIME wins. Then add the format to the corpus in
`crates/quicksearch-core/tests/corpus/` and `tests/extraction_corpus.rs`;
no corpus file may be written by the library that reads it back. New
cascade behavior: `search/cascade.rs` documents the rank invariants
that keep streamed results append-only.
- `packaging/capture.sh`: regenerates the website assets into
`packaging/captures/` (gitignored) by building the GUI with the
`capture` feature, whose scripted driver
(`packaging/capture-scenario.txt`) records from the app's own
framebuffer against a throwaway index under scratch XDG dirs. Needs a
graphical session and ffmpeg with `libx264rgb` and `libvpx-vp9`.
To cut a release:
1. Bump `[workspace.package] version` in `Cargo.toml`.
2. Commit and push on a branch named `Release...` (CI runs
`cargo update -w`, so the lockfile's own member versions follow).
3. Once both build jobs are green, CI tags the commit `v<version>` and
publishes the release with the `.deb`, the AppImage and its `.zsync`
sidecar, a Linux tarball, the Windows installer and a Windows zip.
Pushing a `v*` tag by hand does the same thing.
4. Release builds run on the oldest supported LTS (an Ubuntu 22.04
container): the builder's glibc becomes the `.deb`'s and the
AppImage's floor.

View file

@ -141,18 +141,17 @@ use_keychain = false
# Zoom factor for the whole GUI: fonts, spacing, and widgets scale # Zoom factor for the whole GUI: fonts, spacing, and widgets scale
# together (0.5 2.5). Ctrl +/- and Ctrl 0 adjust it temporarily at # together (0.5 2.5). Ctrl +/- and Ctrl 0 adjust it temporarily at
# runtime; this value is the persistent baseline. # runtime; this value is the persistent baseline.
scale = 1.1 scale = 1.25
# Written by QuickSearch, not by you: the folders that have already shown # Written by QuickSearch, not by you: the folders that have already shown
# the "more subfolders than the watcher can follow" warning, so restarting # the "more subfolders than the watcher can follow" warning, so restarting
# does not repeat it. Deleting it just means the warnings come back once # does not repeat it. Deleting it just means the warnings come back once
# each. # each.
watch_cap_warned_roots = [] watch_cap_warned_roots = []
# System-wide shortcut that raises QuickSearch, switches to the Search tab # The shortcut QuickSearch claims for itself while it is running: brings it to
# and selects whatever is in the search box, from anywhere. Modifiers are # the front, switches to the Search tab and selects whatever is in the search
# Ctrl, Alt and Shift, joined to one key with "+". Leave it empty ("") for # box. Modifiers are Ctrl, Alt and Shift, joined to one key with "+". Leave it
# no shortcut. On Wayland this is only a preference: the shortcut is # empty ("") for no shortcut. To start QuickSearch when it is closed, bind
# registered with your desktop, which may assign a different key and lets # "quicksearch --toggle" in your desktop's keyboard settings.
# you change it in its own keyboard settings.
search_hotkey = "Ctrl+Shift+F" search_hotkey = "Ctrl+Shift+F"
# 'dark' or 'light'; anything other than 'light' is dark. Applied as soon # 'dark' or 'light'; anything other than 'light' is dark. Applied as soon
# as it is changed on the Settings tab. Following the desktop's own # as it is changed on the Settings tab. Following the desktop's own
@ -228,3 +227,29 @@ content_match = true
size = false size = false
modified = false modified = false
rank = true rank = true
# What the Duplicates tab lists. Both keys are written by the tab itself, from
# its exclusion box and its right-click menu; they are here because they are
# yours to read and edit. Neither changes what is indexed or what a search
# finds: a file left out of the duplicate listing is still in the index.
[duplicates]
# Paths left out of the listing. Same syntax and same matcher as
# indexing.ignore_patterns - a pattern without a separator matches any single
# name ("*.iso"), one with a separator matches a whole path and everything
# under it ("/home/you/Backups/*").
#
# A member whose path matches is not counted, and the group is re-priced
# around the copies that are left; a group down to one copy is not a duplicate
# of anything and is not listed at all. For a folder that is *meant* to hold
# copies, this is the key to use.
exclude_patterns = []
# Groups dismissed with "Hide this group", by content hash, lowercase hex.
# For the other case: a group that is not really a duplicate at all, because
# grouping only reads each file's size and its first processing.hash_length
# bytes.
#
# Keyed by hash, not by path, so a hidden group stays hidden when its files
# are renamed or moved, and comes back if their contents change. Changing
# processing.hash_length rebuilds the index and gives every file a new hash,
# which strands every entry here; the tab's Clear button empties the list.
hidden_groups = []

View file

@ -0,0 +1,866 @@
//! Where the time goes when an added ignore pattern scrubs the index.
//!
//! Adding one pattern sets `IndexWork { prune_scope: true, reindex: false }`
//! (`config::diff_actions`), and `scope::advance` then reads **every** stored
//! row under every root, decides each one, and deletes the losers by id. On a
//! large index that has been measured slower than building the index was. This
//! attributes that time to a phase.
//!
//! ```text
//! cargo build -p quicksearch-core --example pruneprobe --release
//! ./target/release/examples/pruneprobe
//! ```
//!
//! `QSB_FILES` / `QSB_DIRS` / `QSB_CONTENT_EVERY` size the corpus;
//! `QSB_ARMS=plain|keyed|both` picks the key states. A keyed arm is not
//! optional dressing — every page the delete touches is decrypted and
//! re-encrypted, and the two arms have disagreed before (see
//! `db::schema::PAGE_SIZE`).
//!
//! The stages are cumulative, so consecutive rows subtract to a phase cost:
//!
//! ```text
//! page read the rows, decide nothing
//! +cover ...and run the scope test per row
//! +files ...and delete the doomed `files` rows
//! +fts(all) ...and tombstone every doomed id <- ships today
//! +fts(done) ...tombstoning only ids that can have an FTS row
//! +subtree ...range-deleting a doomed directory instead of paging it
//! ```
//!
//! **Read the `commit` column, not `fts`.** FTS5 buffers a contentless delete
//! in memory and writes the tombstone pages when the transaction is flushed, so
//! the `DELETE` statement itself times as nearly free and the cost lands in the
//! commit. That is also why `-tx` sweeps the number of pages per transaction:
//! if the flush is priced per commit rather than per tombstone, transaction
//! size is the variable that matters and the row count is not.
//!
//! 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.
mod common;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use rusqlite::Connection;
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::scope::{self, Scope, WorkCursor};
use quicksearch_core::testutil::{self, Arm, SeedSpec};
use common::Io;
/// The root every `seed_index` row hangs under; it need not exist on disk,
/// because nothing in the prune path stats a stored path on Unix (see
/// `platform::entry_hidden_reason`, which short-circuits on the dot prefix).
const ROOT: &str = "/seed";
/// The ignore patterns the probe can add, each excluding a fifth of the index.
///
/// `seed_index` names its second path segment `WORDS[(d * 7 + 13) % 35]`, and
/// because 7 and 35 share a factor only these five of the thirty-five words are
/// ever reachable — one per residue of `d mod 5`. So each excludes a fifth of
/// the directories, spread across the keyspace rather than gathered at one end,
/// which is the shape a real `node_modules` or `.git` pattern has. Taking a
/// prefix of this list is how the probe sweeps the excluded fraction. `main`
/// asserts the fractions rather than trusting this comment.
const PRUNE_PATTERNS: [&str; 5] = ["jumps", "content", "revenue", "figure", "eta"];
/// The single-pattern case, and the one the stage table runs.
const PRUNE_PATTERN: &str = PRUNE_PATTERNS[0];
/// Rows per page, matching `processing.batch_size`'s default — the figure
/// `scope::advance` runs at.
const PAGE: i64 = 500;
/// Ids per `IN (...)` list, matching `repo::DELETE_IDS_CHUNK`.
const CHUNK: usize = 512;
/// Output leaf pages per `'merge'` call, matching `FINALIZE_MERGE_PAGES`.
const MERGE_PAGES: i64 = 1000;
// ---------------------------------------------------------------------------
// Stages
// ---------------------------------------------------------------------------
/// Cumulative slices of the prune. Each does everything the one before it does.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum Stage {
Page,
Cover,
Files,
FtsAll,
FtsDone,
Subtree,
}
impl Stage {
const ALL: [Stage; 6] = [
Stage::Page,
Stage::Cover,
Stage::Files,
Stage::FtsAll,
Stage::FtsDone,
Stage::Subtree,
];
fn label(self) -> &'static str {
match self {
Stage::Page => "page",
Stage::Cover => "+cover",
Stage::Files => "+files",
Stage::FtsAll => "+fts(all)",
Stage::FtsDone => "+fts(done)",
Stage::Subtree => "+subtree",
}
}
fn tag(self) -> &'static str {
match self {
Stage::Page => "page",
Stage::Cover => "cover",
Stage::Files => "files",
Stage::FtsAll => "fts-all",
Stage::FtsDone => "fts-done",
Stage::Subtree => "subtree",
}
}
/// Whether the doomed rows leave `files`.
fn deletes_rows(self) -> bool {
self >= Stage::Files
}
/// Whether the doomed ids are tombstoned in FTS.
fn deletes_fts(self) -> bool {
self >= Stage::FtsAll
}
/// Whether the FTS delete is narrowed to ids that can hold a posting.
fn fts_filtered(self) -> bool {
self >= Stage::FtsDone
}
}
/// What one stage cost, split by phase.
#[derive(Default)]
struct Timing {
page: Duration,
cover: Duration,
files: Duration,
fts: Duration,
commit: Duration,
total: Duration,
examined: usize,
deleted: usize,
/// Rows skipped by a range delete rather than paged through.
skipped: usize,
io: Io,
misses: i64,
}
// ---------------------------------------------------------------------------
// The measured loop
// ---------------------------------------------------------------------------
fn placeholders(n: usize) -> String {
let mut s = String::with_capacity(n * 2);
for i in 0..n {
if i > 0 {
s.push(',');
}
s.push('?');
}
s
}
/// Whether the walker would still descend into the stored parent `parent`.
///
/// `Scope::covers` judges a path component by component, which for a directory
/// is exactly "would the walk enter it" — the same question the walker asks
/// before recursing. The trailing separator every stored parent carries has to
/// go first, or the leaf component is empty.
fn dir_covered(scope: &Scope, root: &Path, parent: &str) -> bool {
let trimmed = parent.trim_end_matches(['/', '\\']);
scope.covers(root, Path::new(trimmed))
}
/// When the pass commits.
#[derive(Clone, Copy)]
enum Commit {
/// One transaction per page — what ships.
PerPage,
/// One per `n` pages; the sweep's variable.
Pages(usize),
/// One per elapsed slice, which is what `scope::advance` already uses as
/// its unit of interruptible work.
Slice(Duration),
}
impl Commit {
fn due(self, pages_open: usize, since: Instant) -> bool {
match self {
Commit::PerPage => true,
Commit::Pages(n) => pages_open >= n,
Commit::Slice(d) => since.elapsed() >= d,
}
}
}
/// Page the index the way `scope::advance` does, doing `stage`'s share of the
/// work and timing each phase separately.
///
/// The deletes are spelled out here rather than routed through `repo` so that
/// the filtered and unfiltered FTS variants can be compared in one run — and
/// so this keeps measuring the same thing after `repo` is reshaped around
/// whichever variant wins.
fn run_stage(conn: &Connection, config: &Config, stage: Stage, commit: Commit) -> Timing {
let scope = Scope::from_config(config).expect("compile the scope");
let root = PathBuf::from(ROOT);
let range = ExtractCursor::for_root(ROOT);
let mut t = Timing::default();
let io_before = Io::read();
let (_, misses_before) = testutil::cache_stats(conn);
let started = Instant::now();
let mut after = (range.lo.clone(), String::new());
let mut covered = scope::CoverCache::default();
// `unchecked_transaction` borrows shared, which is what lets one end and
// the next begin inside the loop — the same trick `testutil::seed_index`
// uses for `commit_every`.
let mut tx = conn.unchecked_transaction().expect("begin");
let mut pages_open = 0usize;
let mut tx_since = Instant::now();
loop {
let at = Instant::now();
let rows =
repo::rows_in_range_page(&tx, &after.0, &after.1, &range.hi, PAGE).expect("read a page");
t.page += at.elapsed();
let Some(last) = rows.last() else { break };
after = (last.parent.clone(), last.name.clone());
pages_open += 1;
if stage == Stage::Page {
t.examined += rows.len();
continue;
}
// The subtree short-circuit. A page spans several directories, so the
// verdict is taken per distinct parent *as it appears* — stopping at
// the first doomed one, range-deleting it, and re-seeking past it.
// Testing only `rows[0]` would fire only on the pages that happen to
// begin inside an excluded directory.
let mut rows: &[repo::ScopeRow] = &rows;
if stage == Stage::Subtree {
let at = Instant::now();
let mut doomed_at = None;
let mut last_dir: Option<(&str, bool)> = None;
for (i, row) in rows.iter().enumerate() {
let ok = match last_dir {
Some((dir, ok)) if dir == row.parent => ok,
_ => {
let ok = dir_covered(&scope, &root, &row.parent);
last_dir = Some((&row.parent, ok));
ok
}
};
if !ok {
doomed_at = Some(i);
break;
}
}
t.cover += at.elapsed();
if let Some(i) = doomed_at {
let dir = rows[i].parent.trim_end_matches(['/', '\\']).to_string();
let sub = ExtractCursor::for_root(&dir);
let (removed, fts, files) = delete_range(&tx, &sub.lo, &sub.hi);
t.fts += fts;
t.files += files;
t.deleted += removed;
t.skipped += removed;
// The rows before the doomed directory still need deciding;
// everything from it on is gone or will be re-read after the
// seek. Both happen inside the open transaction, so the seek
// sees the range delete without needing a commit first.
after = (sub.hi.clone(), String::new());
rows = &rows[..i];
}
}
let at = Instant::now();
let mut doomed: Vec<i64> = Vec::new();
let mut doomed_fts: Vec<i64> = Vec::new();
for row in rows {
if scope.covers_cached(&root, Path::new(&row.path), &mut covered) {
continue;
}
doomed.push(row.id);
if row.content_state == repo::STATE_DONE {
doomed_fts.push(row.id);
}
}
t.cover += at.elapsed();
t.examined += rows.len();
if stage.deletes_rows() && !doomed.is_empty() {
let fts_ids: &[i64] = if stage.fts_filtered() {
&doomed_fts
} else {
&doomed
};
if stage.deletes_fts() {
for chunk in fts_ids.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 doomed.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();
}
}
if commit.due(pages_open, tx_since) {
let at = Instant::now();
tx.commit().expect("commit");
t.commit += at.elapsed();
tx = conn.unchecked_transaction().expect("begin");
pages_open = 0;
tx_since = Instant::now();
}
}
let at = Instant::now();
tx.commit().expect("commit");
t.commit += 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
}
/// Delete a whole parent range, tombstoning only the ids that can hold a
/// posting. Returns `(rows, fts time, files time)`.
fn delete_range(tx: &Connection, lo: &str, hi: &str) -> (usize, Duration, Duration) {
let at = Instant::now();
tx.execute(
"DELETE FROM searchabletext WHERE rowid IN \
(SELECT id FROM files WHERE parent >= ?1 AND parent < ?2 AND content_state = ?3)",
rusqlite::params![lo, hi, repo::STATE_DONE],
)
.expect("tombstone range");
let fts = at.elapsed();
let at = Instant::now();
let removed = tx
.execute(
"DELETE FROM files WHERE parent >= ?1 AND parent < ?2",
rusqlite::params![lo, hi],
)
.expect("delete range");
(removed, fts, at.elapsed())
}
/// How much data FTS5 is holding — the only quiescence signal that works.
///
/// `sqlite3_changes()` after a `'merge'` does **not** report whether the merge
/// did any work: measured here, it reports non-zero forever, so a
/// `while changes() != 0` loop never terminates. Watching `%_data` shrink and
/// stop is what actually detects a consolidated index.
fn fts_data_rows(conn: &Connection) -> i64 {
conn.query_row("SELECT COUNT(*) FROM searchabletext_data", [], |r| r.get(0))
.unwrap_or(-1)
}
/// Merge until `%_data` stops moving, and report what that took.
///
/// The **positive** argument is deliberate: negative picks
/// `fts5IndexOptimizeStruct` instead, which is a different algorithm and a
/// documented trap (`file_handling::records::fts_finalize_after_text_indexing`).
fn merge_to_quiescence(conn: &Connection) -> (Duration, u32, i64, i64) {
let before = fts_data_rows(conn);
let started = Instant::now();
let mut rounds = 0;
let mut last = before;
loop {
conn.execute(
"INSERT INTO searchabletext(searchabletext, rank) VALUES('merge', ?1)",
[MERGE_PAGES],
)
.expect("merge");
rounds += 1;
let now = fts_data_rows(conn);
if now == last || rounds > 500 {
break;
}
last = now;
}
(started.elapsed(), rounds, before, last)
}
// ---------------------------------------------------------------------------
// Corpus and arms
// ---------------------------------------------------------------------------
fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key)
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(default)
}
fn spec() -> SeedSpec {
SeedSpec {
files: env_usize("QSB_FILES", 200_000),
dirs: env_usize("QSB_DIRS", 2_000),
// **Coprime with 5, and that is the whole point.** `seed_index` places
// content every `content_every` files and directories every `dirs`,
// and [`PRUNE_PATTERN`] excludes the directories where `d mod 5 == 0`.
// The shipped default of 10 shares the factor 5 with that, so *every*
// extracted document lands in an excluded directory: the prune deletes
// 100% of the postings, the FTS index goes empty, and every question
// about tombstone cost is answered by a degenerate corpus. 7 puts a
// fifth of the documents in the doomed set, which is the real shape.
// `main` asserts the fraction.
content_every: env_usize("QSB_CONTENT_EVERY", 7),
// At least two segments, so the second can carry the excluded name.
// Depth is most of what decides stored row width, and therefore how
// many pages the scan reads — see `SeedSpec::dir_depth`.
dir_depth: env_usize("QSB_DIR_DEPTH", 6).max(2),
// A real run commits in slices, and each commit flushes FTS5's hash to
// its own segment. Seeding in one transaction would leave a single
// segment and understate every tombstone cost below.
commit_every: 500,
..SeedSpec::default()
}
}
fn config_with(patterns: &[&str]) -> Config {
let mut config = Config::default();
config.paths.indexing_paths = vec![ROOT.to_string()];
config.indexing.ignore_patterns = patterns.iter().map(|p| p.to_string()).collect();
config.processing.batch_size = PAGE as usize;
config
}
/// A byte-identical copy of `master`, under its own scratch directory.
///
/// `seed_index` ends with a TRUNCATE checkpoint, so the one file holds the
/// whole index and there is no `-wal` to carry across.
fn clone_arm(master: &Arm, tag: &str) -> Arm {
let path = testutil::scratch_db(tag);
std::fs::copy(&master.path, &path).expect("copy the seeded index");
Arm {
what: master.what.clone(),
keyed: master.keyed,
pgsz: master.pgsz,
page_size: master.page_size,
hmac: master.hmac,
path,
seeded_in: Duration::ZERO,
}
}
/// The coordinator's own writer profile, which is what the reconcile actually
/// runs on — `PRAGMAS_INCREMENTAL`, a 4 MiB page cache. Opening this
/// `open_existing(.., true)` instead would measure `PRAGMAS_FAST`'s 8 MiB and
/// quietly flatter every figure below.
fn open(arm: &Arm) -> Connection {
arm.with_key(|| {
db::open::open_incremental_writer(&arm.path.to_string_lossy()).expect("open the copy")
})
}
/// A writer knob worth sweeping, applied over [`open`]'s profile.
#[derive(Clone, Copy)]
struct Knobs {
label: &'static str,
/// `None` leaves SQLite's 1000-page default: the committing thread
/// checkpoints — copying WAL pages back into the main file, re-encrypting
/// every one on a keyed index — every 8 MiB of log.
autocheckpoint: Option<i64>,
/// MiB of page cache; `None` keeps `PRAGMAS_INCREMENTAL`'s 4.
cache_mib: Option<i64>,
}
const SHIPPED: Knobs = Knobs {
label: "as shipped",
autocheckpoint: None,
cache_mib: None,
};
/// What `PRAGMAS_INCREMENTAL` gives the reconcile today, in MiB. Named so the
/// sweep's first row is labelled with the figure it is arguing against rather
/// than repeating it as a literal.
const SHIPPED_MIB: i64 = 4;
fn apply(conn: &Connection, knobs: Knobs) {
if let Some(n) = knobs.autocheckpoint {
conn.execute_batch(&format!("PRAGMA wal_autocheckpoint = {};", n))
.expect("autocheckpoint");
}
if let Some(mib) = knobs.cache_mib {
conn.execute_batch(&format!("PRAGMA cache_size = -{};", mib * 1024))
.expect("cache_size");
}
}
fn count(conn: &Connection, sql: &str) -> i64 {
conn.query_row(sql, [], |r| r.get(0)).unwrap_or(-1)
}
// ---------------------------------------------------------------------------
fn main() {
let spec = spec();
let arms: Vec<bool> = match std::env::var("QSB_ARMS").as_deref() {
Ok("plain") => vec![false],
Ok("keyed") => vec![true],
_ => vec![false, true],
};
println!(
"corpus: {} files across {} dirs, 1 in {} with content, pattern {:?}",
spec.files, spec.dirs, spec.content_every, PRUNE_PATTERN
);
for keyed in arms {
let label = if keyed { "keyed" } else { "plain" };
let master = Arm::seed(label, &format!("prune-{}", label), keyed, &spec);
println!(
"\n=== {} === seeded in {:.1}s, {}",
label,
master.seeded_in.as_secs_f64(),
common::mib(master.size_bytes())
);
// What the pattern is worth, read off the index rather than assumed.
{
let conn = open(&master);
let total = count(&conn, "SELECT COUNT(*) FROM files");
let doomed = count(
&conn,
&format!(
"SELECT COUNT(*) FROM files WHERE parent LIKE '%/{}/%'",
PRUNE_PATTERN
),
);
let doomed_fts = count(
&conn,
&format!(
"SELECT COUNT(*) FROM files WHERE parent LIKE '%/{}/%' AND content_state = 1",
PRUNE_PATTERN
),
);
let fts = count(&conn, "SELECT COUNT(*) FROM searchabletext");
let done = count(&conn, "SELECT COUNT(*) FROM files WHERE content_state = 1");
assert!(
doomed > 0,
"the pattern excludes nothing — seed_index's directory naming moved"
);
assert_eq!(fts, done, "a posting exists exactly for content_state = 1");
assert!(
doomed_fts * 2 < done,
"the prune takes {} of {} postings — the content and directory \
strides have collided again; see `spec`'s content_every",
doomed_fts,
done
);
println!(
" {} rows, {} doomed ({:.0}%), of which {} hold a posting ({:.0}%) — \
the rest are tombstoned today for nothing",
total,
doomed,
100.0 * doomed as f64 / total as f64,
doomed_fts,
100.0 * doomed_fts as f64 / doomed as f64,
);
println!(
" plan: {}",
conn.query_row(
"EXPLAIN QUERY PLAN DELETE FROM searchabletext WHERE rowid IN (1,2,3)",
[],
|r| r.get::<_, String>(3),
)
.unwrap_or_else(|e| format!("unavailable: {}", e))
);
// What the tombstone actually has to find its way into. `%_data`
// holds the segment leaves, `%_idx` one row per segment b-tree
// node, `%_docsize` the origin a contentless delete looks up.
println!(
" fts: {} %_data rows ({}), {} %_idx, {} %_docsize",
count(&conn, "SELECT COUNT(*) FROM searchabletext_data"),
common::mib(
count(
&conn,
"SELECT COALESCE(SUM(pgsize), 0) FROM dbstat \
WHERE name LIKE 'searchabletext%'"
)
.max(0) as u64
),
count(&conn, "SELECT COUNT(*) FROM searchabletext_idx"),
count(&conn, "SELECT COUNT(*) FROM searchabletext_docsize"),
);
}
let config = config_with(&[PRUNE_PATTERN]);
let header = || {
println!(
"\n {:<11} {:>7} {:>7} {:>7} {:>7} {:>8} {:>8} {:>9} {:>8}",
"stage", "page", "cover", "files", "fts", "commit", "total", "misses", "deleted"
);
};
let row = |name: &str, t: &Timing| {
let ms = |d: Duration| d.as_secs_f64() * 1000.0;
println!(
" {:<11} {:>7.0} {:>7.0} {:>7.0} {:>7.0} {:>8.0} {:>8.0} {:>9} {:>8}",
name,
ms(t.page),
ms(t.cover),
ms(t.files),
ms(t.fts),
ms(t.commit),
ms(t.total),
t.misses,
t.deleted,
);
};
header();
for stage in Stage::ALL {
let arm = clone_arm(&master, &format!("prune-{}-{}", label, stage.tag()));
let conn = open(&arm);
let t = run_stage(&conn, &config, stage, Commit::PerPage);
row(stage.label(), &t);
if stage == Stage::Subtree {
println!(
" {:<11} {} of those rows never reached a page",
"", t.skipped
);
}
// Consolidation, priced apart: today one 1000-page `'merge'` runs
// and the scrub stops, whatever it left behind.
if stage.deletes_fts() {
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();
}
// Is the cost priced per commit or per row? Today's
// one-transaction-per-page is the first line; if it collapses as pages
// are folded together, commit cadence is the lever and none of the
// stages above are.
println!("\n transaction size, at +fts(all):");
header();
for pages in [1usize, 8, 64, 512] {
let arm = clone_arm(&master, &format!("prune-{}-tx{}", label, pages));
let conn = open(&arm);
let t = run_stage(&conn, &config, Stage::FtsAll, Commit::Pages(pages));
row(&format!("{} page/tx", pages), &t);
drop(conn);
arm.discard();
}
// The projected end state: commit on the slice `advance` already runs
// to, skip doomed directories wholesale, tombstone only what can hold
// a posting. Nothing here needs a longer uninterruptible window than
// ships today — the slice boundary is already the cancellation point.
println!("\n combined, committing once per {:?} slice:", scope::SLICE);
header();
{
let arm = clone_arm(&master, &format!("prune-{}-combined", label));
let conn = open(&arm);
let t = run_stage(&conn, &config, Stage::Subtree, Commit::Slice(scope::SLICE));
row("combined", &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 page cache. `PRAGMAS_INCREMENTAL` gives the reconcile a flat
// 4 MiB while it scans and deletes across the *whole* index — and a
// delete does not only touch the index the scan reads in order. It
// maintains `idx_files_mtime`, `idx_files_mime` and `idx_files_hash`
// too, three b-trees in orders uncorrelated with `(parent, name)`, so
// every doomed row scatters ~3 page touches that a 512-page cache
// cannot hold. `misses` is the column to read.
//
// Both commit policies are swept, because a cache that holds the
// working set may make the commit cadence stop mattering.
println!("\n page cache, at +fts(all) [{} = shipped]:", SHIPPED_MIB);
header();
for mib in [SHIPPED_MIB, 16, 32, 64, 128, 256] {
for (name, commit) in [
("per page", Commit::PerPage),
("per slice", Commit::Slice(scope::SLICE)),
] {
let arm = clone_arm(&master, &format!("prune-{}-c{}-{}", label, mib, name.len()));
let conn = open(&arm);
apply(
&conn,
Knobs {
cache_mib: Some(mib),
..SHIPPED
},
);
let t = run_stage(&conn, &config, Stage::FtsAll, commit);
row(&format!("{} MiB, {}", mib, name), &t);
drop(conn);
arm.discard();
}
}
// Measured and rejected, so the table records it: with autocheckpoint
// at its 1000-page default the committing thread copies the log back
// into the main file every 8 MiB and re-encrypts every page it moves,
// which looked like an obvious suspect. Deferring it to one checkpoint
// at the end is a wash — the pages have to move either way.
{
let arm = clone_arm(&master, &format!("prune-{}-nockpt", label));
let conn = open(&arm);
apply(
&conn,
Knobs {
autocheckpoint: Some(0),
..SHIPPED
},
);
let t = run_stage(&conn, &config, Stage::FtsAll, Commit::Slice(scope::SLICE));
let at = Instant::now();
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
let ckpt = at.elapsed();
row(
"no autockpt",
&Timing {
commit: t.commit + ckpt,
total: t.total + ckpt,
..t
},
);
drop(conn);
arm.discard();
}
// How much of the index the pattern takes, swept. A prune that removes
// most of the postings is a different problem from one that removes a
// few: past some fraction FTS5 stops tombstoning and starts rewriting
// segments, and rewriting the full-text index is what building it was.
// `today` is what ships; `fixed` is the projection above.
println!("\n excluded fraction (today -> fixed, both + merge to quiescence):");
println!(
" {:<11} {:>8} {:>9} {:>9} {:>9} {:>11} {:>11}",
"patterns", "deleted", "today", "fixed", "merge", "%_data", "postings"
);
for n in 1..=PRUNE_PATTERNS.len() {
let patterns = &PRUNE_PATTERNS[..n];
let config = config_with(patterns);
let mut timings = Vec::new();
for (tag, stage, commit) in [
("today", Stage::FtsAll, Commit::PerPage),
("fixed", Stage::Subtree, Commit::Slice(scope::SLICE)),
] {
let arm = clone_arm(&master, &format!("prune-{}-f{}-{}", label, n, tag));
let conn = open(&arm);
let before = fts_data_rows(&conn);
let t = run_stage(&conn, &config, stage, commit);
let (merge, _, _, after) = merge_to_quiescence(&conn);
let left = count(&conn, "SELECT COUNT(*) FROM searchabletext");
timings.push((t, merge, before, after, left));
drop(conn);
arm.discard();
}
let ms = |d: Duration| d.as_secs_f64() * 1000.0;
println!(
" {:<11} {:>8} {:>8.0} {:>8.0} {:>8.0} {:>11} {:>11}",
format!("{} of 5", n),
timings[0].0.deleted,
ms(timings[0].0.total),
ms(timings[1].0.total),
ms(timings[1].1),
format!("{}->{}", timings[1].2, timings[1].3),
timings[1].4,
);
}
// The reference number every stage above is decomposing: the real
// `scope::advance`, driven to completion the way the coordinator drives
// it. It must land on `+fts(all)`.
{
let arm = clone_arm(&master, &format!("prune-{}-live", label));
let mut conn = open(&arm);
let old = config_with(&[]);
let actions = quicksearch_core::config::diff_actions(&old, &config);
let mut cursor = WorkCursor::new(actions.work, &config).expect("plan");
let registry = Registry::default_set();
let run = std::sync::atomic::AtomicBool::new(false);
let started = Instant::now();
while !cursor.done() {
scope::advance(
&mut conn,
&config,
&registry,
&mut cursor,
Instant::now() + scope::SLICE,
&run,
)
.expect("advance");
}
println!(
" {:<11} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8.0} {:>10} {:>9} <- scope::advance",
"live",
"",
"",
"",
"",
"",
started.elapsed().as_secs_f64() * 1000.0,
"",
cursor.deleted,
);
drop(conn);
arm.discard();
}
master.discard();
}
}

View file

@ -23,6 +23,7 @@ pub struct Config {
pub indexing: IndexingConfig, pub indexing: IndexingConfig,
pub processing: ProcessingConfig, pub processing: ProcessingConfig,
pub search: SearchConfig, pub search: SearchConfig,
pub duplicates: DuplicatesConfig,
pub ui: UiConfig, pub ui: UiConfig,
pub security: SecurityConfig, pub security: SecurityConfig,
/// File this config was loaded from; `save()` writes back to it. /// File this config was loaded from; `save()` writes back to it.
@ -172,6 +173,27 @@ impl Default for ColumnsConfig {
} }
} }
/// What the Duplicates tab lists. Nothing here changes what is indexed or
/// what a search finds — a file excluded from the duplicate listing is still
/// in the index and still turns up in results.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct DuplicatesConfig {
/// Globs, the same syntax and the same matcher as
/// `indexing.ignore_patterns` ([`IgnoreSet`]). A member whose path matches
/// is not counted, and a group left with fewer than two members is not
/// listed at all.
pub exclude_patterns: Vec<String>,
/// Content hashes of groups dismissed with "Hide this group", lowercase
/// hex — see [`crate::search::DuplicateGroup::hash_hex`]. Keyed by hash
/// rather than by path so a hidden group stays hidden when its files are
/// renamed or moved, and comes back when their contents change.
///
/// The hash covers `processing.hash_length` bytes, so changing that
/// setting (which rebuilds the index anyway) strands every entry here.
pub hidden_groups: Vec<String>,
}
impl SearchConfig { impl SearchConfig {
/// The caution to show next to `fuzzy_max_edits`, or `None` when sane. /// The caution to show next to `fuzzy_max_edits`, or `None` when sane.
pub fn fuzzy_edits_warning(&self) -> Option<String> { pub fn fuzzy_edits_warning(&self) -> Option<String> {
@ -286,9 +308,11 @@ pub struct UiConfig {
/// for. Keyed by root so that adding a folder warns again while /// for. Keyed by root so that adding a folder warns again while
/// restarting the app does not. /// restarting the app does not.
pub watch_cap_warned_roots: Vec<String>, pub watch_cap_warned_roots: Vec<String>,
/// System-wide shortcut that raises the window, as `Ctrl+Shift+F`. Empty /// The shortcut QuickSearch claims for itself while running, as
/// disables it; an unparseable value degrades to "no shortcut". On /// `Ctrl+Shift+F`. Empty disables it; an unparseable value degrades to
/// Wayland the desktop, not this value, has the final say. /// "no shortcut". On Wayland the desktop, not this value, has the final
/// say. It cannot fire while QuickSearch is not running: that is what a
/// desktop binding to `quicksearch --toggle` is for.
pub search_hotkey: String, pub search_hotkey: String,
/// `dark` or `light`. An unrecognised value falls back to dark, where a /// `dark` or `light`. An unrecognised value falls back to dark, where a
/// typed-out enum would fail to deserialize and take the whole config /// typed-out enum would fail to deserialize and take the whole config
@ -319,7 +343,7 @@ pub struct UiConfig {
impl Default for UiConfig { impl Default for UiConfig {
fn default() -> Self { fn default() -> Self {
UiConfig { UiConfig {
scale: 1.1, scale: 1.25,
watch_cap_warned_roots: Vec::new(), watch_cap_warned_roots: Vec::new(),
search_hotkey: "Ctrl+Shift+F".to_string(), search_hotkey: "Ctrl+Shift+F".to_string(),
color_scheme: "dark".to_string(), color_scheme: "dark".to_string(),

View file

@ -54,7 +54,7 @@ fn partial_file_gets_section_defaults() {
assert_eq!(cfg.paths.indexing_paths, vec!["/x".to_string()]); assert_eq!(cfg.paths.indexing_paths, vec!["/x".to_string()]);
assert_eq!(cfg.processing.batch_size, 500, "missing sections default"); assert_eq!(cfg.processing.batch_size, 500, "missing sections default");
assert_eq!(cfg.search.debounce_ms, 150); assert_eq!(cfg.search.debounce_ms, 150);
assert!((cfg.ui.scale - 1.1).abs() < f32::EPSILON); assert!((cfg.ui.scale - 1.25).abs() < f32::EPSILON);
assert_eq!(cfg.ui.search_hotkey, "Ctrl+Shift+F"); assert_eq!(cfg.ui.search_hotkey, "Ctrl+Shift+F");
} }
@ -785,11 +785,19 @@ fn ui_bookkeeping_fields_are_soft_knobs() {
// Named so the closures below coerce to fn pointers and share one array // Named so the closures below coerce to fn pointers and share one array
// type; without an annotation each would be its own anonymous type. // type; without an annotation each would be its own anonymous type.
type Knob = (&'static str, fn(&mut Config)); type Knob = (&'static str, fn(&mut Config));
let cases: [Knob; 2] = [ let cases: [Knob; 4] = [
("watch_cap_warned_roots", |c| { ("watch_cap_warned_roots", |c| {
c.ui.watch_cap_warned_roots = vec!["/media/ApolloStore".to_string()] c.ui.watch_cap_warned_roots = vec!["/media/ApolloStore".to_string()]
}), }),
("color_scheme", |c| c.ui.color_scheme = "light".to_string()), ("color_scheme", |c| c.ui.color_scheme = "light".to_string()),
// The duplicates filters decide what one tab lists and nothing else:
// a file left out of that listing is still indexed and still found.
("duplicates.exclude_patterns", |c| {
c.duplicates.exclude_patterns = vec!["*.iso".to_string()]
}),
("duplicates.hidden_groups", |c| {
c.duplicates.hidden_groups = vec!["ab".repeat(32)]
}),
]; ];
for (label, mutate) in cases { for (label, mutate) in cases {
let mut c = base.clone(); let mut c = base.clone();
@ -813,6 +821,8 @@ fn newer_fields_round_trip_and_default_when_absent() {
vec!["/media/ApolloStore".to_string(), "/media/GSSD".to_string()]; vec!["/media/ApolloStore".to_string(), "/media/GSSD".to_string()];
cfg.ui.color_scheme = "light".to_string(); cfg.ui.color_scheme = "light".to_string();
cfg.search.fuzzy_max_edits = 4; cfg.search.fuzzy_max_edits = 4;
cfg.duplicates.exclude_patterns = vec!["*.iso".to_string()];
cfg.duplicates.hidden_groups = vec!["ab".repeat(32)];
cfg.save().unwrap(); cfg.save().unwrap();
let loaded = Config::load_from(&path).unwrap(); let loaded = Config::load_from(&path).unwrap();
assert_eq!( assert_eq!(
@ -821,6 +831,7 @@ fn newer_fields_round_trip_and_default_when_absent() {
); );
assert_eq!(loaded.ui.color_scheme, "light"); assert_eq!(loaded.ui.color_scheme, "light");
assert_eq!(loaded.search.fuzzy_max_edits, 4); assert_eq!(loaded.search.fuzzy_max_edits, 4);
assert_eq!(loaded.duplicates, cfg.duplicates);
assert_eq!(Config::default().ui.color_scheme, "dark"); assert_eq!(Config::default().ui.color_scheme, "dark");
fs::write( fs::write(
@ -831,6 +842,11 @@ fn newer_fields_round_trip_and_default_when_absent() {
.unwrap(); .unwrap();
let cfg = Config::load_from(&path).unwrap(); let cfg = Config::load_from(&path).unwrap();
assert!(cfg.ui.watch_cap_warned_roots.is_empty()); assert!(cfg.ui.watch_cap_warned_roots.is_empty());
assert_eq!(
cfg.duplicates,
DuplicatesConfig::default(),
"a config predating the duplicates filters must not arrive with any"
);
assert_eq!(cfg.ui.color_scheme, "dark"); assert_eq!(cfg.ui.color_scheme, "dark");
assert_eq!(cfg.search.fuzzy_max_edits, 2); assert_eq!(cfg.search.fuzzy_max_edits, 2);
assert_eq!(cfg.ui.scale, 1.25, "existing ui keys still parse"); assert_eq!(cfg.ui.scale, 1.25, "existing ui keys still parse");
@ -886,6 +902,7 @@ fn the_documented_example_config_parses_to_the_defaults() {
"[search] drifted from the defaults" "[search] drifted from the defaults"
); );
assert_eq!(parsed.processing, d.processing); assert_eq!(parsed.processing, d.processing);
assert_eq!(parsed.duplicates, d.duplicates);
assert_eq!(parsed.ui.scale, d.ui.scale); assert_eq!(parsed.ui.scale, d.ui.scale);
assert_eq!(parsed.ui.color_scheme, d.ui.color_scheme); assert_eq!(parsed.ui.color_scheme, d.ui.color_scheme);
assert_eq!(parsed.ui.tutorial_seen, Some(false)); assert_eq!(parsed.ui.tutorial_seen, Some(false));

View file

@ -49,9 +49,9 @@ impl ExtractedRow {
} }
} }
/// The `files.name` the FTS row is indexed under. /// The whole path, parent and all — what progress and logs name the file by.
pub fn name(&self) -> &str { pub fn path(&self) -> &str {
self.path.name() self.path.as_str()
} }
} }

View file

@ -387,10 +387,10 @@ pub fn store_extracted(
// Counted before anything can skip it: a failed row still leaves. // Counted before anything can skip it: a failed row still leaves.
done.consumed += 1; done.consumed += 1;
match bodies.get(i) { match bodies.get(i) {
Err(e) => crate::log_warn!("compress text for {}: {}", row.name(), e), Err(e) => crate::log_warn!("compress text for {}: {}", row.path(), e),
Ok(zstd) => match store_content_outcome(&tx, row.file_id, &row.outcome, zstd) { Ok(zstd) => match store_content_outcome(&tx, row.file_id, &row.outcome, zstd) {
Ok(()) => done.written += 1, Ok(()) => done.written += 1,
Err(e) => crate::log_warn!("content indexing for {}: {}", row.name(), e), Err(e) => crate::log_warn!("content indexing for {}: {}", row.path(), e),
}, },
} }
if stop_flag.load(Ordering::Relaxed) || std::time::Instant::now() >= deadline { if stop_flag.load(Ordering::Relaxed) || std::time::Instant::now() >= deadline {

View file

@ -247,7 +247,7 @@ mod census {
.iter() .iter()
.map(|r| { .map(|r| {
(crate::file_handling::outcome_body(&r.outcome).map_or(0, str::len) (crate::file_handling::outcome_body(&r.outcome).map_or(0, str::len)
+ r.name().len()) as u64 + r.path().len()) as u64
}) })
.sum(); .sum();
crate::log_info!( crate::log_info!(
@ -529,8 +529,10 @@ impl RootPipeline {
} }
let stored = store_extracted(&cx.conn_mutex, ready, cx.stop_flag, cx.config, deadline)?; let stored = store_extracted(&cx.conn_mutex, ready, cx.stop_flag, cx.config, deadline)?;
if stored.consumed > 0 { if stored.consumed > 0 {
// The last row *written*, not the last fetched. // The last row *written*, not the last fetched. The whole path,
*current_file = Some(ready[stored.consumed - 1].name().to_string()); // as the walk phase publishes: the hint must not change what it
// means to a shorter name when a root crosses into extraction.
*current_file = Some(ready[stored.consumed - 1].path().to_string());
} }
ready.drain(..stored.consumed); ready.drain(..stored.consumed);
*written += stored.written; *written += stored.written;

View file

@ -42,6 +42,8 @@ pub struct RootProgress {
/// The count of files that have or will have text — not of files under the /// The count of files that have or will have text — not of files under the
/// root. `None` until the content pass has counted its range. /// root. `None` until the content pass has counted its range.
pub extract_total: Option<usize>, pub extract_total: Option<usize>,
/// A file the root has recently reached, as a whole path in every phase —
/// sampled, not every file, and during extraction the last one *written*.
pub current_file: Option<String>, pub current_file: Option<String>,
/// Threads busy / pool size for the current phase's pool; both zero once done. /// Threads busy / pool size for the current phase's pool; both zero once done.
pub active_workers: usize, pub active_workers: usize,

View file

@ -279,6 +279,13 @@ fn an_extracting_turn_lands_its_leftovers_one_slice_at_a_time() {
assert_eq!(done, 5, "every row reached the index"); assert_eq!(done, 5, "every row reached the index");
assert_eq!(p.snapshot().extracted, 5); assert_eq!(p.snapshot().extracted, 5);
assert_eq!(p.snapshot().extract_total, Some(0)); assert_eq!(p.snapshot().extract_total, Some(0));
// A whole path, as the walk phase publishes — not the bare `files.name`.
// The rows land in order, so the last one written is the last one seeded.
assert_eq!(
p.snapshot().current_file.as_deref(),
Some(format!("{}f4.txt", crate::file_handling::dir_to_db_parent(&tree)).as_str()),
"the extracting phase names the file by its full path"
);
drop(p); drop(p);
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();

View file

@ -36,6 +36,15 @@ pub struct DuplicateGroup {
pub members: Vec<(i64, String, String, u64, i64)>, pub members: Vec<(i64, String, String, u64, i64)>,
} }
impl DuplicateGroup {
/// The group's identity, for a UI that wants to remember one: lowercase
/// hex of the hash. Stable across renames and moves, because the hash
/// covers the content and nothing about where the files live.
pub fn hash_hex(&self) -> String {
crate::security::hex_encode(&self.hash)
}
}
/// A ranked group, ordered so that **greater is better**: more reclaimable /// A ranked group, ordered so that **greater is better**: more reclaimable
/// bytes first, and the lower rowid on a tie so repeated scans of an unchanged /// bytes first, and the lower rowid on a tie so repeated scans of an unchanged
/// index list the same groups in the same order. /// index list the same groups in the same order.
@ -297,6 +306,36 @@ mod tests {
std::fs::remove_file(&p).ok(); std::fs::remove_file(&p).ok();
} }
/// The identity a UI remembers a dismissed group by: it must be the hash
/// itself, spelled the one way, or a group hidden today comes back
/// tomorrow under a different spelling.
#[test]
fn a_group_spells_its_hash_the_one_way() {
let p = seed_db();
let groups = find_duplicate_groups(p.to_str().unwrap(), 10).unwrap();
for group in &groups {
let hex = group.hash_hex();
assert_eq!(hex.len(), group.hash.len() * 2);
assert!(hex
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
assert_eq!(hex, group.hash_hex(), "not stable across calls");
}
let again = find_duplicate_groups(p.to_str().unwrap(), 10).unwrap();
let spelled = |gs: &[DuplicateGroup]| gs.iter().map(|g| g.hash_hex()).collect::<Vec<_>>();
assert_eq!(
spelled(&groups),
spelled(&again),
"an unchanged index names its groups the same way twice"
);
assert_ne!(
groups[0].hash_hex(),
groups[1].hash_hex(),
"two groups must not answer to one name"
);
std::fs::remove_file(&p).ok();
}
#[test] #[test]
fn zero_limit_answers_without_opening_the_index() { fn zero_limit_answers_without_opening_the_index() {
assert!(find_duplicate_groups("/nonexistent/index.sqlite", 0) assert!(find_duplicate_groups("/nonexistent/index.sqlite", 0)

View file

@ -77,7 +77,9 @@ pub fn derive_key(password: &str, salt: &[u8; SALT_LEN]) -> IndexKey {
IndexKey(out) IndexKey(out)
} }
fn hex_encode(bytes: &[u8]) -> String { /// The crate's only hex encoder — also how a duplicate group spells its hash
/// ([`crate::search::DuplicateGroup::hash_hex`]).
pub(crate) fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2); let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes { for b in bytes {
use std::fmt::Write; use std::fmt::Write;

View file

@ -45,6 +45,9 @@ pub struct VerifyReport {
/// Index into the input paths of the file everything else was compared /// Index into the input paths of the file everything else was compared
/// against: the first one that opened. `None` when none of them did. /// against: the first one that opened. `None` when none of them did.
pub reference: Option<usize>, pub reference: Option<usize>,
/// How long that reference was, so a report can say *where* in the file a
/// difference landed. 0 when there was no reference to measure.
pub reference_len: u64,
pub verdicts: Vec<MemberVerdict>, pub verdicts: Vec<MemberVerdict>,
pub bytes_read: u64, pub bytes_read: u64,
} }
@ -109,6 +112,7 @@ pub fn verify_identical(paths: &[PathBuf], cancel: &AtomicBool, on: &mut dyn FnM
let Some((reference, mut reference_file, reference_len)) = reference else { let Some((reference, mut reference_file, reference_len)) = reference else {
on(VerifyUpdate::Done(VerifyReport { on(VerifyUpdate::Done(VerifyReport {
reference: None, reference: None,
reference_len: 0,
verdicts, verdicts,
bytes_read: 0, bytes_read: 0,
})); }));
@ -232,6 +236,7 @@ pub fn verify_identical(paths: &[PathBuf], cancel: &AtomicBool, on: &mut dyn FnM
on(VerifyUpdate::Done(VerifyReport { on(VerifyUpdate::Done(VerifyReport {
reference: Some(reference), reference: Some(reference),
reference_len,
verdicts, verdicts,
bytes_read, bytes_read,
})); }));

View file

@ -53,9 +53,10 @@ rfd = "0.15"
open = "5" open = "5"
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
# The system-wide search shortcut, where the display server lets an # The shortcut QuickSearch claims for itself while running, where the display
# application claim keys for itself: `RegisterHotKey` on Windows, `XGrabKey` # server lets an application claim keys: `RegisterHotKey` on Windows,
# on X11. Wayland does not, and is handled by the portal below. # `XGrabKey` on X11. Wayland does not, and is handled by the portal below.
# This is the zero-setup path; `--toggle` covers the app not running.
global-hotkey = "0.8" global-hotkey = "0.8"
# Display backends, which only exist on Linux/BSD. `default-features = false` # Display backends, which only exist on Linux/BSD. `default-features = false`
@ -67,25 +68,33 @@ eframe = { version = "0.32", default-features = false, features = [
"x11", "x11",
] } ] }
# The Wayland half of the search shortcut: `org.freedesktop.portal.GlobalShortcuts`, # The Wayland half of the in-application shortcut:
# the only way a Wayland application can be told about a key it does not own. # `org.freedesktop.portal.GlobalShortcuts`, the only way a Wayland application
# `rfd` already pulls all four in (it uses the file-chooser portal), so the # can be told about a key it does not own. `rfd` already pulls all four in (it
# versions here are the ones already resolved and nothing extra is compiled. # uses the file-chooser portal), so the versions here are the ones already
# `default-features = false` matters: ashpd defaults to Tokio, which would add # resolved and nothing extra is compiled. `default-features = false` matters:
# a second async runtime and switch `zbus` over to it underneath `rfd`. # 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"] } ashpd = { version = "0.11", default-features = false, features = ["async-std"] }
futures-channel = "0.3" futures-channel = "0.3"
futures-util = "0.3" futures-util = "0.3"
pollster = "0.4" pollster = "0.4"
# Raising the window from the shortcut on X11, which winit cannot do: it asks # Raising the window from either shortcut on X11, which winit cannot
# with a source indication of "application", and every mainstream window # do: it asks with a source indication of "application", and every mainstream
# manager refuses that from a window that is not already focused. See # window manager refuses that from a window that is not already focused. See
# `hotkey::raise`. Both are already in the tree (winit's own X11 backend, and # `activate::raise`. Both are already in the tree (winit's own X11 backend,
# eframe's window handle), so neither adds a crate. # and eframe's window handle), so neither adds a crate.
x11rb = "0.13" x11rb = "0.13"
raw-window-handle = "0.6" raw-window-handle = "0.6"
# 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 —
# `cargo build --bin quicksearch` does not build dev-dependencies at all.
[target.'cfg(all(unix, not(target_os = "macos")))'.dev-dependencies]
x11rb = { version = "0.13", features = ["xtest"] }
# Console attachment for the GUI binary (which has no stdio when launched from # Console attachment for the GUI binary (which has no stdio when launched from
# Explorer) and VT-mode enabling for the CLI binary. 0.59 matches what eframe # Explorer) and VT-mode enabling for the CLI binary. 0.59 matches what eframe
# and rfd already resolve, so no extra crate is compiled. # and rfd already resolve, so no extra crate is compiled.
@ -93,4 +102,11 @@ raw-window-handle = "0.6"
windows-sys = { version = "0.59", features = [ windows-sys = { version = "0.59", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_System_Console", "Win32_System_Console",
# The `--toggle` named pipe in `activate`. `Win32_System_IO` and
# `Win32_Security` are not used directly: they carry the `OVERLAPPED` and
# `SECURITY_ATTRIBUTES` types naming the pointers we pass as null.
"Win32_System_Pipes",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_Security",
] } ] }

View file

@ -0,0 +1,234 @@
//! Does the window manager honour `_NET_ACTIVE_WINDOW` from a client that is
//! not focused, and does the timestamp matter?
//!
//! `activate::raise` depends on the answer: it sends source indication 2
//! ("direct user action") with `CURRENT_TIME`, and the search shortcut looked
//! broken because the window never came forward. This reproduces that exact
//! request in isolation, so the fix is chosen against a real window manager
//! rather than against a reading of the EWMH spec.
//!
//! It creates its own window, lets a freshly spawned `xterm` take focus, then
//! tries to activate itself and reports whether it won.
//!
//! ```text
//! cargo run -p quicksearch-gui --example raiseprobe -- current
//! cargo run -p quicksearch-gui --example raiseprobe -- server
//! ```
//!
//! `current` sends `CURRENT_TIME`, what the code does today. `server` fetches
//! a real server timestamp first and also sets `_NET_WM_USER_TIME`.
use std::time::Duration;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
AtomEnum, ChangeWindowAttributesAux, ClientMessageEvent, ConnectionExt, CreateWindowAux,
EventMask, PropMode, Window, WindowClass,
};
use x11rb::protocol::Event;
use x11rb::rust_connection::RustConnection;
use x11rb::wrapper::ConnectionExt as _;
use x11rb::CURRENT_TIME;
fn atom(conn: &RustConnection, name: &[u8]) -> Result<u32, Box<dyn std::error::Error>> {
Ok(conn.intern_atom(false, name)?.reply()?.atom)
}
/// The window the WM currently considers active, per the root property.
fn active(conn: &RustConnection, root: Window) -> Result<Window, Box<dyn std::error::Error>> {
let prop = atom(conn, b"_NET_ACTIVE_WINDOW")?;
let reply = conn
.get_property(false, root, prop, AtomEnum::WINDOW, 0, 1)?
.reply()?;
Ok(reply.value32().and_then(|mut v| v.next()).unwrap_or(0))
}
/// A real timestamp, the standard way: a zero-length property append comes
/// back as a `PropertyNotify` carrying the server's current time.
fn server_time(conn: &RustConnection, window: Window) -> Result<u32, Box<dyn std::error::Error>> {
let marker = atom(conn, b"_QUICKSEARCH_TIME")?;
conn.change_property8(PropMode::APPEND, window, marker, AtomEnum::STRING, &[])?;
conn.flush()?;
loop {
if let Event::PropertyNotify(e) = conn.wait_for_event()? {
if e.window == window && e.atom == marker {
return Ok(e.time);
}
}
}
}
/// Minimise someone else's window, so the "restores it if it was minimised"
/// half of `raise` can be tested. There is no command-line tool for this on
/// this machine, and ICCCM says a client asks by sending `WM_CHANGE_STATE`.
fn iconify(target: &str) -> Result<(), Box<dyn std::error::Error>> {
let target = u32::from_str_radix(target.trim_start_matches("0x"), 16)?;
let (conn, screen_num) = x11rb::connect(None)?;
let root = conn.setup().roots[screen_num].root;
let change_state = atom(&conn, b"WM_CHANGE_STATE")?;
// 3 is ICCCM's IconicState.
let event = ClientMessageEvent::new(32, target, change_state, [3, 0, 0, 0, 0]);
conn.send_event(
false,
root,
EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
event,
)?;
conn.flush()?;
std::thread::sleep(Duration::from_millis(300));
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mode = std::env::args().nth(1).unwrap_or_else(|| "current".into());
if mode == "iconify" {
let target = std::env::args()
.nth(2)
.ok_or("usage: raiseprobe iconify <winid>")?;
return iconify(&target);
}
if mode == "hotkey" {
return send_hotkey();
}
if mode != "current" && mode != "server" {
eprintln!("usage: raiseprobe [current|server|iconify <winid>|hotkey]");
std::process::exit(2);
}
let (conn, screen_num) = x11rb::connect(None)?;
let screen = &conn.setup().roots[screen_num];
let root = screen.root;
// A window the WM will manage like any other: not override-redirect, and
// asking for PropertyNotify so `server_time` has something to wait on.
let window = conn.generate_id()?;
conn.create_window(
x11rb::COPY_DEPTH_FROM_PARENT,
window,
root,
100,
100,
400,
200,
2,
WindowClass::INPUT_OUTPUT,
screen.root_visual,
&CreateWindowAux::new()
.background_pixel(screen.white_pixel)
.event_mask(EventMask::PROPERTY_CHANGE | EventMask::STRUCTURE_NOTIFY),
)?;
conn.change_property8(
PropMode::REPLACE,
window,
AtomEnum::WM_NAME,
AtomEnum::STRING,
b"quicksearch raiseprobe",
)?;
conn.map_window(window)?;
conn.flush()?;
std::thread::sleep(Duration::from_millis(800));
println!("probe window : 0x{:x}", window);
// Something else has to hold focus, or activating ourselves proves nothing.
let mut thief = std::process::Command::new("xterm")
.args(["-geometry", "40x8+600+400", "-T", "raiseprobe-thief"])
.spawn()?;
std::thread::sleep(Duration::from_millis(2500));
let before = active(&conn, root)?;
println!("active before : 0x{:x}", before);
if before == window {
println!("INCONCLUSIVE: the probe still had focus; xterm never took it.");
let _ = thief.kill();
return Ok(());
}
// The request under test, as `raise::x11_activate` builds it.
let time = if mode == "server" {
let t = server_time(&conn, window)?;
let user_time = atom(&conn, b"_NET_WM_USER_TIME")?;
conn.change_property32(
PropMode::REPLACE,
window,
user_time,
AtomEnum::CARDINAL,
&[t],
)?;
conn.flush()?;
println!("server timestamp : {}", t);
t
} else {
println!("server timestamp : CURRENT_TIME (0)");
CURRENT_TIME
};
let net_active = atom(&conn, b"_NET_ACTIVE_WINDOW")?;
let message = ClientMessageEvent::new(32, window, net_active, [2, time, 0, 0, 0]);
conn.send_event(
false,
root,
EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
message,
)?;
conn.flush()?;
std::thread::sleep(Duration::from_millis(1200));
let after = active(&conn, root)?;
println!("active after : 0x{:x}", after);
println!(
"\nRESULT ({}): {}",
mode,
if after == window {
"RAISED — the window manager honoured it"
} else {
"REFUSED — the window did not come forward"
}
);
let _ = thief.kill();
let _ = thief.wait();
conn.change_window_attributes(window, &ChangeWindowAttributesAux::new())?;
conn.destroy_window(window)?;
conn.flush()?;
Ok(())
}
/// Synthesise a global `Ctrl+Shift+F` through XTEST, so the whole shortcut
/// path can be tested the way a user exercises it: a real key press arriving
/// while some other window has focus.
fn send_hotkey() -> Result<(), Box<dyn std::error::Error>> {
use x11rb::protocol::xproto::{GetKeyboardMappingReply, KEY_PRESS_EVENT, KEY_RELEASE_EVENT};
let (conn, screen_num) = x11rb::connect(None)?;
let root = conn.setup().roots[screen_num].root;
let min = conn.setup().min_keycode;
let count = conn.setup().max_keycode - min + 1;
let mapping: GetKeyboardMappingReply = conn.get_keyboard_mapping(min, count)?.reply()?;
let per = mapping.keysyms_per_keycode as usize;
let keycode = |sym: u32| -> Option<u8> {
mapping
.keysyms
.chunks(per)
.position(|row| row.contains(&sym))
.map(|i| min + i as u8)
};
// XK_Control_L, XK_Shift_L, XK_f.
let ctrl = keycode(0xffe3).ok_or("no Control key")?;
let shift = keycode(0xffe1).ok_or("no Shift key")?;
let f = keycode(0x0066).ok_or("no F key")?;
for (ty, code) in [
(KEY_PRESS_EVENT, ctrl),
(KEY_PRESS_EVENT, shift),
(KEY_PRESS_EVENT, f),
(KEY_RELEASE_EVENT, f),
(KEY_RELEASE_EVENT, shift),
(KEY_RELEASE_EVENT, ctrl),
] {
x11rb::protocol::xtest::fake_input(&conn, ty, code, 0, root, 0, 0, 0)?;
}
conn.flush()?;
std::thread::sleep(Duration::from_millis(300));
Ok(())
}

View file

@ -0,0 +1,590 @@
//! Bringing the running window forward from a second process.
//!
//! The desktop owns the search shortcut: the user binds it to
//! `quicksearch --toggle`, which either hands the activation to an instance
//! that is already running and exits, or — when nothing is running — becomes
//! that instance. This is the only shape that can satisfy "focus the search
//! box whether or not the app was started", because a shortcut an application
//! registers for itself cannot fire while the application is not there.
//!
//! The message carries nothing: "come forward" is the whole protocol, and
//! the reply exists only so the sender can tell a live instance from a
//! leftover socket. An xdg-activation token would be the natural thing to
//! carry — it is what a compositor wants before letting a background client
//! take focus — but nothing downstream can consume one: winit 0.30 applies a
//! token only in `WindowAttributes`, and egui's `ViewportBuilder` has no
//! field for it, so eframe never plumbs one either. See [`raise`] for what
//! that costs on Wayland.
pub mod raise;
pub use raise::raise;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
/// A flag rather than a queue: two presses before the app can redraw mean
/// the same thing as one.
static PENDING: AtomicBool = AtomicBool::new(false);
/// The socket identifying the instance that `config_path` configures.
///
/// **Keyed by the config file, not the index.** The index path is a setting
/// the user can change while running — `IndexLock` moves with it — and a
/// socket that moved too would leave `--toggle` looking for the old one; the
/// config path is fixed for the life of both processes and is what makes them
/// agree. Two GUIs under different configs still get different sockets, which
/// is the only way two can legitimately run at once.
///
/// It lives in the runtime directory rather than beside the index: that is
/// tmpfs, private to the session, and cleared at logout, so a socket cannot
/// outlive the login that made it or land on a read-only portable install.
pub fn path_for(config_path: &Path) -> PathBuf {
runtime_dir().join(format!("quicksearch-{:016x}.sock", key(config_path)))
}
/// FNV-1a over the config path. Spelled out rather than taken from
/// `DefaultHasher`, whose output Rust does not promise to keep stable: the
/// `--toggle` process and the running one must agree on this name even when
/// an upgrade has left them different builds.
fn key(config_path: &Path) -> u64 {
let bytes = config_path.as_os_str().as_encoded_bytes();
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// `$XDG_RUNTIME_DIR` when the session set one, else a private directory of
/// our own under the temporary directory — which is shared, hence the mode.
fn runtime_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") {
let dir = PathBuf::from(dir);
if dir.is_absolute() {
return dir;
}
}
let fallback = std::env::temp_dir().join(format!(
"quicksearch-{}",
std::env::var("USER").unwrap_or_else(|_| "user".to_string())
));
let _ = quicksearch_core::platform::create_dir_private(&fallback);
fallback
}
/// Take the activation asked for since the last call.
pub fn take_pending() -> bool {
PENDING.swap(false, Ordering::SeqCst)
}
/// What to tell the user to bind, as they would type it. The installed name
/// when we are on the path under it, and the full path otherwise — a build
/// run out of `target/` is the common case, and "quicksearch" would be wrong
/// advice there.
pub fn command_name() -> String {
let Ok(exe) = std::env::current_exe() else {
return "quicksearch".to_string();
};
let installed = exe
.parent()
.is_some_and(|dir| matches!(dir.to_str(), Some("/usr/bin") | Some("/usr/local/bin")));
if installed {
exe.file_name()
.and_then(|n| n.to_str())
.unwrap_or("quicksearch")
.to_string()
} else {
exe.display().to_string()
}
}
/// Record an activation and wake the window. Without the repaint an idle
/// event loop would leave it unread until the user moved the mouse.
///
/// Called both by the socket listener here and by `crate::hotkey` when the
/// 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) {
PENDING.store(true, Ordering::SeqCst);
ctx.request_repaint();
}
#[cfg(unix)]
mod imp {
use super::*;
use std::os::unix::net::{UnixListener, UnixStream};
/// Ask the instance configured by `config_path` to come forward.
///
/// `true` means a live instance accepted it. `false` means there is none
/// — a refused connection, or no socket at all — and the caller should
/// 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.
pub fn signal(config_path: &Path) -> bool {
let Ok(mut stream) = UnixStream::connect(path_for(config_path)) else {
return false;
};
// Bounded on both halves: a `--toggle` must never become a process
// that hangs on the keypress, whatever is on the other end.
let timeout = std::time::Duration::from_secs(5);
if stream.set_read_timeout(Some(timeout)).is_err()
|| stream.set_write_timeout(Some(timeout)).is_err()
{
return false;
}
if stream.write_all(b"\n").is_err() || stream.flush().is_err() {
return false;
}
// The reply is what distinguishes "delivered" from "wrote into a
// socket nobody reads". EOF means the peer went away.
let mut ack = [0u8; 1];
matches!(stream.read(&mut ack), Ok(1))
}
/// Start answering activations for the instance `config_path` configures.
///
/// Call only while holding the index lock: binding unlinks whatever is
/// in the way, and the lock is what proves no live instance under this
/// config could be listening on it.
///
/// Failure is logged and otherwise ignored — a runtime directory that
/// cannot host a socket is not a reason to refuse to open the window.
pub fn listen(ctx: &egui::Context, config_path: &Path) {
let path = path_for(config_path);
let _ = std::fs::remove_file(&path);
let listener = match UnixListener::bind(&path) {
Ok(l) => l,
Err(e) => {
quicksearch_core::log_warn!(
"the search shortcut cannot listen on {}: {}",
path.display(),
e
);
return;
}
};
// Ours alone: this socket is a way to make the window jump to the
// front, and `bind` honours the umask rather than any mode we want.
if let Err(e) = restrict(&path) {
quicksearch_core::log_warn!("securing {}: {}", path.display(), e);
}
let ctx = ctx.clone();
if let Err(e) = std::thread::Builder::new()
.name("quicksearch-activate".to_string())
.spawn(move || serve(&ctx, listener))
{
quicksearch_core::log_warn!("the search shortcut listener: {}", e);
}
}
fn restrict(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
}
/// Runs until the process exits; a connection is one activation.
fn serve(ctx: &egui::Context, listener: UnixListener) {
for stream in listener.incoming() {
let Ok(stream) = stream else { continue };
match answer(stream) {
Ok(()) => fire(ctx),
// One stalled or truncated peer must not stop the loop, and
// must not raise the window on a request it never finished.
Err(e) => quicksearch_core::log_warn!("a search shortcut request: {}", e),
}
}
}
/// Read the request and acknowledge it.
///
/// Hostile input is the norm rather than the exception: any process of
/// this user can connect. The read is bounded in both bytes and time, so
/// a peer that connects and stalls cannot wedge the one thread that
/// answers every activation.
pub(super) fn answer(mut stream: UnixStream) -> std::io::Result<()> {
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)?;
stream.write_all(b"\n")?;
stream.flush()
}
}
/// The same handshake over a named pipe, which is what Windows has instead
/// of a unix socket.
///
/// **One deliberate difference: there is no reply.** A named pipe exists only
/// while a server holds an instance open — there is no file left behind — so
/// a successful `CreateFileW` already proves a live instance accepted us, and
/// the reply the unix side needs to tell a listener from a leftover socket
/// would be dead weight here.
#[cfg(windows)]
mod imp {
use super::*;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{
CloseHandle, GetLastError, ERROR_PIPE_BUSY, GENERIC_READ, GENERIC_WRITE, HANDLE,
INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, ReadFile, FILE_SHARE_NONE, OPEN_EXISTING, PIPE_ACCESS_DUPLEX,
};
use windows_sys::Win32::Storage::FileSystem::{FlushFileBuffers, WriteFile};
use windows_sys::Win32::System::Pipes::{
ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE,
PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT,
};
/// `\\.\pipe\quicksearch-<key>`, keyed exactly as the unix socket is, so
/// the two processes agree by the same rule on both platforms.
pub(super) fn pipe_name(config_path: &Path) -> Vec<u16> {
let name = format!("\\\\.\\pipe\\quicksearch-{:016x}", key(config_path));
std::ffi::OsStr::new(&name)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
/// Closes its handle however the scope ends, including on an early error.
struct Handle(HANDLE);
impl Drop for Handle {
fn drop(&mut self) {
// SAFETY: `self.0` came from a Create* call that returned success
// and is closed exactly once, here.
unsafe { CloseHandle(self.0) };
}
}
/// Ask the instance configured by `config_path` to come forward.
///
/// `false` means no instance is serving the pipe and the caller should
/// start the GUI itself.
pub fn signal(config_path: &Path) -> bool {
let name = pipe_name(config_path);
// A busy pipe means an instance is there but mid-handshake with
// another `--toggle`; anything else means nobody is listening.
for _ in 0..5 {
// SAFETY: `name` is a NUL-terminated wide string that outlives
// the call, and the two null pointers are documented as optional.
let handle = unsafe {
CreateFileW(
name.as_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_NONE,
std::ptr::null(),
OPEN_EXISTING,
0,
std::ptr::null_mut(),
)
};
if handle == INVALID_HANDLE_VALUE {
// SAFETY: reads this thread's last error, always valid.
if unsafe { GetLastError() } == ERROR_PIPE_BUSY {
std::thread::sleep(std::time::Duration::from_millis(50));
continue;
}
return false;
}
let pipe = Handle(handle);
let mut written = 0u32;
// SAFETY: a one-byte buffer and an output slot, both live here.
let ok = unsafe {
WriteFile(
pipe.0,
[b'\n'].as_ptr(),
1,
&mut written,
std::ptr::null_mut(),
)
};
if ok == 0 || written != 1 {
return false;
}
// SAFETY: the handle is open for the length of this call.
unsafe { FlushFileBuffers(pipe.0) };
return true;
}
false
}
/// Start answering activations for the instance `config_path` configures.
///
/// Failure is logged and otherwise ignored, exactly as on unix: a pipe
/// that cannot be created is not a reason to refuse to open the window.
pub fn listen(ctx: &egui::Context, config_path: &Path) {
let name = pipe_name(config_path);
let ctx = ctx.clone();
if let Err(e) = std::thread::Builder::new()
.name("quicksearch-activate".to_string())
.spawn(move || serve(&ctx, &name))
{
quicksearch_core::log_warn!("the search shortcut listener: {}", e);
}
}
/// Runs until the process exits; one instance serves one activation, and
/// a fresh instance is created for the next.
fn serve(ctx: &egui::Context, name: &[u16]) {
loop {
// SAFETY: `name` is a NUL-terminated wide string borrowed for the
// call; the null security descriptor gives the default, which
// keeps the pipe to this user's own session.
let handle = unsafe {
CreateNamedPipeW(
name.as_ptr(),
PIPE_ACCESS_DUPLEX,
PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
PIPE_UNLIMITED_INSTANCES,
16,
16,
0,
std::ptr::null(),
)
};
if handle == INVALID_HANDLE_VALUE {
// SAFETY: reads this thread's last error, always valid.
let code = unsafe { GetLastError() };
quicksearch_core::log_warn!("the search shortcut pipe: error {}", code);
return;
}
let pipe = Handle(handle);
// SAFETY: the handle is open and no overlapped structure is used.
let connected = unsafe { ConnectNamedPipe(pipe.0, std::ptr::null_mut()) };
// Zero can still mean a client that connected before we asked;
// the read below is what decides, so it is not checked here.
let _ = connected;
let mut scratch = [0u8; 1];
let mut read = 0u32;
// SAFETY: a one-byte buffer and an output slot, both live here.
let ok = unsafe {
ReadFile(
pipe.0,
scratch.as_mut_ptr(),
1,
&mut read,
std::ptr::null_mut(),
)
};
// SAFETY: the handle is open for the length of this call.
unsafe { DisconnectNamedPipe(pipe.0) };
// A peer that connected and said nothing is not an activation.
if ok != 0 && read == 1 {
fire(ctx);
}
}
}
}
pub use imp::{listen, signal};
#[cfg(test)]
mod tests {
use super::*;
/// `PENDING` is process-wide and these tests run in parallel, so the ones
/// that read it must not overlap: without this, one test's `take_pending`
/// consumes the flag another just set and both look correct in isolation
/// while failing together perhaps one run in fifty.
static PENDING_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Ignore poisoning: a failed test elsewhere must not cascade into
/// every other test that touches the flag.
fn pending_guard() -> std::sync::MutexGuard<'static, ()> {
PENDING_TESTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The name must be a function of the config path alone, so the
/// `--toggle` process and the running one always agree on it.
#[test]
fn one_config_always_names_one_socket() {
let config = Path::new("/home/u/.config/quicksearch/config.toml");
assert_eq!(path_for(config), path_for(config));
assert_ne!(path_for(config), path_for(Path::new("/elsewhere.toml")));
}
/// The index path is a live setting; the socket must not follow it, or a
/// `database_path` change would strand every later `--toggle`.
#[test]
fn the_socket_does_not_depend_on_the_index() {
assert!(!path_for(Path::new("/c.toml"))
.to_string_lossy()
.contains("index"));
}
/// Distinct paths that share a suffix must not collide.
#[test]
fn different_configs_name_different_sockets() {
let a = key(Path::new("/a/config.toml"));
let b = key(Path::new("/b/config.toml"));
assert_ne!(a, b);
}
#[test]
fn a_pending_activation_is_consumed_once() {
let _serial = pending_guard();
PENDING.store(true, Ordering::SeqCst);
assert!(take_pending());
assert!(!take_pending(), "the flag is consumed");
}
/// A config path unique to this test, so the sockets these bind never
/// collide: they run on one process, in parallel.
#[cfg(unix)]
fn scratch(name: &str) -> PathBuf {
let path = PathBuf::from(format!("/qs-activate-{}-{}.toml", name, std::process::id()));
let _ = std::fs::remove_file(path_for(&path));
path
}
/// Nothing is listening on a path that was never bound.
#[cfg(unix)]
#[test]
fn signalling_nothing_reports_nothing() {
assert!(!signal(&scratch("absent")));
}
/// A leftover socket file with no listener must read as "not running",
/// or a crash would strand the user behind a file nobody answers.
#[cfg(unix)]
#[test]
fn a_stale_socket_file_is_not_an_instance() {
let db = scratch("stale");
std::fs::write(path_for(&db), b"").expect("leftover file");
assert!(!signal(&db));
}
/// The whole point, end to end: a signal to a live listener is accepted,
/// and the listener sees a well-formed request.
#[cfg(unix)]
#[test]
fn a_signal_crosses_the_socket() {
use std::os::unix::net::UnixListener;
let db = scratch("roundtrip");
let listener = UnixListener::bind(path_for(&db)).expect("bind");
let sender = std::thread::spawn(move || signal(&db));
let stream = listener
.incoming()
.next()
.expect("a connection")
.expect("accepted");
imp::answer(stream).expect("a well-formed request");
assert!(sender.join().expect("sender"), "the client saw the reply");
}
/// `listen` must replace a crashed predecessor's socket rather than
/// giving up on it.
#[cfg(unix)]
#[test]
fn binding_replaces_a_leftover_socket() {
let _serial = pending_guard();
let db = scratch("rebind");
let path = path_for(&db);
std::fs::write(&path, b"").expect("leftover file");
let ctx = egui::Context::default();
listen(&ctx, &db);
// Proof the listener took the path over: a signal now lands.
assert!(signal(&db), "the new listener answers");
assert!(take_pending(), "and the window was asked to come forward");
}
/// A peer that connects and says nothing must not wedge the listener,
/// nor count as an activation.
#[cfg(unix)]
#[test]
fn a_silent_peer_does_not_raise_the_window() {
use std::os::unix::net::{UnixListener, UnixStream};
let db = scratch("silent");
let path = path_for(&db);
let listener = UnixListener::bind(&path).expect("bind");
// Connect, send nothing, close: `read_exact` sees EOF.
let peer = UnixStream::connect(&path).expect("connect");
drop(peer);
let stream = listener
.incoming()
.next()
.expect("a connection")
.expect("accepted");
assert!(imp::answer(stream).is_err(), "an empty request is refused");
}
/// A config path unique to this test. Never opened as a file — only
/// hashed into a pipe name — so it need not exist.
#[cfg(windows)]
fn scratch(name: &str) -> PathBuf {
PathBuf::from(format!(
"C:\\qs-activate-{}-{}.toml",
name,
std::process::id()
))
}
/// The pipe name must be a function of the config path alone, exactly as
/// the socket name is, or the two processes would not meet.
#[cfg(windows)]
#[test]
fn one_config_always_names_one_pipe() {
let a = imp::pipe_name(Path::new("C:\\a\\config.toml"));
assert_eq!(a, imp::pipe_name(Path::new("C:\\a\\config.toml")));
assert_ne!(a, imp::pipe_name(Path::new("C:\\b\\config.toml")));
// NUL-terminated, as every wide Win32 string argument must be.
assert_eq!(a.last(), Some(&0));
}
/// Nothing is serving a pipe nobody created. On Windows this is the
/// whole liveness test: a pipe cannot outlive the process serving it, so
/// there is no stale-file case to cover as there is on unix.
#[cfg(windows)]
#[test]
fn signalling_nothing_reports_nothing() {
assert!(!signal(&scratch("absent")));
}
/// End to end: `listen` serves the pipe and a signal reaches it.
#[cfg(windows)]
#[test]
fn a_signal_crosses_the_pipe() {
let _serial = pending_guard();
let config = scratch("roundtrip");
let ctx = egui::Context::default();
listen(&ctx, &config);
// The server thread has to reach its first `CreateNamedPipeW`; retry
// rather than sleeping a guessed amount.
let mut delivered = false;
for _ in 0..100 {
if signal(&config) {
delivered = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(delivered, "the listener never answered");
assert!(take_pending(), "and the window was asked to come forward");
}
}

View file

@ -0,0 +1,229 @@
//! Bringing the window to the front when a search shortcut fires — either the
//! one QuickSearch registered for itself (`crate::hotkey`) or one a desktop
//! binding relayed through `--toggle`. Both land here. Every display server
//! stops applications raising themselves, so the request has to carry a
//! reason a window manager will accept.
//!
//! * **X11**: winit asks with `_NET_ACTIVE_WINDOW` source indication 1
//! ("application"), which KWin, Mutter and Xfwm all refuse from an
//! unfocused window, and its `focus_window` does nothing while minimised.
//! Hence [`x11_activate`], which sends source indication 2 (EWMH's "direct
//! user action"). Do not replace it with winit's version.
//! * **Wayland**: a client cannot raise itself; the compositor only honours
//! an xdg-activation token, and winit 0.30 applies one in exactly one
//! place — `WindowAttributes::with_activation_token`, at window creation —
//! which egui's `ViewportBuilder` has no field for and eframe therefore
//! never sets. `focus_window` on Wayland is an empty function body. So a
//! `--toggle` that *starts* the app gets whatever focus the compositor
//! gives a newly mapped window, and one that finds it already running
//! cannot raise it at all: the most this path can do is ask for attention,
//! which shows up as a highlighted task entry. Closing that gap means
//! patching both winit (to activate a live surface) and eframe (to carry
//! the token), the way `vendor/` already patches two other crates.
//! * **Windows**: `SetForegroundWindow` is refused to background processes,
//! so the same limit applies to a process that did not just receive input.
/// 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.
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) {
#[cfg(all(unix, not(target_os = "macos")))]
{
if x11_activate(frame) {
// Belt and braces: EWMH says activating an iconified window
// de-iconifies it, but not every window manager does, and a
// window left minimised cannot take the caret.
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() {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(
egui::UserAttentionType::Informational,
));
return;
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
let _ = frame;
}
// A window still minimised cannot take focus.
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
// The X connection used for activation, kept open across presses.
// Thread-local because `raise` only ever runs on the UI thread, and held
// rather than reconnected because a connect per keypress is both wasteful
// and, with the timestamp round trip below, an extra round trip.
#[cfg(all(unix, not(target_os = "macos")))]
thread_local! {
static X11: std::cell::RefCell<Option<X11State>> = const { std::cell::RefCell::new(None) };
}
#[cfg(all(unix, not(target_os = "macos")))]
struct X11State {
conn: x11rb::rust_connection::RustConnection,
root: u32,
/// A 1x1 unmapped window of our own, existing only so there is a
/// property we may change to ask the server what time it is.
clock: u32,
net_active: u32,
user_time: u32,
marker: u32,
}
#[cfg(all(unix, not(target_os = "macos")))]
impl X11State {
fn connect() -> Result<X11State, Box<dyn std::error::Error>> {
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ConnectionExt, CreateWindowAux, EventMask, WindowClass};
let (conn, screen_num) = x11rb::connect(None)?;
let root = conn.setup().roots[screen_num].root;
let clock = conn.generate_id()?;
// InputOnly and never mapped: invisible, and the window manager
// ignores it entirely.
conn.create_window(
0,
clock,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_ONLY,
0,
&CreateWindowAux::new().event_mask(EventMask::PROPERTY_CHANGE),
)?;
let intern = |name: &[u8]| -> Result<u32, Box<dyn std::error::Error>> {
Ok(conn.intern_atom(false, name)?.reply()?.atom)
};
let state = X11State {
root,
clock,
net_active: intern(b"_NET_ACTIVE_WINDOW")?,
user_time: intern(b"_NET_WM_USER_TIME")?,
marker: intern(b"_QUICKSEARCH_CLOCK")?,
conn,
};
Ok(state)
}
/// The server's current time.
///
/// **Not `CURRENT_TIME`.** KWin reads a zero timestamp as the window
/// saying it does not want focus and refuses the activation, which is
/// what made the shortcut raise the window only sometimes. The standard
/// way to get a real one is a zero-length property append, which comes
/// back as a `PropertyNotify` stamped by the server.
fn now(&self) -> Result<u32, Box<dyn std::error::Error>> {
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{AtomEnum, PropMode};
use x11rb::protocol::Event;
use x11rb::wrapper::ConnectionExt as _;
self.conn.change_property8(
PropMode::APPEND,
self.clock,
self.marker,
AtomEnum::STRING,
&[],
)?;
self.conn.flush()?;
// Bounded: this runs on the UI thread, which may never block on the
// X server for longer than a frame or two.
let deadline = std::time::Instant::now() + std::time::Duration::from_millis(100);
while std::time::Instant::now() < deadline {
match self.conn.poll_for_event()? {
Some(Event::PropertyNotify(e)) if e.window == self.clock => return Ok(e.time),
Some(_) => continue,
None => std::thread::sleep(std::time::Duration::from_millis(2)),
}
}
Err("the X server did not answer with a timestamp".into())
}
fn activate(&self, window: u32) -> Result<(), Box<dyn std::error::Error>> {
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{
AtomEnum, ClientMessageEvent, ConnectionExt, EventMask, PropMode,
};
use x11rb::wrapper::ConnectionExt as _;
let time = self.now()?;
// Says the activation traces to real user input, and at this instant.
// Without it the window manager weighs our request against whatever
// the user touched most recently and can decide we are stale.
self.conn.change_property32(
PropMode::REPLACE,
window,
self.user_time,
AtomEnum::CARDINAL,
&[time],
)?;
// data: source indication, timestamp, the window losing focus.
// Source 2 is EWMH's "direct user action".
let event = ClientMessageEvent::new(32, window, self.net_active, [2, time, 0, 0, 0]);
self.conn.send_event(
false,
self.root,
EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
event,
)?;
self.conn.flush()?;
Ok(())
}
}
/// Activate our window, EWMH style. `false` when this is not X11 or the
/// server would not take it, so the caller can fall back to winit.
#[cfg(all(unix, not(target_os = "macos")))]
fn x11_activate(frame: &eframe::Frame) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
// Both of these used to fail silently, which is how a shortcut that never
// raised the window looked like a shortcut that never fired.
let handle = match frame.window_handle() {
Ok(handle) => handle,
Err(e) => {
quicksearch_core::log_warn!("raising the window: no window handle: {}", e);
return false;
}
};
let window = match handle.as_raw() {
RawWindowHandle::Xlib(xlib) => xlib.window as u32,
other => {
quicksearch_core::log_warn!("raising the window: not an X11 window: {:?}", other);
return false;
}
};
let sent = X11.with(|slot| -> Result<(), Box<dyn std::error::Error>> {
let mut slot = slot.borrow_mut();
let state = match slot.as_mut() {
Some(state) => state,
None => slot.insert(X11State::connect()?),
};
state.activate(window)
});
match sent {
Ok(()) => true,
Err(e) => {
quicksearch_core::log_warn!("raising the window: {}", e);
// A connection that failed mid-way is not reused: the next press
// reconnects rather than inheriting a broken one.
X11.with(|slot| slot.borrow_mut().take());
false
}
}
}

View file

@ -235,13 +235,16 @@ impl QuickSearchApp {
if let Some(query) = initial_query { if let Some(query) = initial_query {
search.seed(query); search.seed(query);
} }
// Before `cfg` moves into the struct: the tab owns its filters from
// here on, and `pin_live_fields` keeps a Settings draft off them.
let dups = DuplicatesTab::new(&cfg.duplicates);
Ok(QuickSearchApp { Ok(QuickSearchApp {
cfg, cfg,
backend, backend,
tab, tab,
search, search,
manage: ManageTab::new(), manage: ManageTab::new(),
dups: DuplicatesTab::new(), dups,
logs: LogsTab::new(), logs: LogsTab::new(),
settings: SettingsTab::new(), settings: SettingsTab::new(),
rebuild_prompt: None, rebuild_prompt: None,
@ -289,7 +292,11 @@ impl QuickSearchApp {
fn start_duplicates_scan(&mut self, ctx: &egui::Context) { fn start_duplicates_scan(&mut self, ctx: &egui::Context) {
self.dups.state = DupState::Loading; self.dups.state = DupState::Loading;
let cfg = self.cfg.clone(); let cfg = self.cfg.clone();
self.backend.start_duplicates(&cfg, ctx.clone()); let limit = self.backend.start_duplicates(&cfg, ctx.clone());
// 0 means a scan was already running, whose limit still stands.
if limit > 0 {
self.dups.scan_limit = limit;
}
} }
/// Drop a duplicate listing the index has moved out from under — a finished /// Drop a duplicate listing the index has moved out from under — a finished
@ -597,13 +604,22 @@ pub(crate) fn pin_live_fields(new: &mut Config, live: &Config) {
// protection — Apply must not put the rows away again. // protection — Apply must not put the rows away again.
new.search.columns = live.search.columns.clone(); new.search.columns = live.search.columns.clone();
new.ui.show_advanced_settings = live.ui.show_advanced_settings; new.ui.show_advanced_settings = live.ui.show_advanced_settings;
// Likewise the duplicates tab's own filters: hiding a group is a live
// edit, and a Settings draft taken before it must not bring the group back.
new.duplicates = live.duplicates.clone();
} }
/// The zoom factors the GUI offers, and the range a hand-edited config is
/// clamped into. One range: the Settings slider and the tour's both show it.
pub(crate) const SCALE_RANGE: std::ops::RangeInclusive<f32> = 0.5..=2.5;
fn clamp_scale(scale: f32) -> f32 { fn clamp_scale(scale: f32) -> f32 {
if scale.is_finite() { if scale.is_finite() {
scale.clamp(0.5, 2.5) scale.clamp(*SCALE_RANGE.start(), *SCALE_RANGE.end())
} else { } else {
1.1 // Taken from the config rather than written out again: a hardcoded
// fallback drifts away from the default the moment it changes.
quicksearch_core::config::UiConfig::default().scale
} }
} }
@ -747,10 +763,19 @@ impl eframe::App for QuickSearchApp {
} }
} }
Tab::Duplicates => { Tab::Duplicates => {
let actions = self.dups.ui(ui, self.verify.is_some()); let actions =
self.dups
.ui(ui, self.verify.is_some(), self.cfg.processing.hash_length);
if actions.refresh { if actions.refresh {
self.start_duplicates_scan(ctx); self.start_duplicates_scan(ctx);
} }
// Live state, written the moment it changes — the same path
// the search tab's column picker takes. Nothing here changes
// what is indexed, so there is no reconciliation to run.
if let Some(filters) = actions.save_filters {
self.cfg.duplicates = filters;
self.save_cfg();
}
if let Some(paths) = actions.verify { if let Some(paths) = actions.verify {
let paths: Vec<std::path::PathBuf> = let paths: Vec<std::path::PathBuf> =
paths.into_iter().map(std::path::PathBuf::from).collect(); paths.into_iter().map(std::path::PathBuf::from).collect();

View file

@ -203,7 +203,8 @@ impl QuickSearchApp {
return; return;
}; };
let roots = self.cfg.paths.indexing_paths.clone(); let roots = self.cfg.paths.indexing_paths.clone();
let actions = tour.ui(ctx, &roots); let hotkey = self.cfg.ui.search_hotkey.clone();
let actions = tour.ui(ctx, &roots, &hotkey);
// Through the same path a keystroke takes: `seed` arms the debounce, // Through the same path a keystroke takes: `seed` arms the debounce,
// so the demonstration search runs once the typing stops. // so the demonstration search runs once the typing stops.
if let Some(query) = actions.set_query { if let Some(query) = actions.set_query {
@ -212,6 +213,23 @@ impl QuickSearchApp {
if actions.focus_search { if actions.focus_search {
self.search.request_focus(); self.search.request_focus();
} }
// Live, like the Settings slider on Apply — but saved only when the
// drag ends, so crossing the slider does not rewrite the config file
// on every frame.
if let Some(scale) = actions.set_scale {
self.cfg.ui.scale = scale;
ctx.set_zoom_factor(super::clamp_scale(scale));
if actions.save_scale {
self.save_cfg();
}
}
// Registered as it is captured, not on an Apply the tour has no
// button for — the page says it takes effect at once.
if let Some(hotkey) = actions.set_hotkey {
crate::hotkey::apply(&hotkey);
self.cfg.ui.search_hotkey = hotkey;
self.save_cfg();
}
// Through the guard, so a dirty draft still gets its say. // Through the guard, so a dirty draft still gets its say.
if let Some(tab) = actions.goto_tab { if let Some(tab) = actions.goto_tab {
self.request_tab(ctx, tab); self.request_tab(ctx, tab);

View file

@ -41,14 +41,19 @@ impl VerifyModal {
} }
} }
/// One line of the report, in the words the modal paints. /// One line of the report, in the words the modal paints. `reference_len` is
pub(crate) fn verdict_line(verdict: &MemberVerdict, reference: bool) -> String { /// how long the file everything was compared against was: an offset means
/// little on its own, and "byte 91 of 2.1 MB" is what says the difference is
/// in the header rather than in anything anyone typed.
pub(crate) fn verdict_line(verdict: &MemberVerdict, reference: bool, reference_len: u64) -> String {
match verdict { match verdict {
MemberVerdict::Identical if reference => "compared against".to_string(), MemberVerdict::Identical if reference => "compared against".to_string(),
MemberVerdict::Identical => "identical".to_string(), MemberVerdict::Identical => "identical".to_string(),
MemberVerdict::DiffersAt(offset) => { MemberVerdict::DiffersAt(offset) => format!(
format!("differs at byte {}", group_thousands(*offset)) "differs at byte {} of {}",
} group_thousands(*offset),
human_size(reference_len)
),
MemberVerdict::LengthDiffers { len, reference_len } => format!( MemberVerdict::LengthDiffers { len, reference_len } => format!(
"size differs: {} against {}", "size differs: {} against {}",
human_size(*len), human_size(*len),
@ -181,9 +186,20 @@ pub(crate) fn verify_modal(ctx: &egui::Context, modal: &VerifyModal) -> bool {
}; };
ui.colored_label(color, summary_line(report)); ui.colored_label(color, summary_line(report));
if !identical { if !identical {
ui.label(
"Grouping only reads each file's size and how it begins; \
this read every byte.",
);
// The question this answers is the one everybody asks next:
// the files look the same when opened, so how can they
// differ? Named formats, because those are the ones people
// hit — a run of invoices or a folder of .docx.
ui.label(hint( ui.label(hint(
"Files are grouped by size and how they begin, which is all \ "A difference near the start of a file is usually metadata you \
indexing reads. This compared every byte.", never see. PDFs and Office documents store creation and \
modification times, document IDs and revision numbers inside \
the file itself, so copies that look and print identically are \
still different files on disk.",
)); ));
} }
ui.add_space(6.0); ui.add_space(6.0);
@ -201,7 +217,8 @@ pub(crate) fn verify_modal(ctx: &egui::Context, modal: &VerifyModal) -> bool {
ui.label( ui.label(
egui::RichText::new(path.display().to_string()).monospace(), egui::RichText::new(path.display().to_string()).monospace(),
); );
let line = verdict_line(verdict, is_reference); let line =
verdict_line(verdict, is_reference, report.reference_len);
if verdict.is_identical() { if verdict.is_identical() {
ui.label(hint(line)); ui.label(hint(line));
} else { } else {

View file

@ -17,9 +17,14 @@ fn modal(state: VerifyState, n: usize) -> VerifyModal {
} }
} }
/// A reference a megabyte long, so the offsets in the verdicts below have
/// something to be reported "of".
const REFERENCE_LEN: u64 = 4_000_000;
fn report(verdicts: Vec<MemberVerdict>, bytes_read: u64) -> VerifyState { fn report(verdicts: Vec<MemberVerdict>, bytes_read: u64) -> VerifyState {
VerifyState::Done(Box::new(VerifyReport { VerifyState::Done(Box::new(VerifyReport {
reference: Some(0), reference: Some(0),
reference_len: REFERENCE_LEN,
verdicts, verdicts,
bytes_read, bytes_read,
})) }))
@ -111,12 +116,41 @@ fn a_mismatch_names_the_file_and_the_offset() {
"{painted:?}" "{painted:?}"
); );
assert!( assert!(
painted.contains(&"differs at byte 1,234,567".to_string()), painted.contains(&"differs at byte 1,234,567 of 4.0 MB".to_string()),
"{painted:?}" "{painted:?}"
); );
assert!(painted.contains(&"/d/copy1.bin".to_string()), "{painted:?}"); assert!(painted.contains(&"/d/copy1.bin".to_string()), "{painted:?}");
} }
/// The report someone reads before deleting something has to answer the
/// question it provokes: the files open identically, so how do they differ?
#[test]
fn a_mismatch_explains_a_difference_nobody_can_see() {
let ctx = crate::test_ui::ctx();
let m = modal(report(vec![Identical, DiffersAt(91)], 2), 2);
let painted = painted_text(&frame(&ctx, &m, Vec::new()).0).join(" ");
assert!(
painted.contains("this read every byte"),
"the report does not say what it did: {painted:?}"
);
assert!(
painted.contains("metadata") && painted.contains("revision"),
"nothing explains an invisible difference: {painted:?}"
);
}
/// …and does not raise the question when there is nothing to explain.
#[test]
fn a_clean_result_does_not_explain_a_difference_it_did_not_find() {
let ctx = crate::test_ui::ctx();
let m = modal(report(vec![Identical, Identical], 2), 2);
let painted = painted_text(&frame(&ctx, &m, Vec::new()).0).join(" ");
assert!(
!painted.contains("metadata"),
"an identical group was told about differences: {painted:?}"
);
}
#[test] #[test]
fn a_cancelled_run_says_so_rather_than_showing_a_verdict() { fn a_cancelled_run_says_so_rather_than_showing_a_verdict() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
@ -158,15 +192,18 @@ fn both_dismiss_buttons_report_the_dismissal() {
#[test] #[test]
fn every_verdict_reads_as_a_sentence_about_the_file() { fn every_verdict_reads_as_a_sentence_about_the_file() {
assert_eq!(verdict_line(&Identical, false), "identical"); let line = |v: &MemberVerdict, reference| verdict_line(v, reference, 2_000_000);
assert_eq!(verdict_line(&Identical, true), "compared against"); assert_eq!(line(&Identical, false), "identical");
assert_eq!(verdict_line(&DiffersAt(0), false), "differs at byte 0"); assert_eq!(line(&Identical, true), "compared against");
// Where in the file, not just how far in: byte 0 of 2 MB is the format's
// own header, which is what makes an invisible difference make sense.
assert_eq!(line(&DiffersAt(0), false), "differs at byte 0 of 2.0 MB");
assert_eq!( assert_eq!(
verdict_line(&DiffersAt(1_048_576), false), line(&DiffersAt(1_048_576), false),
"differs at byte 1,048,576" "differs at byte 1,048,576 of 2.0 MB"
); );
assert_eq!( assert_eq!(
verdict_line( line(
&LengthDiffers { &LengthDiffers {
len: 2048, len: 2048,
reference_len: 1024 reference_len: 1024
@ -175,7 +212,7 @@ fn every_verdict_reads_as_a_sentence_about_the_file() {
), ),
"size differs: 2.0 KB against 1.0 KB" "size differs: 2.0 KB against 1.0 KB"
); );
assert!(verdict_line(&CannotRead("/d/x: denied".into()), false) assert!(line(&CannotRead("/d/x: denied".into()), false)
.contains("could not be read — /d/x: denied")); .contains("could not be read — /d/x: denied"));
} }
@ -184,6 +221,7 @@ fn the_summary_counts_what_it_found() {
let of = |verdicts: Vec<MemberVerdict>, reference| { let of = |verdicts: Vec<MemberVerdict>, reference| {
summary_line(&VerifyReport { summary_line(&VerifyReport {
reference, reference,
reference_len: REFERENCE_LEN,
verdicts, verdicts,
bytes_read: 0, bytes_read: 0,
}) })

View file

@ -30,6 +30,22 @@ impl VerifyJob {
} }
} }
/// Duplicate groups a scan is asked for. Every group listed costs one row
/// fetch and one member query to hydrate, so this is a real cost, not a
/// display cap.
pub const DUP_SCAN_LIMIT: u32 = 500;
/// …and the most the hidden set may add to it.
const DUP_HIDDEN_ALLOWANCE: u32 = 4_500;
/// The limit for a scan whose result `hidden` groups will be dropped from.
/// Hidden groups are groups the scan returns and the tab discards, so a flat
/// limit would quietly charge the user for every group they dismissed — the
/// opposite of what hiding one is for. Bounded, so a config with a million
/// hidden hashes cannot turn one scan into a full hydration of the index.
fn dup_scan_limit(hidden: usize) -> u32 {
DUP_SCAN_LIMIT + (hidden.min(DUP_HIDDEN_ALLOWANCE as usize) as u32)
}
pub struct Backend { pub struct Backend {
pub coordinator: Arc<IndexCoordinator>, pub coordinator: Arc<IndexCoordinator>,
pub search: Option<SearchService>, pub search: Option<SearchService>,
@ -132,20 +148,24 @@ impl Backend {
} }
/// Ignored while a scan is already running: a second one would re-read the /// Ignored while a scan is already running: a second one would re-read the
/// whole hash index for an answer the first is about to produce. /// whole hash index for an answer the first is about to produce. Returns
pub fn start_duplicates(&mut self, config: &Config, ctx: egui::Context) { /// the limit it asked for, which is what tells the tab a full page from a
/// complete one; 0 when nothing was started.
pub fn start_duplicates(&mut self, config: &Config, ctx: egui::Context) -> u32 {
if self.dup_job.is_some() { if self.dup_job.is_some() {
return; return 0;
} }
let limit = dup_scan_limit(config.duplicates.hidden_groups.len());
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
let db = config.resolved_database_path(); let db = config.resolved_database_path();
std::thread::spawn(move || { std::thread::spawn(move || {
let result = let result =
quicksearch_core::search::find_duplicate_groups(&db.to_string_lossy(), 500); quicksearch_core::search::find_duplicate_groups(&db.to_string_lossy(), limit);
let _ = tx.send(result); let _ = tx.send(result);
ctx.request_repaint(); ctx.request_repaint();
}); });
self.dup_job = Some(rx); self.dup_job = Some(rx);
limit
} }
/// Compare every member against the first, byte for byte, on a worker /// Compare every member against the first, byte for byte, on a worker

View file

@ -24,10 +24,15 @@ QuickSearch: indexed file search
USAGE: USAGE:
quicksearch open the GUI quicksearch open the GUI
quicksearch --toggle focus the running GUI, or start it
quicksearch [FLAGS] <query terms> search from the terminal quicksearch [FLAGS] <query terms> search from the terminal
(Windows: quicksearch-cli) (Windows: quicksearch-cli)
FLAGS: FLAGS:
--toggle bring the running GUI forward with the search box
focused, starting it if none is running. Bind a key to
this in your desktop's keyboard settings for a
system-wide search shortcut.
--fuzzy also run the fuzzy filename/full-text passes --fuzzy also run the fuzzy filename/full-text passes
--limit <N> maximum results (default: [search].display_limit) --limit <N> maximum results (default: [search].display_limit)
--long rank, size, mtime, and snippets instead of bare paths --long rank, size, mtime, and snippets instead of bare paths

View file

@ -293,6 +293,28 @@ pub fn palette(dark_mode: bool) -> Palette {
} }
} }
// --- Plain text ---
/// egui's stock text greys read thin on its own panel fills: 5.1:1 in dark,
/// 7.6:1 in light. Both themes get a step, applied to *both* styles at once —
/// styling only the live theme reverts to egui's the moment the color scheme
/// is switched.
pub fn apply_text_contrast(ctx: &egui::Context) {
ctx.all_styles_mut(|style| {
// Body text is the noninteractive stroke; button and widget labels
// are the inactive one. Hovered, active and open already sit at
// gray 240 / white / black, with nothing left to gain.
let (body, widget) = if style.visuals.dark_mode {
(Color32::from_gray(152), Color32::from_gray(188))
} else {
(Color32::from_gray(70), Color32::from_gray(52))
};
let widgets = &mut style.visuals.widgets;
widgets.noninteractive.fg_stroke.color = body;
widgets.inactive.fg_stroke.color = widget;
});
}
// --- The rank ramp --- // --- The rank ramp ---
const RANK_HUE_BEST: f64 = 250.0; const RANK_HUE_BEST: f64 = 250.0;

View file

@ -222,6 +222,119 @@ fn every_color_clears_wcag_aa_on_its_own_background() {
} }
} }
/// One call has to reach both themes: the live one alone is thrown away the
/// moment the color scheme is switched.
#[test]
fn the_text_greys_land_on_both_themes() {
let ctx = egui::Context::default();
apply_text_contrast(&ctx);
for (theme, stock) in [
(egui::Theme::Dark, egui::Visuals::dark()),
(egui::Theme::Light, egui::Visuals::light()),
] {
let visuals = &ctx.style_of(theme).visuals;
assert_ne!(
visuals.text_color(),
stock.text_color(),
"body text in {:?} is still egui's",
theme
);
assert_ne!(
visuals.widgets.inactive.text_color(),
stock.widgets.inactive.text_color(),
"widget text in {:?} is still egui's",
theme
);
}
}
/// Which way each theme moved. Written against egui's own defaults so that an
/// upgrade quietly moving the baseline past us fails here instead of shipping.
#[test]
fn dark_text_lightens_and_light_text_darkens() {
let ctx = egui::Context::default();
apply_text_contrast(&ctx);
let dark = &ctx.style_of(egui::Theme::Dark).visuals;
let light = &ctx.style_of(egui::Theme::Light).visuals;
for (name, ours, stock) in [
(
"dark body",
dark.text_color(),
egui::Visuals::dark().text_color(),
),
(
"dark widget",
dark.widgets.inactive.text_color(),
egui::Visuals::dark().widgets.inactive.text_color(),
),
] {
assert!(
luminance(ours) > luminance(stock),
"{} is not lighter than egui's: {:?} vs {:?}",
name,
ours,
stock
);
}
for (name, ours, stock) in [
(
"light body",
light.text_color(),
egui::Visuals::light().text_color(),
),
(
"light widget",
light.widgets.inactive.text_color(),
egui::Visuals::light().widgets.inactive.text_color(),
),
] {
assert!(
luminance(ours) < luminance(stock),
"{} is not darker than egui's: {:?} vs {:?}",
name,
ours,
stock
);
}
}
/// The floors the greys were picked to clear, each on the fills it is
/// actually painted over.
#[test]
fn plain_text_clears_its_backgrounds() {
let ctx = egui::Context::default();
apply_text_contrast(&ctx);
for (theme, floor) in [(egui::Theme::Dark, 5.5), (egui::Theme::Light, 8.5)] {
let visuals = &ctx.style_of(theme).visuals;
// Widget text is painted on the button fill, not on the panel.
for (name, color, bgs) in [
(
"body",
visuals.text_color(),
[visuals.panel_fill, visuals.extreme_bg_color],
),
(
"widget",
visuals.widgets.inactive.text_color(),
// A frameless button (the tab strip) keeps the panel behind it.
[visuals.widgets.inactive.weak_bg_fill, visuals.panel_fill],
),
] {
for bg in bgs {
let ratio = contrast(color, bg);
assert!(
ratio >= floor,
"{} text in {:?} is {:.2}:1 on {:?}",
name,
theme,
ratio,
bg
);
}
}
}
}
#[test] #[test]
fn the_rank_ramp_is_an_even_sweep_from_blue_to_red() { fn the_rank_ramp_is_an_even_sweep_from_blue_to_red() {
let mut prev: Option<f64> = None; let mut prev: Option<f64> = None;

View file

@ -1,10 +1,23 @@
//! The Duplicates tab: groups of files sharing a content hash. //! The Duplicates tab: groups of files sharing a content hash.
//!
//! Grouping is a *suspicion*, not a verdict — the hash covers each file's size
//! and its first `processing.hash_length` bytes and nothing else. The tab says
//! so where someone about to delete something will read it, and offers the
//! byte-for-byte verification that settles it.
//!
//! Two ways to stop seeing a group again: exclude paths by glob (a folder that
//! is *meant* to hold copies), or hide one group by its hash (a head-hash
//! false positive). Both are persisted, and both are applied to the listing
//! already in hand rather than by going back to the database.
use std::collections::HashSet;
use quicksearch_core::config::{DuplicatesConfig, IgnoreSet};
use quicksearch_core::search::DuplicateGroup; use quicksearch_core::search::DuplicateGroup;
use crate::format::{group_thousands, human_size}; use crate::format::{group_thousands, human_size};
use crate::platform; use crate::platform;
use crate::ui_util::hint; use crate::ui_util::{hint, stable_section};
pub enum DupState { pub enum DupState {
NotLoaded, NotLoaded,
@ -33,127 +46,302 @@ impl DupSort {
} }
} }
/// A group's extension, lowercased: the one from the member the title names, /// A name's extension, lowercased; empty when it has none.
/// since copies of one file can be filed under different names. Empty for a fn extension_of(name: &str) -> String {
/// group whose representative has no extension at all. std::path::Path::new(name)
fn group_extension(group: &DuplicateGroup) -> String { .extension()
group
.members
.first()
.map(|m| m.1.as_str())
.and_then(|name| std::path::Path::new(name).extension())
.map(|ext| ext.to_string_lossy().to_lowercase()) .map(|ext| ext.to_string_lossy().to_lowercase())
.unwrap_or_default() .unwrap_or_default()
} }
/// What [`LoadedGroups::sort`] orders an extension listing by, ending in the /// What [`LoadedGroups::rebuild`] orders an extension listing by: no extension
/// group's own index: no extension last, then the extension, then the /// last, then the extension, then the biggest waste, then the hash to settle
/// biggest waste, then the hash to settle whatever is left. /// whatever is left.
type ExtensionKey<'a> = (bool, String, std::cmp::Reverse<i64>, &'a [u8], usize); type ExtensionKey<'a> = (bool, &'a str, std::cmp::Reverse<i64>, &'a [u8]);
/// The scan's result, with each group's header line already built. Measured: fn extension_key<'a>(row: &'a Row, groups: &'a [DuplicateGroup]) -> ExtensionKey<'a> {
/// building the titles in the render loop cost ~2,000 allocations a frame, on (
/// a list that repaints at 20 Hz for as long as the tab is open. // Extensionless groups last: a category of their own, and not an
// interesting one.
row.extension.is_empty(),
row.extension.as_str(),
std::cmp::Reverse(row.redundant),
groups[row.group].hash.as_slice(),
)
}
// --- Filters ---------------------------------------------------------------
/// The tab's copy of `[duplicates]`, with the matcher it compiles to. The tab
/// owns this outright once the app has started: `app::pin_live_fields` keeps a
/// Settings draft from writing an older copy back over it.
pub struct DupFilters {
config: DuplicatesConfig,
exclude: IgnoreSet,
/// Why `exclude` is empty when it should not be. Only a hand-edited config
/// can get here — the editor refuses an invalid glob — and it is painted
/// rather than swallowed: silently dropping the pattern would list files
/// the user asked never to see again.
error: Option<String>,
hidden: HashSet<String>,
/// Bumped by every edit; [`LoadedGroups`] rebuilds when it moves.
revision: u64,
}
impl DupFilters {
pub fn new(config: &DuplicatesConfig) -> DupFilters {
let mut filters = DupFilters {
config: config.clone(),
exclude: IgnoreSet::compile(&[]).expect("an empty pattern set compiles"),
error: None,
hidden: HashSet::new(),
revision: 0,
};
filters.recompile();
filters
}
fn recompile(&mut self) {
self.revision += 1;
self.hidden = self
.config
.hidden_groups
.iter()
.map(|h| h.trim().to_ascii_lowercase())
.collect();
match IgnoreSet::compile(&self.config.exclude_patterns) {
Ok(set) => {
self.exclude = set;
self.error = None;
}
Err(e) => {
self.exclude = IgnoreSet::compile(&[]).expect("an empty pattern set compiles");
self.error = Some(e);
}
}
}
fn add_pattern(&mut self, pattern: &str) {
let pattern = pattern.trim().to_string();
if pattern.is_empty() || self.config.exclude_patterns.contains(&pattern) {
return;
}
self.config.exclude_patterns.push(pattern);
self.recompile();
}
fn remove_pattern(&mut self, i: usize) {
if i < self.config.exclude_patterns.len() {
self.config.exclude_patterns.remove(i);
self.recompile();
}
}
fn hide(&mut self, hash_hex: String) {
if self.hidden.contains(&hash_hex) {
return;
}
self.config.hidden_groups.push(hash_hex);
self.recompile();
}
fn unhide(&mut self, hash_hex: &str) {
self.config
.hidden_groups
.retain(|h| !h.trim().eq_ignore_ascii_case(hash_hex));
self.recompile();
}
fn clear_hidden(&mut self) {
self.config.hidden_groups.clear();
self.recompile();
}
fn excluded(&self, path: &str) -> bool {
!self.exclude.is_empty() && self.exclude.matches_path(std::path::Path::new(path))
}
fn is_hidden(&self, group: &DuplicateGroup) -> bool {
!self.hidden.is_empty() && self.hidden.contains(&group.hash_hex())
}
}
// --- The loaded listing ----------------------------------------------------
/// One group as listed: which of its members survived the exclusions, and the
/// totals recomputed from them.
struct Row {
group: usize,
/// Indices into the group's members, path-ordered.
members: Vec<usize>,
hidden: bool,
title: String,
redundant: i64,
extension: String,
}
/// The group's totals from the members that survived, as the scan prices them:
/// every member of a hash group shares a size — the hash covers it (see
/// [`quicksearch_core::search::find_duplicate_groups`]) — so the first one
/// speaks for all of them. An unfiltered group therefore reads exactly as the
/// scan ranked it, and saturates on absurd sizes the same way.
fn totals(group: &DuplicateGroup, members: &[usize]) -> (i64, i64, i64) {
let count = members.len() as i64;
let size = members
.first()
.map(|&j| group.members[j].3.min(i64::MAX as u64) as i64)
.unwrap_or(0);
(
count,
size.saturating_mul(count),
size.saturating_mul(count - 1),
)
}
/// The scan's result, with each visible group's header line already built.
/// Measured: building the titles in the render loop cost ~2,000 allocations a
/// frame, on a list that repaints at 20 Hz for as long as the tab is open.
pub struct LoadedGroups { pub struct LoadedGroups {
pub groups: Vec<DuplicateGroup>, groups: Vec<DuplicateGroup>,
titles: Vec<String>, /// The visible groups, in display order.
/// Indices into `groups`, in display order. Reordering this leaves rows: Vec<Row>,
/// `groups` and `titles` parallel, which the render loop relies on. /// Groups the filters kept off screen, for the line that says so.
order: Vec<usize>, filtered_out: usize,
sorted_by: DupSort, /// The `(sort, filter revision, show hidden)` `rows` was built for.
built_for: Option<(DupSort, u64, bool)>,
} }
impl LoadedGroups { impl LoadedGroups {
pub fn new(groups: Vec<DuplicateGroup>) -> LoadedGroups { pub fn new(groups: Vec<DuplicateGroup>) -> LoadedGroups {
let titles = groups
.iter()
.map(|group| {
let name = group
.members
.first()
.map(|m| m.1.as_str())
.unwrap_or("(unknown)");
format!(
"{} × {}: {} reclaimable ({} total)",
group_thousands(group.count as u64),
name,
human_size(group.redundant_size.max(0) as u64),
human_size(group.total_size.max(0) as u64),
)
})
.collect();
let order = (0..groups.len()).collect();
LoadedGroups { LoadedGroups {
groups, groups,
titles, rows: Vec::new(),
order, filtered_out: 0,
sorted_by: DupSort::Reclaimable, built_for: None,
} }
} }
/// Reorder to `key`. A no-op when it is already the order in force, so /// Rebuild the visible rows. A no-op when nothing that decides them has
/// the render loop can call it unconditionally. /// moved, so the render loop can call it unconditionally.
fn sort(&mut self, key: DupSort) { fn rebuild(&mut self, sort: DupSort, filters: &DupFilters, show_hidden: bool) {
if self.sorted_by == key { let key = (sort, filters.revision, show_hidden);
if self.built_for == Some(key) {
return; return;
} }
self.sorted_by = key; self.built_for = Some(key);
match key { self.rows.clear();
// What the query already returned, so the indices go back as they came. self.filtered_out = 0;
DupSort::Reclaimable => self.order.sort_unstable(),
// Extensionless groups last, biggest waste first within an for (i, group) in self.groups.iter().enumerate() {
// extension, hash to break the remaining ties for good. let hidden = filters.is_hidden(group);
DupSort::Extension => { if hidden && !show_hidden {
let mut keyed: Vec<ExtensionKey<'_>> = self self.filtered_out += 1;
.order continue;
}
let members: Vec<usize> = group
.members
.iter() .iter()
.map(|&i| { .enumerate()
let group = &self.groups[i]; .filter(|(_, m)| !filters.excluded(&m.2))
let ext = group_extension(group); .map(|(j, _)| j)
(
ext.is_empty(),
ext,
std::cmp::Reverse(group.redundant_size),
group.hash.as_slice(),
i,
)
})
.collect(); .collect();
keyed.sort_unstable(); // One surviving copy is not a duplicate of anything.
self.order = keyed.into_iter().map(|k| k.4).collect(); if members.len() < 2 {
} self.filtered_out += 1;
continue;
}
let (count, total, redundant) = totals(group, &members);
// Copies of one file can be filed under different names, so the
// group is named — and filed under the extension of — the member
// its title names.
let name = group.members[members[0]].1.as_str();
let title = format!(
"{}{} × {}: {} reclaimable ({} total)",
if hidden { "Hidden — " } else { "" },
group_thousands(count as u64),
name,
human_size(redundant.max(0) as u64),
human_size(total.max(0) as u64),
);
self.rows.push(Row {
group: i,
extension: extension_of(name),
members,
hidden,
title,
redundant,
});
}
// `rows` came out in the order the scan returned, which is already the
// reclaimable one; only the extension listing has work to do.
if sort == DupSort::Extension {
let groups = &self.groups;
self.rows
.sort_by(|a, b| extension_key(a, groups).cmp(&extension_key(b, groups)));
} }
} }
} }
// --- The tab ---------------------------------------------------------------
pub struct DuplicatesTab { pub struct DuplicatesTab {
pub state: DupState, pub state: DupState,
pub sort: DupSort, pub sort: DupSort,
pub filters: DupFilters,
/// List the groups the filters hid, so hiding one is reversible.
show_hidden: bool,
/// What the last scan was asked for; a result of exactly this many groups
/// is a truncated one. See [`crate::backend::Backend::start_duplicates`].
pub scan_limit: u32,
/// The exclusion being typed.
draft: String,
} }
/// What the tab asks the app to do after this frame. /// What the tab asks the app to do after this frame.
#[derive(Default)] #[derive(Default)]
pub struct DuplicatesActions { pub struct DuplicatesActions {
pub refresh: bool, pub refresh: bool,
/// Every member of one group, whichever row it was asked for from. /// Every surviving member of one group, whichever row it was asked for
/// from — excluded members are not what anyone is looking at.
pub verify: Option<Vec<String>>, pub verify: Option<Vec<String>>,
/// The exclusions or the hidden groups changed; write them to the config.
pub save_filters: Option<DuplicatesConfig>,
} }
const VERIFY_LABEL: &str = "Verify copies are identical…"; const VERIFY_LABEL: &str = "Verify copies are identical…";
const VERIFY_TIP: &str = "Reads every file in the group in full and compares them byte for \ const VERIFY_TIP: &str = "Reads every file in the group in full and compares them byte for \
byte. Grouping only reads each file's size and how it begins."; byte. Grouping only reads each file's size and how it begins.";
const HIDE_LABEL: &str = "Hide this group";
const UNHIDE_LABEL: &str = "Unhide this group";
const NO_GROUPS: &str = "No duplicate files found.";
const ALL_FILTERED: &str = "Every duplicate group is hidden by your filters.";
/// Prose wraps to this, rather than to a maximised window's full width.
const PROSE_WIDTH: f32 = 720.0;
impl DuplicatesTab { impl DuplicatesTab {
pub fn new() -> DuplicatesTab { pub fn new(filters: &DuplicatesConfig) -> DuplicatesTab {
DuplicatesTab { DuplicatesTab {
state: DupState::NotLoaded, state: DupState::NotLoaded,
sort: DupSort::default(), sort: DupSort::default(),
filters: DupFilters::new(filters),
show_hidden: false,
scan_limit: 0,
draft: String::new(),
} }
} }
/// `verify_open` greys the entry out: there is only one verify window. /// `verify_open` greys the verify entry out: there is only one verify
pub fn ui(&mut self, ui: &mut egui::Ui, verify_open: bool) -> DuplicatesActions { /// window. `hash_length` is what the banner quotes as the amount of each
/// file the grouping actually read.
pub fn ui(
&mut self,
ui: &mut egui::Ui,
verify_open: bool,
hash_length: usize,
) -> DuplicatesActions {
let mut actions = DuplicatesActions::default(); let mut actions = DuplicatesActions::default();
let mut edited = false;
ui.horizontal(|ui| { ui.horizontal(|ui| {
let loading = matches!(self.state, DupState::Loading); let loading = matches!(self.state, DupState::Loading);
@ -184,14 +372,32 @@ impl DuplicatesTab {
ui.label("Scanning for duplicates…"); ui.label("Scanning for duplicates…");
} }
}); });
// Everything from here to the exclusion editor appears and disappears
// with the state, and egui names a widget by how many precede it in
// the same `Ui` — an unstable count would rename the editor below and
// drop its focus mid-word. One id each, whatever they hold.
let listing = matches!(&self.state, DupState::Loaded(l) if !l.groups.is_empty());
stable_section(ui, |ui| {
if listing {
caution_banner(ui, hash_length);
}
});
edited |= self.filter_ui(ui);
ui.separator(); ui.separator();
// Applied here rather than at the click, so a Refresh landing under a // Applied here rather than at the click, so a Refresh landing under a
// non-default choice comes out in that order too. // non-default choice comes out in that order too.
if let DupState::Loaded(loaded) = &mut self.state { if let DupState::Loaded(loaded) = &mut self.state {
loaded.sort(self.sort); loaded.rebuild(self.sort, &self.filters, self.show_hidden);
} }
// The menus mutate the filters, which the listing below is borrowed
// from; they report what they were asked for and it is applied after.
let mut hide: Option<String> = None;
let mut unhide: Option<String> = None;
let mut exclude: Option<String> = None;
match &self.state { match &self.state {
// `NotLoaded` survives at most the one frame before the app starts // `NotLoaded` survives at most the one frame before the app starts
// the scan (`switch_tab`), and `Loading` has its spinner and label // the scan (`switch_tab`), and `Loading` has its spinner and label
@ -200,31 +406,50 @@ impl DuplicatesTab {
DupState::Error(e) => { DupState::Error(e) => {
ui.colored_label(ui.visuals().error_fg_color, e); ui.colored_label(ui.visuals().error_fg_color, e);
} }
DupState::Loaded(loaded) => { // Nothing here may return early: the filter edits collected above
let groups = &loaded.groups; // are applied at the bottom of this function, and skipping that is
if groups.is_empty() { // how an exclusion that empties the list loses itself.
ui.label("No duplicate files found."); DupState::Loaded(loaded) if loaded.groups.is_empty() => {
return actions; ui.label(NO_GROUPS);
} }
if groups.len() == 500 { DupState::Loaded(loaded) if loaded.rows.is_empty() => {
ui.label(ALL_FILTERED);
}
DupState::Loaded(loaded) => {
// "Limited to", not "showing": the scan asks for extra to
// cover the hidden set, so the number it found is not always
// the number on screen. The filtered-out line below accounts
// for the difference.
if loaded.groups.len() as u32 >= self.scan_limit && self.scan_limit > 0 {
ui.label(hint(match self.sort { ui.label(hint(match self.sort {
DupSort::Reclaimable => "Showing the 500 largest groups.", DupSort::Reclaimable => {
format!("Limited to the {} largest groups.", self.scan_limit)
}
// Said plainly: this is not every .raw file you own, // Said plainly: this is not every .raw file you own,
// it is the 500 biggest groups put in that order. // it is the biggest groups put in that order.
DupSort::Extension => "Showing the 500 largest groups, by extension.", DupSort::Extension => format!(
"Limited to the {} largest groups, then ordered by extension.",
self.scan_limit
),
})); }));
} }
if loaded.filtered_out > 0 {
ui.label(hint(format!(
"{} more hidden by your filters.",
group_thousands(loaded.filtered_out as u64)
)));
}
let scroll = egui::ScrollArea::vertical() let scroll = egui::ScrollArea::vertical()
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
for &i in &loaded.order { for row in &loaded.rows {
let group = &groups[i]; let group = &loaded.groups[row.group];
let title = loaded.titles[i].as_str(); let header = egui::CollapsingHeader::new(&row.title)
let header = .id_salt(row.group)
egui::CollapsingHeader::new(title)
.id_salt(i)
.show(ui, |ui| { .show(ui, |ui| {
for (_, _, path, size, _) in &group.members { for &j in &row.members {
let (_, _, path, size, _) = &group.members[j];
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label(human_size(*size)); ui.label(human_size(*size));
let response = ui.add( let response = ui.add(
@ -241,14 +466,25 @@ impl DuplicatesTab {
platform::open_file(path); platform::open_file(path);
ui.close(); ui.close();
} }
if ui.button("Open containing folder").clicked() if ui.button("Open containing folder").clicked() {
{
platform::reveal_in_folder(path); platform::reveal_in_folder(path);
ui.close(); ui.close();
} }
ui.separator(); ui.separator();
if verify_entry(ui, verify_open) { if verify_entry(ui, verify_open) {
actions.verify = Some(member_paths(group)); actions.verify = Some(member_paths(group, row));
}
ui.separator();
if hide_entry(ui, row.hidden) {
let hex = group.hash_hex();
if row.hidden {
unhide = Some(hex);
} else {
hide = Some(hex);
}
}
if let Some(p) = exclude_entries(ui, path) {
exclude = Some(p);
} }
}); });
}); });
@ -258,7 +494,16 @@ impl DuplicatesTab {
// behind a collapsed header until they are not. // behind a collapsed header until they are not.
header.header_response.context_menu(|ui| { header.header_response.context_menu(|ui| {
if verify_entry(ui, verify_open) { if verify_entry(ui, verify_open) {
actions.verify = Some(member_paths(group)); actions.verify = Some(member_paths(group, row));
}
ui.separator();
if hide_entry(ui, row.hidden) {
let hex = group.hash_hex();
if row.hidden {
unhide = Some(hex);
} else {
hide = Some(hex);
}
} }
}); });
} }
@ -266,8 +511,138 @@ impl DuplicatesTab {
crate::ui_util::more_below_hint(ui, &scroll); crate::ui_util::more_below_hint(ui, &scroll);
} }
} }
if let Some(hex) = hide {
self.filters.hide(hex);
edited = true;
}
if let Some(hex) = unhide {
self.filters.unhide(&hex);
edited = true;
}
if let Some(pattern) = exclude {
self.filters.add_pattern(&pattern);
edited = true;
}
if edited {
actions.save_filters = Some(self.filters.config.clone());
}
actions actions
} }
/// The exclusion editor, its chips, and the hidden-group controls.
/// `true` when something here changed the filters.
fn filter_ui(&mut self, ui: &mut egui::Ui) -> bool {
use crate::ui_util::{pattern_edit, pattern_hint_label};
let mut edited = false;
let mut add: Option<String> = None;
let mut remove: Option<usize> = None;
ui.horizontal_wrapped(|ui| {
ui.label("Exclude:")
.on_hover_text("Files whose path matches are left out of the listing entirely.");
let (response, valid) = pattern_edit(ui, &mut self.draft, 200.0, "name, path or glob");
let entered =
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && valid;
if ui
.add_enabled(valid, egui::Button::new("Add"))
.on_disabled_hover_text(if self.draft.trim().is_empty() {
"Type a name, path or glob first."
} else {
"That is not a valid pattern."
})
.clicked()
|| entered
{
add = Some(std::mem::take(&mut self.draft));
}
for (i, pattern) in self.filters.config.exclude_patterns.iter().enumerate() {
if ui
.small_button(format!("{} ×", pattern))
.on_hover_text("Stop excluding this")
.clicked()
{
remove = Some(i);
}
}
});
pattern_hint_label(ui, &self.draft);
let error = self.filters.error.clone();
stable_section(ui, |ui| {
if let Some(e) = &error {
ui.colored_label(
ui.visuals().error_fg_color,
format!("Exclusions are not being applied: {e}"),
);
}
});
let hidden = self.filters.config.hidden_groups.len();
let show_hidden = &mut self.show_hidden;
let filters = &mut self.filters;
stable_section(ui, |ui| {
if hidden == 0 {
return;
}
ui.horizontal(|ui| {
ui.label(hint(format!(
"Hidden: {} group{}",
group_thousands(hidden as u64),
if hidden == 1 { "" } else { "s" }
)));
ui.checkbox(show_hidden, "Show hidden");
if ui
.small_button("Clear")
.on_hover_text("List every hidden group again")
.clicked()
{
filters.clear_hidden();
edited = true;
}
});
});
if let Some(pattern) = add {
self.filters.add_pattern(&pattern);
edited = true;
}
if let Some(i) = remove {
self.filters.remove_pattern(i);
edited = true;
}
edited
}
}
/// The tab's standing caution, painted above the list rather than left to a
/// right-click nobody has performed yet: the group is a suspicion, and the way
/// to settle it is right here.
fn caution_banner(ui: &mut egui::Ui, hash_length: usize) {
let p = crate::color::palette(ui.visuals().dark_mode);
egui::Frame::new()
.stroke(egui::Stroke::new(1.0, p.orange))
.corner_radius(4)
.inner_margin(egui::Margin::symmetric(8, 5))
.show(ui, |ui| {
// Only ever a cap, never a floor: `set_max_width` widens a `Ui` as
// readily as it narrows one, and a column set wider than the panel
// lays its text out past the clip rect, where the ends of the
// lines are simply cut off.
ui.set_max_width(ui.available_width().min(PROSE_WIDTH));
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.label(egui::RichText::new("These are suspected duplicates. ").color(p.orange));
ui.label(format!(
"Files are grouped on their size and their first {}, which is all \
indexing reads, and why it can be so fast. This is not proof that they are identical! \
Right-click any group and choose \"{}\" to compare every byte before \
you delete anything.",
human_size(hash_length as u64),
VERIFY_LABEL.trim_end_matches('…'),
));
});
});
} }
/// Whether the shared entry was clicked; closes the menu when it was. /// Whether the shared entry was clicked; closes the menu when it was.
@ -283,8 +658,63 @@ fn verify_entry(ui: &mut egui::Ui, open: bool) -> bool {
clicked clicked
} }
fn member_paths(group: &DuplicateGroup) -> Vec<String> { /// The hide/unhide entry, in whichever direction this group is in. `true` when
group.members.iter().map(|m| m.2.clone()).collect() /// it was clicked; which way round is the caller's to read from `hidden`.
fn hide_entry(ui: &mut egui::Ui, hidden: bool) -> bool {
let (label, tip) = if hidden {
(UNHIDE_LABEL, "List this group again.")
} else {
(
HIDE_LABEL,
"Keep this group out of the listing from now on, including after a \
restart. Nothing is deleted, and \"Show hidden\" brings it back.",
)
};
let clicked = ui.button(label).on_hover_text(tip).clicked();
if clicked {
ui.close();
}
clicked
}
/// The two exclusions a member row can offer, and the pattern the one that was
/// clicked stands for.
fn exclude_entries(ui: &mut egui::Ui, path: &str) -> Option<String> {
let path = std::path::Path::new(path);
let mut chosen: Option<String> = None;
if let Some(ext) = path.extension() {
let pattern = format!("*.{}", ext.to_string_lossy());
if ui
.button(format!("Exclude {pattern} from duplicates"))
.on_hover_text("Files of this type are left out of the listing; they stay indexed.")
.clicked()
{
chosen = Some(pattern);
}
}
if let Some(dir) = path.parent() {
let pattern = crate::ui_util::dir_ignore_pattern(dir);
if ui
.button("Exclude this folder from duplicates")
.on_hover_text(&pattern)
.clicked()
{
chosen = Some(pattern);
}
}
if chosen.is_some() {
ui.close();
}
chosen
}
/// The members of `group` that survived the exclusions — verifying files
/// nobody is being shown would answer a question nobody asked.
fn member_paths(group: &DuplicateGroup, row: &Row) -> Vec<String> {
row.members
.iter()
.map(|&j| group.members[j].2.clone())
.collect()
} }
#[cfg(test)] #[cfg(test)]

View file

@ -3,6 +3,8 @@ use super::*;
use crate::test_ui::{painted_text, painted_text_center, raw_input}; use crate::test_ui::{painted_text, painted_text_center, raw_input};
const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0); const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0);
/// What `app.rs` passes; the banner quotes it back.
const HASH_LENGTH: usize = 8192;
fn group(paths: &[&str]) -> DuplicateGroup { fn group(paths: &[&str]) -> DuplicateGroup {
DuplicateGroup { DuplicateGroup {
@ -21,11 +23,15 @@ fn group(paths: &[&str]) -> DuplicateGroup {
} }
} }
fn loaded(paths: &[&str]) -> DuplicatesTab { fn tab(groups: Vec<DuplicateGroup>, filters: DuplicatesConfig) -> DuplicatesTab {
DuplicatesTab { let mut tab = DuplicatesTab::new(&filters);
state: DupState::Loaded(LoadedGroups::new(vec![group(paths)])), tab.scan_limit = crate::backend::DUP_SCAN_LIMIT;
sort: DupSort::default(), tab.state = DupState::Loaded(LoadedGroups::new(groups));
tab
} }
fn loaded(paths: &[&str]) -> DuplicatesTab {
tab(vec![group(paths)], DuplicatesConfig::default())
} }
fn frame( fn frame(
@ -37,7 +43,7 @@ fn frame(
let mut actions = DuplicatesActions::default(); let mut actions = DuplicatesActions::default();
let out = ctx.run(raw_input(SCREEN, events), |ctx| { let out = ctx.run(raw_input(SCREEN, events), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
actions = tab.ui(ui, busy); actions = tab.ui(ui, busy, HASH_LENGTH);
}); });
}); });
crate::test_ui::assert_no_tofu(ctx, &out); crate::test_ui::assert_no_tofu(ctx, &out);
@ -81,17 +87,39 @@ fn context_menu_on(
(painted_text(&out), target) (painted_text(&out), target)
} }
/// Click the menu entry labelled `label`, which the caller has already opened.
fn click_menu_entry(
ctx: &egui::Context,
tab: &mut DuplicatesTab,
label: &str,
) -> DuplicatesActions {
let (out, _) = frame(ctx, tab, false, Vec::new());
let entry =
painted_text_center(&out, label).unwrap_or_else(|| panic!("no {label:?} entry painted"));
frame(ctx, tab, false, click(entry, egui::PointerButton::Primary)).1
}
/// The title line carries the group; find it without rebuilding its wording. /// The title line carries the group; find it without rebuilding its wording.
fn header_of(ctx: &egui::Context, tab: &mut DuplicatesTab) -> String { fn header_of(ctx: &egui::Context, tab: &mut DuplicatesTab) -> String {
let (out, _) = frame(ctx, tab, false, Vec::new()); let (out, _) = frame(ctx, tab, false, Vec::new());
painted_text(&out) headers(&out)
.into_iter() .into_iter()
.find(|t| t.contains("reclaimable")) .next()
.expect("no group header painted") .expect("no group header painted")
} }
/// Every group header on screen, in listed order.
fn headers(out: &egui::FullOutput) -> Vec<String> {
painted_text(out)
.into_iter()
.filter(|t| t.contains("reclaimable"))
.collect()
}
const PATHS: [&str; 3] = ["/a/img.raw", "/b/img.raw", "/c/img.raw"]; const PATHS: [&str; 3] = ["/a/img.raw", "/b/img.raw", "/c/img.raw"];
// --- The verification, and how it is found ---------------------------------
#[test] #[test]
fn a_group_header_offers_the_verification() { fn a_group_header_offers_the_verification() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
@ -111,15 +139,7 @@ fn verifying_asks_for_every_member_of_the_group() {
let mut tab = loaded(&PATHS); let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab); let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header); context_menu_on(&ctx, &mut tab, false, &header);
let actions = click_menu_entry(&ctx, &mut tab, VERIFY_LABEL);
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let entry = painted_text_center(&out, VERIFY_LABEL).expect("no verify entry painted");
let (_, actions) = frame(
&ctx,
&mut tab,
false,
click(entry, egui::PointerButton::Primary),
);
assert_eq!( assert_eq!(
actions.verify, actions.verify,
Some(PATHS.iter().map(|p| p.to_string()).collect::<Vec<_>>()) Some(PATHS.iter().map(|p| p.to_string()).collect::<Vec<_>>())
@ -174,23 +194,84 @@ fn a_member_row_offers_the_verification_too() {
); );
} }
/// The complaint this answers: the only word about verification used to be
/// behind a right-click nobody had performed yet.
#[test]
fn the_listing_says_what_a_group_is_without_being_asked() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0).join(" ");
assert!(
painted.contains("suspected duplicates"),
"nothing warns that a group is a suspicion: {painted:?}"
);
assert!(
painted.contains("not proof"),
"the caution does not say what it is cautioning about: {painted:?}"
);
assert!(
painted.contains(VERIFY_LABEL.trim_end_matches('…')),
"the banner does not name the way to settle it: {painted:?}"
);
}
/// It quotes the setting, not a number written twice.
#[test]
fn the_banner_quotes_the_configured_sample_size() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let mut actions = DuplicatesActions::default();
let out = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
actions = tab.ui(ui, false, 64 * 1024);
});
});
let _ = actions;
let painted = painted_text(&out).join(" ");
assert!(
painted.contains("first 65.5 KB"),
"the banner ignores hash_length: {painted:?}"
);
}
/// A tab with nothing to warn about does not warn.
#[test]
fn an_empty_result_says_so_rather_than_showing_an_empty_list() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(Vec::new(), DuplicatesConfig::default());
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&NO_GROUPS.to_string()), "{painted:?}");
assert!(
!painted.iter().any(|t| t.contains("suspected duplicates")),
"an empty tab cautioned about nothing: {painted:?}"
);
}
// --- Ordering ---------------------------------------------------------------
/// A group named by its first member, with the waste that decides the /// A group named by its first member, with the waste that decides the
/// default order set by hand. /// default order set by hand.
fn sized_group(name: &str, redundant: i64) -> DuplicateGroup { fn sized_group(name: &str, redundant: i64) -> DuplicateGroup {
let mut group = group(&[&format!("/a/{name}"), &format!("/b/{name}")]); let mut group = group(&[&format!("/a/{name}"), &format!("/b/{name}")]);
group.hash = name.as_bytes().to_vec(); group.hash = name.as_bytes().to_vec();
group.redundant_size = redundant; group.redundant_size = redundant;
// The title is priced from the members, so the size has to agree with the
// waste the ordering is being tested on: two copies, one reclaimable.
for member in group.members.iter_mut() {
member.3 = redundant.max(0) as u64;
}
group group
} }
/// The names of the groups, in the order the tab would list them. /// The names of the groups, in the order the tab would list them.
fn listed(groups: Vec<DuplicateGroup>, sort: DupSort) -> Vec<String> { fn listed(groups: Vec<DuplicateGroup>, sort: DupSort) -> Vec<String> {
let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(groups); let mut loaded = LoadedGroups::new(groups);
loaded.sort(sort); loaded.rebuild(sort, &filters, false);
loaded loaded
.order .rows
.iter() .iter()
.map(|&i| loaded.groups[i].members[0].1.clone()) .map(|row| loaded.groups[row.group].members[row.members[0]].1.clone())
.collect() .collect()
} }
@ -260,13 +341,14 @@ fn the_order_returns_when_the_choice_does() {
sized_group("small.jpg", 10), sized_group("small.jpg", 10),
sized_group("middling.txt", 100), sized_group("middling.txt", 100),
]; ];
let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(groups); let mut loaded = LoadedGroups::new(groups);
loaded.sort(DupSort::Extension); loaded.rebuild(DupSort::Extension, &filters, false);
loaded.sort(DupSort::Reclaimable); loaded.rebuild(DupSort::Reclaimable, &filters, false);
let names: Vec<&str> = loaded let names: Vec<&str> = loaded
.order .rows
.iter() .iter()
.map(|&i| loaded.groups[i].members[0].1.as_str()) .map(|row| loaded.groups[row.group].members[row.members[0]].1.as_str())
.collect(); .collect();
assert_eq!(names, ["big.txt", "small.jpg", "middling.txt"]); assert_eq!(names, ["big.txt", "small.jpg", "middling.txt"]);
} }
@ -276,27 +358,18 @@ fn the_order_returns_when_the_choice_does() {
#[test] #[test]
fn choosing_an_order_relists_without_rescanning() { fn choosing_an_order_relists_without_rescanning() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let mut tab = DuplicatesTab { let mut tab = tab(
state: DupState::Loaded(LoadedGroups::new(vec![ vec![sized_group("b.txt", 900), sized_group("a.jpg", 10)],
sized_group("b.txt", 900), DuplicatesConfig::default(),
sized_group("a.jpg", 10), );
])),
sort: DupSort::default(),
};
let order_on_screen = |out: &egui::FullOutput| -> Vec<String> {
painted_text(out)
.into_iter()
.filter(|t| t.contains("reclaimable"))
.collect()
};
let (out, _) = frame(&ctx, &mut tab, false, Vec::new()); let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let before = order_on_screen(&out); let before = headers(&out);
assert!(before[0].contains("b.txt"), "{before:?}"); assert!(before[0].contains("b.txt"), "{before:?}");
tab.sort = DupSort::Extension; tab.sort = DupSort::Extension;
let (out, actions) = frame(&ctx, &mut tab, false, Vec::new()); let (out, actions) = frame(&ctx, &mut tab, false, Vec::new());
let after = order_on_screen(&out); let after = headers(&out);
assert!(after[0].contains("a.jpg"), "{after:?}"); assert!(after[0].contains("a.jpg"), "{after:?}");
assert!( assert!(
!actions.refresh, !actions.refresh,
@ -305,16 +378,362 @@ fn choosing_an_order_relists_without_rescanning() {
assert_eq!(before.len(), after.len(), "a group went missing"); assert_eq!(before.len(), after.len(), "a group went missing");
} }
// --- Exclusions -------------------------------------------------------------
fn excluding(patterns: &[&str]) -> DuplicatesConfig {
DuplicatesConfig {
exclude_patterns: patterns.iter().map(|p| p.to_string()).collect(),
hidden_groups: Vec::new(),
}
}
/// An excluded copy stops being counted, and the group is re-priced around
/// the ones that are left — listing "3 ×" beside two paths would be a lie
/// about how much space deleting them saves.
#[test] #[test]
fn an_empty_result_says_so_rather_than_showing_an_empty_list() { fn an_excluded_member_leaves_the_group_and_its_totals() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let mut tab = DuplicatesTab { let mut tab = tab(vec![group(&PATHS)], excluding(&["/c/*"]));
state: DupState::Loaded(LoadedGroups::new(Vec::new())), let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
sort: DupSort::default(), let header = headers(&out)
}; .into_iter()
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0); .next()
.expect("group went missing");
assert!( assert!(
painted.contains(&"No duplicate files found.".to_string()), header.starts_with("2 × img.raw:"),
"{painted:?}" "the count still includes the excluded copy: {header:?}"
);
assert!(
header.contains("100 B reclaimable (200 B total)"),
"the totals still include the excluded copy: {header:?}"
);
let painted = painted_text(&out);
assert!(
!painted.contains(&"/c/img.raw".to_string()),
"the excluded path is still listed: {painted:?}"
);
}
/// One surviving copy is not a duplicate of anything.
#[test]
fn a_group_down_to_one_survivor_is_not_listed() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["/b/*", "/c/*"]));
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
assert!(
!painted.contains(&NO_GROUPS.to_string()),
"a filtered listing must not read as an index with no duplicates in it"
);
}
/// Verification is offered on what is on screen, not on what was excluded.
#[test]
fn verifying_a_filtered_group_reads_only_its_survivors() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["/c/*"]));
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header);
let actions = click_menu_entry(&ctx, &mut tab, VERIFY_LABEL);
assert_eq!(
actions.verify,
Some(vec!["/a/img.raw".to_string(), "/b/img.raw".to_string()]),
"an excluded file was read anyway"
);
}
/// Adding one is an edit to the listing, not a reason to re-read the index.
#[test]
fn a_new_exclusion_relists_without_rescanning_and_is_persisted() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], DuplicatesConfig::default());
frame(&ctx, &mut tab, false, Vec::new());
tab.draft = "*.raw".to_string();
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let add = painted_text_center(&out, "Add").expect("no Add button painted");
let (_, actions) = frame(
&ctx,
&mut tab,
false,
click(add, egui::PointerButton::Primary),
);
assert!(!actions.refresh, "an exclusion asked for another scan");
assert_eq!(
actions.save_filters.map(|f| f.exclude_patterns),
Some(vec!["*.raw".to_string()]),
"the exclusion was not handed back to be saved"
);
// The click lands after the frame it was laid out for, so what it changed
// is on the next one.
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(
painted.contains(&ALL_FILTERED.to_string()),
"the group survived its own exclusion: {painted:?}"
);
assert!(
painted.contains(&"*.raw ×".to_string()),
"no chip to take it back off with: {painted:?}"
);
assert!(
!painted.contains(&"*.raw".to_string()),
"the editor kept the pattern it just added: {painted:?}"
);
}
/// …and taking the chip off puts the group back.
#[test]
fn removing_the_chip_brings_the_groups_back() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["*.raw"]));
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let chip = painted_text_center(&out, "*.raw ×").expect("no chip painted");
let (out, actions) = frame(
&ctx,
&mut tab,
false,
click(chip, egui::PointerButton::Primary),
);
assert_eq!(
actions.save_filters.map(|f| f.exclude_patterns),
Some(Vec::new())
);
assert_eq!(headers(&out).len(), 1, "the group did not come back");
}
/// Right-clicking a file is the shortest way to say "not this folder again".
#[test]
fn a_member_row_offers_the_two_exclusions_it_stands_for() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let pos = painted_text_center(&out, &header).expect("no header painted");
frame(
&ctx,
&mut tab,
false,
click(pos, egui::PointerButton::Primary),
);
let (menu, _) = context_menu_on(&ctx, &mut tab, false, PATHS[1]);
assert!(
menu.contains(&"Exclude *.raw from duplicates".to_string()),
"no extension exclusion: {menu:?}"
);
assert!(
menu.iter().any(|t| t.contains("Exclude this folder")),
"no folder exclusion: {menu:?}"
);
let actions = click_menu_entry(&ctx, &mut tab, "Exclude *.raw from duplicates");
assert_eq!(
actions.save_filters.map(|f| f.exclude_patterns),
Some(vec!["*.raw".to_string()])
);
}
/// Only a hand-edited config can get here — the editor refuses one — and the
/// answer is to say so, not to quietly list files someone asked to never see.
#[test]
fn an_unparseable_pattern_is_reported_rather_than_silently_dropped() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(vec![group(&PATHS)], excluding(&["a[b"]));
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(
painted
.iter()
.any(|t| t.contains("Exclusions are not being applied")),
"a broken pattern set said nothing: {painted:?}"
);
assert_eq!(
headers(&frame(&ctx, &mut tab, false, Vec::new()).0).len(),
1,
"a broken pattern set hid the listing instead of listing it"
);
}
// --- Hidden groups ----------------------------------------------------------
/// The group `group()` builds, as the config spells it.
fn hash_of(group: &DuplicateGroup) -> String {
group.hash_hex()
}
#[test]
fn hiding_a_group_takes_it_off_the_list_and_is_persisted() {
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let hash = hash_of(&group(&PATHS));
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header);
let actions = click_menu_entry(&ctx, &mut tab, HIDE_LABEL);
assert_eq!(
actions.save_filters.map(|f| f.hidden_groups),
Some(vec![hash]),
"hiding is not remembered by content hash"
);
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
assert!(
painted.iter().any(|t| t.contains("Hidden: 1 group")),
"nothing says a group is being kept off screen: {painted:?}"
);
}
/// A listing already on screen when the config named the group still drops it,
/// so the hiding survives a restart.
#[test]
fn a_group_hidden_in_the_config_is_not_listed() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS), sized_group("keep.txt", 40)],
DuplicatesConfig {
exclude_patterns: Vec::new(),
hidden_groups: vec![hash_of(&group(&PATHS))],
},
);
let out = frame(&ctx, &mut tab, false, Vec::new()).0;
let listed = headers(&out);
assert_eq!(listed.len(), 1, "{listed:?}");
assert!(listed[0].contains("keep.txt"), "{listed:?}");
assert!(
painted_text(&out)
.iter()
.any(|t| t.contains("1 more hidden by your filters")),
"the listing does not account for what it dropped"
);
}
/// Hiding has to be reversible without editing a config file by hand.
#[test]
fn show_hidden_lists_them_again_and_offers_the_way_back() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS)],
DuplicatesConfig {
exclude_patterns: Vec::new(),
hidden_groups: vec![hash_of(&group(&PATHS))],
},
);
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let toggle = painted_text_center(&out, "Show hidden").expect("no Show hidden control");
let (out, _) = frame(
&ctx,
&mut tab,
false,
click(toggle, egui::PointerButton::Primary),
);
let listed = headers(&out);
assert_eq!(listed.len(), 1, "showing hidden groups listed none");
assert!(
listed[0].starts_with("Hidden — "),
"a revealed group is not marked as one: {listed:?}"
);
context_menu_on(&ctx, &mut tab, false, &listed[0]);
let actions = click_menu_entry(&ctx, &mut tab, UNHIDE_LABEL);
assert_eq!(
actions.save_filters.map(|f| f.hidden_groups),
Some(Vec::new()),
"unhiding did not take it back out of the config"
);
}
/// Clear is the escape hatch for a hidden list nobody wants to unpick.
#[test]
fn clear_empties_the_hidden_list() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS)],
DuplicatesConfig {
exclude_patterns: Vec::new(),
hidden_groups: vec![hash_of(&group(&PATHS)), "00".repeat(32)],
},
);
let (out, _) = frame(&ctx, &mut tab, false, Vec::new());
let clear = painted_text_center(&out, "Clear").expect("no Clear button");
let (out, actions) = frame(
&ctx,
&mut tab,
false,
click(clear, egui::PointerButton::Primary),
);
assert_eq!(
actions.save_filters.map(|f| f.hidden_groups),
Some(Vec::new())
);
assert_eq!(headers(&out).len(), 1, "the group did not come back");
}
/// A hash spelled in capitals by a hand-edited config still means that group.
#[test]
fn hidden_hashes_are_matched_case_insensitively() {
let ctx = crate::test_ui::ctx();
let mut tab = tab(
vec![group(&PATHS)],
DuplicatesConfig {
exclude_patterns: Vec::new(),
hidden_groups: vec![hash_of(&group(&PATHS)).to_uppercase()],
},
);
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
assert!(painted.contains(&ALL_FILTERED.to_string()), "{painted:?}");
}
// --- The two together -------------------------------------------------------
/// The extension listing orders on what the titles say, which after an
/// exclusion is not what the scan ranked.
#[test]
fn the_extension_order_follows_the_recomputed_waste() {
// Three copies of one .txt (200 B reclaimable) against two of another
// (100 B) — until an exclusion takes the third copy off the first.
let mut big = group(&["/a/big.txt", "/b/big.txt", "/keep-out/big.txt"]);
big.hash = b"big".to_vec();
let mut small = group(&["/a/small.txt", "/b/small.txt"]);
small.hash = b"small".to_vec();
assert_eq!(
listed(vec![big.clone(), small.clone()], DupSort::Extension),
["big.txt", "small.txt"],
"unfiltered, the three-copy group wastes more"
);
let filters = DupFilters::new(&excluding(&["/keep-out/*"]));
let mut loaded = LoadedGroups::new(vec![big, small]);
loaded.rebuild(DupSort::Extension, &filters, false);
let names: Vec<&str> = loaded
.rows
.iter()
.map(|row| loaded.groups[row.group].members[row.members[0]].1.as_str())
.collect();
assert_eq!(
names,
["big.txt", "small.txt"],
"both waste 100 B now, so the hash settles it — but stably"
);
assert!(
loaded.rows.iter().all(|row| row.redundant == 100),
"the order was taken from the scan's price, not the listed one"
);
}
/// The rebuild is what every filter edit goes through, so it must not run for
/// a frame that changed nothing.
#[test]
fn an_unchanged_listing_is_not_rebuilt() {
let filters = DupFilters::new(&DuplicatesConfig::default());
let mut loaded = LoadedGroups::new(vec![group(&PATHS)]);
loaded.rebuild(DupSort::Reclaimable, &filters, false);
let built = loaded.built_for;
loaded.rows.clear();
loaded.rebuild(DupSort::Reclaimable, &filters, false);
assert_eq!(loaded.built_for, built);
assert!(
loaded.rows.is_empty(),
"an unchanged listing was rebuilt anyway"
); );
} }

View file

@ -1,45 +1,97 @@
//! The Help tab: a quickstart guide for first-time users. The complete //! The Help tab: what QuickSearch is, how to start, how results are ranked
//! technical reference stays in README.md. //! and what ends up in the index. Anything belonging to one control lives on
//! that control's hover tip (`tips.rs`); the query language lives in the
//! Search tab's syntax window; the complete technical reference stays in
//! README.md.
use crate::ui_util::hint;
/// Returns true when the "Show the introduction again" button was clicked. /// Returns true when the "Show the introduction again" button was clicked.
pub fn ui(ui: &mut egui::Ui) -> bool { pub fn ui(ui: &mut egui::Ui) -> bool {
let mut replay = false; let mut replay = false;
// Cap the column like a document page: a maximized window would
// otherwise stretch every paragraph into one long line. Only ever a cap,
// never a floor: `set_max_width` widens a `Ui` as readily as it narrows
// one, and text laid out wider than the panel is *clipped* by the scroll
// area rather than wrapped, so the ends of the lines simply vanish.
//
// Measured out here, on the panel: inside a vertical `ScrollArea` the
// content `Ui` is free to be wider than the viewport, so its own
// `available_width` is no guide to what will be visible. The bar's
// allocation comes off the top, or the last few characters of every line
// would sit under it (zero while the bars float, as they do by default).
let column = (ui.available_width() - ui.spacing().scroll.allocated_width()).min(620.0);
let scroll = egui::ScrollArea::vertical() let scroll = egui::ScrollArea::vertical()
.auto_shrink([false; 2]) .auto_shrink([false; 2])
.show(ui, |ui| { .show(ui, |ui| {
// Cap the column like a document page: a maximized window ui.set_max_width(column);
// would otherwise stretch every paragraph into one long line.
ui.set_max_width(620.0);
ui.heading(egui::RichText::new("Welcome to QuickSearch").strong()); ui.heading(egui::RichText::new("Welcome to QuickSearch").strong());
ui.add_space(4.0); ui.add_space(4.0);
ui.label( ui.label(
"QuickSearch keeps an index of the folders you choose and finds \ "QuickSearch is a search engine for the files on this computer \
files by name and by what is inside them, as you type.", and for the text inside them. It reads through your folders \
once, remembers what it found, and then answers from what it \
remembers rather than by going back to the disk. That is why \
results appear as fast as you can type, across hundreds of \
thousands of files.",
); );
ui.add_space(4.0);
ui.label(
"It never connects to the internet and nothing ever leaves this \
computer. What it remembers can be encrypted with a password, \
from the Settings tab.",
);
ui.add_space(6.0);
ui.label("The primary home of this software is:");
let link = |ui: &mut egui::Ui, url: &str| ui.hyperlink_to(url, url);
link(ui, "https://quicksearch.karsttech.com");
link(ui, "https://code.karsttech.com/jeremy/quick_search");
ui.add_space(6.0);
ui.label(
"The code is also mirrored to GitHub for easier bug reporting \
and issue tracking:",
);
link(ui, "https://github.com/DataScienceDIY/quick_search");
ui.add_space(6.0); ui.add_space(6.0);
if ui.button("Show the introduction again").clicked() { if ui.button("Show the introduction again").clicked() {
replay = true; replay = true;
} }
ui.add_space(6.0);
ui.label(hint(
"Where to look for what: hover any control for what that one \
control does and what to set it to. This tab covers the ideas \
behind them. The ? button beside the search box covers the \
query language. README.md is the complete reference.",
));
ui.add_space(12.0); ui.add_space(12.0);
ui.heading(egui::RichText::new("Getting started").strong()); ui.heading(egui::RichText::new("Getting started").strong());
ui.add_space(4.0); ui.add_space(4.0);
ui.label( ui.label(
"1. The first time QuickSearch runs it starts indexing your home \ "1. The first time QuickSearch runs it starts indexing your home \
folder on its own. The status bar along the bottom shows the \ folder on its own. The status bar along the bottom shows the \
progress, and searching already works while it runs.", progress, and searching already works while it runs, though a \
file it has not reached yet cannot appear until it does.",
); );
ui.label( ui.label(
"2. To index different folders, open the Manage Index tab and edit \ "2. To search other places, open the Manage Index tab and add them \
the folder list. Indexed folders are watched, so the index follows \ to the folder list. Indexed folders are watched, so the index \
your files as they change.", follows your files as they change, and adding one leaves \
everything already indexed alone.",
); );
ui.label( ui.label(
"3. Type in the search box on the Search tab. Results appear as \ "3. Type in the search box on the Search tab. Results appear as \
you type, best matches first.", you type, best matches first.",
); );
ui.label(
"4. If the index grows larger than you would like, narrow it with \
the two filters under Content filters on the Manage Index tab. \
They are explained under What gets indexed, below.",
);
ui.add_space(12.0); ui.add_space(12.0);
ui.heading(egui::RichText::new("Searching").strong()); ui.heading(egui::RichText::new("Searching").strong());
@ -54,35 +106,50 @@ pub fn ui(ui: &mut egui::Ui) -> bool {
ui.label("The ? button left of the search box shows the full query syntax."); ui.label("The ? button left of the search box shows the full query syntax.");
ui.add_space(6.0); ui.add_space(6.0);
ui.label( ui.label(
"• Tick Fuzzy to also find matches with typos in them, at some \ "• A match in a file's name or path is highlighted in that \
cost in speed.", column; a match in its contents shows a snippet of the \
); surrounding text in the Content Match column, with the rest on \
ui.label( hover.",
"• Click a column header to sort the results; click it again to \
reverse the order. Right-click any header to choose which \
columns are shown size and modified date start hidden.",
); );
ui.label( ui.label(
"• Right-click a result to open it, open its containing folder, \ "• Right-click a result to open it, open its containing folder, \
or hide files like it from the results.", or hide files like it from the results.",
); );
ui.label( ui.label(
"• A match in a file's name or path is highlighted in that \ "• Click a column header to sort by it; right-click any header to \
column; a match in its contents shows a snippet of the \ choose which columns are shown.",
surrounding text in the Content Match column, with the rest on \
hover.",
); );
ui.add_space(12.0);
ranking_section(ui);
ui.add_space(12.0);
what_gets_indexed_section(ui);
ui.add_space(12.0);
duplicates_section(ui);
ui.add_space(12.0);
ui.heading(egui::RichText::new("Updates").strong());
ui.add_space(4.0);
ui.label(
"QuickSearch never sends anything off of your computer, so it \
never updates itself automatically.",
);
ui.label("To update, install the latest version yourself from:");
link(ui, "https://quicksearch.karsttech.com");
ui.add_space(12.0); ui.add_space(12.0);
ui.heading(egui::RichText::new("The other tabs").strong()); ui.heading(egui::RichText::new("The other tabs").strong());
ui.add_space(4.0); ui.add_space(4.0);
let prose = prose_width(ui.available_width());
egui::Grid::new("help-tabs") egui::Grid::new("help-tabs")
.num_columns(2) .num_columns(2)
.spacing([18.0, 5.0]) .spacing([CELL_SPACING, 5.0])
.show(ui, |ui| { .show(ui, |ui| {
let row = |ui: &mut egui::Ui, name: &str, what: &str| { let row = |ui: &mut egui::Ui, name: &str, what: &str| {
ui.strong(name); ui.strong(name);
ui.label(what); cell(ui, prose, what);
ui.end_row(); ui.end_row();
}; };
row( row(
@ -94,14 +161,10 @@ pub fn ui(ui: &mut egui::Ui) -> bool {
row( row(
ui, ui,
"Duplicates", "Duplicates",
"files whose contents are identical, grouped", "files that are probably identical copies of each other, \
); grouped; verify before deleting anything",
row(
ui,
"Logs",
"warnings from indexing and folder watching that a \
terminal would have shown",
); );
row(ui, "Logs", "warnings from issues during indexing");
row( row(
ui, ui,
"Settings", "Settings",
@ -149,6 +212,319 @@ pub fn ui(ui: &mut egui::Ui) -> bool {
replay replay
} }
/// The tiers of `search::cascade`, collapsed to the ones a reader can act on:
/// its eleven ranks pair up (exact case, then any case) everywhere but the
/// fuzzy stages, and the pairing is an implementation detail here.
fn ranking_section(ui: &mut egui::Ui) {
ui.heading(egui::RichText::new("How results are ranked").strong());
ui.add_space(4.0);
ui.label(
"Every result is placed in a tier by where it matched, and the tiers \
come out in this order. Within a tier, a match with the case you \
typed comes before one that only matches ignoring case, and a file \
mentioning your words more often comes before one mentioning them \
once.",
);
ui.add_space(6.0);
let prose = prose_width(ui.available_width());
egui::Grid::new("help-ranking")
.num_columns(2)
.spacing([CELL_SPACING, 5.0])
.striped(true)
.show(ui, |ui| {
let row = |ui: &mut egui::Ui, tier: &str, what: &str| {
ui.strong(tier);
cell(ui, prose, what);
ui.end_row();
};
row(
ui,
"Exact name",
"the file is called exactly what you typed",
);
row(
ui,
"Name contains",
"what you typed appears somewhere in the file's name",
);
row(
ui,
"Text inside",
"the words are in the file's contents, most mentions first",
);
row(
ui,
"Close spelling",
"a name or some text within a typo or two of what you typed, \
only while Fuzzy is ticked",
);
row(
ui,
"Path only",
"nothing in the name or the text matched, but a folder along \
the way did",
);
});
ui.add_space(6.0);
ui.label(
"The Rank column carries the tier a result came from, coloured from \
blue for a close match to red for a distant one. Sorting by any other \
column sets that ordering aside until you sort by Rank again.",
);
ui.label(hint(
"Results stream in while a search runs and are only ever added to the \
end, so the list never reshuffles under you as you read it.",
));
}
/// The two Manage Index filters, side by side, because the confusion they
/// cause is about which one keeps a file out of the index (ignore patterns)
/// and which one only stops its text being read (the whitelist).
fn what_gets_indexed_section(ui: &mut egui::Ui) {
ui.heading(egui::RichText::new("What gets indexed").strong());
ui.add_space(4.0);
ui.label(
"Everything inside your indexed folders, minus whatever the two \
filters on the Manage Index tab take out. The filters do different \
jobs: one decides which files exist in the index at all, the other \
decides which of them have their text read.",
);
ui.add_space(8.0);
ui.strong("Ignore patterns: files and folders never indexed");
ui.add_space(4.0);
ui.label(
"A pattern is compared against file and folder names, and against \
whole paths. It is never compared against what is inside a file, so \
nothing is excluded for the words it contains. A file kept out this \
way is gone from the index completely, contents included.",
);
ui.add_space(4.0);
ui.label(
"• A pattern with no slash in it is a name: it matches any file or \
folder called that, anywhere under your indexed folders, and it has \
to match the whole name. Excluding an extension therefore needs a \
wildcard.",
);
ui.label(
"• A pattern with a slash in it is a path: it is matched against the \
whole path of a file or folder, and takes out everything underneath \
it.",
);
ui.label("• * stands for any run of characters, including none; ? for exactly one.");
ui.add_space(6.0);
ignore_examples(ui);
ui.add_space(6.0);
ui.label(hint(
"A path pattern has to match from the start, so Vacation/Diary on its \
own matches nothing: the leading */ is what lets it find that folder \
wherever it sits. Whether case matters follows the filesystem, so \
*diary* also catches Diary on Windows and macOS, but not on Linux.",
));
ui.label(hint(
"Adding a pattern takes out the entries it matches. Deleting a pattern \
from the list is what brings those files back, at the next indexing \
run; deleting the files it matched is never something QuickSearch does.",
));
ui.add_space(8.0);
ui.strong("Full-text extensions whitelist: which files have their text read");
ui.add_space(4.0);
ui.label(
"This one limits contents only. Files it leaves out are still indexed \
and still turn up in results by their name and their path; what you \
lose is finding them by the words inside them.",
);
ui.add_space(4.0);
ui.label(
"• Empty, which is how it starts, means QuickSearch reads the text of \
every file type it understands.",
);
ui.label(
"• To narrow it, enter one extension per line, the leading dot \
optional. Listing only txt, md and pdf keeps the stored text small \
and focused on documents, while every other file stays findable by \
name.",
);
ui.label(
"• A list that has anything in it also leaves out files with no \
extension, such as Makefile or README. Add the line (none) to include \
them.",
);
ui.label("• Anything after a # is a comment, so a line can be switched off in place.");
}
/// What a duplicate group is and is not. Written out here because the tab
/// itself has room for the caution and not for the reasoning, and because the
/// question it answers — "it says these differ, but they look the same" — is
/// the one the feature reliably provokes.
fn duplicates_section(ui: &mut egui::Ui) {
ui.heading(egui::RichText::new("Duplicates").strong());
ui.add_space(4.0);
ui.label(
"The Duplicates tab groups files that look like copies of each other, \
biggest waste first, so the space worth reclaiming is at the top. \
Nothing there is ever deleted or moved for you; the tab only shows \
you the groups.",
);
ui.add_space(4.0);
ui.label(
"A group is a strong suspicion, not a verdict. Indexing reads each \
file's size and its first few kilobytes and hashes those, and that \
hash is what puts two files in a group the rest of the file was \
never read. Right-click a group and choose Verify copies are \
identical to read all of it and compare every byte, which is the \
answer to have before deleting anything.",
);
ui.add_space(8.0);
ui.strong("When the verification says two files differ and they look the same");
ui.add_space(4.0);
ui.label(
"They do differ, in bytes you never see. PDFs and Office documents \
carry the date they were created and last modified, a document ID, \
and a revision number, all stored inside the file itself. Two \
invoices printed from one template, or one document saved twice, \
are different files on disk however identical they look on screen.",
);
ui.label(hint(
"The report says which byte disagreed and how big the file was. A \
difference in the first few hundred bytes of a document is almost \
always that bookkeeping; one in the middle of a large file is not.",
));
ui.add_space(8.0);
ui.strong("Groups you do not want to be shown again");
ui.add_space(4.0);
ui.label(
"• Hide this group, from the right-click menu, drops one group for \
good the group that is not really a duplicate at all. It is \
remembered by content, so it stays hidden when those files are \
renamed or moved, and comes back if they are edited. Show hidden \
lists them again and Clear forgets the lot.",
);
ui.label(
"• Exclude leaves out whole sets of files: the box above the list \
takes the same patterns as the ignore filters, and the right-click \
menu offers this file's type and this file's folder. That is the one \
for a backup folder that is meant to hold copies.",
);
ui.label(hint(
"Neither hides anything from search. An excluded file is still \
indexed, still found by name and still found by its contents; it is \
only left out of this one listing.",
));
}
/// One ignore-pattern example: what it takes out, and the near miss it
/// leaves alone. The near miss is half the point, so it survives every
/// layout.
struct Example {
pattern: &'static str,
excluded: &'static str,
kept: &'static str,
}
const IGNORE_EXAMPLES: &[Example] = &[
Example {
pattern: "node_modules",
excluded: "a file or folder named exactly that, and all it holds",
kept: "node_modules_old",
},
Example {
pattern: "*.jpg",
excluded: "holiday.jpg, 1.jpg",
kept: "holiday.jpeg, holiday.jpg.exe",
},
Example {
pattern: "?.jpg",
excluded: "a.jpg, 1.jpg",
kept: "12.jpg, holiday.jpg",
},
Example {
pattern: "*diary*",
excluded: "Mydiary.jpg, and a folder named Diary with all it holds",
kept: "Pictures/Vacation",
},
Example {
pattern: "*/Vacation/Diary",
excluded: "that one folder, and everything under it",
kept: "Pictures/Diary",
},
];
/// Under this, three columns get so little each that the middle one wraps to
/// one or two words a line and the table is harder to read than a list.
const EXAMPLE_TABLE_MIN_WIDTH: f32 = 560.0;
/// The examples as a table where there is room for one, stacked where there
/// is not. Inside a [`crate::ui_util::stable_section`] because the two
/// layouts allocate different numbers of widgets, and a resize across the
/// threshold would otherwise rename everything below them.
fn ignore_examples(ui: &mut egui::Ui) {
crate::ui_util::stable_section(ui, |ui| {
let available = ui.available_width();
if available < EXAMPLE_TABLE_MIN_WIDTH {
for example in IGNORE_EXAMPLES {
ui.monospace(example.pattern);
cell(ui, available, format!("Excluded: {}", example.excluded));
cell(ui, available, format!("Still indexed: {}", example.kept));
ui.add_space(6.0);
}
return;
}
// The two prose columns share what the pattern column and the two
// gaps leave over.
let prose = (available - KEY_COL_WIDTH - 2.0 * CELL_SPACING) / 2.0;
egui::Grid::new("help-ignore-examples")
.num_columns(3)
.spacing([CELL_SPACING, 5.0])
.striped(true)
.show(ui, |ui| {
ui.strong("Pattern");
cell(ui, prose, egui::RichText::new("Excluded").strong());
cell(ui, prose, egui::RichText::new("Still indexed").strong());
ui.end_row();
for example in IGNORE_EXAMPLES {
ui.monospace(example.pattern);
cell(ui, prose, example.excluded);
cell(ui, prose, example.kept);
ui.end_row();
}
});
});
}
/// Width allowed for a table's leading key column, generous enough for the
/// longest of them (`*/Vacation/Diary`, in monospace).
const KEY_COL_WIDTH: f32 = 140.0;
/// Both tables' horizontal cell spacing.
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`.
///
/// Two things force the explicit width. Grid cells default to
/// `TextWrapMode::Extend`, which lays a long cell out past the panel and, far
/// worse, *widens the `Ui`* it was drawn in, so every paragraph below the
/// table wraps to that width and is then clipped by the scroll area. Wrapping
/// the cell instead fixes that but breaks the other way: a wrapped `Label`
/// reports its narrowest possible width as what it wants, and `Grid` sizes
/// 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`.
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());
});
}
/// The prose width for a two-column table: everything the key column and the
/// spacing leave over.
fn prose_width(available: f32) -> f32 {
(available - KEY_COL_WIDTH - CELL_SPACING).max(120.0)
}
/// Where this build left the README: under the install prefix's `share/doc` /// Where this build left the README: under the install prefix's `share/doc`
/// (the .deb puts it in `/usr/share/doc/quicksearch/`), beside the executable /// (the .deb puts it in `/usr/share/doc/quicksearch/`), beside the executable
/// (the Windows installer and portable copies), or at the top of a build tree /// (the Windows installer and portable copies), or at the top of a build tree
@ -186,4 +562,84 @@ mod tests {
); );
crate::test_ui::assert_no_tofu(&ctx, &out); crate::test_ui::assert_no_tofu(&ctx, &out);
} }
/// Ranking and the filter rules live on this tab and nowhere else a user
/// can read without hovering, so a section quietly dropped from `ui`
/// would take the only copy with it. Tall enough a viewport that the
/// scroll area paints the whole document.
#[test]
fn the_tab_carries_the_sections_that_live_nowhere_else() {
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_text(&out).join("\n");
for expected in [
"How results are ranked",
"What gets indexed",
// One row of each table: the grids are the part most easily lost
// to a refactor, and the tooltips point here for them.
"Path only",
"*/Vacation/Diary",
// The duplicates section exists to answer one question in full,
// and the tab has room for the caution but not the reasoning.
"Verify copies are identical",
"revision number",
] {
assert!(painted.contains(expected), "no {:?}: {}", expected, painted);
}
}
/// The column is capped at 620 points so a maximized window does not
/// stretch a paragraph into one long line. A cap that is also a floor
/// lays the text out past the scroll area, which clips it rather than
/// wrapping it: the ends of the lines simply vanish. Galley rects are
/// pre-clip, so this measures the layout rather than the paint.
#[test]
fn a_window_narrower_than_the_column_reflows_rather_than_clipping() {
// 640 is the smallest window the app allows, and the UI scale
// divides it: 400 is roughly that window at 1.6x.
for width in [400.0_f32, 480.0, 560.0, 620.0] {
let ctx = crate::test_ui::ctx();
let input = crate::test_ui::raw_input(egui::vec2(width, 6000.0), vec![]);
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
super::ui(ui);
});
});
let overflowing: Vec<_> = crate::test_ui::painted(&out)
.into_iter()
.filter(|(_, rect)| rect.max.x > width)
.map(|(text, rect)| format!("{:?} reaches {}", text, rect.max.x))
.collect();
assert!(
overflowing.is_empty(),
"text laid out past the {}pt panel: {:#?}",
width,
overflowing
);
}
}
/// 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]
fn the_stacked_examples_keep_both_halves_of_each_row() {
let ctx = crate::test_ui::ctx();
let input = crate::test_ui::raw_input(egui::vec2(400.0, 6000.0), vec![]);
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
super::ui(ui);
});
});
let painted = crate::test_ui::painted_text(&out).join("\n");
for example in super::IGNORE_EXAMPLES {
for expected in [example.pattern, example.excluded, example.kept] {
assert!(painted.contains(expected), "no {:?}", expected);
}
}
}
} }

View file

@ -1,7 +1,14 @@
//! The system-wide shortcut that raises QuickSearch and focuses the search //! The in-application half of the search shortcut: the key QuickSearch
//! box. Windows and X11 claim the key via `global-hotkey` //! claims for itself while it is running. Windows and X11 grant that via
//! (`RegisterHotKey`/`XGrabKey`); Wayland refuses grabs by design, so the //! `global-hotkey` (`RegisterHotKey`/`XGrabKey`); Wayland refuses grabs by
//! shortcut goes through the XDG portal and the *desktop* owns the binding. //! design, so it goes through the XDG portal and the *desktop* picks the key.
//!
//! 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
//! while QuickSearch is not running — nothing an application registers for
//! itself can — which is what [`crate::activate`] and `--toggle` are for.
//! Both funnel into the same pending flag, so the window comes forward the
//! same way whichever one fired.
//! //!
//! Held in a thread-local global rather than a field: the registration is //! Held in a thread-local global rather than a field: the registration is
//! process-wide, the event handler is set-once, and on Windows //! process-wide, the event handler is set-once, and on Windows
@ -11,21 +18,14 @@
mod binding; mod binding;
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
mod portal; mod portal;
mod raise;
pub use binding::{parse_setting, Binding}; pub use binding::{parse_setting, Binding};
pub use raise::raise;
use std::cell::RefCell; use std::cell::RefCell;
use std::sync::atomic::{AtomicBool, Ordering};
use global_hotkey::hotkey::HotKey; use global_hotkey::hotkey::HotKey;
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
/// A flag rather than a queue: two presses before the app can redraw mean
/// the same thing as one.
static FIRED: AtomicBool = AtomicBool::new(false);
thread_local! { thread_local! {
static REGISTRY: RefCell<Option<Registry>> = const { RefCell::new(None) }; static REGISTRY: RefCell<Option<Registry>> = const { RefCell::new(None) };
} }
@ -128,10 +128,6 @@ pub fn apply(setting: &str) {
}); });
} }
pub fn take_fired() -> bool {
FIRED.swap(false, Ordering::SeqCst)
}
pub fn status() -> Status { pub fn status() -> Status {
REGISTRY.with_borrow(|slot| match slot.as_ref() { REGISTRY.with_borrow(|slot| match slot.as_ref() {
None => Status::Disabled, None => Status::Disabled,
@ -145,10 +141,11 @@ pub fn status() -> Status {
}) })
} }
/// Without the repaint an idle window would leave the flag unread. /// Hand the press to [`crate::activate`], which owns the pending flag and
/// the repaint. One place to consume, whether the press came from our own
/// registration or from a `--toggle` the desktop launched.
fn fire(ctx: &egui::Context) { fn fire(ctx: &egui::Context) {
FIRED.store(true, Ordering::SeqCst); crate::activate::fire(ctx);
ctx.request_repaint();
} }
impl Backend { impl Backend {
@ -225,14 +222,7 @@ mod tests {
fn an_uninitialised_registry_is_inert() { fn an_uninitialised_registry_is_inert() {
apply("Ctrl+Shift+F"); apply("Ctrl+Shift+F");
assert_eq!(status(), Status::Disabled); assert_eq!(status(), Status::Disabled);
assert!(!take_fired()); assert!(!crate::activate::take_pending());
}
#[test]
fn a_press_is_reported_once() {
FIRED.store(true, Ordering::SeqCst);
assert!(take_fired());
assert!(!take_fired(), "the flag is consumed");
} }
/// `Idle` must accept every call: `apply` runs on every config save. /// `Idle` must accept every call: `apply` runs on every config save.

View file

@ -1,72 +0,0 @@
//! Bringing the window to the front when the shortcut fires. Every desktop
//! stops applications raising themselves, so the request has to say *why*.
//!
//! * **Windows**: `SetForegroundWindow` is refused to background processes,
//! except for one whose registered hotkey was just pressed — so winit's
//! commands work, as long as they happen straight away.
//! * **X11**: winit asks with `_NET_ACTIVE_WINDOW` source indication 1
//! ("application"), which KWin, Mutter and Xfwm all refuse from an
//! unfocused window, and its `focus_window` does nothing while minimised.
//! 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 at all, by design.
/// Bring the window to the front, restoring it if it was minimised.
///
/// On Wayland this asks and is ignored — raising needs an xdg-activation
/// token winit will not issue without its own `Window`. The rest of the
/// shortcut still works there.
pub fn raise(ctx: &egui::Context, frame: &eframe::Frame) {
#[cfg(all(unix, not(target_os = "macos")))]
if x11_activate(frame) {
return;
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
let _ = frame;
// A window still minimised cannot take focus.
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
/// Activate our window, EWMH style. `false` when this is not X11 or the
/// server would not take it, so the caller can fall back to winit.
#[cfg(all(unix, not(target_os = "macos")))]
fn x11_activate(frame: &eframe::Frame) -> bool {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use x11rb::connection::Connection;
use x11rb::protocol::xproto::{ClientMessageEvent, ConnectionExt, EventMask};
let Ok(handle) = frame.window_handle() else {
return false;
};
let RawWindowHandle::Xlib(xlib) = handle.as_raw() else {
return false;
};
let window = xlib.window as u32;
let sent = || -> Result<(), Box<dyn std::error::Error>> {
let (conn, screen) = x11rb::connect(None)?;
let root = conn.setup().roots[screen].root;
let atom = conn.intern_atom(true, b"_NET_ACTIVE_WINDOW")?.reply()?.atom;
// data: source indication, timestamp, the window losing focus.
// `CURRENT_TIME` because the shortcut arrives over D-Bus or a grab,
// not as an X event carrying one; WMs accept it from source 2.
let event = ClientMessageEvent::new(32, window, atom, [2, x11rb::CURRENT_TIME, 0, 0, 0]);
conn.send_event(
false,
root,
EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY,
event,
)?;
conn.flush()?;
Ok(())
}();
match sent {
Ok(()) => true,
Err(e) => {
quicksearch_core::log_warn!("raising the window: {}", e);
false
}
}
}

View file

@ -10,6 +10,7 @@
#[global_allocator] #[global_allocator]
static GLOBAL: quicksearch_core::platform::Allocator = quicksearch_core::platform::Allocator; static GLOBAL: quicksearch_core::platform::Allocator = quicksearch_core::platform::Allocator;
mod activate;
mod app; mod app;
mod backend; mod backend;
#[cfg(feature = "capture")] #[cfg(feature = "capture")]
@ -64,21 +65,42 @@ fn seed_query() -> Option<String> {
} }
} }
/// What the desktop's search shortcut runs. Not a query flag: it is checked
/// before terminal mode, because `--toggle` is about the window.
const TOGGLE_FLAG: &str = "--toggle";
fn wants_toggle() -> bool {
std::env::args().skip(1).any(|a| a == TOGGLE_FLAG)
}
fn main() { fn main() {
// First: printing without a stdio handle panics rather than failing quietly. // First: printing without a stdio handle panics rather than failing quietly.
#[cfg(windows)] #[cfg(windows)]
platform::redirect_null_stdio(); platform::redirect_null_stdio();
let toggle = wants_toggle();
// Before terminal mode, which would read `--toggle` as an unknown flag.
#[cfg(not(windows))] #[cfg(not(windows))]
if !toggle {
if let Some(code) = cli::maybe_run_cli() { if let Some(code) = cli::maybe_run_cli() {
std::process::exit(code); std::process::exit(code);
} }
}
// A broken config must never keep the window from opening. // A broken config must never keep the window from opening.
let (config, config_error) = match Config::load() { let (config, config_error) = match Config::load() {
Ok(c) => (c, None), Ok(c) => (c, None),
Err(e) => (Config::default(), Some(e)), Err(e) => (Config::default(), Some(e)),
}; };
// The desktop's shortcut, on an app that is already up: hand the
// activation over and get out of the way. Failure is the normal case —
// nothing is running — and falls through to starting the GUI, which is
// what makes one binding both "raise it" and "launch it".
if toggle && activate::signal(&Config::config_path()) {
return;
}
// Before any search connection exists: the ceiling is applied at open, and // Before any search connection exists: the ceiling is applied at open, and
// `0` leaves it derived from the index. // `0` leaves it derived from the index.
quicksearch_core::db::set_search_cache_override( quicksearch_core::db::set_search_cache_override(
@ -93,6 +115,12 @@ fn main() {
match IndexLock::hold(&config.resolved_database_path()) { match IndexLock::hold(&config.resolved_database_path()) {
Ok(()) => {} Ok(()) => {}
Err(LockError::Held { pid }) => { Err(LockError::Held { pid }) => {
// Losing the race to an instance that came up between the signal
// above and here, or a plain second launch: either way the user
// asked to see QuickSearch, and there is one to show them.
if activate::signal(&Config::config_path()) {
return;
}
let who = match pid { let who = match pid {
Some(pid) => format!(" (process {})", pid), Some(pid) => format!(" (process {})", pid),
None => String::new(), None => String::new(),
@ -146,12 +174,21 @@ fn main() {
// egui has no bundled fonts; this closure is the last place // egui has no bundled fonts; this closure is the last place
// still ahead of frame 1. // still ahead of frame 1.
fonts::install(&cc.egui_ctx); fonts::install(&cc.egui_ctx);
// Only once the index lock is held, which `main` has by now:
// binding the socket unlinks whatever is in the way, and the
// lock is what proves nobody else is listening on it. Before the
// gate, so the shortcut works while the unlock screen is up.
activate::listen(&cc.egui_ctx, &Config::config_path());
// Must run on the event-loop thread with the loop running — this // Must run on the event-loop thread with the loop running — this
// closure is the first place that is true. Before the gate, so // closure is the first place that is true. Before the gate, so
// the shortcut works while the unlock screen is up. // the shortcut works while the unlock screen is up.
hotkey::init(&cc.egui_ctx, &config.ui.search_hotkey); hotkey::init(&cc.egui_ctx, &config.ui.search_hotkey);
// Before the gate so the unlock screen honors the setting. // Before the gate so the unlock screen honors the setting.
app::apply_theme(&cc.egui_ctx, &config.ui.color_scheme); app::apply_theme(&cc.egui_ctx, &config.ui.color_scheme);
// Both themes at once, so this survives a scheme switch — and
// before the gate, since the unlock screen is drawn without the
// app ever being built.
color::apply_text_contrast(&cc.egui_ctx);
let gate = match key_source { let gate = match key_source {
Some(source) => { Some(source) => {
unlock::Gate::running(&cc.egui_ctx, config, config_error, initial_query, source) unlock::Gate::running(&cc.egui_ctx, config, config_error, initial_query, source)

View file

@ -241,10 +241,17 @@ impl ManageTab {
// Safe because the Remove click surrenders focus before the // Safe because the Remove click surrenders focus before the
// shift — pinned by tests::removing_a_root_does_not_leak_an_edit_onto_another_row. // shift — pinned by tests::removing_a_root_does_not_leak_an_edit_onto_another_row.
for (i, root) in paths.indexing_paths.iter().enumerate() { for (i, root) in paths.indexing_paths.iter().enumerate() {
// One rule per row, plus the closing one below the loop:
// the Remove button then sits inside a bounded band with
// the folder it belongs to, even with a single folder
// listed. Drawn for every row alike, so the positional
// widget ids below shift by a constant.
ui.separator();
ui.horizontal(|ui| { ui.horizontal(|ui| {
// Controls claim the right edge first. // Controls claim the right edge first.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let remove_btn = ui.small_button("Remove").tip(&tips::REMOVE_ROOT); let remove_btn =
ui.small_button("Remove Folder").tip(&tips::REMOVE_ROOT);
#[cfg(test)] #[cfg(test)]
tests::record_widget("remove", &remove_btn); tests::record_widget("remove", &remove_btn);
if remove_btn.clicked() { if remove_btn.clicked() {
@ -304,6 +311,9 @@ impl ManageTab {
}); });
}); });
} }
// Closes the last row's band; the add controls below belong to
// no folder and must not read as another row.
ui.separator();
if let Some(i) = remove { if let Some(i) = remove {
let removed = draft.paths.indexing_paths.remove(i); let removed = draft.paths.indexing_paths.remove(i);
draft.indexing.root_workers.remove(&removed); draft.indexing.root_workers.remove(&removed);
@ -342,33 +352,35 @@ impl ManageTab {
} }
}); });
ui.label(hint( ui.label(hint(
"Removing a folder removes its entries and leaves the rest of \ "Adding a folder does not replace the current index, it adds to it.",
the index untouched; adding one reindexes to pick it up. \ ));
Neither rebuilds.", ui.label(hint(
"Removing a folder does not affect the whole index, only that \
folder's entries.",
));
ui.label(hint(
"Neither action causes the index to be rebuilt from scratch.",
)); ));
ui.separator(); ui.separator();
// --- Filters --------------------------------------------------- // --- Filters ---------------------------------------------------
ui.heading(egui::RichText::new("Content filters").strong()); ui.heading(egui::RichText::new("Content filters").strong());
// A box scrolled out of this tab's scroll area is still laid
// out, so its rect is somewhere off the panel: marking it
// would ring whatever now sits at those coordinates.
let mark_visible = |ui: &egui::Ui, spot, rect: egui::Rect| {
if ui.is_rect_visible(rect) {
crate::spotlight::mark(ui.ctx(), spot, rect);
}
};
ui.columns(2, |cols| { ui.columns(2, |cols| {
cols[0] cols[0]
.label("Full-text extensions whitelist (empty = all supported):") .label("Ignore patterns (files and folders never indexed):")
.tip(&tips::EXT_WHITELIST);
cols[0]
.add(
egui::TextEdit::multiline(&mut self.ext_filter_text)
.desired_rows(4)
.desired_width(f32::INFINITY)
.hint_text("txt\nmd\npdf # comments allowed\n(none)"),
)
.tip(&tips::EXT_WHITELIST);
cols[1]
.label("Ignore patterns (excluded entirely):")
.tip(&tips::IGNORE_PATTERNS); .tip(&tips::IGNORE_PATTERNS);
let mut remove_pat: Option<usize> = None; let mut remove_pat: Option<usize> = None;
// The list grows and shrinks: keep it off the id of the // The list grows and shrinks: keep it off the id of the
// editor below it (see `ui_util::stable_section`). // editor below it (see `ui_util::stable_section`).
crate::ui_util::stable_section(&mut cols[1], |ui| { crate::ui_util::stable_section(&mut cols[0], |ui| {
for (i, pat) in draft.indexing.ignore_patterns.iter().enumerate() { for (i, pat) in draft.indexing.ignore_patterns.iter().enumerate() {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.with_layout( ui.with_layout(
@ -394,7 +406,7 @@ impl ManageTab {
if let Some(i) = remove_pat { if let Some(i) = remove_pat {
draft.indexing.ignore_patterns.remove(i); draft.indexing.ignore_patterns.remove(i);
} }
cols[1].horizontal(|ui| { cols[0].horizontal(|ui| {
let (response, valid) = crate::ui_util::pattern_edit( let (response, valid) = crate::ui_util::pattern_edit(
ui, ui,
&mut self.new_ignore, &mut self.new_ignore,
@ -403,6 +415,9 @@ impl ManageTab {
); );
let submitted = let submitted =
response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
mark_visible(ui, crate::spotlight::Spot::IgnorePatterns, response.rect);
#[cfg(test)]
tests::record_widget("ignore-entry", &response);
response.tip(&tips::IGNORE_PATTERNS); response.tip(&tips::IGNORE_PATTERNS);
if ui if ui
.add_enabled(valid, egui::Button::new("Add")) .add_enabled(valid, egui::Button::new("Add"))
@ -417,12 +432,29 @@ impl ManageTab {
self.new_ignore.clear(); self.new_ignore.clear();
} }
}); });
crate::ui_util::pattern_hint_label(&mut cols[1], &self.new_ignore); crate::ui_util::pattern_hint_label(&mut cols[0], &self.new_ignore);
cols[1].label(hint( cols[0].label(hint(
"Changes apply on Apply & Save. A new pattern removes the \ "Changes apply on Apply & Save. A new pattern removes the \
entries it matches; removing one reindexes to bring them \ entries it matches; deleting a pattern from this list \
back.", reindexes to bring those files back.",
)); ));
cols[1]
.label("Full-text extensions whitelist (empty = read all supported types):")
.tip(&tips::EXT_WHITELIST);
let ext = cols[1]
.add(
egui::TextEdit::multiline(&mut self.ext_filter_text)
.desired_rows(4)
.desired_width(f32::INFINITY)
.hint_text("#EXAMPLE WHITELISTED FILE EXTENSIONS FOR FULL-TEXT-SEARCH:\n#MOUSE \
OVER FOR MORE INFO\n#------------------------------------------------\ntxt\nmd\n \
pdf # comments allowed\n(none)"),
)
.tip(&tips::EXT_WHITELIST);
mark_visible(&cols[1], crate::spotlight::Spot::ExtWhitelist, ext.rect);
#[cfg(test)]
tests::record_widget("ext-filter", &ext);
}); });
ui.separator(); ui.separator();
@ -597,7 +629,7 @@ fn watch_contents(ui: &mut egui::Ui, state: &IndexerState, config: &Config) {
WatcherStatus::Active { dirs } => { WatcherStatus::Active { dirs } => {
ui.label( ui.label(
egui::RichText::new(format!( egui::RichText::new(format!(
"Live updates on, watching {} folders", "Live index updates, watching {} folders",
group_thousands(*dirs as u64) group_thousands(*dirs as u64)
)) ))
.small() .small()
@ -608,7 +640,7 @@ fn watch_contents(ui: &mut egui::Ui, state: &IndexerState, config: &Config) {
ui.colored_label( ui.colored_label(
ui.visuals().warn_fg_color, ui.visuals().warn_fg_color,
format!( format!(
"Live updates off; reindexing every {}", "Periodic index updates; updating every {}",
fmt_interval(config.indexing.reindex_interval_minutes) fmt_interval(config.indexing.reindex_interval_minutes)
), ),
) )
@ -836,8 +868,14 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress, maintenance: Option<Maintenance
// than one in flight. That moment of staleness is worth less than the row // than one in flight. That moment of staleness is worth less than the row
// keeping its height: with a checkpoint every few seconds, a line // keeping its height: with a checkpoint every few seconds, a line
// vanishing and returning per root reflowed the whole block on a loop. // vanishing and returning per root reflowed the whole block on a loop.
// Elided to the panel rather than to a character count: a full path over
// the width would wrap, and a hint that is one line or two is the very
// height change this row is drawn unconditionally to avoid. `Small` is the
// font `hint` paints in, and the budget has to be measured in that one.
if let Some(f) = &r.current_file { if let Some(f) = &r.current_file {
ui.label(hint(middle_truncate(f, 90))); let font_id = egui::TextStyle::Small.resolve(ui.style());
let shown = middle_elide(ui, f, ui.available_width(), &font_id);
ui.label(hint(shown.as_ref()));
} }
} }

View file

@ -521,6 +521,67 @@ fn an_upkeep_step_names_itself_once_and_keeps_the_file_hints() {
); );
} }
/// The hint carries a whole path, which can outrun any window. It is elided to
/// the panel rather than to a character count, so it stays one line: a hint
/// that wrapped when the path got long would change the block's height as
/// files came and went, the same reflow the line above is drawn to avoid.
#[test]
fn an_overlong_file_hint_loses_its_middle_rather_than_wrapping() {
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
const FILE: &str = "deeply-buried-report.pdf";
let mut hint_shape = |file: &str| {
let state = state_with(vec![RootProgress {
current_file: Some(file.to_string()),
..root_progress(RootPhase::Extracting, 100, Some(1000))
}]);
WIDGETS.with(|w| w.borrow_mut().clear());
let out = ctx.run(raw_input(vec![]), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
tab.ui(ui, &state, &cfg_with_root());
});
});
crate::test_ui::painted(&out)
.into_iter()
.find(|(t, _)| t.ends_with(FILE))
.unwrap_or_else(|| panic!("no file hint drawn for {}", file))
};
let short = format!("/data/{}", FILE);
let (drawn_short, short_rect) = hint_shape(&short);
assert_eq!(drawn_short, short, "a path that fits is drawn whole");
let long = format!(
"/data/{}/{}",
["a-rather-long-directory-name"; 12].join("/"),
FILE
);
let (drawn_long, long_rect) = hint_shape(&long);
assert!(
drawn_long.contains('…'),
"a path far past the panel was not elided: {}",
drawn_long
);
assert!(
drawn_long.starts_with("/data/"),
"the elide took the head rather than the middle: {}",
drawn_long
);
assert!(
long_rect.width() <= 1000.0,
"the hint is {} wide in a 1000 px viewport: {}",
long_rect.width(),
drawn_long
);
assert_eq!(
long_rect.height(),
short_rect.height(),
"the hint wrapped to a second line: {}",
drawn_long
);
}
#[test] #[test]
fn a_walking_root_without_a_count_shows_no_denominator() { fn a_walking_root_without_a_count_shows_no_denominator() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
@ -1191,3 +1252,47 @@ fn the_tour_can_find_every_way_to_add_a_folder() {
"the mark covers {marked:?}, which is more than one row" "the mark covers {marked:?}, which is more than one row"
); );
} }
/// The tour's indexing page names both content filters and glows them. Each
/// glow has to land on the box itself — not its label, not the list of
/// patterns above it, and not nothing at all.
#[test]
fn the_tour_glows_both_content_filter_boxes() {
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let cfg = cfg_with_root();
let state = idle_state();
WIDGETS.with(|w| w.borrow_mut().clear());
let mut marks = (None, None);
let _ = ctx.run(raw_input(vec![]), |ctx| {
crate::spotlight::set_active(ctx, true);
egui::CentralPanel::default().show(ctx, |ui| {
tab.ui(ui, &state, &cfg);
});
// Inside the pass: a mark is only live for the pass that wrote it.
marks = (
crate::spotlight::rect(ctx, crate::spotlight::Spot::IgnorePatterns),
crate::spotlight::rect(ctx, crate::spotlight::Spot::ExtWhitelist),
);
});
let ignore = widget("ignore-entry").1;
let whitelist = widget("ext-filter").1;
assert_eq!(
marks.0,
Some(ignore),
"the ignore-pattern box was not the thing glowed"
);
assert_eq!(
marks.1,
Some(whitelist),
"the extensions box was not the thing glowed"
);
// Reversed columns: ignore patterns first, the whitelist beside it.
assert!(
ignore.right() < whitelist.left(),
"the ignore box at {ignore:?} is not left of the whitelist at {whitelist:?}"
);
}

View file

@ -103,6 +103,39 @@ fn uri_escape_path(path: &str) -> String {
out out
} }
#[cfg(test)]
mod keyboard_settings_tests {
/// The first entry that is recognised wins, so a desktop that lists
/// itself ahead of a generic fallback gets its own settings application.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn the_most_specific_desktop_wins() {
let (program, args, _) = super::keyboard_settings_for("KDE").expect("KDE is known");
assert_eq!(program, "systemsettings");
assert_eq!(args, ["kcm_keys"]);
let (program, _, _) =
super::keyboard_settings_for("ubuntu:GNOME").expect("GNOME after a vendor prefix");
assert_eq!(program, "gnome-control-center");
}
/// The spec does not pin the case and desktops disagree.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn the_match_ignores_case() {
assert!(super::keyboard_settings_for("kde").is_some());
assert!(super::keyboard_settings_for("Kde").is_some());
}
/// An unknown or absent desktop offers no button rather than a broken one.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn an_unknown_desktop_offers_nothing() {
assert!(super::keyboard_settings_for("").is_none());
assert!(super::keyboard_settings_for("i3:sway").is_none());
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
@ -116,3 +149,86 @@ mod tests {
); );
} }
} }
/// The desktop's keyboard-shortcut settings, when this is a desktop whose
/// settings application we know how to name.
///
/// A pair with [`open_keyboard_settings`]: the Settings tab only offers the
/// button when there is something to open, rather than showing one that may
/// do nothing.
#[cfg(all(unix, not(target_os = "macos")))]
fn keyboard_settings_command() -> Option<(&'static str, &'static [&'static str], &'static str)> {
keyboard_settings_for(&std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default())
}
/// The mapping itself, split from the environment so it can be tested
/// without one. `desktops` is `XDG_CURRENT_DESKTOP`: a colon-separated list,
/// most specific first, and matched case-insensitively because the spec does
/// not pin the case and desktops disagree in practice.
#[cfg(all(unix, not(target_os = "macos")))]
fn keyboard_settings_for(
desktops: &str,
) -> Option<(&'static str, &'static [&'static str], &'static str)> {
for desktop in desktops.split(':') {
let found = match desktop.to_ascii_uppercase().as_str() {
"KDE" => Some((
"systemsettings",
&["kcm_keys"][..],
"Open Shortcuts settings",
)),
"GNOME" | "UNITY" => Some((
"gnome-control-center",
&["keyboard"][..],
"Open Keyboard settings",
)),
"XFCE" => Some(("xfce4-keyboard-settings", &[][..], "Open Keyboard settings")),
"CINNAMON" => Some((
"cinnamon-settings",
&["keyboard"][..],
"Open Keyboard settings",
)),
_ => None,
};
if found.is_some() {
return found;
}
}
None
}
/// The button label for [`open_keyboard_settings`], or `None` when this
/// desktop has no settings application we can name.
pub fn keyboard_settings_label() -> Option<&'static str> {
#[cfg(all(unix, not(target_os = "macos")))]
{
// Naming it is not enough; it also has to be installed, or the button
// would promise something that silently fails.
let (program, _, label) = keyboard_settings_command()?;
which(program).then_some(label)
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
None
}
}
/// Whether `program` is on PATH. `Command::spawn` would tell us, but only by
/// running it, and this decides whether to offer the button at all.
#[cfg(all(unix, not(target_os = "macos")))]
fn which(program: &str) -> bool {
let Some(path) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path).any(|dir| dir.join(program).is_file())
}
/// Open the desktop's keyboard settings, detached, so the user can bind
/// `quicksearch --toggle` without hunting for the page.
pub fn open_keyboard_settings() {
#[cfg(all(unix, not(target_os = "macos")))]
if let Some((program, args, _)) = keyboard_settings_command() {
if let Err(e) = Command::new(program).args(args).spawn() {
quicksearch_core::log_warn!("opening {}: {}", program, e);
}
}
}

View file

@ -21,8 +21,7 @@ mod snippet_render;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
use crate::ui_util::hint; use crate::ui_util::{dir_ignore_pattern, hint};
use ignore_dialog::dir_ignore_pattern;
pub use ignore_dialog::IgnoreDialog; pub use ignore_dialog::IgnoreDialog;
use snippet_render::{centered_match_job, marked_field_job, path_cell_job, snippet_job}; use snippet_render::{centered_match_job, marked_field_job, path_cell_job, snippet_job};

View file

@ -11,14 +11,6 @@ pub struct IgnoreDialog {
pub persist: bool, pub persist: bool,
} }
/// Glob ignoring everything under `dir`, spelled with the platform
/// separator. `Path::join` inserts a separator only where one is needed, so
/// a drive root yields `C:\*` rather than the never-matching `C:\/*` a
/// `format!("{}/*")` would produce.
pub(super) fn dir_ignore_pattern(dir: &std::path::Path) -> String {
dir.join("*").to_string_lossy().into_owned()
}
impl SearchTab { impl SearchTab {
pub(super) fn ignore_dialog_ui(&mut self, ctx: &egui::Context, actions: &mut SearchActions) { pub(super) fn ignore_dialog_ui(&mut self, ctx: &egui::Context, actions: &mut SearchActions) {
use crate::ui_util::{bordered_button, pattern_edit}; use crate::ui_util::{bordered_button, pattern_edit};

View file

@ -261,10 +261,6 @@ impl SettingsTab {
)); ));
ui.separator(); ui.separator();
ui.heading(egui::RichText::new("Processing").strong());
config_editor_ui(ui, draft, Section::Processing, indexed_files, form);
ui.separator();
ui.heading(egui::RichText::new("Search").strong()); ui.heading(egui::RichText::new("Search").strong());
config_editor_ui(ui, draft, Section::Search, indexed_files, form); config_editor_ui(ui, draft, Section::Search, indexed_files, form);
ui.add_space(6.0); ui.add_space(6.0);
@ -276,7 +272,7 @@ impl SettingsTab {
egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| {
form.row(Level::Everyday, ui, "UI scale", &tips::UI_SCALE, |ui| { form.row(Level::Everyday, ui, "UI scale", &tips::UI_SCALE, |ui| {
ui.add( ui.add(
egui::Slider::new(&mut draft.ui.scale, 0.5..=2.5) egui::Slider::new(&mut draft.ui.scale, crate::app::SCALE_RANGE)
.step_by(0.05) .step_by(0.05)
.fixed_decimals(2), .fixed_decimals(2),
) )
@ -297,6 +293,7 @@ impl SettingsTab {
); );
}); });
hotkey_note(ui, &draft.ui.search_hotkey, &current.ui.search_hotkey); hotkey_note(ui, &draft.ui.search_hotkey, &current.ui.search_hotkey);
shortcut_note(ui);
ui.separator(); ui.separator();
// Security acts on the live config, not the draft; the KDF // Security acts on the live config, not the draft; the KDF
@ -305,6 +302,10 @@ impl SettingsTab {
out.security = security_ui(ui, current, keychain_active, form); out.security = security_ui(ui, current, keychain_active, form);
ui.separator(); ui.separator();
ui.heading(egui::RichText::new("Processing").strong());
config_editor_ui(ui, draft, Section::Processing, indexed_files, form);
ui.separator();
let p = crate::color::palette(ui.visuals().dark_mode); let p = crate::color::palette(ui.visuals().dark_mode);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let apply = ui let apply = ui
@ -367,7 +368,13 @@ fn color_scheme_edit(ui: &mut egui::Ui, setting: &mut String) -> egui::Response
/// A button showing the current binding that turns into a key-press reader /// A button showing the current binding that turns into a key-press reader
/// when clicked, and a Clear beside it. /// when clicked, and a Clear beside it.
fn hotkey_edit(ui: &mut egui::Ui, setting: &mut String, capturing: &mut bool) -> egui::Response { /// The shortcut button: click it, press a combination, or Clear. Shared with
/// the tour's shortcut page, which offers the same setting.
pub(crate) fn hotkey_edit(
ui: &mut egui::Ui,
setting: &mut String,
capturing: &mut bool,
) -> egui::Response {
let p = crate::color::palette(ui.visuals().dark_mode); let p = crate::color::palette(ui.visuals().dark_mode);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let label = if *capturing { let label = if *capturing {
@ -477,6 +484,55 @@ fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) {
}); });
} }
/// How to get a shortcut that also *starts* QuickSearch.
///
/// The shortcut above is ours and needs no setup, but it cannot fire while
/// QuickSearch is not running — see `crate::activate`. Only the desktop can
/// bind a key that launches something, so this says what to bind and opens
/// the place to bind it. Writing the desktop's own configuration instead was
/// considered and rejected: it differs per desktop and between versions of
/// the same one, and a shortcut we wrote and the user cannot see is worse
/// than one they created.
///
/// Shared with the tour's shortcut page, which puts it under the same
/// shortcut button this sentence says is "above".
pub(crate) fn shortcut_note(ui: &mut egui::Ui) {
let command = format!("{} --toggle", crate::activate::command_name());
crate::ui_util::stable_section(ui, |ui| {
ui.label(
egui::RichText::new(
"The shortcut above works while QuickSearch is open. To have a key \
start it as well, bind this command in your desktop's keyboard \
settings:",
)
.small()
.weak(),
);
ui.horizontal_wrapped(|ui| {
ui.label(egui::RichText::new(&command).small().monospace());
if ui.add(egui::Button::new("Copy").small()).clicked() {
ui.ctx().copy_text(command.clone());
}
if let Some(label) = crate::platform::keyboard_settings_label() {
if ui.add(egui::Button::new(label).small()).clicked() {
crate::platform::open_keyboard_settings();
}
}
});
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.",
)
.small()
.weak(),
);
}
});
}
/// The Search-tab column picker, mirroring the header right-click menu. /// The Search-tab column picker, mirroring the header right-click menu.
/// Acts on the **live** config: the header menu writes columns the instant /// Acts on the **live** config: the header menu writes columns the instant
/// they change, and a draft-backed copy here would silently revert that on /// they change, and a draft-backed copy here would silently revert that on

View file

@ -361,8 +361,9 @@ fn every_row_shows_its_own_tip() {
let mut run = |events: Vec<egui::Event>| { let mut run = |events: Vec<egui::Event>| {
let input = crate::test_ui::raw_input(egui::vec2(600.0, 800.0), events); let input = crate::test_ui::raw_input(egui::vec2(600.0, 800.0), events);
ctx.run(input, |ctx| { ctx.run(input, |ctx| {
egui::CentralPanel::default() egui::CentralPanel::default().show(ctx, |ui| {
.show(ctx, |ui| config_editor_ui(ui, &mut cfg, *section, None, form)); config_editor_ui(ui, &mut cfg, *section, None, form)
});
}) })
}; };
@ -727,3 +728,20 @@ fn showing_advanced_settings_is_not_an_unsaved_edit() {
"applying the stale draft hid the advanced settings again" "applying the stale draft hid the advanced settings again"
); );
} }
/// The panel that tells a user how to get a shortcut that also starts
/// QuickSearch has to actually show the command they must bind.
#[test]
fn the_shortcut_note_names_the_command_to_bind() {
let ctx = crate::test_ui::ctx();
let input = crate::test_ui::raw_input(egui::vec2(700.0, 300.0), vec![]);
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| super::shortcut_note(ui));
});
let painted = painted_text(&out).join("\n");
assert!(
painted.contains("--toggle"),
"the command to bind was not shown: {painted}"
);
assert!(painted.contains("Copy"), "no way to copy it: {painted}");
}

View file

@ -20,6 +20,10 @@ use crate::app::Tab;
pub enum Spot { pub enum Spot {
/// The *Add folder… / path box / Add* row of Manage Index's folder list. /// The *Add folder… / path box / Add* row of Manage Index's folder list.
IndexedFolderAdd, IndexedFolderAdd,
/// The box new ignore patterns are typed into, under Content filters.
IgnorePatterns,
/// The full-text extensions box beside it.
ExtWhitelist,
/// The query box on the Search tab. /// The query box on the Search tab.
SearchBar, SearchBar,
/// The `?` button that opens the query-syntax window. /// The `?` button that opens the query-syntax window.

View file

@ -40,6 +40,8 @@ pub fn click_at(pos: egui::Pos2) -> Vec<egui::Event> {
pub fn ctx() -> egui::Context { pub fn ctx() -> egui::Context {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
crate::fonts::install(&ctx); crate::fonts::install(&ctx);
// The shipped text greys, so what a test measures is what the app paints.
crate::color::apply_text_contrast(&ctx);
ctx ctx
} }
@ -137,6 +139,19 @@ pub fn painted_spans(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> {
.collect() .collect()
} }
/// Each galley with the font size its first run was laid out at — how big
/// text actually came out, which the style alone cannot say once a widget
/// has overridden it.
pub fn painted_sizes(out: &egui::FullOutput) -> Vec<(String, f32)> {
painted_galleys(out)
.into_iter()
.filter_map(|(g, _)| {
let size = g.job.sections.first()?.format.font_id.size;
Some((g.text().to_string(), size))
})
.collect()
}
/// Runs with a background behind them — the distinguishing mark of a match: /// Runs with a background behind them — the distinguishing mark of a match:
/// headers use the same *text* color, so [`painted_spans`] cannot tell them apart. /// headers use the same *text* color, so [`painted_spans`] cannot tell them apart.
pub fn painted_backgrounds(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> { pub fn painted_backgrounds(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> {

View file

@ -421,16 +421,15 @@ pub static SEARCH_HOTKEY: Tip = Tip {
start typing.\n\n\ start typing.\n\n\
Click the button and press the keys you want. Combine Ctrl, Alt \ Click the button and press the keys you want. Combine Ctrl, Alt \
and Shift with one other key. Clear switches the shortcut off.\n\n\ and Shift with one other key. Clear switches the shortcut off.\n\n\
On Wayland the shortcut is registered with your desktop rather \ This works whenever QuickSearch is running and needs no setting \
than claimed directly, so your desktop may assign a different key \ up, but it cannot start QuickSearch. For a shortcut that opens it \
or ask you to confirm it, and its own keyboard settings are where \ too, bind the command shown below in your desktop's own keyboard \
to change it afterwards. Wayland also does not let any application \ settings.\n\n\
put itself in front of what you are doing, so there the shortcut \ On Wayland your desktop registers the shortcut, so it may pick a \
selects the Search tab and the search box, but bringing the window \ different key and owns it afterwards, and it decides whether the \
forward is up to your desktop.", window comes forward.",
examples: &[ examples: &[
"Ctrl+Shift+F, the default, which few other programs use.", "Ctrl+Shift+F, the default, which few other programs use."
"Ctrl+Alt+Space if something else on your system already answers to it.",
], ],
caution: None, caution: None,
}; };
@ -567,18 +566,22 @@ pub static ADD_ROOT: Tip = Tip {
body: "Adds a folder for QuickSearch to index, along with everything \ body: "Adds a folder for QuickSearch to index, along with everything \
inside it. Choose it with the browser, or type the path and press \ inside it. Choose it with the browser, or type the path and press \
Add.\n\n\ Add.\n\n\
Adding a folder does not replace the current index, it adds to it: \
an indexing pass picks up the new folder and the rest of the index \
is left alone. Nothing is rebuilt.\n\n\
Indexed folders may not overlap, so a folder already inside \ Indexed folders may not overlap, so a folder already inside \
another one is refused. Adding a folder starts an indexing pass to \ another one is refused.",
pick it up and leaves the rest of the index alone.",
examples: &["a second drive, or a network share you search often."], examples: &["a second drive, or a network share you search often."],
caution: None, caution: None,
}; };
pub static REMOVE_ROOT: Tip = Tip { pub static REMOVE_ROOT: Tip = Tip {
title: "Remove this folder", title: "Remove this folder",
body: "Stops indexing this folder and removes its entries from the \ body: "Stops indexing the folder named on this line, and removes that \
index. The rest of the index is left alone, and the files \ folder's entries from the index.\n\n\
themselves are not touched.\n\n\ Removing a folder does not affect the whole index, only its own \
entries; everything else stays searchable and nothing is rebuilt. \
Your files themselves are never touched.\n\n\
Takes effect when you click Apply & Save.", Takes effect when you click Apply & Save.",
examples: &[], examples: &[],
caution: None, caution: None,
@ -618,18 +621,20 @@ pub static ROOT_COUNTS: Tip = Tip {
pub static EXT_WHITELIST: Tip = Tip { pub static EXT_WHITELIST: Tip = Tip {
title: "Full-text extensions whitelist", title: "Full-text extensions whitelist",
body: "Which kinds of file QuickSearch reads the text out of, one \ body: "Which kinds of file QuickSearch is allowed to read the text out \
extension per line, the leading dot optional. Empty means every \ of. It limits contents only: every file is still indexed and still \
kind it understands.\n\n\ found by its name and its path, whatever you put here. A file left \
Every file is still indexed by name whatever you put here. A list \ off the list simply cannot be found by the words inside it.\n\n\
also leaves out files with no extension at all, such as Makefile \ Empty, the default, means every kind QuickSearch understands. To \
or README, unless you add the line (none). Anything after a # is a \ narrow it, enter one extension per line, the leading dot optional. \
comment, so a file type can be switched off without losing the \ A non-empty list also leaves out files with no extension at all, \
line.\n\n\ 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 \ Narrowing the list discards the text it now excludes; widening it \
reads those files again.", reads those files again.",
examples: &[ examples: &[
"txt, md and pdf to keep the index small and focused on documents.", "txt, md and pdf to keep the stored text small and focused on documents, \
with everything else still findable by name.",
"empty to search inside everything QuickSearch can read.", "empty to search inside everything QuickSearch can read.",
], ],
caution: None, caution: None,
@ -637,19 +642,21 @@ pub static EXT_WHITELIST: Tip = Tip {
pub static IGNORE_PATTERNS: Tip = Tip { pub static IGNORE_PATTERNS: Tip = Tip {
title: "Ignore patterns", title: "Ignore patterns",
body: "Files and folders left out of the index entirely, by name and by \ body: "Whole files and folders kept out of the index. A pattern is \
content alike. Type one pattern and click Add.\n\n\ compared against names and paths, never against what is inside a \
A pattern without a slash matches a file or folder name anywhere, \ file: nothing is excluded for the words it holds, and a file \
and must match the whole name: .jpg matches only something called \ excluded here loses its text along with its name. Type one pattern \
exactly that, while *.jpg matches every JPEG. A pattern with a \ and click Add.\n\n\
slash in it is matched against the whole path, and skips \ A pattern with no slash matches a file or folder name anywhere \
everything underneath. * stands for any run of characters and ? \ under your indexed folders, and must match that name in full. A \
for a single one.\n\n\ pattern with a slash is matched against the whole path, and skips \
Adding a pattern removes the entries it matches; removing one \ everything under it. * is any run of characters, ? exactly one.\n\n\
indexes them again.", Adding a pattern removes the entries it matches; deleting a \
pattern from this list indexes those files again. Worked examples \
are on the Help tab.",
examples: &[ examples: &[
"node_modules to skip that folder wherever it turns up.", "node_modules to skip that folder wherever it turns up.",
"*.tmp to skip temporary files by extension.", "*.log to skip every file ending in .log, whatever it is called.",
"a full path such as the Videos folder to skip it and everything inside it.", "a full path such as the Videos folder to skip it and everything inside it.",
], ],
caution: None, caution: None,

View file

@ -36,6 +36,19 @@ struct Page {
spots: &'static [Spot], spots: &'static [Spot],
/// Typed into the search box a character at a time, on entry. /// Typed into the search box a character at a time, on entry.
type_query: Option<&'static str>, type_query: Option<&'static str>,
/// A control of the page's own, under its prose.
extra: Option<Extra>,
}
/// What a page offers in the tour's own window. Most pages point at a widget
/// in the app instead; these two are setup the user can do from here.
#[derive(Debug, Clone, Copy, PartialEq)]
enum Extra {
/// The zoom slider, on the welcome page.
Scale,
/// The command to bind to a key, with Copy and a way to the desktop's
/// keyboard settings.
Shortcut,
} }
/// Field defaults for the pages that do not point anywhere. /// Field defaults for the pages that do not point anywhere.
@ -47,15 +60,15 @@ const PLAIN: Page = Page {
tab: None, tab: None,
spots: &[], spots: &[],
type_query: None, type_query: None,
extra: None,
}; };
const PAGES: &[Page] = &[ const PAGES: &[Page] = &[
Page { Page {
title: "Welcome to QuickSearch", title: "Welcome to QuickSearch",
body: &[ body: &[
"QuickSearch keeps an index of the folders you choose, and searches \ "QuickSearch is a search engine for your local files and their contents.",
as you type.", "By default, only your user folder is indexed and searchable.",
"By default only your user folder is indexed and searchable.",
"Because the answers come from the index rather than from reading \ "Because the answers come from the index rather than from reading \
your disk, results appear as fast as you can type, even across \ your disk, results appear as fast as you can type, even across \
hundreds of thousands of files.", hundreds of thousands of files.",
@ -65,6 +78,24 @@ const PAGES: &[Page] = &[
if it covers something. Hit Skip to exit.", if it covers something. Hit Skip to exit.",
), ),
tab: Some(Tab::Search), tab: Some(Tab::Search),
extra: Some(Extra::Scale),
..PLAIN
},
Page {
title: "Open QuickSearch hotkey",
body: &[
"Would you like a keyboard shortcut that brings QuickSearch up \
from anywhere? Click the button below and press the keys you \
want it takes effect at once.",
"QuickSearch does not sit in the background waiting for that key, \
though. When you close it, it closes completely and gives back \
the memory it was using; it starts again, index and all, in a \
moment. So to have a key start it as well, bind the command \
underneath in your desktop's own keyboard settings.",
],
pointer: Some("Both of these are on the Settings tab too, under Interface."),
tab: Some(Tab::Search),
extra: Some(Extra::Shortcut),
..PLAIN ..PLAIN
}, },
Page { Page {
@ -85,11 +116,16 @@ const PAGES: &[Page] = &[
remembering what it found, so that searching later is instant. It \ remembering what it found, so that searching later is instant. It \
runs on its own in the background and keeps up with changes as you \ runs on its own in the background and keeps up with changes as you \
make them.", make them.",
"You decide what gets read: [ignore patterns] keep files and folders \
out of the index entirely, while the [extensions whitelist] limits \
which file types have their contents read. Both are under Content \
filters on this tab.",
"QuickSearch never connects to the internet, and always respects your privacy. \ "QuickSearch never connects to the internet, and always respects your privacy. \
QuickSearch can encrypt your index to make this remembered data more secure.", QuickSearch can encrypt your index to make this remembered data more secure.",
], ],
pointer: Some("To set an index password, look near the bottom of the Settings tab."), pointer: Some("To set an index password, look near the bottom of the Settings tab."),
tab: Some(Tab::Manage), tab: Some(Tab::Manage),
spots: &[Spot::IgnorePatterns, Spot::ExtWhitelist],
..PLAIN ..PLAIN
}, },
Page { Page {
@ -139,13 +175,13 @@ const PAGES: &[Page] = &[
..PLAIN ..PLAIN
}, },
Page { Page {
title: "Typos, and what a result can do", title: "Fuzzy Finding",
body: &[ body: &[
"Tick [Fuzzy] beside the search box to also match words with typos \ "Tick [Fuzzy] beside the search box to also match words with typos \
in them \"repot\" will find \"report\". It searches more \ in them! When checked, \"repot\" will find \"report\". It searches more \
thoroughly, so it is a little slower; leave it off until you need \ thoroughly, so it is a little slower to find all results, but the exact \
it.", matches will be found just as fast and be displayed first.",
"Right-click any result for more: open it, open the folder holding \ "Right-click any result for more options: open it, open the folder holding \
it, copy its path, or build a filter that hides files like it from \ it, copy its path, or build a filter that hides files like it from \
future searches.", future searches.",
], ],
@ -162,10 +198,16 @@ const PAGES: &[Page] = &[
"It is a quick way to find the same download sitting in three \ "It is a quick way to find the same download sitting in three \
places. QuickSearch only shows you the groups; deleting anything is \ places. QuickSearch only shows you the groups; deleting anything is \
left to you.", left to you.",
"For speed, files are matched on their size and on how they begin — the \
first few kilobytes which makes a group a strong suspicion rather \
than a certainty. Right-click any group and choose \"Verify copies are \
identical\" to read every byte before you delete anything. The tab says \
so at the top, every time.",
], ],
pointer: Some( pointer: Some(
"For speed, files are compared by size and by how they begin (first 8KB). \ "A group you never want to see again can be hidden, and a folder that is \
This is not a guarantee of an exact match. You can right click a result to verify before you delete anything.", meant to hold copies can be excluded both from that same right-click \
menu.",
), ),
tab: Some(Tab::Duplicates), tab: Some(Tab::Duplicates),
spots: &[Spot::TabButton(Tab::Duplicates)], spots: &[Spot::TabButton(Tab::Duplicates)],
@ -291,6 +333,77 @@ fn paragraph_job(ui: &egui::Ui, text: &str, next_keyword: &mut usize, pulse: f32
job job
} }
/// The tour's own note size. `hint` is egui's `Small`, which is a touch fine
/// to read in the one window a first-time user meets the app through; full
/// Body would stop a note reading as an aside.
fn note(ui: &egui::Ui, text: impl Into<egui::RichText>) -> egui::RichText {
hint(text).size(egui::TextStyle::Small.resolve(ui.style()).size + 2.0)
}
/// The live state the tour's own controls read, and the flag the shortcut
/// capture keeps between frames.
struct Live<'a> {
/// `ctx.zoom_factor()`, read before the window is laid out.
zoom: f32,
/// The shortcut in force, as the config spells it.
hotkey: &'a str,
capturing_hotkey: &'a mut bool,
}
/// The page's own control, under its prose. What they were used for goes
/// back through `actions`: the config is the app's to write, not the tour's.
fn extra_ui(ui: &mut egui::Ui, extra: Extra, live: &mut Live, actions: &mut TourActions) {
ui.add_space(6.0);
ui.separator();
match extra {
Extra::Scale => {
let mut scale = live.zoom;
ui.horizontal(|ui| {
ui.label("UI scale");
// Scoped to this row, which is the whole of its use.
ui.spacing_mut().slider_width = 220.0;
let slider = ui.add(
egui::Slider::new(&mut scale, crate::app::SCALE_RANGE)
.step_by(0.05)
.fixed_decimals(2),
);
// Applied on every frame it moves, saved once it settles: a
// drag would otherwise rewrite the config file dozens of
// times on its way across. The release frame is not itself a
// change — the value stopped moving — so it is asked about
// separately, and carries the value with it so the app has
// one thing to act on.
let moved = slider.changed();
let settled = slider.drag_stopped() || (moved && !slider.dragged());
if moved || settled {
actions.set_scale = Some(scale);
actions.save_scale = settled;
}
});
ui.label(note(
ui,
"Makes everything larger or smaller together. You can change \
it again on the Settings tab.",
));
}
// Both of the Settings tab's shortcut controls, and the same two
// widgets rather than a second pair that could drift from them: the
// button that claims a key while QuickSearch is running, and under
// it the command a desktop shortcut binds to start it.
Extra::Shortcut => {
let mut setting = live.hotkey.to_string();
ui.horizontal(|ui| {
ui.label("Search shortcut");
crate::settings_tab::hotkey_edit(ui, &mut setting, live.capturing_hotkey);
});
if setting != live.hotkey {
actions.set_hotkey = Some(setting);
}
crate::settings_tab::shortcut_note(ui);
}
}
}
/// Rings around a widget, painted into the *panel* layer: over the widget, /// Rings around a widget, painted into the *panel* layer: over the widget,
/// but under the tour's own window, which the user can drag aside rather /// but under the tour's own window, which the user can drag aside rather
/// than have the glow drawn across. /// than have the glow drawn across.
@ -340,6 +453,12 @@ pub struct TourActions {
pub goto_tab: Option<Tab>, pub goto_tab: Option<Tab>,
pub set_query: Option<String>, pub set_query: Option<String>,
pub focus_search: bool, pub focus_search: bool,
/// A new UI scale from the welcome page's slider, to apply live.
pub set_scale: Option<f32>,
/// The drag ended, so the scale above is worth writing to the config.
pub save_scale: bool,
/// A shortcut captured on the shortcut page, to register and save.
pub set_hotkey: Option<String>,
} }
pub struct Tutorial { pub struct Tutorial {
@ -351,6 +470,9 @@ pub struct Tutorial {
typing: Option<Typing>, typing: Option<Typing>,
/// The user has dragged the window, so where it sits is their business. /// The user has dragged the window, so where it sits is their business.
moved: bool, moved: bool,
/// The shortcut button is armed and the next key combination is the
/// answer — the Settings tab's own capture, and its own flag.
capturing_hotkey: bool,
} }
impl Tutorial { impl Tutorial {
@ -360,11 +482,14 @@ impl Tutorial {
shown: None, shown: None,
typing: None, typing: None,
moved: false, moved: false,
capturing_hotkey: false,
} }
} }
/// `roots` is the live indexed-folder list, which the folders page reads. /// `roots` is the live indexed-folder list, which the folders page reads;
pub fn ui(&mut self, ctx: &egui::Context, roots: &[String]) -> TourActions { /// `hotkey` is the shortcut in force, which the shortcut page both shows
/// and rebinds.
pub fn ui(&mut self, ctx: &egui::Context, roots: &[String], hotkey: &str) -> TourActions {
let page = &PAGES[self.page.min(PAGES.len() - 1)]; let page = &PAGES[self.page.min(PAGES.len() - 1)];
let (first, last) = (self.page == 0, self.page + 1 == PAGES.len()); let (first, last) = (self.page == 0, self.page + 1 == PAGES.len());
let mut actions = TourActions::default(); let mut actions = TourActions::default();
@ -393,6 +518,14 @@ impl Tutorial {
} }
let lit = pulse(now); let lit = pulse(now);
// Read before the window: `set_zoom_factor` stores the new value at
// once, so the slider reads back what it asked for on the next frame
// rather than snapping to the old value mid-drag. It also follows an
// ad-hoc Ctrl +/- zoom, which is the size the user is looking at.
let zoom = ctx.zoom_factor();
// Lifted out of `self` for the window's closure, and put back after:
// the closure already holds the page and the actions.
let mut capturing = self.capturing_hotkey;
let mut dismissed = false; let mut dismissed = false;
let mut window = egui::Window::new(page.title) let mut window = egui::Window::new(page.title)
.id(egui::Id::new(WINDOW_ID)) .id(egui::Id::new(WINDOW_ID))
@ -416,8 +549,19 @@ impl Tutorial {
ui.label(job); ui.label(job);
ui.add_space(6.0); ui.add_space(6.0);
} }
// The page's control first, then its note: a note that mentions
// what is on the page has to come after it, and it is the last
// and smallest thing before the footer either way.
if let Some(extra) = page.extra {
let mut live = Live {
zoom,
hotkey,
capturing_hotkey: &mut capturing,
};
extra_ui(ui, extra, &mut live, &mut actions);
}
if let Some(pointer) = page.pointer { if let Some(pointer) = page.pointer {
ui.label(hint(pointer)); ui.label(note(ui, pointer));
} }
ui.add_space(10.0); ui.add_space(10.0);
@ -459,6 +603,7 @@ impl Tutorial {
if shown.is_some_and(|window| window.response.dragged()) { if shown.is_some_and(|window| window.response.dragged()) {
self.moved = true; self.moved = true;
} }
self.capturing_hotkey = capturing;
// The widgets this page names, in the colours its keywords were given. // The widgets this page names, in the colours its keywords were given.
let dark_mode = ctx.style().visuals.dark_mode; let dark_mode = ctx.style().visuals.dark_mode;

View file

@ -6,6 +6,8 @@ const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0);
/// Deliberately not the real home directory: the folders page's fallback /// Deliberately not the real home directory: the folders page's fallback
/// wording is the one every other test should see, whatever machine it runs on. /// wording is the one every other test should see, whatever machine it runs on.
const ROOT: &str = "/srv/projects"; const ROOT: &str = "/srv/projects";
/// The shortcut in force, as the app would pass it in.
const HOTKEY: &str = "Ctrl+Shift+F";
fn roots() -> Vec<String> { fn roots() -> Vec<String> {
vec![ROOT.to_string()] vec![ROOT.to_string()]
@ -19,6 +21,7 @@ fn at(page: usize) -> Tutorial {
shown: Some(page), shown: Some(page),
typing: None, typing: None,
moved: false, moved: false,
capturing_hotkey: false,
} }
} }
@ -30,6 +33,7 @@ fn entering(page: usize) -> Tutorial {
shown: None, shown: None,
typing: None, typing: None,
moved: false, moved: false,
capturing_hotkey: false,
} }
} }
@ -43,7 +47,7 @@ fn pass(
let roots = roots(); let roots = roots();
let mut actions = TourActions::default(); let mut actions = TourActions::default();
let out = ctx.run(raw_input_at(SCREEN, events, time), |ctx| { let out = ctx.run(raw_input_at(SCREEN, events, time), |ctx| {
actions = tour.ui(ctx, &roots); actions = tour.ui(ctx, &roots, HOTKEY);
}); });
(out, actions) (out, actions)
} }
@ -54,6 +58,9 @@ fn merge(a: TourActions, b: TourActions) -> TourActions {
goto_tab: b.goto_tab.or(a.goto_tab), goto_tab: b.goto_tab.or(a.goto_tab),
set_query: b.set_query.or(a.set_query), set_query: b.set_query.or(a.set_query),
focus_search: a.focus_search || b.focus_search, focus_search: a.focus_search || b.focus_search,
set_scale: b.set_scale.or(a.set_scale),
save_scale: a.save_scale || b.save_scale,
set_hotkey: b.set_hotkey.or(a.set_hotkey),
} }
} }
@ -197,7 +204,7 @@ fn the_keyword_and_its_ring_pulse_in_step() {
ctx.run(raw_input_at(SCREEN, Vec::new(), time), |ctx| { ctx.run(raw_input_at(SCREEN, Vec::new(), time), |ctx| {
crate::spotlight::set_active(ctx, true); crate::spotlight::set_active(ctx, true);
crate::spotlight::mark(ctx, Spot::StatusBar, target); crate::spotlight::mark(ctx, Spot::StatusBar, target);
tour.ui(ctx, &roots); tour.ui(ctx, &roots, HOTKEY);
}) })
}; };
run(); run();
@ -261,6 +268,245 @@ fn an_unmarked_spot_is_not_ringed() {
); );
} }
/// The page carrying `extra`, which every test about one of them wants.
fn page_with(extra: Extra) -> usize {
PAGES
.iter()
.position(|p| p.extra == Some(extra))
.unwrap_or_else(|| panic!("no page carries {extra:?}"))
}
/// A press, a move and a release, one frame each — a slider only follows a
/// pointer that is already down, so a drag cannot be squeezed into one pass.
fn drag(
ctx: &egui::Context,
tour: &mut Tutorial,
from: egui::Pos2,
to: egui::Pos2,
) -> [TourActions; 3] {
let button = |pos, pressed| egui::Event::PointerButton {
pos,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::NONE,
};
let (_, press) = pass(
ctx,
tour,
vec![egui::Event::PointerMoved(from), button(from, true)],
1.0,
);
let (_, moved) = pass(ctx, tour, vec![egui::Event::PointerMoved(to)], 1.1);
let (_, release) = pass(ctx, tour, vec![button(to, false)], 1.2);
[press, moved, release]
}
/// The whole point of putting it on the first page: someone who cannot read
/// the window can fix that without finding the Settings tab first.
#[test]
fn the_welcome_page_slider_sets_the_ui_scale() {
let ctx = crate::test_ui::ctx();
let scale_page = page_with(Extra::Scale);
let mut tour = at(scale_page);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let label = painted(&out)
.into_iter()
.find(|(text, _)| text == "UI scale")
.expect("the slider is labelled")
.1;
// The rail runs to the right of its label, on the same row.
let from = egui::pos2(label.right() + 30.0, label.center().y);
let [_, moved, release] = drag(&ctx, &mut tour, from, from + egui::vec2(120.0, 0.0));
let dragged = moved
.set_scale
.expect("dragging the slider changed nothing");
assert!(
crate::app::SCALE_RANGE.contains(&dragged),
"{dragged} is outside the range the slider offers"
);
assert_ne!(dragged, 1.0, "the drag did not move the value");
assert!(!moved.save_scale, "the config was written mid-drag");
assert_eq!(
release.set_scale,
Some(dragged),
"the release did not hand the settled value over to be saved"
);
assert!(release.save_scale, "the drag ended without being saved");
// And nothing happens on a frame nobody touched it.
let (_, quiet) = pass(&ctx, &mut tour, Vec::new(), 2.0);
assert_eq!(quiet.set_scale, None);
assert!(!quiet.save_scale);
}
/// The slider shows the size the window is already at — the config's, or
/// whatever Ctrl +/- has done to it since — rather than offering a value
/// that is not the user's.
#[test]
fn the_slider_starts_at_the_current_zoom() {
let ctx = crate::test_ui::ctx();
ctx.set_zoom_factor(1.4);
let mut tour = at(page_with(Extra::Scale));
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let painted = painted_text(&out);
assert!(
painted.iter().any(|t| t == "1.40"),
"the slider does not read back a 1.4 zoom: {painted:?}"
);
}
/// The slider belongs to its page: on every other one the tour is prose and
/// a footer, and a stray control would be pointing at nothing.
#[test]
fn only_the_welcome_page_offers_the_slider() {
let ctx = crate::test_ui::ctx();
for (page, spec) in PAGES.iter().enumerate() {
if page == page_with(Extra::Scale) {
continue;
}
let mut tour = at(page);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
assert!(
!painted_text(&out).iter().any(|t| t == "UI scale"),
"page {page} ({}) also carries the scale slider",
spec.title
);
}
}
/// The command has to be the one that would actually work — a wrong one is
/// worse than none, because the key it is bound to fails silently.
#[test]
fn the_shortcut_page_offers_the_command_to_bind() {
let ctx = crate::test_ui::ctx();
let mut tour = at(page_with(Extra::Shortcut));
let (out, actions) = frame(&ctx, &mut tour, Vec::new());
let want = format!("{} --toggle", crate::activate::command_name());
assert!(
painted_text(&out).contains(&want),
"the page does not show {want:?}: {:?}",
painted_text(&out)
);
assert!(
actions.set_hotkey.is_none(),
"nothing was pressed, but the tour asked to rebind the shortcut"
);
// The command is a full path when the app is not installed — this test
// binary's own, which is as long as it gets. It has to wrap inside the
// window rather than be what decides how wide the window is.
let width = ctx
.memory(|m| m.area_rect(egui::Id::new(WINDOW_ID)))
.expect("the tour's window")
.width();
assert!(
width <= 560.0,
"the command ({} points long) stretched the window to {width}",
want.len()
);
// Copy hands over the same string, verbatim.
let copy = crate::test_ui::painted_text_center(&out, "Copy").expect("a Copy button");
let (out, _) = pass(&ctx, &mut tour, click_at(copy), 1.0);
let copied: Vec<&String> = out
.platform_output
.commands
.iter()
.filter_map(|c| match c {
egui::OutputCommand::CopyText(text) => Some(text),
_ => None,
})
.collect();
assert_eq!(copied, [&want], "Copy put something else on the clipboard");
}
/// The other half of the page: the same button the Settings tab uses to
/// claim a key while QuickSearch is running, acting the moment it is pressed
/// — the tour has no Apply to wait for.
#[test]
fn the_shortcut_page_rebinds_the_shortcut_it_shows() {
let ctx = crate::test_ui::ctx();
let mut tour = at(page_with(Extra::Shortcut));
let (out, _) = frame(&ctx, &mut tour, Vec::new());
// The button carries the shortcut in force, which is what the app passed.
let button = crate::test_ui::painted_text_center(&out, HOTKEY)
.expect("the shortcut button shows the shortcut in force");
// Armed by a click, and it says so rather than looking unchanged. The
// label is chosen before the click is known, so the next frame is the
// one that shows it.
let (_, actions) = pass(&ctx, &mut tour, click_at(button), 1.0);
assert!(tour.capturing_hotkey, "the click did not arm the capture");
assert!(
actions.set_hotkey.is_none(),
"armed, but nothing pressed yet"
);
let (out, _) = pass(&ctx, &mut tour, Vec::new(), 1.05);
assert!(
painted_text(&out)
.iter()
.any(|t| t.contains("Press a key combination")),
"nothing says the tour is waiting for keys: {:?}",
painted_text(&out)
);
// And the combination pressed next is the one handed back.
let press = vec![egui::Event::Key {
key: egui::Key::J,
physical_key: None,
pressed: true,
repeat: false,
modifiers: egui::Modifiers::CTRL | egui::Modifiers::ALT,
}];
let (_, actions) = pass(&ctx, &mut tour, press, 1.1);
assert_eq!(actions.set_hotkey.as_deref(), Some("Ctrl+Alt+J"));
assert!(
!tour.capturing_hotkey,
"the capture stayed armed after taking a shortcut"
);
// The value comes from the app, so on the next frame — with the config
// not yet updated in this test — the tour asks for nothing further.
let (_, actions) = pass(&ctx, &mut tour, Vec::new(), 1.2);
assert_eq!(actions.set_hotkey, None, "it asked twice for one press");
}
/// The page a first-time user reads is not the place for egui's smallest
/// text: the notes are 2 points up on `hint`, and this is what says so.
#[test]
fn the_notes_read_larger_than_the_page_counter() {
let ctx = crate::test_ui::ctx();
// No spots, so no keywords: the body is painted as it is written, and a
// paragraph can be looked up by the string in the table.
let page = PAGES
.iter()
.position(|p| p.pointer.is_some() && p.spots.is_empty() && !p.body.is_empty())
.expect("a page with a note and plain prose");
let mut tour = at(page);
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let sizes = crate::test_ui::painted_sizes(&out);
let counter = format!("{} of {}", page + 1, PAGES.len());
let size_of = |needle: &str| {
sizes
.iter()
.find(|(text, _)| text == needle)
.unwrap_or_else(|| panic!("{needle:?} was not painted: {sizes:?}"))
.1
};
let note = size_of(PAGES[page].pointer.expect("checked above"));
let counter = size_of(&counter);
assert!(
note > counter,
"the note is {note} against the footer's {counter}"
);
let body = size_of(PAGES[page].body[0]);
assert!(
note < body,
"the note is {note}, no smaller than the body's {body} — it stops \
reading as an aside"
);
}
#[test] #[test]
fn the_folders_page_says_which_folders_those_are() { fn the_folders_page_says_which_folders_those_are() {
// The machine's real home, which is what a default install indexes. // The machine's real home, which is what a default install indexes.
@ -549,6 +795,17 @@ impl Span {
} }
} }
/// A middle page with no control of its own: `footer_spans` clicks every x
/// across a row, and a page carrying buttons or a slider is a page where a
/// probe can hit something other than what it came for.
fn plain_middle_page() -> usize {
PAGES
.iter()
.enumerate()
.position(|(n, page)| n > 0 && n + 1 < PAGES.len() && page.extra.is_none())
.expect("a middle page without an extra")
}
/// The three footer buttons, found by what clicking each one does. Must /// The three footer buttons, found by what clicking each one does. Must
/// run on a middle page: on the last, Finish and Skip both dismiss /// run on a middle page: on the last, Finish and Skip both dismiss
/// without moving; on the first, Back is disabled. /// without moving; on the first, Back is disabled.
@ -583,7 +840,7 @@ fn footer_spans(ctx: &egui::Context, page: usize) -> [Option<Span>; 3] {
#[test] #[test]
fn the_footer_runs_back_then_skip_then_next_at_the_same_size() { fn the_footer_runs_back_then_skip_then_next_at_the_same_size() {
let ctx = crate::test_ui::ctx(); let ctx = crate::test_ui::ctx();
let [back, skip, next] = footer_spans(&ctx, 1); let [back, skip, next] = footer_spans(&ctx, plain_middle_page());
let back = back.expect("no Back button in the footer"); let back = back.expect("no Back button in the footer");
let skip = skip.expect("no Skip button in the footer"); let skip = skip.expect("no Skip button in the footer");
let next = next.expect("no Next button in the footer"); let next = next.expect("no Next button in the footer");
@ -676,7 +933,7 @@ fn the_window_centres_itself_on_the_window_it_is_in() {
let roots = roots(); let roots = roots();
let mut run = |size: egui::Vec2| { let mut run = |size: egui::Vec2| {
let _ = ctx.run(raw_input_at(size, Vec::new(), 0.0), |ctx| { let _ = ctx.run(raw_input_at(size, Vec::new(), 0.0), |ctx| {
tour.ui(ctx, &roots); tour.ui(ctx, &roots, HOTKEY);
}); });
let rect = ctx let rect = ctx
.memory(|m| m.area_rect(egui::Id::new(WINDOW_ID))) .memory(|m| m.area_rect(egui::Id::new(WINDOW_ID)))

View file

@ -23,6 +23,15 @@ pub fn stable_section<R>(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)
ui.vertical(contents).inner ui.vertical(contents).inner
} }
/// Glob matching everything under `dir`, spelled with the platform
/// separator. `Path::join` inserts a separator only where one is needed, so
/// a drive root yields `C:\*` rather than the never-matching `C:\/*` a
/// `format!("{}/*")` would produce. Shared by the search tab's ignore dialog
/// and the duplicates tab's exclusions, which speak the same glob syntax.
pub fn dir_ignore_pattern(dir: &std::path::Path) -> String {
dir.join("*").to_string_lossy().into_owned()
}
/// `IgnoreSet::compile` silently *skips* patterns that trim to nothing, so /// `IgnoreSet::compile` silently *skips* patterns that trim to nothing, so
/// emptiness is checked here with the same trimming rules. /// emptiness is checked here with the same trimming rules.
pub fn ignore_pattern_valid(pattern: &str) -> bool { pub fn ignore_pattern_valid(pattern: &str) -> bool {

View file

@ -52,10 +52,11 @@ impl Gate {
Gate::Locked(UnlockScreen::new(cfg, config_error, initial_query)) Gate::Locked(UnlockScreen::new(cfg, config_error, initial_query))
} }
/// Act on the system-wide search shortcut. Handled here because while /// Act on a search shortcut, whether QuickSearch's own registration fired
/// locked the unlock screen *is* the window. /// it or a `--toggle` process relayed the desktop's. Handled here because
fn handle_hotkey(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { /// while locked the unlock screen *is* the window.
if !crate::hotkey::take_fired() { fn handle_activation(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
if !crate::activate::take_pending() {
return; return;
} }
if let Gate::Running(app) = self { if let Gate::Running(app) = self {
@ -65,13 +66,13 @@ impl Gate {
} }
app.activate_search(ctx); app.activate_search(ctx);
} }
crate::hotkey::raise(ctx, frame); crate::activate::raise(ctx, frame);
} }
} }
impl eframe::App for Gate { impl eframe::App for Gate {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.handle_hotkey(ctx, frame); self.handle_activation(ctx, frame);
match self { match self {
Gate::Running(app) => app.update(ctx, frame), Gate::Running(app) => app.update(ctx, frame),
Gate::Locked(screen) => { Gate::Locked(screen) => {

View file

@ -7,6 +7,8 @@ quicksearch, quicksearch\-cli \- fast full\-text search across your files
.SH SYNOPSIS .SH SYNOPSIS
.B quicksearch .B quicksearch
.br .br
.B quicksearch \-\-toggle
.br
.B quicksearch .B quicksearch
.RI [ FLAGS ] .RI [ FLAGS ]
.IR "query terms" ... .IR "query terms" ...
@ -42,6 +44,24 @@ that invoked it. Given no query it prints usage and exits 2 rather than falling
back to the application. back to the application.
.SH OPTIONS .SH OPTIONS
.TP .TP
.B \-\-toggle
Bring a running QuickSearch to the front with the search box focused, or start
it if none is running. Intended as the target of a key binding made in the
desktop's own keyboard settings.
.IP
QuickSearch also claims a shortcut of its own while it is running
.RI ( Ctrl+Shift+F
by default, rebindable to any combination on the Settings tab), which needs no
setting up. That one cannot fire while QuickSearch is not running, because
nothing a program registers for itself can; this option is what a desktop
binding runs so that a key can start it as well.
.IP
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.
.TP
.B \-\-fuzzy .B \-\-fuzzy
Also run the fuzzy filename and full\-text passes, which tolerate spelling Also run the fuzzy filename and full\-text passes, which tolerate spelling
differences at the cost of speed. The edit distance comes from differences at the cost of speed. The edit distance comes from
@ -167,6 +187,18 @@ The index. The location is set by
.I [paths].database_path .I [paths].database_path
in the configuration. in the configuration.
.TP .TP
.IR $XDG_RUNTIME_DIR /quicksearch\- <key> .sock
The socket
.B \-\-toggle
connects to, where
.I <key>
is a hash of the configuration file's path \(em not the index's, which is a
setting that can change while running. Present only while the application is
running; a leftover one is inert and is replaced at the next start. Falls back
to a private directory under the temporary directory when
.I XDG_RUNTIME_DIR
is unset.
.TP
.I ./config.toml .I ./config.toml
A configuration file placed next to the A configuration file placed next to the
.B quicksearch .B quicksearch

View file

@ -17,3 +17,11 @@ StartupNotify=true
# the Wayland app id and the X11 WM_CLASS. Without the match the desktop shows a # the Wayland app id and the X11 WM_CLASS. Without the match the desktop shows a
# generic window icon. # generic window icon.
StartupWMClass=quicksearch StartupWMClass=quicksearch
# The target for a system-wide search shortcut. A desktop entry cannot claim
# a key for itself, so this is what the user binds in their desktop's own
# keyboard settings; desktops that show entry actions also offer it as one.
Actions=Search;
[Desktop Action Search]
Name=Search
Exec=quicksearch --toggle

View file

@ -169,11 +169,33 @@ Section "Start Menu shortcut" SecStartMenu
; One shortcut, no program folder: a single-application folder is noise in ; One shortcut, no program folder: a single-application folder is noise in
; the Windows 10/11 Start menu, and the uninstaller lives in Add/Remove ; the Windows 10/11 Start menu, and the uninstaller lives in Add/Remove
; Programs rather than next to it. ; Programs rather than next to it.
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "" "$INSTDIR\quicksearch.ico" ; --toggle rather than a bare launch: it raises the running window
; instead of a second copy refusing to start on the held index lock.
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" "$INSTDIR\quicksearch.ico"
SectionEnd
Section "Search hotkey (Ctrl+Alt+F)" SecHotkey
; The .lnk "shortcut key" field is the only thing on Windows that binds a
; key to a command, and it is what makes the shortcut work while
; QuickSearch is closed - nothing an application registers for itself can
; fire when it is not running. Windows only honours the field on a
; shortcut in the Start menu or on the desktop, and only for combinations
; including Ctrl+Alt, which is why this is Ctrl+Alt+F and not the
; Ctrl+Shift+F the Settings tab offers. The in-application shortcut takes
; any combination but only answers while the window is already open, so
; the two are complementary rather than duplicates.
;
; Rewrites the same shortcut the section above creates: NSIS cannot add a
; hotkey to an existing .lnk, and creating it twice is harmless.
CreateShortcut "$SMPROGRAMS\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" \
"$INSTDIR\quicksearch.ico" 0 SW_SHOWNORMAL ALT|CONTROL|F \
"Search your files with ${APP}"
SectionEnd SectionEnd
Section /o "Desktop shortcut" SecDesktop Section /o "Desktop shortcut" SecDesktop
CreateShortcut "$DESKTOP\${APP}.lnk" "$INSTDIR\quicksearch.exe" "" "$INSTDIR\quicksearch.ico" ; Deliberately no hotkey here: two shortcuts claiming one key is a
; conflict, and the Start menu entry above already owns it.
CreateShortcut "$DESKTOP\${APP}.lnk" "$INSTDIR\quicksearch.exe" "--toggle" "$INSTDIR\quicksearch.ico"
SectionEnd SectionEnd
; There is deliberately no "add to PATH" section, tempting as one is for ; There is deliberately no "add to PATH" section, tempting as one is for
@ -189,6 +211,10 @@ SectionEnd
"The desktop app and quicksearch-cli, the terminal search tool." "The desktop app and quicksearch-cli, the terminal search tool."
!insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} \ !insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} \
"Add ${APP} to the Start menu for all users." "Add ${APP} to the Start menu for all users."
!insertmacro MUI_DESCRIPTION_TEXT ${SecHotkey} \
"Press Ctrl+Alt+F anywhere to search, starting ${APP} if it is not \
already running. Windows allows this only on Ctrl+Alt combinations; \
the Settings tab has one that takes any keys while ${APP} is open."
!insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \ !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \
"Add a ${APP} shortcut to the desktop." "Add a ${APP} shortcut to the desktop."
!insertmacro MUI_FUNCTION_DESCRIPTION_END !insertmacro MUI_FUNCTION_DESCRIPTION_END