Added system-wide shortcut to launch app and search even while not running on both Windows and Linux.
This commit is contained in:
parent
a9b23bc7a6
commit
fe832109d7
52 changed files with 5059 additions and 1175 deletions
|
|
@ -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
4
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
100
DESIGN.md
Normal 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
767
README.md
|
|
@ -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 16–256px 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 1–4) and sets
|
|
||||||
aside full-path matches from the same rows (ranks 9–10); one FTS
|
|
||||||
phrase probe verified against the decompressed text yields full-text
|
|
||||||
ranks 5–6 ordered by occurrence count; the opt-in fuzzy passes run a
|
|
||||||
bitap (Wu–Manber) 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
866
crates/quicksearch-core/examples/pruneprobe.rs
Normal file
866
crates/quicksearch-core/examples/pruneprobe.rs
Normal 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,
|
||||||
|
®istry,
|
||||||
|
&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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
] }
|
] }
|
||||||
|
|
|
||||||
234
crates/quicksearch-gui/examples/raiseprobe.rs
Normal file
234
crates/quicksearch-gui/examples/raiseprobe.rs
Normal 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(())
|
||||||
|
}
|
||||||
590
crates/quicksearch-gui/src/activate.rs
Normal file
590
crates/quicksearch-gui/src/activate.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
229
crates/quicksearch-gui/src/activate/raise.rs
Normal file
229
crates/quicksearch-gui/src/activate/raise.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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, ¤t.ui.search_hotkey);
|
hotkey_note(ui, &draft.ui.search_hotkey, ¤t.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
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)> {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue