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