diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 3fb8887..be4edbc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -5,6 +5,9 @@ name: CI # DEFAULT_ACTIONS_URL, which points at data.forgejo.org, so nothing here reaches # 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: push: # 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 # 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. - branches: [master, 'Release*', 'Release/**', 'release*', 'release/**'] + branches: ['Release*', 'Release/**', 'release*', 'release/**'] tags: ['v*'] - pull_request: - branches: [master] workflow_dispatch: concurrency: 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: CARGO_TERM_COLOR: always @@ -205,10 +209,12 @@ jobs: # 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. - # On master and pull requests the job stops after Build and Test, which - # is what those runs are for — the packaging that used to follow built a - # .deb, an AppImage, a tarball and a 14-day artifact upload that nothing - # would ever download, because the release job is skipped there anyway. + # The gate still earns its place now that those are the only push + # triggers, because workflow_dispatch is not restricted to them: running + # this by hand against master is the way to build and test that branch, + # 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 # 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 # -node20 tags are Forgejo's builds for precisely this combination. - uses: actions/upload-artifact@v3-node20 - # `if-no-files-found: error` below would fail every master push once the - # packaging steps above are gated, so this carries the same gate. + # `if-no-files-found: error` below would fail any hand-dispatched run + # off a release ref, where the packaging steps above are skipped and + # dist/ stays empty, so this carries the same gate. if: >- startsWith(github.ref, 'refs/tags/v') || 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 # can come out. The DLL check above deliberately stays ungated: it # validates the .exe itself rather than packaging it, costs an objdump, - # and is exactly the kind of regression worth catching on master rather - # than at release time. + # and is exactly the kind of regression worth catching on a hand-run + # master build rather than at release time. - name: Build the installer if: >- startsWith(github.ref, 'refs/tags/v') diff --git a/Cargo.lock b/Cargo.lock index 143d5aa..2aecf11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3108,7 +3108,7 @@ dependencies = [ [[package]] name = "quicksearch-core" -version = "1.1.6" +version = "1.1.7" dependencies = [ "argon2", "cfb", @@ -3150,7 +3150,7 @@ dependencies = [ [[package]] name = "quicksearch-gui" -version = "1.1.6" +version = "1.1.7" dependencies = [ "ashpd", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 96c2e32..e22d3f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ pdf-extract = { path = "vendor/pdf-extract" } # Patched unbounded reads which co rtf-parser = { path = "vendor/rtf-parser" } # Patched a parsing error which occurs on UTF-16 characters [workspace.package] -version = "1.1.6" +version = "1.1.7" edition = "2021" license = "GPL-3.0-or-later" authors = ["Jeremy "] diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..d7714c8 --- /dev/null +++ b/DESIGN.md @@ -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/`. diff --git a/README.md b/README.md index 906e5fa..b0735e8 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,53 @@ # QuickSearch -A fast local file indexer and search tool. QuickSearch walks your chosen -folders into a compact SQLite index (FTS5 full-text + zstd-compressed text -sidecar), keeps it fresh automatically with filesystem watchers and -periodic reindexing, and serves ranked search-as-you-type results in a -compact egui desktop app, or straight to your terminal. +A fast local file search tool. QuickSearch indexes the folders you choose, +keeps that index fresh automatically. It finds files by name or by text +content as fast as you type. ## 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: -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 +- https://quicksearch.karsttech.com +- 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 ` 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 @@ -23,696 +56,36 @@ https://github.com/DataScienceDIY/quick_search build.bat # Windows ``` -These take a fresh machine to a running app: install missing build -dependencies, build release, launch the GUI. Stages already satisfied are -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). +These take a fresh machine to a running app: they install missing build +dependencies (with your permission), build a release binary, and launch it. ```sh cargo build --release -p quicksearch-gui # binaries: target/release/quicksearch{,-cli} 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 -is built as a window-subsystem app so no console appears behind it. -`quicksearch-cli` is terminal search — a console app, so pipes, redirection -and exit codes behave normally. On Unix `quicksearch` also does both, and -`quicksearch-cli` is simply the same tool under a clearer name. - -## Install (Debian / Ubuntu) - -```sh -./packaging/build-deb.sh -sudo apt install ./dist/quicksearch__amd64.deb -``` - -Substitute the current release version for ``. - -The script builds the release binary, strips it, and assembles a `.deb` with -`dpkg-deb`. It needs no `cargo-deb`, no `debhelper` and no SVG rasteriser — -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` -to keep debug symbols, `-o DIR` to write elsewhere. `DEB_MAINTAINER` overrides -the packaging maintainer. - -The package installs: - -| 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--x86_64.AppImage` from the release page (substitute -the current release version), make it executable and run it: - -```sh -chmod +x quicksearch--x86_64.AppImage -./quicksearch--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--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--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--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 `) only reads and keeps working while the -window is open. The guard is a kernel lock on `.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//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 ` reports an indexing run's peak and - what it settles at once idle; `rssprobe [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` 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. +## Install + +- **Debian / Ubuntu**: `./packaging/build-deb.sh`, then + `sudo apt install ./dist/quicksearch__amd64.deb`. +- **Other Linux**: download the AppImage from the release page, make it + executable, and run it. If your distribution lacks FUSE, run it with + `APPIMAGE_EXTRACT_AND_RUN=1`. +- **Windows**: run the setup `.exe` from the release page, or unpack the + `.zip` anywhere for an install-free copy. + +## Configuration + +Settings live in `~/.config/quicksearch/config.toml` (Windows: +`%APPDATA%\quicksearch\config.toml`), created on first run and edited live +by the GUI. [config_example.toml](config_example.toml) is the full +reference for every key. A `config.toml` placed next to the binary enables +portable mode. + +## More + +Design choices and the reasoning behind them are described in +[DESIGN.md](DESIGN.md); the code and its comments are the detailed +reference. QuickSearch is open source under the terms in +[LICENSE](LICENSE). diff --git a/config_example.toml b/config_example.toml index 42fe302..ba9cb47 100644 --- a/config_example.toml +++ b/config_example.toml @@ -141,18 +141,17 @@ use_keychain = false # Zoom factor for the whole GUI: fonts, spacing, and widgets scale # together (0.5 – 2.5). Ctrl +/- and Ctrl 0 adjust it temporarily at # 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 # the "more subfolders than the watcher can follow" warning, so restarting # does not repeat it. Deleting it just means the warnings come back once # each. watch_cap_warned_roots = [] -# System-wide shortcut that raises QuickSearch, switches to the Search tab -# and selects whatever is in the search box, from anywhere. Modifiers are -# Ctrl, Alt and Shift, joined to one key with "+". Leave it empty ("") for -# no shortcut. On Wayland this is only a preference: the shortcut is -# registered with your desktop, which may assign a different key and lets -# you change it in its own keyboard settings. +# The shortcut QuickSearch claims for itself while it is running: brings it to +# the front, switches to the Search tab and selects whatever is in the search +# box. Modifiers are Ctrl, Alt and Shift, joined to one key with "+". Leave it +# empty ("") for no shortcut. To start QuickSearch when it is closed, bind +# "quicksearch --toggle" in your desktop's keyboard settings. search_hotkey = "Ctrl+Shift+F" # 'dark' or 'light'; anything other than 'light' is dark. Applied as soon # as it is changed on the Settings tab. Following the desktop's own @@ -228,3 +227,29 @@ content_match = true size = false modified = false 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 = [] diff --git a/crates/quicksearch-core/examples/pruneprobe.rs b/crates/quicksearch-core/examples/pruneprobe.rs new file mode 100644 index 0000000..14a556b --- /dev/null +++ b/crates/quicksearch-core/examples/pruneprobe.rs @@ -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 = Vec::new(); + let mut doomed_fts: Vec = 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, + /// MiB of page cache; `None` keeps `PRAGMAS_INCREMENTAL`'s 4. + cache_mib: Option, +} + +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 = 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(); + } +} diff --git a/crates/quicksearch-core/src/config/mod.rs b/crates/quicksearch-core/src/config/mod.rs index 71187b6..22ed758 100644 --- a/crates/quicksearch-core/src/config/mod.rs +++ b/crates/quicksearch-core/src/config/mod.rs @@ -23,6 +23,7 @@ pub struct Config { pub indexing: IndexingConfig, pub processing: ProcessingConfig, pub search: SearchConfig, + pub duplicates: DuplicatesConfig, pub ui: UiConfig, pub security: SecurityConfig, /// 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, + /// 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, +} + impl SearchConfig { /// The caution to show next to `fuzzy_max_edits`, or `None` when sane. pub fn fuzzy_edits_warning(&self) -> Option { @@ -286,9 +308,11 @@ pub struct UiConfig { /// for. Keyed by root so that adding a folder warns again while /// restarting the app does not. pub watch_cap_warned_roots: Vec, - /// System-wide shortcut that raises the window, as `Ctrl+Shift+F`. Empty - /// disables it; an unparseable value degrades to "no shortcut". On - /// Wayland the desktop, not this value, has the final say. + /// The shortcut QuickSearch claims for itself while running, as + /// `Ctrl+Shift+F`. Empty disables it; an unparseable value degrades to + /// "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, /// `dark` or `light`. An unrecognised value falls back to dark, where a /// typed-out enum would fail to deserialize and take the whole config @@ -319,7 +343,7 @@ pub struct UiConfig { impl Default for UiConfig { fn default() -> Self { UiConfig { - scale: 1.1, + scale: 1.25, watch_cap_warned_roots: Vec::new(), search_hotkey: "Ctrl+Shift+F".to_string(), color_scheme: "dark".to_string(), diff --git a/crates/quicksearch-core/src/config/tests.rs b/crates/quicksearch-core/src/config/tests.rs index 0caef86..cad1a51 100644 --- a/crates/quicksearch-core/src/config/tests.rs +++ b/crates/quicksearch-core/src/config/tests.rs @@ -54,7 +54,7 @@ fn partial_file_gets_section_defaults() { assert_eq!(cfg.paths.indexing_paths, vec!["/x".to_string()]); assert_eq!(cfg.processing.batch_size, 500, "missing sections default"); 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"); } @@ -785,11 +785,19 @@ fn ui_bookkeeping_fields_are_soft_knobs() { // 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 Knob = (&'static str, fn(&mut Config)); - let cases: [Knob; 2] = [ + let cases: [Knob; 4] = [ ("watch_cap_warned_roots", |c| { c.ui.watch_cap_warned_roots = vec!["/media/ApolloStore".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 { 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()]; cfg.ui.color_scheme = "light".to_string(); 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(); let loaded = Config::load_from(&path).unwrap(); 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.search.fuzzy_max_edits, 4); + assert_eq!(loaded.duplicates, cfg.duplicates); assert_eq!(Config::default().ui.color_scheme, "dark"); fs::write( @@ -831,6 +842,11 @@ fn newer_fields_round_trip_and_default_when_absent() { .unwrap(); let cfg = Config::load_from(&path).unwrap(); 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.search.fuzzy_max_edits, 2); 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" ); assert_eq!(parsed.processing, d.processing); + assert_eq!(parsed.duplicates, d.duplicates); assert_eq!(parsed.ui.scale, d.ui.scale); assert_eq!(parsed.ui.color_scheme, d.ui.color_scheme); assert_eq!(parsed.ui.tutorial_seen, Some(false)); diff --git a/crates/quicksearch-core/src/content.rs b/crates/quicksearch-core/src/content.rs index a8a3e71..7f9e371 100644 --- a/crates/quicksearch-core/src/content.rs +++ b/crates/quicksearch-core/src/content.rs @@ -49,9 +49,9 @@ impl ExtractedRow { } } - /// The `files.name` the FTS row is indexed under. - pub fn name(&self) -> &str { - self.path.name() + /// The whole path, parent and all — what progress and logs name the file by. + pub fn path(&self) -> &str { + self.path.as_str() } } diff --git a/crates/quicksearch-core/src/file_handling/batch.rs b/crates/quicksearch-core/src/file_handling/batch.rs index 6428d59..e4caa50 100644 --- a/crates/quicksearch-core/src/file_handling/batch.rs +++ b/crates/quicksearch-core/src/file_handling/batch.rs @@ -387,10 +387,10 @@ pub fn store_extracted( // Counted before anything can skip it: a failed row still leaves. done.consumed += 1; 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(()) => 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 { diff --git a/crates/quicksearch-core/src/indexing/pipeline.rs b/crates/quicksearch-core/src/indexing/pipeline.rs index 27044e9..1b5010a 100644 --- a/crates/quicksearch-core/src/indexing/pipeline.rs +++ b/crates/quicksearch-core/src/indexing/pipeline.rs @@ -247,7 +247,7 @@ mod census { .iter() .map(|r| { (crate::file_handling::outcome_body(&r.outcome).map_or(0, str::len) - + r.name().len()) as u64 + + r.path().len()) as u64 }) .sum(); crate::log_info!( @@ -529,8 +529,10 @@ impl RootPipeline { } let stored = store_extracted(&cx.conn_mutex, ready, cx.stop_flag, cx.config, deadline)?; if stored.consumed > 0 { - // The last row *written*, not the last fetched. - *current_file = Some(ready[stored.consumed - 1].name().to_string()); + // The last row *written*, not the last fetched. The whole path, + // 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); *written += stored.written; diff --git a/crates/quicksearch-core/src/indexing/progress.rs b/crates/quicksearch-core/src/indexing/progress.rs index adf2011..734110c 100644 --- a/crates/quicksearch-core/src/indexing/progress.rs +++ b/crates/quicksearch-core/src/indexing/progress.rs @@ -42,6 +42,8 @@ pub struct RootProgress { /// 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. pub extract_total: Option, + /// 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, /// Threads busy / pool size for the current phase's pool; both zero once done. pub active_workers: usize, diff --git a/crates/quicksearch-core/src/indexing/tests.rs b/crates/quicksearch-core/src/indexing/tests.rs index c8a043a..adbb18f 100644 --- a/crates/quicksearch-core/src/indexing/tests.rs +++ b/crates/quicksearch-core/src/indexing/tests.rs @@ -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!(p.snapshot().extracted, 5); 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); std::fs::remove_dir_all(&dir).ok(); diff --git a/crates/quicksearch-core/src/search/duplicates.rs b/crates/quicksearch-core/src/search/duplicates.rs index 5a7651b..c3694c1 100644 --- a/crates/quicksearch-core/src/search/duplicates.rs +++ b/crates/quicksearch-core/src/search/duplicates.rs @@ -36,6 +36,15 @@ pub struct DuplicateGroup { 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 /// bytes first, and the lower rowid on a tie so repeated scans of an unchanged /// index list the same groups in the same order. @@ -297,6 +306,36 @@ mod tests { 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::>(); + 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] fn zero_limit_answers_without_opening_the_index() { assert!(find_duplicate_groups("/nonexistent/index.sqlite", 0) diff --git a/crates/quicksearch-core/src/security.rs b/crates/quicksearch-core/src/security.rs index bfe77bf..defb5b6 100644 --- a/crates/quicksearch-core/src/security.rs +++ b/crates/quicksearch-core/src/security.rs @@ -77,7 +77,9 @@ pub fn derive_key(password: &str, salt: &[u8; SALT_LEN]) -> IndexKey { 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); for b in bytes { use std::fmt::Write; diff --git a/crates/quicksearch-core/src/verify.rs b/crates/quicksearch-core/src/verify.rs index fb4be4b..d684c83 100644 --- a/crates/quicksearch-core/src/verify.rs +++ b/crates/quicksearch-core/src/verify.rs @@ -45,6 +45,9 @@ pub struct VerifyReport { /// Index into the input paths of the file everything else was compared /// against: the first one that opened. `None` when none of them did. pub reference: Option, + /// 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, 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 { on(VerifyUpdate::Done(VerifyReport { reference: None, + reference_len: 0, verdicts, bytes_read: 0, })); @@ -232,6 +236,7 @@ pub fn verify_identical(paths: &[PathBuf], cancel: &AtomicBool, on: &mut dyn FnM on(VerifyUpdate::Done(VerifyReport { reference: Some(reference), + reference_len, verdicts, bytes_read, })); diff --git a/crates/quicksearch-gui/Cargo.toml b/crates/quicksearch-gui/Cargo.toml index 3e75e12..ef6ced0 100644 --- a/crates/quicksearch-gui/Cargo.toml +++ b/crates/quicksearch-gui/Cargo.toml @@ -53,9 +53,10 @@ rfd = "0.15" open = "5" chrono = { version = "0.4", default-features = false, features = ["clock"] } -# The system-wide search shortcut, where the display server lets an -# application claim keys for itself: `RegisterHotKey` on Windows, `XGrabKey` -# on X11. Wayland does not, and is handled by the portal below. +# The shortcut QuickSearch claims for itself while running, where the display +# server lets an application claim keys: `RegisterHotKey` on Windows, +# `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" # Display backends, which only exist on Linux/BSD. `default-features = false` @@ -67,25 +68,33 @@ eframe = { version = "0.32", default-features = false, features = [ "x11", ] } -# The Wayland half of the search shortcut: `org.freedesktop.portal.GlobalShortcuts`, -# the only way a Wayland application can be told about a key it does not own. -# `rfd` already pulls all four in (it uses the file-chooser portal), so the -# versions here are the ones already resolved and nothing extra is compiled. -# `default-features = false` matters: ashpd defaults to Tokio, which would add -# a second async runtime and switch `zbus` over to it underneath `rfd`. +# The Wayland half of the in-application shortcut: +# `org.freedesktop.portal.GlobalShortcuts`, the only way a Wayland application +# can be told about a key it does not own. `rfd` already pulls all four in (it +# uses the file-chooser portal), so the versions here are the ones already +# resolved and nothing extra is compiled. `default-features = false` matters: +# ashpd defaults to Tokio, which would add a second async runtime and switch +# `zbus` over to it underneath `rfd`. ashpd = { version = "0.11", default-features = false, features = ["async-std"] } futures-channel = "0.3" futures-util = "0.3" pollster = "0.4" -# Raising the window from the shortcut on X11, which winit cannot do: it asks -# with a source indication of "application", and every mainstream window -# manager refuses that from a window that is not already focused. See -# `hotkey::raise`. Both are already in the tree (winit's own X11 backend, and -# eframe's window handle), so neither adds a crate. +# Raising the window from either shortcut on X11, which winit cannot +# do: it asks with a source indication of "application", and every mainstream +# window manager refuses that from a window that is not already focused. See +# `activate::raise`. Both are already in the tree (winit's own X11 backend, +# and eframe's window handle), so neither adds a crate. x11rb = "0.13" 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 # Explorer) and VT-mode enabling for the CLI binary. 0.59 matches what eframe # 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 = [ "Win32_Foundation", "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", ] } diff --git a/crates/quicksearch-gui/examples/raiseprobe.rs b/crates/quicksearch-gui/examples/raiseprobe.rs new file mode 100644 index 0000000..19f1731 --- /dev/null +++ b/crates/quicksearch-gui/examples/raiseprobe.rs @@ -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> { + 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> { + 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> { + 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> { + 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> { + 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 ")?; + return iconify(&target); + } + if mode == "hotkey" { + return send_hotkey(); + } + if mode != "current" && mode != "server" { + eprintln!("usage: raiseprobe [current|server|iconify |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> { + 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 { + 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(()) +} diff --git a/crates/quicksearch-gui/src/activate.rs b/crates/quicksearch-gui/src/activate.rs new file mode 100644 index 0000000..8d9caac --- /dev/null +++ b/crates/quicksearch-gui/src/activate.rs @@ -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-`, 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 { + 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"); + } +} diff --git a/crates/quicksearch-gui/src/activate/raise.rs b/crates/quicksearch-gui/src/activate/raise.rs new file mode 100644 index 0000000..f3c1229 --- /dev/null +++ b/crates/quicksearch-gui/src/activate/raise.rs @@ -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> = 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> { + 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> { + 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> { + 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> { + 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> { + 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 + } + } +} diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs index 2f3a121..f0ae0ac 100644 --- a/crates/quicksearch-gui/src/app.rs +++ b/crates/quicksearch-gui/src/app.rs @@ -235,13 +235,16 @@ impl QuickSearchApp { if let Some(query) = initial_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 { cfg, backend, tab, search, manage: ManageTab::new(), - dups: DuplicatesTab::new(), + dups, logs: LogsTab::new(), settings: SettingsTab::new(), rebuild_prompt: None, @@ -289,7 +292,11 @@ impl QuickSearchApp { fn start_duplicates_scan(&mut self, ctx: &egui::Context) { self.dups.state = DupState::Loading; 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 @@ -597,13 +604,22 @@ pub(crate) fn pin_live_fields(new: &mut Config, live: &Config) { // protection — Apply must not put the rows away again. new.search.columns = live.search.columns.clone(); 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 = 0.5..=2.5; + fn clamp_scale(scale: f32) -> f32 { if scale.is_finite() { - scale.clamp(0.5, 2.5) + scale.clamp(*SCALE_RANGE.start(), *SCALE_RANGE.end()) } 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 => { - 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 { 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 { let paths: Vec = paths.into_iter().map(std::path::PathBuf::from).collect(); diff --git a/crates/quicksearch-gui/src/app/modals.rs b/crates/quicksearch-gui/src/app/modals.rs index ee21f4c..c659824 100644 --- a/crates/quicksearch-gui/src/app/modals.rs +++ b/crates/quicksearch-gui/src/app/modals.rs @@ -203,7 +203,8 @@ impl QuickSearchApp { return; }; 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, // so the demonstration search runs once the typing stops. if let Some(query) = actions.set_query { @@ -212,6 +213,23 @@ impl QuickSearchApp { if actions.focus_search { self.search.request_focus(); } + // Live, like the Settings slider on Apply — but saved only when the + // drag ends, so crossing the slider does not rewrite the config file + // on every frame. + 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. if let Some(tab) = actions.goto_tab { self.request_tab(ctx, tab); diff --git a/crates/quicksearch-gui/src/app/verify.rs b/crates/quicksearch-gui/src/app/verify.rs index 8da5f80..23ae5a2 100644 --- a/crates/quicksearch-gui/src/app/verify.rs +++ b/crates/quicksearch-gui/src/app/verify.rs @@ -41,14 +41,19 @@ impl VerifyModal { } } -/// One line of the report, in the words the modal paints. -pub(crate) fn verdict_line(verdict: &MemberVerdict, reference: bool) -> String { +/// One line of the report, in the words the modal paints. `reference_len` is +/// 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 { MemberVerdict::Identical if reference => "compared against".to_string(), MemberVerdict::Identical => "identical".to_string(), - MemberVerdict::DiffersAt(offset) => { - format!("differs at byte {}", group_thousands(*offset)) - } + MemberVerdict::DiffersAt(offset) => format!( + "differs at byte {} of {}", + group_thousands(*offset), + human_size(reference_len) + ), MemberVerdict::LengthDiffers { len, reference_len } => format!( "size differs: {} against {}", 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)); 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( - "Files are grouped by size and how they begin, which is all \ - indexing reads. This compared every byte.", + "A difference near the start of a file is usually metadata you \ + 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); @@ -201,7 +217,8 @@ pub(crate) fn verify_modal(ctx: &egui::Context, modal: &VerifyModal) -> bool { ui.label( 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() { ui.label(hint(line)); } else { diff --git a/crates/quicksearch-gui/src/app/verify_tests.rs b/crates/quicksearch-gui/src/app/verify_tests.rs index 0a352c4..0085725 100644 --- a/crates/quicksearch-gui/src/app/verify_tests.rs +++ b/crates/quicksearch-gui/src/app/verify_tests.rs @@ -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, bytes_read: u64) -> VerifyState { VerifyState::Done(Box::new(VerifyReport { reference: Some(0), + reference_len: REFERENCE_LEN, verdicts, bytes_read, })) @@ -111,12 +116,41 @@ fn a_mismatch_names_the_file_and_the_offset() { "{painted:?}" ); 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:?}" ); 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] fn a_cancelled_run_says_so_rather_than_showing_a_verdict() { let ctx = crate::test_ui::ctx(); @@ -158,15 +192,18 @@ fn both_dismiss_buttons_report_the_dismissal() { #[test] fn every_verdict_reads_as_a_sentence_about_the_file() { - assert_eq!(verdict_line(&Identical, false), "identical"); - assert_eq!(verdict_line(&Identical, true), "compared against"); - assert_eq!(verdict_line(&DiffersAt(0), false), "differs at byte 0"); + let line = |v: &MemberVerdict, reference| verdict_line(v, reference, 2_000_000); + assert_eq!(line(&Identical, false), "identical"); + 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!( - verdict_line(&DiffersAt(1_048_576), false), - "differs at byte 1,048,576" + line(&DiffersAt(1_048_576), false), + "differs at byte 1,048,576 of 2.0 MB" ); assert_eq!( - verdict_line( + line( &LengthDiffers { len: 2048, 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" ); - 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")); } @@ -184,6 +221,7 @@ fn the_summary_counts_what_it_found() { let of = |verdicts: Vec, reference| { summary_line(&VerifyReport { reference, + reference_len: REFERENCE_LEN, verdicts, bytes_read: 0, }) diff --git a/crates/quicksearch-gui/src/backend.rs b/crates/quicksearch-gui/src/backend.rs index 9e216b0..7c5650c 100644 --- a/crates/quicksearch-gui/src/backend.rs +++ b/crates/quicksearch-gui/src/backend.rs @@ -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 coordinator: Arc, pub search: Option, @@ -132,20 +148,24 @@ impl Backend { } /// 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. - pub fn start_duplicates(&mut self, config: &Config, ctx: egui::Context) { + /// whole hash index for an answer the first is about to produce. Returns + /// 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() { - return; + return 0; } + let limit = dup_scan_limit(config.duplicates.hidden_groups.len()); let (tx, rx) = mpsc::channel(); let db = config.resolved_database_path(); std::thread::spawn(move || { 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); ctx.request_repaint(); }); self.dup_job = Some(rx); + limit } /// Compare every member against the first, byte for byte, on a worker diff --git a/crates/quicksearch-gui/src/cli.rs b/crates/quicksearch-gui/src/cli.rs index 432761c..5756d2f 100644 --- a/crates/quicksearch-gui/src/cli.rs +++ b/crates/quicksearch-gui/src/cli.rs @@ -24,10 +24,15 @@ QuickSearch: indexed file search USAGE: quicksearch open the GUI + quicksearch --toggle focus the running GUI, or start it quicksearch [FLAGS] search from the terminal (Windows: quicksearch-cli) 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 --limit maximum results (default: [search].display_limit) --long rank, size, mtime, and snippets instead of bare paths diff --git a/crates/quicksearch-gui/src/color.rs b/crates/quicksearch-gui/src/color.rs index 723e273..a8591ee 100644 --- a/crates/quicksearch-gui/src/color.rs +++ b/crates/quicksearch-gui/src/color.rs @@ -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 --- const RANK_HUE_BEST: f64 = 250.0; diff --git a/crates/quicksearch-gui/src/color/tests.rs b/crates/quicksearch-gui/src/color/tests.rs index ff609c4..9377827 100644 --- a/crates/quicksearch-gui/src/color/tests.rs +++ b/crates/quicksearch-gui/src/color/tests.rs @@ -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] fn the_rank_ramp_is_an_even_sweep_from_blue_to_red() { let mut prev: Option = None; diff --git a/crates/quicksearch-gui/src/duplicates_tab.rs b/crates/quicksearch-gui/src/duplicates_tab.rs index e1f6fb8..f90bc44 100644 --- a/crates/quicksearch-gui/src/duplicates_tab.rs +++ b/crates/quicksearch-gui/src/duplicates_tab.rs @@ -1,10 +1,23 @@ //! 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 crate::format::{group_thousands, human_size}; use crate::platform; -use crate::ui_util::hint; +use crate::ui_util::{hint, stable_section}; pub enum DupState { NotLoaded, @@ -33,127 +46,302 @@ impl DupSort { } } -/// A group's extension, lowercased: the one from the member the title names, -/// since copies of one file can be filed under different names. Empty for a -/// group whose representative has no extension at all. -fn group_extension(group: &DuplicateGroup) -> String { - group - .members - .first() - .map(|m| m.1.as_str()) - .and_then(|name| std::path::Path::new(name).extension()) +/// A name's extension, lowercased; empty when it has none. +fn extension_of(name: &str) -> String { + std::path::Path::new(name) + .extension() .map(|ext| ext.to_string_lossy().to_lowercase()) .unwrap_or_default() } -/// What [`LoadedGroups::sort`] orders an extension listing by, ending in the -/// group's own index: no extension last, then the extension, then the -/// biggest waste, then the hash to settle whatever is left. -type ExtensionKey<'a> = (bool, String, std::cmp::Reverse, &'a [u8], usize); +/// What [`LoadedGroups::rebuild`] orders an extension listing by: no extension +/// last, then the extension, then the biggest waste, then the hash to settle +/// whatever is left. +type ExtensionKey<'a> = (bool, &'a str, std::cmp::Reverse, &'a [u8]); -/// The scan's result, with each 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. +fn extension_key<'a>(row: &'a Row, groups: &'a [DuplicateGroup]) -> ExtensionKey<'a> { + ( + // 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, + hidden: HashSet, + /// 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, + 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 groups: Vec, - titles: Vec, - /// Indices into `groups`, in display order. Reordering this leaves - /// `groups` and `titles` parallel, which the render loop relies on. - order: Vec, - sorted_by: DupSort, + groups: Vec, + /// The visible groups, in display order. + rows: Vec, + /// Groups the filters kept off screen, for the line that says so. + filtered_out: usize, + /// The `(sort, filter revision, show hidden)` `rows` was built for. + built_for: Option<(DupSort, u64, bool)>, } impl LoadedGroups { pub fn new(groups: Vec) -> 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 { groups, - titles, - order, - sorted_by: DupSort::Reclaimable, + rows: Vec::new(), + filtered_out: 0, + built_for: None, } } - /// Reorder to `key`. A no-op when it is already the order in force, so - /// the render loop can call it unconditionally. - fn sort(&mut self, key: DupSort) { - if self.sorted_by == key { + /// Rebuild the visible rows. A no-op when nothing that decides them has + /// moved, so the render loop can call it unconditionally. + fn rebuild(&mut self, sort: DupSort, filters: &DupFilters, show_hidden: bool) { + let key = (sort, filters.revision, show_hidden); + if self.built_for == Some(key) { return; } - self.sorted_by = key; - match key { - // What the query already returned, so the indices go back as they came. - DupSort::Reclaimable => self.order.sort_unstable(), - // Extensionless groups last, biggest waste first within an - // extension, hash to break the remaining ties for good. - DupSort::Extension => { - let mut keyed: Vec> = self - .order - .iter() - .map(|&i| { - let group = &self.groups[i]; - let ext = group_extension(group); - ( - ext.is_empty(), - ext, - std::cmp::Reverse(group.redundant_size), - group.hash.as_slice(), - i, - ) - }) - .collect(); - keyed.sort_unstable(); - self.order = keyed.into_iter().map(|k| k.4).collect(); + self.built_for = Some(key); + self.rows.clear(); + self.filtered_out = 0; + + for (i, group) in self.groups.iter().enumerate() { + let hidden = filters.is_hidden(group); + if hidden && !show_hidden { + self.filtered_out += 1; + continue; } + let members: Vec = group + .members + .iter() + .enumerate() + .filter(|(_, m)| !filters.excluded(&m.2)) + .map(|(j, _)| j) + .collect(); + // One surviving copy is not a duplicate of anything. + 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 state: DupState, 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. #[derive(Default)] pub struct DuplicatesActions { 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>, + /// The exclusions or the hidden groups changed; write them to the config. + pub save_filters: Option, } 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 \ 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 { - pub fn new() -> DuplicatesTab { + pub fn new(filters: &DuplicatesConfig) -> DuplicatesTab { DuplicatesTab { state: DupState::NotLoaded, 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. - pub fn ui(&mut self, ui: &mut egui::Ui, verify_open: bool) -> DuplicatesActions { + /// `verify_open` greys the verify entry out: there is only one verify + /// 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 edited = false; ui.horizontal(|ui| { let loading = matches!(self.state, DupState::Loading); @@ -184,14 +372,32 @@ impl DuplicatesTab { 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(); // Applied here rather than at the click, so a Refresh landing under a // non-default choice comes out in that order too. 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 = None; + let mut unhide: Option = None; + let mut exclude: Option = None; + match &self.state { // `NotLoaded` survives at most the one frame before the app starts // the scan (`switch_tab`), and `Loading` has its spinner and label @@ -200,65 +406,104 @@ impl DuplicatesTab { DupState::Error(e) => { ui.colored_label(ui.visuals().error_fg_color, e); } + // Nothing here may return early: the filter edits collected above + // are applied at the bottom of this function, and skipping that is + // how an exclusion that empties the list loses itself. + DupState::Loaded(loaded) if loaded.groups.is_empty() => { + ui.label(NO_GROUPS); + } + DupState::Loaded(loaded) if loaded.rows.is_empty() => { + ui.label(ALL_FILTERED); + } DupState::Loaded(loaded) => { - let groups = &loaded.groups; - if groups.is_empty() { - ui.label("No duplicate files found."); - return actions; - } - if groups.len() == 500 { + // "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 { - 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, - // it is the 500 biggest groups put in that order. - DupSort::Extension => "Showing the 500 largest groups, by extension.", + // it is the biggest groups put in that order. + 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() .auto_shrink([false; 2]) .show(ui, |ui| { - for &i in &loaded.order { - let group = &groups[i]; - let title = loaded.titles[i].as_str(); - let header = - egui::CollapsingHeader::new(title) - .id_salt(i) - .show(ui, |ui| { - for (_, _, path, size, _) in &group.members { - ui.horizontal(|ui| { - ui.label(human_size(*size)); - let response = ui.add( - egui::Label::new( - egui::RichText::new(path).monospace(), - ) - .sense(egui::Sense::click()), - ); - if response.double_clicked() { + for row in &loaded.rows { + let group = &loaded.groups[row.group]; + let header = egui::CollapsingHeader::new(&row.title) + .id_salt(row.group) + .show(ui, |ui| { + for &j in &row.members { + let (_, _, path, size, _) = &group.members[j]; + ui.horizontal(|ui| { + ui.label(human_size(*size)); + let response = ui.add( + egui::Label::new( + egui::RichText::new(path).monospace(), + ) + .sense(egui::Sense::click()), + ); + if response.double_clicked() { + platform::open_file(path); + } + response.context_menu(|ui| { + if ui.button("Open File").clicked() { platform::open_file(path); + ui.close(); } - response.context_menu(|ui| { - if ui.button("Open File").clicked() { - platform::open_file(path); - ui.close(); + if ui.button("Open containing folder").clicked() { + platform::reveal_in_folder(path); + ui.close(); + } + ui.separator(); + if verify_entry(ui, verify_open) { + 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 ui.button("Open containing folder").clicked() - { - platform::reveal_in_folder(path); - ui.close(); - } - ui.separator(); - if verify_entry(ui, verify_open) { - actions.verify = Some(member_paths(group)); - } - }); + } + if let Some(p) = exclude_entries(ui, path) { + exclude = Some(p); + } }); - } - }); + }); + } + }); // Also on the group's own row, whose members are // behind a collapsed header until they are not. header.header_response.context_menu(|ui| { 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); } } + + 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 } + + /// 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 = None; + let mut remove: Option = 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. @@ -283,8 +658,63 @@ fn verify_entry(ui: &mut egui::Ui, open: bool) -> bool { clicked } -fn member_paths(group: &DuplicateGroup) -> Vec { - group.members.iter().map(|m| m.2.clone()).collect() +/// The hide/unhide entry, in whichever direction this group is in. `true` when +/// 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 { + let path = std::path::Path::new(path); + let mut chosen: Option = 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 { + row.members + .iter() + .map(|&j| group.members[j].2.clone()) + .collect() } #[cfg(test)] diff --git a/crates/quicksearch-gui/src/duplicates_tab/tests.rs b/crates/quicksearch-gui/src/duplicates_tab/tests.rs index 7da1b54..d97018b 100644 --- a/crates/quicksearch-gui/src/duplicates_tab/tests.rs +++ b/crates/quicksearch-gui/src/duplicates_tab/tests.rs @@ -3,6 +3,8 @@ use super::*; use crate::test_ui::{painted_text, painted_text_center, raw_input}; 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 { DuplicateGroup { @@ -21,11 +23,15 @@ fn group(paths: &[&str]) -> DuplicateGroup { } } +fn tab(groups: Vec, filters: DuplicatesConfig) -> DuplicatesTab { + let mut tab = DuplicatesTab::new(&filters); + tab.scan_limit = crate::backend::DUP_SCAN_LIMIT; + tab.state = DupState::Loaded(LoadedGroups::new(groups)); + tab +} + fn loaded(paths: &[&str]) -> DuplicatesTab { - DuplicatesTab { - state: DupState::Loaded(LoadedGroups::new(vec![group(paths)])), - sort: DupSort::default(), - } + tab(vec![group(paths)], DuplicatesConfig::default()) } fn frame( @@ -37,7 +43,7 @@ fn frame( let mut actions = DuplicatesActions::default(); let out = ctx.run(raw_input(SCREEN, events), |ctx| { 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); @@ -81,17 +87,39 @@ fn context_menu_on( (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. fn header_of(ctx: &egui::Context, tab: &mut DuplicatesTab) -> String { let (out, _) = frame(ctx, tab, false, Vec::new()); - painted_text(&out) + headers(&out) .into_iter() - .find(|t| t.contains("reclaimable")) + .next() .expect("no group header painted") } +/// Every group header on screen, in listed order. +fn headers(out: &egui::FullOutput) -> Vec { + painted_text(out) + .into_iter() + .filter(|t| t.contains("reclaimable")) + .collect() +} + const PATHS: [&str; 3] = ["/a/img.raw", "/b/img.raw", "/c/img.raw"]; +// --- The verification, and how it is found --------------------------------- + #[test] fn a_group_header_offers_the_verification() { 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 header = header_of(&ctx, &mut tab); context_menu_on(&ctx, &mut tab, false, &header); - - 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), - ); + let actions = click_menu_entry(&ctx, &mut tab, VERIFY_LABEL); assert_eq!( actions.verify, Some(PATHS.iter().map(|p| p.to_string()).collect::>()) @@ -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 /// default order set by hand. fn sized_group(name: &str, redundant: i64) -> DuplicateGroup { let mut group = group(&[&format!("/a/{name}"), &format!("/b/{name}")]); group.hash = name.as_bytes().to_vec(); 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 } /// The names of the groups, in the order the tab would list them. fn listed(groups: Vec, sort: DupSort) -> Vec { + let filters = DupFilters::new(&DuplicatesConfig::default()); let mut loaded = LoadedGroups::new(groups); - loaded.sort(sort); + loaded.rebuild(sort, &filters, false); loaded - .order + .rows .iter() - .map(|&i| loaded.groups[i].members[0].1.clone()) + .map(|row| loaded.groups[row.group].members[row.members[0]].1.clone()) .collect() } @@ -260,13 +341,14 @@ fn the_order_returns_when_the_choice_does() { sized_group("small.jpg", 10), sized_group("middling.txt", 100), ]; + let filters = DupFilters::new(&DuplicatesConfig::default()); let mut loaded = LoadedGroups::new(groups); - loaded.sort(DupSort::Extension); - loaded.sort(DupSort::Reclaimable); + loaded.rebuild(DupSort::Extension, &filters, false); + loaded.rebuild(DupSort::Reclaimable, &filters, false); let names: Vec<&str> = loaded - .order + .rows .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(); assert_eq!(names, ["big.txt", "small.jpg", "middling.txt"]); } @@ -276,27 +358,18 @@ fn the_order_returns_when_the_choice_does() { #[test] fn choosing_an_order_relists_without_rescanning() { let ctx = crate::test_ui::ctx(); - let mut tab = DuplicatesTab { - state: DupState::Loaded(LoadedGroups::new(vec![ - sized_group("b.txt", 900), - sized_group("a.jpg", 10), - ])), - sort: DupSort::default(), - }; + let mut tab = tab( + vec![sized_group("b.txt", 900), sized_group("a.jpg", 10)], + DuplicatesConfig::default(), + ); - let order_on_screen = |out: &egui::FullOutput| -> Vec { - painted_text(out) - .into_iter() - .filter(|t| t.contains("reclaimable")) - .collect() - }; 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:?}"); tab.sort = DupSort::Extension; 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!( !actions.refresh, @@ -305,16 +378,362 @@ fn choosing_an_order_relists_without_rescanning() { 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] -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 mut tab = DuplicatesTab { - state: DupState::Loaded(LoadedGroups::new(Vec::new())), - sort: DupSort::default(), - }; - let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0); + let mut tab = tab(vec![group(&PATHS)], excluding(&["/c/*"])); + let (out, _) = frame(&ctx, &mut tab, false, Vec::new()); + let header = headers(&out) + .into_iter() + .next() + .expect("group went missing"); assert!( - painted.contains(&"No duplicate files found.".to_string()), - "{painted:?}" + header.starts_with("2 × img.raw:"), + "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" ); } diff --git a/crates/quicksearch-gui/src/help_tab.rs b/crates/quicksearch-gui/src/help_tab.rs index beeecbf..d3fccf6 100644 --- a/crates/quicksearch-gui/src/help_tab.rs +++ b/crates/quicksearch-gui/src/help_tab.rs @@ -1,45 +1,97 @@ -//! The Help tab: a quickstart guide for first-time users. The complete -//! technical reference stays in README.md. +//! The Help tab: what QuickSearch is, how to start, how results are ranked +//! 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. pub fn ui(ui: &mut egui::Ui) -> bool { 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() .auto_shrink([false; 2]) .show(ui, |ui| { - // Cap the column like a document page: a maximized window - // would otherwise stretch every paragraph into one long line. - ui.set_max_width(620.0); + ui.set_max_width(column); ui.heading(egui::RichText::new("Welcome to QuickSearch").strong()); ui.add_space(4.0); ui.label( - "QuickSearch keeps an index of the folders you choose and finds \ - files by name and by what is inside them, as you type.", + "QuickSearch is a search engine for the files on this computer \ + 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); if ui.button("Show the introduction again").clicked() { 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.heading(egui::RichText::new("Getting started").strong()); ui.add_space(4.0); ui.label( "1. The first time QuickSearch runs it starts indexing your home \ 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( - "2. To index different folders, open the Manage Index tab and edit \ - the folder list. Indexed folders are watched, so the index follows \ - your files as they change.", + "2. To search other places, open the Manage Index tab and add them \ + to the folder list. Indexed folders are watched, so the index \ + follows your files as they change, and adding one leaves \ + everything already indexed alone.", ); ui.label( "3. Type in the search box on the Search tab. Results appear as \ 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.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.add_space(6.0); ui.label( - "• Tick Fuzzy to also find matches with typos in them, at some \ - cost in speed.", - ); - ui.label( - "• 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.", + "• A match in a file's name or path is highlighted in that \ + column; a match in its contents shows a snippet of the \ + surrounding text in the Content Match column, with the rest on \ + hover.", ); ui.label( "• Right-click a result to open it, open its containing folder, \ or hide files like it from the results.", ); ui.label( - "• A match in a file's name or path is highlighted in that \ - column; a match in its contents shows a snippet of the \ - surrounding text in the Content Match column, with the rest on \ - hover.", + "• Click a column header to sort by it; right-click any header to \ + choose which columns are shown.", ); + 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.heading(egui::RichText::new("The other tabs").strong()); ui.add_space(4.0); + let prose = prose_width(ui.available_width()); egui::Grid::new("help-tabs") .num_columns(2) - .spacing([18.0, 5.0]) + .spacing([CELL_SPACING, 5.0]) .show(ui, |ui| { let row = |ui: &mut egui::Ui, name: &str, what: &str| { ui.strong(name); - ui.label(what); + cell(ui, prose, what); ui.end_row(); }; row( @@ -94,14 +161,10 @@ pub fn ui(ui: &mut egui::Ui) -> bool { row( ui, "Duplicates", - "files whose contents are identical, grouped", - ); - row( - ui, - "Logs", - "warnings from indexing and folder watching that a \ - terminal would have shown", + "files that are probably identical copies of each other, \ + grouped; verify before deleting anything", ); + row(ui, "Logs", "warnings from issues during indexing"); row( ui, "Settings", @@ -149,6 +212,319 @@ pub fn ui(ui: &mut egui::Ui) -> bool { 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) { + 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` /// (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 @@ -186,4 +562,84 @@ mod tests { ); 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); + } + } + } } diff --git a/crates/quicksearch-gui/src/hotkey/mod.rs b/crates/quicksearch-gui/src/hotkey/mod.rs index ebe8d64..0df3752 100644 --- a/crates/quicksearch-gui/src/hotkey/mod.rs +++ b/crates/quicksearch-gui/src/hotkey/mod.rs @@ -1,7 +1,14 @@ -//! The system-wide shortcut that raises QuickSearch and focuses the search -//! box. Windows and X11 claim the key via `global-hotkey` -//! (`RegisterHotKey`/`XGrabKey`); Wayland refuses grabs by design, so the -//! shortcut goes through the XDG portal and the *desktop* owns the binding. +//! The in-application half of the search shortcut: the key QuickSearch +//! claims for itself while it is running. Windows and X11 grant that via +//! `global-hotkey` (`RegisterHotKey`/`XGrabKey`); Wayland refuses grabs by +//! design, so it goes through the XDG portal and the *desktop* picks the key. +//! +//! 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 //! process-wide, the event handler is set-once, and on Windows @@ -11,21 +18,14 @@ mod binding; #[cfg(all(unix, not(target_os = "macos")))] mod portal; -mod raise; pub use binding::{parse_setting, Binding}; -pub use raise::raise; use std::cell::RefCell; -use std::sync::atomic::{AtomicBool, Ordering}; use global_hotkey::hotkey::HotKey; 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! { static REGISTRY: RefCell> = 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 { REGISTRY.with_borrow(|slot| match slot.as_ref() { 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) { - FIRED.store(true, Ordering::SeqCst); - ctx.request_repaint(); + crate::activate::fire(ctx); } impl Backend { @@ -225,14 +222,7 @@ mod tests { fn an_uninitialised_registry_is_inert() { apply("Ctrl+Shift+F"); assert_eq!(status(), Status::Disabled); - assert!(!take_fired()); - } - - #[test] - fn a_press_is_reported_once() { - FIRED.store(true, Ordering::SeqCst); - assert!(take_fired()); - assert!(!take_fired(), "the flag is consumed"); + assert!(!crate::activate::take_pending()); } /// `Idle` must accept every call: `apply` runs on every config save. diff --git a/crates/quicksearch-gui/src/hotkey/raise.rs b/crates/quicksearch-gui/src/hotkey/raise.rs deleted file mode 100644 index 4793a2a..0000000 --- a/crates/quicksearch-gui/src/hotkey/raise.rs +++ /dev/null @@ -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> { - 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 - } - } -} diff --git a/crates/quicksearch-gui/src/main.rs b/crates/quicksearch-gui/src/main.rs index 1fb1757..108c41a 100644 --- a/crates/quicksearch-gui/src/main.rs +++ b/crates/quicksearch-gui/src/main.rs @@ -10,6 +10,7 @@ #[global_allocator] static GLOBAL: quicksearch_core::platform::Allocator = quicksearch_core::platform::Allocator; +mod activate; mod app; mod backend; #[cfg(feature = "capture")] @@ -64,14 +65,27 @@ fn seed_query() -> Option { } } +/// 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() { // First: printing without a stdio handle panics rather than failing quietly. #[cfg(windows)] platform::redirect_null_stdio(); + let toggle = wants_toggle(); + + // Before terminal mode, which would read `--toggle` as an unknown flag. #[cfg(not(windows))] - if let Some(code) = cli::maybe_run_cli() { - std::process::exit(code); + if !toggle { + if let Some(code) = cli::maybe_run_cli() { + std::process::exit(code); + } } // A broken config must never keep the window from opening. @@ -79,6 +93,14 @@ fn main() { Ok(c) => (c, None), 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 // `0` leaves it derived from the index. quicksearch_core::db::set_search_cache_override( @@ -93,6 +115,12 @@ fn main() { match IndexLock::hold(&config.resolved_database_path()) { Ok(()) => {} 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 { Some(pid) => format!(" (process {})", pid), None => String::new(), @@ -146,12 +174,21 @@ fn main() { // egui has no bundled fonts; this closure is the last place // still ahead of frame 1. 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 // closure is the first place that is true. Before the gate, so // the shortcut works while the unlock screen is up. hotkey::init(&cc.egui_ctx, &config.ui.search_hotkey); // Before the gate so the unlock screen honors the setting. 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 { Some(source) => { unlock::Gate::running(&cc.egui_ctx, config, config_error, initial_query, source) diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index 52edda5..358c63f 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -241,10 +241,17 @@ impl ManageTab { // Safe because the Remove click surrenders focus before the // shift — pinned by tests::removing_a_root_does_not_leak_an_edit_onto_another_row. 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| { // Controls claim the right edge first. 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)] tests::record_widget("remove", &remove_btn); 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 { let removed = draft.paths.indexing_paths.remove(i); draft.indexing.root_workers.remove(&removed); @@ -342,33 +352,35 @@ impl ManageTab { } }); ui.label(hint( - "Removing a folder removes its entries and leaves the rest of \ - the index untouched; adding one reindexes to pick it up. \ - Neither rebuilds.", + "Adding a folder does not replace the current index, it adds to it.", + )); + 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(); // --- Filters --------------------------------------------------- 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| { cols[0] - .label("Full-text extensions whitelist (empty = all supported):") - .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):") + .label("Ignore patterns (files and folders never indexed):") .tip(&tips::IGNORE_PATTERNS); let mut remove_pat: Option = None; // The list grows and shrinks: keep it off the id of the // 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() { ui.horizontal(|ui| { ui.with_layout( @@ -394,7 +406,7 @@ impl ManageTab { if let Some(i) = remove_pat { draft.indexing.ignore_patterns.remove(i); } - cols[1].horizontal(|ui| { + cols[0].horizontal(|ui| { let (response, valid) = crate::ui_util::pattern_edit( ui, &mut self.new_ignore, @@ -403,6 +415,9 @@ impl ManageTab { ); let submitted = 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); if ui .add_enabled(valid, egui::Button::new("Add")) @@ -417,12 +432,29 @@ impl ManageTab { self.new_ignore.clear(); } }); - crate::ui_util::pattern_hint_label(&mut cols[1], &self.new_ignore); - cols[1].label(hint( + crate::ui_util::pattern_hint_label(&mut cols[0], &self.new_ignore); + cols[0].label(hint( "Changes apply on Apply & Save. A new pattern removes the \ - entries it matches; removing one reindexes to bring them \ - back.", + entries it matches; deleting a pattern from this list \ + 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(); @@ -597,7 +629,7 @@ fn watch_contents(ui: &mut egui::Ui, state: &IndexerState, config: &Config) { WatcherStatus::Active { dirs } => { ui.label( egui::RichText::new(format!( - "Live updates on, watching {} folders", + "Live index updates, watching {} folders", group_thousands(*dirs as u64) )) .small() @@ -608,7 +640,7 @@ fn watch_contents(ui: &mut egui::Ui, state: &IndexerState, config: &Config) { ui.colored_label( ui.visuals().warn_fg_color, format!( - "Live updates off; reindexing every {}", + "Periodic index updates; updating every {}", fmt_interval(config.indexing.reindex_interval_minutes) ), ) @@ -836,8 +868,14 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress, maintenance: Option String { 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)] mod tests { #[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); + } + } +} diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index 0efb71d..a345170 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -21,8 +21,7 @@ mod snippet_render; #[cfg(test)] mod tests; -use crate::ui_util::hint; -use ignore_dialog::dir_ignore_pattern; +use crate::ui_util::{dir_ignore_pattern, hint}; pub use ignore_dialog::IgnoreDialog; use snippet_render::{centered_match_job, marked_field_job, path_cell_job, snippet_job}; diff --git a/crates/quicksearch-gui/src/search_tab/ignore_dialog.rs b/crates/quicksearch-gui/src/search_tab/ignore_dialog.rs index 63c12f8..f7d0adc 100644 --- a/crates/quicksearch-gui/src/search_tab/ignore_dialog.rs +++ b/crates/quicksearch-gui/src/search_tab/ignore_dialog.rs @@ -11,14 +11,6 @@ pub struct IgnoreDialog { 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 { pub(super) fn ignore_dialog_ui(&mut self, ctx: &egui::Context, actions: &mut SearchActions) { use crate::ui_util::{bordered_button, pattern_edit}; diff --git a/crates/quicksearch-gui/src/settings_tab.rs b/crates/quicksearch-gui/src/settings_tab.rs index 38efe20..d84d792 100644 --- a/crates/quicksearch-gui/src/settings_tab.rs +++ b/crates/quicksearch-gui/src/settings_tab.rs @@ -261,10 +261,6 @@ impl SettingsTab { )); 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()); config_editor_ui(ui, draft, Section::Search, indexed_files, form); ui.add_space(6.0); @@ -276,7 +272,7 @@ impl SettingsTab { egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { form.row(Level::Everyday, ui, "UI scale", &tips::UI_SCALE, |ui| { 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) .fixed_decimals(2), ) @@ -297,6 +293,7 @@ impl SettingsTab { ); }); hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey); + shortcut_note(ui); ui.separator(); // 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); 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); ui.horizontal(|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 /// 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); ui.horizontal(|ui| { 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. /// 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 diff --git a/crates/quicksearch-gui/src/settings_tab/tests.rs b/crates/quicksearch-gui/src/settings_tab/tests.rs index 616eb5d..8865076 100644 --- a/crates/quicksearch-gui/src/settings_tab/tests.rs +++ b/crates/quicksearch-gui/src/settings_tab/tests.rs @@ -361,8 +361,9 @@ fn every_row_shows_its_own_tip() { let mut run = |events: Vec| { let input = crate::test_ui::raw_input(egui::vec2(600.0, 800.0), events); ctx.run(input, |ctx| { - egui::CentralPanel::default() - .show(ctx, |ui| config_editor_ui(ui, &mut cfg, *section, None, form)); + egui::CentralPanel::default().show(ctx, |ui| { + 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" ); } + +/// 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}"); +} diff --git a/crates/quicksearch-gui/src/spotlight.rs b/crates/quicksearch-gui/src/spotlight.rs index 5916765..c2a8f1f 100644 --- a/crates/quicksearch-gui/src/spotlight.rs +++ b/crates/quicksearch-gui/src/spotlight.rs @@ -20,6 +20,10 @@ use crate::app::Tab; pub enum Spot { /// The *Add folder… / path box / Add* row of Manage Index's folder list. 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. SearchBar, /// The `?` button that opens the query-syntax window. diff --git a/crates/quicksearch-gui/src/test_ui.rs b/crates/quicksearch-gui/src/test_ui.rs index a752c2b..caadb30 100644 --- a/crates/quicksearch-gui/src/test_ui.rs +++ b/crates/quicksearch-gui/src/test_ui.rs @@ -40,6 +40,8 @@ pub fn click_at(pos: egui::Pos2) -> Vec { pub fn ctx() -> egui::Context { let ctx = egui::Context::default(); 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 } @@ -137,6 +139,19 @@ pub fn painted_spans(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> { .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: /// headers use the same *text* color, so [`painted_spans`] cannot tell them apart. pub fn painted_backgrounds(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> { diff --git a/crates/quicksearch-gui/src/tips.rs b/crates/quicksearch-gui/src/tips.rs index 30e06ea..63c632b 100644 --- a/crates/quicksearch-gui/src/tips.rs +++ b/crates/quicksearch-gui/src/tips.rs @@ -421,16 +421,15 @@ pub static SEARCH_HOTKEY: Tip = Tip { start typing.\n\n\ Click the button and press the keys you want. Combine Ctrl, Alt \ and Shift with one other key. Clear switches the shortcut off.\n\n\ - On Wayland the shortcut is registered with your desktop rather \ - than claimed directly, so your desktop may assign a different key \ - or ask you to confirm it, and its own keyboard settings are where \ - to change it afterwards. Wayland also does not let any application \ - put itself in front of what you are doing, so there the shortcut \ - selects the Search tab and the search box, but bringing the window \ - forward is up to your desktop.", + This works whenever QuickSearch is running and needs no setting \ + up, but it cannot start QuickSearch. For a shortcut that opens it \ + too, bind the command shown below in your desktop's own keyboard \ + settings.\n\n\ + On Wayland your desktop registers the shortcut, so it may pick a \ + different key and owns it afterwards, and it decides whether the \ + window comes forward.", examples: &[ - "Ctrl+Shift+F, the default, which few other programs use.", - "Ctrl+Alt+Space if something else on your system already answers to it.", + "Ctrl+Shift+F, the default, which few other programs use." ], caution: None, }; @@ -567,18 +566,22 @@ pub static ADD_ROOT: Tip = Tip { body: "Adds a folder for QuickSearch to index, along with everything \ inside it. Choose it with the browser, or type the path and press \ 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 \ - another one is refused. Adding a folder starts an indexing pass to \ - pick it up and leaves the rest of the index alone.", + another one is refused.", examples: &["a second drive, or a network share you search often."], caution: None, }; pub static REMOVE_ROOT: Tip = Tip { title: "Remove this folder", - body: "Stops indexing this folder and removes its entries from the \ - index. The rest of the index is left alone, and the files \ - themselves are not touched.\n\n\ + body: "Stops indexing the folder named on this line, and removes that \ + folder's entries from the index.\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.", examples: &[], caution: None, @@ -618,18 +621,20 @@ pub static ROOT_COUNTS: Tip = Tip { pub static EXT_WHITELIST: Tip = Tip { title: "Full-text extensions whitelist", - body: "Which kinds of file QuickSearch reads the text out of, one \ - extension per line, the leading dot optional. Empty means every \ - kind it understands.\n\n\ - Every file is still indexed by name whatever you put here. A list \ - also leaves out files with no extension at all, such as Makefile \ - or README, unless you add the line (none). Anything after a # is a \ - comment, so a file type can be switched off without losing the \ - line.\n\n\ + body: "Which kinds of file QuickSearch is allowed to read the text out \ + of. It limits contents only: every file is still indexed and still \ + found by its name and its path, whatever you put here. A file left \ + off the list simply cannot be found by the words inside it.\n\n\ + Empty, the default, means every kind QuickSearch understands. To \ + narrow it, enter one extension per line, the leading dot optional. \ + A non-empty list also leaves out files with no extension at all, \ + such as Makefile or README, unless you add the line (none). \ + Anything after a # is a comment.\n\n\ Narrowing the list discards the text it now excludes; widening it \ reads those files again.", 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.", ], caution: None, @@ -637,19 +642,21 @@ pub static EXT_WHITELIST: Tip = Tip { pub static IGNORE_PATTERNS: Tip = Tip { title: "Ignore patterns", - body: "Files and folders left out of the index entirely, by name and by \ - content alike. Type one pattern and click Add.\n\n\ - A pattern without a slash matches a file or folder name anywhere, \ - and must match the whole name: .jpg matches only something called \ - exactly that, while *.jpg matches every JPEG. A pattern with a \ - slash in it is matched against the whole path, and skips \ - everything underneath. * stands for any run of characters and ? \ - for a single one.\n\n\ - Adding a pattern removes the entries it matches; removing one \ - indexes them again.", + body: "Whole files and folders kept out of the index. A pattern is \ + compared against names and paths, never against what is inside a \ + file: nothing is excluded for the words it holds, and a file \ + excluded here loses its text along with its name. Type one pattern \ + and click Add.\n\n\ + A pattern with no slash matches a file or folder name anywhere \ + under your indexed folders, and must match that name in full. A \ + pattern with a slash is matched against the whole path, and skips \ + everything under it. * is any run of characters, ? exactly one.\n\n\ + 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: &[ "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.", ], caution: None, diff --git a/crates/quicksearch-gui/src/tutorial.rs b/crates/quicksearch-gui/src/tutorial.rs index a0924b7..70b4471 100644 --- a/crates/quicksearch-gui/src/tutorial.rs +++ b/crates/quicksearch-gui/src/tutorial.rs @@ -36,6 +36,19 @@ struct Page { spots: &'static [Spot], /// Typed into the search box a character at a time, on entry. type_query: Option<&'static str>, + /// A control of the page's own, under its prose. + extra: Option, +} + +/// 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. @@ -47,15 +60,15 @@ const PLAIN: Page = Page { tab: None, spots: &[], type_query: None, + extra: None, }; const PAGES: &[Page] = &[ Page { title: "Welcome to QuickSearch", body: &[ - "QuickSearch keeps an index of the folders you choose, and searches \ - as you type.", - "By default only your user folder is indexed and searchable.", + "QuickSearch is a search engine for your local files and their contents.", + "By default, only your user folder is indexed and searchable.", "Because the answers come from the index rather than from reading \ your disk, results appear as fast as you can type, even across \ hundreds of thousands of files.", @@ -65,6 +78,24 @@ const PAGES: &[Page] = &[ if it covers something. Hit Skip to exit.", ), 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 }, Page { @@ -85,11 +116,16 @@ const PAGES: &[Page] = &[ 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 \ 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 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."), tab: Some(Tab::Manage), + spots: &[Spot::IgnorePatterns, Spot::ExtWhitelist], ..PLAIN }, Page { @@ -139,13 +175,13 @@ const PAGES: &[Page] = &[ ..PLAIN }, Page { - title: "Typos, and what a result can do", + title: "Fuzzy Finding", body: &[ "Tick [Fuzzy] beside the search box to also match words with typos \ - in them — \"repot\" will find \"report\". It searches more \ - thoroughly, so it is a little slower; leave it off until you need \ - it.", - "Right-click any result for more: open it, open the folder holding \ + in them! When checked, \"repot\" will find \"report\". It searches more \ + thoroughly, so it is a little slower to find all results, but the exact \ + matches will be found just as fast and be displayed first.", + "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 \ future searches.", ], @@ -162,10 +198,16 @@ const PAGES: &[Page] = &[ "It is a quick way to find the same download sitting in three \ places. QuickSearch only shows you the groups; deleting anything is \ 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( - "For speed, files are compared by size and by how they begin (first 8KB). \ - This is not a guarantee of an exact match. You can right click a result to verify before you delete anything.", + "A group you never want to see again can be hidden, and a folder that is \ + meant to hold copies can be excluded — both from that same right-click \ + menu.", ), tab: Some(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 } +/// 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 { + 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, /// but under the tour's own window, which the user can drag aside rather /// than have the glow drawn across. @@ -340,6 +453,12 @@ pub struct TourActions { pub goto_tab: Option, pub set_query: Option, pub focus_search: bool, + /// A new UI scale from the welcome page's slider, to apply live. + pub set_scale: Option, + /// 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, } pub struct Tutorial { @@ -351,6 +470,9 @@ pub struct Tutorial { typing: Option, /// The user has dragged the window, so where it sits is their business. 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 { @@ -360,11 +482,14 @@ impl Tutorial { shown: None, typing: None, moved: false, + capturing_hotkey: false, } } - /// `roots` is the live indexed-folder list, which the folders page reads. - pub fn ui(&mut self, ctx: &egui::Context, roots: &[String]) -> TourActions { + /// `roots` is the live indexed-folder list, which the folders page reads; + /// `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 (first, last) = (self.page == 0, self.page + 1 == PAGES.len()); let mut actions = TourActions::default(); @@ -393,6 +518,14 @@ impl Tutorial { } 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 window = egui::Window::new(page.title) .id(egui::Id::new(WINDOW_ID)) @@ -416,8 +549,19 @@ impl Tutorial { ui.label(job); 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 { - ui.label(hint(pointer)); + ui.label(note(ui, pointer)); } ui.add_space(10.0); @@ -459,6 +603,7 @@ impl Tutorial { if shown.is_some_and(|window| window.response.dragged()) { self.moved = true; } + self.capturing_hotkey = capturing; // The widgets this page names, in the colours its keywords were given. let dark_mode = ctx.style().visuals.dark_mode; diff --git a/crates/quicksearch-gui/src/tutorial/tests.rs b/crates/quicksearch-gui/src/tutorial/tests.rs index fd43324..f88885c 100644 --- a/crates/quicksearch-gui/src/tutorial/tests.rs +++ b/crates/quicksearch-gui/src/tutorial/tests.rs @@ -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 /// wording is the one every other test should see, whatever machine it runs on. 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 { vec![ROOT.to_string()] @@ -19,6 +21,7 @@ fn at(page: usize) -> Tutorial { shown: Some(page), typing: None, moved: false, + capturing_hotkey: false, } } @@ -30,6 +33,7 @@ fn entering(page: usize) -> Tutorial { shown: None, typing: None, moved: false, + capturing_hotkey: false, } } @@ -43,7 +47,7 @@ fn pass( let roots = roots(); let mut actions = TourActions::default(); let out = ctx.run(raw_input_at(SCREEN, events, time), |ctx| { - actions = tour.ui(ctx, &roots); + actions = tour.ui(ctx, &roots, HOTKEY); }); (out, actions) } @@ -54,6 +58,9 @@ fn merge(a: TourActions, b: TourActions) -> TourActions { goto_tab: b.goto_tab.or(a.goto_tab), set_query: b.set_query.or(a.set_query), focus_search: a.focus_search || b.focus_search, + set_scale: b.set_scale.or(a.set_scale), + save_scale: a.save_scale || b.save_scale, + set_hotkey: b.set_hotkey.or(a.set_hotkey), } } @@ -197,7 +204,7 @@ fn the_keyword_and_its_ring_pulse_in_step() { ctx.run(raw_input_at(SCREEN, Vec::new(), time), |ctx| { crate::spotlight::set_active(ctx, true); crate::spotlight::mark(ctx, Spot::StatusBar, target); - tour.ui(ctx, &roots); + tour.ui(ctx, &roots, HOTKEY); }) }; 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] fn the_folders_page_says_which_folders_those_are() { // 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 /// run on a middle page: on the last, Finish and Skip both dismiss /// without moving; on the first, Back is disabled. @@ -583,7 +840,7 @@ fn footer_spans(ctx: &egui::Context, page: usize) -> [Option; 3] { #[test] fn the_footer_runs_back_then_skip_then_next_at_the_same_size() { 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 skip = skip.expect("no Skip 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 mut run = |size: egui::Vec2| { let _ = ctx.run(raw_input_at(size, Vec::new(), 0.0), |ctx| { - tour.ui(ctx, &roots); + tour.ui(ctx, &roots, HOTKEY); }); let rect = ctx .memory(|m| m.area_rect(egui::Id::new(WINDOW_ID))) diff --git a/crates/quicksearch-gui/src/ui_util.rs b/crates/quicksearch-gui/src/ui_util.rs index 7c034a7..f245734 100644 --- a/crates/quicksearch-gui/src/ui_util.rs +++ b/crates/quicksearch-gui/src/ui_util.rs @@ -23,6 +23,15 @@ pub fn stable_section(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) 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 /// emptiness is checked here with the same trimming rules. pub fn ignore_pattern_valid(pattern: &str) -> bool { diff --git a/crates/quicksearch-gui/src/unlock.rs b/crates/quicksearch-gui/src/unlock.rs index a6c09cf..1cce567 100644 --- a/crates/quicksearch-gui/src/unlock.rs +++ b/crates/quicksearch-gui/src/unlock.rs @@ -52,10 +52,11 @@ impl Gate { Gate::Locked(UnlockScreen::new(cfg, config_error, initial_query)) } - /// Act on the system-wide search shortcut. Handled here because while - /// locked the unlock screen *is* the window. - fn handle_hotkey(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { - if !crate::hotkey::take_fired() { + /// Act on a search shortcut, whether QuickSearch's own registration fired + /// it or a `--toggle` process relayed the desktop's. Handled here because + /// while locked the unlock screen *is* the window. + fn handle_activation(&mut self, ctx: &egui::Context, frame: &eframe::Frame) { + if !crate::activate::take_pending() { return; } if let Gate::Running(app) = self { @@ -65,13 +66,13 @@ impl Gate { } app.activate_search(ctx); } - crate::hotkey::raise(ctx, frame); + crate::activate::raise(ctx, frame); } } impl eframe::App for Gate { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { - self.handle_hotkey(ctx, frame); + self.handle_activation(ctx, frame); match self { Gate::Running(app) => app.update(ctx, frame), Gate::Locked(screen) => { diff --git a/packaging/quicksearch.1 b/packaging/quicksearch.1 index 920a1b3..d36c07f 100644 --- a/packaging/quicksearch.1 +++ b/packaging/quicksearch.1 @@ -7,6 +7,8 @@ quicksearch, quicksearch\-cli \- fast full\-text search across your files .SH SYNOPSIS .B quicksearch .br +.B quicksearch \-\-toggle +.br .B quicksearch .RI [ FLAGS ] .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. .SH OPTIONS .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 Also run the fuzzy filename and full\-text passes, which tolerate spelling 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 in the configuration. .TP +.IR $XDG_RUNTIME_DIR /quicksearch\- .sock +The socket +.B \-\-toggle +connects to, where +.I +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 A configuration file placed next to the .B quicksearch diff --git a/packaging/quicksearch.desktop b/packaging/quicksearch.desktop index e0c579e..18ab1d5 100644 --- a/packaging/quicksearch.desktop +++ b/packaging/quicksearch.desktop @@ -17,3 +17,11 @@ StartupNotify=true # the Wayland app id and the X11 WM_CLASS. Without the match the desktop shows a # generic window icon. 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 diff --git a/packaging/quicksearch.nsi b/packaging/quicksearch.nsi index abaf661..f11a5f9 100644 --- a/packaging/quicksearch.nsi +++ b/packaging/quicksearch.nsi @@ -169,11 +169,33 @@ Section "Start Menu shortcut" SecStartMenu ; 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 ; 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 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 ; 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." !insertmacro MUI_DESCRIPTION_TEXT ${SecStartMenu} \ "Add ${APP} to the Start menu for all users." + !insertmacro MUI_DESCRIPTION_TEXT ${SecHotkey} \ + "Press Ctrl+Alt+F anywhere to search, starting ${APP} if it is not \ + already running. Windows allows this only on Ctrl+Alt combinations; \ + the Settings tab has one that takes any keys while ${APP} is open." !insertmacro MUI_DESCRIPTION_TEXT ${SecDesktop} \ "Add a ${APP} shortcut to the desktop." !insertmacro MUI_FUNCTION_DESCRIPTION_END