From 9760503a3f930cd5f960f5c617e632dfbab31445 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 17 Aug 2026 19:26:18 -0400 Subject: [PATCH] Release v1.1: live results, duplicate verification, and a Settings tab. Search results now track the disk as it changes (live.rs), duplicate groups can be confirmed byte-for-byte before deletion (verify.rs), the Options window becomes a Settings tab with configurable/sortable result columns, and a first-start tutorial explains the basics. --- .forgejo/workflows/ci.yml | 6 - Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 158 +- config_example.toml | 41 +- crates/quicksearch-core/examples/walkprobe.rs | 1 - crates/quicksearch-core/src/config/mod.rs | 62 + crates/quicksearch-core/src/config/tests.rs | 58 + crates/quicksearch-core/src/content.rs | 174 +- crates/quicksearch-core/src/coordinator.rs | 22 + .../quicksearch-core/src/coordinator/inner.rs | 111 +- .../quicksearch-core/src/coordinator/tests.rs | 143 ++ crates/quicksearch-core/src/db/repo.rs | 79 + crates/quicksearch-core/src/db/schema.rs | 19 +- crates/quicksearch-core/src/extract/mod.rs | 11 +- .../src/file_handling/batch.rs | 174 +- .../file_handling/count_and_extract_tests.rs | 11 + .../quicksearch-core/src/file_handling/mod.rs | 7 +- crates/quicksearch-core/src/incremental.rs | 67 +- crates/quicksearch-core/src/indexing/mod.rs | 49 +- .../quicksearch-core/src/indexing/pipeline.rs | 342 ++-- .../quicksearch-core/src/indexing/progress.rs | 25 +- crates/quicksearch-core/src/indexing/tests.rs | 183 +- crates/quicksearch-core/src/lib.rs | 2 + crates/quicksearch-core/src/live.rs | 734 ++++++++ crates/quicksearch-core/src/live_tests.rs | 655 +++++++ crates/quicksearch-core/src/platform.rs | 20 - crates/quicksearch-core/src/scope.rs | 9 - crates/quicksearch-core/src/scope_tests.rs | 41 - crates/quicksearch-core/src/search/cascade.rs | 56 + .../src/search/cascade/passes.rs | 165 +- crates/quicksearch-core/src/search/fuzzy.rs | 314 +++- crates/quicksearch-core/src/search/mod.rs | 49 + crates/quicksearch-core/src/snippet.rs | 66 +- crates/quicksearch-core/src/testutil.rs | 141 +- crates/quicksearch-core/src/verify.rs | 299 +++ crates/quicksearch-core/src/verify_tests.rs | 281 +++ crates/quicksearch-core/src/walk.rs | 8 +- crates/quicksearch-core/src/walk/tests.rs | 11 - crates/quicksearch-core/tests/cascade.rs | 172 +- crates/quicksearch-core/tests/full_index.rs | 384 +++- crates/quicksearch-core/tests/snippet_perf.rs | 3 +- crates/quicksearch-gui/src/app.rs | 246 ++- crates/quicksearch-gui/src/app/modals.rs | 52 +- crates/quicksearch-gui/src/app/security.rs | 273 ++- .../quicksearch-gui/src/app/security_tests.rs | 169 ++ crates/quicksearch-gui/src/app/status_bar.rs | 6 +- crates/quicksearch-gui/src/app/tests.rs | 124 +- crates/quicksearch-gui/src/app/verify.rs | 239 +++ .../quicksearch-gui/src/app/verify_tests.rs | 216 +++ crates/quicksearch-gui/src/backend.rs | 101 +- crates/quicksearch-gui/src/capture.rs | 2 +- crates/quicksearch-gui/src/capture/script.rs | 14 +- crates/quicksearch-gui/src/duplicates_tab.rs | 100 +- .../src/duplicates_tab/tests.rs | 186 ++ crates/quicksearch-gui/src/help_tab.rs | 30 +- crates/quicksearch-gui/src/hotkey/binding.rs | 16 +- crates/quicksearch-gui/src/hotkey/mod.rs | 4 +- crates/quicksearch-gui/src/hotkey/portal.rs | 6 +- crates/quicksearch-gui/src/main.rs | 3 +- crates/quicksearch-gui/src/manage_tab.rs | 50 +- .../quicksearch-gui/src/manage_tab/tests.rs | 8 +- crates/quicksearch-gui/src/search_tab.rs | 1182 ++++++++++-- .../src/search_tab/snippet_render.rs | 88 +- .../quicksearch-gui/src/search_tab/tests.rs | 1611 ++++++++++++++++- .../src/{options.rs => settings_tab.rs} | 268 +-- .../src/{options => settings_tab}/tests.rs | 279 ++- crates/quicksearch-gui/src/test_ui.rs | 25 + crates/quicksearch-gui/src/tips.rs | 66 +- crates/quicksearch-gui/src/tutorial.rs | 441 +++++ crates/quicksearch-gui/src/ui_util.rs | 52 +- crates/quicksearch-gui/src/unlock.rs | 6 +- packaging/capture-scenario.txt | 14 +- packaging/capture.sh | 30 +- packaging/quicksearch.1 | 2 +- packaging/quicksearch.nsi | 12 + 76 files changed, 9666 insertions(+), 1414 deletions(-) create mode 100644 crates/quicksearch-core/src/live.rs create mode 100644 crates/quicksearch-core/src/live_tests.rs create mode 100644 crates/quicksearch-core/src/verify.rs create mode 100644 crates/quicksearch-core/src/verify_tests.rs create mode 100644 crates/quicksearch-gui/src/app/security_tests.rs create mode 100644 crates/quicksearch-gui/src/app/verify.rs create mode 100644 crates/quicksearch-gui/src/app/verify_tests.rs create mode 100644 crates/quicksearch-gui/src/duplicates_tab/tests.rs rename crates/quicksearch-gui/src/{options.rs => settings_tab.rs} (71%) rename crates/quicksearch-gui/src/{options => settings_tab}/tests.rs (59%) create mode 100644 crates/quicksearch-gui/src/tutorial.rs diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 9cd25c5..3fb8887 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -63,12 +63,6 @@ jobs: HOME: /root # The highest libc6 version the .deb is allowed to require. MAX_GLIBC: '2.35' - # full_index.rs asserts a heavy indexing root cannot stall a light one, and - # measures that as wall-clock stall. The 100 ms default is calibrated on a - # developer machine; this runner measured 188 ms for the same correct - # behaviour. 600 ms keeps the check meaningful - the regression it exists to - # catch is ~6x the healthy figure, so it would land near 1.2 s here. - QSB_STALL_BUDGET_MS: '600' steps: - uses: actions/checkout@v4 diff --git a/Cargo.lock b/Cargo.lock index 9c4fab1..81b979d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3188,7 +3188,7 @@ dependencies = [ [[package]] name = "quicksearch-core" -version = "1.0.6" +version = "1.1.0" dependencies = [ "argon2", "cfb", @@ -3222,7 +3222,7 @@ dependencies = [ [[package]] name = "quicksearch-gui" -version = "1.0.6" +version = "1.1.0" dependencies = [ "ashpd", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 085e79a..9ddd46b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ # into a killed process instead of one skipped file. [workspace.package] -version = "1.0.6" +version = "1.1.0" edition = "2021" license = "GPL-3.0-or-later" authors = ["Jeremy "] diff --git a/README.md b/README.md index 33bca15..a54c462 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,9 @@ The install is per-machine and asks for elevation. Into | `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, for the +(off); both are created for all users. The final page lists what was installed +and where — the install itself takes about a second, which without saying so +reads as a failure — and offers to start QuickSearch, ticked. No `config.toml` is installed, for the same reason the `.deb` ships none — 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 @@ -224,12 +226,32 @@ inside that folder. `quicksearch` with no query arguments opens the app: - **Search**: results appear as you type; every keystroke cancels the - previous search. One checkbox enables the two fuzzy passes. Sort by - rank, name, path, size, or modified. Double-click a result to open it; + 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, which is what buys the width the path + and the match get instead. 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. Matches in file contents show highlighted snippets. + 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 + actually on screen are watched, and what they show is read from the + files themselves: a rename, a deletion or an edit lands within a + second, whether or not indexing is running. The rows coming on screen + are also checked against the disk as they are watched, so one the index + was already out of date about corrects itself; the index is then + brought back in line for those files alone. Over a network share, where + the system reports no events, that check is all you get — the row is + right when it comes on screen and then holds still. Nothing is ever + added, removed or re-ordered underneath you; a file that disappears is + struck through where it sits. Editing the query drops every watch. - **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 @@ -244,25 +266,45 @@ inside that folder. folder nothing has finished indexing reads "not yet indexed" rather than zero, and because the figures come from completed runs they do not move as live updates apply single changes in between. -- **Duplicates**: files sharing a content hash, grouped. +- **Duplicates**: files sharing a content hash, grouped. That hash covers + each file's size and its first `processing.hash_length` bytes and nothing + else, which is the whole reason indexing is affordable — and the reason a + group is a strong suspicion rather than a fact. 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. - **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. + 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`). +- **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, since the Search tab's own header menu writes the + same settings. 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, so the next thing you type is the new one. The -Options window's Interface section rebinds it — click the button and press +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 instead; your desktop then has the final say over which key it is, and its -own keyboard settings are where to change it. The Options window says which +own keyboard settings are where to change it. The Settings tab says which key it settled on. Wayland likewise gives no application a way to put itself in front of what you are doing, so under it the shortcut selects the Search tab and the search box but leaves raising the window to the desktop; on X11 @@ -301,7 +343,7 @@ processing; Windows Terminal has it, and older consoles get plain text. 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. **Options → Security → Enable password +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 @@ -315,6 +357,10 @@ rebuilds the index — there is no in-place conversion. 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//environ`) — prefer the keychain. @@ -397,13 +443,13 @@ containing the binary, its config, and its index can be moved wholesale. The GUI edits the config live; external edits apply on next start. `[ui] search_hotkey` is the system-wide search shortcut, written the way -the Options window prints it (`Ctrl+Shift+F`): Ctrl, Alt and Shift in any +the Settings tab prints it (`Ctrl+Shift+F`): Ctrl, Alt and Shift in any combination, plus one key, joined with `+`. An empty string switches it off. A value that is not a shortcut is not a config error — the app loads, -says so in the Options window, and runs without one. +says so on the Settings tab, and runs without one. -`[ui] color_scheme` is `dark` (the default) or `light`, changeable in the -Options window and applied without a restart. It does not follow the +`[ui] color_scheme` is `dark` (the default) or `light`, changeable on the +Settings tab and applied without a restart. It does not follow the desktop's own light/dark setting: on Linux nothing in the window system reports that, so the only way to know is to connect to the session message bus and subscribe to the user's settings feed — more of your session than a @@ -499,7 +545,25 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime. bigger index — `indexing.content_extensions` remains the throttle. Files no larger than `processing.hash_length` skip that second pass entirely: the head the walk reads to hash them is already their whole content, so a plaintext body is extracted in the same `read` and - stored complete. Every run ends — whether + stored complete. Every root runs its own pipeline — its own walker pool + and, once the walk ends, its own extraction pool — but every root's + *writes* go through one thread and one connection + (`indexing/pipeline.rs`), because that is what a single SQLite file + allows. That thread is where FTS5 tokenizes, up to `maximum_text_size` + of text per document inside the insert, and it is the run's dominant + cost. So its loop is scheduled around the walk, the disk-bound phase and + the one whose stall shows: each round serves every walking root first, + then one extracting root, and no turn runs past a 100 ms slice — an + extraction turn commits at the slice and carries the rows it did not + reach to its next turn. A walk therefore waits at most one slice per + round, which its walkers' channel absorbs, so a root walking a large tree + runs at its own rate while another root tokenizes big documents beside + it. What a root has left to extract is counted by its own content pass, + on that pass's read connection, rather than on the writer: on a large + root the count is seconds, and seconds of writer time is every other + root's walk standing still. Total write throughput is what one connection + tokenizing can do; the scheduling shares it fairly and keeps the walk + first, it does not raise it. Every run ends — whether it completed or was stopped — with an optimize pass on its own connection: checkpoint, VACUUM if the file has at least 10% slack to reclaim, `PRAGMA optimize`, checkpoint again. Progress streams through a polled @@ -555,6 +619,26 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime. handle and takes a single watch per root, filtering the events instead. Either way a tree too large to watch degrades to periodic reindexing rather than going silently stale. +- **Live results** (`live.rs`): a second, much smaller watcher, owned by the + frontend rather than the coordinator, pointed at the parent directories of + the result rows *currently on screen* once they have held still for a + moment. It watches directories, not the result files: editors save by + writing a temporary file and renaming it over the target, so the event + lands on the directory and a watch on the file is left holding an orphaned + inode. What a row shows is read from the **file**, never from the index — + metadata from `stat`, and for a content match the same MIME sniffing and + extractors the indexer uses, re-cut through the same `cascade::text_snippet` + the search itself does. That is what makes it work with indexing stopped. + Arming also sweeps each target once against the size and modified time the + row is displaying, which on a fresh result is what the index said: so + bringing a row on screen *is* a check of the index against the disk, and it + is the only thing that reports anything where the platform sends no events. + It still writes nothing itself; the paths it has just read go to + `IndexCoordinator::update_paths`, which applies them on the coordinator's + own thread — in any mode, so a stopped index does not drift from the screen + — leaving the single-writer rule 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, @@ -598,6 +682,21 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime. case-insensitive literal branch used to allocate a lowercased copy of its haystack, which the filename pass asked for twice per row of a full-table scan. +- **Duplicate verification** (`verify.rs`): the second opinion on a group from + `search/duplicates.rs`, which groups by `sha256(size ‖ head)` and so cannot + tell two pre-allocated disk images apart — same size, same zeroes at the + front, everything that distinguishes them in a footer. One lockstep pass: + open every member, drop the ones whose length already disagrees without + reading them, then read a chunk from the first that opened and the same + span from each of the others, reporting the offset of the first byte that + differs and dropping that file from the walk. Deliberately not a hash — + "the same digest" is a probabilistic answer, and a probabilistic answer is + what the head hash already gave. The reference is the first member that + *opens*, so one unreadable file costs its own verdict and nobody else's, + and termination follows what that file actually reads rather than the + length it claimed, so a file truncated mid-run degrades to a short + comparison. The read buffers share a fixed 8 MiB between them however many + members a group has, because a hardlink farm's group runs to thousands. - **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 @@ -630,14 +729,17 @@ core threads ─────────────▶ ctx.request_repaint() (w ``` 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 and -the confirmation modals), `search_tab.rs` (query strip and virtualized +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 rather than 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), `options.rs` (draft-based settings editor shared between the window -and the Manage tab), `platform.rs` (open / reveal-in-file-manager, and the +ring), `settings_tab.rs` (the draft-based config editor, the second of the +two tabs that stage their edits behind an Apply & Save), `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 @@ -650,12 +752,26 @@ microseconds regardless of row count. - `cargo test -p quicksearch-core`: unit + integration suites (cascade ranking, cancellation, incremental indexing, coordinator modes, config - resolution, fuzzy matcher vs. brute-force oracle). + resolution, fuzzy matcher vs. brute-force oracle, `verify.rs`'s byte-for-byte + comparison — the shared-head-different-tail case the head hash cannot see, an + unreadable first member, a difference past the first chunk, cancellation — + and `live.rs`'s event + classification, where the platform-specific rename and atomic-save shapes are + synthesized rather than provoked, 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 and manage tabs, the options editor, the unlock gate, the logs - and duplicates tabs, and query highlighting. + the search, manage and settings tabs, the unlock gate, the logs + and duplicates tabs, the first-start tour, and query highlighting. The search + tab's cover the column picker (including that the path column survives all + 32 combinations of the others), which column a match is highlighted in, and + that the repeat-search button appearing inside the query box does not cost it + keyboard focus. The duplicates tab's open the real context menus and click + the entries inside them, so "the verification asks for the whole group, from + either menu, and not at all while one is running" is checked rather than + assumed; the verification modal is rendered in each of its states, and the + tour's footer is probed for where its three buttons actually landed rather + than for the numbers they were expected to land on. - `cargo bench -p quicksearch-core --bench search` and `--bench index`: divan microbenchmarks over the two hot paths. Each group runs *what the code does today* against *the change being considered*, in one process on one corpus, diff --git a/config_example.toml b/config_example.toml index d4fa338..26a34e7 100644 --- a/config_example.toml +++ b/config_example.toml @@ -133,7 +133,7 @@ store_text_for_snippets = true [security] # Encrypt the index with a password (SQLCipher). The password is asked # for every time QuickSearch starts; turning this on or off deletes and -# rebuilds the index. Change it from the GUI (Options → Security), not by +# rebuilds the index. Change it from the GUI (Settings → Security), not by # hand: enabling protection also generates the KDF salt below. password_protected = false # Store the derived key in the OS keychain (Secret Service / KWallet on @@ -163,12 +163,18 @@ watch_cap_warned_roots = [] # registered with your desktop, which may assign a different key and lets # you change it in its own keyboard settings. search_hotkey = "Ctrl+Shift+F" -# 'dark' or 'light'. Applied as soon as it is changed in the Options -# window. Your desktop's own light/dark setting is not consulted: reading +# 'dark' or 'light'. Applied as soon as it is changed on the Settings +# tab. Your desktop's own light/dark setting is not consulted: reading # it would mean connecting to your session's message bus and subscribing to # your settings, which is more than a search tool should ask for. Anything # other than 'light' is dark. color_scheme = "dark" +# Written by QuickSearch, not by you: whether the short introduction shown +# on a brand-new installation has been dismissed. Absent means this config +# predates that introduction - an installation that upgraded into this +# version, which is not offered it. The Help tab can show it again at any +# time. +tutorial_seen = false [search] # Start with the fuzzy passes enabled. @@ -185,3 +191,32 @@ display_limit = 1000 results_per_page = 100 # How long the GUI waits after the last keystroke before searching (ms). debounce_ms = 150 +# Watch the search results on screen and show renames, deletions and +# content changes as they happen. Only the rows actually visible are +# watched, and editing the query drops the watches. What a row shows is +# read from the file itself, so this works whether or not indexing is +# running — and the files it reads are then brought up to date in the +# index, so what is stored cannot drift from what you are looking at. +# Rows are also checked against the disk as they come on screen, which is +# all you get over a network share, where the system does not report other +# machines' writes. Nothing is ever added, removed or re-ordered while you +# read. +live_results = true + +# Which columns the Search tab shows. The same choices are on the +# right-click menu of any column header, and in Settings → Search; both +# write here immediately, without an Apply. +# +# There is deliberately no 'path' key: the path is always shown, because +# it is the only column that identifies a result on its own. +[search.columns] +name = true +# The excerpt of a file's contents around the match. Rows that matched on +# their name or path show a dash there instead. +content_match = true +# Off by default: the width these take is usually better spent on the path +# and the matched text. Turning one on also makes it available to sort by; +# sorting by a column that is hidden falls back to sorting by rank. +size = false +modified = false +rank = true diff --git a/crates/quicksearch-core/examples/walkprobe.rs b/crates/quicksearch-core/examples/walkprobe.rs index 2c193ff..d85541c 100644 --- a/crates/quicksearch-core/examples/walkprobe.rs +++ b/crates/quicksearch-core/examples/walkprobe.rs @@ -122,7 +122,6 @@ fn parallel(root: &str, config: &Config, db_path: &str) -> (usize, usize) { config.clone(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, ) { let WalkEvent::File(file) = event else { diff --git a/crates/quicksearch-core/src/config/mod.rs b/crates/quicksearch-core/src/config/mod.rs index 98d9ef8..e2bb513 100644 --- a/crates/quicksearch-core/src/config/mod.rs +++ b/crates/quicksearch-core/src/config/mod.rs @@ -169,6 +169,47 @@ pub struct SearchConfig { pub results_per_page: usize, /// How long the GUI waits after the last keystroke before searching. pub debounce_ms: u64, + /// Watch the search results currently on screen and show renames, + /// deletions and content changes as they happen. Only the rows actually + /// visible are watched, and any edit to the query drops the watches. + /// What a row shows is read from the file itself, so this holds whether + /// or not indexing is running; the files it reads are then brought up to + /// date in the index, so what is stored cannot drift from what is on + /// screen. See [`crate::live`]. + pub live_results: bool, + /// Which columns the Search tab shows. + pub columns: ColumnsConfig, +} + +/// Which columns the Search tab shows, as picked from the right-click menu on +/// any column header or from the Settings tab. +/// +/// The path column is deliberately not represented: it is always shown, so +/// "no columns at all" is not a state this can hold. Size and modified are off +/// by default — the width they cost is better spent on the path and the match, +/// and both are one click away. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct ColumnsConfig { + pub name: bool, + /// The matched excerpt from a file's contents. Rows that matched on their + /// name or path instead show a dash there. + pub content_match: bool, + pub size: bool, + pub modified: bool, + pub rank: bool, +} + +impl Default for ColumnsConfig { + fn default() -> Self { + ColumnsConfig { + name: true, + content_match: true, + size: false, + modified: false, + rank: true, + } + } } impl SearchConfig { @@ -233,6 +274,8 @@ impl Default for SearchConfig { display_limit: 1000, results_per_page: 100, debounce_ms: 150, + live_results: true, + columns: ColumnsConfig::default(), } } } @@ -302,6 +345,21 @@ pub struct UiConfig { /// recognises falls back to dark, where a typed-out enum would fail to /// deserialize and take the whole config file down with it. pub color_scheme: String, + /// Whether the first-start tour has been dismissed. + /// + /// Three-valued on purpose. `None` means the key predates the tour — an + /// installation that upgraded into this version, which has already found + /// its way around — so only a config file this version *created* (which + /// gets `Some(false)` from [`UiConfig::default`]) is ever offered the tour. + /// A plain `bool` could not tell those apart. + /// + /// The field-level `default` is load-bearing and not redundant with the + /// `#[serde(default)]` on the struct: that one fills a missing field from + /// `UiConfig::default()`, which says `Some(false)` — and would hand every + /// upgrading installation the tour. This one fills it from + /// `Option::default()`, which is `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tutorial_seen: Option, } impl Default for UiConfig { @@ -311,6 +369,10 @@ impl Default for UiConfig { watch_cap_warned_roots: Vec::new(), search_hotkey: "Ctrl+Shift+F".to_string(), color_scheme: "dark".to_string(), + // Not `None`: a config built from these defaults is a config being + // written for the first time, and that is exactly who the tour is + // for. `None` is reserved for a file that predates the key. + tutorial_seen: Some(false), } } } diff --git a/crates/quicksearch-core/src/config/tests.rs b/crates/quicksearch-core/src/config/tests.rs index 49b0c2a..fcdc4cc 100644 --- a/crates/quicksearch-core/src/config/tests.rs +++ b/crates/quicksearch-core/src/config/tests.rs @@ -985,3 +985,61 @@ fn fuzzy_edits_warning_only_above_the_threshold() { assert!(msg.contains(&FUZZY_EDITS_WARN_ABOVE.to_string())); } } + +/// `config_example.toml` is the documentation for every setting, so a key +/// renamed in the struct and not here would silently ship a config file that +/// does nothing. Parsing it also proves the `[search.columns]` sub-table is +/// spelled the way serde expects. +#[test] +fn the_documented_example_config_parses_to_the_defaults() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../config_example.toml") + .canonicalize() + .expect("config_example.toml sits at the repository root"); + let text = std::fs::read_to_string(&path).expect("readable"); + let parsed: Config = toml::from_str(&text).expect("config_example.toml parses"); + + // The example documents the shipped defaults for everything that has one + // that does not depend on the machine (paths and the hotkey do). + let d = Config::default(); + assert_eq!( + parsed.search, d.search, + "[search] drifted from the defaults" + ); + assert_eq!(parsed.processing, d.processing); + 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)); +} + +/// The tour is offered to an installation this version created, and to no +/// other. A config written before the key existed reads as `None`, which is +/// how "already found their way around" is distinguished from "brand new". +#[test] +fn only_a_freshly_written_config_asks_for_the_tour() { + assert_eq!(UiConfig::default().tutorial_seen, Some(false)); + + let older: Config = toml::from_str("[ui]\nscale = 1.1\n").expect("parses"); + assert_eq!( + older.ui.tutorial_seen, None, + "a config predating the tour must not be offered it" + ); + + let dismissed: Config = toml::from_str("[ui]\ntutorial_seen = true\n").expect("parses"); + assert_eq!(dismissed.ui.tutorial_seen, Some(true)); +} + +/// Size and modified cost more width than they earn for most searches. +#[test] +fn the_search_table_ships_without_size_or_modified() { + let cols = ColumnsConfig::default(); + assert!(cols.name && cols.content_match && cols.rank); + assert!(!cols.size, "the size column is on by default"); + assert!(!cols.modified, "the modified column is on by default"); + + // A `[search]` block written before the picker existed still gets them. + let older: Config = toml::from_str("[search]\ndisplay_limit = 500\n").expect("parses"); + assert_eq!(older.search.columns, cols); + assert_eq!(older.search.display_limit, 500); + assert!(older.search.live_results, "live results default to on"); +} diff --git a/crates/quicksearch-core/src/content.rs b/crates/quicksearch-core/src/content.rs index 8b7d5a4..009d086 100644 --- a/crates/quicksearch-core/src/content.rs +++ b/crates/quicksearch-core/src/content.rs @@ -1,22 +1,24 @@ //! Parallel content extraction for one indexing root. //! //! The second half of a root's pipeline, and the sibling of [`crate::walk`]: -//! a pool of worker threads produces finished work over a bounded channel, and -//! the single writer drains it round-robin against every other root. +//! a pool of worker threads produces finished work over a bounded channel, +//! and the single writer drains it in time-bounded turns. Walking roots are +//! served first and only one extracting root per round, so a pass that is +//! producing faster than the writer can tokenize waits rather than holding up +//! anyone's walk — see `indexing::pipeline`. //! //! **One feeder thread owns the only database connection**, paging through //! the root's pending rows, while N workers do nothing but filesystem work. A //! connection per worker would multiply SQLite's page cache by the pool size //! (see [`crate::db::schema::PRAGMAS_WALK_READER`]). -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread::JoinHandle; use crate::config::Config; use crate::extract::Registry; -use crate::file_handling::{decide_content, ContentOutcome, ExtractCursor}; -use crate::indexing::should_abort; +use crate::file_handling::{decide_content, ContentOutcome, ExtractCursor, ExtractScope}; use crate::walk::{try_recv_next, TryNext, WorkerStats}; /// Finished rows waiting for the writer. @@ -69,6 +71,11 @@ struct Queue { struct Shared { queue: Mutex, idle: Condvar, + /// What the range held when the pass began: rows still to extract and + /// rows already done. Set by the feeder before it pages anything, so + /// `already_done + rows written this pass` stays exact; never set if the + /// feeder could not count. + totals: std::sync::OnceLock, } impl Shared { @@ -163,6 +170,16 @@ impl ContentPass { self.stats.clone() } + /// The range's pending and already-done counts as they stood when the + /// pass began. + /// + /// `None` until the feeder has counted — a scan that takes seconds on a + /// large root, which is why it happens here on the pass's own connection + /// and not on the indexer's writer — and forever if it could not. + pub fn totals(&self) -> Option { + self.shared.totals.get().copied() + } + /// Join the workers and report whether every one finished cleanly. /// See [`crate::walk::ParallelWalk::finish`]. pub fn finish(&mut self) -> bool { @@ -197,7 +214,7 @@ impl Drop for ContentPass { /// A failed query ends the pass rather than retrying: the rows stay /// `content_state = 0` and the next run picks them up, which is the same /// outcome as being interrupted. -fn feeder(shared: &Shared, db_path: &str, mut cursor: ExtractCursor, max_size: i64) { +fn feeder(shared: &Shared, db_path: &str, mut cursor: ExtractCursor, config: &Config) { let conn = match crate::db::open::open_walk_reader(db_path) { Ok(conn) => conn, Err(e) => { @@ -207,6 +224,18 @@ fn feeder(shared: &Shared, db_path: &str, mut cursor: ExtractCursor, max_size: i } }; + // Before the first page, so nothing this pass writes is inside the count. + // The workers cannot run ahead of this: they block in `take` until the + // first page lands. A failure here costs the progress figure, not the + // pass. + match crate::file_handling::count_extract_scope(&conn, &cursor, config) { + Ok(totals) => { + let _ = shared.totals.set(totals); + } + Err(e) => crate::log_warn!("content reader: {}", e), + } + + let max_size = crate::file_handling::max_text_file_size(config); while shared.take_feed_slot().is_some() { let page = match crate::db::repo::pending_content_page(&conn, &cursor, max_size, FEED_PAGE as i64) @@ -244,14 +273,13 @@ fn worker( registry: &Registry, config: &Config, stop_flag: &Arc, - suspend_flag: &Arc, stats: &WorkerStats, ) { while let Some(row) = shared.take() { // Held for the whole of `decide_content`; that is the work the // progress line reports. let _busy = stats.enter(); - if should_abort(stop_flag, suspend_flag) { + if stop_flag.load(Ordering::Relaxed) { shared.shutdown(); return; } @@ -279,14 +307,13 @@ pub fn extract_content( registry: Arc, config: Config, stop_flag: Arc, - suspend_flag: Arc, workers: usize, ) -> ContentPass { let shared = Arc::new(Shared { queue: Mutex::new(Queue::default()), idle: Condvar::new(), + totals: std::sync::OnceLock::new(), }); - let max_size = i64::try_from(config.processing.maximum_text_file_size).unwrap_or(i64::MAX); let (tx, rx) = mpsc::sync_channel(READY_CAP); let stats = WorkerStats::new(workers.clamp(1, 64)); @@ -294,19 +321,11 @@ pub fn extract_content( .map(|_| { let (shared, tx) = (shared.clone(), tx.clone()); let (registry, config) = (registry.clone(), config.clone()); - let (stop_flag, suspend_flag) = (stop_flag.clone(), suspend_flag.clone()); + let stop_flag = stop_flag.clone(); let stats = stats.clone(); crate::platform::spawn_worker("qs-extract", move || { crate::platform::set_background_priority(); - worker( - &shared, - &tx, - ®istry, - &config, - &stop_flag, - &suspend_flag, - &stats, - ) + worker(&shared, &tx, ®istry, &config, &stop_flag, &stats) }) }) .collect(); @@ -318,7 +337,7 @@ pub fn extract_content( let (shared, db_path, cursor) = (shared.clone(), db_path.to_string(), cursor.clone()); crate::platform::spawn_worker("qs-feeder", move || { crate::platform::set_background_priority(); - feeder(&shared, &db_path, cursor, max_size) + feeder(&shared, &db_path, cursor, &config) }) }; @@ -338,9 +357,23 @@ mod tests { use crate::db::open_or_recreate; use crate::db::repo::{self, insert_file, NewFile}; - use crate::file_handling::{extract_scope_prepare, store_extracted}; + use crate::file_handling::{store_extracted, ExtractScope, Stored}; use crate::mime::FileType; use std::path::{Path, PathBuf}; + use std::time::{Duration, Instant}; + + /// The removed `extract_scope_prepare`: the sweep on the writer, then the + /// count the content pass now does on its own connection. Composed here + /// because these tests want both halves in one call. + fn extract_scope_prepare( + conn_mutex: &Arc>, + cursor: &ExtractCursor, + config: &Config, + ) -> Result { + let conn = crate::lock_ok(conn_mutex); + crate::file_handling::mark_oversize_pending_na(&conn, cursor, config)?; + crate::file_handling::count_extract_scope(&conn, cursor, config) + } /// A path that does not exist yet — the caller builds the tree under it. fn tmp(tag: &str) -> PathBuf { crate::testutil::scratch_dir(tag).join("tree") @@ -391,7 +424,6 @@ mod tests { Arc::new(Registry::default_set()), Config::default(), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), workers, ) } @@ -450,9 +482,13 @@ mod tests { assert_eq!(rows.len(), 3); let stop = Arc::new(AtomicBool::new(false)); + let far = Instant::now() + Duration::from_secs(60); assert_eq!( - store_extracted(&conn_mutex, &rows, &stop, &config).unwrap(), - 3 + store_extracted(&conn_mutex, &rows, &stop, &config, far).unwrap(), + Stored { + consumed: 3, + written: 3 + } ); let state = |p: &Path| -> i64 { @@ -501,6 +537,93 @@ mod tests { let mut pass = pass_for(&tree, &db, "nonexistent", 4); assert!(drain(&mut pass).is_empty()); assert!(pass.finish()); + // The pass still counted: an empty range is a known zero, not an + // unknown. + assert_eq!( + pass.totals(), + Some(ExtractScope { + pending: 0, + already_done: 0 + }) + ); + std::fs::remove_dir_all(&tree).ok(); + std::fs::remove_file(&db).ok(); + } + + /// The pass counts its range on its own connection, before it pages — + /// which is what lets the writer thread stop doing it. The count is what + /// stood at the start: rows this pass writes are not inside it. + #[test] + fn the_pass_counts_its_range_before_it_starts() { + let (tree, db) = seed("totals", &[("r1", 3), ("r2", 2)]); + let mut pass = pass_for(&tree, &db, "r1", 2); + let rows = drain(&mut pass); + assert!(pass.finish()); + assert_eq!(rows.len(), 3); + assert_eq!( + pass.totals(), + Some(ExtractScope { + pending: 3, + already_done: 0 + }), + "only r1's rows, all of them pending when the pass began" + ); + std::fs::remove_dir_all(&tree).ok(); + std::fs::remove_file(&db).ok(); + } + + /// The writer's turn is bounded by time, not by batch: `store_extracted` + /// stops at its deadline, tells the caller how far it got, and always + /// gets at least one row down so a caller looping on it cannot spin. + #[test] + fn store_extracted_honours_its_deadline_but_always_makes_progress() { + let (tree, db) = seed("deadline", &[("r1", 5)]); + let conn_mutex = Arc::new(Mutex::new( + open_or_recreate(db.to_str().unwrap(), "trigram").unwrap(), + )); + let config = Config::default(); + let mut pass = pass_for(&tree, &db, "r1", 2); + let rows = drain(&mut pass); + assert!(pass.finish()); + assert_eq!(rows.len(), 5); + let stop = Arc::new(AtomicBool::new(false)); + + // A deadline already gone by: one row, then out. + let past = Instant::now() - Duration::from_secs(1); + assert_eq!( + store_extracted(&conn_mutex, &rows, &stop, &config, past).unwrap(), + Stored { + consumed: 1, + written: 1 + } + ); + // Plenty of time: the rest, in one call. + let far = Instant::now() + Duration::from_secs(60); + assert_eq!( + store_extracted(&conn_mutex, &rows[1..], &stop, &config, far).unwrap(), + Stored { + consumed: 4, + written: 4 + } + ); + let done: i64 = conn_mutex + .lock() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM files WHERE content_state = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(done, 5, "every row landed across the two calls"); + + // Stopped before it starts: nothing consumed, and the caller can tell. + stop.store(true, std::sync::atomic::Ordering::Relaxed); + assert_eq!( + store_extracted(&conn_mutex, &rows, &stop, &config, far).unwrap(), + Stored::default() + ); + std::fs::remove_dir_all(&tree).ok(); std::fs::remove_file(&db).ok(); } @@ -514,7 +637,6 @@ mod tests { Arc::new(Registry::default_set()), Config::default(), Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(false)), 4, ); assert!(drain(&mut pass).len() < 400); diff --git a/crates/quicksearch-core/src/coordinator.rs b/crates/quicksearch-core/src/coordinator.rs index 6543c5e..dea639f 100644 --- a/crates/quicksearch-core/src/coordinator.rs +++ b/crates/quicksearch-core/src/coordinator.rs @@ -130,6 +130,7 @@ enum CoordCmd { ConfigChanged(Config), RebuildIndex, ClearIndex, + UpdatePaths(Vec), Shutdown, } @@ -228,6 +229,7 @@ impl IndexCoordinator { watcher_rx: None, watcher_gen: 0, pending: HashMap::new(), + targeted: HashMap::new(), last_event_at: None, pending_since: None, needs_full_run: false, @@ -303,6 +305,26 @@ impl IndexCoordinator { let _ = self.cmd_tx.send(CoordCmd::ClearIndex); } + /// Bring the index up to date for these paths and nothing else. + /// + /// For [`crate::live`]: a frontend that has just read a displayed file + /// from disk hands the path here so the index agrees with what the user + /// is looking at. Deliberately **not** gated on [`IndexMode`] — the whole + /// point is that the rows on screen stay honest with indexing stopped — + /// but still applied on the coordinator's own thread, so the + /// single-writer rule holds and a full run is never raced. + /// + /// Each path is re-read and rewritten only if its modified time has moved + /// (see [`crate::incremental::apply_fs_event`]), so submitting a path that + /// is already current costs a `stat` and a row lookup. A path that no + /// longer exists is removed from the index. + pub fn update_paths(&self, paths: Vec) { + if paths.is_empty() { + return; + } + let _ = self.cmd_tx.send(CoordCmd::UpdatePaths(paths)); + } + /// Compare `config` against what the index was built with. Read-only. pub fn check_config_validation( &self, diff --git a/crates/quicksearch-core/src/coordinator/inner.rs b/crates/quicksearch-core/src/coordinator/inner.rs index 64d6282..e396a01 100644 --- a/crates/quicksearch-core/src/coordinator/inner.rs +++ b/crates/quicksearch-core/src/coordinator/inner.rs @@ -22,6 +22,12 @@ pub(super) struct Inner { pub(super) watcher_rx: Option)>>, pub(super) watcher_gen: u64, pub(super) pending: HashMap, + /// Paths a frontend asked for by name — see + /// [`IndexCoordinator::update_paths`]. Kept apart from [`Inner::pending`] + /// on purpose: this queue survives [`Inner::clear_pending`] and is applied + /// in manual mode, because it exists to keep the rows a user is *reading* + /// in step with the disk however the indexer is configured. + pub(super) targeted: HashMap, /// When the most recent event arrived; the burst is over once this is /// `pending_settle` old. pub(super) last_event_at: Option, @@ -158,6 +164,36 @@ impl Inner { drop(shared); self.files_at = None; } + CoordCmd::UpdatePaths(paths) => { + // Only paths under an indexed root: the watcher never + // delivers anything else, so nothing downstream checks, and + // a file renamed *out* of every root would otherwise be + // written into the index at its new home. Roots in the same + // spelling `files.path` uses — the caller's paths are. + let prefixes: Vec = self + .config + .normalized_indexing_paths() + .iter() + .map(|root| crate::file_handling::ExtractCursor::for_root(root).lo) + .collect(); + for path in paths { + let spelled = path.to_string_lossy(); + if !prefixes.iter().any(|lo| spelled.starts_with(lo.as_str())) { + continue; + } + // Existence decides the verb. The caller knows a file + // changed, not what it changed into, and a `Modify` for a + // path that is gone would be silently skipped rather than + // removing the row. + let event = if path.is_file() { + FsEvent::Modify(path) + } else { + FsEvent::Remove(path) + }; + enqueue(&mut self.targeted, event); + } + self.was_busy = true; + } CoordCmd::Shutdown => unreachable!("handled in run()"), } } @@ -197,6 +233,14 @@ impl Inner { self.refresh_file_count(); + // Ahead of both the reconcile and the mode gate, and ahead of the + // settle window the watcher queue waits out: these are rows a user is + // looking at right now, there are at most a screenful, and a stopped + // indexer is exactly when the frontend most needs them to be current. + if !self.targeted.is_empty() { + self.apply_targeted(); + } + // Ahead of the mode gate: a config edit is reconciled in manual mode // too. if self.pending_work.is_some() { @@ -469,6 +513,71 @@ impl Inner { } } + /// Apply the by-name queue: the paths a frontend is displaying. + /// + /// Shaped like [`Inner::apply_pending`] — removals first, same budget — + /// but it never escalates to [`Inner::needs_full_run`]. A frontend reads + /// what it shows from the file itself, so a failure here leaves the screen + /// correct and only the index behind; reindexing the world over that would + /// be wildly out of proportion. + fn apply_targeted(&mut self) { + self.was_busy = true; + let mut conn = match self.ensure_write_conn() { + Ok(conn) => conn, + Err(e) => { + crate::log_warn!("coordinator: targeted update unavailable: {}", e); + self.targeted.clear(); + return; + } + }; + let deadline = Instant::now() + APPLY_BUDGET; + let chunk = self.config.processing.batch_size.max(1); + + // Removals lead for the same reason they do in `apply_pending`: the + // queue is an unordered map, and a rename enqueues both halves. + let removals: Vec = self + .targeted + .iter() + .filter(|(_, ev)| is_removal(ev)) + .map(|(p, _)| p.clone()) + .collect(); + for batch in removals.chunks(chunk) { + if let Err(e) = crate::incremental::remove_paths(&mut conn, batch, chunk) { + crate::log_warn!("coordinator: targeted remove: {}", e); + } + for path in batch { + self.targeted.remove(path); + } + if Instant::now() >= deadline { + break; + } + } + + if Instant::now() < deadline { + let upserts: Vec = self + .targeted + .iter() + .filter(|(_, ev)| !is_removal(ev)) + .map(|(p, _)| p.clone()) + .collect(); + for path in upserts { + let Some(ev) = self.targeted.remove(&path) else { + continue; + }; + if let Err(e) = + apply_fs_event(&mut conn, &ev, &self.config, &self.ignore, &self.registry) + { + crate::log_warn!("coordinator: targeted apply {:?}: {}", ev, e); + } + if Instant::now() >= deadline { + break; + } + } + } + + self.write_conn = Some(conn); + } + fn ensure_write_conn(&mut self) -> Result { if let Some(conn) = self.write_conn.take() { return Ok(conn); @@ -799,7 +908,7 @@ impl Inner { ); let mut shared = crate::lock_ok(&self.shared); shared.mode = self.mode; - shared.queued_events = self.pending.len(); + shared.queued_events = self.pending.len() + self.targeted.len(); shared.reconcile = reconcile; drop(shared); diff --git a/crates/quicksearch-core/src/coordinator/tests.rs b/crates/quicksearch-core/src/coordinator/tests.rs index 7d0282a..d991986 100644 --- a/crates/quicksearch-core/src/coordinator/tests.rs +++ b/crates/quicksearch-core/src/coordinator/tests.rs @@ -505,6 +505,149 @@ fn a_run_it_schedules_itself_wakes_the_frontend() { coord.shutdown(); } +// --- targeted updates (see `IndexCoordinator::update_paths`) -------------- + +impl Fixture { + /// The `mtime` the index holds for one path, or `None` if it has no row. + fn stored_mtime(&self, path: &std::path::Path) -> Option { + let conn = db::open_existing(&self.db.to_string_lossy(), false).ok()?; + conn.query_row( + "SELECT mtime FROM files WHERE path = ?1", + [path.to_string_lossy().as_ref()], + |r| r.get(0), + ) + .ok() + } +} + +/// The point of the whole thing: the frontend has just read a file the user is +/// looking at, and the index catches up even though indexing is stopped — with +/// no watcher running and no full run scheduled. +#[test] +fn update_paths_indexes_one_file_with_indexing_stopped() { + let f = Fixture::new(false); + std::fs::write(f.dir.join("seed.txt"), "initial content").unwrap(); + f.seed_index(); + assert_eq!(f.file_count(), 1); + + let coord = start_coord(f.config.clone()); + let added = f.dir.join("added.txt"); + std::fs::write(&added, "written while indexing was stopped").unwrap(); + coord.update_paths(vec![added.clone()]); + + wait_for("targeted insert", Duration::from_secs(20), || { + f.stored_mtime(&added).is_some() + }); + assert_eq!( + coord.state().mode, + IndexMode::ManualStopped, + "a targeted update started a run" + ); + assert!( + coord.state().last_full_index.is_some(), + "the seed stamp was disturbed" + ); + coord.shutdown(); +} + +/// The same call is how a row is *validated*: submitting a path the index +/// already agrees with must not rewrite it, which is what makes it cheap +/// enough for the frontend to submit whatever it just looked at. +#[test] +fn update_paths_leaves_a_row_that_already_agrees_alone() { + let f = Fixture::new(false); + let file = f.dir.join("steady.txt"); + std::fs::write(&file, "unchanged").unwrap(); + f.seed_index(); + let before = f.stored_mtime(&file).expect("seeded"); + + let coord = start_coord(f.config.clone()); + coord.update_paths(vec![file.clone()]); + // No state change to wait on, so wait out a few ticks instead. + std::thread::sleep(Duration::from_secs(3)); + + assert_eq!(f.stored_mtime(&file), Some(before)); + assert_eq!(f.file_count(), 1); + coord.shutdown(); +} + +/// A path outside every indexed root is not the index's to hold, however it +/// was submitted: a result renamed into an un-indexed folder must not follow +/// the row into the index at its new home. +#[test] +fn update_paths_ignores_a_path_outside_every_root() { + let f = Fixture::new(false); + std::fs::write(f.dir.join("seed.txt"), "initial content").unwrap(); + f.seed_index(); + assert_eq!(f.file_count(), 1); + + // A sibling of the indexed tree, under the same scratch parent. + let outside = f.dir.parent().unwrap().join("elsewhere"); + std::fs::create_dir_all(&outside).unwrap(); + let stray = outside.join("moved-here.txt"); + std::fs::write(&stray, "renamed out of the index").unwrap(); + + let coord = start_coord(f.config.clone()); + coord.update_paths(vec![stray.clone()]); + std::thread::sleep(Duration::from_secs(3)); + + assert_eq!( + f.stored_mtime(&stray), + None, + "an un-indexed folder gained a row" + ); + assert_eq!(f.file_count(), 1); + coord.shutdown(); + std::fs::remove_dir_all(&outside).ok(); +} + +/// A row whose file has gone leaves the index too — the frontend hands over +/// the path, not a verb, so the coordinator decides from what is on disk. +#[test] +fn update_paths_removes_a_row_whose_file_is_gone() { + let f = Fixture::new(false); + let file = f.dir.join("doomed.txt"); + std::fs::write(&file, "not for long").unwrap(); + f.seed_index(); + assert!(f.stored_mtime(&file).is_some()); + + let coord = start_coord(f.config.clone()); + std::fs::remove_file(&file).unwrap(); + coord.update_paths(vec![file.clone()]); + + wait_for("targeted remove", Duration::from_secs(20), || { + f.stored_mtime(&file).is_none() + }); + coord.shutdown(); +} + +/// The single-writer rule still holds: a targeted update submitted while a +/// full run owns the database waits for it rather than opening a second +/// writer beside it. +#[test] +fn update_paths_waits_for_a_full_run_rather_than_racing_it() { + let f = Fixture::new(false); + for i in 0..400 { + std::fs::write(f.dir.join(format!("f{i}.txt")), "body").unwrap(); + } + let coord = start_coord(f.config.clone()); + coord.reindex_now(); + + let added = f.dir.join("late.txt"); + std::fs::write(&added, "submitted mid-run").unwrap(); + coord.update_paths(vec![added.clone()]); + + wait_for("run finished", Duration::from_secs(60), || { + coord.state().last_full_index.is_some() + }); + wait_for( + "targeted insert after the run", + Duration::from_secs(20), + || f.stored_mtime(&added).is_some(), + ); + coord.shutdown(); +} + #[test] fn auto_mode_runs_initial_index_and_applies_watcher_events() { let f = Fixture::new(true); diff --git a/crates/quicksearch-core/src/db/repo.rs b/crates/quicksearch-core/src/db/repo.rs index 48fa991..165703f 100644 --- a/crates/quicksearch-core/src/db/repo.rs +++ b/crates/quicksearch-core/src/db/repo.rs @@ -217,6 +217,85 @@ pub fn set_content_done( set_state_clearing_failure(tx, file_id, STATE_DONE, "update DONE") } +/// Reusable decode buffer and decompression context for the readers of +/// `documents_text` — the read side's mirror of [`DocEncoder`]. +/// +/// Shared by the cascade's full-text passes and by [`crate::live`], which +/// re-reads one row's body when a file under a visible result changes. +/// +/// `zstd::decode_all` builds and tears down a `ZSTD_DCtx` *and* allocates a +/// fresh output `Vec` on every call, and it is called once per candidate row. +/// One context and one buffer, reused across a whole scan, make that a +/// per-scan cost instead of a per-row one. +pub struct DocDecoder { + dctx: zstd::bulk::Decompressor<'static>, + buf: Vec, +} + +/// Where [`DocDecoder::decode`]'s buffer starts before it has seen a document. +/// Most extracted text is well under this, so the doubling below rarely runs. +const INITIAL_DOC_CAPACITY: usize = 64 * 1024; + +/// Where the doubling stops. Stored text is capped at +/// `processing.maximum_text_size` (256 KiB by default), so this is far above +/// any legitimate document even if that setting is raised — past it, a failure +/// is a corrupt frame rather than a buffer that is too small. +const MAX_DOC_CAPACITY: usize = 64 * 1024 * 1024; + +impl DocDecoder { + pub fn new() -> Result { + Ok(DocDecoder { + dctx: zstd::bulk::Decompressor::new().map_err(|e| e.to_string())?, + buf: Vec::new(), + }) + } + + /// Decompress `blob` and borrow the result as text. + /// + /// Returns `None` for a corrupt frame or non-UTF-8 content. Nothing is + /// copied: the indexer stores UTF-8, so the bytes are borrowed in place + /// rather than run through `String::from_utf8_lossy(..).into_owned()`, + /// which duplicated the whole document even when it was already valid. + pub fn decode(&mut self, blob: &[u8]) -> Option<&str> { + self.buf.clear(); + // `decompress_to_buffer` writes into spare capacity and fails rather + // than growing, so the room has to be there first. + // + // The frame header would say how much is needed, but the indexer + // writes with `zstd::encode_all`, which is *stream*-based and so + // records no content size — `get_frame_content_size` says `None` for + // every row this ever sees. Falling back to `zstd::decode_all` there + // looked harmless and was not: it builds a streaming decoder per call, + // which measured as one ~2.4 MiB allocation per document and 27 of the + // 30 GiB a fuzzy search moved through the allocator. + // + // So grow this buffer instead and keep reusing it. It settles at the + // largest document in the scan within the first few rows, after which + // decoding a row allocates nothing at all. + if let Ok(Some(size)) = zstd::zstd_safe::get_frame_content_size(blob) { + self.buf.reserve(usize::try_from(size).ok()?); + } + loop { + if self.buf.capacity() == 0 { + self.buf.reserve(INITIAL_DOC_CAPACITY); + } + match self.dctx.decompress_to_buffer(blob, &mut self.buf) { + Ok(_) => break, + // Too small, or corrupt — the bulk API cannot tell us which. + // Growing is only worth trying while the buffer is still + // smaller than any document could legitimately be. + Err(_) if self.buf.capacity() < MAX_DOC_CAPACITY => { + let bigger = self.buf.capacity().saturating_mul(2); + self.buf.clear(); + self.buf.reserve(bigger); + } + Err(_) => return None, + } + } + std::str::from_utf8(&self.buf).ok() + } +} + /// Level 3 hits ~3-5× on English prose at high throughput (hundreds of /// MB/s); level 9+ would shave a few percent more at 10× the CPU cost, and /// readers decompress far faster than writers compress. diff --git a/crates/quicksearch-core/src/db/schema.rs b/crates/quicksearch-core/src/db/schema.rs index 13a4e0f..a6829b8 100644 --- a/crates/quicksearch-core/src/db/schema.rs +++ b/crates/quicksearch-core/src/db/schema.rs @@ -22,7 +22,7 @@ //! | [`PRAGMAS_SEARCH`] | search worker | held across a typing session | 32 MiB | //! | [`PRAGMAS_READONLY`] | one-shot readers | a single query | 4 MiB | //! | [`PRAGMAS_MAINTENANCE`] | VACUUM | one bulk copy | 8 MiB | -//! | [`PRAGMAS_WALK_READER`] | per-root row prefetch | the walk | 1 MiB | +//! | [`PRAGMAS_WALK_READER`] | per-root walk prefetch and content feeder | the run | 1 MiB | //! //! `PRAGMA mmap_size` is absent from all of them: SQLCipher's codec disables //! mmap at runtime only when a key is set, mapped pages still count in @@ -124,12 +124,19 @@ pub const PRAGMAS_READONLY: &str = " PRAGMA foreign_keys = ON; "; -/// Pragmas for a walk's row-prefetch connection. +/// Pragmas for a root's own reader: the walk's row prefetch, and then the +/// content pass's feeder ([`crate::content`]), which reuses this profile for +/// the rest of the run. /// -/// One of these exists per indexing root, so the cache size is multiplied by -/// the root count. 1 MiB holds the upper levels of `idx_files_parent` hot, -/// which is all these queries touch: each is a single index range lookup, -/// and the pages under it are read once and not revisited. +/// Two of these can exist per indexing root, so the cache size is multiplied +/// by the root count. 1 MiB is sized for the walk's queries, which each read +/// one range of `idx_files_parent` once and never revisit it. The feeder's +/// paging is the same shape, but its one-off `count_extract_scope` at pass +/// start is not: that scans the root's whole path range fetching a row per +/// entry, so on a large root it is a cold read all the way through. It is +/// deliberately here rather than on the writer — the writer holding still for +/// it stopped every other root's walk — and this is the connection that pays +/// for that, once per root. pub const PRAGMAS_WALK_READER: &str = " PRAGMA busy_timeout = 5000; PRAGMA cache_size = -1024; diff --git a/crates/quicksearch-core/src/extract/mod.rs b/crates/quicksearch-core/src/extract/mod.rs index 5666d9c..a7cea1f 100644 --- a/crates/quicksearch-core/src/extract/mod.rs +++ b/crates/quicksearch-core/src/extract/mod.rs @@ -41,11 +41,6 @@ impl ExtractedContent { } } - pub fn with_property(mut self, key: impl Into, value: impl Into) -> Self { - self.properties.insert(key.into(), value.into()); - self - } - /// Convert properties into the `Vec<(String, String)>` shape expected by /// [`crate::db::repo::set_content_done`]. Keys are sorted for determinism /// in tests and snapshots. @@ -300,9 +295,9 @@ mod tests { #[test] fn properties_sorted_is_deterministic() { - let c = ExtractedContent::with_text("hi") - .with_property("b", "2") - .with_property("a", "1"); + let mut c = ExtractedContent::with_text("hi"); + c.properties.insert("b".into(), "2".into()); + c.properties.insert("a".into(), "1".into()); assert_eq!( c.properties_sorted(), vec![ diff --git a/crates/quicksearch-core/src/file_handling/batch.rs b/crates/quicksearch-core/src/file_handling/batch.rs index d33dd60..b8552b1 100644 --- a/crates/quicksearch-core/src/file_handling/batch.rs +++ b/crates/quicksearch-core/src/file_handling/batch.rs @@ -9,7 +9,6 @@ use rusqlite::Connection; use super::*; use crate::config::Config; use crate::db::repo::{self}; -use crate::indexing::should_abort; /// The compressed sidecar for one row, or `None` where there is none to write /// — an empty body, or `store_text_for_snippets` turned off. @@ -29,12 +28,13 @@ type Body = Result>, String>; /// /// What that lock does *not* gate, so the benefit is not overclaimed: search /// holds its own connection (`db::open::open_search_reader`) and the database -/// is WAL, where a reader never blocks on a writer. `conn_mutex` serializes -/// the indexer against itself — one root's content stores against another's -/// walk inserts, the scope reconciler's slices, and WAL checkpointing. A -/// whole-tree wall-clock run is dominated by FTS5 trigram tokenization and -/// does not move measurably from this change; it is contention that improves, -/// not throughput. +/// is WAL, where a reader never blocks on a writer. Nor does it separate one +/// root from another — every root's writes already run on the single writer +/// thread, so two of them are never inside the lock at once. What it actually +/// serializes the run against is WAL checkpointing, which `run_indexing` +/// forces from the same thread between turns. A whole-tree wall-clock run is +/// dominated by FTS5 trigram tokenization and does not move measurably from +/// this change; it is the length of the hold that improves, not throughput. fn compress_bodies<'a>( texts: impl Iterator>, config: &Config, @@ -198,15 +198,13 @@ pub fn process_batch_inserts( /// Delete the rows a completed run found no file behind, in chunked /// transactions. Returns how many went. /// -/// `should_abort` *blocks* while the indexer is suspended, so it must only be -/// observed between chunks with nothing held — checking it mid-transaction -/// pins the shared connection for the whole suspension and freezes the GUI. -/// The stop flag, which never blocks, guards the inner loop. +/// The stop flag is checked between chunks and again per path, never with a +/// transaction open: a chunk either commits whole or is not begun, so a stop +/// cannot leave the index half-reconciled. pub fn cleanup_stale_index_entries( conn_mutex: &Arc>, stale_paths: &[String], stop_flag: &Arc, - suspend_flag: &Arc, config: &Config, ) -> Result { if stale_paths.is_empty() { @@ -216,8 +214,8 @@ pub fn cleanup_stale_index_entries( let mut deleted_count = 0usize; for batch in stale_paths.chunks(chunk) { - // Outside the lock, so a suspend parks here rather than mid-transaction. - if should_abort(stop_flag, suspend_flag) { + // Outside the lock, so a stop is seen before a transaction is begun. + if stop_flag.load(Ordering::Relaxed) { return Ok(deleted_count); } let conn = crate::lock_ok(conn_mutex); @@ -241,7 +239,7 @@ pub fn cleanup_stale_index_entries( } } - if deleted_count > 0 && !should_abort(stop_flag, suspend_flag) { + if deleted_count > 0 && !stop_flag.load(Ordering::Relaxed) { let conn = crate::lock_ok(conn_mutex); fts_finalize_after_text_indexing(&conn); } @@ -296,71 +294,107 @@ pub struct ExtractScope { pub already_done: usize, } -/// Prepare a root's extraction scope: flip oversize pending rows to NA -/// (idempotent) and count what is pending vs. already extracted in the range. +/// The `maximum_text_file_size` bound as the SQL below compares it. +pub(crate) fn max_text_file_size(config: &Config) -> i64 { + i64::try_from(config.processing.maximum_text_file_size).unwrap_or(i64::MAX) +} + +/// Flip a root's oversize pending rows to NA. Idempotent. /// -/// The oversize sweep covers what walk-time decisions cannot: a -/// `maximum_text_file_size` *lowered* between runs (which does not force a -/// rebuild), and rows left pending by an older build. -pub fn extract_scope_prepare( - conn_mutex: &Arc>, +/// Covers what walk-time decisions cannot: a `maximum_text_file_size` +/// *lowered* between runs (which does not force a rebuild), and rows left +/// pending by an older build. Rows this misses would stay pending forever, so +/// it runs on the writer before a root's content pass starts. +pub fn mark_oversize_pending_na( + conn: &Connection, cursor: &ExtractCursor, config: &Config, -) -> Result { - let max_size = i64::try_from(config.processing.maximum_text_file_size).unwrap_or(i64::MAX); - let conn = crate::lock_ok(conn_mutex); +) -> Result<(), String> { conn.execute( "UPDATE files SET content_state = 3 \ WHERE content_state = 0 AND size > ?1 AND path >= ?2 AND path < ?3", - rusqlite::params![max_size, cursor.lo, cursor.hi], + rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi], ) .map_err(|e| format!("mark oversize files NA: {}", e))?; - let pending: i64 = conn + Ok(()) +} + +/// Count what a root's range holds: rows still to extract this run, and rows +/// whose text is already searchable from earlier runs. +/// +/// One range scan for both figures. Deliberately callable on any connection +/// — the content pass runs it on its own read connection rather than on the +/// indexer's writer, because on a large root it takes seconds, and seconds of +/// writer time is every other root's walk standing still. +pub fn count_extract_scope( + conn: &Connection, + cursor: &ExtractCursor, + config: &Config, +) -> Result { + let (pending, already_done): (i64, i64) = conn .query_row( - "SELECT COUNT(*) FROM files \ - WHERE content_state = 0 AND size <= ?1 AND path >= ?2 AND path < ?3", - rusqlite::params![max_size, cursor.lo, cursor.hi], - |row| row.get(0), + "SELECT COALESCE(SUM(content_state = 0 AND size <= ?1), 0), \ + COALESCE(SUM(content_state = 1), 0) \ + FROM files WHERE path >= ?2 AND path < ?3", + rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi], + |row| Ok((row.get(0)?, row.get(1)?)), ) - .map_err(|e| format!("Failed to count pending text files: {}", e))?; - let already_done: i64 = conn - .query_row( - "SELECT COUNT(*) FROM files \ - WHERE content_state = 1 AND path >= ?1 AND path < ?2", - rusqlite::params![cursor.lo, cursor.hi], - |row| row.get(0), - ) - .map_err(|e| format!("Failed to count extracted files: {}", e))?; + .map_err(|e| format!("Failed to count text files: {}", e))?; Ok(ExtractScope { pending: pending.max(0) as usize, already_done: already_done.max(0) as usize, }) } -/// Write a batch of already-extracted rows — the cheap half of the content -/// pass, and all that runs with the connection held. Chunked so each -/// transaction stays short. +/// Rows per compression chunk and per transaction inside [`store_extracted`]. /// -/// Returns how many rows were written. A row whose write fails is logged and -/// skipped rather than failing the run: its `content_state` stays pending, so -/// the next run retries it. +/// Half of what a writer turn may hand in (`pipeline::READY_TOPUP` is 64), so +/// a full turn commits twice rather than once — short holds of the connection +/// being the point. It also bounds the compression thrown away when the +/// deadline cuts a chunk short, to at most `STORE_CHUNK - 1` bodies. +/// +/// Note the two buffers are additive: a root can hold `READY_TOPUP` extracted +/// rows waiting for the writer *and* `content::READY_CAP` more in its +/// channel, so in-flight text per root is bounded by their sum, not by either +/// alone. +const STORE_CHUNK: usize = 32; + +/// What one [`store_extracted`] call did with the rows it was handed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Stored { + /// Rows the caller must now drop from its buffer, written or not. + pub consumed: usize, + /// Rows whose write succeeded — whose `content_state` moved. + pub written: usize, +} + +/// Write already-extracted rows — the cheap half of the content pass, and all +/// that runs with the connection held — until `deadline`. +/// +/// This is where a document's FTS5 trigram tokenization happens, up to +/// `maximum_text_size` of it per row, and it is the writer thread's dominant +/// cost. The deadline is checked after every row, so a turn on the writer +/// overruns it by at most one document; the rows not reached are left for the +/// caller to hand back next turn. At least one row is always consumed unless +/// the run is already stopped, so a caller looping on this cannot spin. +/// +/// A row whose write fails is logged and consumed rather than failing the +/// run: its `content_state` stays pending, so the next run retries it. pub fn store_extracted( conn_mutex: &Arc>, rows: &[crate::content::ExtractedRow], stop_flag: &Arc, config: &Config, -) -> Result { - if rows.is_empty() { - return Ok(0); - } - let mut written = 0usize; - for batch in rows.chunks(config.processing.batch_size.max(1)) { + deadline: std::time::Instant, +) -> Result { + let mut done = Stored::default(); + for chunk in rows.chunks(STORE_CHUNK) { if stop_flag.load(Ordering::Relaxed) { - return Ok(written); + break; } // Outside the lock — see `compress_bodies`. let bodies = compress_bodies( - batch + chunk .iter() .map(|r| crate::file_handling::outcome_body(&r.outcome)), config, @@ -369,16 +403,34 @@ pub fn store_extracted( let tx = conn .unchecked_transaction() .map_err(|e| format!("Failed to begin transaction: {}", e))?; - for (i, row) in batch.iter().enumerate() { - let zstd = body_or_skip!(bodies, i, row.name); - if let Err(e) = store_content_outcome(&tx, row.file_id, &row.name, &row.outcome, zstd) { - crate::log_warn!("content indexing for {}: {}", row.name, e); - continue; + let mut cut = false; + for (i, row) in chunk.iter().enumerate() { + // Counted before anything can skip it: consumed is what the + // caller drains, and a row that failed still has to leave. + done.consumed += 1; + match &bodies[i] { + Err(e) => crate::log_warn!("compress text for {}: {}", row.name, e), + Ok(zstd) => match store_content_outcome( + &tx, + row.file_id, + &row.name, + &row.outcome, + zstd.as_deref(), + ) { + Ok(()) => done.written += 1, + Err(e) => crate::log_warn!("content indexing for {}: {}", row.name, e), + }, + } + if stop_flag.load(Ordering::Relaxed) || std::time::Instant::now() >= deadline { + cut = true; + break; } - written += 1; } tx.commit() .map_err(|e| format!("Failed to commit transaction: {}", e))?; + if cut { + break; + } } - Ok(written) + Ok(done) } diff --git a/crates/quicksearch-core/src/file_handling/count_and_extract_tests.rs b/crates/quicksearch-core/src/file_handling/count_and_extract_tests.rs index b79a768..0bec201 100644 --- a/crates/quicksearch-core/src/file_handling/count_and_extract_tests.rs +++ b/crates/quicksearch-core/src/file_handling/count_and_extract_tests.rs @@ -8,6 +8,17 @@ use super::*; use std::sync::atomic::{AtomicBool, Ordering}; /// A path that does not exist yet — these tests build the tree themselves. +/// The removed `extract_scope_prepare`: the oversize sweep, then the count. +fn extract_scope_prepare( + conn_mutex: &std::sync::Arc>, + cursor: &ExtractCursor, + config: &Config, +) -> Result { + let conn = crate::lock_ok(conn_mutex); + super::mark_oversize_pending_na(&conn, cursor, config)?; + super::count_extract_scope(&conn, cursor, config) +} + fn tmp(tag: &str) -> std::path::PathBuf { crate::testutil::scratch_dir(tag).join("tree") } diff --git a/crates/quicksearch-core/src/file_handling/mod.rs b/crates/quicksearch-core/src/file_handling/mod.rs index 9fb85ef..7e44d9f 100644 --- a/crates/quicksearch-core/src/file_handling/mod.rs +++ b/crates/quicksearch-core/src/file_handling/mod.rs @@ -11,11 +11,12 @@ mod count_and_extract_tests; #[cfg(test)] mod tests; -pub(crate) use batch::store_inline_text; pub use batch::{ - cleanup_stale_index_entries, extract_scope_prepare, process_batch_inserts, - process_batch_updates, store_extracted, ExtractCursor, ExtractScope, + cleanup_stale_index_entries, count_extract_scope, mark_oversize_pending_na, + process_batch_inserts, process_batch_updates, store_extracted, ExtractCursor, ExtractScope, + Stored, }; +pub(crate) use batch::{max_text_file_size, store_inline_text}; pub use counting::count_tree_entries_fast; pub use paths::{db_key_for_missing_path, filtered_dirs, filtered_walk, UnreadableDirs}; pub(crate) use paths::{normalize_root_string, path_to_db_string, warn_if_unrepresentable}; diff --git a/crates/quicksearch-core/src/incremental.rs b/crates/quicksearch-core/src/incremental.rs index b15bdd8..e2dd01d 100644 --- a/crates/quicksearch-core/src/incremental.rs +++ b/crates/quicksearch-core/src/incremental.rs @@ -159,25 +159,6 @@ fn remove_path(conn: &mut Connection, path: &Path) -> Result<(), String> { remove_paths(conn, std::slice::from_ref(&path.to_path_buf()), 1) } -/// Drop removals that a removal of one of their ancestors already covers. -/// -/// `rm -rf dir/` reports `dir` *and* every file beneath it; removing `dir` -/// sweeps its whole path range, so each descendant event is duplicate work. -/// For callers holding a raw removal set — the coordinator collapses on -/// arrival instead (`collapse_pending_removals`). Containment is -/// component-wise, per [`crate::file_handling::UnreadableDirs::covers`]. -pub fn collapse_removal_roots(paths: Vec) -> Vec { - if paths.len() < 2 { - return paths; - } - let all: std::collections::HashSet<&Path> = paths.iter().map(|p| p.as_path()).collect(); - paths - .iter() - .filter(|p| !p.ancestors().skip(1).any(|a| all.contains(a))) - .cloned() - .collect() -} - /// Delete `paths` and everything indexed beneath them, in transactions of at /// most `chunk` paths. /// @@ -314,47 +295,6 @@ mod tests { } } - fn collapse(paths: &[&str]) -> Vec { - let mut out: Vec = - collapse_removal_roots(paths.iter().map(std::path::PathBuf::from).collect()) - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - out.sort(); - out - } - - #[test] - fn removal_roots_collapse_to_the_shallowest_ancestor() { - assert_eq!( - collapse(&["/dir", "/dir/a.txt", "/dir/b/c.txt", "/dir/b"]), - vec!["/dir"] - ); - - // Component-wise, so a name-prefix sibling is not swallowed. - assert_eq!( - collapse(&["/a/b", "/a/bc"]), - vec!["/a/b", "/a/bc"], - "/a/bc does not live under /a/b" - ); - assert_eq!( - collapse(&["/a/b", "/a/b.txt"]), - vec!["/a/b", "/a/b.txt"], - "a sibling file sorting between a dir and its children survives" - ); - - // Unrelated removals all survive; order of input does not matter. - assert_eq!( - collapse(&["/x/deep/f", "/y", "/x"]), - vec!["/x", "/y"], - "/x/deep/f is covered by /x, /y is independent" - ); - - // Degenerate inputs. - assert!(collapse(&[]).is_empty()); - assert_eq!(collapse(&["/only"]), vec!["/only"]); - } - /// The collapse must not change what ends up deleted — only how much work /// it takes to get there. #[test] @@ -378,9 +318,10 @@ mod tests { format!("{}/a.txt", canonical_tree).into(), format!("{}/deep/b.txt", canonical_tree).into(), ]; - let roots = collapse_removal_roots(reported); - assert_eq!(roots.len(), 1, "one range covers the whole tree"); - + // Collapsed to its root the way the coordinator collapses an + // arriving queue (`collapse_pending_removals`): one range covers the + // whole tree, which is what makes `remove_paths` cheap. + let roots = vec![reported[0].clone()]; remove_paths(&mut f.conn, &roots, 200).unwrap(); assert_eq!(f.counts(), (1, 1, 1), "only tree2 survives"); let survivor = f.canonical(&f.dir.join("tree2").join("keep.txt")); diff --git a/crates/quicksearch-core/src/indexing/mod.rs b/crates/quicksearch-core/src/indexing/mod.rs index 6b6d8aa..42a1a0f 100644 --- a/crates/quicksearch-core/src/indexing/mod.rs +++ b/crates/quicksearch-core/src/indexing/mod.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::config::Config; use crate::db; @@ -84,7 +84,6 @@ pub struct IndexingService { status: Arc>, command_tx: mpsc::Sender, db_connection: Arc>>>>, - suspend_flag: Arc, /// The long single statement a run is inside, if any: the prologue's /// reconcile scan or the epilogue's VACUUM — never both, so one slot /// serves and [`IndexingService::cancel_db_work`] reaches either. @@ -92,9 +91,6 @@ pub struct IndexingService { _handle: thread::JoinHandle<()>, } -/// Polling interval for `should_abort` while suspended. -const SUSPEND_POLL_MS: u64 = 100; - /// `indexing.root_workers` rekeyed from the spellings the user typed to the /// canonical roots the indexer walks, so an override survives a `~`, a /// trailing slash, a relative path or a symlinked root. Entries naming a @@ -112,39 +108,21 @@ fn resolved_root_workers(config: &Config) -> HashMap { .collect() } -/// Combined stop/suspend check used by worker loops; `true` iff the caller -/// should abort. While suspended (and not stopped) it parks the thread in -/// short sleeps until `resume()`. -pub(crate) fn should_abort(stop: &Arc, suspend: &Arc) -> bool { - loop { - if stop.load(Ordering::Relaxed) { - return true; - } - if !suspend.load(Ordering::Relaxed) { - return false; - } - thread::sleep(Duration::from_millis(SUSPEND_POLL_MS)); - } -} - impl IndexingService { pub fn new() -> Self { let status = Arc::new(Mutex::new(IndexingStatus::Idle)); let (command_tx, command_rx) = mpsc::channel(); let db_connection = Arc::new(Mutex::new(None)); - let suspend_flag = Arc::new(AtomicBool::new(false)); let interrupt: Arc = Arc::new(db::InterruptSlot::default()); let status_clone = status.clone(); let db_connection_clone = db_connection.clone(); - let suspend_clone = suspend_flag.clone(); let interrupt_clone = interrupt.clone(); let handle = thread::spawn(move || { Self::indexing_thread( status_clone, command_rx, db_connection_clone, - suspend_clone, interrupt_clone, ); }); @@ -153,7 +131,6 @@ impl IndexingService { status, command_tx, db_connection, - suspend_flag, interrupt, _handle: handle, } @@ -171,22 +148,6 @@ impl IndexingService { db::interrupt(&self.interrupt) } - /// Pause the indexer: worker loops calling [`should_abort`] block until - /// [`resume`](Self::resume). Does not stop the worker. - pub fn suspend(&self) { - self.suspend_flag.store(true, Ordering::Relaxed); - } - - /// Resume indexing after [`suspend`](Self::suspend). No-op if not - /// suspended. - pub fn resume(&self) { - self.suspend_flag.store(false, Ordering::Relaxed); - } - - pub fn is_suspended(&self) -> bool { - self.suspend_flag.load(Ordering::Relaxed) - } - /// Start indexing one or more roots; all walk concurrently, funnelling /// into one writer thread. Duplicate roots collapse to one walk, and a /// file reachable from more than one is written once. Returns `Err` if a @@ -301,11 +262,6 @@ impl IndexingService { } } - /// Force graceful shutdown - used for signal handling - pub fn graceful_shutdown(&self) -> Result<(), String> { - self.stop_indexing() - } - /// Check if configuration changes require index recreation. A pure /// *read* check that never wipes; a missing or incompatible DB means /// there is nothing to validate. @@ -367,7 +323,6 @@ impl IndexingService { status: Arc>, command_rx: mpsc::Receiver, db_connection: Arc>>>>, - suspend_flag: Arc, interrupt: Arc, ) { let stop_flag = Arc::new(AtomicBool::new(false)); @@ -396,7 +351,6 @@ impl IndexingService { let config_owned = config.clone(); let db_connection_clone = db_connection.clone(); - let suspend_clone = suspend_flag.clone(); let interrupt_clone = interrupt.clone(); indexing_handle = Some(thread::spawn(move || { // The writer thread: every DB write and every text @@ -407,7 +361,6 @@ impl IndexingService { &paths_owned, &db_path_owned, &stop_flag_clone, - &suspend_clone, &config_owned, &db_connection_clone, &interrupt_clone, diff --git a/crates/quicksearch-core/src/indexing/pipeline.rs b/crates/quicksearch-core/src/indexing/pipeline.rs index 43d4c0c..be4f54d 100644 --- a/crates/quicksearch-core/src/indexing/pipeline.rs +++ b/crates/quicksearch-core/src/indexing/pipeline.rs @@ -13,9 +13,9 @@ use crate::db; use crate::db::repo; use crate::extract::Registry; use crate::file_handling::{ - cleanup_stale_index_entries, count_tree_entries_fast, extract_scope_prepare, - fts_finalize_after_text_indexing, normalize_root_string, process_batch_inserts, - process_batch_updates, store_extracted, ExtractCursor, FileIndexAction, OwnedNewFile, + cleanup_stale_index_entries, count_tree_entries_fast, fts_finalize_after_text_indexing, + mark_oversize_pending_na, normalize_root_string, process_batch_inserts, process_batch_updates, + store_extracted, ExtractCursor, ExtractScope, FileIndexAction, OwnedNewFile, }; use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WalkEvent}; @@ -84,6 +84,20 @@ impl Drop for CancelOnDrop { } } +/// Writer time one root's turn may take before the round moves on. +/// +/// This is the bound on how long any root can hold up the others. Before +/// there was one, an extraction turn ran to the end of whatever was ready — +/// half a second to two seconds of FTS5 trigram tokenization for a batch of +/// large documents — while a walking root's rows sat in its channel and its +/// walkers parked behind them. Reads as "4/4 workers busy, no progress". +const TURN_SLICE: Duration = Duration::from_millis(100); + +/// Most extracted rows a root holds back between turns. Not `quantum`: a row +/// carries up to `maximum_text_size` of text, and 500 of those would be +/// 128 MiB per root. At 64 it is 16 MiB. +const READY_TOPUP: usize = 64; + /// One root's in-flight indexing state, owned by the writer loop. pub(super) struct RootPipeline { pub(super) root: String, @@ -100,8 +114,14 @@ pub(super) struct RootPipeline { pub(super) phase: RootPhase, /// The running content pass, once this root's walk has finished. pub(super) content: Option, - pub(super) extract_total: usize, - pub(super) extracted: usize, + /// Extracted rows pulled off the pass and not yet written. A turn writes + /// for its slice, not for its batch, so it may leave some behind. + pub(super) ready: Vec, + /// Rows this run's content pass has written for this root. + pub(super) written: usize, + /// The pass's range counts, cached once known so a `Done` root still has + /// them after its pass is gone. + pub(super) totals: Option, pub(super) current_file: Option, /// When this root's current phase began, for the one line each phase logs /// when it ends. @@ -173,8 +193,16 @@ impl RootPipeline { stats.map_or((0, 0), |s| (s.active(), s.total())) } - fn snapshot(&self) -> RootProgress { + /// The pass's counts, from the cache or — until the cache is filled — from + /// the pass itself. + fn extract_totals(&self) -> Option { + self.totals + .or_else(|| self.content.as_ref().and_then(|p| p.totals())) + } + + pub(super) fn snapshot(&self) -> RootProgress { let (active_workers, total_workers) = self.worker_counts(); + let totals = self.extract_totals(); RootProgress { root: self.root.clone(), phase: self.phase, @@ -183,30 +211,58 @@ impl RootPipeline { 0 => None, n => Some(n), }, - extracted: self.extracted, - extract_total: self.extract_total, + // Earlier runs' rows count once the pass has counted them; until + // then only this run's, so the figure never goes backwards. + extracted: totals.map_or(self.written, |t| t.already_done + self.written), + extract_total: totals.map(|t| t.pending + t.already_done), current_file: self.current_file.clone(), active_workers, total_workers, } } - /// Drain up to one quantum of walk events into the pending batches, + /// Drain walk events into the pending batches for up to one slice, /// finishing the walk if it ends. Returns whether anything happened. - fn service_walking(&mut self, cx: &mut RunCx<'_>) -> Result { + /// + /// Batches still land per quantum; the slice only decides how many of + /// them one turn may write. A walk slower than the writer ends its turn at + /// `Empty` well inside the slice; only a walk that has the writer + /// saturated uses all of it. + pub(super) fn service_walking(&mut self, cx: &mut RunCx<'_>) -> Result { + let deadline = Instant::now() + cx.slice; let mut took = 0usize; let mut finished = false; - while took < cx.quantum { + while !finished { + let quantum_end = took + cx.quantum; + let more = self.walk_quantum(cx, &mut took, quantum_end, &mut finished)?; + if !more || Instant::now() >= deadline { + break; + } + } + Ok(finished || took > 0) + } + + /// One quantum of [`RootPipeline::service_walking`]. Returns whether the + /// channel still had events when the quantum ended — false on `Empty` or + /// on the walk finishing. + fn walk_quantum( + &mut self, + cx: &mut RunCx<'_>, + took: &mut usize, + quantum_end: usize, + finished: &mut bool, + ) -> Result { + while *took < quantum_end { match self.walk.try_next() { TryNext::Item(WalkEvent::Stale(paths)) => { - took += 1; + *took += 1; // Applied at the end of the run: deleting mid-walk would // break "a stopped run deletes nothing", and an aliased // sighting that exempts a path may still be ahead. cx.stale_candidates.extend(paths); } TryNext::Item(WalkEvent::File(file)) => { - took += 1; + *took += 1; self.walked += 1; if self.walked.is_multiple_of(64) { self.current_file = Some(file.path.clone()); @@ -247,15 +303,15 @@ impl RootPipeline { } } } - TryNext::Empty => break, + TryNext::Empty => return Ok(false), TryNext::Finished => { self.finish_walk(cx)?; - finished = true; - break; + *finished = true; + return Ok(false); } } } - Ok(finished || took > 0) + Ok(true) } /// The walk ended: land the buffered batches, then either hand the root @@ -314,55 +370,89 @@ impl RootPipeline { } } let cursor = ExtractCursor::for_root(&self.root); - let scope = extract_scope_prepare(&cx.conn_mutex, &cursor, cx.config)?; - // Progress counts the root's whole searchable set: files extracted - // in earlier runs start the counter, so an unchanged root shows - // "X of X" rather than "0 of 0". - self.extract_total = scope.pending + scope.already_done; - self.extracted = scope.already_done; - if scope.pending == 0 { - self.phase = RootPhase::Done; - } else { - // Starts only now: the rows have to exist before the feeder - // can page over them. - self.content = Some(crate::content::extract_content( - cx.db_path, - &cursor, - cx.registry.clone(), - cx.config.clone(), - cx.stop_flag.clone(), - cx.suspend_flag.clone(), - self.workers, - )); - self.phase = RootPhase::Extracting; + // Only the sweep runs on the writer. Counting the range is the + // pass's own job, on its own connection: on a large root it is + // seconds, and here that was seconds of every other root's walk + // standing still. + { + let conn = crate::lock_ok(&cx.conn_mutex); + mark_oversize_pending_na(&conn, &cursor, cx.config)?; } + self.totals = None; + self.written = 0; + self.ready.clear(); + // Starts only now: the rows have to exist before the feeder can + // page over them. Started even when nothing may be pending — the + // count that would say so is the pass's — and an empty range + // finishes on its own next turn. + self.content = Some(crate::content::extract_content( + cx.db_path, + &cursor, + cx.registry.clone(), + cx.config.clone(), + cx.stop_flag.clone(), + self.workers, + )); + self.phase = RootPhase::Extracting; } Ok(()) } - /// Drain up to a quantum of finished extraction work, then write it; - /// extraction runs on this root's own pool. Returns whether anything - /// happened. - fn service_extracting(&mut self, cx: &mut RunCx<'_>) -> Result { - let pass = self.content.as_mut().expect("extracting root has a pass"); - let mut batch: Vec = Vec::new(); + /// Write finished extraction work for up to one slice; extraction itself + /// runs on this root's own pool. Returns whether anything happened. + /// + /// Rows the slice does not reach stay in `ready` for the next turn, and + /// the pass is not declared done until they have all landed. + pub(super) fn service_extracting(&mut self, cx: &mut RunCx<'_>) -> Result { + let deadline = Instant::now() + cx.slice; let mut finished = false; - while batch.len() < cx.quantum { - match pass.try_next() { - TryNext::Item(row) => batch.push(row), - TryNext::Empty => break, - TryNext::Finished => { - finished = true; - break; + let mut consumed = 0usize; + // Disjoint borrows: the pass is held across the store. + let Self { + content, + ready, + written, + totals, + current_file, + .. + } = self; + let pass = content.as_mut().expect("extracting root has a pass"); + if totals.is_none() { + *totals = pass.totals(); + } + loop { + while ready.len() < READY_TOPUP { + match pass.try_next() { + TryNext::Item(row) => ready.push(row), + TryNext::Empty => break, + TryNext::Finished => { + finished = true; + break; + } } } + if ready.is_empty() { + break; + } + 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: with leftovers + // the two can be a slice apart. + *current_file = Some(ready[stored.consumed - 1].name.clone()); + } + ready.drain(..stored.consumed); + *written += stored.written; + consumed += stored.consumed; + // Stopped, out of time, or still holding rows the deadline cut + // short — the next turn takes it from here. + if stored.consumed == 0 || !ready.is_empty() || Instant::now() >= deadline { + break; + } } - if let Some(row) = batch.last() { - self.current_file = Some(row.name.clone()); - } - let took = batch.len(); - self.extracted += store_extracted(&cx.conn_mutex, &batch, cx.stop_flag, cx.config)?; - if finished { + if finished && ready.is_empty() { + if totals.is_none() { + *totals = pass.totals(); + } // Join before deciding; see `ParallelWalk::finish`. if !pass.finish() { crate::log_warn!("a content worker for {} terminated abnormally", self.root); @@ -370,39 +460,67 @@ impl RootPipeline { self.content = None; self.phase = RootPhase::Done; let extract_time = self.phase_elapsed(); - crate::log_info!( - "{}: content done — {}", - self.root, - phase_summary(self.extracted, "files with text", extract_time) - ); + // Quiet for the pass that found nothing to do: every root passes + // through here now, changed or not. + if self.written > 0 { + crate::log_info!( + "{}: content done — {}", + self.root, + phase_summary(self.written, "files with text", extract_time) + ); + } } - Ok(finished || took > 0) + Ok(finished || consumed > 0) } } /// One run's shared environment and cross-root state, threaded through the /// per-phase [`RootPipeline`] service methods. -struct RunCx<'a> { - conn_mutex: Arc>, - config: &'a Config, - db_path: &'a str, - stop_flag: &'a Arc, - suspend_flag: &'a Arc, +pub(super) struct RunCx<'a> { + pub(super) conn_mutex: Arc>, + pub(super) config: &'a Config, + pub(super) db_path: &'a str, + pub(super) stop_flag: &'a Arc, /// Shared with every root's walk workers, which use it to finish small /// text files without handing them to the content pass. - registry: Arc, - quantum: usize, + pub(super) registry: Arc, + pub(super) quantum: usize, + /// See [`TURN_SLICE`]; a field so tests can shrink it. + pub(super) slice: Duration, /// 128-bit path digests, not paths: at millions of files, owning every /// path string again was the single largest allocation in a run. See /// `walk::path_digest`. - seen_paths: HashSet, + pub(super) seen_paths: HashSet, /// Rows the per-directory reconciliation found no file behind, plus /// whatever the vanished-directory sweep adds once the walks end. - stale_candidates: Vec, + pub(super) stale_candidates: Vec, /// Paths reached by resolving a symlink, whose row lives under a parent /// that may be outside every root. - aliased_paths: HashSet, - stale_cleanup_ok: bool, + pub(super) aliased_paths: HashSet, + pub(super) stale_cleanup_ok: bool, +} + +impl<'a> RunCx<'a> { + pub(super) fn new( + conn_mutex: Arc>, + config: &'a Config, + db_path: &'a str, + stop_flag: &'a Arc, + ) -> RunCx<'a> { + RunCx { + conn_mutex, + config, + db_path, + stop_flag, + registry: Arc::new(Registry::default_set()), + quantum: config.processing.batch_size.max(1), + slice: TURN_SLICE, + seen_paths: HashSet::new(), + stale_candidates: Vec::new(), + aliased_paths: HashSet::new(), + stale_cleanup_ok: true, + } + } } /// Publish a status snapshot. Never clobbers Stopping — the command thread @@ -447,7 +565,6 @@ fn build_pipeline( cx.config.clone(), cx.registry.clone(), cx.stop_flag.clone(), - cx.suspend_flag.clone(), workers, ); @@ -491,8 +608,9 @@ fn build_pipeline( phase: RootPhase::Walking, workers, content: None, - extract_total: 0, - extracted: 0, + ready: Vec::new(), + written: 0, + totals: None, current_file: None, phase_started: Instant::now(), }) @@ -557,7 +675,6 @@ fn cleanup_stale(pipelines: &mut [RootPipeline], cx: &mut RunCx<'_>) -> Result<( &cx.conn_mutex, stale_paths.as_slice(), cx.stop_flag, - cx.suspend_flag, cx.config, )?; crate::log_info!( @@ -575,7 +692,6 @@ impl IndexingService { paths: &[String], db_path: &str, stop_flag: &Arc, - suspend_flag: &Arc, config: &Config, db_connection: &Arc>>>>, interrupt: &db::InterruptSlot, @@ -637,19 +753,7 @@ impl IndexingService { let count_cancel = Arc::new(AtomicBool::new(false)); let _count_guard = CancelOnDrop(count_cancel.clone()); - let mut cx = RunCx { - conn_mutex, - config, - db_path, - stop_flag, - suspend_flag, - registry: Arc::new(Registry::default_set()), - quantum: config.processing.batch_size.max(1), - seen_paths: HashSet::new(), - stale_candidates: Vec::new(), - aliased_paths: HashSet::new(), - stale_cleanup_ok: true, - }; + let mut cx = RunCx::new(conn_mutex, config, db_path, stop_flag); // Read stored counts up front, under one lock, before the walks // compete for the connection. @@ -689,10 +793,19 @@ impl IndexingService { }; let mut checkpoint_at = wal_cap; - // Round-robin with skipping: each round takes at most one quantum - // from every root that has work ready. + // Walks first, one slice each, then a single extraction slice. + // + // The walk is the disk-bound phase and the one whose stall shows: its + // workers can only run as far ahead as their channel, so a writer that + // does not come back to it soon enough parks a whole pool behind one + // root's tokenizing. Serving every walking root before any extraction + // caps a walk's wait at one slice per round; taking one extraction + // slice per round, not one per root, keeps that cap independent of + // how many roots are extracting — while still handing extraction a + // slice every round, so it is never starved either. Any root's turn + // ends early the moment it has nothing ready. loop { - if should_abort(stop_flag, suspend_flag) { + if stop_flag.load(Ordering::Relaxed) { aborted = true; break; } @@ -700,11 +813,16 @@ impl IndexingService { let n = pipelines.len(); for k in 0..n { let p = &mut pipelines[(rr + k) % n]; - progressed |= match p.phase { - RootPhase::Walking => p.service_walking(&mut cx)?, - RootPhase::Extracting => p.service_extracting(&mut cx)?, - RootPhase::Done => false, - }; + if p.phase == RootPhase::Walking { + progressed |= p.service_walking(&mut cx)?; + } + } + for k in 0..n { + let p = &mut pipelines[(rr + k) % n]; + if p.phase == RootPhase::Extracting { + progressed |= p.service_extracting(&mut cx)?; + break; + } } rr = rr.wrapping_add(1); @@ -766,15 +884,19 @@ impl IndexingService { } if aborted { - // Buffered records are valid work — land them before leaving. - for p in &mut pipelines { - process_batch_updates(&cx.conn_mutex, &p.pending_updates, stop_flag, config)?; - p.pending_updates.clear(); - process_batch_inserts(&cx.conn_mutex, &p.pending_inserts, stop_flag, config)?; - p.pending_inserts.clear(); - } - // No stale cleanup: a partial walk's seen set would delete most - // of the index. + // Nothing is landed on the way out, and there used to be a loop + // here that looked as though it did: `aborted` implies the stop + // flag is set, and both batch writers return on it before their + // first chunk, so it wrote nothing. What a stop drops is each + // root's part-filled insert/update batch (under `batch_size` + // rows) and whatever extraction had ready — all of it still + // `content_state = 0` or absent, so the next run finds it again. + // That is what "a stopped run promises nothing" already means, + // and it is cheaper than tokenizing a slice's worth of documents + // while someone waits for the window to close. + // + // No stale cleanup either: a partial walk's seen set would delete + // most of the index. report_run_warnings(); crate::log_info!( "indexing stopped after {:.1}s", diff --git a/crates/quicksearch-core/src/indexing/progress.rs b/crates/quicksearch-core/src/indexing/progress.rs index 856a7ee..47a4625 100644 --- a/crates/quicksearch-core/src/indexing/progress.rs +++ b/crates/quicksearch-core/src/indexing/progress.rs @@ -26,12 +26,15 @@ pub struct RootProgress { /// counts tree *entries* and so reads high. Read it through /// [`RootProgress::walk_denominator`]. pub walk_total: Option, - /// Rows with searchable text: extracted in earlier runs plus this one. + /// Rows with searchable text: this run's, plus earlier runs' once + /// `extract_total` is known. pub extracted: usize, /// The root's whole searchable set: pending + already-extracted rows when /// the walk finished — the count of files that have or will have text, - /// not of files under the root. - pub extract_total: usize, + /// not of files under the root. `None` until the root's content pass has + /// counted its range: a scan that takes seconds on a large root, and one + /// that used to run on the writer thread with every other root waiting. + pub extract_total: Option, pub current_file: Option, /// Threads busy right now / pool size, for the pool this root's current /// phase is running. Both zero once the root is done: its threads are @@ -80,14 +83,22 @@ impl OverallProgress { } /// Aggregate every root's progress into the one pair the status bar shows. -/// `extract_total` is exact the moment a root's walk ends; before that a -/// root contributes only its walk. +/// +/// A root contributes its extraction half only once `extract_total` is known +/// — both to `processed` and to `total`, so the two stay in step. Until then +/// (during the walk, and for the moments after it while the pass counts) it +/// contributes its walk alone. pub fn overall_progress(roots: &[RootProgress]) -> OverallProgress { - let processed = roots.iter().map(|r| r.walked + r.extracted).sum(); + let processed = roots + .iter() + .map(|r| r.walked + r.extract_total.map_or(0, |_| r.extracted)) + .sum(); let mut total = Some(0usize); for r in roots { match (total, r.walk_denominator()) { - (Some(acc), Some(walk)) => total = Some(acc + walk + r.extract_total), + (Some(acc), Some(walk)) => { + total = Some(acc + walk + r.extract_total.unwrap_or(0)); + } _ => { total = None; break; diff --git a/crates/quicksearch-core/src/indexing/tests.rs b/crates/quicksearch-core/src/indexing/tests.rs index 2879629..b62dfd2 100644 --- a/crates/quicksearch-core/src/indexing/tests.rs +++ b/crates/quicksearch-core/src/indexing/tests.rs @@ -4,6 +4,7 @@ use crate::extract::Registry; use crate::file_handling::ExtractCursor; use crate::walk::walk_indexable_files; use std::sync::atomic::AtomicUsize; +use std::time::Duration; fn tmp_dir(tag: &str) -> std::path::PathBuf { // Canonical: the temp dir itself may sit behind a symlink @@ -86,7 +87,6 @@ fn worker_counts_follow_the_phase() { let root = dir.to_string_lossy().into_owned(); let stop = Arc::new(AtomicBool::new(false)); - let suspend = Arc::new(AtomicBool::new(false)); let walk = walk_indexable_files( std::slice::from_ref(&root), false, @@ -96,7 +96,6 @@ fn worker_counts_follow_the_phase() { Config::default(), Arc::new(Registry::default_set()), stop.clone(), - suspend.clone(), 3, ); // An empty range, so the pass ends immediately — but its pool size is @@ -107,7 +106,6 @@ fn worker_counts_follow_the_phase() { Arc::new(Registry::default_set()), Config::default(), stop, - suspend, 2, ); @@ -123,8 +121,9 @@ fn worker_counts_follow_the_phase() { phase: RootPhase::Walking, phase_started: Instant::now(), content: Some(content), - extract_total: 0, - extracted: 0, + ready: Vec::new(), + written: 0, + totals: None, current_file: None, }; @@ -138,6 +137,157 @@ fn worker_counts_follow_the_phase() { std::fs::remove_dir_all(&dir).ok(); } +/// The writer's extraction turn is bounded by its slice, not by what is +/// ready: rows the slice does not reach are carried to the next turn, and the +/// root is not `Done` until they have all landed. Pinned with a zero slice, +/// under which every turn writes exactly one row. +#[test] +fn an_extracting_turn_lands_its_leftovers_one_slice_at_a_time() { + use super::pipeline::RunCx; + use crate::content::ExtractedRow; + use crate::db::repo::{insert_file, NewFile}; + use crate::file_handling::ContentOutcome; + use crate::mime::FileType; + + let dir = tmp_dir("slice-leftovers"); + let db_path = dir.join("index.db").to_string_lossy().into_owned(); + let mut conn = db::open_or_recreate(&db_path, "trigram").unwrap(); + let tree = dir.join("tree"); + std::fs::create_dir_all(&tree).unwrap(); + // Five rows the walk would have written, whose extracted text is + // hand-built below rather than read back — the pass is not the subject. + let mut ready: Vec = Vec::new(); + { + let tx = conn.transaction().unwrap(); + for i in 0..5 { + let path = tree.join(format!("f{}.txt", i)); + std::fs::write(&path, "sphinx of black quartz").unwrap(); + let file_id = insert_file( + &tx, + &NewFile { + name: &format!("f{}.txt", i), + path: &path.to_string_lossy(), + parent: &tree.to_string_lossy(), + size: 22, + mtime: 1, + inode: None, + device_id: None, + mime: Some("text/plain"), + ftype: FileType::TEXT, + hash: None, + needs_content: true, + }, + ) + .unwrap() + .expect("unique path"); + ready.push(ExtractedRow { + file_id, + name: format!("f{}.txt", i), + outcome: ContentOutcome::Done { + text: format!("sphinx of black quartz {}", i), + properties: Vec::new(), + }, + }); + } + tx.commit().unwrap(); + } + let conn_mutex = Arc::new(Mutex::new(conn)); + + let root = dir.to_string_lossy().into_owned(); + let stop = Arc::new(AtomicBool::new(false)); + let config = Config::default(); + let walk = walk_indexable_files( + std::slice::from_ref(&root), + false, + false, + crate::config::IgnoreSet::compile(&[]).unwrap(), + &db_path, + config.clone(), + Arc::new(Registry::default_set()), + stop.clone(), + 1, + ); + // An empty range: the pass reports `Finished` on its own, and the turn + // has to keep going past that until `ready` is empty. + let content = crate::content::extract_content( + &db_path, + &ExtractCursor::for_root(&dir.join("nothing").to_string_lossy()), + Arc::new(Registry::default_set()), + config.clone(), + stop.clone(), + 1, + ); + let mut p = RootPipeline { + root, + walk, + count_total: Arc::new(AtomicUsize::new(0)), + workers: 1, + pending_updates: Vec::new(), + pending_inserts: Vec::new(), + walked: 0, + walk_clean: true, + phase: RootPhase::Extracting, + phase_started: Instant::now(), + content: Some(content), + ready, + written: 0, + totals: None, + current_file: None, + }; + let mut cx = RunCx::new(conn_mutex.clone(), &config, &db_path, &stop); + cx.slice = Duration::ZERO; + + let mut turns = 0; + while p.phase == RootPhase::Extracting { + turns += 1; + assert!( + turns < 200, + "the root never finished: {} written", + p.written + ); + let before = p.written; + let progressed = p.service_extracting(&mut cx).unwrap(); + assert!( + p.written - before <= 1, + "a zero slice wrote {} rows in one turn", + p.written - before + ); + assert!( + p.phase != RootPhase::Done || p.ready.is_empty(), + "Done with {} rows still to write", + p.ready.len() + ); + if !progressed { + // The empty pass has not reported `Finished` yet. + std::thread::sleep(Duration::from_millis(1)); + } + } + assert_eq!(p.written, 5); + assert!(p.ready.is_empty()); + assert!( + turns >= 5, + "five rows cannot land in {} zero-slice turns", + turns + ); + let done: i64 = conn_mutex + .lock() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM files WHERE content_state = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(done, 5, "every row reached the index"); + // The empty pass counted its (empty) range, so the totals are known and + // the snapshot reports this run's rows on top of the range's zero. + assert_eq!(p.snapshot().extracted, 5); + assert_eq!(p.snapshot().extract_total, Some(0)); + + drop(p); + std::fs::remove_dir_all(&dir).ok(); +} + /// One full run over `config`'s roots, driven directly so the caller owns /// the stop flag. Returns when the run does. fn run_with(config: &Config, db_path: &str, stop: &Arc) -> Result<(), String> { @@ -146,7 +296,6 @@ fn run_with(config: &Config, db_path: &str, stop: &Arc) -> Result<() &config.paths.indexing_paths, db_path, stop, - &Arc::new(AtomicBool::new(false)), config, &Arc::new(Mutex::new(None)), &db::InterruptSlot::default(), @@ -374,7 +523,7 @@ fn progress(phase: RootPhase, walked: usize, walk_total: Option) -> RootP walked, walk_total, extracted: 0, - extract_total: 0, + extract_total: None, current_file: None, active_workers: 0, total_workers: 0, @@ -426,7 +575,7 @@ fn overall_progress_sums_both_halves_of_every_root() { let mut walking = progress(RootPhase::Walking, 100, Some(1000)); let mut extracting = progress(RootPhase::Extracting, 500, Some(9999)); extracting.extracted = 200; - extracting.extract_total = 400; + extracting.extract_total = Some(400); walking.extracted = 0; let o = overall_progress(&[walking, extracting]); @@ -435,6 +584,19 @@ fn overall_progress_sums_both_halves_of_every_root() { assert_eq!(o.total, Some(1900)); } +/// A root whose content pass has not counted its range yet contributes only +/// its walk — to both halves, so processed and total stay in step and the +/// bar cannot jump when the count lands. +#[test] +fn an_uncounted_extraction_contributes_only_its_walk() { + let mut counting = progress(RootPhase::Extracting, 500, None); + counting.extracted = 7; + counting.extract_total = None; + let o = overall_progress(&[counting]); + assert_eq!(o.processed, 500); + assert_eq!(o.total, Some(500)); +} + #[test] fn one_uncounted_walking_root_leaves_the_whole_total_unknown() { let known = progress(RootPhase::Done, 10, Some(10)); @@ -472,7 +634,7 @@ fn a_finished_run_reaches_exactly_one_hundred_percent() { .map(|&(walked, extracted)| { let mut p = progress(RootPhase::Done, walked, Some(walked * 2)); p.extracted = extracted; - p.extract_total = extracted; + p.extract_total = Some(extracted); p }) .collect(); @@ -496,6 +658,9 @@ fn a_run_with_nothing_to_do_has_no_fraction_to_show() { fn the_fraction_never_exceeds_one() { let mut p = progress(RootPhase::Done, 10, None); p.extracted = 100; + // A counted scope the writes then overran; an uncounted one would be + // left out of both halves and prove nothing here. + p.extract_total = Some(0); let o = overall_progress(&[p]); assert_eq!(o.processed, 110); assert_eq!(o.total, Some(10)); diff --git a/crates/quicksearch-core/src/lib.rs b/crates/quicksearch-core/src/lib.rs index 5829e43..e54383d 100644 --- a/crates/quicksearch-core/src/lib.rs +++ b/crates/quicksearch-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod extract; pub mod file_handling; pub mod incremental; pub mod indexing; +pub mod live; pub mod log; pub mod mime; pub mod platform; @@ -19,6 +20,7 @@ pub mod snippet; #[doc(hidden)] pub mod testutil; pub mod textenc; +pub mod verify; pub mod walk; pub mod watcher; diff --git a/crates/quicksearch-core/src/live.rs b/crates/quicksearch-core/src/live.rs new file mode 100644 index 0000000..e996413 --- /dev/null +++ b/crates/quicksearch-core/src/live.rs @@ -0,0 +1,734 @@ +//! Watching the search results a frontend is actually showing. +//! +//! What a row shows is read from the *file*, never from the index. That is +//! what lets this work with indexing stopped, with a file outside every +//! indexed root, or against a row the indexer has not caught up with yet — +//! and it is the whole point of the feature: the list on screen describes the +//! disk, not a snapshot of it. +//! +//! Nothing here writes to the index. Keeping it so is what lets this run +//! alongside the indexer without a second writer; a frontend that wants the +//! index brought back in line with what it just displayed hands the paths to +//! [`crate::coordinator::IndexCoordinator::update_paths`], which does the +//! write on its own thread. +//! +//! # Why directories, not files +//! +//! The obvious design is a watch per result file. It does not work. Editors +//! save by writing a temporary file and renaming it over the target, so the +//! event lands on the *directory* and the old inode — the one a file watch is +//! attached to — is simply orphaned. A file watch also cannot report the new +//! name of a rename. Watching the deduplicated set of parent directories +//! `NonRecursive` sees both, on inotify and on `ReadDirectoryChangesW` alike. +//! +//! # Why this is not [`crate::watcher`] +//! +//! That module exists to cover *subtrees*: it walks each root registering +//! every directory beneath it, adds directories that appear later, and backs a +//! 128k budget with an all-or-nothing guarantee. Pointing it at a result's +//! parent would register that parent's whole tree. This is a flat, fixed, tiny +//! set with no growth and no budget, and its timings are a tenth of that one's +//! — the indexer can afford to coalesce for thirty seconds, a cursor blinking +//! next to a stale filename cannot. +//! +//! # What an event turns into +//! +//! A rename is applied from the event itself. A content change is answered by +//! reading the file: `metadata` for size and modified time, and — for a row +//! whose cell shows body text — the same MIME sniffing and extractors the +//! indexer uses, re-cut through the same [`crate::search::cascade::text_snippet`] +//! (or, for a fuzzy hit, [`crate::search::cascade::fuzzy_snippet`]) the +//! search itself uses. +//! +//! Arming also sweeps every target once, comparing the file on disk against +//! the size and modified time the row is *currently displaying*. Since a fresh +//! result carries what the index said, that sweep is exactly a check of the +//! index against the disk, and it is what makes a row corrected while it was +//! scrolled out of view right itself the moment it comes back. It is also the +//! only thing that works on a filesystem the platform reports no events for. + +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use notify::{ + Config as NotifyConfig, Event as NotifyEvent, EventKind, RecommendedWatcher, RecursiveMode, + Watcher as NotifyWatcher, +}; + +use crate::config::Config; +use crate::extract::Registry; +use crate::query::split::split_for_cascade; +use crate::search::fuzzy::{edit_budget, Bitap}; +use crate::search::ContentTier; +use crate::snippet::Snippet; + +/// How long events for one path are pooled before being acted on, so the +/// several notify emits behind a single save collapse into one update. +const SETTLE: Duration = Duration::from_millis(150); + +/// Floor on how often any one path may produce an update. A file being written +/// in a loop — a log, a build artifact — cannot spin the UI. +const MIN_INTERVAL: Duration = Duration::from_millis(750); + +/// How often the loop wakes while it has work pending. It blocks outright when +/// it has none, so an idle QuickSearch does not tick at all. +const TICK: Duration = Duration::from_millis(50); + +/// Ceiling on updates emitted per tick, so a directory-wide change (an +/// unpack, a `chmod -R`) drains over several frames instead of one. +const MAX_PER_TICK: usize = 4; + +/// Most watches to register. Results cluster hard — a query's hits usually +/// share a handful of directories — so this is generous for the visible rows +/// while staying negligible against the indexer's 128k budget. +const MAX_DIRS: usize = 64; + +/// Most rows to track, whatever the frontend asks for. +const MAX_TARGETS: usize = 256; + +/// One row the frontend is showing and wants kept current. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + /// The row's path, spelled exactly as the search returned it. Event paths + /// are compared against this byte for byte — see the note in [`watch`]. + /// + /// [`watch`]: LiveWatcher::watch + pub path: String, + /// `Some` when this row displays text from the file's *body*, and so + /// needs its snippet re-cut when the file changes — and how, since an + /// exact-tier window is cut around the literal term and a fuzzy one + /// around a bitap match the literal is usually absent from. `None` for a + /// filename or path match, which costs one `metadata` call per change and + /// never opens the file. + pub text: Option, + /// The size the row is displaying. The arm-time sweep compares the file + /// against this, so on a fresh result — where it is whatever the index + /// said — the sweep doubles as a check of the index against the disk. + pub size: u64, + /// The modified time the row is displaying; see [`Target::size`]. + pub mtime: i64, +} + +/// What a change did to a row's Content Match window. +/// +/// Three states, not an `Option`, because "I did not look" and "I looked and +/// it is not there any more" have to reach the frontend as different answers. +/// Blanking a cell because the file was too large to re-read would lose a +/// window the search legitimately found. +#[derive(Debug, Clone, PartialEq)] +pub enum WindowUpdate { + /// Nothing to say: not a body-text row, or its body could not be re-read + /// (too large, no extractor, unreadable). The cell keeps what it has. + Unchanged, + /// Re-cut from the file as it is now. + Cut(Snippet), + /// The body was read and the query is no longer in it. The cell has + /// nothing to show and falls back to its dash. + NoMatch, +} + +/// A ready-to-apply change to one row on screen. +#[derive(Debug, Clone, PartialEq)] +pub enum LiveUpdate { + /// The file moved. `path` is the row's old path — the frontend's key. + Renamed { + path: String, + to: String, + name: String, + }, + /// The file's contents changed, as read from the file itself. + Changed { + path: String, + size: u64, + mtime: i64, + window: WindowUpdate, + }, + /// The file is no longer there. Reversible: the directory watch stays, so + /// a file recreated at the same path reports [`LiveUpdate::Changed`]. + Gone { path: String }, +} + +impl LiveUpdate { + /// The row this update is keyed by — the path the frontend knows it as. + pub fn path(&self) -> &str { + match self { + LiveUpdate::Renamed { path, .. } + | LiveUpdate::Changed { path, .. } + | LiveUpdate::Gone { path } => path, + } + } +} + +/// What one settled event window decided about a path, before the filesystem +/// is consulted. +#[derive(Debug, Clone, PartialEq)] +enum Op { + Changed, + Renamed(PathBuf), + /// Provisional: on Linux the `From` half of a rename arrives before the + /// paired event that names the destination, so this may still be upgraded + /// to [`Op::Renamed`] inside the same window. + Gone, +} + +/// Commands and events share one channel so the loop can block on `recv()` +/// whenever nothing is pending. +enum Msg { + Event(NotifyEvent), + Watch { + query: String, + targets: Vec, + /// Boxed: this is by far the largest variant, and a `Watch` is rare + /// next to the events sharing the channel with it. + config: Box, + }, + Clear, + Stop, +} + +/// Handle on the watcher thread. Dropping it stops the thread. +pub struct LiveWatcher { + tx: mpsc::Sender, + handle: Option>, +} + +impl LiveWatcher { + /// Spawn the watcher. `notify` is called after every update is queued, so + /// an egui frontend can `request_repaint`; pass a no-op for headless use. + pub fn start(notify: Arc) -> (LiveWatcher, mpsc::Receiver) { + let (tx, rx) = mpsc::channel::(); + let (update_tx, update_rx) = mpsc::channel::(); + let event_tx = tx.clone(); + let handle = thread::Builder::new() + .name("qs-live".into()) + .spawn(move || { + Loop { + rx, + event_tx, + update_tx, + notify, + watcher: None, + targets: HashMap::new(), + pending: HashMap::new(), + last_emit: HashMap::new(), + orphan_to: Vec::new(), + settle_at: None, + query: None, + fuzzy: None, + config: None, + registry: Registry::default_set(), + } + .run() + }) + .expect("spawn live watcher"); + ( + LiveWatcher { + tx, + handle: Some(handle), + }, + update_rx, + ) + } + + /// Replace the watched set wholesale. + /// + /// `query` is the search these rows came from; it is what a re-cut snippet + /// is marked against. `config` supplies the extraction limits and filters, + /// so a snippet cut here is the text the indexer would have stored. + /// Registration happens on the watcher's own thread, so this never blocks + /// the caller on a spun-down disk or a stale mount. + pub fn watch(&self, query: &str, targets: Vec, config: &Config) { + let _ = self.tx.send(Msg::Watch { + query: query.to_string(), + targets, + config: Box::new(config.clone()), + }); + } + + /// Drop every watch and forget every pending update. + pub fn clear(&self) { + let _ = self.tx.send(Msg::Clear); + } + + /// Stop the thread and join it. Idempotent; [`Drop`] calls it. + pub fn stop(&mut self) { + let _ = self.tx.send(Msg::Stop); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +impl Drop for LiveWatcher { + fn drop(&mut self) { + self.stop(); + } +} + +struct Loop { + rx: mpsc::Receiver, + event_tx: mpsc::Sender, + update_tx: mpsc::Sender, + notify: Arc, + watcher: Option, + targets: HashMap, + pending: HashMap, + last_emit: HashMap, + /// Rename destinations seen in this window whose source is not a target — + /// the other half of a Windows rename, which carries no pairing cookie. + orphan_to: Vec, + settle_at: Option, + query: Option, + /// The fuzzy matcher for `query`, built at arm time — building it is the + /// cost, running it is cheap — for re-cutting [`ContentTier::Fuzzy`] + /// rows. `None` when the term does not fuzz (too short, wildcarded, or a + /// zero edit budget), which is when the fuzzy pass would not have run. + fuzzy: Option, + config: Option>, + /// Built once and reused: the extractors are stateless, and the frontend + /// re-arms often enough that rebuilding the table per arm would be waste. + registry: Registry, +} + +impl Loop { + fn run(mut self) { + loop { + // Block outright when there is nothing to time out on: an idle + // window costs no wakeups at all. + let msg = match self.settle_at { + None => match self.rx.recv() { + Ok(msg) => Some(msg), + Err(_) => return, + }, + Some(deadline) => { + let wait = deadline.saturating_duration_since(Instant::now()); + match self.rx.recv_timeout(wait.min(TICK)) { + Ok(msg) => Some(msg), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + } + }; + match msg { + Some(Msg::Stop) => return, + Some(Msg::Clear) => self.reset(), + Some(Msg::Watch { + query, + targets, + config, + }) => self.rearm(&query, targets, config), + Some(Msg::Event(event)) => { + classify( + &event, + &self.targets, + &mut self.pending, + &mut self.orphan_to, + ); + if !self.pending.is_empty() && self.settle_at.is_none() { + self.settle_at = Some(Instant::now() + SETTLE); + } + } + None => {} + } + self.flush_settled(); + } + } + + fn reset(&mut self) { + self.watcher = None; + self.targets.clear(); + self.pending.clear(); + self.last_emit.clear(); + self.orphan_to.clear(); + self.settle_at = None; + self.query = None; + self.fuzzy = None; + self.config = None; + } + + /// Point the watcher at a new set of rows, dropping everything about the + /// old one, then check each one against the disk. Registration failures + /// are per-directory and silent beyond the log: this is a cosmetic + /// feature, and a modal about a missing highlight would be worse than the + /// missing highlight. + fn rearm(&mut self, query: &str, targets: Vec, config: Box) { + self.reset(); + if targets.is_empty() { + return; + } + self.query = split_for_cascade(query).ok(); + // The same construction the fuzzy pass makes for a scan, so a fuzzy + // row is re-cut against exactly what matched it. + self.fuzzy = self.query.as_ref().and_then(|q| { + if q.pattern.is_wildcard() { + return None; + } + let folded = q.term.to_ascii_lowercase(); + let k = edit_budget(folded.len(), config.search.fuzzy_max_edits)?; + Bitap::new(folded.as_bytes(), k) + }); + self.config = Some(config); + + let mut watcher = { + let tx = self.event_tx.clone(); + let sink = move |res: notify::Result| { + if let Ok(event) = res { + let _ = tx.send(Msg::Event(event)); + } + }; + match RecommendedWatcher::new(sink, NotifyConfig::default()) { + Ok(w) => w, + Err(e) => { + crate::log_warn!("live results: no watcher available: {}", e); + return; + } + } + }; + + let mut dirs: Vec = Vec::new(); + for target in targets.into_iter().take(MAX_TARGETS) { + // Derived from the row's own path, never canonicalized: notify + // builds each event path as `watched_dir.join(name)`, so leaving + // this spelled as the index spells it is what lets event paths be + // compared to `Target::path` as plain strings. + let Some(dir) = Path::new(&target.path).parent().map(Path::to_path_buf) else { + continue; + }; + if !dirs.contains(&dir) { + if dirs.len() >= MAX_DIRS { + continue; + } + if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) { + // Deliberately not the indexer's all-or-nothing: partial + // coverage of a display nicety is fine. Rows under this + // directory are dropped rather than swept — a row nothing + // can follow is better left alone than corrected once and + // then silently frozen. + crate::log_warn!("live results: not watching {}: {}", dir.display(), e); + continue; + } + dirs.push(dir); + } + self.targets.insert(target.path.clone(), target); + } + if self.targets.is_empty() { + return; + } + self.watcher = Some(watcher); + self.sweep(); + } + + /// Compare every target against the disk once and emit what disagrees. + /// + /// Nothing here waits for an event, which is the point: a row whose file + /// changed while it was scrolled out of view — or one whose index row was + /// simply out of date when the search returned it — is corrected the + /// moment it is watched. + fn sweep(&mut self) { + let mut updates: Vec = Vec::new(); + for target in self.targets.values() { + match std::fs::metadata(&target.path) { + Ok(meta) if meta.is_file() => { + let (size, mtime) = (meta.len(), mtime_of(&meta)); + if size == target.size && mtime == target.mtime { + continue; + } + updates.push(self.changed_update(&target.path, target.text, size, mtime)); + } + // Unreadable counts as gone: the row cannot be shown as + // current when we cannot see the file at all. + _ => updates.push(LiveUpdate::Gone { + path: target.path.clone(), + }), + } + } + let now = Instant::now(); + for update in updates { + // Recorded so an event arriving right behind the sweep — a save + // still in flight when the row came on screen — does not repeat + // the same answer a moment later. + self.last_emit.insert(update.path().to_string(), now); + self.note_emitted(&update); + self.send(update); + } + } + + /// Turn the settled window's decisions into updates. + fn flush_settled(&mut self) { + let Some(at) = self.settle_at else { return }; + if Instant::now() < at { + return; + } + self.settle_at = None; + + // Windows never reports the two halves of a rename as one event and + // gives no cookie to pair them by. When exactly one target went away + // and exactly one unclaimed destination appeared in the same window, + // they are the same file; anything more ambiguous resolves to the + // truthful "gone". + let orphans = std::mem::take(&mut self.orphan_to); + let gone: Vec = self + .pending + .iter() + .filter(|(_, op)| **op == Op::Gone) + .map(|(path, _)| path.clone()) + .collect(); + if gone.len() == 1 && orphans.len() == 1 { + self.pending + .insert(gone[0].clone(), Op::Renamed(orphans[0].clone())); + } + + let now = Instant::now(); + let ready: Vec<(String, Op)> = self + .pending + .iter() + .filter(|(path, _)| { + self.last_emit + .get(*path) + .is_none_or(|t| now.duration_since(*t) >= MIN_INTERVAL) + }) + .take(MAX_PER_TICK) + .map(|(path, op)| (path.clone(), op.clone())) + .collect(); + + for (path, op) in ready { + self.pending.remove(&path); + self.last_emit.insert(path.clone(), now); + self.apply(path, op); + } + if !self.pending.is_empty() { + self.settle_at = Some(now + SETTLE); + } + } + + fn apply(&mut self, path: String, op: Op) { + let update = match op { + Op::Gone => LiveUpdate::Gone { path }, + Op::Renamed(to) => { + let name = to + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + LiveUpdate::Renamed { + path, + to: to.to_string_lossy().into_owned(), + name, + } + } + Op::Changed => { + // Read from the file, not from the index: this has to land + // whatever the indexer is doing, or not doing. + match std::fs::metadata(&path) { + Ok(meta) if meta.is_file() => { + let text = self.targets.get(&path).and_then(|t| t.text); + self.changed_update(&path, text, meta.len(), mtime_of(&meta)) + } + _ => LiveUpdate::Gone { path }, + } + } + }; + self.note_emitted(&update); + self.send(update); + } + + /// What the file at `path` implies for the row showing it. + fn changed_update( + &self, + path: &str, + text: Option, + size: u64, + mtime: i64, + ) -> LiveUpdate { + LiveUpdate::Changed { + path: path.to_string(), + size, + mtime, + window: text.map_or(WindowUpdate::Unchanged, |tier| { + self.window_from_disk(path, size, tier) + }), + } + } + + /// Re-cut this row's Content Match window from the file on disk. + /// + /// Deliberately the indexer's own path — [`crate::mime::guess_mime_from_head`] + /// then [`crate::file_handling::decide_content`], which already applies + /// `content_extensions` and the `maximum_text_size` truncation — so the + /// text a window is cut from is the text the index would have stored, and + /// a refreshed row cannot disagree with a re-run search about anything but + /// timing. Then the tier's own matcher, for the same reason. + fn window_from_disk(&self, path: &str, size: u64, tier: ContentTier) -> WindowUpdate { + let (Some(config), Some(query)) = (self.config.as_deref(), self.query.as_ref()) else { + return WindowUpdate::Unchanged; + }; + // The indexer would not have stored text for a file this large, so + // neither does the row — reading it would stall this thread over a + // window nobody can see the whole of anyway. + if size > config.processing.maximum_text_file_size { + return WindowUpdate::Unchanged; + } + let file = Path::new(path); + let Some(head) = read_head(file, config.processing.hash_length) else { + return WindowUpdate::Unchanged; + }; + let mime = crate::mime::guess_mime_from_head(file, &head); + // Extractors run third-party parsers over whatever the file now + // holds. One that panics must not take this thread — and with it every + // live row for the rest of the session — down with it. + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + crate::file_handling::decide_content(path, mime.as_deref(), &self.registry, config) + })); + let outcome = match outcome { + Ok(outcome) => outcome, + Err(_) => { + crate::log_warn!("live results: extracting {} panicked", path); + return WindowUpdate::Unchanged; + } + }; + let Some(text) = crate::file_handling::outcome_body(&outcome) else { + return WindowUpdate::Unchanged; + }; + let folded = text.to_ascii_lowercase(); + let cut = match tier { + ContentTier::Exact => { + // A literal term always yields a window, marked or not, + // because the passes only ever call this for a body FTS + // already matched. Here the body may genuinely have stopped + // matching, and an unmarked window is how that reads. + crate::search::cascade::text_snippet(&query.pattern, text, &folded) + .filter(|snip| !snip.ranges.is_empty()) + } + ContentTier::Fuzzy => match &self.fuzzy { + Some(bitap) => { + crate::search::cascade::fuzzy_snippet(bitap, text, &folded).map(|(_, s)| s) + } + // The term does not fuzz, so a fuzzy row cannot be re-judged; + // leaving it is the honest reading. + None => return WindowUpdate::Unchanged, + }, + }; + match cut { + Some(snip) => WindowUpdate::Cut(snip), + None => WindowUpdate::NoMatch, + } + } + + /// Keep the target's baseline in step with what the frontend was just + /// told, so a later sweep over the same arm does not repeat itself. + fn note_emitted(&mut self, update: &LiveUpdate) { + let LiveUpdate::Changed { + path, size, mtime, .. + } = update + else { + return; + }; + if let Some(target) = self.targets.get_mut(path) { + target.size = *size; + target.mtime = *mtime; + } + } + + fn send(&self, update: LiveUpdate) { + if self.update_tx.send(update).is_ok() { + (self.notify)(); + } + } +} + +/// The first `limit` bytes of a file, for MIME sniffing. A short read is the +/// whole file and is not an error; an unreadable file simply has no MIME. +fn read_head(path: &Path, limit: usize) -> Option> { + let file = std::fs::File::open(path).ok()?; + let mut head = Vec::new(); + file.take(limit as u64).read_to_end(&mut head).ok()?; + Some(head) +} + +fn mtime_of(meta: &std::fs::Metadata) -> i64 { + meta.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Fold one notify event into the pending decisions for this window. +/// +/// Pure and filesystem-free, which is what makes the platform differences +/// testable: every shape below is a real emission from `notify` 6.1 on one +/// platform or the other, and the coalescing window is what reconciles them. +fn classify( + event: &NotifyEvent, + targets: &HashMap, + pending: &mut HashMap, + orphan_to: &mut Vec, +) { + use notify::event::{ModifyKind, RenameMode}; + + let key = |p: &PathBuf| p.to_string_lossy().into_owned(); + let is_target = |p: &PathBuf| targets.contains_key(&key(p)); + + match event.kind { + EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => { + // Linux pairs the halves and emits this *after* the From/To pair, + // so it lands in the same window and overwrites the provisional + // Gone recorded below. + let (Some(from), Some(to)) = (event.paths.first(), event.paths.get(1)) else { + return; + }; + if is_target(from) { + pending.insert(key(from), Op::Renamed(to.clone())); + } else if is_target(to) { + // The atomic-save shape: a temporary file renamed over a row + // we are watching. The row did not move; its contents changed. + pending.insert(key(to), Op::Changed); + } + } + EventKind::Modify(ModifyKind::Name(RenameMode::To)) => { + for path in &event.paths { + if is_target(path) { + pending.insert(key(path), Op::Changed); + } else { + orphan_to.push(path.clone()); + } + } + } + EventKind::Modify(ModifyKind::Name(RenameMode::From)) => { + for path in &event.paths { + if is_target(path) { + // Provisional; a Both in this same window upgrades it. + pending.entry(key(path)).or_insert(Op::Gone); + } + } + } + EventKind::Remove(_) => { + for path in &event.paths { + if is_target(path) { + pending.insert(key(path), Op::Gone); + } + } + } + EventKind::Create(_) | EventKind::Modify(_) => { + for path in &event.paths { + if is_target(path) { + // A Create at a watched path un-deletes the row. + pending.insert(key(path), Op::Changed); + } + } + } + // Access events, and anything for a path we are not showing. Live + // results never *add* rows: we have no way to know an unrelated new + // file matches the query, and guessing would be a second, unranked + // search wearing the first one's clothes. + _ => {} + } +} + +#[cfg(test)] +#[path = "live_tests.rs"] +mod tests; diff --git a/crates/quicksearch-core/src/live_tests.rs b/crates/quicksearch-core/src/live_tests.rs new file mode 100644 index 0000000..b24293e --- /dev/null +++ b/crates/quicksearch-core/src/live_tests.rs @@ -0,0 +1,655 @@ +//! Tests for live result watching. +//! +//! The [`classify`] tests are pure: they feed the exact event shapes `notify` +//! 6.1 emits on each platform and check what one settled window decides. They +//! are the ones that pin the design — in particular that a watch on a +//! *directory* sees an atomic save, which a watch on the file would not. +//! +//! The end-to-end tests drive a real filesystem through a real watcher. + +use super::*; + +use notify::event::{CreateKind, ModifyKind, RemoveKind, RenameMode}; + +fn target(path: &str) -> (String, Target) { + ( + path.to_string(), + Target { + path: path.to_string(), + text: Some(ContentTier::Exact), + size: 0, + mtime: 0, + }, + ) +} + +fn targets(paths: &[&str]) -> HashMap { + paths.iter().map(|p| target(p)).collect() +} + +fn event(kind: EventKind, paths: &[&str]) -> NotifyEvent { + NotifyEvent { + kind, + paths: paths.iter().map(PathBuf::from).collect(), + attrs: Default::default(), + } +} + +/// Feed a window of events and report what it decided, after the same +/// orphan-pairing `flush_settled` applies. +fn window(targets: &HashMap, events: Vec) -> HashMap { + let mut pending = HashMap::new(); + let mut orphan_to = Vec::new(); + for event in &events { + classify(event, targets, &mut pending, &mut orphan_to); + } + let gone: Vec = pending + .iter() + .filter(|(_, op)| **op == Op::Gone) + .map(|(p, _)| p.clone()) + .collect(); + if gone.len() == 1 && orphan_to.len() == 1 { + pending.insert(gone[0].clone(), Op::Renamed(orphan_to[0].clone())); + } + pending +} + +/// An editor saving a file writes a temporary and renames it over the target. +/// The row did not move — its contents changed — and a watch on the file +/// itself would have seen none of this, because the inode it was attached to +/// is the one that got orphaned. This test is why the watches are on +/// directories. +#[test] +fn an_atomic_save_reads_as_a_content_change() { + let t = targets(&["/docs/report.txt"]); + let decided = window( + &t, + vec![ + event( + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + &["/docs/.report.txt.swp"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + &["/docs/report.txt"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::Both)), + &["/docs/.report.txt.swp", "/docs/report.txt"], + ), + ], + ); + assert_eq!(decided.get("/docs/report.txt"), Some(&Op::Changed)); + assert_eq!(decided.len(), 1, "nothing else was decided: {decided:?}"); +} + +/// Linux emits From, To and then Both for one in-directory rename. The +/// provisional Gone recorded for the From half must not escape the window. +#[test] +fn a_linux_rename_pairs_without_leaking_a_gone() { + let t = targets(&["/docs/old.txt"]); + let decided = window( + &t, + vec![ + event( + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + &["/docs/old.txt"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + &["/docs/new.txt"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::Both)), + &["/docs/old.txt", "/docs/new.txt"], + ), + ], + ); + assert_eq!( + decided.get("/docs/old.txt"), + Some(&Op::Renamed(PathBuf::from("/docs/new.txt"))) + ); + assert!( + !decided.values().any(|op| *op == Op::Gone), + "a provisional Gone escaped: {decided:?}" + ); +} + +/// Windows never emits `Both` and gives no cookie to pair the halves by, so +/// one unclaimed destination beside one departed target is paired by position. +#[test] +fn a_windows_rename_pairs_by_elimination() { + let t = targets(&["/docs/old.txt"]); + let decided = window( + &t, + vec![ + event( + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + &["/docs/old.txt"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + &["/docs/new.txt"], + ), + ], + ); + assert_eq!( + decided.get("/docs/old.txt"), + Some(&Op::Renamed(PathBuf::from("/docs/new.txt"))) + ); +} + +/// Two departures and two arrivals in one window cannot be paired without +/// guessing. Guessing wrong renames a row to someone else's file, so the +/// ambiguous case resolves to the truthful answer instead. +#[test] +fn an_ambiguous_windows_window_reports_gone_rather_than_guessing() { + let t = targets(&["/docs/a.txt", "/docs/b.txt"]); + let decided = window( + &t, + vec![ + event( + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + &["/docs/a.txt"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::From)), + &["/docs/b.txt"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + &["/docs/x.txt"], + ), + event( + EventKind::Modify(ModifyKind::Name(RenameMode::To)), + &["/docs/y.txt"], + ), + ], + ); + assert_eq!(decided.get("/docs/a.txt"), Some(&Op::Gone)); + assert_eq!(decided.get("/docs/b.txt"), Some(&Op::Gone)); +} + +/// A watched directory is full of files that are not on screen. None of them +/// may produce an update — live results never add rows. +#[test] +fn events_for_paths_that_are_not_shown_decide_nothing() { + let t = targets(&["/docs/shown.txt"]); + let decided = window( + &t, + vec![ + event(EventKind::Create(CreateKind::File), &["/docs/other.txt"]), + event(EventKind::Modify(ModifyKind::Any), &["/docs/another.txt"]), + event(EventKind::Remove(RemoveKind::File), &["/docs/third.txt"]), + ], + ); + assert!(decided.is_empty(), "{decided:?}"); +} + +/// A delete marks the row; a file recreated at the same path un-marks it, +/// which is what makes the mark reversible without re-registering anything. +#[test] +fn a_delete_marks_the_row_and_a_recreate_clears_it() { + let t = targets(&["/docs/report.txt"]); + let gone = window( + &t, + vec![event( + EventKind::Remove(RemoveKind::File), + &["/docs/report.txt"], + )], + ); + assert_eq!(gone.get("/docs/report.txt"), Some(&Op::Gone)); + + let back = window( + &t, + vec![event( + EventKind::Create(CreateKind::File), + &["/docs/report.txt"], + )], + ); + assert_eq!(back.get("/docs/report.txt"), Some(&Op::Changed)); +} + +/// Several writes to one file inside a window are one decision, not several. +#[test] +fn repeated_writes_in_one_window_coalesce() { + let t = targets(&["/docs/log.txt"]); + let decided = window( + &t, + vec![ + event(EventKind::Modify(ModifyKind::Any), &["/docs/log.txt"]), + event(EventKind::Modify(ModifyKind::Any), &["/docs/log.txt"]), + event(EventKind::Modify(ModifyKind::Any), &["/docs/log.txt"]), + ], + ); + assert_eq!(decided.len(), 1); + assert_eq!(decided.get("/docs/log.txt"), Some(&Op::Changed)); +} + +// --- end to end ---------------------------------------------------------- + +use crate::testutil::scratch_dir; + +/// Collect updates until `want` of them arrive or the timeout expires. +fn collect(rx: &mpsc::Receiver, want: usize, timeout: Duration) -> Vec { + let deadline = Instant::now() + timeout; + let mut out = Vec::new(); + while out.len() < want { + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + break; + } + match rx.recv_timeout(left) { + Ok(update) => out.push(update), + Err(_) => break, + } + } + out +} + +/// A target describing `path` exactly as it is on disk right now, so the +/// arm-time sweep finds nothing to report and the test sees only what it +/// provokes afterwards. +fn current_target(path: &str, text: Option) -> Target { + let meta = std::fs::metadata(path).expect("target file exists"); + Target { + path: path.to_string(), + text, + size: meta.len(), + mtime: mtime_of(&meta), + } +} + +fn watch_one(dir: &Path, name: &str) -> (LiveWatcher, mpsc::Receiver, String) { + watch_one_matching(dir, name, "hello world", None) +} + +/// Write `body` at `dir/name` and watch it for the query `hello`. +fn watch_one_matching( + dir: &Path, + name: &str, + body: &str, + text: Option, +) -> (LiveWatcher, mpsc::Receiver, String) { + let path = dir.join(name).to_string_lossy().into_owned(); + std::fs::write(&path, body).unwrap(); + let (watcher, rx) = LiveWatcher::start(Arc::new(|| {})); + watcher.watch( + "hello", + vec![current_target(&path, text)], + &Config::default(), + ); + // Registration happens on the watcher thread. + std::thread::sleep(Duration::from_millis(300)); + (watcher, rx, path) +} + +/// Stop the watcher, then take the tree with it. +/// +/// The stop is not optional: the watcher holds an inotify registration on the +/// directory, and pulling the directory out from under a live one is a race +/// worth not having. Unlike most of this crate's tests these do clean up on +/// the way out — the trees are a file or two apiece, generated identically +/// every run, so they hold no evidence the assertion message does not already +/// carry. +fn stop_and_clean(mut watcher: LiveWatcher, dir: &Path) { + watcher.stop(); + std::fs::remove_dir_all(dir).ok(); +} + +#[test] +fn e2e_a_rename_surfaces_with_the_new_name() { + let dir = scratch_dir("live-rename"); + let (watcher, rx, path) = watch_one(&dir, "before.txt"); + let renamed = dir.join("after.txt"); + std::fs::rename(&path, &renamed).unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + let Some(LiveUpdate::Renamed { + path: from, name, .. + }) = updates.first() + else { + panic!("expected a rename, got {updates:?}"); + }; + assert_eq!(from, &path); + assert_eq!(name, "after.txt"); +} + +#[test] +fn e2e_a_delete_surfaces_as_gone() { + let dir = scratch_dir("live-delete"); + let (watcher, rx, path) = watch_one(&dir, "doomed.txt"); + std::fs::remove_file(&path).unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + assert!( + updates + .iter() + .any(|u| matches!(u, LiveUpdate::Gone { path: p } if *p == path)), + "expected a Gone for {path}, got {updates:?}" + ); +} + +/// The design test, on a real filesystem: write a temporary and rename it over +/// the target, the way an editor saves. It must read as a change to the row, +/// not as the row disappearing — which is exactly what a watch on the file +/// itself would have reported. +#[test] +fn e2e_an_atomic_save_does_not_read_as_a_delete() { + let dir = scratch_dir("live-atomic"); + let (watcher, rx, path) = watch_one(&dir, "report.txt"); + let tmp = dir.join("report.txt.tmp"); + std::fs::write(&tmp, "hello, replaced").unwrap(); + std::fs::rename(&tmp, &path).unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + assert!( + !updates.iter().any(|u| matches!(u, LiveUpdate::Gone { .. })), + "an atomic save was reported as a deletion: {updates:?}" + ); +} + +/// A row whose directory does not exist must not panic, and must not stop the +/// watcher from covering the rows whose directories do. +#[test] +fn a_missing_directory_does_not_stop_the_others() { + let dir = scratch_dir("live-missing-dir"); + let good = dir.join("present.txt"); + std::fs::write(&good, "hello world").unwrap(); + let (watcher, rx) = LiveWatcher::start(Arc::new(|| {})); + watcher.watch( + "hello", + vec![ + Target { + path: dir + .join("nowhere") + .join("ghost.txt") + .to_string_lossy() + .into_owned(), + text: None, + size: 0, + mtime: 0, + }, + current_target(&good.to_string_lossy(), None), + ], + &Config::default(), + ); + std::thread::sleep(Duration::from_millis(300)); + std::fs::remove_file(&good).unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + assert!( + updates.iter().any(|u| matches!(u, LiveUpdate::Gone { .. })), + "the reachable row stopped working: {updates:?}" + ); +} + +// --- content, read from the file rather than from the index --------------- +// +// None of these open a database. That is the assertion they all share: a row +// on screen tracks the disk with no indexer involved, which is what the +// feature is for. + +/// Pull the one `Changed` out of a batch, failing loudly on anything else. +fn one_change(updates: &[LiveUpdate], path: &str) -> (u64, i64, WindowUpdate) { + let found = updates.iter().find_map(|u| match u { + LiveUpdate::Changed { + path: p, + size, + mtime, + window, + } if p == path => Some((*size, *mtime, window.clone())), + _ => None, + }); + found.unwrap_or_else(|| panic!("expected a Changed for {path}, got {updates:?}")) +} + +/// The test the old index-backed design could not have: edit a watched file +/// with no index anywhere, and the row's size, modified time and Content Match +/// window all follow it. +#[test] +fn e2e_a_content_change_re_cuts_the_snippet_with_no_index() { + let dir = scratch_dir("live-content"); + let (watcher, rx, path) = + watch_one_matching(&dir, "notes.txt", "hello world", Some(ContentTier::Exact)); + std::fs::write(&path, "hello there, a much longer world").unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + let (size, mtime, window) = one_change(&updates, &path); + assert_eq!(size, "hello there, a much longer world".len() as u64); + assert!(mtime > 0, "no modified time was read"); + let WindowUpdate::Cut(snippet) = window else { + panic!("the body still matches, so it should carry a window: {window:?}"); + }; + assert!( + snippet.window.contains("much longer"), + "the window is stale: {:?}", + snippet.window + ); + assert!(!snippet.ranges.is_empty(), "the match was not marked"); +} + +/// Edited until it no longer matches, the row keeps its place and its metadata +/// but loses its window — the Content Match cell falls back to its dash rather +/// than showing text that is no longer a hit. +#[test] +fn e2e_an_edit_that_removes_the_match_clears_the_window() { + let dir = scratch_dir("live-unmatch"); + let (watcher, rx, path) = + watch_one_matching(&dir, "notes.txt", "hello world", Some(ContentTier::Exact)); + std::fs::write(&path, "nothing of interest here").unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + let (size, _, window) = one_change(&updates, &path); + assert_eq!(size, "nothing of interest here".len() as u64); + assert_eq!( + window, + WindowUpdate::NoMatch, + "a window survived the match it no longer has" + ); +} + +/// A fuzzy content hit is re-cut with the fuzzy matcher, not the literal one. +/// The literal is absent from the body by construction — that is what made +/// it a fuzzy hit — so re-cutting it as an exact row would read as "no longer +/// matches" and blank a cell that still has a hit in it. +#[test] +fn e2e_a_fuzzy_content_hit_is_re_cut_with_the_fuzzy_matcher() { + let dir = scratch_dir("live-fuzzy"); + let path = dir.join("notes.txt").to_string_lossy().into_owned(); + // "quartz" within one edit of "quarts": a stage-8 hit for that query. + std::fs::write(&path, "sphinx of black quarts judge my vow").unwrap(); + let (watcher, rx) = LiveWatcher::start(Arc::new(|| {})); + watcher.watch( + "quartz", + vec![current_target(&path, Some(ContentTier::Fuzzy))], + &Config::default(), + ); + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(&path, "the sphinx of black quarts judged my vow again").unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + let (_, _, window) = one_change(&updates, &path); + let WindowUpdate::Cut(snippet) = window else { + panic!("a fuzzy hit was re-judged as exact: {window:?}"); + }; + assert!( + snippet.window.contains("quarts judged"), + "the window was not re-cut from the new body: {:?}", + snippet.window + ); + assert!(!snippet.ranges.is_empty(), "the fuzzy match was not marked"); +} + +/// A file no extractor claims still reports what `metadata` knows. Size and +/// Modified are not the text columns' to withhold. +#[test] +fn e2e_a_file_with_no_extractable_text_still_reports_its_metadata() { + let dir = scratch_dir("live-binary"); + let path = dir.join("blob.bin").to_string_lossy().into_owned(); + std::fs::write(&path, [0x00u8, 0x01, 0x02, 0xFF]).unwrap(); + let (watcher, rx) = LiveWatcher::start(Arc::new(|| {})); + watcher.watch( + "hello", + vec![current_target(&path, Some(ContentTier::Exact))], + &Config::default(), + ); + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(&path, [0x00u8, 0x01, 0x02, 0xFF, 0xFE, 0xFD]).unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + let (size, _, window) = one_change(&updates, &path); + assert_eq!(size, 6); + // `Unchanged`, not `NoMatch`: nothing readable came back, so there is no + // evidence the row's window is wrong — only that it is unverifiable. + assert_eq!(window, WindowUpdate::Unchanged); +} + +/// Past `maximum_text_file_size` the indexer stores no text, so neither does +/// the row — but it still says how big the file got. +#[test] +fn e2e_a_file_over_the_text_size_limit_reports_size_but_no_window() { + let dir = scratch_dir("live-oversize"); + let (watcher, rx) = LiveWatcher::start(Arc::new(|| {})); + let path = dir.join("huge.txt").to_string_lossy().into_owned(); + std::fs::write(&path, "hello world").unwrap(); + let mut config = Config::default(); + config.processing.maximum_text_file_size = 16; + watcher.watch( + "hello", + vec![current_target(&path, Some(ContentTier::Exact))], + &config, + ); + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(&path, "hello world, and rather more of it besides").unwrap(); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + let (size, _, window) = one_change(&updates, &path); + assert_eq!( + size, + "hello world, and rather more of it besides".len() as u64 + ); + // Not read, so not disproved: the window the search found stands. + assert_eq!(window, WindowUpdate::Unchanged); +} + +// --- the arm-time sweep --------------------------------------------------- + +/// A row armed with what the *index* said about a file that has since moved on +/// is corrected the moment it is watched. This is the check of the index +/// against the disk, and it is also the only thing that reports anything at +/// all on a filesystem the platform sends no events for. +#[test] +fn arming_corrects_a_row_that_went_stale_while_it_was_not_watched() { + let dir = scratch_dir("live-sweep-stale"); + let path = dir.join("drifted.txt").to_string_lossy().into_owned(); + std::fs::write(&path, "hello, a body the index never saw").unwrap(); + let (watcher, rx) = LiveWatcher::start(Arc::new(|| {})); + // What a stale index row would have claimed. + watcher.watch( + "hello", + vec![Target { + path: path.clone(), + text: Some(ContentTier::Exact), + size: 5, + mtime: 1, + }], + &Config::default(), + ); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + let (size, mtime, window) = one_change(&updates, &path); + assert_eq!(size, "hello, a body the index never saw".len() as u64); + assert!(mtime > 1, "the stale modified time survived"); + assert!( + matches!(window, WindowUpdate::Cut(_)), + "the window was not re-cut: {window:?}" + ); +} + +/// Same sweep, for a row whose file is simply not there any more. +#[test] +fn arming_reports_a_row_whose_file_vanished_while_it_was_not_watched() { + let dir = scratch_dir("live-sweep-gone"); + // A sibling keeps the directory watchable, so the ghost is dropped for + // being missing rather than for its directory being missing. + let sibling = dir.join("present.txt"); + std::fs::write(&sibling, "hello world").unwrap(); + let ghost = dir.join("vanished.txt").to_string_lossy().into_owned(); + let (watcher, rx) = LiveWatcher::start(Arc::new(|| {})); + watcher.watch( + "hello", + vec![Target { + path: ghost.clone(), + text: None, + size: 11, + mtime: 1, + }], + &Config::default(), + ); + + let updates = collect(&rx, 1, Duration::from_secs(5)); + stop_and_clean(watcher, &dir); + + assert!( + updates + .iter() + .any(|u| matches!(u, LiveUpdate::Gone { path } if *path == ghost)), + "expected a Gone for {ghost}, got {updates:?}" + ); +} + +/// The other half of the sweep, and the one that keeps it quiet: a row that +/// already agrees with the disk is not touched. Without this the watcher would +/// repaint every visible row on every scroll. +#[test] +fn arming_says_nothing_about_a_row_that_already_agrees_with_the_disk() { + let dir = scratch_dir("live-sweep-quiet"); + let (watcher, rx, _path) = + watch_one_matching(&dir, "steady.txt", "hello world", Some(ContentTier::Exact)); + + // `watch_one_matching` already waited out registration; anything the + // sweep decided has been sent by now. + let updates = collect(&rx, 1, Duration::from_millis(500)); + stop_and_clean(watcher, &dir); + + assert!( + updates.is_empty(), + "the sweep invented an update: {updates:?}" + ); +} + +/// Re-arming replaces the set wholesale; an event for a path that is no longer +/// shown decides nothing. +#[test] +fn re_arming_drops_the_previous_targets() { + let t = targets(&["/docs/new.txt"]); + let decided = window( + &t, + vec![event( + EventKind::Modify(ModifyKind::Any), + &["/docs/old.txt"], + )], + ); + assert!(decided.is_empty(), "{decided:?}"); +} diff --git a/crates/quicksearch-core/src/platform.rs b/crates/quicksearch-core/src/platform.rs index debe0b6..e515afc 100644 --- a/crates/quicksearch-core/src/platform.rs +++ b/crates/quicksearch-core/src/platform.rs @@ -490,26 +490,6 @@ pub fn release_free_heap() { // spans to the kernel on free. } -/// Live and free-but-retained heap bytes, as `(in_use, free)`: `free` is -/// memory already given back to the allocator that glibc still charges the -/// process for. `None` where the platform has no way to answer. -pub fn heap_stats() -> Option<(u64, u64)> { - #[cfg(all(target_os = "linux", target_env = "gnu"))] - { - // `mallinfo2`, not `mallinfo`: the older struct is `int`-typed and - // silently wraps past 2 GiB, which is exactly the size where the - // answer starts to matter. - // - // SAFETY: no arguments, returns a plain struct by value. - let info = unsafe { libc::mallinfo2() }; - Some((info.uordblks as u64, info.fordblks as u64)) - } - #[cfg(not(all(target_os = "linux", target_env = "gnu")))] - { - None - } -} - /// How long to keep retrying a delete that fails because something else holds /// the file open. #[cfg(windows)] diff --git a/crates/quicksearch-core/src/scope.rs b/crates/quicksearch-core/src/scope.rs index 98c13a3..edde34c 100644 --- a/crates/quicksearch-core/src/scope.rs +++ b/crates/quicksearch-core/src/scope.rs @@ -89,15 +89,6 @@ impl Scope { }) } - /// The configured root `path` lives under, if any. Containment is - /// component-wise, per [`crate::file_handling::UnreadableDirs::covers`]. - pub fn owning_root(&self, path: &Path) -> Option<&Path> { - self.roots - .iter() - .map(|r| r.path.as_path()) - .find(|root| path.starts_with(root) && path != *root) - } - /// Whether the walker would still emit `path` while walking `root`. /// /// Mirrors `read_directory`'s three `continue`s. Full-path ignore diff --git a/crates/quicksearch-core/src/scope_tests.rs b/crates/quicksearch-core/src/scope_tests.rs index 5117d2b..889b287 100644 --- a/crates/quicksearch-core/src/scope_tests.rs +++ b/crates/quicksearch-core/src/scope_tests.rs @@ -31,7 +31,6 @@ fn walked(config: &Config, db: &Path) -> HashSet { config.clone(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 2, ) .filter_map(|e| match e { @@ -155,28 +154,6 @@ fn a_root_is_never_filtered_but_its_children_still_are() { std::fs::remove_dir_all(&base).ok(); } -/// Root ownership compares whole components, so a sibling whose name -/// merely starts with a root's is not inside it — a prune that got this -/// wrong would delete a neighbouring folder's entire index. -#[test] -fn owning_root_does_not_match_name_prefixes() { - let base = tmp_tree("prefix"); - let root = base.join("data"); - let sibling = base.join("database"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::create_dir_all(&sibling).unwrap(); - - let mut config = Config::default(); - config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()]; - let scope = Scope::from_config(&config).unwrap(); - - assert_eq!(scope.owning_root(&root.join("f.txt")), Some(root.as_path())); - assert_eq!(scope.owning_root(&sibling.join("f.txt")), None); - // The root itself is a directory, never a row, and owns nothing. - assert_eq!(scope.owning_root(&root), None); - std::fs::remove_dir_all(&base).ok(); -} - /// The counters the status display reads: a scan that reports nothing is /// indistinguishable from a hang. #[test] @@ -323,21 +300,3 @@ fn cancelling_stops_the_scan_without_finishing_it() { std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); } - -/// A path under no configured root has no rules to apply — a followed -/// symlink's target is the real case; `owning_root` returning `None` is -/// what keeps it alive. -#[test] -fn a_path_outside_every_root_has_no_owner() { - let base = tmp_tree("outside"); - let root = base.join("indexed"); - std::fs::create_dir_all(&root).unwrap(); - - let mut config = Config::default(); - config.paths.indexing_paths = vec![root.to_string_lossy().into_owned()]; - config.indexing.ignore_patterns = vec!["*".into()]; - let scope = Scope::from_config(&config).unwrap(); - - assert_eq!(scope.owning_root(Path::new("/elsewhere/target.txt")), None); - std::fs::remove_dir_all(&base).ok(); -} diff --git a/crates/quicksearch-core/src/search/cascade.rs b/crates/quicksearch-core/src/search/cascade.rs index 3181ee9..9b08bef 100644 --- a/crates/quicksearch-core/src/search/cascade.rs +++ b/crates/quicksearch-core/src/search/cascade.rs @@ -80,6 +80,62 @@ const CANCEL_CHECK_ROWS: usize = 256; /// around the match, and the mouseover shows the rest as extended context. const SNIPPET_WINDOW_CHARS: usize = 600; +/// The Content Match snippet for one document body, cut exactly as the +/// full-text passes cut it. +/// +/// `folded` must be `text` ASCII-lowercased. That fold is byte-length +/// preserving, which is the whole reason offsets found in it can slice `text`; +/// the passes hold one reusable fold buffer per scan and hand it in here +/// rather than paying for a second copy. +/// +/// Shared so that [`crate::live`], re-cutting a snippet for a file that +/// changed under a result already on screen, produces the same window the +/// search itself would — otherwise a row would visibly re-frame its own match +/// the moment the file was touched. +pub fn text_snippet( + pattern: &crate::query::pattern::TermPattern, + text: &str, + folded: &str, +) -> Option { + let opts = snippet::Options { + approx_chars: SNIPPET_WINDOW_CHARS, + }; + match pattern.literal() { + // Literal terms keep the richer multi-occurrence extract; a wildcard + // match marks its own first range. + Some(term) => Some(snippet::extract_folded(text, folded, &[term], &opts)), + None => pattern.find_first_folded(folded).map(|r| { + // A greedy pattern can match megabytes; clamp before the window. + let r = clamp_match_range(text, r, SNIPPET_WINDOW_CHARS); + snippet::window_around(text, (r.start, r.end), &opts) + }), + } +} + +/// The fuzzy full-text match in one document body: how many times the term +/// occurs within the edit budget, and the Content Match window cut around +/// the first occurrence at the cascade's own width. `None` when it does not +/// occur at all. +/// +/// Shared with [`crate::live`] for the same reason as [`text_snippet`]: a +/// fuzzy row whose file changes has to be re-cut the way it was cut, and +/// bitap's range is what it was cut around. `bitap` is built once by the +/// caller — per scan in the pass, per arm in the live watcher — since +/// building it is the cost, and `folded` must be `text` ASCII-lowercased. +pub fn fuzzy_snippet( + bitap: &crate::search::fuzzy::Bitap, + text: &str, + folded: &str, +) -> Option<(usize, snippet::Snippet)> { + let opts = snippet::Options { + approx_chars: SNIPPET_WINDOW_CHARS, + }; + // `first` is `Some` exactly when `count` is non-zero: it *is* the first + // of them. + let (count, first) = bitap.count_and_first(folded.as_bytes()); + first.map(|range| (count, snippet::window_around(text, range, &opts))) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Outcome { pub total: usize, diff --git a/crates/quicksearch-core/src/search/cascade/passes.rs b/crates/quicksearch-core/src/search/cascade/passes.rs index 2ab589b..0ba6d97 100644 --- a/crates/quicksearch-core/src/search/cascade/passes.rs +++ b/crates/quicksearch-core/src/search/cascade/passes.rs @@ -10,82 +10,6 @@ enum RowHit { Defer(SearchHit), } -/// Reusable decode buffer and decompression context for the passes that read -/// document text. -/// -/// `zstd::decode_all` builds and tears down a `ZSTD_DCtx` *and* allocates a -/// fresh output `Vec` on every call, and it is called once per candidate row. -/// One context and one buffer, reused across a whole scan, make that a -/// per-scan cost instead of a per-row one. -struct DocDecoder { - dctx: zstd::bulk::Decompressor<'static>, - buf: Vec, -} - -/// Where [`DocDecoder::decode`]'s buffer starts before it has seen a document. -/// Most extracted text is well under this, so the doubling below rarely runs. -const INITIAL_DOC_CAPACITY: usize = 64 * 1024; - -/// Where the doubling stops. Stored text is capped at -/// `processing.maximum_text_size` (256 KiB by default), so this is far above -/// any legitimate document even if that setting is raised — past it, a failure -/// is a corrupt frame rather than a buffer that is too small. -const MAX_DOC_CAPACITY: usize = 64 * 1024 * 1024; - -impl DocDecoder { - fn new() -> Result { - Ok(DocDecoder { - dctx: zstd::bulk::Decompressor::new().map_err(|e| e.to_string())?, - buf: Vec::new(), - }) - } - - /// Decompress `blob` and borrow the result as text. - /// - /// Returns `None` for a corrupt frame or non-UTF-8 content. Nothing is - /// copied: the indexer stores UTF-8, so the bytes are borrowed in place - /// rather than run through `String::from_utf8_lossy(..).into_owned()`, - /// which duplicated the whole document even when it was already valid. - fn decode(&mut self, blob: &[u8]) -> Option<&str> { - self.buf.clear(); - // `decompress_to_buffer` writes into spare capacity and fails rather - // than growing, so the room has to be there first. - // - // The frame header would say how much is needed, but the indexer - // writes with `zstd::encode_all`, which is *stream*-based and so - // records no content size — `get_frame_content_size` says `None` for - // every row this ever sees. Falling back to `zstd::decode_all` there - // looked harmless and was not: it builds a streaming decoder per call, - // which measured as one ~2.4 MiB allocation per document and 27 of the - // 30 GiB a fuzzy search moved through the allocator. - // - // So grow this buffer instead and keep reusing it. It settles at the - // largest document in the scan within the first few rows, after which - // decoding a row allocates nothing at all. - if let Ok(Some(size)) = zstd::zstd_safe::get_frame_content_size(blob) { - self.buf.reserve(usize::try_from(size).ok()?); - } - loop { - if self.buf.capacity() == 0 { - self.buf.reserve(INITIAL_DOC_CAPACITY); - } - match self.dctx.decompress_to_buffer(blob, &mut self.buf) { - Ok(_) => break, - // Too small, or corrupt — the bulk API cannot tell us which. - // Growing is only worth trying while the buffer is still - // smaller than any document could legitimately be. - Err(_) if self.buf.capacity() < MAX_DOC_CAPACITY => { - let bigger = self.buf.capacity().saturating_mul(2); - self.buf.clear(); - self.buf.reserve(bigger); - } - Err(_) => return None, - } - } - std::str::from_utf8(&self.buf).ok() - } -} - /// Fold `text` into `dst` in place, reusing its allocation. /// /// The ASCII fold is byte-length preserving, which is what lets the cascade @@ -242,16 +166,10 @@ impl<'a> Cx<'a> { let is_path_tier = rank >= 9.0; // The "snippet" of a name or path hit is that field itself // with the matched span marked. - let snip = snippet::Snippet { - ranges: vec![match_range], - window: if is_path_tier { - path.to_string() - } else { - name.clone() - }, - truncated_start: false, - truncated_end: false, - }; + let snip = snippet::whole_field( + if is_path_tier { path } else { name.as_str() }, + match_range, + ); let (size, mtime) = size_and_mtime(row)?; let hit = SearchHit { file_id, @@ -327,12 +245,9 @@ impl<'a> Cx<'a> { self.params_with_filters(Vec::new()), ), }; - let snippet_opts = snippet::Options { - approx_chars: SNIPPET_WINDOW_CHARS, - }; // One decoder and one fold buffer for the whole scan; both are reused // per row rather than reallocated. - let mut doc = DocDecoder::new()?; + let mut doc = crate::db::repo::DocDecoder::new()?; let mut lower = String::new(); // Decompression dominates: check cancellation every row. self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path| { @@ -369,20 +284,7 @@ impl<'a> Cx<'a> { if !folded { fold_into(&mut lower, text); } - // Literal terms keep the richer multi-occurrence - // extract; a wildcard match marks its own first range. - let snip = match pattern.literal() { - Some(term) => Some(snippet::extract_folded( - text, - &lower, - &[term], - &snippet_opts, - )), - None => pattern.find_first_folded(&lower).map(|r| { - let r = clamp_match_range(text, r, SNIPPET_WINDOW_CHARS); - snippet::window_around(text, (r.start, r.end), &snippet_opts) - }), - }; + let snip = super::text_snippet(pattern, text, &lower); (stage as f64 + count_frac(count), stage as u8, snip) } // No stored text: can't case-verify or count. On the @@ -465,15 +367,14 @@ impl<'a> Cx<'a> { if !cx.regex_accepts(file_id, path, None)? { return Ok(RowHit::Skip); } - // Mark the approximate matched span in the matched field; - // window_around clamps and aligns. - let snip = Some(snippet::window_around( - field, - range, - &snippet::Options { - approx_chars: field.len().saturating_mul(2).max(8), - }, - )); + // The matched field itself with the fuzzy span marked — the + // same shape pass A emits, and what `SearchHit::snippet` + // documents for the name and path tiers. Windowing it here + // used to hand back a *suffix* whenever the match sat past + // two thirds of the way through, which broke that contract + // and left a frontend unable to line the ranges up against + // the field it paints. + let snip = Some(snippet::whole_field(field, range)); let is_path_tier = rank >= 11.0; let (size, mtime) = size_and_mtime(row)?; let hit = SearchHit { @@ -483,7 +384,14 @@ impl<'a> Cx<'a> { size, mtime, rank, - stage: rank as u8, + // Stamped, not truncated from `rank`: this is the one pass + // whose ranks carry a fraction large enough to reach the + // next integer. `edit_budget` is only warned about above + // 3, so a distance of 10 makes rank 8.0 — and truncating + // that would file a *filename* hit under stage 8, the + // fuzzy full-text tier, telling every frontend to render + // it as a content match. + stage: if is_path_tier { 11 } else { 7 }, snippet: snip, }; Ok(if is_path_tier { @@ -518,11 +426,8 @@ impl<'a> Cx<'a> { HIT_COLUMNS, self.query.filter_sql ); let params = self.params_with_filters(Vec::new()); - let snippet_opts = snippet::Options { - approx_chars: SNIPPET_WINDOW_CHARS, - }; // One decoder and one fold buffer for the whole scan, reused per row. - let mut doc = DocDecoder::new()?; + let mut doc = crate::db::repo::DocDecoder::new()?; let mut folded = String::new(); // Decompression dominates: check cancellation every row. self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path| { @@ -537,14 +442,12 @@ impl<'a> Cx<'a> { // ASCII folding is byte-length preserving, so ranges found in // the folded buffer are valid in the original. fold_into(&mut folded, text); - let (count, first) = bitap.count_and_first(folded.as_bytes()); - if count == 0 { + let Some((count, snip)) = super::fuzzy_snippet(&bitap, text, &folded) else { return Ok(RowHit::Skip); - } + }; if !cx.regex_accepts(file_id, path, Some(text))? { return Ok(RowHit::Skip); } - let snip = first.map(|range| snippet::window_around(text, range, &snippet_opts)); let (size, mtime) = size_and_mtime(row)?; Ok(RowHit::Emit(SearchHit { file_id, @@ -554,7 +457,7 @@ impl<'a> Cx<'a> { mtime, rank: 8.0 + count_frac(count), stage: 8, - snippet: snip, + snippet: Some(snip), })) }) } @@ -587,16 +490,10 @@ impl<'a> Cx<'a> { None => return Ok(RowHit::Skip), }, }; - let snip = snippet::Snippet { - ranges: vec![match_range], - window: if is_path_tier { - path.to_string() - } else { - name.clone() - }, - truncated_start: false, - truncated_end: false, - }; + let snip = snippet::whole_field( + if is_path_tier { path } else { name.as_str() }, + match_range, + ); let (size, mtime) = size_and_mtime(row)?; let hit = SearchHit { file_id, @@ -631,7 +528,7 @@ impl<'a> Cx<'a> { approx_chars: SNIPPET_WINDOW_CHARS, }; // One decoder for the whole scan, reused per row. - let mut doc = DocDecoder::new()?; + let mut doc = crate::db::repo::DocDecoder::new()?; // Decompression dominates: check cancellation every row. self.scan_pass(&sql, params, 1, None, |_cx, row, file_id, path| { let blob: Option<&[u8]> = row diff --git a/crates/quicksearch-core/src/search/fuzzy.rs b/crates/quicksearch-core/src/search/fuzzy.rs index 552d157..e0a8367 100644 --- a/crates/quicksearch-core/src/search/fuzzy.rs +++ b/crates/quicksearch-core/src/search/fuzzy.rs @@ -21,6 +21,10 @@ const MAX_REGISTERS: usize = 22; pub struct Bitap { /// `masks[c]` has bit `i` set iff `pattern[i] == c`. masks: [u64; 256], + /// The same table for the *reversed* pattern, which is what lets + /// [`Bitap::match_start`] find where a match began by scanning backwards + /// from where it ended. + rev_masks: [u64; 256], /// Pattern length in bytes (1..=64). len: usize, /// Maximum edit distance. @@ -36,11 +40,14 @@ impl Bitap { return None; } let mut masks = [0u64; 256]; + let mut rev_masks = [0u64; 256]; for (i, &b) in pattern.iter().enumerate() { masks[b as usize] |= 1u64 << i; + rev_masks[b as usize] |= 1u64 << (pattern.len() - 1 - i); } Some(Bitap { masks, + rev_masks, len: pattern.len(), k, }) @@ -58,9 +65,14 @@ impl Bitap { /// Advance all registers by one haystack byte. Returns the smallest /// error count d for which the full pattern just matched, if any. + /// + /// `masks` selects the direction: [`Bitap::masks`] to scan forwards, + /// [`Bitap::rev_masks`] to scan backwards. Everything else — `len`, `k`, + /// the `done` bit, `reset` — is the same either way, since a reversed + /// pattern is still a pattern of the same length. #[inline] - fn step(&self, r: &mut [u64], byte: u8) -> Option { - let mask = self.masks[byte as usize]; + fn step(&self, masks: &[u64; 256], r: &mut [u64], byte: u8) -> Option { + let mask = masks[byte as usize]; let done = 1u64 << (self.len - 1); let mut hit = None; let mut prev_old = r[0]; // R_old[d-1] for the d-th iteration @@ -83,52 +95,127 @@ impl Bitap { hit } - /// Minimum edit distance (≤ k) of any occurrence of the pattern in - /// `hay`, or `None` if nothing matches within k edits. - pub fn best_distance(&self, hay: &[u8]) -> Option { - self.best_distance_and_first(hay).map(|(d, _)| d) - } - - /// [`best_distance`](Self::best_distance) plus the first match's - /// approximate byte range, from one sweep. The range carries the same - /// caveat as [`count_and_first`](Self::count_and_first): it assumes a - /// pattern-length match, so edits can shift the true start by up to `k`. - pub fn best_distance_and_first(&self, hay: &[u8]) -> Option<(usize, (usize, usize))> { + /// Where the match that ended at `end` with `errors` edits began. + /// + /// The forward scan knows an occurrence's *end* exactly — that is the bit + /// it tests — but not its start, and with an insertion or a deletion the + /// match is not `len` bytes long, so `end - len` is simply the wrong + /// offset. Highlighting it put the marks a byte or two off the match and + /// over whatever preceded it: `repot` against `1Reporter` marked `1Repo`. + /// + /// So the same automaton runs over the *reversed* pattern, backwards from + /// `end`. The first position it accepts **within `errors` edits** is the + /// start. That bound is what makes this correct rather than merely + /// plausible: the reversed pattern will also accept far shorter spans by + /// spending its whole budget on deletions — against `xabc` with a 2-edit + /// budget it accepts `c` alone on the very first byte — and taking that + /// would mark one letter of an exact three-letter match. The true + /// alignment costs the same read either way, so requiring `≤ errors` + /// rejects the cheap wrong answers and is still guaranteed to fire at or + /// before the real start. + /// + /// Which occurrence gets marked is settled elsewhere — see + /// [`Bitap::refine_end`]. The rule both producers land on is *the earliest + /// alignment at the smallest edit distance*, so a mark is never longer + /// than the term and never shorter by more than the budget. + fn match_start(&self, hay: &[u8], end: usize, errors: usize) -> usize { + // A ≤k-edit alignment of a len-byte pattern is at most len+k long, + // so nothing before this can be the start. + let floor = end.saturating_sub(self.len + self.k); let mut r = [0u64; MAX_REGISTERS]; self.reset(&mut r); - let mut best: Option<(usize, (usize, usize))> = None; - for (i, &b) in hay.iter().enumerate() { - if let Some(d) = self.step(&mut r, b) { - let end = i + 1; - let range = (end.saturating_sub(self.len), end); - if d == 0 { - return Some((0, range)); - } - if best.is_none_or(|(cur, _)| d < cur) { - best = Some((d, range)); + for (back, &b) in hay[floor..end].iter().rev().enumerate() { + if self + .step(&self.rev_masks, &mut r, b) + .is_some_and(|d| d <= errors) + { + return end - (back + 1); + } + } + // Unreachable: the forward scan proved an alignment ends here, and + // reversed it costs the same. Falling back to the floor keeps a + // hypothetical miss inside the haystack. + floor + } + + /// Improve on the *earliest* accepting end by looking a little past it. + /// + /// The automaton accepts as soon as a leading part of the pattern has + /// matched, paying for the rest with trailing deletions — so the first + /// end it reports is systematically short. Searching `abcdef` over + /// `zzabcdefzz` accepts after `abcd`, two deletions, with the whole word + /// sitting right there. + /// + /// Each further byte can turn one of those deletions into a match, so a + /// better alignment ends at most `errors` bytes later and never more. + /// Stepping a *copy* of the registers that far finds it without + /// disturbing the caller's scan, or its count. + fn refine_end( + &self, + hay: &[u8], + r: &[u64; MAX_REGISTERS], + end: usize, + errors: usize, + ) -> (usize, usize) { + let mut best = (errors, end); + let mut probe = *r; + for (ahead, &b) in hay[end..].iter().take(errors).enumerate() { + if let Some(d) = self.step(&self.masks, &mut probe, b) { + if d < best.0 { + best = (d, end + ahead + 1); } } } best } - /// Count non-overlapping occurrences (at ≤ k edits) and report the - /// first match's approximate byte range in `hay`. After each hit the - /// automaton resets, so an exact match followed by trailing bytes - /// counts once, and overlapping suffix matches don't inflate counts. - /// The reported range assumes pattern-length matches — edits can shift - /// the true start by up to k bytes, which is fine for snippet windows. + /// The smallest edit distance (≤ k) at which the pattern occurs in `hay`, + /// and that occurrence's byte range — the span a frontend marks. + /// + /// This one sweeps the whole haystack, so it finds the best alignment + /// without help; [`Bitap::count_and_first`] resets after every hit and + /// needs [`Bitap::refine_end`] instead. + pub fn best_distance_and_first(&self, hay: &[u8]) -> Option<(usize, (usize, usize))> { + let mut r = [0u64; MAX_REGISTERS]; + self.reset(&mut r); + // (errors, end). The start is resolved once, at the end, rather than + // per improvement — `match_start` is a second scan, however short. + let mut best: Option<(usize, usize)> = None; + for (i, &b) in hay.iter().enumerate() { + if let Some(d) = self.step(&self.masks, &mut r, b) { + let end = i + 1; + if d == 0 { + best = Some((0, end)); + break; + } + if best.is_none_or(|(cur, _)| d < cur) { + best = Some((d, end)); + } + } + } + best.map(|(d, end)| (d, (self.match_start(hay, end, d), end))) + } + + /// Count non-overlapping occurrences (at ≤ k edits) and report the first + /// one's byte range in `hay`. After each hit the automaton resets, so an + /// exact match followed by trailing bytes counts once, and overlapping + /// suffix matches don't inflate counts. + /// + /// The range is the occurrence itself: [`Bitap::refine_end`] settles which + /// end, then [`Bitap::match_start`] finds where it began. Both run for the + /// first hit only, and both are bounded by the edit budget, so the cost is + /// O(len + k) per row rather than per byte. pub fn count_and_first(&self, hay: &[u8]) -> (usize, Option<(usize, usize)>) { let mut r = [0u64; MAX_REGISTERS]; self.reset(&mut r); let mut count = 0usize; let mut first: Option<(usize, usize)> = None; for (i, &b) in hay.iter().enumerate() { - if self.step(&mut r, b).is_some() { + if let Some(d) = self.step(&self.masks, &mut r, b) { count += 1; if first.is_none() { - let end = i + 1; - first = Some((end.saturating_sub(self.len), end)); + let (errors, end) = self.refine_end(hay, &r, i + 1, d); + first = Some((self.match_start(hay, end, errors), end)); } self.reset(&mut r); } @@ -158,7 +245,28 @@ mod tests { fn best(pattern: &str, hay: &str, k: usize) -> Option { Bitap::new(pattern.as_bytes(), k) .unwrap() - .best_distance(hay.as_bytes()) + .best_distance_and_first(hay.as_bytes()) + .map(|(d, _)| d) + } + + /// The slice of `hay` that a pattern's first occurrence marks — what a + /// frontend highlights. + fn marked<'h>(pattern: &str, hay: &'h str, k: usize) -> &'h str { + let (_, first) = Bitap::new(pattern.as_bytes(), k) + .unwrap() + .count_and_first(hay.as_bytes()); + let (s, e) = first.expect("the pattern occurs"); + &hay[s..e] + } + + /// [`marked`] through the other range producer, which shares + /// `match_start` but reaches it by a different route. + fn marked_best<'h>(pattern: &str, hay: &'h str, k: usize) -> &'h str { + let (_, (s, e)) = Bitap::new(pattern.as_bytes(), k) + .unwrap() + .best_distance_and_first(hay.as_bytes()) + .expect("the pattern occurs"); + &hay[s..e] } #[test] @@ -223,19 +331,136 @@ mod tests { } #[test] - fn count_fuzzy_and_range_sane() { + fn count_fuzzy_and_range_is_the_occurrence_itself() { let b = Bitap::new(b"hello", 1).unwrap(); let hay = b"say helo and hxllo again"; let (count, first) = b.count_and_first(hay); assert_eq!(count, 2); - let (s, e) = first.unwrap(); - assert!(s < e && e <= hay.len()); - let window = &hay[s..e]; - assert!( - std::str::from_utf8(window).unwrap().contains("hel"), - "first range should cover the first hit, got {:?}", - std::str::from_utf8(window) - ); + // The occurrence, not a five-byte window ending where it ends: that + // reached back over the space and marked " helo". + assert_eq!(first, Some((4, 8))); + assert_eq!(&hay[4..8], b"helo"); + } + + /// The reported bug, exactly: `repot` marked `1Repo` in `1Reporter`. + /// + /// The match is `repo` — one deletion, dropping the `t` — so it is four + /// bytes where the term is five, and a range assumed to be term-length + /// reached one byte too far left, over the `1`. Both producers, since + /// they share `match_start`. + #[test] + fn a_match_shorter_than_the_term_is_still_marked_exactly() { + assert_eq!(marked("repot", "1reporter", 1), "repo"); + assert_eq!(marked_best("repot", "1reporter", 1), "repo"); + + // Substitution keeps the length, which is the case that always + // worked — worth holding, since it is the one the old arithmetic got + // right by accident. + assert_eq!(marked("hello", "xx hxllo xx", 1), "hxllo"); + assert_eq!(marked_best("hello", "xx hxllo xx", 1), "hxllo"); + } + + /// Text with a byte inserted into the term marks up to the insertion, not + /// across it: `abxc` is a one-edit alignment of `abc`, but so is the `ab` + /// that ends two bytes earlier, and the rule is the *earliest* alignment + /// at the best distance. Nothing longer than the term can win — spanning + /// an inserted byte costs an edit, and deleting instead costs the same and + /// ends sooner. + #[test] + fn an_insertion_marks_up_to_it_rather_than_over_it() { + assert_eq!(marked("abc", "abxcd", 1), "ab"); + assert_eq!(marked_best("abc", "zzabxczz", 1), "ab"); + } + + /// The trap in resolving the start backwards: the reversed pattern will + /// happily accept a much shorter span by spending its budget on + /// deletions, so taking its *first* acceptance marks one letter of an + /// exact match. `abc` occurs verbatim in `xabc`, and a 2-edit budget lets + /// the reverse pass accept `c` alone one byte in. + /// + /// Only the whole-haystack producer can reach the trap — it is the one + /// that reports an exact match while the budget is still generous, so + /// `errors` is 0 where `k` is 2. + #[test] + fn a_generous_budget_does_not_shrink_an_exact_match() { + assert_eq!(marked_best("abc", "xabc", 2), "abc"); + assert_eq!(marked_best("abcdef", "zzabcdefzz", 2), "abcdef"); + assert_eq!(marked("hello", "say hello world", 2), "hello"); + } + + /// The automaton accepts as soon as a leading part of the term has + /// matched, spending the rest of the budget on trailing deletions — so + /// the earliest end is short, and marking it highlighted `abcd` for a + /// search for `abcdef` with the whole word right there. `refine_end` + /// looks the budget's worth of bytes past the first acceptance. + /// + /// Checked against a brute-force Levenshtein oracle over every span. + #[test] + fn the_mark_is_not_truncated_to_a_leading_part_of_the_term() { + assert_eq!(marked("abcdef", "zzabcdefzz", 2), "abcdef"); + assert_eq!(marked("abc", "xabc", 1), "abc"); + assert_eq!(marked("reports", "the report went out", 2), "report"); + + // Not every term can be extended: `repot` against `1reporter` stops + // at `repo` because the next byte (`r`) costs an edit of its own, so + // one is the best it does either way. + assert_eq!(marked("repot", "1reporter", 1), "repo"); + // And an alignment already at zero errors has nothing to improve. + assert_eq!(marked("hello", "say hello world", 0), "hello"); + } + + /// However the span is chosen it is a real alignment at the best distance, + /// so it is never longer than the term and never shorter by more than the + /// budget. That bound is what keeps a mark recognisable: at the production + /// ladder of one edit per three characters, a mark is always at least two + /// thirds of the term. + #[test] + fn the_marked_span_is_within_the_budget_of_the_terms_length() { + for (term, hay) in [ + ("repot", "1reporter"), + ("abcdef", "zzabcdefzz"), + ("quarterly", "the quartrly budget"), + ("hello", "say helo and hxllo again"), + ("reports", "the report went out"), + ] { + let k = edit_budget(term.len(), 2).expect("a real budget"); + let span = marked(term, hay, k).len(); + assert!( + span <= term.len() && term.len() - span <= k, + "{term:?} in {hay:?} (k={k}) marked {span} bytes" + ); + } + } + + #[test] + fn a_zero_budget_marks_exactly_the_term() { + assert_eq!(marked("hello", "say hello world", 0), "hello"); + assert_eq!(marked("ab", "ab ab", 0), "ab"); + } + + /// A match at the very start, and one whose end is inside the term's own + /// length, are where the offset arithmetic can underflow. + #[test] + fn a_match_at_the_start_of_the_haystack_stays_in_bounds() { + assert_eq!(marked("repot", "reporter", 1), "repo"); + // The haystack is shorter than the term: "ab" matches "abc" with one + // deletion, ending at 2. + let (_, first) = Bitap::new(b"abc", 1).unwrap().count_and_first(b"ab"); + assert_eq!(first, Some((0, 2))); + } + + /// Bitap works on bytes over an ASCII-folded copy, so a range can land + /// inside a multi-byte character. `snippet::aligned_range` is what widens + /// it before anything slices; this only pins that the range stays inside + /// the haystack so that alignment has something valid to work from. + #[test] + fn a_range_over_multibyte_text_stays_within_the_haystack() { + let hay = "café notes — le rapport"; + let (_, first) = Bitap::new(b"raport", 1) + .unwrap() + .count_and_first(hay.as_bytes()); + let (s, e) = first.expect("one deletion from 'rapport'"); + assert!(s < e && e <= hay.len(), "({s}, {e}) outside {}", hay.len()); } #[test] @@ -335,7 +560,10 @@ mod tests { let pattern: Vec = (0..plen).map(|_| alphabet[rng() % 4]).collect(); let hay: Vec = (0..hlen).map(|_| alphabet[rng() % 4]).collect(); for k in 0..=4 { - let got = Bitap::new(&pattern, k).unwrap().best_distance(&hay); + let got = Bitap::new(&pattern, k) + .unwrap() + .best_distance_and_first(&hay) + .map(|(d, _)| d); let want = oracle(&pattern, &hay, k); assert_eq!( got, diff --git a/crates/quicksearch-core/src/search/mod.rs b/crates/quicksearch-core/src/search/mod.rs index 9ca1516..39a423d 100644 --- a/crates/quicksearch-core/src/search/mod.rs +++ b/crates/quicksearch-core/src/search/mod.rs @@ -69,6 +69,55 @@ pub struct SearchHit { pub snippet: Option, } +/// Which field a hit's [`SearchHit::snippet`] excerpts, derived from the +/// cascade stage. See the rank table at the top of [`crate::search::cascade`]. +/// +/// Frontends branch on this rather than on the raw stage number, so a new tier +/// is classified in one place instead of in every renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchField { + Name, + Contents, + Path, +} + +/// How a [`MatchField::Contents`] hit matched its body — what has to be +/// re-run to cut its snippet again from the file as it now stands. +/// +/// The two are not interchangeable: an exact tier's snippet is cut around the +/// literal term, and a fuzzy tier's around a bitap match the literal is +/// usually *absent* from. Re-cutting a fuzzy hit as if it were exact finds +/// nothing and reads as "the file no longer matches". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentTier { + /// Stages 5 and 6: the body contains the term as written. + Exact, + /// Stage 8: the body contains something within the fuzzy edit budget of + /// the term. + Fuzzy, +} + +impl SearchHit { + pub fn match_field(&self) -> MatchField { + match self.stage { + 1..=4 | 7 => MatchField::Name, + 5 | 6 | 8 => MatchField::Contents, + // 9..=11, and whatever a later tier adds: the path is the safe + // reading, since it is the one field every hit carries in full. + _ => MatchField::Path, + } + } + + /// `Some` for a hit whose snippet is a window on the file's body. + pub fn content_tier(&self) -> Option { + match self.stage { + 5 | 6 => Some(ContentTier::Exact), + 8 => Some(ContentTier::Fuzzy), + _ => None, + } + } +} + #[derive(Debug, Clone)] pub enum SearchUpdate { Started { diff --git a/crates/quicksearch-core/src/snippet.rs b/crates/quicksearch-core/src/snippet.rs index 32bcf32..561158c 100644 --- a/crates/quicksearch-core/src/snippet.rs +++ b/crates/quicksearch-core/src/snippet.rs @@ -52,13 +52,6 @@ impl Snippet { } } -/// Extract a snippet from `text` marking every occurrence of any term in -/// `terms` (ASCII-case-insensitive). With no terms or no matches, returns -/// the head of the text as the window with no ranges. -pub fn extract(text: &str, terms: &[&str], opts: &Options) -> Snippet { - extract_folded(text, &text.to_ascii_lowercase(), terms, opts) -} - /// [`extract`] against a haystack the caller has already ASCII-folded. /// `folded` must be `text.to_ascii_lowercase()` — the fold is byte-length /// preserving, which is what lets offsets found in it slice the original. @@ -139,6 +132,48 @@ pub fn extract_folded(text: &str, folded: &str, terms: &[&str], opts: &Options) } } +/// Clamp `range` into `text` and widen it to the nearest char boundaries. +/// +/// Both callers below take ranges from matchers that work on bytes — bitap +/// over an ASCII-folded copy — so an endpoint can land inside a multi-byte +/// character. Slicing there panics, and `Snippet::ranges` promises boundaries. +fn aligned_range(text: &str, range: (usize, usize)) -> (usize, usize) { + let (mut start, mut end) = range; + start = start.min(text.len()); + end = end.clamp(start, text.len()); + while start > 0 && !text.is_char_boundary(start) { + start -= 1; + } + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } + (start, end) +} + +/// The whole of `text` as the window, with `range` marked. +/// +/// This is the shape [`crate::search::SearchHit::snippet`] documents for the +/// name and path tiers, and what lets a frontend highlight the matched span +/// inside its own Name or Path column: `window` is that field verbatim, so the +/// ranges index the field the column is already painting. A filename or a path +/// is short enough to carry whole, so there is nothing to gain by windowing it. +pub fn whole_field(text: &str, range: (usize, usize)) -> Snippet { + if text.is_empty() { + return Snippet::empty(); + } + let (start, end) = aligned_range(text, range); + Snippet { + window: text.to_string(), + ranges: if end > start { + vec![(start, end)] + } else { + Vec::new() + }, + truncated_start: false, + truncated_end: false, + } +} + /// Build a snippet window around one known match range in `text` (byte /// offsets into `text`). Used by fuzzy full-text search, where the match /// was located by the fuzzy matcher rather than exact term search. The @@ -147,15 +182,7 @@ pub fn window_around(text: &str, range: (usize, usize), opts: &Options) -> Snipp if text.is_empty() { return Snippet::empty(); } - let (mut ms, mut me) = range; - ms = ms.min(text.len()); - me = me.clamp(ms, text.len()); - while ms > 0 && !text.is_char_boundary(ms) { - ms -= 1; - } - while me < text.len() && !text.is_char_boundary(me) { - me += 1; - } + let (ms, me) = aligned_range(text, range); let pre_pad = opts.approx_chars / 3; let mut win_start = ms.saturating_sub(pre_pad); @@ -244,6 +271,13 @@ fn coalesce_overlapping(v: Vec<(usize, usize)>) -> Vec<(usize, usize)> { mod tests { use super::*; + /// The tests were written against a since-removed `extract` wrapper. + /// Production always holds a fold buffer already, so the wrapper earned + /// nothing; folding here keeps its coverage of the window logic. + fn extract(text: &str, terms: &[&str], opts: &Options) -> Snippet { + extract_folded(text, &text.to_ascii_lowercase(), terms, opts) + } + fn opts_small() -> Options { Options { approx_chars: 40 } } diff --git a/crates/quicksearch-core/src/testutil.rs b/crates/quicksearch-core/src/testutil.rs index 816f06a..12c385a 100644 --- a/crates/quicksearch-core/src/testutil.rs +++ b/crates/quicksearch-core/src/testutil.rs @@ -21,12 +21,78 @@ pub fn zstd_of(text: &str) -> Option> { crate::db::repo::encode_one(text, true).expect("zstd encode") } +/// How old a leftover scratch directory must be before [`sweep_stale`] takes +/// it. Far longer than any test run, so a failure investigated the same day — +/// or the next morning — still has its tree. +const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(12 * 60 * 60); + +/// Whether `name` is one of [`scratch_dir`]'s own directories. +/// +/// Matched on the *shape* — `quicksearch-{tag}-{pid}-{seq}`, so the last two +/// dash-separated components must be numbers — rather than on the +/// `quicksearch-` prefix alone. `packaging/capture.sh` keeps its output in +/// `quicksearch-capture` in the same directory, and a prefix match would eat a +/// capture run's screenshots along with the litter. +fn is_scratch_name(name: &str) -> bool { + let Some(rest) = name.strip_prefix("quicksearch-") else { + return false; + }; + let numeric = |part: Option<&str>| { + part.is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) + }; + let mut tail = rest.rsplitn(3, '-'); + // seq, then pid, and a tag must remain in front of them. + numeric(tail.next()) && numeric(tail.next()) && tail.next().is_some_and(|tag| !tag.is_empty()) +} + +/// Remove scratch directories left by runs that are long over. +/// +/// Nothing here cleans up on the way *out*: a failed test's tree is most of +/// the evidence, which is why [`scratch_dir`] deliberately leaves it. But +/// passing tests leave theirs too, and most never remove it — so the temp +/// directory grew by roughly three hundred directories per full run and had +/// accumulated some nine thousand of them. Where `/tmp` is a tmpfs that is +/// gigabytes of RAM, which slows the whole suite and pushes the +/// timing-sensitive tests toward their budgets. +/// +/// Sweeping on the way *in* keeps both halves: this run's evidence survives, +/// and so does yesterday's, while nothing accumulates without bound. Only +/// [`scratch_dir`]'s own naming is touched. +fn sweep_stale() { + let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else { + return; + }; + let now = std::time::SystemTime::now(); + for entry in entries.flatten() { + let name = entry.file_name(); + if !name.to_str().is_some_and(is_scratch_name) { + continue; + } + let stale = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| now.duration_since(t).ok()) + .is_some_and(|age| age >= STALE_AFTER); + if stale { + // Best effort throughout: two test binaries starting together race + // on the same directory and one of them loses, which is fine. + std::fs::remove_dir_all(entry.path()).ok(); + } + } +} + /// A fresh, empty directory under the system temp dir, named for `tag`. +/// /// Not cleaned up on drop: when a test fails, the tree it built is most of -/// the evidence. Panics — a test that cannot create a directory has nothing -/// left to assert. +/// the evidence. Long-dead runs' trees are swept once per process instead — +/// see [`sweep_stale`]. Panics — a test that cannot create a directory has +/// nothing left to assert. #[doc(hidden)] pub fn scratch_dir(tag: &str) -> PathBuf { + static SWEPT: std::sync::Once = std::sync::Once::new(); + SWEPT.call_once(sweep_stale); + let mut p = std::env::temp_dir(); p.push(format!( "quicksearch-{}-{}-{}", @@ -95,4 +161,75 @@ mod tests { touch(&deep, b"hi"); assert_eq!(std::fs::read(&deep).unwrap(), b"hi"); } + + /// The sweep runs against a shared temp directory, so what it matches is + /// the whole safety argument. `quicksearch-capture` is the one that would + /// hurt: `packaging/capture.sh` puts a run's screenshots and screencasts + /// there, and a prefix match would delete them mid-capture. + #[test] + fn only_scratch_directories_are_swept() { + for ours in [ + "quicksearch-coord-1234-0", + "quicksearch-stall-heavy-1001402-7", + "quicksearch-a-0-0", + // Tags contain dashes of their own; only the last two components + // are read as numbers. + "quicksearch-sniff-binary-db-2621744-1", + ] { + assert!(is_scratch_name(ours), "{ours} should be swept"); + } + + for theirs in [ + // The capture output directory, the reason this is a shape match. + "quicksearch-capture", + "quicksearch", + "quicksearch-", + // A tag but no pid/seq pair. + "quicksearch-coord", + "quicksearch-coord-1234", + // Numbers, but nothing in front of them to be a tag. + "quicksearch-1234-0", + // Not ours at all. + "cargo-install-abc-1-2", + "tmp-quicksearch-coord-1-2", + ] { + assert!(!is_scratch_name(theirs), "{theirs} must not be swept"); + } + } + + /// Fresh directories survive; only long-dead runs are collected. Uses a + /// hand-built name rather than `scratch_dir` so the assertion is about the + /// age gate and not about whatever else the suite has left lying around. + #[test] + fn the_sweep_keeps_recent_trees_and_takes_old_ones() { + let fresh = scratch_dir("sweep-fresh"); + touch(&fresh.join("evidence.txt"), b"kept"); + + // Same shape, but back-dated past the threshold. `set_times` is the + // only way to age a directory without waiting twelve hours for it. + let old = std::env::temp_dir().join(format!( + "quicksearch-sweep-old-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&old).expect("create the aged directory"); + let long_ago = + std::time::SystemTime::now() - STALE_AFTER - std::time::Duration::from_secs(60); + std::fs::File::open(&old) + .and_then(|d| { + d.set_times( + std::fs::FileTimes::new() + .set_accessed(long_ago) + .set_modified(long_ago), + ) + }) + .expect("back-date the aged directory"); + + sweep_stale(); + + assert!(fresh.exists(), "a fresh scratch tree was swept away"); + assert!(!old.exists(), "a long-dead scratch tree survived the sweep"); + + std::fs::remove_dir_all(&fresh).ok(); + } } diff --git a/crates/quicksearch-core/src/verify.rs b/crates/quicksearch-core/src/verify.rs new file mode 100644 index 0000000..12cf2ab --- /dev/null +++ b/crates/quicksearch-core/src/verify.rs @@ -0,0 +1,299 @@ +//! Byte-for-byte verification that a set of files really is identical. +//! +//! The index groups duplicates by `sha256(size ‖ first hash_length bytes)` +//! (see [`crate::file_handling`]), which reads a file's head and nothing else +//! — a deliberate trade, since hashing every byte on a disk is most of the +//! cost of indexing it. Files of the same size whose heads agree are therefore +//! listed as duplicates whether or not they are: a fixed-size VHD keeps what +//! makes it unique in a footer, and a freshly pre-allocated disk image is +//! zeroes as far as the head can see. This turns that advisory grouping into +//! an answer, for the moment before someone deletes something. +//! +//! No hashing here, by policy. A digest per file would be shorter code and the +//! same answer nearly always — but "nearly always" is what the head hash +//! already offers, and the whole point of asking a second time is that this +//! time the bytes are compared. + +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +/// Total read-buffer memory, split across the files being compared. A group is +/// usually two files and can be thousands — a hardlink farm, which is exactly +/// what `[indexing] ignore_patterns` warns about — so a per-file buffer of any +/// fixed size would become the largest allocation the process ever makes. +const CHUNK_BUDGET: usize = 8 * 1024 * 1024; +const MIN_CHUNK: usize = 16 * 1024; +const MAX_CHUNK: usize = 256 * 1024; + +/// How often progress is emitted. Each one repaints the UI, and a chunk off a +/// warm page cache takes microseconds. +const PROGRESS_INTERVAL: Duration = Duration::from_millis(100); + +/// What one member turned out to be, against the reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MemberVerdict { + /// Same length, every byte agreed. The reference itself reads this. + Identical, + /// Offset of the first byte that disagreed. + DiffersAt(u64), + /// Lengths disagree, so nothing was read. Within a duplicate group this + /// can only mean a stale index — the hash covers the size. + LengthDiffers { len: u64, reference_len: u64 }, + /// Could not be opened, or stopped being readable part way through. + Unreadable(String), +} + +impl MemberVerdict { + pub fn is_identical(&self) -> bool { + matches!(self, MemberVerdict::Identical) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +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, + /// One verdict per input path, in the input order. + pub verdicts: Vec, + /// Bytes actually read from disk, across every file. + pub bytes_read: u64, +} + +impl VerifyReport { + /// Whether every member was read and matched. An empty or single-file set + /// is vacuously identical. + pub fn all_identical(&self) -> bool { + self.verdicts.iter().all(MemberVerdict::is_identical) + } + + pub fn differing(&self) -> usize { + self.verdicts.iter().filter(|v| !v.is_identical()).count() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifyUpdate { + Progress { bytes_read: u64, bytes_total: u64 }, + Done(VerifyReport), + Cancelled, +} + +/// A file still in the running, with its own read buffer. +struct Live { + index: usize, + file: File, + buf: Vec, +} + +/// Compare every path against the first one that opens, byte for byte, and +/// report what each turned out to be. +/// +/// Emits `Progress` while it works and exactly one terminal update — `Done`, +/// or `Cancelled` if `cancel` went up before the comparison finished. +pub fn verify_identical(paths: &[PathBuf], cancel: &AtomicBool, on: &mut dyn FnMut(VerifyUpdate)) { + // Checked before the files are even opened, so a run cancelled before it + // starts reports the cancellation rather than a verdict nobody waited for. + if cancel.load(Ordering::Relaxed) { + on(VerifyUpdate::Cancelled); + return; + } + let mut verdicts = vec![MemberVerdict::Identical; paths.len()]; + + // The reference is the first path that both opens *and* stats, not simply + // the first path: one unreadable member must not cost the answer about all + // the others. + let mut reference: Option<(usize, File, u64)> = None; + let mut rest: Vec<(usize, File)> = Vec::new(); + for (i, path) in paths.iter().enumerate() { + let file = match File::open(path) { + Ok(f) => f, + Err(e) => { + verdicts[i] = MemberVerdict::Unreadable(describe(path, &e)); + continue; + } + }; + if reference.is_some() { + rest.push((i, file)); + continue; + } + match file.metadata() { + Ok(m) => reference = Some((i, file, m.len())), + Err(e) => verdicts[i] = MemberVerdict::Unreadable(describe(path, &e)), + } + } + + let Some((reference, mut reference_file, reference_len)) = reference else { + on(VerifyUpdate::Done(VerifyReport { + reference: None, + verdicts, + bytes_read: 0, + })); + return; + }; + + // A length mismatch is decided from the handles, before a byte is read. + let mut live: Vec = Vec::with_capacity(rest.len()); + for (i, file) in rest { + match file.metadata() { + Ok(m) if m.len() != reference_len => { + verdicts[i] = MemberVerdict::LengthDiffers { + len: m.len(), + reference_len, + }; + } + Ok(_) => live.push(Live { + index: i, + file, + buf: Vec::new(), + }), + Err(e) => verdicts[i] = MemberVerdict::Unreadable(describe(&paths[i], &e)), + } + } + + let chunk = (CHUNK_BUDGET / (live.len() + 1)).clamp(MIN_CHUNK, MAX_CHUNK); + let mut reference_buf = vec![0u8; chunk]; + for l in live.iter_mut() { + l.buf = vec![0u8; chunk]; + } + + let bytes_total = match live.len() { + 0 => 0, + n => reference_len.saturating_mul(n as u64 + 1), + }; + let mut bytes_read = 0u64; + let mut offset = 0u64; + // Backdated so the first chunk reports: a progress bar that only appears + // after the first interval reads as a frozen window on a slow disk, which + // is the case this is for. + let mut last_progress = Instant::now() + .checked_sub(PROGRESS_INTERVAL) + .unwrap_or_else(Instant::now); + + while !live.is_empty() { + if cancel.load(Ordering::Relaxed) { + on(VerifyUpdate::Cancelled); + return; + } + + // Termination is driven by what the reference actually reads rather + // than by the length it claimed, so a file truncated underneath us + // degrades to a short comparison instead of a hang or a false match. + let n = match read_chunk(&mut reference_file, &mut reference_buf) { + Ok(0) => break, // EOF: everything still live matched all the way. + Ok(n) => n, + Err(e) => { + verdicts[reference] = MemberVerdict::Unreadable(describe(&paths[reference], &e)); + // Survivors agreed up to here but cannot be finished. Saying + // so is the only honest answer; "identical" would not be. + for l in live.iter() { + verdicts[l.index] = MemberVerdict::Unreadable(format!( + "compared only to byte {offset}: {} could not be read to the end", + paths[reference].display() + )); + } + break; + } + }; + bytes_read += n as u64; + + let mut i = 0; + while i < live.len() { + let (got, verdict) = { + let l = &mut live[i]; + match read_chunk(&mut l.file, &mut l.buf[..n]) { + Ok(m) => { + let common = n.min(m); + if let Some(k) = + first_difference(&reference_buf[..common], &l.buf[..common]) + { + (m, Some(MemberVerdict::DiffersAt(offset + k as u64))) + } else if m < n { + // Same length a moment ago, shorter now. + ( + m, + Some(MemberVerdict::Unreadable(format!( + "{}: ended at byte {} while the file it was compared \ + against had more", + paths[l.index].display(), + offset + m as u64 + ))), + ) + } else { + (m, None) + } + } + Err(e) => ( + 0, + Some(MemberVerdict::Unreadable(describe(&paths[l.index], &e))), + ), + } + }; + bytes_read += got as u64; + match verdict { + Some(v) => { + verdicts[live[i].index] = v; + live.swap_remove(i); + } + None => i += 1, + } + } + offset += n as u64; + + if last_progress.elapsed() >= PROGRESS_INTERVAL { + last_progress = Instant::now(); + on(VerifyUpdate::Progress { + bytes_read, + bytes_total, + }); + } + } + + on(VerifyUpdate::Done(VerifyReport { + reference: Some(reference), + verdicts, + bytes_read, + })); +} + +/// Fill `buf` as far as the file allows, returning how much. Short reads are +/// resumed and `Interrupted` retried, the way `extract::plaintext` does, so a +/// short return really does mean end of file. +fn read_chunk(f: &mut File, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while filled < buf.len() { + match f.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(filled) +} + +/// Offset of the first byte that differs. The equality test comes first +/// because it is a `memcmp`; the byte walk only ever runs on the one chunk +/// that turned out to differ. +fn first_difference(a: &[u8], b: &[u8]) -> Option { + if a == b { + return None; + } + Some( + a.iter() + .zip(b.iter()) + .position(|(x, y)| x != y) + .unwrap_or(a.len().min(b.len())), + ) +} + +fn describe(path: &Path, e: &std::io::Error) -> String { + format!("{}: {}", path.display(), e) +} + +#[cfg(test)] +#[path = "verify_tests.rs"] +mod tests; diff --git a/crates/quicksearch-core/src/verify_tests.rs b/crates/quicksearch-core/src/verify_tests.rs new file mode 100644 index 0000000..d592edb --- /dev/null +++ b/crates/quicksearch-core/src/verify_tests.rs @@ -0,0 +1,281 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::*; +use crate::testutil::{scratch_dir, touch}; + +/// Run to completion with cancellation switched off, returning every update. +fn run(paths: &[PathBuf]) -> Vec { + let cancel = AtomicBool::new(false); + let mut seen = Vec::new(); + verify_identical(paths, &cancel, &mut |u| seen.push(u)); + seen +} + +/// The report from a run, asserting it produced exactly one terminal update +/// and that the update was `Done`. +fn report(paths: &[PathBuf]) -> VerifyReport { + let seen = run(paths); + let terminal: Vec<&VerifyUpdate> = seen + .iter() + .filter(|u| !matches!(u, VerifyUpdate::Progress { .. })) + .collect(); + assert_eq!(terminal.len(), 1, "expected one terminal update: {seen:?}"); + match terminal[0] { + VerifyUpdate::Done(r) => r.clone(), + other => panic!("expected Done, got {other:?}"), + } +} + +/// `n` files in a fresh directory, each with the body it is given. +fn files(tag: &str, bodies: &[&[u8]]) -> Vec { + let dir = scratch_dir(tag); + bodies + .iter() + .enumerate() + .map(|(i, body)| { + let p = dir.join(format!("copy{i}.bin")); + touch(&p, body); + p + }) + .collect() +} + +#[test] +fn identical_files_all_report_identical() { + for count in [2, 3] { + let bodies = vec![&b"the same bytes in every copy"[..]; count]; + let paths = files("verify-same", &bodies); + let r = report(&paths); + assert_eq!(r.reference, Some(0)); + assert!(r.all_identical(), "{r:?}"); + assert_eq!(r.differing(), 0); + // Every file was read through: the head hash alone would not do. + assert_eq!(r.bytes_read, 28 * count as u64); + } +} + +#[test] +fn a_difference_is_reported_at_its_offset() { + // First byte, mid-file, and the very last byte: the last is the one a + // head-only hash can never see, and the reason this module exists. + for (label, a, b, at) in [ + ("first", &b"Xbcdefgh"[..], &b"abcdefgh"[..], 0), + ("middle", &b"abcdefgh"[..], &b"abcXefgh"[..], 3), + ("last", &b"abcdefgh"[..], &b"abcdefgX"[..], 7), + ] { + let paths = files("verify-diff", &[a, b]); + let r = report(&paths); + assert_eq!(r.verdicts[0], MemberVerdict::Identical, "{label}"); + assert_eq!(r.verdicts[1], MemberVerdict::DiffersAt(at), "{label}"); + assert!(!r.all_identical(), "{label}"); + assert_eq!(r.differing(), 1, "{label}"); + } +} + +/// The realistic false positive: same size, same head, different tail — a +/// pre-allocated disk image, which is what `[processing] hash_length` +/// documents as the known limitation. +#[test] +fn a_shared_head_with_a_different_tail_is_caught() { + let head = vec![0u8; 64 * 1024]; + let mut a = head.clone(); + let mut b = head; + a.extend_from_slice(b"footer-a"); + b.extend_from_slice(b"footer-b"); + let paths = files("verify-tail", &[a.as_slice(), b.as_slice()]); + + let r = report(&paths); + assert_eq!(r.verdicts[1], MemberVerdict::DiffersAt(64 * 1024 + 7)); +} + +/// Bigger than one chunk, so the multi-chunk path and the running offset are +/// both exercised rather than assumed. +#[test] +fn a_difference_past_the_first_chunk_is_found() { + // Two files, so the chunk is the 256 KiB ceiling and the difference sits + // in the second one. + let size = MAX_CHUNK * 2 + 1234; + let a = vec![7u8; size]; + let mut b = a.clone(); + let at = MAX_CHUNK + 500; + b[at] = 8; + let paths = files("verify-chunks", &[a.as_slice(), b.as_slice()]); + + let r = report(&paths); + assert_eq!(r.verdicts[1], MemberVerdict::DiffersAt(at as u64)); + // Two chunks out of each file and then it stops: with nothing left to + // compare against, reading the remainder of the reference would be work + // that cannot change the answer. + assert_eq!( + r.bytes_read, + 4 * MAX_CHUNK as u64, + "did not stop once the last live file dropped out" + ); +} + +#[test] +fn different_lengths_are_decided_without_reading() { + let paths = files("verify-len", &[b"abcdefgh", b"abcdefghij"]); + let r = report(&paths); + assert_eq!( + r.verdicts[1], + MemberVerdict::LengthDiffers { + len: 10, + reference_len: 8 + } + ); + assert_eq!(r.bytes_read, 0, "a length mismatch reads nothing"); +} + +#[test] +fn empty_files_are_identical() { + let paths = files("verify-empty", &[b"", b""]); + let r = report(&paths); + assert!(r.all_identical(), "{r:?}"); + assert_eq!(r.bytes_read, 0); +} + +#[test] +fn a_missing_member_is_unreadable_and_the_rest_still_compare() { + let mut paths = files("verify-missing", &[b"same", b"same"]); + paths.insert(1, PathBuf::from("/nonexistent/quicksearch-verify-missing")); + + let r = report(&paths); + assert_eq!(r.reference, Some(0)); + assert!(matches!(r.verdicts[1], MemberVerdict::Unreadable(_))); + assert_eq!( + r.verdicts[2], + MemberVerdict::Identical, + "an unreadable member stopped the others being compared" + ); + assert_eq!(r.differing(), 1); +} + +/// The first path is the obvious reference, but not a required one: an +/// unreadable first member must not sink the whole run. +#[test] +fn the_reference_falls_through_to_the_first_readable_member() { + let mut paths = files("verify-refmissing", &[b"same", b"same"]); + paths.insert(0, PathBuf::from("/nonexistent/quicksearch-verify-ref")); + + let r = report(&paths); + assert_eq!(r.reference, Some(1)); + assert!(matches!(r.verdicts[0], MemberVerdict::Unreadable(_))); + assert_eq!(r.verdicts[1], MemberVerdict::Identical); + assert_eq!(r.verdicts[2], MemberVerdict::Identical); +} + +#[test] +fn nothing_readable_reports_no_reference() { + let paths = vec![ + PathBuf::from("/nonexistent/quicksearch-verify-a"), + PathBuf::from("/nonexistent/quicksearch-verify-b"), + ]; + let r = report(&paths); + assert_eq!(r.reference, None); + assert_eq!(r.verdicts.len(), 2); + assert!(r.verdicts.iter().all(|v| !v.is_identical())); +} + +#[test] +fn a_single_file_and_an_empty_set_are_vacuously_identical() { + let paths = files("verify-one", &[b"alone"]); + let r = report(&paths); + assert_eq!(r.reference, Some(0)); + assert!(r.all_identical()); + assert_eq!(r.bytes_read, 0, "nothing to compare it against"); + + let r = report(&[]); + assert_eq!(r.reference, None); + assert!(r.verdicts.is_empty()); + assert!(r.all_identical()); +} + +#[test] +fn a_run_cancelled_before_it_starts_reports_only_that() { + let paths = files("verify-cancel", &[b"same", b"same"]); + let cancel = AtomicBool::new(true); + let mut seen = Vec::new(); + verify_identical(&paths, &cancel, &mut |u| seen.push(u)); + assert_eq!(seen, vec![VerifyUpdate::Cancelled]); +} + +/// Cancelling part way through ends the run there, with no `Done` claiming a +/// verdict it never reached. The first chunk always reports progress, so the +/// flag goes up between two chunks rather than at a time the test has to race +/// for. +#[test] +fn cancelling_mid_run_ends_it_without_a_verdict() { + let body = vec![3u8; MAX_CHUNK * 4]; + let paths = files("verify-cancel-mid", &[body.as_slice(), body.as_slice()]); + + let cancel = AtomicBool::new(false); + let mut seen = Vec::new(); + verify_identical(&paths, &cancel, &mut |u| { + cancel.store(true, Ordering::Relaxed); + seen.push(u); + }); + assert!( + matches!(seen.first(), Some(VerifyUpdate::Progress { .. })), + "the first chunk did not report progress: {seen:?}" + ); + assert_eq!(seen.last(), Some(&VerifyUpdate::Cancelled), "{seen:?}"); + assert!( + !seen.iter().any(|u| matches!(u, VerifyUpdate::Done(_))), + "a cancelled run still reported a verdict: {seen:?}" + ); +} + +/// Whatever progress reports, it is a fraction that makes sense: monotonic, +/// and never past its own denominator. +#[test] +fn progress_climbs_and_never_overruns_its_denominator() { + let body = vec![9u8; MAX_CHUNK * 6]; + let paths = files( + "verify-progress", + &[body.as_slice(), body.as_slice(), body.as_slice()], + ); + + let mut last = 0; + let mut count = 0; + for update in run(&paths) { + if let VerifyUpdate::Progress { + bytes_read, + bytes_total, + } = update + { + assert!( + bytes_read <= bytes_total, + "{bytes_read} read of {bytes_total}" + ); + assert!(bytes_read >= last, "progress went backwards"); + last = bytes_read; + count += 1; + } + } + assert!(count > 0, "a six-chunk comparison reported no progress"); +} + +/// A directory is not a file this can compare, however the platform refuses +/// it — `File::open` fails outright on Windows, while on Linux it opens and +/// then refuses to be read. Either way it is that member's problem, not the +/// run's. +#[test] +fn an_unreadable_member_does_not_stop_the_run() { + let dir = scratch_dir("verify-dir"); + let a = dir.join("a.bin"); + let b = dir.join("b.bin"); + touch(&a, b"identical bytes"); + touch(&b, b"identical bytes"); + let sub = dir.join("subdir"); + std::fs::create_dir_all(&sub).unwrap(); + + let r = report(&[a, sub, b]); + assert_eq!(r.reference, Some(0)); + assert!( + !r.verdicts[1].is_identical(), + "a directory was called an identical file" + ); + assert_eq!(r.verdicts[2], MemberVerdict::Identical); +} diff --git a/crates/quicksearch-core/src/walk.rs b/crates/quicksearch-core/src/walk.rs index 82231f8..fe7e1e5 100644 --- a/crates/quicksearch-core/src/walk.rs +++ b/crates/quicksearch-core/src/walk.rs @@ -29,7 +29,6 @@ use crate::file_handling::{ classify_by_mtime, classify_for_indexing, path_to_db_string, prepare_file_record, warn_if_unrepresentable, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs, }; -use crate::indexing::should_abort; mod pool; #[cfg(test)] @@ -170,7 +169,6 @@ struct Ctx { registry: Arc, unreadable: UnreadableDirs, stop_flag: Arc, - suspend_flag: Arc, } /// Individual unreadable-directory warnings allowed per run before only the @@ -422,7 +420,7 @@ fn prepare(file: PendingFile, known: Known<'_>, ctx: &Ctx) -> WalkedFile { fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { while let Some((job, slot)) = shared.take() { let _busy = shared.stats.enter(); - if should_abort(&ctx.stop_flag, &ctx.suspend_flag) { + if ctx.stop_flag.load(Ordering::Relaxed) { shared.shutdown(); return; } @@ -459,7 +457,7 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { } for file in files { - if should_abort(&ctx.stop_flag, &ctx.suspend_flag) { + if ctx.stop_flag.load(Ordering::Relaxed) { shared.shutdown(); return; } @@ -703,7 +701,6 @@ pub fn walk_indexable_files( config: Config, registry: Arc, stop_flag: Arc, - suspend_flag: Arc, workers: usize, ) -> ParallelWalk { let mut queue = Queue::default(); @@ -746,7 +743,6 @@ pub fn walk_indexable_files( registry, unreadable: UnreadableDirs::default(), stop_flag, - suspend_flag, }); for root in unresolvable { diff --git a/crates/quicksearch-core/src/walk/tests.rs b/crates/quicksearch-core/src/walk/tests.rs index 43b9dff..98c62ab 100644 --- a/crates/quicksearch-core/src/walk/tests.rs +++ b/crates/quicksearch-core/src/walk/tests.rs @@ -54,7 +54,6 @@ fn walk_with( Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, )) } @@ -304,7 +303,6 @@ fn unreadable_directory_is_recorded_not_silently_empty() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, ); let files: Vec = w @@ -462,7 +460,6 @@ fn hidden_and_ignored_entries_are_pruned() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, )); assert_eq!(names(&files), vec!["keep.txt", "keep2.txt"]); @@ -508,7 +505,6 @@ fn pruned_entries_are_counted_by_reason() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, ); let files: Vec = (&mut walk) @@ -557,7 +553,6 @@ fn a_tree_with_nothing_pruned_reports_no_summary() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, ); let files: Vec = (&mut walk) @@ -603,7 +598,6 @@ fn a_directory_reports_rows_with_no_file_behind_them() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, )); stale.sort(); @@ -643,7 +637,6 @@ fn an_unreadable_directory_reports_nothing_stale() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, )); fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).ok(); @@ -684,7 +677,6 @@ fn stop_flag_ends_the_walk_without_hanging() { Config::default(), Arc::new(Registry::default_set()), stop, - Arc::new(AtomicBool::new(false)), 4, )); @@ -713,7 +705,6 @@ fn dropping_the_walk_early_does_not_hang() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, ); assert!(w.next().is_some()); @@ -739,7 +730,6 @@ fn overlapping_roots_yield_each_file_once() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, )); @@ -787,7 +777,6 @@ fn finish_reports_a_clean_walk_and_is_idempotent() { Config::default(), Arc::new(Registry::default_set()), Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), 4, ); let files: Vec = w diff --git a/crates/quicksearch-core/tests/cascade.rs b/crates/quicksearch-core/tests/cascade.rs index 48e9f8a..34311aa 100644 --- a/crates/quicksearch-core/tests/cascade.rs +++ b/crates/quicksearch-core/tests/cascade.rs @@ -9,7 +9,9 @@ use quicksearch_core::db::open_or_recreate; use quicksearch_core::db::repo::{insert_file, set_content_done, NewFile}; use quicksearch_core::mime::FileType; use quicksearch_core::query::split::split_for_cascade; -use quicksearch_core::search::{cascade, SearchHit, SearchOptions, SearchService, SearchUpdate}; +use quicksearch_core::search::{ + cascade, MatchField, SearchHit, SearchOptions, SearchService, SearchUpdate, +}; use quicksearch_core::testutil::zstd_of; mod common; @@ -355,6 +357,39 @@ fn fuzzy_max_edits_widens_and_narrows_the_budget() { std::fs::remove_file(&p).ok(); } +/// Regression: the fuzzy filename tier stamps stage 7 rather than truncating +/// its own rank. `7.0 + 0.1 * distance` reaches 8.0 at ten edits — the fuzzy +/// *full-text* tier — and every frontend reads `match_field()`, so a filename +/// hit would have been rendered as a match on the file's contents. +#[test] +fn a_distant_fuzzy_filename_hit_stays_a_name_hit() { + let p = tmp_db("fuzzystage"); + let mut s = Seeder::new(&p, true); + // Ten substitutions against a 30-character term, whose budget is ten. + let far = s.add("abcdefghijklmnopqrst##########", "/d", 1, None); + let conn = s.done(); + + let (hits, _) = run_collect( + &conn, + "abcdefghijklmnopqrstuvwxyz0123", + &fuzzy_options_with_edits(10), + ); + assert_eq!( + hits.iter().map(|h| h.file_id).collect::>(), + vec![far] + ); + assert!( + (hits[0].rank - 8.0).abs() < 1e-9, + "rank {} is not the 8.0 that used to truncate into the next stage", + hits[0].rank + ); + assert_eq!(hits[0].stage, 7, "the name tier is stage 7 at any distance"); + assert_eq!(hits[0].match_field(), MatchField::Name); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + #[test] fn dedup_keeps_best_rank() { let p = tmp_db("dedup"); @@ -1400,3 +1435,138 @@ fn streaming_does_not_change_the_result_set() { assert_eq!(outcome.total, 3, "batch size {}", batch); } } + +/// `SearchHit::snippet` is documented as "the filename for name stages, the +/// full path for path stages", and a frontend relies on it to highlight the +/// match inside the Name or Path column it is already painting: the ranges +/// index that field, so they only line up if the window *is* that field. +/// +/// The fuzzy tiers used to window it instead, which silently broke the +/// contract whenever the match landed past two thirds of the way through a +/// long name — the ranges then indexed a suffix, and a column that trusted +/// them would mark the wrong glyphs. +#[test] +fn fuzzy_name_and_path_snippets_carry_the_whole_field() { + let p = tmp_db("fuzzy-whole-field"); + let mut s = Seeder::new(&p, true); + // The match sits in the last third of the name, which is what used to + // push the window's start off zero. + s.add( + "a_long_and_deliberately_padded_out_quarterly_repot.txt", + "/home/me/documents/archive", + 1, + None, + ); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "report", &fuzzy_options()); + let hit = hits + .iter() + .find(|h| h.stage == 7) + .expect("a fuzzy filename hit"); + + let snip = hit.snippet.as_ref().expect("a name hit carries a snippet"); + assert_eq!(snip.window, hit.name, "the window is not the whole name"); + assert!(!snip.truncated_start, "the name was windowed"); + assert!(!snip.truncated_end, "the name was windowed"); + for &(a, b) in &snip.ranges { + assert!(b <= hit.name.len(), "range {a}..{b} runs past the name"); + assert!( + hit.name.is_char_boundary(a) && hit.name.is_char_boundary(b), + "range {a}..{b} is not on char boundaries" + ); + } +} + +/// A fuzzy tier's marks have to cover the *matched* text and nothing else. +/// +/// The bug this pins: bitap reports where a match ends, and the range took +/// its start to be `end - term.len()`, which is only right when the match +/// happens to be as long as the term. Searching `repot` marked `1Repo` inside +/// `1Reporter` — one byte too far left, over a character that matched nothing. +/// +/// Asserted on the *text* rather than on offsets, so it reads as the symptom +/// and survives the fixture being reworded. +#[test] +fn a_fuzzy_mark_covers_the_matched_text_and_nothing_else() { + let p = tmp_db("fuzzy-mark-span"); + let mut s = Seeder::new(&p, true); + // The leading digit is the point: it is what the old range reached back + // over. In the body too, for the full-text tier. + s.add("1Reporter.txt", "/home/me/docs", 1, None); + s.add( + "body.txt", + "/home/me/docs", + 2, + Some("filed under 1Reporter last week"), + ); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "repot", &fuzzy_options()); + + // Stage 7 — fuzzy filename, marked inside the whole name. + let name_hit = hits + .iter() + .find(|h| h.stage == 7) + .expect("a fuzzy filename hit"); + let snip = name_hit.snippet.as_ref().expect("name tiers carry one"); + let (a, b) = snip.ranges[0]; + assert_eq!( + &snip.window[a..b], + "Repo", + "the mark is {:?}; it must cover the match and not the leading digit", + &snip.window[a..b] + ); + + // Stage 8 — fuzzy full text, marked inside the snippet window. + let body_hit = hits + .iter() + .find(|h| h.stage == 8) + .expect("a fuzzy full-text hit"); + let snip = body_hit.snippet.as_ref().expect("content tiers carry one"); + let (a, b) = snip.ranges[0]; + assert_eq!( + &snip.window[a..b], + "Repo", + "the mark is {:?}; it must cover the match and not the leading digit", + &snip.window[a..b] + ); +} + +/// The other half of the same defect: the automaton accepts the moment a +/// leading part of the term has matched, paying for the term's tail with +/// deletions — so the mark stopped short of the text that actually matched. +/// +/// `quarterly` against a body holding `quartrly` accepts after `quartrl`, +/// spending both edits on the missing `y` and the dropped `e`. One byte +/// further is a *better* alignment (one edit) covering the whole word, which +/// is what a reader expects to see lit up. +#[test] +fn a_fuzzy_mark_is_not_truncated_to_a_leading_part_of_the_term() { + let p = tmp_db("fuzzy-mark-full"); + let mut s = Seeder::new(&p, true); + // The name must not match at all: a row the filename tier claims never + // reaches the full-text tier. And the body must hold a fuzzy *variant* — + // the term verbatim would be an exact content match, stage 5 or 6. + s.add( + "notes.txt", + "/home/me/docs", + 1, + Some("the quartrly budget was revised"), + ); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "quarterly", &fuzzy_options()); + let hit = hits + .iter() + .find(|h| h.stage == 8) + .expect("a fuzzy full-text hit"); + let snip = hit.snippet.as_ref().expect("content tiers carry one"); + let (a, b) = snip.ranges[0]; + assert_eq!( + &snip.window[a..b], + "quartrly", + "the mark is {:?}, a leading part of what matched", + &snip.window[a..b] + ); +} diff --git a/crates/quicksearch-core/tests/full_index.rs b/crates/quicksearch-core/tests/full_index.rs index ad8b133..18f5c92 100644 --- a/crates/quicksearch-core/tests/full_index.rs +++ b/crates/quicksearch-core/tests/full_index.rs @@ -11,12 +11,26 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; use quicksearch_core::config::Config; -use quicksearch_core::file_handling::{extract_scope_prepare, ExtractCursor}; +use quicksearch_core::file_handling::{ + count_extract_scope, mark_oversize_pending_na, ExtractCursor, ExtractScope, +}; use quicksearch_core::indexing::{IndexingService, IndexingStatus, RootPhase}; mod common; use common::{scratch_dir as tmp_dir, touch}; +/// The removed `extract_scope_prepare`: the oversize sweep the writer still +/// does, then the count the content pass now does on its own connection. +fn extract_scope_prepare( + conn_mutex: &Arc>, + cursor: &ExtractCursor, + config: &Config, +) -> Result { + let conn = conn_mutex.lock().unwrap(); + mark_oversize_pending_na(&conn, cursor, config).unwrap(); + count_extract_scope(&conn, cursor, config) +} + /// Run one full index over `root` and wait for it to finish. fn index_once(root: &Path, db: &Path, config: &Config) { common::IndexOnce { @@ -1230,6 +1244,80 @@ fn contentless_mode_still_indexes_inlined_files_without_storing_bodies() { std::fs::remove_dir_all(&db_dir).ok(); } +/// What one watch of a heavy/light overlap saw; see [`observe_overlap`]. +struct Overlap { + /// Light files drained and heavy rows stored, across the window in which + /// the heavy root extracted while the light root walked. + light_drained: usize, + heavy_stored: usize, + /// The heavy root's `extract_total` and pool size, for the fixture guards. + heavy_pending: usize, + heavy_pool: usize, +} + +/// Watch a two-root run until the heavy root has finished extracting and +/// report how the two counters moved while both were in flight, then stop +/// the run. Removing the fixture is the caller's. +/// +/// Deltas across the overlap, never durations. Sparse samples cost only the +/// window's edges, and they trim both counters together. Panics if the window +/// never opened — a fixture that does not exercise the case proves nothing. +fn observe_overlap(service: &IndexingService, heavy_tag: &str, light_tag: &str) -> Overlap { + let mut opened: Option<(usize, usize)> = None; // (light.walked, heavy.extracted) + let mut last = (0usize, 0usize); + let mut heavy_pending = 0usize; + let mut heavy_pool = 0usize; + let deadline = Instant::now() + Duration::from_secs(120); + while Instant::now() < deadline { + let mut in_window = false; + match service.get_status() { + IndexingStatus::Running { roots, .. } => { + let heavy_p = roots.iter().find(|r| r.root.contains(heavy_tag)); + let light_p = roots.iter().find(|r| r.root.contains(light_tag)); + if let (Some(h), Some(l)) = (heavy_p, light_p) { + // The light root's *walk* is what used to be starved, so the + // window closes with it — past that there is no drain left + // to observe, and `walked` is the only counter in play. + in_window = h.phase == RootPhase::Extracting && l.phase == RootPhase::Walking; + if in_window { + last = (l.walked, h.extracted); + opened.get_or_insert(last); + if let Some(total) = h.extract_total { + heavy_pending = total; + } + heavy_pool = h.total_workers; + } + } + } + // The run is claimed but has not reached its walk yet; there is + // nothing to sample, and breaking here would end the watch before + // the run it is watching had started. + IndexingStatus::Preparing { .. } => {} + IndexingStatus::Error(e) => panic!("indexing failed: {}", e), + _ => break, + } + // Both phases are monotone, so a closed window will not reopen. + if opened.is_some() && !in_window { + break; + } + std::thread::sleep(Duration::from_millis(2)); + } + service.stop_indexing().unwrap(); + + let Some((light_open, heavy_open)) = opened else { + panic!( + "never observed the heavy root extracting while the light root walked; \ + the fixture is not exercising the case" + ); + }; + Overlap { + light_drained: last.0 - light_open, + heavy_stored: last.1 - heavy_open, + heavy_pending, + heavy_pool, + } +} + /// A slow root must not stall the others. /// /// This is the complaint stated directly: one root doing heavy extraction used @@ -1241,120 +1329,252 @@ fn contentless_mode_still_indexes_inlined_files_without_storing_bodies() { /// wrong measure: writing is serial by construction (one SQLite connection), /// so on a local disk the writer, not extraction, is the bottleneck and a /// wall-clock comparison would mostly measure the machine. +/// +/// A stall is therefore counted in *work*, not in milliseconds: while the heavy +/// root extracts, how many files the light root's walk was drained of, against +/// how many rows the heavy root's extraction landed. Both counters are advanced +/// by the same writer loop, each root's turn bounded by one slice +/// (`service_walking`, `service_extracting` in `indexing/pipeline.rs`), so their +/// ratio *is* the interleaving. +/// +/// - Serialised — the regression — the writer reads the heavy batch itself and +/// drains nobody meanwhile. Whatever shape that takes it obeys +/// `light < heavy + quantum`: one quantum of each per round is the most a +/// single thread taking turns can manage. Its own time budget says the same +/// from the other side, since time spent reading is time not spent inserting. +/// - As built, extraction is off on the root's own pool and the writer's turn +/// for the heavy root is a store and nothing more, so the light root is +/// drained at the writer's full rate throughout — on this fixture several +/// times the bound. +/// +/// Counting rather than timing is what makes the verdict the same on a loaded +/// CI runner and an idle workstation. Every way a host can be slow — a +/// preempted writer, a checkpoint, a long round — freezes *both* counters, and +/// cancels. The wall-clock figure this replaced did not cancel: the same +/// correct behaviour measured ~20 ms here and 188 ms on the CI runner, which is +/// *more* than the 130 ms the broken design measured here. At that point CI was +/// overriding the budget six-fold and the check had stopped telling the two +/// designs apart. A bound that has to be calibrated per host is not an +/// assertion. #[test] fn a_heavy_root_does_not_stall_a_light_one() { - // HEAVY: few files, each big enough that reading it is real work, with a + // The writer's round-robin quantum. Set here rather than inherited from the + // default 500 because the bound below is arithmetic in it, and because a + // 500-file round is a coarse enough publish interval to look like a stall + // on a slow host all by itself. + const QUANTUM: usize = 16; + // HEAVY: few files, each big enough that reading one is real work, with a // small `maximum_text_size` so the cost lands in extraction rather than in - // the writer's tokenising. + // the writer's tokenising. Few and large rather than many and small: the + // margin is set by the cost of *one* heavy file against one light one, + // while the fixture's size on disk is their product — so for a given number + // of bytes, bigger files discriminate better. If a host ever comes in + // short, raise this size; raising the count only lengthens the window. + const HEAVY_FILES: usize = 32; + // LIGHT: a wide tree of tiny files, so its walk outlasts the heavy root's + // extraction and its counter moves finely. Each is inlined by its walk + // worker, so this root has no extraction phase of its own to confuse the + // window with. Sized for a walk the writer never holds up: at 6000 it was + // over before half the heavy rows had landed. + const LIGHT_FILES: usize = 16_000; + // Light files drained per (heavy row + quantum). Three times a bound the + // serialised design provably cannot reach, and roughly a ninth of what the + // built one reaches here. + const MIN_INTERLEAVE: usize = 3; + let heavy = tmp_dir("stall-heavy"); let body: Vec = "sphinx of black quartz judge my vow " - .repeat(40_000) + .repeat(80_000) .into_bytes(); - for i in 0..200 { + for i in 0..HEAVY_FILES { touch(&heavy.join(format!("d{}/big{:04}.txt", i % 8, i)), &body); } - // LIGHT: a wide tree of tiny files, so its walk runs long enough to sample - // and its progress counter moves finely. let light = tmp_dir("stall-light"); - for i in 0..6000 { + for i in 0..LIGHT_FILES { touch(&light.join(format!("d{}/f{:05}.txt", i % 60, i)), b"x"); } let db_dir = tmp_dir("stall-db"); let db = db_dir.join("index.sqlite"); + let roots = vec![ + heavy.to_string_lossy().into_owned(), + light.to_string_lossy().into_owned(), + ]; + let mut config = test_config(); config.processing.maximum_text_size = 1024; config.processing.maximum_text_file_size = 8 * 1024 * 1024; + config.processing.batch_size = QUANTUM; + // One extraction thread for the heavy root, so its pass costs about what + // the broken design's inline read would and the two differ only in *which* + // thread pays for it. `root_workers` is keyed by the `indexing_paths` + // spelling; both sides canonicalize before matching. + config.paths.indexing_paths = roots.clone(); + config.indexing.root_workers.insert(roots[0].clone(), 1); + // The default WAL cap is far above anything this run writes, so no forced + // checkpoint lands inside the window. That stops being true if the fixture + // ever grows by an order of magnitude. let service = IndexingService::new(); service - .start_indexing( - vec![ - heavy.to_string_lossy().into_owned(), - light.to_string_lossy().into_owned(), - ], - db.to_string_lossy().into_owned(), - config.clone(), - ) + .start_indexing(roots, db.to_string_lossy().into_owned(), config.clone()) .unwrap(); - - // Sample the light root's progress while the heavy one is extracting, and - // keep the longest interval over which it did not move. - let mut worst = Duration::ZERO; - let mut last_change = Instant::now(); - let mut last_seen = 0usize; - let mut sampled_together = false; - let deadline = Instant::now() + Duration::from_secs(120); - while Instant::now() < deadline { - match service.get_status() { - IndexingStatus::Running { roots, .. } => { - let heavy_p = roots.iter().find(|r| r.root.contains("stall-heavy")); - let light_p = roots.iter().find(|r| r.root.contains("stall-light")); - if let (Some(h), Some(l)) = (heavy_p, light_p) { - let light_busy = l.phase != RootPhase::Done; - if h.phase == RootPhase::Extracting && light_busy { - sampled_together = true; - let now = l.walked + l.extracted; - if now != last_seen { - last_seen = now; - last_change = Instant::now(); - } else { - worst = worst.max(last_change.elapsed()); - } - } - } - } - // The run is claimed but has not reached its walk yet; there is - // nothing to sample, and breaking here would end the watch before - // the run it is watching had started. - IndexingStatus::Preparing { .. } => {} - IndexingStatus::Error(e) => panic!("indexing failed: {}", e), - _ => break, - } - std::thread::sleep(Duration::from_millis(2)); - } - service.stop_indexing().unwrap(); + let seen = observe_overlap(&service, "stall-heavy", "stall-light"); drop(service); + // Before the assertions, unlike the rest of this file: those tests keep + // their trees because a failing test's tree is the evidence, but this + // fixture is generated and identical every run, and its evidence is the two + // counters printed below. Leaving 92 MB of it in a RAM-backed /tmp behind a + // failure is itself a reason for the next run to fail. + std::fs::remove_dir_all(&heavy).ok(); + std::fs::remove_dir_all(&light).ok(); + std::fs::remove_dir_all(&db_dir).ok(); + + // The fixture is as configured. Each of these silently costs a factor of + // the margin below if it stops holding, so they are checked before the + // ratio is read as a verdict on the design. + assert_eq!( + seen.heavy_pool, 1, + "the heavy root must extract on the single worker root_workers asked for; \ + with the default four its pass is four times shorter and so is the margin" + ); + assert_eq!( + seen.heavy_pending, HEAVY_FILES, + "every heavy file must reach the content pass; one inlined by its walk \ + worker never produces an extraction phase to overlap with" + ); assert!( - sampled_together, - "never observed the two roots overlapping; the fixture is not exercising the case" - ); - // Measured on this fixture: ~20 ms with the extraction pools, ~130 ms when - // the file reading is forced back onto the writer thread (and unbounded in - // the real failure, where the heavy root is on a network share). The bound - // sits between, with several times the observed headroom. - // - // The fixed design's stall does not grow with the heavy root's cost — it is - // one round-robin pass plus one commit — so making that root heavier only - // widens the margin. - // The figures above are wall-clock, so they scale with the host: a small CI - // VM running the other tests in this binary alongside this one measures - // several times the developer-machine number without the design having - // changed at all. QSB_STALL_BUDGET_MS lets that environment say so out loud - // instead of the bound being quietly loosened for everyone. Keep any override - // well under the broken design's figure scaled by the same factor, or the - // test stops discriminating between the two. - let budget = Duration::from_millis( - std::env::var("QSB_STALL_BUDGET_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(100), + seen.heavy_stored * 2 >= HEAVY_FILES, + "only {} of {} heavy rows landed inside the observed window; the sample \ + did not cover the pass", + seen.heavy_stored, + HEAVY_FILES ); + eprintln!( - "longest light-root stall while heavy extracted: {:?} (budget {:?})", - worst, budget + "light files drained while the heavy root extracted: {} against {} heavy \ + rows (quantum {}) — {}x the {}x required; the serialised design cannot \ + exceed 1x", + seen.light_drained, + seen.heavy_stored, + QUANTUM, + seen.light_drained / (seen.heavy_stored + QUANTUM), + MIN_INTERLEAVE ); assert!( - worst < budget, - "the light root stalled for {:?} while the heavy root extracted (budget {:?})", - worst, - budget + seen.light_drained >= MIN_INTERLEAVE * (seen.heavy_stored + QUANTUM), + "the light root was drained of only {} files while the heavy root landed \ + {} rows; one quantum of each per round is all a writer that extracts \ + inline can manage, so anything near {} means the extraction is back on \ + the writer thread", + seen.light_drained, + seen.heavy_stored, + seen.heavy_stored + QUANTUM ); +} + +/// The sibling of [`a_heavy_root_does_not_stall_a_light_one`] for the cost that +/// test deliberately keeps small: the writer's own tokenising. +/// +/// There the heavy files are expensive to *read* and cheap to *write* +/// (`maximum_text_size = 1024`), so it never exercised the writer. Here each +/// heavy row carries the default 256 KiB of text and its FTS5 trigram insert is +/// the expensive step — and it runs on the writer thread, inside the +/// transaction, where nothing can take it off. Four workers keep the ready +/// channel full, so what one turn finds waiting is a whole channel of them. +/// +/// Before turns had a slice, an extraction turn wrote everything it found — +/// half a second to two seconds of tokenising — and the light root's walk got +/// one quantum in between: the ratio below came in under one. With turns +/// bounded by `TURN_SLICE` and walks served first, the light root drains at +/// its own rate while the heavy root lands a row or two per round. +#[test] +fn a_heavy_root_does_not_stall_a_light_one_at_the_writer() { + const QUANTUM: usize = 16; + // Over the walk's inline threshold, and enough that the stored text is the + // full `maximum_text_size` (256 KiB) — the tokenising is what is measured. + const HEAVY_FILES: usize = 32; + // Wider than the sibling's: with the walk no longer waiting on the writer + // it drains so fast that 6000 files were gone before half the heavy rows + // had landed, and the window closed on a sample too short to trust. + const LIGHT_FILES: usize = 16_000; + // As in the sibling: three times a bound the unsliced writer cannot reach. + const MIN_INTERLEAVE: usize = 3; + + let heavy = tmp_dir("stall-writer-heavy"); + let body: Vec = "sphinx of black quartz judge my vow " + .repeat(9_000) + .into_bytes(); + for i in 0..HEAVY_FILES { + touch(&heavy.join(format!("d{}/big{:04}.txt", i % 8, i)), &body); + } + let light = tmp_dir("stall-writer-light"); + for i in 0..LIGHT_FILES { + touch(&light.join(format!("d{}/f{:05}.txt", i % 60, i)), b"x"); + } + + let db_dir = tmp_dir("stall-writer-db"); + let db = db_dir.join("index.sqlite"); + let roots = vec![ + heavy.to_string_lossy().into_owned(), + light.to_string_lossy().into_owned(), + ]; + + let mut config = test_config(); + config.processing.batch_size = QUANTUM; + config.paths.indexing_paths = roots.clone(); + // Four readers, so the heavy rows reach the writer faster than it can + // tokenise them and the ready channel is full when its turn comes. + config.indexing.root_workers.insert(roots[0].clone(), 4); + + let service = IndexingService::new(); + service + .start_indexing(roots, db.to_string_lossy().into_owned(), config.clone()) + .unwrap(); + let seen = observe_overlap(&service, "stall-writer-heavy", "stall-writer-light"); + drop(service); std::fs::remove_dir_all(&heavy).ok(); std::fs::remove_dir_all(&light).ok(); std::fs::remove_dir_all(&db_dir).ok(); + + assert_eq!( + seen.heavy_pool, 4, + "the heavy root must extract on four workers" + ); + assert_eq!( + seen.heavy_pending, HEAVY_FILES, + "every heavy file must reach the content pass" + ); + // A quarter, not the sibling's half: the light walk now outruns the heavy + // pass by design, and a window over eight 256 KiB rows is evidence enough + // that the writer yielded between them. + assert!( + seen.heavy_stored * 4 >= HEAVY_FILES, + "only {} of {} heavy rows landed inside the observed window; the sample \ + did not cover the pass", + seen.heavy_stored, + HEAVY_FILES + ); + + eprintln!( + "light files drained while the heavy root tokenised: {} against {} heavy \ + rows (quantum {}) — {}x the {}x required", + seen.light_drained, + seen.heavy_stored, + QUANTUM, + seen.light_drained / (seen.heavy_stored + QUANTUM), + MIN_INTERLEAVE + ); + assert!( + seen.light_drained >= MIN_INTERLEAVE * (seen.heavy_stored + QUANTUM), + "the light root was drained of only {} files while the heavy root landed \ + {} rows; an extraction turn is writing to the end of its batch again \ + instead of yielding at its slice", + seen.light_drained, + seen.heavy_stored + ); } /// The write-ahead log must not grow for the length of a run. diff --git a/crates/quicksearch-core/tests/snippet_perf.rs b/crates/quicksearch-core/tests/snippet_perf.rs index 77d9a0f..74eda1f 100644 --- a/crates/quicksearch-core/tests/snippet_perf.rs +++ b/crates/quicksearch-core/tests/snippet_perf.rs @@ -289,7 +289,8 @@ fn snippet_paths_perf_comparison() { } None => String::new(), }; - let _snip = snippet::extract(&text, &[q], &opts); + let folded = text.to_ascii_lowercase(); + let _snip = snippet::extract_folded(&text, &folded, &[q], &opts); rows_b_total += 1; } } diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs index cf8c0fd..d68f1fe 100644 --- a/crates/quicksearch-gui/src/app.rs +++ b/crates/quicksearch-gui/src/app.rs @@ -1,6 +1,7 @@ //! Application shell: tab strip, per-frame event drains, debounce, //! status bar, and config-change routing. +use std::path::PathBuf; use std::sync::mpsc; use std::time::Duration; @@ -22,8 +23,8 @@ use crate::format::{fmt_interval, group_thousands}; use crate::keychain; use crate::logs_tab::LogsTab; use crate::manage_tab::ManageTab; -use crate::options::{OptionsWindow, SecurityAction}; use crate::search_tab::SearchTab; +use crate::settings_tab::{SecurityAction, SettingsTab}; use crate::unlock::KeySource; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -33,6 +34,17 @@ pub(crate) enum Tab { Duplicates, Logs, Help, + Settings, +} + +/// The editor a tab holds, if it stages its edits on a draft rather than +/// saving them the moment they change. +fn tab_editor(tab: Tab) -> Option { + match tab { + Tab::Manage => Some(UnsavedSource::Manage), + Tab::Settings => Some(UnsavedSource::Settings), + Tab::Search | Tab::Duplicates | Tab::Logs | Tab::Help => None, + } } /// A navigation the unsaved-changes guard put on hold; once nothing relevant @@ -40,7 +52,6 @@ pub(crate) enum Tab { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NavIntent { SwitchTab(Tab), - CloseOptions, Quit, } @@ -48,21 +59,30 @@ enum NavIntent { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UnsavedSource { Manage, - Options, + Settings, } -/// Which editor the guard must ask about for `intent`, if any. Quit asks -/// about Options before Manage, one prompt at a time: each draft is a full -/// `Config` snapshot, so applying both at once would revert the first. +/// The editor `from` holds, if it has one and it is holding unapplied edits. +fn dirty_editor(from: Tab, manage_dirty: bool, settings_dirty: bool) -> Option { + match tab_editor(from)? { + UnsavedSource::Manage => manage_dirty.then_some(UnsavedSource::Manage), + UnsavedSource::Settings => settings_dirty.then_some(UnsavedSource::Settings), + } +} + +/// Which editor the guard must ask about for `intent` while sitting on +/// `from`, if any. A tab switch asks only about the tab being left; Quit asks +/// about Settings before Manage, one prompt at a time, because each draft is +/// a full `Config` snapshot and applying both at once would revert the first. fn guard_source( intent: NavIntent, + from: Tab, manage_dirty: bool, - options_dirty: bool, + settings_dirty: bool, ) -> Option { match intent { - NavIntent::SwitchTab(_) => manage_dirty.then_some(UnsavedSource::Manage), - NavIntent::CloseOptions => options_dirty.then_some(UnsavedSource::Options), - NavIntent::Quit if options_dirty => Some(UnsavedSource::Options), + NavIntent::SwitchTab(_) => dirty_editor(from, manage_dirty, settings_dirty), + NavIntent::Quit if settings_dirty => Some(UnsavedSource::Settings), NavIntent::Quit if manage_dirty => Some(UnsavedSource::Manage), NavIntent::Quit => None, } @@ -78,8 +98,13 @@ fn quit_needs_reconcile_warning(intent: NavIntent, reconciling: bool) -> bool { /// Whether leaving the current tab has to go through the unsaved-changes /// guard. A navigation already on hold wins: a second intent would replace /// the answer the guard is waiting for. -fn switch_needs_guard(from: Tab, manage_dirty: bool, nav_pending: bool) -> bool { - from == Tab::Manage && manage_dirty && !nav_pending +fn switch_needs_guard( + from: Tab, + manage_dirty: bool, + settings_dirty: bool, + nav_pending: bool, +) -> bool { + !nav_pending && dirty_editor(from, manage_dirty, settings_dirty).is_some() } pub struct QuickSearchApp { @@ -90,9 +115,12 @@ pub struct QuickSearchApp { manage: ManageTab, dups: DuplicatesTab, logs: LogsTab, - options: OptionsWindow, + settings: SettingsTab, /// Set when applying a config that invalidates the stored index. rebuild_prompt: Option>, + /// The first-start tour, while it is open. Only ever `Some` for a config + /// file this version created — see [`crate::tutorial`]. + tutorial: Option, /// Set while the "delete the index?" confirmation is open. clear_prompt: bool, /// Nested roots found in the loaded config; shown as a modal over the @@ -112,8 +140,12 @@ pub struct QuickSearchApp { /// Set when the watcher gave up on the directory budget and live /// updates are off. watch_cap_prompt: Option, + /// The byte-for-byte check of one duplicate group, while its modal is up. + verify: Option, /// In-flight security flow (enable/disable/change password). security_prompt: Option, + /// In-flight show-key flow (confirm password, then reveal). + key_prompt: Option, /// A navigation held by the unsaved-changes guard; see [`NavIntent`]. pending_nav: Option, /// The guard resolved a Quit: let the next close request through. @@ -129,8 +161,10 @@ mod security; mod status_bar; #[cfg(test)] mod tests; +mod verify; -use security::SecurityPrompt; +use security::{KeyPrompt, SecurityPrompt}; +use verify::VerifyModal; impl QuickSearchApp { /// `initial_query` pre-fills the search box and fires a search on the @@ -178,7 +212,12 @@ impl QuickSearchApp { } else { (Tab::Manage, Some(nested)) }; - let mut search = SearchTab::new(fuzzy); + // `Some(false)` means a config file *this version wrote*, which is + // the only thing that counts as a first start. A key that is absent + // (`None`) belongs to an installation that upgraded into this version + // and has already found its way around. + let tutorial = (cfg.ui.tutorial_seen == Some(false)).then(crate::tutorial::Tutorial::new); + let mut search = SearchTab::new(fuzzy, cfg.search.columns.clone(), cfg.search.live_results); if let Some(query) = initial_query { search.seed(query); } @@ -190,8 +229,9 @@ impl QuickSearchApp { manage: ManageTab::new(), dups: DuplicatesTab::new(), logs: LogsTab::new(), - options: OptionsWindow::new(), + settings: SettingsTab::new(), rebuild_prompt: None, + tutorial, clear_prompt: false, nested_prompt, key_source, @@ -199,7 +239,9 @@ impl QuickSearchApp { reconcile_owed, reconcile_owed_since, watch_cap_prompt: None, + verify: None, security_prompt: None, + key_prompt: None, pending_nav: None, quit_confirmed: false, config_error, @@ -222,6 +264,10 @@ impl QuickSearchApp { let Some(search) = self.backend.search() else { return; }; + // The single funnel every search goes through — the debounce, `seed`, + // and every `actions.rerun` producer — so it is the one place the old + // results' watches have to be dropped. + self.backend.clear_live(); let generation = search.search(&self.search.query, self.search_options()); self.search.on_search_started(generation); } @@ -232,6 +278,32 @@ impl QuickSearchApp { self.backend.start_duplicates(&cfg, ctx.clone()); } + /// Move to another tab, running what leaving one tab and arriving at the + /// other owe. Every switch goes through here — including the ones the + /// unsaved-changes guard completes a frame later, which is why this is a + /// funnel rather than a comparison against the previous frame's tab. + fn switch_tab(&mut self, ctx: &egui::Context, to: Tab) { + if self.tab == to { + return; + } + match self.tab { + // Watching rows nobody is looking at costs descriptors for + // nothing. + Tab::Search => { + self.backend.clear_live(); + self.search.reset_live(); + } + // A draft kept while the config is edited elsewhere would go + // stale, and applying it later would revert those edits. + Tab::Settings => self.settings.discard(), + _ => {} + } + self.tab = to; + if to == Tab::Duplicates { + self.start_duplicates_scan(ctx); + } + } + /// Save + route an edited config to the running services. Reports /// whether the config was accepted — a `false` means nothing was saved /// and the caller must keep any staged edits alive. @@ -290,6 +362,16 @@ impl QuickSearchApp { self.rebuild_prompt = Some(changes); } } + self.search.live_enabled = new.search.live_results; + if new.search.live_results { + // The watcher holds a copy of the config for its extraction + // limits and filters, so a config edit has to re-arm; dropping + // the tab-side state is what makes the next frame do it. + self.search.reset_live(); + } else { + self.backend.clear_live(); + self.search.reset_live(); + } self.cfg = new; true } @@ -297,18 +379,23 @@ impl QuickSearchApp { /// What the system-wide search shortcut does once the window is up: /// show the Search tab with the caret in the query box and any existing /// text selected. - pub(crate) fn activate_search(&mut self) { - if switch_needs_guard(self.tab, self.manage.is_dirty(), self.pending_nav.is_some()) { + pub(crate) fn activate_search(&mut self, ctx: &egui::Context) { + if switch_needs_guard( + self.tab, + self.manage.is_dirty(), + self.settings.is_dirty(&self.cfg), + self.pending_nav.is_some(), + ) { self.pending_nav = Some(NavIntent::SwitchTab(Tab::Search)); } else { - self.tab = Tab::Search; + self.switch_tab(ctx, Tab::Search); } self.search.request_focus(); } - /// Whether the Options window is currently reading a key press to bind. + /// Whether the Settings tab is currently reading a key press to bind. pub(crate) fn capturing_hotkey(&self) -> bool { - self.options.capturing_hotkey() + self.tab == Tab::Settings && self.settings.capturing_hotkey() } /// Switch the indexing mode and write it to the config immediately: a @@ -334,6 +421,21 @@ impl QuickSearchApp { self.search .apply_update(update, self.cfg.search.display_limit); } + // Every live update is something the watcher read off the disk that + // the index has not been told about. Handing the paths back keeps the + // index from drifting away from the rows on screen — and it is the + // only thing that does so while indexing is stopped. + let mut touched: Vec = Vec::new(); + while let Ok(update) = self.backend.live_rx.try_recv() { + touched.push(PathBuf::from(update.path())); + // A rename has two sides: the old path leaves the index and the + // new one enters it. + if let quicksearch_core::live::LiveUpdate::Renamed { to, .. } = &update { + touched.push(PathBuf::from(to)); + } + self.search.apply_live(update); + } + self.backend.reindex_live_paths(touched); // Duplicates worker. if let Some(rx) = &self.backend.dup_job { use std::sync::mpsc::TryRecvError; @@ -350,6 +452,7 @@ impl QuickSearchApp { self.backend.dup_job = None; } } + self.drain_verify(); } fn tick_debounce(&mut self, ctx: &egui::Context) { @@ -389,7 +492,7 @@ impl QuickSearchApp { } pub(crate) fn capture_search_settled(&self) -> bool { - self.search.capture_settled() + self.search.settled() } pub(crate) fn capture_dups_done(&self) -> bool { @@ -419,6 +522,12 @@ impl QuickSearchApp { pub(crate) fn pin_live_fields(new: &mut Config, live: &Config) { new.security = live.security.clone(); new.indexing.auto_index = live.indexing.auto_index; + // The column picker writes straight to the live config the moment a + // checkbox moves — from the table header *or* from the Settings tab, + // which is why the Settings controls for it are not draft-backed. Pinning + // here is what stops a draft taken before a header-menu change from + // undoing it on Apply. + new.search.columns = live.search.columns.clone(); } /// Keep the configured UI scale within sane, recoverable bounds. @@ -448,7 +557,8 @@ pub(crate) fn apply_theme(ctx: &egui::Context, setting: &str) { impl eframe::App for QuickSearchApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - // First, so `previous_tab` below sees pre-navigation state. + // First, so a scripted navigation is held before the tab strip reads + // this frame's state. #[cfg(feature = "capture")] self.capture_tick(ctx); @@ -461,7 +571,7 @@ impl eframe::App for QuickSearchApp { if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed && (self.manage.is_dirty() - || self.options.is_dirty(&self.cfg) + || self.settings.is_dirty(&self.cfg) || self.backend.coordinator.reconciling()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); @@ -471,7 +581,6 @@ impl eframe::App for QuickSearchApp { self.status_bar(ctx); - let previous_tab = self.tab; // Tab clicks land on a local first so the unsaved-changes guard can // hold them. let mut requested = self.tab; @@ -482,31 +591,21 @@ impl eframe::App for QuickSearchApp { ui.selectable_value(&mut requested, Tab::Duplicates, "Duplicates"); ui.selectable_value(&mut requested, Tab::Logs, "Logs"); ui.selectable_value(&mut requested, Tab::Help, "Help"); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("⚙").on_hover_text("Options").clicked() { - if !self.options.open { - self.options.open_with(&self.cfg); - } else if self.options.is_dirty(&self.cfg) { - if self.pending_nav.is_none() { - self.pending_nav = Some(NavIntent::CloseOptions); - } - } else { - self.options.close_discard(); - } - } - }); + ui.selectable_value(&mut requested, Tab::Settings, "Settings"); }); }); if requested != self.tab { - if switch_needs_guard(self.tab, self.manage.is_dirty(), self.pending_nav.is_some()) { + if switch_needs_guard( + self.tab, + self.manage.is_dirty(), + self.settings.is_dirty(&self.cfg), + self.pending_nav.is_some(), + ) { self.pending_nav = Some(NavIntent::SwitchTab(requested)); } else { - self.tab = requested; + self.switch_tab(ctx, requested); } } - if self.tab == Tab::Duplicates && previous_tab != Tab::Duplicates { - self.start_duplicates_scan(ctx); - } if let Some(err) = &self.config_error { let err = err.clone(); @@ -533,6 +632,17 @@ impl eframe::App for QuickSearchApp { self.config_error = Some(e); } } + // Live state, saved the moment it changes — like the fuzzy + // default above, and unlike anything edited through the + // Settings draft. The Settings tab's own column controls take + // this same path, so the two editors cannot disagree and a + // stale draft cannot revert either of them. + if let Some(columns) = actions.save_columns { + self.cfg.search.columns = columns; + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } if let Some(pattern) = actions.persist_ignore { let mut new_cfg = self.cfg.clone(); if !new_cfg.indexing.ignore_patterns.contains(&pattern) { @@ -540,6 +650,10 @@ impl eframe::App for QuickSearchApp { self.apply_new_config(ctx, new_cfg); } } + if let Some(targets) = actions.live_targets { + self.backend + .watch_live(&self.search.query, targets, &self.cfg); + } if actions.rerun { self.start_search(); } @@ -571,32 +685,54 @@ impl eframe::App for QuickSearchApp { } } Tab::Duplicates => { - let actions = self.dups.ui(ui); + let actions = self.dups.ui(ui, self.verify.is_some()); if actions.refresh { self.start_duplicates_scan(ctx); } + if let Some(paths) = actions.verify { + let paths: Vec = + paths.into_iter().map(std::path::PathBuf::from).collect(); + self.backend.start_verify(paths.clone(), ctx.clone()); + self.verify = Some(VerifyModal::new(paths)); + } } Tab::Logs => self.logs.ui(ui), - Tab::Help => crate::help_tab::ui(ui), + Tab::Help => { + if crate::help_tab::ui(ui) { + self.show_tutorial(); + } + } + Tab::Settings => { + let out = self.settings.ui(ui, &self.cfg); + if let Some(new_cfg) = out.applied { + self.apply_new_config(ctx, new_cfg); + } + if let Some(action) = out.security { + self.handle_security_action(action); + } + // Same live path the table header's picker takes, so the two + // controls stay in step and neither needs an Apply. + if let Some(columns) = out.columns { + self.cfg.search.columns = columns.clone(); + self.search.columns = columns; + self.search.mark_sort_dirty(); + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } + } }); - let options_out = self.options.ui(ctx, &self.cfg); - if let Some(new_cfg) = options_out.applied { - self.apply_new_config(ctx, new_cfg); - } - if let Some(action) = options_out.security { - self.handle_security_action(action); - } - if options_out.close_requested && self.pending_nav.is_none() { - self.pending_nav = Some(NavIntent::CloseOptions); - } self.rebuild_prompt_ui(ctx); self.security_prompt_ui(ctx); + self.key_prompt_ui(ctx); self.clear_prompt_ui(ctx); self.nested_prompt_ui(ctx); // Ahead of the watch-cap warning: on a fresh upgrade both can be true. self.stale_index_prompt_ui(ctx); self.watch_cap_prompt_ui(ctx); + self.verify_modal_ui(ctx); + self.tutorial_ui(ctx); // Last: the guard must sit above everything else on screen. self.unsaved_prompt_ui(ctx); } diff --git a/crates/quicksearch-gui/src/app/modals.rs b/crates/quicksearch-gui/src/app/modals.rs index d563535..fb0fd9e 100644 --- a/crates/quicksearch-gui/src/app/modals.rs +++ b/crates/quicksearch-gui/src/app/modals.rs @@ -84,7 +84,7 @@ impl QuickSearchApp { }); if close == Some(true) { self.nested_prompt = None; - self.tab = Tab::Manage; + self.switch_tab(ctx, Tab::Manage); } } @@ -187,6 +187,29 @@ impl QuickSearchApp { } } + /// The first-start tour. Dismissal is written straight to the config, the + /// way the fuzzy default is — not through the Settings draft, which this + /// has nothing to do with. + pub(super) fn tutorial_ui(&mut self, ctx: &egui::Context) { + let Some(tour) = &mut self.tutorial else { + return; + }; + let roots = self.cfg.paths.indexing_paths.clone(); + if tour.ui(ctx, &roots) { + self.tutorial = None; + self.cfg.ui.tutorial_seen = Some(true); + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } + } + + /// Re-open the tour from the Help tab. Nothing is written until it is + /// dismissed again, so a re-read costs the config nothing. + pub(crate) fn show_tutorial(&mut self) { + self.tutorial = Some(crate::tutorial::Tutorial::new()); + } + pub(super) fn clear_prompt_ui(&mut self, ctx: &egui::Context) { if !self.clear_prompt { return; @@ -225,8 +248,10 @@ impl QuickSearchApp { let Some(intent) = self.pending_nav else { return; }; - let dirty = (self.manage.is_dirty(), self.options.is_dirty(&self.cfg)); - let Some(source) = guard_source(intent, dirty.0, dirty.1) else { + let dirty = (self.manage.is_dirty(), self.settings.is_dirty(&self.cfg)); + // `self.tab` is the tab being left: the switch itself is what the + // intent is holding back. + let Some(source) = guard_source(intent, self.tab, dirty.0, dirty.1) else { // Inside the guard: the Discard-then-quit path sets // `quit_confirmed` and never returns to the close-request check, // so a warning living only there would be skipped. @@ -249,7 +274,7 @@ impl QuickSearchApp { Some(UnsavedChoice::Cancel) => self.pending_nav = None, Some(UnsavedChoice::Discard) => match source { UnsavedSource::Manage => self.manage.discard(), - UnsavedSource::Options => self.options.close_discard(), + UnsavedSource::Settings => self.settings.discard(), }, Some(UnsavedChoice::Apply) => { let ok = match source { @@ -263,11 +288,11 @@ impl QuickSearchApp { } None => true, }, - UnsavedSource::Options => match self.options.draft_config() { + UnsavedSource::Settings => match self.settings.draft_config() { Some(cfg) => { let ok = self.apply_new_config(ctx, cfg); if ok { - self.options.close_discard(); + self.settings.discard(); } ok } @@ -287,14 +312,7 @@ impl QuickSearchApp { pub(super) fn complete_nav(&mut self, ctx: &egui::Context, intent: NavIntent) { self.pending_nav = None; match intent { - NavIntent::SwitchTab(tab) => { - let was = self.tab; - self.tab = tab; - if tab == Tab::Duplicates && was != Tab::Duplicates { - self.start_duplicates_scan(ctx); - } - } - NavIntent::CloseOptions => self.options.close_discard(), + NavIntent::SwitchTab(tab) => self.switch_tab(ctx, tab), NavIntent::Quit => { self.quit_confirmed = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -308,7 +326,7 @@ impl QuickSearchApp { /// /// Unlike the centered `egui::Window` the other prompts use, `egui::Modal`'s /// backdrop blocks input to everything behind it — a click landing on the -/// tab strip or the Options ✕ would re-trigger or bypass the guard. +/// tab strip would re-trigger or bypass the guard. fn unsaved_changes_modal(ctx: &egui::Context, source: UnsavedSource) -> Option { let mut choice = None; let modal = egui::Modal::new(egui::Id::new("unsaved-guard")).show(ctx, |ui| { @@ -316,7 +334,7 @@ fn unsaved_changes_modal(ctx: &egui::Context, source: UnsavedSource) -> Option "The Manage Index tab has edits that have not been applied.", - UnsavedSource::Options => "The Options window has edits that have not been applied.", + UnsavedSource::Settings => "The Settings tab has edits that have not been applied.", }); ui.add_space(6.0); ui.horizontal(|ui| { @@ -527,7 +545,7 @@ mod tests { /// buttons fire, Esc cancels, and an untouched frame decides nothing. #[test] fn the_unsaved_modal_reports_each_choice() { - for source in [UnsavedSource::Manage, UnsavedSource::Options] { + for source in [UnsavedSource::Manage, UnsavedSource::Settings] { let ctx = egui::Context::default(); assert_eq!( modal_frame(&ctx, source, Vec::new()), diff --git a/crates/quicksearch-gui/src/app/security.rs b/crates/quicksearch-gui/src/app/security.rs index 66bc21a..612cab4 100644 --- a/crates/quicksearch-gui/src/app/security.rs +++ b/crates/quicksearch-gui/src/app/security.rs @@ -3,7 +3,9 @@ use super::*; -use crate::ui_util::centered_modal; +use quicksearch_core::security::SALT_LEN; + +use crate::ui_util::{centered_modal, hint}; /// The two-step security flow: collect a password (enable/change), derive /// its key off the UI thread, then confirm the mandatory index rebuild. @@ -16,7 +18,10 @@ pub(super) enum SecurityPrompt { change: bool, }, Deriving { - rx: mpsc::Receiver<(SecurityConfig, IndexKey)>, + rx: mpsc::Receiver, + /// Built with the salt the pending key is being derived from, so the + /// two always describe each other. + new_security: SecurityConfig, }, ConfirmRebuild { new_security: SecurityConfig, @@ -33,8 +38,69 @@ impl Drop for SecurityPrompt { } } +/// The show-key flow: confirm the password, re-derive from it, then reveal +/// the installed key. Nothing here can change the key or the config. +pub(super) enum KeyPrompt { + Confirm { + pw: String, + wrong: bool, + }, + Deriving { + rx: mpsc::Receiver, + }, + /// The key as displayed: `0x` followed by 64 hex digits. + Reveal { + display: String, + }, +} + +impl Drop for KeyPrompt { + fn drop(&mut self) { + match self { + KeyPrompt::Confirm { pw, .. } => pw.zeroize(), + KeyPrompt::Reveal { display } => display.zeroize(), + KeyPrompt::Deriving { .. } => {} + } + } +} + +/// Derive a key off the UI thread. The password is consumed and dropped +/// there, so it never outlives the derivation. +fn spawn_derive( + ctx: &egui::Context, + password: Zeroizing, + salt: [u8; SALT_LEN], +) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(); + let repaint = ctx.clone(); + std::thread::spawn(move || { + let key = derive_key(&password, &salt); + drop(password); + let _ = tx.send(key); + repaint.request_repaint(); + }); + rx +} + +/// Paint the spinner shown while a derivation runs. Not `centered_modal`: +/// this one hides its title bar. +fn deriving_window(ctx: &egui::Context) { + egui::Window::new("Deriving key") + .collapsible(false) + .resizable(false) + .title_bar(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Deriving key…"); + }); + }); + ctx.request_repaint_after(Duration::from_millis(100)); +} + impl QuickSearchApp { - /// Route a click in the Options window's Security block. Keychain + /// Route a click in the Settings tab's Security block. Keychain /// toggles act immediately; everything else opens the two-step flow. pub(super) fn handle_security_action(&mut self, action: SecurityAction) { match action { @@ -52,6 +118,12 @@ impl QuickSearchApp { new_key: None, }); } + SecurityAction::ShowKey => { + self.key_prompt = Some(KeyPrompt::Confirm { + pw: String::new(), + wrong: false, + }); + } SecurityAction::SetKeychain(remember) => { let db_path = self.cfg.resolved_database_path(); if remember { @@ -139,47 +211,25 @@ impl QuickSearchApp { } else if submit { let password = Zeroizing::new(std::mem::take(pw1)); pw2.zeroize(); - let remember = *remember; + let salt = generate_salt(); + let new_security = SecurityConfig { + password_protected: true, + salt: Some(salt_to_hex(&salt)), + use_keychain: *remember, + }; purge_security_field_state(ctx); - let (tx, rx) = mpsc::channel(); - let repaint = ctx.clone(); - std::thread::spawn(move || { - let salt = generate_salt(); - let key = derive_key(&password, &salt); - drop(password); - let new_security = SecurityConfig { - password_protected: true, - salt: Some(salt_to_hex(&salt)), - use_keychain: remember, - }; - let _ = tx.send((new_security, key)); - repaint.request_repaint(); - }); - self.security_prompt = Some(SecurityPrompt::Deriving { rx }); + let rx = spawn_derive(ctx, password, salt); + self.security_prompt = Some(SecurityPrompt::Deriving { rx, new_security }); } } - SecurityPrompt::Deriving { rx } => match rx.try_recv() { - Ok((new_security, key)) => { + SecurityPrompt::Deriving { rx, new_security } => match rx.try_recv() { + Ok(key) => { self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { - new_security, + new_security: new_security.clone(), new_key: Some(key), }); } - Err(mpsc::TryRecvError::Empty) => { - // Not `centered_modal`: this one hides its title bar. - egui::Window::new("Deriving key") - .collapsible(false) - .resizable(false) - .title_bar(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.spinner(); - ui.label("Deriving key…"); - }); - }); - ctx.request_repaint_after(Duration::from_millis(100)); - } + Err(mpsc::TryRecvError::Empty) => deriving_window(ctx), Err(mpsc::TryRecvError::Disconnected) => { self.config_error = Some("key derivation thread died".to_string()); self.security_prompt = None; @@ -222,6 +272,74 @@ impl QuickSearchApp { } } + /// Render the show-key flow (drawn with the other modals). Only ever + /// open while protection is on, so a salt and a process key both exist. + pub(super) fn key_prompt_ui(&mut self, ctx: &egui::Context) { + let Some(prompt) = &mut self.key_prompt else { + return; + }; + match prompt { + KeyPrompt::Confirm { pw, wrong } => { + let (submit, cancel) = confirm_key_modal(ctx, pw, *wrong); + if cancel { + self.key_prompt = None; // Drop impl zeroizes + purge_security_field_state(ctx); + } else if submit { + let password = Zeroizing::new(std::mem::take(pw)); + purge_security_field_state(ctx); + match self.cfg.security.salt_bytes() { + Ok(salt) => { + let rx = spawn_derive(ctx, password, salt); + self.key_prompt = Some(KeyPrompt::Deriving { rx }); + } + Err(e) => { + self.config_error = Some(e); + self.key_prompt = None; + } + } + } + } + KeyPrompt::Deriving { rx } => match rx.try_recv() { + Ok(key) => match db::process_key_hex() { + // What is shown is the installed key, not the derived + // one: it is the key that actually opens the index. + Some(installed) => match reveal_display(&installed, &key.to_hex()) { + Some(display) => { + self.key_prompt = Some(KeyPrompt::Reveal { display }); + } + None => { + self.key_prompt = Some(KeyPrompt::Confirm { + pw: String::new(), + wrong: true, + }); + } + }, + None => { + // Unreachable while protected — the gate always + // installs a key before the app starts. + self.config_error = + Some("no key installed; restart and unlock first".to_string()); + self.key_prompt = None; + } + }, + Err(mpsc::TryRecvError::Empty) => deriving_window(ctx), + Err(mpsc::TryRecvError::Disconnected) => { + self.config_error = Some("key derivation thread died".to_string()); + self.key_prompt = None; + } + }, + KeyPrompt::Reveal { display } => { + let (copy, close) = reveal_key_modal(ctx, display); + if copy { + ctx.copy_text(display.clone()); + } + if close { + self.key_prompt = None; // Drop impl zeroizes + } + } + } + } + /// Commit a confirmed security change: config, keychain, process key — /// in that order, before the rebuild so the fresh index is created /// under the new key (or none). @@ -255,11 +373,92 @@ impl QuickSearchApp { } } +/// Id of the show-key confirmation field, shared by the widget and the +/// purge below. +const SHOW_KEY_FIELD: &str = "show-key-pw"; + /// Drop egui's retained text-field state (buffer + undo history) for the /// password dialog fields. fn purge_security_field_state(ctx: &egui::Context) { ctx.data_mut(|d| { d.remove::(egui::Id::new("security-pw1")); d.remove::(egui::Id::new("security-pw2")); + d.remove::(egui::Id::new(SHOW_KEY_FIELD)); }); } + +/// The display form of the installed key, or `None` when the password the +/// user typed does not derive it. Both arguments come from +/// [`IndexKey::to_hex`], which is always lowercase, so a plain comparison is +/// exact; nothing secret is learned from its timing, since the caller +/// already holds the guess. +fn reveal_display(installed_hex: &str, derived_hex: &str) -> Option { + (installed_hex == derived_hex).then(|| format!("0x{}", installed_hex)) +} + +/// Paint the password confirmation; `(submit, cancel)` from its buttons. +/// Free, like the reveal below, so both halves of the flow can be rendered +/// against a bare context. +fn confirm_key_modal(ctx: &egui::Context, pw: &mut String, wrong: bool) -> (bool, bool) { + centered_modal(ctx, "Show database key", |ui| { + ui.set_max_width(360.0); + ui.label( + "Confirm your password to show the raw key the index is \ + encrypted with.", + ); + let field = ui.add( + egui::TextEdit::singleline(pw) + .id(egui::Id::new(SHOW_KEY_FIELD)) + .password(true) + .hint_text("Password") + .desired_width(240.0), + ); + // On open, and again after a wrong attempt. Never steals focus from + // something the user moved to themselves. + if ui.memory(|m| m.focused().is_none()) { + field.request_focus(); + } + if wrong { + ui.colored_label(ui.visuals().error_fg_color, "That password is not correct."); + } + ui.horizontal(|ui| { + let ok = !pw.is_empty(); + // Enter in the field submits, like the unlock screen. + let entered = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let submit = + ui.add_enabled(ok, egui::Button::new("Show key")).clicked() || (ok && entered); + (submit, ui.button("Cancel").clicked()) + }) + .inner + }) + .unwrap_or((false, false)) +} + +/// Paint the revealed key; `(copy, close)` from its buttons. A free function +/// rather than a method so it can be rendered against a bare context. +fn reveal_key_modal(ctx: &egui::Context, display: &str) -> (bool, bool) { + centered_modal(ctx, "Database key", |ui| { + ui.set_max_width(420.0); + ui.label( + "This is the SQLCipher raw key for the index. Anyone holding it can \ + read the index without the password.", + ); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(display).monospace()); + }); + ui.add_space(6.0); + ui.label(hint( + "Other SQLCipher tools take the key in this form. A copy stays on the \ + clipboard until something else replaces it.", + )); + ui.add_space(6.0); + ui.horizontal(|ui| (ui.button("Copy").clicked(), ui.button("Close").clicked())) + .inner + }) + .unwrap_or((false, false)) +} + +#[cfg(test)] +#[path = "security_tests.rs"] +mod tests; diff --git a/crates/quicksearch-gui/src/app/security_tests.rs b/crates/quicksearch-gui/src/app/security_tests.rs new file mode 100644 index 0000000..930f4b6 --- /dev/null +++ b/crates/quicksearch-gui/src/app/security_tests.rs @@ -0,0 +1,169 @@ +use super::*; + +use crate::test_ui::{click_at, painted_text, painted_text_center, raw_input}; + +const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0); + +/// Two 64-digit keys that differ, in the lowercase form [`IndexKey::to_hex`] +/// produces. +const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const OTHER: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +/// Two passes: an `egui::Window` is measured on its first frame and placed on +/// the next, so a single pass paints nothing to read back. Same shape as the +/// verify modal's test frame. +fn frame( + ctx: &egui::Context, + display: &str, + events: Vec, +) -> (egui::FullOutput, (bool, bool)) { + let _ = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| { + reveal_key_modal(ctx, display); + }); + let mut buttons = (false, false); + let out = ctx.run(raw_input(SCREEN, events), |ctx| { + buttons = reveal_key_modal(ctx, display); + }); + (out, buttons) +} + +/// The confirmation half, in the same two passes. +fn confirm_frame( + ctx: &egui::Context, + pw: &mut String, + wrong: bool, + events: Vec, +) -> (egui::FullOutput, (bool, bool)) { + let _ = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| { + confirm_key_modal(ctx, pw, wrong); + }); + let mut buttons = (false, false); + let out = ctx.run(raw_input(SCREEN, events), |ctx| { + buttons = confirm_key_modal(ctx, pw, wrong); + }); + (out, buttons) +} + +/// An empty field cannot submit: there is nothing to derive from, and a +/// dead button says so more clearly than a rejected attempt would. +#[test] +fn the_confirmation_will_not_submit_an_empty_password() { + let ctx = egui::Context::default(); + let mut pw = String::new(); + let (out, buttons) = confirm_frame(&ctx, &mut pw, false, Vec::new()); + assert_eq!(buttons, (false, false)); + + let pos = painted_text_center(&out, "Show key").expect("no submit button painted"); + let (_, buttons) = confirm_frame(&ctx, &mut pw, false, click_at(pos)); + assert!(!buttons.0, "an empty password was submitted"); +} + +#[test] +fn the_confirmation_submits_a_typed_password_and_cancels_on_request() { + let ctx = egui::Context::default(); + let mut pw = "hunter2".to_string(); + let (out, _) = confirm_frame(&ctx, &mut pw, false, Vec::new()); + assert!( + !painted_text(&out).contains(&pw), + "the password was painted in the clear: {:?}", + painted_text(&out) + ); + + let submit = painted_text_center(&out, "Show key").expect("no submit button painted"); + let (_, buttons) = confirm_frame(&ctx, &mut pw, false, click_at(submit)); + assert_eq!(buttons, (true, false)); + + let cancel = painted_text_center(&out, "Cancel").expect("no cancel button painted"); + let (_, buttons) = confirm_frame(&ctx, &mut pw, false, click_at(cancel)); + assert_eq!(buttons, (false, true)); +} + +/// A retry has to say why it is asking again, or it reads as the dialog +/// having ignored the first attempt. +#[test] +fn a_retry_says_the_password_was_wrong() { + let ctx = egui::Context::default(); + let mut pw = String::new(); + let (quiet, _) = confirm_frame(&ctx, &mut pw, false, Vec::new()); + assert!( + !painted_text(&quiet) + .iter() + .any(|t| t.contains("not correct")), + "the first attempt was called wrong before it was made" + ); + + let (out, _) = confirm_frame(&ctx, &mut pw, true, Vec::new()); + assert!( + painted_text(&out) + .iter() + .any(|t| t.contains("That password is not correct")), + "{:?}", + painted_text(&out) + ); +} + +/// The right password derives the installed key, and the key is shown in the +/// `0x` form other SQLCipher tools take. +#[test] +fn the_matching_password_reveals_the_installed_key() { + assert_eq!(reveal_display(KEY, KEY), Some(format!("0x{KEY}"))); +} + +/// A wrong password derives some other key. Nothing about the real one may +/// leak from the attempt, so the caller gets no display string at all. +#[test] +fn a_password_that_derives_another_key_reveals_nothing() { + assert_eq!(reveal_display(KEY, OTHER), None); + assert_eq!(reveal_display(KEY, ""), None); + // A prefix must not pass: the whole key is compared, not the start of it. + assert_eq!(reveal_display(KEY, &KEY[..62]), None); + // Both sides come from `to_hex`, which is always lowercase, so an + // uppercase spelling is a mismatch rather than a value to normalise. + assert_eq!(reveal_display(KEY, &KEY.to_uppercase()), None); +} + +#[test] +fn the_reveal_shows_the_key_and_what_holding_it_means() { + let ctx = egui::Context::default(); + let display = format!("0x{KEY}"); + let painted = painted_text(&frame(&ctx, &display, Vec::new()).0); + + assert!( + painted.contains(&display), + "the key itself is not on screen: {painted:?}" + ); + assert!( + painted + .iter() + .any(|t| t.contains("read the index without the password")), + "no warning about what the key is: {painted:?}" + ); + assert!(painted.contains(&"Copy".to_string()), "{painted:?}"); + assert!(painted.contains(&"Close".to_string()), "{painted:?}"); +} + +#[test] +fn both_of_the_reveal_buttons_report_their_click() { + let display = format!("0x{KEY}"); + for (label, expected) in [("Copy", (true, false)), ("Close", (false, true))] { + let ctx = egui::Context::default(); + let (out, _) = frame(&ctx, &display, Vec::new()); + let pos = + painted_text_center(&out, label).unwrap_or_else(|| panic!("no {label} button painted")); + let (_, buttons) = frame(&ctx, &display, click_at(pos)); + assert_eq!( + buttons, expected, + "clicking {label} reported the wrong pair" + ); + } +} + +/// The displayed string is the whole key and nothing else: a truncated or +/// annotated form would be pasted into other tools and fail there. +#[test] +fn the_display_form_is_the_prefix_and_the_whole_key() { + let display = reveal_display(KEY, KEY).expect("a match reveals"); + assert_eq!(display.len(), 66); + assert!(display.starts_with("0x")); + assert!(display[2..].bytes().all(|b| b.is_ascii_hexdigit())); +} diff --git a/crates/quicksearch-gui/src/app/status_bar.rs b/crates/quicksearch-gui/src/app/status_bar.rs index 341805b..9e7fe1a 100644 --- a/crates/quicksearch-gui/src/app/status_bar.rs +++ b/crates/quicksearch-gui/src/app/status_bar.rs @@ -261,7 +261,7 @@ mod tests { walked, walk_total, extracted: 0, - extract_total: 0, + extract_total: None, current_file: None, active_workers: 2, total_workers: 4, @@ -299,11 +299,11 @@ mod tests { let mut extracting = root(RootPhase::Extracting, 1_000, None); extracting.extracted = 200; - extracting.extract_total = 800; + extracting.extract_total = Some(800); extracting.active_workers = 3; let mut done = root(RootPhase::Done, 500, None); done.extracted = 500; - done.extract_total = 500; + done.extract_total = Some(500); done.active_workers = 0; done.total_workers = 0; assert_eq!( diff --git a/crates/quicksearch-gui/src/app/tests.rs b/crates/quicksearch-gui/src/app/tests.rs index c84b081..cb5541e 100644 --- a/crates/quicksearch-gui/src/app/tests.rs +++ b/crates/quicksearch-gui/src/app/tests.rs @@ -30,61 +30,96 @@ fn a_stale_draft_cannot_revert_the_indexing_mode_or_security() { } /// The guard decision table for leaving a tab, however it is asked for. +/// Both draft-backed tabs guard their own departure, and neither answers for +/// the other. #[test] -fn leaving_a_dirty_manage_tab_is_guarded_however_it_is_asked_for() { - assert!(switch_needs_guard(Tab::Manage, true, false)); - assert!( - !switch_needs_guard(Tab::Manage, false, false), - "a clean editor has nothing to ask about" - ); - assert!( - !switch_needs_guard(Tab::Manage, true, true), - "one held navigation at a time" - ); +fn leaving_a_dirty_editor_tab_is_guarded_however_it_is_asked_for() { + // (the tab, whether *its* editor is the dirty one in the pair below) + for (tab, manage_dirty, settings_dirty) in + [(Tab::Manage, true, false), (Tab::Settings, false, true)] + { + assert!( + switch_needs_guard(tab, manage_dirty, settings_dirty, false), + "{tab:?} must guard its own unapplied edits" + ); + assert!( + !switch_needs_guard(tab, false, false, false), + "{tab:?}: a clean editor has nothing to ask about" + ); + assert!( + !switch_needs_guard(tab, manage_dirty, settings_dirty, true), + "{tab:?}: one held navigation at a time" + ); + assert!( + !switch_needs_guard(tab, !manage_dirty, !settings_dirty, false), + "{tab:?} must not answer for the other editor" + ); + } for tab in [Tab::Search, Tab::Duplicates, Tab::Logs, Tab::Help] { assert!( - !switch_needs_guard(tab, true, false), + !switch_needs_guard(tab, true, true, false), "{tab:?} holds no unapplied edits of its own" ); } } #[test] -fn guard_source_orders_quit_prompts_options_first() { +fn guard_source_orders_quit_prompts_settings_first() { use super::NavIntent::*; - let tab = SwitchTab(Tab::Search); + let leave = SwitchTab(Tab::Search); - assert_eq!(guard_source(tab, true, true), Some(UnsavedSource::Manage)); - assert_eq!(guard_source(tab, true, false), Some(UnsavedSource::Manage)); + // A switch asks about the tab being left, and only about that one. assert_eq!( - guard_source(tab, false, true), + guard_source(leave, Tab::Manage, true, true), + Some(UnsavedSource::Manage) + ); + assert_eq!( + guard_source(leave, Tab::Manage, true, false), + Some(UnsavedSource::Manage) + ); + assert_eq!( + guard_source(leave, Tab::Manage, false, true), None, - "options guard its own close" - ); - assert_eq!(guard_source(tab, false, false), None); - - assert_eq!( - guard_source(CloseOptions, true, true), - Some(UnsavedSource::Options) + "the Settings draft is not what leaving Manage disturbs" ); assert_eq!( - guard_source(CloseOptions, false, true), - Some(UnsavedSource::Options) + guard_source(leave, Tab::Settings, true, true), + Some(UnsavedSource::Settings) ); assert_eq!( - guard_source(CloseOptions, true, false), + guard_source(leave, Tab::Settings, false, true), + Some(UnsavedSource::Settings) + ); + assert_eq!( + guard_source(leave, Tab::Settings, true, false), None, - "manage guards tab switches" + "the Manage draft is not what leaving Settings disturbs" ); - assert_eq!(guard_source(CloseOptions, false, false), None); + for tab in [Tab::Search, Tab::Duplicates, Tab::Logs, Tab::Help] { + assert_eq!( + guard_source(leave, tab, true, true), + None, + "{tab:?} stages nothing, so leaving it asks nothing" + ); + } - assert_eq!(guard_source(Quit, true, true), Some(UnsavedSource::Options)); - assert_eq!( - guard_source(Quit, false, true), - Some(UnsavedSource::Options) - ); - assert_eq!(guard_source(Quit, true, false), Some(UnsavedSource::Manage)); - assert_eq!(guard_source(Quit, false, false), None); + // Quit asks about both, Settings first. + for from in [Tab::Search, Tab::Manage, Tab::Settings] { + assert_eq!( + guard_source(Quit, from, true, true), + Some(UnsavedSource::Settings), + "{from:?}: quitting asks about Settings before Manage" + ); + assert_eq!( + guard_source(Quit, from, false, true), + Some(UnsavedSource::Settings) + ); + assert_eq!( + guard_source(Quit, from, true, false), + Some(UnsavedSource::Manage) + ); + assert_eq!(guard_source(Quit, from, false, false), None); + } } /// Only a Quit during a running reconcile warns; a tab switch does not end @@ -95,10 +130,25 @@ fn only_quitting_during_a_reconcile_warns() { assert!(quit_needs_reconcile_warning(Quit, true)); assert!(!quit_needs_reconcile_warning(Quit, false)); assert!(!quit_needs_reconcile_warning(SwitchTab(Tab::Search), true)); - assert!(!quit_needs_reconcile_warning(CloseOptions, true)); + assert!(!quit_needs_reconcile_warning( + SwitchTab(Tab::Settings), + true + )); } -/// The two values the Options window writes, plus hand-edited variants. +/// Every tab is placed on exactly one side of the guard, so a tab added +/// later cannot quietly inherit "stages nothing". +#[test] +fn only_the_two_draft_backed_tabs_have_an_editor() { + assert_eq!(tab_editor(Tab::Manage), Some(UnsavedSource::Manage)); + assert_eq!(tab_editor(Tab::Settings), Some(UnsavedSource::Settings)); + for tab in [Tab::Search, Tab::Duplicates, Tab::Logs, Tab::Help] { + assert_eq!(tab_editor(tab), None, "{tab:?} saves as it goes"); + } +} + +/// The two values the Settings tab's color-scheme box writes, plus +/// hand-edited variants. #[test] fn only_light_is_light() { assert_eq!(theme_for("light"), egui::Theme::Light); diff --git a/crates/quicksearch-gui/src/app/verify.rs b/crates/quicksearch-gui/src/app/verify.rs new file mode 100644 index 0000000..864c6af --- /dev/null +++ b/crates/quicksearch-gui/src/app/verify.rs @@ -0,0 +1,239 @@ +//! Byte-for-byte verification of one duplicate group, and the modal that +//! reports it. +//! +//! The Duplicates tab groups by a hash of each file's size and head, which is +//! all the indexer ever reads (see [`quicksearch_core::verify`]). This is the +//! second opinion, asked for one group at a time, and it exists because the +//! action it precedes is usually deletion. + +use super::*; + +use std::path::PathBuf; + +use quicksearch_core::verify::{MemberVerdict, VerifyReport, VerifyUpdate}; + +use crate::format::{group_thousands, human_size}; +use crate::ui_util::{centered_modal, hint, progress_bar}; + +const MODAL_WIDTH: f32 = 560.0; + +pub(crate) enum VerifyState { + Running { + bytes_read: u64, + /// Zero until the worker's first progress update lands, which is what + /// puts the bar in its indeterminate state to begin with. + bytes_total: u64, + }, + Done(Box), + Cancelled, +} + +pub(crate) struct VerifyModal { + pub paths: Vec, + pub state: VerifyState, +} + +impl VerifyModal { + pub(crate) fn new(paths: Vec) -> VerifyModal { + VerifyModal { + paths, + state: VerifyState::Running { + bytes_read: 0, + bytes_total: 0, + }, + } + } +} + +/// One line of the report: what happened to `path`, in the words the modal +/// paints. Split out from the rendering so the wording is testable without a +/// frame. +pub(crate) fn verdict_line(verdict: &MemberVerdict, reference: bool) -> 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::LengthDiffers { len, reference_len } => format!( + "size differs: {} against {}", + human_size(*len), + human_size(*reference_len) + ), + MemberVerdict::Unreadable(e) => format!("could not be read — {e}"), + } +} + +/// The headline the report earns. +pub(crate) fn summary_line(report: &VerifyReport) -> String { + let total = report.verdicts.len(); + if report.reference.is_none() { + return "None of these files could be read.".to_string(); + } + let differing = report.differing(); + if differing == 0 { + return match total { + 0 | 1 => "Nothing to compare: the group holds one file.".to_string(), + n => format!("All {n} files are byte-for-byte identical."), + }; + } + format!( + "{} of {} files {} not identical.", + differing, + total, + if differing == 1 { "is" } else { "are" } + ) +} + +impl QuickSearchApp { + /// Drain the worker and fold its updates into the modal. + pub(super) fn drain_verify(&mut self) { + use std::sync::mpsc::TryRecvError; + let Some(job) = &self.backend.verify_job else { + return; + }; + let mut finished = false; + loop { + match job.rx.try_recv() { + Ok(VerifyUpdate::Progress { + bytes_read: read, + bytes_total: total, + }) => { + if let Some(modal) = &mut self.verify { + modal.state = VerifyState::Running { + bytes_read: read, + bytes_total: total, + }; + } + } + Ok(VerifyUpdate::Done(report)) => { + if let Some(modal) = &mut self.verify { + modal.state = VerifyState::Done(Box::new(report)); + } + finished = true; + break; + } + Ok(VerifyUpdate::Cancelled) => { + if let Some(modal) = &mut self.verify { + modal.state = VerifyState::Cancelled; + } + finished = true; + break; + } + Err(TryRecvError::Empty) => break, + // The worker died without a terminal update. Nothing else can + // arrive, so say so rather than spinning on an empty channel. + Err(TryRecvError::Disconnected) => { + if let Some(modal) = &mut self.verify { + if matches!(modal.state, VerifyState::Running { .. }) { + modal.state = VerifyState::Cancelled; + } + } + finished = true; + break; + } + } + } + if finished { + self.backend.verify_job = None; + } + } + + pub(super) fn verify_modal_ui(&mut self, ctx: &egui::Context) { + let Some(modal) = &self.verify else { + return; + }; + if verify_modal(ctx, modal) { + self.backend.cancel_verify(); + self.verify = None; + } + } +} + +/// Paint the modal; `true` when its dismiss button was clicked. A free +/// function rather than a method so it can be rendered against a bare +/// context, without an app and the index behind it. +pub(crate) fn verify_modal(ctx: &egui::Context, modal: &VerifyModal) -> bool { + centered_modal(ctx, "Verify duplicates", |ui| { + ui.set_max_width(MODAL_WIDTH); + match &modal.state { + VerifyState::Running { + bytes_read, + bytes_total, + } => { + ui.label(format!( + "Comparing {} files byte for byte…", + modal.paths.len() + )); + // No denominator until the first update lands, which is what + // the indeterminate bar is for. + let fraction = + (*bytes_total > 0).then(|| (*bytes_read as f64 / *bytes_total as f64) as f32); + progress_bar(ui, fraction, MODAL_WIDTH); + ui.label(hint(match bytes_total { + 0 => format!("{} read", human_size(*bytes_read)), + total => format!("{} of {}", human_size(*bytes_read), human_size(*total)), + })); + ui.add_space(6.0); + ui.horizontal(|ui| ui.button("Cancel").clicked()).inner + } + VerifyState::Cancelled => { + ui.label("Verification cancelled."); + ui.add_space(6.0); + ui.horizontal(|ui| ui.button("Close").clicked()).inner + } + VerifyState::Done(report) => { + let p = crate::color::palette(ui.visuals().dark_mode); + let identical = report.all_identical() && report.reference.is_some(); + let color = if identical { + p.green + } else { + ui.visuals().error_fg_color + }; + ui.colored_label(color, summary_line(report)); + if !identical { + ui.label(hint( + "Files are grouped by size and how they begin, which is all \ + indexing reads. This compared every byte.", + )); + } + ui.add_space(6.0); + // Listed even when everything matched: it is the record of + // what was actually read. + egui::ScrollArea::vertical() + .max_height(260.0) + .auto_shrink([false, true]) + .show(ui, |ui| { + for (i, path) in modal.paths.iter().enumerate() { + let Some(verdict) = report.verdicts.get(i) else { + continue; + }; + let is_reference = report.reference == Some(i); + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(path.display().to_string()).monospace(), + ); + let line = verdict_line(verdict, is_reference); + if verdict.is_identical() { + ui.label(hint(line)); + } else { + ui.colored_label( + ui.visuals().error_fg_color, + egui::RichText::new(line).small(), + ); + } + }); + } + }); + ui.add_space(6.0); + ui.label(hint(format!("{} read", human_size(report.bytes_read)))); + ui.horizontal(|ui| ui.button("Close").clicked()).inner + } + } + }) + .unwrap_or(false) +} + +#[cfg(test)] +#[path = "verify_tests.rs"] +mod tests; diff --git a/crates/quicksearch-gui/src/app/verify_tests.rs b/crates/quicksearch-gui/src/app/verify_tests.rs new file mode 100644 index 0000000..3e46ba3 --- /dev/null +++ b/crates/quicksearch-gui/src/app/verify_tests.rs @@ -0,0 +1,216 @@ +use super::*; + +use quicksearch_core::verify::MemberVerdict::{ + DiffersAt, Identical, LengthDiffers, Unreadable as CannotRead, +}; + +use crate::test_ui::{click_at, painted_text, painted_text_center, raw_input}; + +const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0); + +fn modal(state: VerifyState, n: usize) -> VerifyModal { + VerifyModal { + paths: (0..n) + .map(|i| PathBuf::from(format!("/d/copy{i}.bin"))) + .collect(), + state, + } +} + +fn report(verdicts: Vec, bytes_read: u64) -> VerifyState { + VerifyState::Done(Box::new(VerifyReport { + reference: Some(0), + verdicts, + bytes_read, + })) +} + +/// Two passes: an `egui::Window` is measured on its first frame and placed on +/// the next, so a single pass paints nothing to read back. +fn frame( + ctx: &egui::Context, + m: &VerifyModal, + events: Vec, +) -> (egui::FullOutput, bool) { + let _ = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| { + verify_modal(ctx, m); + }); + let mut closed = false; + let out = ctx.run(raw_input(SCREEN, events), |ctx| { + closed = verify_modal(ctx, m); + }); + (out, closed) +} + +#[test] +fn a_run_in_progress_says_what_it_is_doing_and_offers_a_way_out() { + let ctx = egui::Context::default(); + let m = modal( + VerifyState::Running { + bytes_read: 5 * 1024 * 1024, + bytes_total: 20 * 1024 * 1024, + }, + 3, + ); + let painted = painted_text(&frame(&ctx, &m, Vec::new()).0); + assert!( + painted.contains(&"Comparing 3 files byte for byte…".to_string()), + "{painted:?}" + ); + assert!( + painted.iter().any(|t| t.contains("5.2 MB of 21.0 MB")), + "no byte counter: {painted:?}" + ); + assert!(painted.contains(&"Cancel".to_string()), "{painted:?}"); +} + +/// Before the worker's first update there is no denominator, so the modal +/// reports what it has rather than dividing by zero. +#[test] +fn a_run_with_no_denominator_yet_still_reports() { + let ctx = egui::Context::default(); + let m = modal( + VerifyState::Running { + bytes_read: 0, + bytes_total: 0, + }, + 2, + ); + let painted = painted_text(&frame(&ctx, &m, Vec::new()).0); + assert!( + painted.iter().any(|t| t.contains("0 B read")), + "{painted:?}" + ); +} + +#[test] +fn a_clean_result_says_so_and_lists_what_was_read() { + let ctx = egui::Context::default(); + let m = modal(report(vec![Identical, Identical, Identical], 300), 3); + let painted = painted_text(&frame(&ctx, &m, Vec::new()).0); + assert!( + painted.contains(&"All 3 files are byte-for-byte identical.".to_string()), + "{painted:?}" + ); + assert!(painted.contains(&"/d/copy2.bin".to_string()), "{painted:?}"); + assert!( + painted.contains(&"compared against".to_string()), + "{painted:?}" + ); + assert!(painted.contains(&"Close".to_string()), "{painted:?}"); +} + +/// The case the feature exists for: same size, same head, different bytes. +#[test] +fn a_mismatch_names_the_file_and_the_offset() { + let ctx = egui::Context::default(); + let m = modal(report(vec![Identical, DiffersAt(1_234_567)], 2), 2); + let painted = painted_text(&frame(&ctx, &m, Vec::new()).0); + assert!( + painted.contains(&"1 of 2 files is not identical.".to_string()), + "{painted:?}" + ); + assert!( + painted.contains(&"differs at byte 1,234,567".to_string()), + "{painted:?}" + ); + assert!(painted.contains(&"/d/copy1.bin".to_string()), "{painted:?}"); +} + +#[test] +fn a_cancelled_run_says_so_rather_than_showing_a_verdict() { + let ctx = egui::Context::default(); + let m = modal(VerifyState::Cancelled, 2); + let painted = painted_text(&frame(&ctx, &m, Vec::new()).0); + assert!( + painted.contains(&"Verification cancelled.".to_string()), + "{painted:?}" + ); + assert!( + !painted.iter().any(|t| t.contains("identical")), + "a cancelled run claimed a verdict: {painted:?}" + ); +} + +/// Every state's dismiss button reports the dismissal, whatever it is called. +#[test] +fn both_dismiss_buttons_report_the_dismissal() { + for (label, state) in [ + ( + "Cancel", + VerifyState::Running { + bytes_read: 1, + bytes_total: 2, + }, + ), + ("Close", VerifyState::Cancelled), + ("Close", report(vec![Identical, Identical], 8)), + ] { + let ctx = egui::Context::default(); + let m = modal(state, 2); + let (out, _) = frame(&ctx, &m, Vec::new()); + let pos = + painted_text_center(&out, label).unwrap_or_else(|| panic!("no {label} button painted")); + let (_, closed) = frame(&ctx, &m, click_at(pos)); + assert!(closed, "clicking {label} did not dismiss the modal"); + } +} + +#[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"); + assert_eq!( + verdict_line(&DiffersAt(1_048_576), false), + "differs at byte 1,048,576" + ); + assert_eq!( + verdict_line( + &LengthDiffers { + len: 2048, + reference_len: 1024 + }, + false + ), + "size differs: 2.0 KB against 1.0 KB" + ); + assert!(verdict_line(&CannotRead("/d/x: denied".into()), false) + .contains("could not be read — /d/x: denied")); +} + +#[test] +fn the_summary_counts_what_it_found() { + let of = |verdicts: Vec, reference| { + summary_line(&VerifyReport { + reference, + verdicts, + bytes_read: 0, + }) + }; + assert_eq!( + of(vec![Identical, Identical], Some(0)), + "All 2 files are byte-for-byte identical." + ); + assert_eq!( + of(vec![Identical, DiffersAt(4)], Some(0)), + "1 of 2 files is not identical." + ); + assert_eq!( + of(vec![Identical, DiffersAt(4), DiffersAt(9)], Some(0)), + "2 of 3 files are not identical." + ); + // A group of one cannot disagree with itself, and saying "all 1 files are + // identical" would read as an answer to a question nobody asked. + assert_eq!( + of(vec![Identical], Some(0)), + "Nothing to compare: the group holds one file." + ); + assert_eq!( + of( + vec![CannotRead("gone".into()), CannotRead("gone".into())], + None + ), + "None of these files could be read." + ); +} diff --git a/crates/quicksearch-gui/src/backend.rs b/crates/quicksearch-gui/src/backend.rs index a9b7a2f..b82f20d 100644 --- a/crates/quicksearch-gui/src/backend.rs +++ b/crates/quicksearch-gui/src/backend.rs @@ -6,22 +6,48 @@ //! Every core thread wakes the UI through `ctx.request_repaint()`, which is //! what makes polling enough. //! -//! The duplicates scan is the only throwaway thread, and it fires on a user -//! action, not a timer: a thread per refresh opens its own connection — a -//! page cache and an allocator arena glibc never gives back. +//! The duplicates scan and the byte-for-byte verification of one of its +//! groups are the throwaway threads, and both fire on a user action rather +//! than a timer: a thread per refresh opens its own connection — a page cache +//! and an allocator arena glibc never gives back. The verification opens no +//! connection at all, but it can hold a large group's worth of file handles, +//! so it carries a cancel flag and shutdown raises it. +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc}; use quicksearch_core::config::Config; use quicksearch_core::coordinator::IndexCoordinator; +use quicksearch_core::live::{LiveUpdate, LiveWatcher}; use quicksearch_core::search::{DuplicateGroup, SearchService, SearchUpdate}; use quicksearch_core::shutdown; +use quicksearch_core::verify::{verify_identical, VerifyUpdate}; + +/// A duplicate group being read through. The thread is detached and owns +/// nothing the app needs back, so cancelling is just raising the flag: the +/// worker notices between chunks and drops the receiver's other end. +pub struct VerifyJob { + pub rx: mpsc::Receiver, + cancel: Arc, +} + +impl VerifyJob { + pub fn cancel(&self) { + self.cancel.store(true, Ordering::Relaxed); + } +} pub struct Backend { pub coordinator: Arc, pub search: Option, pub search_rx: mpsc::Receiver, pub dup_job: Option, String>>>, + pub verify_job: Option, + /// Watches the results currently on screen; see [`quicksearch_core::live`]. + /// `None` only after [`Backend::shutdown`]. + pub live: Option, + pub live_rx: mpsc::Receiver, } impl Backend { @@ -44,14 +70,49 @@ impl Backend { Arc::new(move || repaint_ctx.request_repaint()), ); + let live_ctx = ctx.clone(); + let (live, live_rx) = LiveWatcher::start(Arc::new(move || live_ctx.request_repaint())); + Ok(Backend { coordinator, search: Some(search), search_rx, dup_job: None, + verify_job: None, + live: Some(live), + live_rx, }) } + /// Point the live watcher at the rows currently on screen, or clear it + /// with an empty `targets`. + pub fn watch_live( + &self, + query: &str, + targets: Vec, + config: &Config, + ) { + let Some(live) = &self.live else { return }; + if targets.is_empty() { + live.clear(); + } else { + live.watch(query, targets, config); + } + } + + /// Ask the coordinator to bring the index in line with these paths — the + /// files the live watcher has just read from disk on the frontend's + /// behalf, so the index does not drift from what is on screen. + pub fn reindex_live_paths(&self, paths: Vec) { + self.coordinator.update_paths(paths); + } + + pub fn clear_live(&self) { + if let Some(live) = &self.live { + live.clear(); + } + } + /// `None` only after [`Backend::shutdown`], i.e. during teardown frames. pub fn search(&self) -> Option<&SearchService> { self.search.as_ref() @@ -70,12 +131,46 @@ impl Backend { self.dup_job = Some(rx); } + /// Read a duplicate group through on a worker thread, comparing every + /// member against the first byte for byte. Replaces any run already going. + pub fn start_verify(&mut self, paths: Vec, ctx: egui::Context) { + if let Some(job) = &self.verify_job { + job.cancel(); + } + let (tx, rx) = mpsc::channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = cancel.clone(); + std::thread::spawn(move || { + verify_identical(&paths, &worker_cancel, &mut |update| { + // A closed receiver means the app moved on; the cancel flag + // is what stops the work, so there is nothing to do here. + let _ = tx.send(update); + ctx.request_repaint(); + }); + }); + self.verify_job = Some(VerifyJob { rx, cancel }); + } + + /// Stop a verification and forget it. The worker sees the flag between + /// chunks and exits on its own. + pub fn cancel_verify(&mut self) { + if let Some(job) = self.verify_job.take() { + job.cancel(); + } + } + /// Join the search worker and stop the coordinator. Called once from /// `on_exit`. pub fn shutdown(&mut self) { + // Detached and holding open file handles: the flag is what makes a + // verification of a slow, large group let go on the way out. + self.cancel_verify(); if let Some(search) = self.search.take() { search.shutdown(); } + if let Some(mut live) = self.live.take() { + live.stop(); + } self.coordinator.shutdown(); } } diff --git a/crates/quicksearch-gui/src/capture.rs b/crates/quicksearch-gui/src/capture.rs index 36fcd50..2394a3e 100644 --- a/crates/quicksearch-gui/src/capture.rs +++ b/crates/quicksearch-gui/src/capture.rs @@ -82,7 +82,7 @@ pub(crate) struct CaptureDriver { /// Screenshot in flight: requested, PNG not yet written. shot: Option, rec: Option, - /// Match-cell row the pointer is pinned to (`hover_match`), and the + /// Content Match cell row the pointer is pinned to (`hover_match`), and the /// on-screen position it resolved to on the last rendered frame. hover: Option, hover_pos: Option, diff --git a/crates/quicksearch-gui/src/capture/script.rs b/crates/quicksearch-gui/src/capture/script.rs index 5f48102..c48edef 100644 --- a/crates/quicksearch-gui/src/capture/script.rs +++ b/crates/quicksearch-gui/src/capture/script.rs @@ -11,9 +11,11 @@ use crate::app::Tab; /// window INT INT # resize to width x height, in the same /// # logical points as the startup size /// hover_match INT # pin the pointer over the Nth visible -/// # Match cell (0-based) until hover_off +/// # Content Match cell (0-based) until +/// # hover_off. Counts every visible row, +/// # including those showing a dash. /// hover_off # release the pinned pointer -/// tab (search|manage|duplicates|logs|help) +/// tab (search|manage|duplicates|logs|help|settings) /// wait_index_running [max INT] # caps in ms; a capped wait cannot fail /// wait_index_idle [max INT] /// wait_search_done [max INT] @@ -187,9 +189,11 @@ fn parse_line(tokens: &[Token], line_no: usize) -> Result, ParseErro "duplicates" => Tab::Duplicates, "logs" => Tab::Logs, "help" => Tab::Help, + "settings" => Tab::Settings, other => { return Err(err(format!( - "unknown tab {other:?}: expected search, manage, duplicates, logs or help" + "unknown tab {other:?}: expected search, manage, duplicates, \ + logs, help or settings" ))); } }), @@ -302,6 +306,7 @@ mod tests { tab duplicates tab logs tab help + tab settings wait_index_running wait_index_running max 15000 wait_index_idle max 13000 @@ -335,6 +340,7 @@ mod tests { Cmd::Tab(Tab::Duplicates), Cmd::Tab(Tab::Logs), Cmd::Tab(Tab::Help), + Cmd::Tab(Tab::Settings), Cmd::WaitIndexRunning { max_ms: None }, Cmd::WaitIndexRunning { max_ms: Some(15000) @@ -447,7 +453,7 @@ mod tests { #[test] fn unknown_tab_and_bad_cps_are_rejected() { - assert!(parse_err("tab settings").msg.contains("unknown tab")); + assert!(parse_err("tab preferences").msg.contains("unknown tab")); assert!(parse_err(r#"type "x" cps 0"#).msg.contains("positive")); assert!(parse_err(r#"type "x" cps -3"#).msg.contains("positive")); } diff --git a/crates/quicksearch-gui/src/duplicates_tab.rs b/crates/quicksearch-gui/src/duplicates_tab.rs index e2feba7..62402d8 100644 --- a/crates/quicksearch-gui/src/duplicates_tab.rs +++ b/crates/quicksearch-gui/src/duplicates_tab.rs @@ -21,8 +21,18 @@ pub struct DuplicatesTab { #[derive(Default)] pub struct DuplicatesActions { pub refresh: bool, + /// Every member of one group, to be read through and compared byte for + /// byte. Group-scoped whichever row it was asked for from: the question + /// "is this row really a duplicate" is a question about the group. + pub verify: Option>, } +/// The entry both context menus carry. Named for what it settles, since the +/// grouping itself never claimed more than a shared size and head. +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."; + impl DuplicatesTab { pub fn new() -> DuplicatesTab { DuplicatesTab { @@ -30,7 +40,10 @@ impl DuplicatesTab { } } - pub fn ui(&mut self, ui: &mut egui::Ui) -> DuplicatesActions { + /// `verify_open` is the verification window being up — running or showing + /// a result. There is one of it, so the entry greys out rather than + /// replacing what someone is reading. + pub fn ui(&mut self, ui: &mut egui::Ui, verify_open: bool) -> DuplicatesActions { let mut actions = DuplicatesActions::default(); ui.horizontal(|ui| { @@ -84,34 +97,48 @@ impl DuplicatesTab { human_size(group.redundant_size.max(0) as u64), human_size(group.total_size.max(0) as u64), ); - 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() { - platform::open_file(path); - } - response.context_menu(|ui| { - if ui.button("Open").clicked() { + 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() { platform::open_file(path); - ui.close(); - } - if ui.button("Open containing folder").clicked() { - platform::reveal_in_folder(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)); + } + }); }); - }); - } - }); + } + }); + // Also on the group's own row: the question is + // about the group, and the rows it is about 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)); + } + }); } }); crate::ui_util::more_below_hint(ui, &scroll); @@ -120,3 +147,24 @@ impl DuplicatesTab { actions } } + +/// The shared menu entry. Returns whether it was clicked, and closes the menu +/// when it was. +fn verify_entry(ui: &mut egui::Ui, open: bool) -> bool { + let clicked = ui + .add_enabled(!open, egui::Button::new(VERIFY_LABEL)) + .on_hover_text(VERIFY_TIP) + .on_disabled_hover_text("Close the verification window first.") + .clicked(); + if clicked { + ui.close(); + } + clicked +} + +fn member_paths(group: &DuplicateGroup) -> Vec { + group.members.iter().map(|m| m.2.clone()).collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/quicksearch-gui/src/duplicates_tab/tests.rs b/crates/quicksearch-gui/src/duplicates_tab/tests.rs new file mode 100644 index 0000000..8c6324a --- /dev/null +++ b/crates/quicksearch-gui/src/duplicates_tab/tests.rs @@ -0,0 +1,186 @@ +use super::*; + +use crate::test_ui::{painted_text, painted_text_center, raw_input}; + +const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0); + +fn group(paths: &[&str]) -> DuplicateGroup { + DuplicateGroup { + hash: vec![0xab; 32], + count: paths.len() as i64, + total_size: 100 * paths.len() as i64, + redundant_size: 100 * (paths.len() as i64 - 1), + members: paths + .iter() + .enumerate() + .map(|(i, p)| { + let name = p.rsplit('/').next().unwrap_or(p).to_string(); + (i as i64, name, p.to_string(), 100u64, 1_700_000_000i64) + }) + .collect(), + } +} + +fn loaded(paths: &[&str]) -> DuplicatesTab { + DuplicatesTab { + state: DupState::Loaded(vec![group(paths)]), + } +} + +fn frame( + ctx: &egui::Context, + tab: &mut DuplicatesTab, + busy: bool, + events: Vec, +) -> (egui::FullOutput, DuplicatesActions) { + 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); + }); + }); + (out, actions) +} + +fn click(pos: egui::Pos2, button: egui::PointerButton) -> Vec { + let mut events = vec![egui::Event::PointerMoved(pos)]; + events.extend( + [true, false] + .into_iter() + .map(|pressed| egui::Event::PointerButton { + pos, + button, + pressed, + modifiers: egui::Modifiers::default(), + }), + ); + events +} + +/// Right-click `needle` and return what the menu it opened painted, plus the +/// actions from that frame. +fn context_menu_on( + ctx: &egui::Context, + tab: &mut DuplicatesTab, + busy: bool, + needle: &str, +) -> (Vec, egui::Pos2) { + let (out, _) = frame(ctx, tab, busy, Vec::new()); + let target = painted_text_center(&out, needle) + .unwrap_or_else(|| panic!("nothing painted for {needle:?}")); + frame( + ctx, + tab, + busy, + click(target, egui::PointerButton::Secondary), + ); + // The menu is its own area, painted on the frame after the click. + let (out, _) = frame(ctx, tab, busy, Vec::new()); + (painted_text(&out), target) +} + +/// 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) + .into_iter() + .find(|t| t.contains("reclaimable")) + .expect("no group header painted") +} + +const PATHS: [&str; 3] = ["/a/img.raw", "/b/img.raw", "/c/img.raw"]; + +#[test] +fn a_group_header_offers_the_verification() { + let ctx = egui::Context::default(); + let mut tab = loaded(&PATHS); + let header = header_of(&ctx, &mut tab); + let (menu, _) = context_menu_on(&ctx, &mut tab, false, &header); + assert!( + menu.contains(&VERIFY_LABEL.to_string()), + "the group's own row does not offer it: {menu:?}" + ); +} + +/// Clicking it asks for the whole group, not the one row it was asked from. +#[test] +fn verifying_asks_for_every_member_of_the_group() { + let ctx = egui::Context::default(); + 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), + ); + assert_eq!( + actions.verify, + Some(PATHS.iter().map(|p| p.to_string()).collect::>()) + ); +} + +/// There is one verification window, so a second run is refused where it is +/// asked for rather than replacing what someone is reading. +#[test] +fn a_second_verification_is_refused_while_the_window_is_open() { + let ctx = egui::Context::default(); + let mut tab = loaded(&PATHS); + let header = header_of(&ctx, &mut tab); + context_menu_on(&ctx, &mut tab, true, &header); + + let (out, _) = frame(&ctx, &mut tab, true, Vec::new()); + let entry = painted_text_center(&out, VERIFY_LABEL).expect("the entry should still be listed"); + let (_, actions) = frame( + &ctx, + &mut tab, + true, + click(entry, egui::PointerButton::Primary), + ); + assert_eq!(actions.verify, None, "a disabled entry still fired"); +} + +/// Expanding a group and right-clicking one of its files offers the same +/// thing: the rows are what someone is looking at when the question occurs. +#[test] +fn a_member_row_offers_the_verification_too() { + let ctx = egui::Context::default(); + 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(&VERIFY_LABEL.to_string()), + "an expanded member row does not offer it: {menu:?}" + ); + assert!( + menu.contains(&"Open File".to_string()), + "the existing entries went missing: {menu:?}" + ); +} + +#[test] +fn an_empty_result_says_so_rather_than_showing_an_empty_list() { + let ctx = egui::Context::default(); + let mut tab = DuplicatesTab { + state: DupState::Loaded(Vec::new()), + }; + let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0); + assert!( + painted.contains(&"No duplicate files found.".to_string()), + "{painted:?}" + ); +} diff --git a/crates/quicksearch-gui/src/help_tab.rs b/crates/quicksearch-gui/src/help_tab.rs index f3beeb2..d27e2e6 100644 --- a/crates/quicksearch-gui/src/help_tab.rs +++ b/crates/quicksearch-gui/src/help_tab.rs @@ -1,7 +1,9 @@ //! The Help tab: a quickstart guide for first-time users. The complete //! technical reference stays in README.md. -pub fn ui(ui: &mut egui::Ui) { +/// Returns true when the "Show the introduction again" button was clicked. +pub fn ui(ui: &mut egui::Ui) -> bool { + let mut replay = false; let scroll = egui::ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { @@ -16,6 +18,11 @@ pub fn ui(ui: &mut egui::Ui) { files by name and by what is inside them, as you type.", ); + ui.add_space(6.0); + if ui.button("Show the introduction again").clicked() { + replay = true; + } + ui.add_space(12.0); ui.heading(egui::RichText::new("Getting started").strong()); ui.add_space(4.0); @@ -44,23 +51,26 @@ pub fn ui(ui: &mut egui::Ui) { words:", ); ui.monospace("type:Document modified:>=2024-01-01 report"); - ui.label("The ? button next to the search box shows the full query syntax."); + ui.label("The ? button left of the search box shows the full query syntax."); ui.add_space(6.0); ui.label( "• Tick Fuzzy to also find matches with typos in them, at some \ cost in speed.", ); ui.label( - "• Click a column header — Name, Path, Size, Modified, Rank — to \ - sort the results; click it again to reverse the order.", + "• Click a column header to sort the results; click it again to \ + reverse the order. Right-click any header to choose which \ + columns are shown — size and modified date start hidden.", ); ui.label( "• Right-click a result to open it, open its containing folder, \ or hide files like it from the results.", ); ui.label( - "• Matches inside a file's contents show a snippet of the \ - surrounding text under the file name.", + "• 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.add_space(12.0); @@ -92,7 +102,12 @@ pub fn ui(ui: &mut egui::Ui) { "warnings from indexing and folder watching that a \ terminal would have shown", ); - row(ui, "⚙ (top right)", "application options"); + row( + ui, + "Settings", + "everything QuickSearch can be told to do, in one \ + place; hover any control for what it means", + ); }); ui.add_space(12.0); @@ -131,6 +146,7 @@ pub fn ui(ui: &mut egui::Ui) { }); }); crate::ui_util::more_below_hint(ui, &scroll); + replay } /// Where this build left the README: under the install prefix's `share/doc` diff --git a/crates/quicksearch-gui/src/hotkey/binding.rs b/crates/quicksearch-gui/src/hotkey/binding.rs index bc3e3cf..d1e5df4 100644 --- a/crates/quicksearch-gui/src/hotkey/binding.rs +++ b/crates/quicksearch-gui/src/hotkey/binding.rs @@ -2,10 +2,10 @@ //! produce. //! //! A shortcut is written in three different vocabularies before it reaches an -//! operating system: the text in `config.toml` and on the Options button, the -//! token `global-hotkey` parses for `RegisterHotKey`/`XGrabKey`, and the -//! xkbcommon keysym name the XDG *shortcuts* specification wants for the -//! Wayland portal. All three come out of [`KEYS`], so a key cannot be +//! operating system: the text in `config.toml` and on the Settings tab's +//! button, the token `global-hotkey` parses for `RegisterHotKey`/`XGrabKey`, +//! and the xkbcommon keysym name the XDG *shortcuts* specification wants for +//! the Wayland portal. All three come out of [`KEYS`], so a key cannot be //! spelled correctly for one backend and wrongly for the other. //! //! The config text and the `global-hotkey` token are the same string: every @@ -103,7 +103,7 @@ const KEYS: &[(Key, &str, &str)] = &[ (Key::CloseBracket, "BracketRight", "bracketright"), ]; -/// Escape is reserved: it cancels the Options window's capture, and a +/// Escape is reserved: it cancels the Settings tab's capture, and a /// system-wide Escape would be unusable anyway. const RESERVED: &[Key] = &[Key::Escape]; @@ -111,7 +111,7 @@ const RESERVED: &[Key] = &[Key::Escape]; /// /// Super/Meta is absent because `egui::Modifiers` has no field for it — egui /// reports alt, ctrl, shift and the Mac command key only — so a Super combo -/// could never be captured in the Options window even if a backend could +/// could never be captured in the Settings tab even if a backend could /// register it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Binding { @@ -122,7 +122,7 @@ pub struct Binding { } /// Why a string or a key press is not a usable shortcut; the wording is -/// shown in the Options window. +/// shown in the Settings tab. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BindingError { Empty, @@ -149,7 +149,7 @@ impl fmt::Display for BindingError { } impl Binding { - /// Build from a key press egui reported, for the Options window's capture + /// Build from a key press egui reported, for the Settings tab's capture /// widget. `None` for a press that cannot be a shortcut: a key with no /// row in [`KEYS`], a reserved key, or a bare key with no modifier held. /// diff --git a/crates/quicksearch-gui/src/hotkey/mod.rs b/crates/quicksearch-gui/src/hotkey/mod.rs index 82460b0..d4bbc23 100644 --- a/crates/quicksearch-gui/src/hotkey/mod.rs +++ b/crates/quicksearch-gui/src/hotkey/mod.rs @@ -44,7 +44,7 @@ thread_local! { static REGISTRY: RefCell> = const { RefCell::new(None) }; } -/// What the Options window says about the shortcut. +/// What the Settings tab says about the shortcut. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Status { /// The setting is empty: no shortcut, by choice. @@ -256,7 +256,7 @@ mod tests { use super::*; /// Nothing may touch an OS registration before `init`, so that the - /// headless UI tests can render the Options row. + /// headless UI tests can render the Settings row. #[test] fn an_uninitialised_registry_is_inert() { apply("Ctrl+Shift+F"); diff --git a/crates/quicksearch-gui/src/hotkey/portal.rs b/crates/quicksearch-gui/src/hotkey/portal.rs index f8fb4bc..5a19896 100644 --- a/crates/quicksearch-gui/src/hotkey/portal.rs +++ b/crates/quicksearch-gui/src/hotkey/portal.rs @@ -7,7 +7,7 @@ //! `preferred_trigger`, and the compositor is free to bind something else, to //! ask the user first, or to let them change it later in its own settings. //! What it actually bound comes back as a human-readable -//! `trigger_description`, which is what the Options window shows. +//! `trigger_description`, which is what the Settings tab shows. //! //! All of this lives on its own thread. The portal is D-Bus, so every call //! is a round trip that could block for as long as a dialog stays on screen, @@ -56,7 +56,7 @@ impl Portal { .spawn(move || pollster::block_on(run(ctx, status, rx))) { // Not worth taking the app down for — but the status must say - // so, or Options shows "Waiting for your desktop…" forever. + // so, or the Settings tab shows "Waiting for your desktop…" forever. quicksearch_core::log_warn!("global shortcut portal thread: {}", e); *lock_ok(&portal.status) = Status::Error(format!("the shortcut thread could not be started: {}", e)); @@ -194,7 +194,7 @@ fn unavailable(e: &ashpd::Error) -> String { fn set(ctx: &egui::Context, status: &Mutex, next: Status) { *lock_ok(status) = next; - // The Options window may be open and waiting for this. + // The Settings tab may be on screen and waiting for this. ctx.request_repaint(); } diff --git a/crates/quicksearch-gui/src/main.rs b/crates/quicksearch-gui/src/main.rs index be8f4f6..9c2eb88 100644 --- a/crates/quicksearch-gui/src/main.rs +++ b/crates/quicksearch-gui/src/main.rs @@ -22,14 +22,15 @@ mod hotkey; mod keychain; mod logs_tab; mod manage_tab; -mod options; mod platform; mod query_highlight; mod search_tab; +mod settings_tab; #[cfg(test)] mod test_ui; mod tips; mod tracker; +mod tutorial; mod ui_util; mod unlock; mod version; diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index 25ee43a..e0f8e8f 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -434,7 +434,7 @@ impl ManageTab { ui.label(hint( "Reindex interval, symlinks, hidden files, tokenizer, and size \ - limits are in Options (⚙ in the toolbar).", + limits are on the Settings tab.", )); ui.add_space(8.0); @@ -542,10 +542,10 @@ fn db_size_tooltip(ui: &mut egui::Ui) { "Remove indexed folders you do not need, in Indexed folders above.", "Narrow the full-text extension whitelist, so text is only extracted \ from the file types you actually search.", - "Turn off \"Store text for snippets\" in Options: full-text search keeps \ - working, but without previews, occurrence ranking or fuzzy matching \ + "Turn off \"Store text for snippets\" on the Settings tab: full-text search \ + keeps working, but without previews, occurrence ranking or fuzzy matching \ inside file contents.", - "Lower \"Max text file size\" and \"Max stored text\" in Options.", + "Lower \"Max text file size\" and \"Max stored text\", both on the Settings tab.", ] { ui.label(format!("• {}", lever)); } @@ -809,20 +809,34 @@ fn root_row(ui: &mut egui::Ui, r: &RootProgress) { RootPhase::Extracting => { ui.label(egui::RichText::new("extracting text").color(phase.green)); divider(ui); - let frac = if r.extract_total > 0 { - (r.extracted as f32 / r.extract_total as f32).clamp(0.0, 1.0) - } else { - 1.0 - }; - ui.label(format!( - "{} / {} ({:.0}%) · {}/{} workers", - group_thousands(r.extracted as u64), - group_thousands(r.extract_total as u64), - frac * 100.0, - r.active_workers, - r.total_workers - )); - crate::ui_util::progress_bar(ui, Some(frac), 160.0); + let workers = format!("{}/{} workers", r.active_workers, r.total_workers); + match r.extract_total { + Some(total) => { + let frac = if total > 0 { + (r.extracted as f32 / total as f32).clamp(0.0, 1.0) + } else { + 1.0 + }; + ui.label(format!( + "{} / {} ({:.0}%) · {}", + group_thousands(r.extracted as u64), + group_thousands(total as u64), + frac * 100.0, + workers + )); + crate::ui_util::progress_bar(ui, Some(frac), 160.0); + } + // The pass is still counting its range — the same shape + // as a walk without a denominator yet. + None => { + ui.label(format!( + "{} files · {}", + group_thousands(r.extracted as u64), + workers + )); + crate::ui_util::progress_bar(ui, None, 160.0); + } + } } RootPhase::Done => { // Whole-root totals: `walked` counts every file the walk saw diff --git a/crates/quicksearch-gui/src/manage_tab/tests.rs b/crates/quicksearch-gui/src/manage_tab/tests.rs index 48a44b5..01baab9 100644 --- a/crates/quicksearch-gui/src/manage_tab/tests.rs +++ b/crates/quicksearch-gui/src/manage_tab/tests.rs @@ -64,7 +64,7 @@ fn running_state(roots: &[&str], current_file: Option<&str>) -> IndexerState { walked: 100, walk_total: Some(1000), extracted: 0, - extract_total: 0, + extract_total: None, current_file: current_file.map(str::to_string), active_workers: 4, total_workers: 4, @@ -304,7 +304,7 @@ fn root_progress(phase: RootPhase, walked: usize, walk_total: Option) -> walked, walk_total, extracted: 0, - extract_total: 0, + extract_total: None, current_file: None, active_workers: 4, total_workers: 4, @@ -683,7 +683,7 @@ fn the_probe_caches_until_the_refresh_interval_is_up() { let _ = std::fs::remove_dir_all(&dir); } -/// A database path edited in Options must not keep showing the old +/// A database path edited on the Settings tab must not keep showing the old /// database's size for the rest of the interval. #[test] fn the_probe_follows_a_changed_database_path() { @@ -762,7 +762,7 @@ fn hovering_the_size_explains_how_to_shrink_the_index() { "Indexed folders", "whitelist", "Store text for snippets", - "Options", + "Settings tab", ] { assert!(text.contains(lever), "tooltip never mentions {}", lever); } diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index 8a5654b..a916858 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -5,13 +5,14 @@ use std::time::Instant; use egui::text::{LayoutJob, TextFormat}; use egui_extras::{Column, TableBuilder}; -use quicksearch_core::search::{SearchHit, SearchUpdate}; +use quicksearch_core::config::ColumnsConfig; +use quicksearch_core::live::{LiveUpdate, Target, WindowUpdate}; +use quicksearch_core::search::{MatchField, SearchHit, SearchUpdate}; use quicksearch_core::snippet::Snippet; use crate::color::rank_tier_color; use crate::format::{fmt_elapsed, fmt_mtime, human_size}; use crate::platform; -use crate::ui_util::middle_elide; mod help_window; mod ignore_dialog; @@ -22,12 +23,35 @@ mod tests; use crate::ui_util::hint; use ignore_dialog::dir_ignore_pattern; pub use ignore_dialog::IgnoreDialog; -use snippet_render::{centered_match_job, snippet_job}; +use snippet_render::{centered_match_job, marked_field_job, path_cell_job, snippet_job}; /// Fixed width (points) of the query strip's status slot, sized for the /// longest `fmt_elapsed` output, so the query box never resizes. const STATUS_SLOT_WIDTH: f32 = 52.0; +/// Points reserved inside the query box for the repeat-search button, held +/// whether or not the button is showing: a text field whose contents shift +/// sideways every time a search finishes is worse than 20 lost points. +const REPEAT_SLOT_W: i8 = 20; + +/// Width (points) of the Fuzzy label-plus-box slot, sized to hold both with a +/// little slack so the strip's spacing does not depend on the font. +const FUZZY_SLOT_WIDTH: f32 = 66.0; + +/// Shared by the Fuzzy checkbox and its label, which are separate widgets so +/// the label can sit on the left — `egui::Checkbox` pushes its icon leftmost +/// unconditionally, so no layout direction can flip them. +const FUZZY_HINT: &str = "Also run fuzzy filename and full-text passes (slower)"; + +/// What the Content Match column shows for a row that did not match on +/// content. An em dash, not a hyphen: at body size `-` reads as a typo and `–` +/// is indistinguishable from one. +const NO_CONTENT_MATCH: &str = "—"; + +/// How long the visible rows must hold still before they are watched. Scrolling +/// through a long result list would otherwise re-register on every frame. +const LIVE_ARM_DELAY: std::time::Duration = std::time::Duration::from_millis(400); + /// Seconds for the old results to fade out; the swap waits on this. const FADE_OUT_SECS: f32 = 0.15; /// Seconds for the new results to wipe in from the top. @@ -53,6 +77,288 @@ pub struct SearchActions { pub persist_ignore: Option, /// The fuzzy toggle changed; remember it in the config. pub save_fuzzy_default: Option, + /// The column picker changed; remember it in the config. + pub save_columns: Option, + /// Replace the live-result watch set. `Some(vec![])` clears it; `None` + /// leaves whatever is registered alone. + pub live_targets: Option>, +} + +/// Whether the live watchers should be pointed at the visible rows this frame. +/// +/// Every clause earns its place. `settled` folds in three things — no search +/// running, no edit pending, and the reveal animation finished — because +/// watching rows that do not correspond to the text in the box would re-cut +/// their snippets against the wrong query. The delay is what stops a scroll +/// from re-registering on every frame. +fn should_arm( + enabled: bool, + armed_already: bool, + changed_at: Option, + settled: bool, + now: Instant, +) -> bool { + enabled + && settled + && !armed_already + && changed_at.is_some_and(|t| now.duration_since(t) >= LIVE_ARM_DELAY) +} + +/// Whether two target lists ask for the same watches. +/// +/// Compares what decides *what is watched* and nothing else. `Target` also +/// carries the size and modified time the row is displaying, but those are +/// only the baseline for the watcher's arm-time sweep — re-registering every +/// inotify watch because a file's size moved would tear down and rebuild the +/// whole set on every write. +fn same_watch_set(a: &[Target], b: &[Target]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(x, y)| x.path == y.path && x.text == y.text) +} + +/// Recolour a laid-out cell as "the file behind this row is gone". +/// +/// The Name column says so with a `RichText`, but that column is optional — +/// with it hidden, a struck-through name is no indication at all. Every other +/// column carries its own share instead of relying on it. Match highlighting +/// goes with it: nothing about a file that is not there is still a hit. +fn mark_missing_job(ui: &egui::Ui, job: &mut egui::text::LayoutJob, strike: bool) { + let color = ui.visuals().weak_text_color(); + for section in &mut job.sections { + section.format.color = color; + section.format.background = egui::Color32::TRANSPARENT; + section.format.strikethrough = if strike { + egui::Stroke::new(1.0, color) + } else { + egui::Stroke::NONE + }; + } +} + +/// The watch target a row asks for. +fn target_for(hit: &SearchHit) -> Target { + Target { + path: hit.path.clone(), + // Only a row showing body text needs its snippet re-cut — and the + // watcher has to know which matcher cut it; a filename match costs + // one metadata call per change and never opens the file. + text: hit.content_tier(), + // What the row is *displaying*, which on a fresh result is whatever + // the index said. Sweeping the disk against it at arm time is what + // turns "watch these rows" into "and tell me if the index was already + // out of date about them". + size: hit.size, + mtime: hit.mtime, + } +} + +/// Which of the two kinds a results column is, in the sense every table +/// library means it: `QHeaderView::Interactive` against `Stretch`, AG Grid's +/// plain `width` against `flex`, GTK's `expand`. +/// +/// Columns holding variable-length text flex, so a wider window gives them the +/// room; the ones holding a number or a date do not, because 52 points is as +/// much Rank as there will ever be to read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ColumnKind { + /// Keeps whatever width it was last given, and can be dragged to another. + Fixed, + /// Shares the space the fixed columns leave, in proportion to its current + /// width — which is also its weight, so a column dragged to a new width + /// keeps that *share* through the next window resize rather than those + /// pixels. + Flex, +} + +/// One results column's fixed characteristics: which kind it is, how narrow it +/// may ever get, and how wide it starts before anyone has dragged anything. +#[derive(Debug, Clone, Copy, PartialEq)] +struct ColumnPlan { + kind: ColumnKind, + floor: f32, + initial: f32, +} + +/// Lay the columns out across `budget`: the standard fixed/flex allocation. +/// +/// The fixed columns take their current width off the top and the flex ones +/// share what is left, in proportion to `current` — which doubles as their +/// weight. A flex column that would land under its floor is pinned there and +/// drops out of the split, and the rest re-share, repeatedly, since pinning +/// one can push another under. AG Grid states the same rule: "if a column with +/// flex is being constrained by its minWidth/maxWidth rules, other flex +/// columns should take up the remaining available space". +/// +/// Once the flex columns are all at their floors there is nothing left to give +/// and the fixed ones have to shrink too, so they join the split rather than +/// letting the table overflow. Past that — the floors alone over budget — the +/// window is narrower than the table can be, and the floors are returned: +/// overflowing honestly beats collapsing a column to nothing. +/// +/// Doing this here rather than with `Column::remainder()` is forced. +/// `egui_extras` reloads a **resizable** column as `Size::exact(stored_width)` +/// and drops its `width_range`, so a remainder stops being one the moment the +/// table is resizable; and only the *last* column gets the fill-the-remainder +/// special case, so a second flex column could never absorb anything. Marking +/// a flex column `resizable(false)` is worse still — that path floors it at +/// `max_used`, which for a clipped column is its own laid-out width, so it +/// grows and never shrinks (emilk/egui#8048, fixed upstream in 0.35). +fn fit_widths(current: &[f32], plans: &[ColumnPlan], budget: f32) -> Vec { + debug_assert_eq!(current.len(), plans.len()); + let floor_total: f32 = plans.iter().map(|p| p.floor).sum(); + if plans.is_empty() || floor_total >= budget { + return plans.iter().map(|p| p.floor).collect(); + } + + let mut out: Vec = plans + .iter() + .zip(current) + .map(|(p, &w)| w.max(p.floor)) + .collect(); + // The columns still sharing what is left. Fixed ones are not in the split + // at all until the flex ones have nothing left to give; the rest have been + // pinned to a floor. + let mut free: Vec = (0..plans.len()) + .filter(|&i| plans[i].kind == ColumnKind::Flex) + .collect(); + let mut fixed_joined = false; + loop { + if free.is_empty() { + // Every flex column bottomed out and the total still does not fit: + // the fixed columns give up the difference in proportion, once. + if fixed_joined || out.iter().sum::() <= budget { + return out; + } + fixed_joined = true; + free = (0..plans.len()) + .filter(|&i| plans[i].kind == ColumnKind::Fixed) + .collect(); + continue; + } + let taken: f32 = (0..plans.len()) + .filter(|i| !free.contains(i)) + .map(|i| out[i]) + .sum(); + let share_budget = budget - taken; + // The weight is the width as it stands, not as it will be clamped: + // floors decide what a column *gets*, never what it is owed. + let share: f32 = free.iter().map(|&i| current[i]).sum(); + // Nothing to take proportions from (a first frame, or every free + // column measured zero): split what is left evenly. + let widths: Vec = if share > 0.0 { + free.iter() + .map(|&i| current[i] / share * share_budget) + .collect() + } else { + vec![share_budget / free.len() as f32; free.len()] + }; + let Some(under) = free + .iter() + .zip(&widths) + .position(|(&i, &w)| w < plans[i].floor) + else { + for (&i, &w) in free.iter().zip(&widths) { + out[i] = w; + } + return out; + }; + let pinned = free.remove(under); + out[pinned] = plans[pinned].floor; + } +} + +/// [`fit_widths`], holding one column at the width the pointer just gave it. +/// +/// A drag is the user stating a width, so the layout takes it as given and the +/// others absorb the difference; refitting the dragged column too would fight +/// the pointer. It is still bounded — held no wider than leaves every other +/// column its floor — so a drag can never make the table overflow. +fn fit_around(current: &[f32], plans: &[ColumnPlan], budget: f32, held: Option) -> Vec { + let Some(held) = held.filter(|&i| i < plans.len()) else { + return fit_widths(current, plans, budget); + }; + // Everything up to and including the dragged column keeps the width it + // has; only what lies to its right gives way. + // + // This is the whole of what makes a drag controllable. `egui_extras` sets + // the dragged column to `column_width + pointer.x - x`, and `x` is the + // running right edge — which already contains `column_width`, so the + // expression is really "put this column's right edge on the pointer, + // measured from its left one". Move anything to its left and that left + // edge shifts, so the divider resizes on its own and slides out from under + // the cursor. Refitting every column but the held one, which is what this + // used to do, moved them on every frame of every drag. + let mut out: Vec = current.to_vec(); + let held_width = current[held].clamp( + plans[held].floor, + grow_ceiling(current, plans, budget, held), + ); + out[held] = held_width; + + let left: f32 = out[..=held].iter().sum(); + let tail = fit_widths(¤t[held + 1..], &plans[held + 1..], budget - left); + out[held + 1..].copy_from_slice(&tail); + out +} + +/// The widest a column may be dragged: everything the columns to its *right* +/// could give up, and nothing more. +/// +/// Only the right-hand side is on offer, for the reason in [`fit_around`] — +/// taking from the left would move the divider away from the pointer. So the +/// last column's ceiling is its own width, and its divider is inert: its right +/// edge is the window's edge, and there is nothing beyond it to trade with. +fn grow_ceiling(current: &[f32], plans: &[ColumnPlan], budget: f32, i: usize) -> f32 { + let left: f32 = current[..i].iter().sum(); + let right_floor: f32 = plans[i + 1..].iter().map(|p| p.floor).sum(); + (budget - left - right_floor).max(plans[i].floor) +} + +/// The sort to actually apply: the requested one, or Rank when the column it +/// keys on is not on screen. +/// +/// Hiding the column you are sorted by would otherwise strand you in a sort +/// you can neither see nor click your way out of. Rank is the fallback because +/// it is what a fresh search uses and it needs no column of its own to mean +/// something. +fn effective_sort(sort: (SortKey, bool), cols: &ColumnsConfig) -> (SortKey, bool) { + let shown = match sort.0 { + // The path column is mandatory, and rank ordering is meaningful with + // or without its column. + SortKey::Path | SortKey::Rank => true, + SortKey::Name => cols.name, + SortKey::Size => cols.size, + SortKey::Modified => cols.modified, + }; + if shown { + sort + } else { + (SortKey::Rank, true) + } +} + +/// Byte ranges into `field` to highlight, or `None` when the hit's snippet is +/// not that field verbatim. +/// +/// Core promises that name- and path-tier snippets are the whole field, which +/// is what lets a column paint its own text and mark the match inside it. This +/// re-checks rather than trusting: ranges cut for a *window* would index the +/// wrong glyphs here, and painting a confidently wrong highlight is worse than +/// painting none. Cheap per visible row — the table is virtualized and a name +/// or a path is short. +fn whole_field_ranges<'a>(snip: Option<&'a Snippet>, field: &str) -> Option<&'a [(usize, usize)]> { + let snip = snip?; + if snip.truncated_start || snip.truncated_end || snip.window != field { + return None; + } + snip.ranges + .iter() + .all(|&(a, b)| { + a <= b && b <= field.len() && field.is_char_boundary(a) && field.is_char_boundary(b) + }) + .then_some(snip.ranges.as_slice()) } /// Add `incoming` to `set`, keeping at most `limit` of them — the best by @@ -79,13 +385,32 @@ fn centered_cell(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R pub struct SearchTab { pub query: String, pub fuzzy: bool, + /// Which columns to paint, mirrored from `[search.columns]`. + pub columns: ColumnsConfig, + /// Mirrored from `[search] live_results`. + pub live_enabled: bool, + /// The rows rendered last frame — the "visually shown" set — as the + /// targets they would be watched as. + /// + /// Targets rather than row indices because a row's path is what the + /// watcher keys on, and a rename changes the path without moving the row: + /// keyed on indices, a renamed row would never be re-armed and would stop + /// tracking after its first move. + live_wanted: Vec, + /// The targets the watcher is currently registered for. + live_armed: Vec, + /// When `live_wanted` last changed; the arm delay runs from here. + live_changed_at: Option, + /// Files that have vanished from under a row on screen, by `file_id`. + /// The row stays put and is struck through rather than being removed — + /// dropping it would shift every index below it while someone is reading. + gone: std::collections::HashSet, /// Set on every edit; the app fires the search after the debounce. pub pending_edit: Option, pub generation: u64, pub results: Vec, /// The next search's hits, swapped into `results` at zero opacity. staging: Vec, - staging_has_snippets: bool, /// True from search start until the staged set has been swapped in. swap_pending: bool, /// How much of the results section the reveal still hides: 1 at the swap, 0 fully shown. @@ -107,7 +432,24 @@ pub struct SearchTab { pub session_ignores: Vec, pub ignore_dialog: Option, pub help_open: bool, - has_snippets: bool, + /// Each results column's width as it was actually laid out last frame, + /// and the width the table had to lay them out in. + /// + /// Measured rather than remembered: once the table is resizable + /// `egui_extras` owns the widths and offers no way to read them back, and + /// this is what [`fit_widths`] needs to keep the user's proportions + /// across a window resize. + col_widths: Vec, + /// The widths asked for last frame. A column that came back a different + /// width is the one under the pointer — `egui_extras` offers no way to ask. + col_wanted: Vec, + /// The column being dragged, held for the length of the drag. + /// + /// Re-deciding it every frame does not work: the table lays out from the + /// widths it stored a frame earlier, so on the frames where the lag has + /// caught up there is nothing to tell a drag from a settled layout, and + /// the ceiling that keeps the drag inside the window would come and go. + col_drag: Option, /// Display-row index hovered last frame; tracked via `contains_pointer()` /// because `row.response().hovered()` is false whenever a selectable label /// wins the hit-test. @@ -115,22 +457,27 @@ pub struct SearchTab { focus_query: bool, /// Query syntax-highlight segments, cached per text. highlight: crate::query_highlight::HighlightCache, - /// Screen rects of last frame's Match cells, in display order — the + /// Screen rects of last frame's Content Match cells, in display order — the /// capture driver's hover targets. #[cfg(feature = "capture")] pub(crate) capture_match_rects: Vec, } impl SearchTab { - pub fn new(fuzzy_default: bool) -> SearchTab { + pub fn new(fuzzy_default: bool, columns: ColumnsConfig, live_enabled: bool) -> SearchTab { SearchTab { query: String::new(), fuzzy: fuzzy_default, + columns, + live_enabled, + live_wanted: Vec::new(), + live_armed: Vec::new(), + live_changed_at: None, + gone: std::collections::HashSet::new(), pending_edit: None, generation: 0, results: Vec::new(), staging: Vec::new(), - staging_has_snippets: false, swap_pending: false, wipe: 0.0, fade: 1.0, @@ -146,7 +493,9 @@ impl SearchTab { session_ignores: Vec::new(), ignore_dialog: None, help_open: false, - has_snippets: false, + col_widths: Vec::new(), + col_wanted: Vec::new(), + col_drag: None, hovered_row: None, focus_query: true, highlight: Default::default(), @@ -161,11 +510,16 @@ impl SearchTab { self.pending_edit = Some(Instant::now()); } - /// What the capture driver's `wait_search_done` means by "done": query - /// executed, swap landed, and the wipe finished — a screenshot during the - /// reveal catches a half-drawn table. - #[cfg(feature = "capture")] - pub(crate) fn capture_settled(&self) -> bool { + /// Whether what the table shows corresponds to the text in the query box: + /// the query executed, the swap landed, and the reveal finished. + /// + /// Two things need exactly this. Arming the live watchers does, because + /// watching rows that do not match the box would re-cut their snippets + /// against the wrong query; and the capture driver's `wait_search_done` + /// does, because a screenshot mid-reveal catches a half-drawn table. They + /// were the same expression written twice, one of them behind the capture + /// feature and so absent from every test build. + pub(crate) fn settled(&self) -> bool { !self.running && self.pending_edit.is_none() && self.fade_settled() } @@ -174,7 +528,14 @@ impl SearchTab { self.focus_query = true; } - /// Screen rect of the Nth visible Match cell from the last rendered + /// Re-sort before the next paint. Needed when the columns change from + /// outside the tab: hiding the sorted column demotes the sort to Rank + /// (see [`effective_sort`]), and the order has to be rebuilt for it. + pub(crate) fn mark_sort_dirty(&mut self) { + self.sort_dirty = true; + } + + /// Screen rect of the Nth visible Content Match cell from the last rendered /// frame, if that many are on screen. #[cfg(feature = "capture")] pub(crate) fn capture_match_cell(&self, n: usize) -> Option { @@ -186,7 +547,12 @@ impl SearchTab { pub fn on_search_started(&mut self, generation: u64) { self.generation = generation; self.staging.clear(); - self.staging_has_snippets = false; + // The watches belong to the results being replaced. The app drops the + // registration itself; this is the tab-side half. + self.live_armed.clear(); + self.live_wanted.clear(); + self.live_changed_at = None; + self.gone.clear(); self.swap_pending = true; self.running = true; self.search_started = Some(Instant::now()); @@ -223,10 +589,8 @@ impl SearchTab { SearchUpdate::Hits { hits, .. } => { if self.swap_pending { // Old results are still fading out; hold the new ones. - self.staging_has_snippets |= hits.iter().any(|h| h.snippet.is_some()); admit(&mut self.staging, hits, display_limit, &mut self.limited); } else { - self.has_snippets |= hits.iter().any(|h| h.snippet.is_some()); // Admitting a batch may reorder or drop rows out from // under the selection's index, so carry it by file id. let selected_id = self @@ -258,19 +622,110 @@ impl SearchTab { } } + /// Apply one live filesystem update to the row it names. + /// + /// Rows are found by path — the same key the watcher was armed with. The + /// display order, the selection and `file_id` are all left alone: a row + /// that jumps or vanishes under the pointer while someone is reading it is + /// worse than a row that is briefly out of position. + pub fn apply_live(&mut self, update: LiveUpdate) { + match update { + LiveUpdate::Renamed { path, to, name } => { + let Some(hit) = self.results.iter_mut().find(|h| h.path == path) else { + return; + }; + // A name- or path-tier snippet *is* the old field, so it has + // to be rewritten. The marks go with it: nothing here says the + // new name still matches the query, and an unhighlighted new + // name is the honest rendering of that. + let field = hit.match_field(); + if let Some(snip) = hit.snippet.as_mut() { + match field { + MatchField::Name => { + snip.window = name.clone(); + snip.ranges.clear(); + } + MatchField::Path => { + snip.window = to.clone(); + snip.ranges.clear(); + } + // A move does not touch the body. + MatchField::Contents => {} + } + } + self.gone.remove(&hit.file_id); + hit.path = to; + hit.name = name; + } + LiveUpdate::Changed { + path, + size, + mtime, + window, + } => { + let Some(hit) = self.results.iter_mut().find(|h| h.path == path) else { + return; + }; + hit.size = size; + hit.mtime = mtime; + match window { + // Either not a body-text row, or one whose body could not + // be re-read. Both mean the cell is better left as it is + // than blanked on no evidence. + WindowUpdate::Unchanged => {} + WindowUpdate::Cut(snippet) => hit.snippet = Some(snippet), + WindowUpdate::NoMatch => hit.snippet = None, + } + self.gone.remove(&hit.file_id); + } + LiveUpdate::Gone { path } => { + if let Some(hit) = self.results.iter().find(|h| h.path == path) { + self.gone.insert(hit.file_id); + } + } + } + } + + /// Whether `live_wanted` already describes exactly these rows. + /// + /// Compared field by field rather than by building the targets and + /// testing equality, because the common frame is "nothing moved" and + /// that frame must allocate nothing at all. + fn live_wanted_current(&self, visible: &[u32]) -> bool { + visible.len() == self.live_wanted.len() + && visible.iter().zip(&self.live_wanted).all(|(&ix, want)| { + self.results.get(ix as usize).is_some_and(|hit| { + hit.path == want.path + && hit.size == want.size + && hit.mtime == want.mtime + && hit.content_tier() == want.text + }) + }) + } + + /// Drop the tab-side live state. The app drops the registration itself. + pub(crate) fn reset_live(&mut self) { + self.live_armed.clear(); + self.live_wanted.clear(); + self.live_changed_at = None; + } + pub fn result_count_label(&self) -> Option { if self.query.trim().is_empty() && self.results.is_empty() { return None; } - Some(if self.limited { - format!("{}+ results (truncated)", self.results.len()) - } else { - format!("{} results", self.results.len()) - }) + // The `+` is the whole warning here: it says the count is a floor. + // The reason and the remedy live in the tab body's own notice, which + // has room for a sentence; the status bar does not. + Some(format!( + "{}{} results", + self.results.len(), + if self.limited { "+" } else { "" } + )) } fn resort(&mut self) { - let (key, ascending) = self.sort; + let (key, ascending) = effective_sort(self.sort, &self.columns); let selected_id = self .selected .and_then(|i| self.results.get(i as usize)) @@ -301,12 +756,21 @@ impl SearchTab { self.sort_dirty = false; } - /// A sortable column header. The sort indicator is a painter-drawn - /// triangle: the default egui fonts have no ▲/▼ glyphs — they render - /// as boxes. - fn sort_header(&mut self, ui: &mut egui::Ui, key: SortKey, label: &str) { - let (cur, asc) = self.sort; - let selected = cur == key; + /// One column header: its label, the sort indicator when it is the active + /// key, the click that re-keys the sort, and the right-click menu that + /// picks columns. `key` is `None` for a header that does not sort. + /// + /// The sort indicator is a painter-drawn triangle: the default egui fonts + /// have no ▲/▼ glyphs — they render as boxes. + fn header_cell( + &mut self, + ui: &mut egui::Ui, + key: Option, + label: &str, + picked: &mut Option, + ) { + let (cur, asc) = effective_sort(self.sort, &self.columns); + let selected = key == Some(cur); let (rect, response) = ui.allocate_exact_size(ui.available_size(), egui::Sense::click()); if ui.is_rect_visible(rect) { if response.hovered() { @@ -349,78 +813,209 @@ impl SearchTab { )); } } - if response.clicked() { - self.sort = if selected { (key, !asc) } else { (key, true) }; - self.sort_dirty = true; + if let Some(key) = key { + if response.clicked() { + self.sort = if selected { (key, !asc) } else { (key, true) }; + self.sort_dirty = true; + } } + // Every header carries the same picker, so a right-click lands wherever + // the pointer happens to be along the row. + response.context_menu(|ui| { + let mut next = self.columns.clone(); + ui.label(hint("Columns")); + let row = |ui: &mut egui::Ui, on: &mut bool, label: &str| { + ui.checkbox(on, label); + }; + row(ui, &mut next.name, "Name"); + // Shown checked and greyed rather than omitted: an absent entry + // reads as an oversight, a disabled one answers the question. + ui.add_enabled(false, egui::Checkbox::new(&mut true, "Path")) + .on_disabled_hover_text( + "The path is always shown — it is the only column that \ + identifies a result on its own.", + ); + row(ui, &mut next.content_match, "Content Match"); + row(ui, &mut next.size, "Size"); + row(ui, &mut next.modified, "Modified"); + row(ui, &mut next.rank, "Rank"); + if next != self.columns { + self.columns = next.clone(); + self.sort_dirty = true; + *picked = Some(next); + } + }); } pub fn ui(&mut self, ui: &mut egui::Ui) -> SearchActions { let mut actions = SearchActions::default(); - // --- Query strip: laid out right to left, the query box taking - // whatever is left of the row. ------------------------------------- + // --- Query strip: the syntax-help button anchors the left edge, then + // everything else is laid out right to left with the query box taking + // whatever is left of the row. -------------------------------------- ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("?").on_hover_text("Query syntax help").clicked() { - self.help_open = !self.help_open; - } - if ui - .checkbox(&mut self.fuzzy, "Fuzzy") - .on_hover_text("Also run fuzzy filename and full-text passes (slower)") - .changed() - { - actions.save_fuzzy_default = Some(self.fuzzy); - actions.rerun = true; - } - let show_elapsed = - !self.running && self.elapsed.is_some() && !self.query.trim().is_empty(); - ui.allocate_ui_with_layout( - egui::vec2(STATUS_SLOT_WIDTH, ui.spacing().interact_size.y), - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - // Hold the width from the inside — the child otherwise - // shrinks to its content. - ui.set_min_width(STATUS_SLOT_WIDTH); - if self.running { - ui.add(egui::Spinner::new().size(16.0)); - } else if show_elapsed { - if let Some(elapsed) = self.elapsed { - ui.label(hint(fmt_elapsed(elapsed))) - .on_hover_text("Time to run all search passes"); - } - } - }, - ); - let width = ui.available_width(); - let highlight = &mut self.highlight; - let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| { - crate::query_highlight::galley(ui, highlight, buf.as_str()) - }; - let response = ui.add( - egui::TextEdit::singleline(&mut self.query) - .desired_width(width.max(120.0)) - .hint_text("Search names and contents… (type:Document regex:… budget*)") - .layouter(&mut layouter), - ); - if self.focus_query { - response.request_focus(); - // Select the existing text, written straight to widget - // state so the selection is in place the frame focus lands. - if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) { - let all = egui::text::CCursorRange::two( - egui::text::CCursor::new(0), - egui::text::CCursor::new(self.query.chars().count()), - ); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), response.id); + if ui.button("?").on_hover_text("Query syntax help").clicked() { + self.help_open = !self.help_open; + } + // Sized to what the `?` left behind, not `with_layout`: a + // right-to-left child takes the row's *full* width, so after the + // button has advanced the cursor its right edge lands a button's + // width past the panel and the rightmost widget falls off screen. + let rest = egui::vec2( + (ui.max_rect().right() - ui.next_widget_position().x).max(0.0), + ui.available_height(), + ); + ui.allocate_ui_with_layout( + rest, + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // The label is a separate widget from the box so it can sit on + // the left: in a right-to-left layout the first widget added is + // the rightmost, and `egui::Checkbox` pushes its own icon + // leftmost whatever the direction, so the two have to be + // separate widgets in this order. Sensing clicks on the label + // keeps the target the combined widget used to have. + // The pair gets its own fixed-width, left-to-right slot, + // the way the status slot below does. A bare `ui.horizontal` + // here would be laid out by the surrounding right-to-left + // strip and land its contents past the panel's edge. + // + // Two widgets rather than one because `egui::Checkbox` + // pushes its own icon leftmost whatever the direction — and + // sensing clicks on the label keeps the target the combined + // widget gave it for free. + let toggled = ui + .allocate_ui_with_layout( + egui::vec2(FUZZY_SLOT_WIDTH, ui.spacing().interact_size.y), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let mut toggled = false; + if ui + .add(egui::Label::new("Fuzzy").sense(egui::Sense::click())) + .on_hover_text(FUZZY_HINT) + .clicked() + { + self.fuzzy = !self.fuzzy; + toggled = true; + } + toggled + | ui.add(egui::Checkbox::without_text(&mut self.fuzzy)) + .on_hover_text(FUZZY_HINT) + .changed() + }, + ) + .inner; + if toggled { + actions.save_fuzzy_default = Some(self.fuzzy); + actions.rerun = true; } - self.focus_query = false; - } - if response.changed() { - self.pending_edit = Some(Instant::now()); - } - }); + ui.separator(); + let show_elapsed = + !self.running && self.elapsed.is_some() && !self.query.trim().is_empty(); + ui.allocate_ui_with_layout( + egui::vec2(STATUS_SLOT_WIDTH, ui.spacing().interact_size.y), + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // Hold the width from the inside — the child otherwise + // shrinks to its content. + ui.set_min_width(STATUS_SLOT_WIDTH); + if self.running { + ui.add(egui::Spinner::new().size(16.0)); + } else if show_elapsed { + if let Some(elapsed) = self.elapsed { + ui.label(hint(fmt_elapsed(elapsed))) + .on_hover_text("Time to run all search passes"); + } + } + }, + ); + let width = ui.available_width(); + let highlight = &mut self.highlight; + let mut layouter = + move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| { + crate::query_highlight::galley(ui, highlight, buf.as_str()) + }; + let response = ui.add( + egui::TextEdit::singleline(&mut self.query) + .desired_width(width.max(120.0)) + // The gutter is held whether or not the button is + // showing. `TextEdit` sizes its frame as + // `wrap_width + margin`, with `wrap_width` capped by + // what is available, so widening the right margin + // leaves the outer box exactly where it was and only + // insets the text — which is what keeps the query from + // shifting sideways every time a search finishes. + .margin(egui::Margin { + right: 4 + REPEAT_SLOT_W, + ..egui::Margin::symmetric(4, 2) + }) + .hint_text( + "Search names and contents… (type:Document regex:… budget*)", + ) + .layouter(&mut layouter), + ); + if self.focus_query { + response.request_focus(); + // Select the existing text, written straight to widget + // state so the selection is in place the frame focus lands. + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), response.id) { + let all = egui::text::CCursorRange::two( + egui::text::CCursor::new(0), + egui::text::CCursor::new(self.query.chars().count()), + ); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), response.id); + } + self.focus_query = false; + } + if response.changed() { + self.pending_edit = Some(Instant::now()); + // The watches belong to the results the old query + // produced. Dropping them here rather than at the next + // search means they go the instant the query stops + // describing what is on screen. + self.reset_live(); + actions.live_targets = Some(Vec::new()); + } + + // Read *after* the edit above, so a keystroke hides the button + // on its own frame. `pending_edit.is_none()` is also what makes + // "repeat the last search" and "run what is in the box" the + // same thing: the flag is set on every edit and cleared in the + // same statement that fires the search, so whenever this is + // true the box holds exactly what last executed. + let show_repeat = !self.running + && self.elapsed.is_some() + && self.pending_edit.is_none() + && !self.query.trim().is_empty(); + if show_repeat { + let slot = egui::Rect::from_min_max( + egui::pos2( + response.rect.right() - REPEAT_SLOT_W as f32, + response.rect.top(), + ), + response.rect.right_bottom(), + ) + .shrink(2.0); + // `place`, not `put`: `put` advances the cursor, which in + // this right-to-left row would shove the query box sideways. + // + // This must stay *after* the TextEdit. egui derives widget + // ids from how many widgets precede them, so a button that + // comes and goes ahead of the box would rename it every + // time a search finished — and a TextEdit whose id changes + // loses focus and its in-progress edit. Nothing follows the + // button here, so the ordering alone is the fix. + if ui + .place(slot, egui::Button::new("⟳").frame_when_inactive(false)) + .on_hover_text("Run this search again") + .clicked() + { + actions.rerun = true; + } + } + }, + ); }); // Session ignore chips. @@ -450,7 +1045,7 @@ impl SearchTab { } else if self.limited { ui.label( egui::RichText::new(format!( - "Showing first {} matches; refine the query (limit configurable in Options).", + "Showing first {} matches; refine the query (limit configurable on the Settings tab).", self.results.len() )) .small() @@ -471,7 +1066,6 @@ impl SearchTab { self.advance_fade(ui.input(|i| i.stable_dt)); if self.swap_pending && self.fade <= 0.0 { self.results = std::mem::take(&mut self.staging); - self.has_snippets = self.staging_has_snippets; self.selected = None; self.swap_pending = false; self.wipe = 1.0; @@ -492,18 +1086,32 @@ impl SearchTab { // --- Results table ------------------------------------------------ // Reserve room for the preview strip; only content matches get one. + // Driven by the selection rather than by the column, so the snippet + // stays reachable even with the Content Match column switched off. let preview_snippet: Option = self .selected .and_then(|i| self.results.get(i as usize)) - .filter(|h| matches!(h.stage, 5 | 6 | 8)) + .filter(|h| h.match_field() == MatchField::Contents) .and_then(|h| h.snippet.clone()); let preview_height = if preview_snippet.is_some() { 44.0 } else { 0.0 }; let table_height = (ui.available_height() - preview_height).max(60.0); + // Shown whenever it is picked, whatever the results turned out to be. + // A result set that matched only on names gets a column of em dashes, + // which is the honest reading of "no content match here" — hiding the + // column instead would make a checked box mean nothing. + let show_match = self.columns.content_match; + let cols = self.columns.clone(); + let body_font = egui::TextStyle::Body.resolve(ui.style()); let text_height = body_font.size + 4.0; let mut open_ignore_dialog: Option = None; let mut hovered_now: Option = None; + let mut picked: Option = None; + // egui_extras invokes the body closure only for the rows it actually + // renders, so collecting here *is* the "visually shown, not all + // returned" set, for free. + let mut visible_now: Vec = Vec::new(); let order = std::mem::take(&mut self.order); #[cfg(feature = "capture")] let mut capture_match_rects: Vec = Vec::new(); @@ -512,38 +1120,164 @@ impl SearchTab { // preview strip is laid out. let section_top = ui.cursor().top(); + // What each enabled column may never go under, and what it starts at. + // Order must match the `header.col` and `row.col` calls below. + let mut plans: Vec = Vec::with_capacity(6); + let flex = |floor: f32, initial: f32| ColumnPlan { + kind: ColumnKind::Flex, + floor, + initial, + }; + if cols.name { + plans.push(flex(80.0, 220.0)); + } + // The path column is not optional. + plans.push(flex(120.0, 320.0)); + if show_match { + plans.push(flex(120.0, 320.0)); + } + // Natural widths, and no reason to grow: a date is as long as a date. + // The floor is under the natural width all the same, so a window too + // narrow for the table squeezes these before anything runs off the + // edge — the flex columns give first, and only then these. + for (on, floor, w) in [ + (cols.size, 52.0, 72.0), + (cols.modified, 78.0, 110.0), + (cols.rank, 40.0, 52.0), + ] { + if on { + plans.push(ColumnPlan { + kind: ColumnKind::Fixed, + floor, + initial: w, + }); + } + } + + // What the columns themselves have to divide up: the width the table + // is given, less what the table spends around them. Both parts are + // read from the style rather than inferred from where the columns + // ended up last frame — inferring it is a feedback loop, since a + // frame in which the columns do not fill the width reads as a frame + // with more overhead, which shrinks the budget, which keeps them from + // filling it. `egui_extras` charges the same two: the scrollbar comes + // off `available_rect_before_wrap`, and `Sizing::to_lengths` bills + // one spacing between each pair of columns. + let table_avail = ui.available_width(); + let stale = self.col_widths.len() != plans.len(); + let gaps = plans.len().saturating_sub(1) as f32 * ui.spacing().item_spacing.x; + let budget = (table_avail - ui.spacing().scroll.allocated_width() - gaps).max(0.0); + // A column toggled on or off means egui_extras has dropped the stored + // widths anyway, so start from the plan. + let current: Vec = if stale { + plans.iter().map(|p| p.initial).collect() + } else { + self.col_widths.clone() + }; + + // Which column the pointer is on, if any: the one egui_extras sized + // differently from what was asked for. There is no way to ask it + // directly, and it has to be left alone — refitting a column while it + // is being dragged fights the pointer. + // + // Gated on a held button because a width can differ from the request + // for a duller reason: egui_extras lays a frame out from the widths it + // stored at the end of the *previous* one, so every refit shows up a + // frame late and would otherwise read as a drag. Nothing can be + // dragged with nothing pressed, which settles it. + if !ui.input(|i| i.pointer.any_down()) { + self.col_drag = None; + } else if self.col_drag.is_none() && !stale && self.col_wanted.len() == plans.len() { + self.col_drag = current + .iter() + .zip(&self.col_wanted) + .position(|(a, b)| (a - b).abs() > 0.5); + } + let dragged = self.col_drag.filter(|&i| i < plans.len()); + let targets = fit_around(¤t, &plans, budget, dragged); + + // `width_range` is the only handle on a resizable table's widths, and + // the clamp it drives is what actually moves them — so a column that + // has to move is pinned, and one already where it belongs is left free + // to be dragged. Pinning only what must move is what keeps a drag able + // to start: were every column pinned to its target, no drag could ever + // produce the first pixel of movement that identifies it. + let ranges: Vec<(f32, f32)> = targets + .iter() + .zip(¤t) + .zip(&plans) + .enumerate() + .map(|(i, ((&target, &width), plan))| { + match dragged { + // Left of the divider, and so not the drag's to touch: held + // exactly where it is, because the divider's position is + // measured from this column's edge. See `fit_around`. + Some(held) if i < held => (width, width), + // The dragged column itself follows the pointer, as far as + // the columns to its right can pay for. + Some(held) if i == held => { + (plan.floor, grow_ceiling(¤t, &plans, budget, i)) + } + // Right of the divider: absorbing, so pinned to its share. + Some(_) => (target, target), + None if (target - width).abs() > 0.5 => (target, target), + // Settled, so left free for a drag to start on — with the + // ceiling it will be held to once it does, rather than a + // looser one that would let the first frame jump. + None => (plan.floor, grow_ceiling(¤t, &plans, budget, i)), + } + }) + .collect(); + self.col_wanted = targets; + + let mut measured: Vec = Vec::with_capacity(plans.len()); let table_scroll = ui .push_id("results", |ui| { + // Changing the column count makes egui_extras drop any widths + // the user had dragged. That is the cost of the picker, and a + // deliberate action on their part, so it is not worked around. let mut table = TableBuilder::new(ui) .striped(true) .resizable(true) .sense(egui::Sense::click()) .max_scroll_height(table_height) - .min_scrolled_height(60.0) - .column(Column::initial(220.0).at_least(80.0).clip(true)) // name - .column(Column::remainder().at_least(120.0).clip(true)); // path - if self.has_snippets { - table = table.column(Column::remainder().at_least(120.0).clip(true)); + .min_scrolled_height(60.0); + for (plan, &(lo, hi)) in plans.iter().zip(&ranges) { + table = table.column( + Column::initial(plan.initial) + .at_least(lo) + .at_most(hi) + .clip(true), + ); } - table = table - .column(Column::exact(72.0)) // size - .column(Column::exact(110.0)) // modified - .column(Column::exact(52.0)); // rank table .header(text_height + 4.0, |mut header| { - header.col(|ui| self.sort_header(ui, SortKey::Name, "Name")); - header.col(|ui| self.sort_header(ui, SortKey::Path, "Path")); - if self.has_snippets { + // Each header cell reports its column's laid-out + // width — the only way to read back what a resizable + // table decided, and what the next frame refits from. + let mut head = |sort, label, measured: &mut Vec| { header.col(|ui| { - centered_cell(ui, |ui| { - ui.label(egui::RichText::new("Match").strong()); - }); + measured.push(ui.max_rect().width()); + self.header_cell(ui, sort, label, &mut picked) }); + }; + if cols.name { + head(Some(SortKey::Name), "Name", &mut measured); + } + head(Some(SortKey::Path), "Path", &mut measured); + if show_match { + head(None, "Content Match", &mut measured); + } + if cols.size { + head(Some(SortKey::Size), "Size", &mut measured); + } + if cols.modified { + head(Some(SortKey::Modified), "Modified", &mut measured); + } + if cols.rank { + head(Some(SortKey::Rank), "Rank", &mut measured); } - header.col(|ui| self.sort_header(ui, SortKey::Size, "Size")); - header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified")); - header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank")); }) .body(|body| { body.rows(text_height, order.len(), |mut row| { @@ -552,91 +1286,156 @@ impl SearchTab { let hit = &self.results[result_ix]; row.set_selected(self.selected == Some(result_ix as u32)); row.set_hovered(self.hovered_row == Some(display_ix)); + visible_now.push(result_ix as u32); + let missing = self.gone.contains(&hit.file_id); // Selectable labels win egui's hit-test over the // row, so union their responses into the row's or // clicks over glyphs would miss. let mut cell_responses: Vec = Vec::new(); - row.col(|ui| { - cell_responses.push(ui.label(&hit.name)); - }); + let field = hit.match_field(); + if cols.name { + row.col(|ui| { + // A filename match is highlighted here + // rather than in the Content Match column, + // which shows a dash for it instead. + let marks = (field == MatchField::Name) + .then(|| { + whole_field_ranges(hit.snippet.as_ref(), &hit.name) + }) + .flatten() + .unwrap_or(&[]); + let mut job = marked_field_job(ui, &hit.name, marks); + // A file that has gone from under the row + // reads as struck through rather than + // disappearing, so nothing below it moves + // while it is being read. Same helper the + // other columns use, so one concept has + // one rendering. + if missing { + mark_missing_job(ui, &mut job, true); + } + cell_responses.push(ui.label(job)); + }); + } row.col(|ui| { // Center-elided: egui's own truncation would // drop the deepest directories. Sizing-pass // cell rects are not final, so don't measure // against them. - let shown = if ui.is_sizing_pass() { - std::borrow::Cow::Borrowed(hit.path.as_str()) + let marks = (field == MatchField::Path) + .then(|| whole_field_ranges(hit.snippet.as_ref(), &hit.path)) + .flatten() + .unwrap_or(&[]); + let (mut job, elided) = if ui.is_sizing_pass() { + // Nothing to elide against, so this is + // the plain marked field. + (marked_field_job(ui, &hit.path, marks), false) } else { - middle_elide( + path_cell_job( ui, &hit.path, + marks, // A point of slack against rounding // disagreements with egui's layout. ui.available_width() - 1.0, &body_font, ) }; - let elided = matches!(shown, std::borrow::Cow::Owned(_)); + // The path *is* the thing that no longer + // exists, so it is struck through like the + // name rather than merely dimmed. + if missing { + mark_missing_job(ui, &mut job, true); + } // egui offers a full-text tooltip only when // *it* elided the galley — and it is handed // the already-shortened string here. - let mut response = ui.add( - egui::Label::new(egui::RichText::new(shown.as_ref()).weak()) - .show_tooltip_when_elided(false), - ); + let mut response = + ui.add(egui::Label::new(job).show_tooltip_when_elided(false)); if elided { response = response.on_hover_text(&hit.path); } cell_responses.push(response); }); - if self.has_snippets { - let snippet = hit.snippet.as_ref(); - // Name and path matches show a whole field, - // rendered bracketed: [matched field]. - let whole_field = - hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9; + if show_match { + let snippet = hit + .snippet + .as_ref() + .filter(|_| field == MatchField::Contents); row.col(|ui| { - if let Some(snip) = snippet { - let width = ui.available_width(); - let job = centered_match_job(ui, snip, width, whole_field); - let mut response = centered_cell(ui, |ui| ui.label(job)); - if !snip.ranges.is_empty() { - response = response.on_hover_ui(|ui| { - ui.set_max_width(520.0); - let job = snippet_job(ui, snip, 10); - ui.label(job); - }); + let response = match snippet { + Some(snip) => { + let width = ui.available_width(); + let mut job = centered_match_job(ui, snip, width); + // Dimmed, not struck through: the + // text is what the file *held*, + // not a name that has gone stale. + if missing { + mark_missing_job(ui, &mut job, false); + } + let mut response = + centered_cell(ui, |ui| ui.label(job)); + if !snip.ranges.is_empty() { + response = response.on_hover_ui(|ui| { + ui.set_max_width(520.0); + let job = snippet_job(ui, snip, 10); + ui.label(job); + }); + } + response } - #[cfg(feature = "capture")] - capture_match_rects.push(response.rect); - cell_responses.push(response); - } + // No tooltip: for a name hit it would + // restate the filename already on + // screen, highlighted, two columns left. + None => centered_cell(ui, |ui| { + ui.label(egui::RichText::new(NO_CONTENT_MATCH).weak()) + }), + }; + #[cfg(feature = "capture")] + capture_match_rects.push(response.rect); + cell_responses.push(response); }); } - row.col(|ui| { - let response = - centered_cell(ui, |ui| ui.label(human_size(hit.size))); - cell_responses.push(response); - }); - row.col(|ui| { - let color = recency_color(ui, hit.mtime); - let response = centered_cell(ui, |ui| { - ui.label(egui::RichText::new(fmt_mtime(hit.mtime)).color(color)) + if cols.size { + row.col(|ui| { + let text = egui::RichText::new(human_size(hit.size)); + let text = if missing { text.weak() } else { text }; + let response = centered_cell(ui, |ui| ui.label(text)); + cell_responses.push(response); }); - cell_responses.push(response); - }); - row.col(|ui| { - let response = centered_cell(ui, |ui| { - ui.label( - egui::RichText::new(format!(" {:.2} ", hit.rank)) - .background_color(rank_tier_color(hit.stage)) - .color(egui::Color32::from_rgb(32, 32, 32)), - ) + } + if cols.modified { + row.col(|ui| { + // Recency colouring says "this file was + // touched recently", which is a claim + // about a file that still exists. + let color = if missing { + ui.visuals().weak_text_color() + } else { + recency_color(ui, hit.mtime) + }; + let response = centered_cell(ui, |ui| { + ui.label( + egui::RichText::new(fmt_mtime(hit.mtime)).color(color), + ) + }); + cell_responses.push(response); }); - cell_responses.push(response); - }); + } + if cols.rank { + row.col(|ui| { + let response = centered_cell(ui, |ui| { + ui.label( + egui::RichText::new(format!(" {:.2} ", hit.rank)) + .background_color(rank_tier_color(hit.stage)) + .color(egui::Color32::from_rgb(32, 32, 32)), + ) + }); + cell_responses.push(response); + }); + } let mut response = row.response(); for r in cell_responses { @@ -657,7 +1456,7 @@ impl SearchTab { platform::reveal_in_folder(&path); ui.close(); } - if ui.button("Open").clicked() { + if ui.button("Open File").clicked() { platform::open_file(&path); ui.close(); } @@ -675,9 +1474,58 @@ impl SearchTab { }) }) .inner; + // Sizing passes lay out a throwaway sample; taking their widths would + // feed the next refit a measurement of nothing. + if measured.len() == plans.len() { + self.col_widths = measured; + } self.order = order; + actions.save_columns = picked; crate::ui_util::more_below_hint(ui, &table_scroll); self.hovered_row = hovered_now; + + // --- Live results: watch what is on screen once it holds still ----- + { + let now = Instant::now(); + // Rebuilt only when it actually differs: this runs every frame, + // and cloning a screenful of paths each time would be a steady + // drip of allocation for nothing. + if !self.live_wanted_current(&visible_now) { + let rebuilt: Vec = visible_now + .iter() + .filter_map(|&ix| self.results.get(ix as usize)) + .map(target_for) + .collect(); + // Only a different watch *set* restarts the arm delay. A row + // whose size or modified time moved under it needs a fresh + // baseline for the next sweep, not a fresh registration. + if !same_watch_set(&rebuilt, &self.live_wanted) { + self.live_changed_at = Some(now); + } + self.live_wanted = rebuilt; + } + let settled = self.settled(); + let armed_already = same_watch_set(&self.live_wanted, &self.live_armed); + if should_arm( + self.live_enabled, + armed_already, + self.live_changed_at, + settled, + now, + ) { + self.live_armed = self.live_wanted.clone(); + actions.live_targets = Some(self.live_wanted.clone()); + } else if settled && !armed_already { + // Once the reveal settles nothing else asks for frames, so a + // bare `Instant` deadline would never come due. Same shape as + // the search debounce in `app::tick_debounce`. + if let Some(changed) = self.live_changed_at { + let waited = now.duration_since(changed); + ui.ctx() + .request_repaint_after(LIVE_ARM_DELAY.saturating_sub(waited)); + } + } + } #[cfg(feature = "capture")] { self.capture_match_rects = capture_match_rects; diff --git a/crates/quicksearch-gui/src/search_tab/snippet_render.rs b/crates/quicksearch-gui/src/search_tab/snippet_render.rs index f46f56e..12d707c 100644 --- a/crates/quicksearch-gui/src/search_tab/snippet_render.rs +++ b/crates/quicksearch-gui/src/search_tab/snippet_render.rs @@ -37,6 +37,11 @@ const SNIPPET_LEAD: &str = "… "; /// Append `window[range]` to `job`, highlighting whatever parts of `ranges` /// (byte offsets into `window`) fall inside it. +/// +/// Ranges are clipped to the slice, so a caller rendering a string in pieces +/// can hand each piece the *whole* set: a range inside this one survives, one +/// straddling an edge survives as the part that is here, and one wholly +/// outside disappears. fn append_marked( job: &mut LayoutJob, fmt: &SnippetFormats, @@ -61,6 +66,18 @@ fn append_marked( } } +/// A whole field — a filename — with its matched spans marked. +/// +/// Wrapping is left at the job's defaults on purpose: `egui::Label` overwrites +/// only `wrap.max_width`, so this is laid out exactly like the plain string it +/// replaces, and the cell keeps the height and clipping it had before. +pub(super) fn marked_field_job(ui: &egui::Ui, text: &str, ranges: &[(usize, usize)]) -> LayoutJob { + let fmt = snippet_formats(ui); + let mut job = LayoutJob::default(); + append_marked(&mut job, &fmt, text, ranges, 0..text.len()); + job +} + /// The byte offset in `snip.window` that rendering has to start at for the /// first match to land on a row that survives `max_rows`; `0` when it /// already does. epaint stops at `wrap.max_rows` and *every* `\n` costs a @@ -159,16 +176,14 @@ pub(super) fn snippet_job(ui: &egui::Ui, snip: &Snippet, max_rows: usize) -> Lay job } -/// The Match column cell: one line with the (first) matched span centered -/// and an equal amount of context on both sides, trimmed to what fits the -/// column width. Matches on a whole field — a filename or a path — are -/// wrapped in brackets: `[name]`. -pub(super) fn centered_match_job( - ui: &egui::Ui, - snip: &Snippet, - width_px: f32, - whole_field: bool, -) -> LayoutJob { +/// The Content Match column cell: one line with the (first) matched span +/// centered and an equal amount of context on both sides, trimmed to what fits +/// the column width. +/// +/// Only ever called with a content snippet. Name and path matches are +/// highlighted in their own columns and leave a dash here, so the bracketed +/// `[whole field]` rendering this used to carry is gone. +pub(super) fn centered_match_job(ui: &egui::Ui, snip: &Snippet, width_px: f32) -> LayoutJob { let fmt = snippet_formats(ui); // Newlines force line breaks even in a one-row LayoutJob; flatten them @@ -189,11 +204,6 @@ pub(super) fn centered_match_job( let font_id = &fmt.normal.font_id; let width_of = |c: char| f.glyph_width(font_id, c); let ellipsis = width_of('…'); - let brackets = if whole_field { - width_of('[') + width_of(']') - } else { - 0.0 - }; let mut marks = 0.0; if snip.truncated_start { marks += ellipsis; @@ -201,13 +211,13 @@ pub(super) fn centered_match_job( if snip.truncated_end { marks += ellipsis; } - if fits_within(window, width_px - brackets - marks, width_of) { + if fits_within(window, width_px - marks, width_of) { return (0, window.len(), true); } // Something has to go, so either end may gain a mark; reserve for // both so a cut never overflows the column. - let budget = width_px - brackets - 2.0 * ellipsis; + let budget = width_px - 2.0 * ellipsis; let Some(&(a, b)) = snip.ranges.first() else { // No ranges (shouldn't happen for match cells) — head trim. return (0, take_forward(window, 0, budget.max(0.0), width_of), true); @@ -261,22 +271,52 @@ pub(super) fn centered_match_job( let mut job = LayoutJob::default(); job.wrap.max_rows = 1; job.wrap.break_anywhere = true; - if whole_field && decorate { - job.append("[", 0.0, fmt.weak.clone()); - } if decorate && (start > 0 || snip.truncated_start) { job.append("…", 0.0, fmt.weak.clone()); } append_marked(&mut job, &fmt, window, &snip.ranges, start..end); if decorate && (end < window.len() || snip.truncated_end) { - job.append("…", 0.0, fmt.weak.clone()); - } - if whole_field && decorate { - job.append("]", 0.0, fmt.weak); + job.append("…", 0.0, fmt.weak); } job } +/// The Path column cell: middle-elided to `width_px`, with whatever of a +/// path-tier match survives the cut highlighted. +/// +/// The path reads at full strength — it is the one column that identifies a +/// result on its own. Only the elision mark is weak, since it is punctuation +/// this renderer added rather than anything the file is named. +/// +/// Returns the job and whether anything was actually elided — the caller's +/// trigger for a full-path tooltip, since egui offers one only when *it* did +/// the eliding and it is handed an already-shortened string. +pub(super) fn path_cell_job( + ui: &egui::Ui, + path: &str, + ranges: &[(usize, usize)], + width_px: f32, + font_id: &egui::FontId, +) -> (LayoutJob, bool) { + let fmt = snippet_formats(ui); + let mut job = LayoutJob::default(); + match crate::ui_util::middle_elide_cut(ui, path, width_px, font_id) { + // It fits: the whole path, marked — which is exactly + // [`marked_field_job`]. + None => (marked_field_job(ui, path, ranges), false), + // The two surviving ends are appended straight from `path` at their + // original offsets: `append_marked` clips the ranges to each end, so a + // match that fell in the dropped middle drops with it rather than + // landing on whatever glyphs moved into those offsets. + Some((head, tail)) => { + append_marked(&mut job, &fmt, path, ranges, 0..head); + job.append("…", 0.0, fmt.weak.clone()); + append_marked(&mut job, &fmt, path, ranges, tail..path.len()); + (job, true) + } + } +} + /// Whether the whole of `text` fits in `budget` pixels; stops at the first /// character that does not. fn fits_within(text: &str, budget: f32, width_of: impl Fn(char) -> f32) -> bool { diff --git a/crates/quicksearch-gui/src/search_tab/tests.rs b/crates/quicksearch-gui/src/search_tab/tests.rs index 8b9886c..52a1b9d 100644 --- a/crates/quicksearch-gui/src/search_tab/tests.rs +++ b/crates/quicksearch-gui/src/search_tab/tests.rs @@ -1,7 +1,12 @@ use super::*; +/// A tab with the shipped defaults: Size and Modified off, live results on. +fn new_tab() -> SearchTab { + SearchTab::new(false, ColumnsConfig::default(), true) +} + fn tab_with_results(n: usize) -> SearchTab { - let mut tab = SearchTab::new(false); + let mut tab = new_tab(); tab.query = "alpha".into(); tab.focus_query = false; tab.results = (0..n) @@ -50,11 +55,14 @@ fn hover_row_text(ctx: &egui::Context, tab: &mut SearchTab, row: usize) -> egui: use crate::test_ui::painted_text; -/// Far too long for the Path column at the test's 1000pt screen width. +/// Far too long for the Path column at the test's 1000pt screen width, with +/// the shipped default columns (Size and Modified off, so the two remainder +/// columns are correspondingly wider). fn deep_path() -> String { concat!( "/media/shared/QuickSearch/crates/quicksearch-gui/src/", - "deeply/nested/under/several/more/directories/alpha_widget_0.txt" + "deeply/nested/under/several/more/directories/that/keep/going/", + "well/past/anything/a/column/could/show/alpha_widget_0.txt" ) .to_string() } @@ -158,7 +166,7 @@ fn batch(tab: &mut SearchTab, hits: Vec) { /// A tab already past the fade, so batches land straight in `results`. fn streaming_tab() -> SearchTab { - let mut tab = SearchTab::new(false); + let mut tab = new_tab(); tab.focus_query = false; tab.query = "zebra".into(); tab.swap_pending = false; @@ -207,6 +215,10 @@ fn batches_respect_a_non_rank_sort_key() { "name order, not arrival or rank order" ); + // Sorting by a column only works while that column is on screen — see + // `effective_sort`, which demotes a hidden key to Rank so nobody gets + // stranded in an order they cannot see or change. + tab.columns.size = true; tab.sort = (SortKey::Size, false); tab.sort_dirty = true; tab.resort(); @@ -216,6 +228,43 @@ fn batches_respect_a_non_rank_sort_key() { ); } +/// Hiding the column the table is sorted by falls back to Rank rather than +/// leaving an order with nothing on screen to explain or undo it. +#[test] +fn a_sort_key_whose_column_is_hidden_falls_back_to_rank() { + let cols = ColumnsConfig { + size: true, + modified: true, + ..ColumnsConfig::default() + }; + + for key in [SortKey::Name, SortKey::Size, SortKey::Modified] { + assert_eq!(effective_sort((key, false), &cols), (key, false)); + } + // Path and Rank hold whatever the columns say: the path column cannot be + // switched off, and a rank order needs no column to be meaningful. + for key in [SortKey::Path, SortKey::Rank] { + assert_eq!( + effective_sort((key, false), &ColumnsConfig::default()), + (key, false) + ); + } + + let off = ColumnsConfig { + name: false, + size: false, + modified: false, + ..ColumnsConfig::default() + }; + for key in [SortKey::Name, SortKey::Size, SortKey::Modified] { + assert_eq!( + effective_sort((key, false), &off), + (SortKey::Rank, true), + "{key:?} survived its column being hidden" + ); + } +} + /// Re-keying the sort mid-stream re-orders rows already shown, and later /// batches land under the new key. #[test] @@ -293,7 +342,7 @@ fn a_late_better_hit_displaces_the_worst_at_the_cap() { /// Batches arriving while the old table fades out are ordered at the swap. #[test] fn staged_batches_are_ordered_once_the_fade_swaps() { - let mut tab = SearchTab::new(false); + let mut tab = new_tab(); tab.focus_query = false; tab.query = "zebra".into(); tab.on_search_started(1); @@ -333,7 +382,7 @@ fn run_fade(tab: &mut SearchTab, done: impl Fn(&SearchTab) -> bool) -> f32 { #[test] fn each_half_of_the_transition_takes_its_own_duration() { - let mut tab = SearchTab::new(false); + let mut tab = new_tab(); tab.swap_pending = true; let out = run_fade(&mut tab, |t| t.fade <= 0.0); assert_eq!(tab.fade, 0.0, "settles exactly on invisible"); @@ -355,7 +404,7 @@ fn each_half_of_the_transition_takes_its_own_duration() { #[test] fn a_stalled_frame_does_not_overshoot() { - let mut tab = SearchTab::new(false); + let mut tab = new_tab(); tab.swap_pending = true; tab.advance_fade(10.0); assert_eq!(tab.fade, 0.0, "a whole ten seconds lands, not passes"); @@ -371,7 +420,7 @@ fn a_stalled_frame_does_not_overshoot() { /// backwards it would flash just-covered rows back on screen. #[test] fn clearing_results_holds_the_reveal_where_it_stands() { - let mut tab = SearchTab::new(false); + let mut tab = new_tab(); tab.wipe = 1.0; tab.advance_fade(FADE_IN_SECS / 5.0); let standing = tab.wipe; @@ -396,7 +445,7 @@ fn clearing_results_holds_the_reveal_where_it_stands() { #[test] fn a_settled_section_stops_asking_for_frames() { - let mut tab = SearchTab::new(false); + let mut tab = new_tab(); // Nothing pending, nothing covered, nothing dimmed: the steady state // must not repaint forever. assert!(tab.fade_settled()); @@ -761,11 +810,11 @@ fn a_snippet_that_fits_is_left_alone() { }); } -/// The Match cell is laid out in Extend mode (infinite wrap width), so +/// The Content Match cell is laid out in Extend mode (infinite wrap width), so /// only its own budget keeps it inside the column; an overshoot is /// clipped on *both* sides with no ellipsis. #[test] -fn the_match_cell_stays_inside_its_column() { +fn the_content_match_cell_stays_inside_its_column() { with_ui(|ui| { let snip = Snippet { window: "a long stretch of leading context NEEDLE and a long tail after it".into(), @@ -776,32 +825,30 @@ fn the_match_cell_stays_inside_its_column() { // Down to widths the column itself cannot reach, so the budget // degrades rather than overflowing. for width in [20.0, 60.0, 90.0, 120.0, 150.0, 240.0, 400.0, 4000.0] { - for whole_field in [false, true] { - let job = centered_match_job(ui, &snip, width, whole_field); - let galley = ui.fonts(|f| f.layout_job(job)); - assert!( - galley.size().x <= width, - "{}pt of text in a {width}pt column (whole_field={whole_field}): {:?}", - galley.size().x, - galley.text() - ); - // At the column's 120pt floor the whole hit must survive; - // below that, its head still gets the room over context. - let kept = if width >= 120.0 { "NEEDLE" } else { "N" }; - assert!( - galley.text().contains(kept), - "the match was budgeted away at {width}pt: {:?}", - galley.text() - ); - } + let job = centered_match_job(ui, &snip, width); + let galley = ui.fonts(|f| f.layout_job(job)); + assert!( + galley.size().x <= width, + "{}pt of text in a {width}pt column: {:?}", + galley.size().x, + galley.text() + ); + // At the column's 120pt floor the whole hit must survive; + // below that, its head still gets the room over context. + let kept = if width >= 120.0 { "NEEDLE" } else { "N" }; + assert!( + galley.text().contains(kept), + "the match was budgeted away at {width}pt: {:?}", + galley.text() + ); } }); } -/// Hovering the Match cell puts the hit on screen. The cell itself paints +/// Hovering the Content Match cell puts the hit on screen. The cell itself paints /// the match once, so the tooltip is the *second* appearance. #[test] -fn hovering_the_match_cell_shows_the_match_in_the_tooltip() { +fn hovering_the_content_match_cell_shows_the_match_in_the_tooltip() { let ctx = egui::Context::default(); // Testing that the tooltip carries the match, not egui's hover timing. ctx.style_mut(|s| { @@ -809,18 +856,32 @@ fn hovering_the_match_cell_shows_the_match_in_the_tooltip() { s.interaction.show_tooltips_only_when_still = false; }); let mut tab = tab_with_results(1); - tab.has_snippets = true; - tab.results[0].stage = 6; // a full-text stage: no [brackets] + tab.results[0].stage = 6; // a full-text stage tab.results[0].snippet = Some(ragged_snippet(40)); run_frame(&ctx, &mut tab, vec![]); // settle the table's layout + + // Find the row first, over the Name column, which is always leftmost. + // Then sweep for the Match cell rather than assuming an x: the columns + // share the window's width between them, so where the cell sits depends + // on the window and on which columns are showing. + let mut row_y = None; for y in 40..250 { - // x lands in the Match column, past Name and Path. - let pos = egui::pos2(600.0, y as f32); - let mut out = run_frame(&ctx, &mut tab, vec![egui::Event::PointerMoved(pos)]); - if tab.hovered_row != Some(0) { - continue; + run_frame( + &ctx, + &mut tab, + vec![egui::Event::PointerMoved(egui::pos2(60.0, y as f32))], + ); + if tab.hovered_row == Some(0) { + row_y = Some(y as f32); + break; } + } + let row_y = row_y.expect("no row under the pointer anywhere down the name column"); + + for x in (80..960).step_by(20) { + let pos = egui::pos2(x as f32, row_y); + let mut out = run_frame(&ctx, &mut tab, vec![egui::Event::PointerMoved(pos)]); // The tooltip is its own area, so it may land a frame behind. for _ in 0..3 { let showing = painted_rows(&out) @@ -835,3 +896,1477 @@ fn hovering_the_match_cell_shows_the_match_in_the_tooltip() { } panic!("the match never appeared in the hover tooltip"); } + +// --- Columns, highlighting, and the query strip --------------------------- + +use crate::test_ui::{click_at, painted, painted_backgrounds, painted_text_center}; + +/// `run_frame`, but keeping the actions the tab reported. Several of the +/// controls below exist only to produce one. +fn run_frame_actions( + ctx: &egui::Context, + tab: &mut SearchTab, + events: Vec, +) -> (egui::FullOutput, SearchActions) { + let input = crate::test_ui::raw_input(egui::vec2(1000.0, 700.0), events); + let mut actions = SearchActions::default(); + let out = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + actions = tab.ui(ui); + }); + }); + (out, actions) +} + +/// A hit that matched on its filename, carrying the whole-field snippet core +/// promises for the name tiers. +fn name_hit(name: &str, mark: (usize, usize)) -> SearchHit { + SearchHit { + file_id: 1, + name: name.to_string(), + path: format!("/qs-test/{name}"), + size: 116, + mtime: 1_700_000_000, + rank: 3.0, + stage: 3, + snippet: Some(Snippet { + window: name.to_string(), + ranges: vec![mark], + truncated_start: false, + truncated_end: false, + }), + } +} + +/// The runs painted as a *matched* span. Keyed on the highlight's background, +/// not its text color: the column headers are painted strong too, and the rank +/// chip has a background of its own. +fn highlight_runs(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { + let marked = ctx.style().visuals.selection.bg_fill.gamma_multiply(0.4); + painted_backgrounds(out) + .into_iter() + .filter(|(_, bg)| *bg == marked) + .map(|(text, _)| text) + .collect() +} + +/// Size and modified cost more width than they earn for most searches, so the +/// table ships without them; both are one click away in the header menu. +#[test] +fn size_and_modified_are_off_by_default_and_can_be_switched_on() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + + let painted = painted_text(&run_frame(&ctx, &mut tab, vec![])); + assert!(!painted.contains(&"Size".to_string()), "{painted:?}"); + assert!(!painted.contains(&"Modified".to_string()), "{painted:?}"); + assert!(!painted.contains(&"116 B".to_string()), "{painted:?}"); + assert!(painted.contains(&"Path".to_string()), "{painted:?}"); + + tab.columns.size = true; + tab.columns.modified = true; + let painted = painted_text(&run_frame(&ctx, &mut tab, vec![])); + assert!(painted.contains(&"Size".to_string()), "{painted:?}"); + assert!(painted.contains(&"Modified".to_string()), "{painted:?}"); + assert!(painted.contains(&"116 B".to_string()), "{painted:?}"); +} + +// --- Column widths ------------------------------------------------------- + +fn flex(specs: &[(f32, f32)]) -> Vec { + specs + .iter() + .map(|&(floor, initial)| ColumnPlan { + kind: ColumnKind::Flex, + floor, + initial, + }) + .collect() +} + +/// The results table's own shape: three flexing text columns then Rank fixed. +fn text_and_rank() -> Vec { + let mut p = flex(&[(80.0, 220.0), (120.0, 320.0), (120.0, 320.0)]); + p.push(ColumnPlan { + kind: ColumnKind::Fixed, + floor: 40.0, + initial: 52.0, + }); + p +} + +/// AG Grid's own worked example, transcribed: a 450px grid holding one 150px +/// fixed column, one `flex: 1` and one `flex: 2` lays out 150 / 100 / 200. +/// +/// Weights here are the columns' current widths rather than a separate `flex` +/// number, so a 1:2 split is written as two flex columns currently 100 and 200 +/// wide. Same allocation, and it is what lets a dragged column keep its share +/// without anything having to store a weight. +#[test] +fn flex_columns_divide_what_the_fixed_ones_leave() { + let plans = vec![ + ColumnPlan { + kind: ColumnKind::Fixed, + floor: 50.0, + initial: 150.0, + }, + ColumnPlan { + kind: ColumnKind::Flex, + floor: 50.0, + initial: 100.0, + }, + ColumnPlan { + kind: ColumnKind::Flex, + floor: 50.0, + initial: 200.0, + }, + ]; + assert_eq!( + fit_widths(&[150.0, 100.0, 200.0], &plans, 450.0), + vec![150.0, 100.0, 200.0] + ); + // The fixed column keeps its 150 whatever the grid does; the flex pair + // shares the rest, still 1:2. + assert_eq!( + fit_widths(&[150.0, 100.0, 200.0], &plans, 750.0), + vec![150.0, 200.0, 400.0] + ); +} + +/// A fixed column does not grow with the window. Rank is 52 points of digits +/// on a laptop and on a 4K panel alike. +#[test] +fn a_fixed_column_keeps_its_width_when_the_window_grows() { + let plans = text_and_rank(); + let narrow = fit_widths(&[220.0, 320.0, 320.0, 52.0], &plans, 912.0); + let wide = fit_widths(&narrow, &plans, 1512.0); + + assert_eq!(narrow[3], 52.0); + assert_eq!(wide[3], 52.0, "Rank grew with the window"); + // The 600 went to the three text columns, in proportion. + for i in 0..3 { + assert!( + wide[i] > narrow[i], + "flex column {i} did not take its share" + ); + } + assert!((wide.iter().sum::() - 1512.0).abs() < 0.01, "{wide:?}"); +} + +/// Once every flex column is at its floor the fixed ones have to give, or the +/// table hangs off the edge of a narrow window. +#[test] +fn fixed_columns_shrink_only_once_the_flex_ones_have_bottomed_out() { + let plans = text_and_rank(); + // The flex floors come to 320 and Rank sits at 52: 372 wanted, 365 there. + let widths = fit_widths(&[220.0, 320.0, 320.0, 52.0], &plans, 365.0); + assert_eq!(&widths[..3], &[80.0, 120.0, 120.0], "flex floors first"); + assert!( + widths[3] < 52.0, + "Rank should have given up the difference: {widths:?}" + ); + assert!( + (widths.iter().sum::() - 365.0).abs() < 0.01, + "{widths:?}" + ); + assert!( + widths[3] >= 40.0, + "Rank went under its own floor: {widths:?}" + ); +} + +/// A drag states a width, so the layout takes it as given and the other +/// columns absorb — including the space freed by narrowing one, which is the +/// case that used to leave a blank strip down the right of the table. +#[test] +fn a_dragged_column_is_taken_as_given_and_the_rest_absorb() { + let plans = text_and_rank(); + let budget = 912.0; + let before = fit_widths(&[220.0, 320.0, 320.0, 52.0], &plans, budget); + + // Name dragged down to 100. + let mut dragged = before.clone(); + dragged[0] = 100.0; + let after = fit_around(&dragged, &plans, budget, Some(0)); + + assert_eq!(after[0], 100.0, "the drag was overruled"); + assert!( + (after.iter().sum::() - budget).abs() < 0.01, + "{after:?}" + ); + assert!(after[1] > before[1] && after[2] > before[2], "{after:?}"); + assert_eq!(after[3], 52.0, "a fixed column absorbed the drag"); + + // Held no wider than leaves everyone else their floor. + let hogged = fit_around(&[5000.0, 320.0, 320.0, 52.0], &plans, budget, Some(0)); + assert!( + (hogged.iter().sum::() - budget).abs() < 0.01, + "a drag overflowed the table: {hogged:?}" + ); +} + +/// The rule chosen over AG Grid's: a dragged column rejoins the pool, so the +/// next window resize scales it with the others and it keeps the *share* it +/// was given rather than those pixels. +#[test] +fn a_dragged_flex_column_keeps_its_share_across_a_resize() { + let plans = text_and_rank(); + let dragged = fit_around(&[100.0, 320.0, 320.0, 52.0], &plans, 912.0, Some(0)); + let flex_total: f32 = dragged[..3].iter().sum(); + let share = dragged[0] / flex_total; + + let resized = fit_widths(&dragged, &plans, 1512.0); + let resized_share = resized[0] / resized[..3].iter().sum::(); + assert!( + (resized_share - share).abs() < 0.001, + "the share moved from {share} to {resized_share}" + ); + assert!(resized[0] > dragged[0], "it did not scale up with the rest"); +} + +fn plans(specs: &[(f32, f32)]) -> Vec { + flex(specs) +} + +/// Refitting keeps the shape the user dragged the table into; only the scale +/// changes. This is what a window resize runs through. +#[test] +fn refitting_fills_the_budget_and_keeps_the_proportions() { + let p = plans(&[(80.0, 220.0), (120.0, 260.0), (120.0, 260.0)]); + // No floor binds at this budget, so this is the proportions alone. + let widths = fit_widths(&[100.0, 200.0, 100.0], &p, 800.0); + + assert!( + (widths.iter().sum::() - 800.0).abs() < 0.01, + "the columns must fill the budget exactly: {widths:?}" + ); + // Doubled budget, doubled columns, same 1:2:1 shape. + assert_eq!(widths, vec![200.0, 400.0, 200.0]); +} + +/// A column that would be refitted under its floor is pinned there and the +/// rest re-share what is left — repeatedly, because pinning one can push the +/// next under. +#[test] +fn a_column_pinned_to_its_floor_does_not_starve_the_others() { + let p = plans(&[(80.0, 220.0), (120.0, 260.0), (120.0, 260.0)]); + // Scaling 1:8:1 into 400 would give the outer two 40, under both floors. + let widths = fit_widths(&[100.0, 800.0, 100.0], &p, 400.0); + + assert_eq!(widths[0], 80.0, "the name column is at its floor"); + assert_eq!(widths[2], 120.0, "the content column is at its floor"); + assert_eq!(widths[1], 200.0, "the rest went to the free column"); + assert!( + (widths.iter().sum::() - 400.0).abs() < 0.01, + "{widths:?}" + ); +} + +/// Narrower than the floors add up to, the floors win and the table overflows +/// — there is no width that satisfies both, and a column collapsed to nothing +/// is worse than one clipped. +#[test] +fn a_window_narrower_than_the_floors_keeps_the_floors() { + let p = plans(&[(80.0, 220.0), (120.0, 260.0), (120.0, 260.0)]); + assert_eq!( + fit_widths(&[200.0, 200.0, 200.0], &p, 100.0), + vec![80.0, 120.0, 120.0] + ); +} + +/// A first frame has nothing measured; every column measuring zero must not +/// divide by zero or hand back a table of nothing. +#[test] +fn refitting_without_a_measurement_splits_the_budget_evenly() { + let p = plans(&[(80.0, 220.0), (120.0, 260.0)]); + let widths = fit_widths(&[0.0, 0.0], &p, 400.0); + assert_eq!(widths, vec![200.0, 200.0]); + assert!(fit_widths(&[], &[], 400.0).is_empty()); +} + +/// The bug this exists for: the table has to follow its window. `egui_extras` +/// reloads a resizable column as `Size::exact(stored_width)`, so nothing +/// re-fits on its own and a narrowed window leaves the right-hand columns +/// past the edge, unreachable and looking switched off. +#[test] +fn the_columns_follow_the_window_when_it_is_resized() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(3); + tab.columns = ColumnsConfig { + name: true, + content_match: true, + size: false, + modified: false, + rank: true, + }; + + let width_of = |ctx: &egui::Context, tab: &mut SearchTab, w: f32| -> Vec { + // Twice: the first frame lays out at the new width, the second + // measures what that produced. + for _ in 0..2 { + let input = crate::test_ui::raw_input(egui::vec2(w, 700.0), Vec::new()); + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + tab.ui(ui); + }); + }); + } + tab.col_widths.clone() + }; + + let wide = width_of(&ctx, &mut tab, 1200.0); + assert_eq!(wide.len(), 4, "name, path, content match, rank"); + let wide_total: f32 = wide.iter().sum(); + + let narrow = width_of(&ctx, &mut tab, 700.0); + let narrow_total: f32 = narrow.iter().sum(); + assert!( + narrow_total < wide_total - 400.0, + "the columns did not follow the window down: {wide_total} then {narrow_total}" + ); + assert!( + narrow_total <= 700.0, + "the table is wider than its window: {narrow_total} in 700" + ); + + // And back out again — the columns have to grow with the window too, or + // the table sits in a strip down the left of a maximised window. + let regrown: f32 = width_of(&ctx, &mut tab, 1200.0).iter().sum(); + assert!( + regrown > narrow_total + 400.0, + "the columns did not follow the window back up: {narrow_total} then {regrown}" + ); +} + +/// The other half: a drag may never push a column past the edge. Growth is +/// bounded by the slack that is actually left, so once the table fills its +/// window a column can only be widened by narrowing another first. +#[test] +fn a_column_cannot_be_dragged_wider_than_the_slack_that_is_left() { + let p = plans(&[(80.0, 220.0), (120.0, 260.0)]); + let budget = 400.0; + // The table already fills its window. + let current = fit_widths(&[200.0, 200.0], &p, budget); + let slack = budget - current.iter().sum::(); + assert!(slack.abs() < 0.01, "the fixture must start full: {slack}"); + + // This is the bound the table hands egui_extras as `at_most`. + for (&w, plan) in current.iter().zip(&p) { + let at_most = (w + slack).max(plan.floor); + assert!( + at_most <= w + 0.01, + "a full table still offered {at_most} of room for a {w} column" + ); + } +} + +/// End to end, through egui_extras' own drag handling: grab the first +/// column's resize handle and haul it far past the right edge of the window. +/// +/// Before the refit this ran the total up to whatever the pointer asked for +/// and *left it there*, pushing Rank — and then Content Match — off the edge, +/// where nothing scrolls to reach them. +/// +/// The contract is about where the table settles, not about every frame in +/// between. `egui_extras` lays a frame out from the widths it stored at the +/// end of the previous one, so the loop runs a frame behind at each step: +/// the drag reaches the measurement, the measurement decides the reflow, the +/// reflow reaches the measurement. What a drag can overshoot by is therefore +/// how far the pointer travelled in those frames — 20-odd points at 60 fps, +/// and bounded by the other columns' floors regardless. The steps below are +/// 400 points each, twenty times a realistic frame's worth, precisely so that +/// the settling is what is measured. +#[test] +fn dragging_a_column_cannot_push_the_table_off_the_edge() { + const W: f32 = 900.0; + const STEP: f32 = 400.0; + let ctx = egui::Context::default(); + let mut tab = tab_with_results(3); + tab.columns = ColumnsConfig { + name: true, + content_match: true, + size: false, + modified: false, + rank: true, + }; + + // A free function rather than a closure: the assertions between drag + // steps read `tab.col_widths`, which a closure capturing `tab` would hold + // borrowed. + fn frame(ctx: &egui::Context, tab: &mut SearchTab, events: Vec) { + let input = crate::test_ui::raw_input(egui::vec2(W, 700.0), events); + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + tab.ui(ui); + }); + }); + } + fn total(tab: &SearchTab) -> f32 { + tab.col_widths.iter().sum() + } + /// Run frames, changing nothing, until the widths stop moving. + /// + /// A fixed count rather than "stop when two frames agree": the total sits + /// unchanged for the two frames the measurement and the reflow each spend + /// in the pipeline, so stopping on the first repeat stops before the + /// answer arrives. + fn settle(ctx: &egui::Context, tab: &mut SearchTab) -> f32 { + for _ in 0..8 { + frame(ctx, tab, Vec::new()); + } + total(tab) + } + let settled = settle(&ctx, &mut tab); + assert!(settled <= W, "the fixture starts overflowing: {settled}"); + + // The Name column's right edge, then a drag far beyond the window. + let handle_x = tab.col_widths[0] + 10.0; + let y = 60.0; + frame( + &ctx, + &mut tab, + vec![ + egui::Event::PointerMoved(egui::pos2(handle_x, y)), + egui::Event::PointerButton { + pos: egui::pos2(handle_x, y), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ], + ); + for step in 1..=8 { + let x = handle_x + step as f32 * STEP; + frame( + &ctx, + &mut tab, + vec![egui::Event::PointerMoved(egui::pos2(x, y))], + ); + // Still held, still where it was, until the widths stop moving. + let total = settle(&ctx, &mut tab); + assert!( + total <= W + 1.0, + "the drag settled at {total} inside a {W} window" + ); + } + frame( + &ctx, + &mut tab, + vec![egui::Event::PointerButton { + pos: egui::pos2(handle_x + 8.0 * STEP, y), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }], + ); + let total = settle(&ctx, &mut tab); + assert!(total <= W + 1.0, "released at {total} inside a {W} window"); + assert!( + total >= settled - 1.0, + "the table gave up {} points of its window", + settled - total + ); + assert_eq!(tab.col_widths.len(), 4, "a column was dropped entirely"); + for (i, &w) in tab.col_widths.iter().enumerate() { + assert!(w > 0.0, "column {i} was squeezed out of existence: {w}"); + } + // Name took everything it could, and the rest are at their floors — which + // is the bound that stopped it, rather than the window's edge. + assert!( + tab.col_widths[0] > settled / 2.0, + "the drag barely moved: {:?}", + tab.col_widths + ); +} + +/// The property the whole drag rests on: a divider drag must not move any +/// column to its left. +/// +/// `egui_extras` sets the dragged column to `column_width + pointer.x - x`, +/// and `x` is the running right edge — which already contains `column_width`, +/// so that is really "put this column's right edge on the pointer, measured +/// from its left one". Shift anything to the left of it and the divider +/// resizes on its own and walks away from the cursor, which is what made +/// dragging uncontrollable. +#[test] +fn a_drag_leaves_every_column_to_its_left_alone() { + let plans = text_and_rank(); + let budget = 912.0; + let before = fit_widths(&[220.0, 320.0, 320.0, 52.0], &plans, budget); + + // Column 1's divider hauled right, by less than Content Match and Rank + // can pay for between them. + let mut dragged = before.clone(); + dragged[1] = before[1] + 150.0; + let after = fit_around(&dragged, &plans, budget, Some(1)); + + assert_eq!(after[0], before[0], "the name column moved under the drag"); + assert_eq!(after[1], dragged[1], "the drag was overruled"); + // Which is to say: the divider's own position is the pointer's to set. + assert_eq!( + after[..2].iter().sum::(), + dragged[..2].iter().sum::(), + "the divider did not land where the drag put it" + ); + // And only what lies right of it paid for the change. + assert!( + after[2] < before[2], + "nothing to the right absorbed: {after:?}" + ); + assert!( + (after.iter().sum::() - budget).abs() < 0.01, + "{after:?}" + ); +} + +/// Dragging left gives the space back to the right-hand columns, and only to +/// them. +#[test] +fn a_drag_leftwards_hands_the_space_to_the_right() { + let plans = text_and_rank(); + let budget = 912.0; + let before = fit_widths(&[220.0, 320.0, 320.0, 52.0], &plans, budget); + + let mut dragged = before.clone(); + dragged[1] = before[1] - 120.0; + let after = fit_around(&dragged, &plans, budget, Some(1)); + + assert_eq!(after[0], before[0], "the column left of the divider moved"); + assert_eq!(after[1], dragged[1], "the drag was overruled"); + assert!( + after[2] > before[2], + "the space was not handed on: {after:?}" + ); + assert!( + (after.iter().sum::() - budget).abs() < 0.01, + "{after:?}" + ); +} + +/// How far a column may be dragged is exactly what the columns to its right +/// can give up — so the last column, having none, cannot be dragged at all. +#[test] +fn the_ceiling_is_what_the_columns_to_the_right_can_give() { + let plans = text_and_rank(); + let budget = 912.0; + let widths = fit_widths(&[220.0, 320.0, 320.0, 52.0], &plans, budget); + + // Name may take everything Path, Content Match and Rank hold above their + // floors, and not a point more. + let ceiling = grow_ceiling(&widths, &plans, budget, 0); + assert!( + (ceiling - (budget - 120.0 - 120.0 - 40.0)).abs() < 0.01, + "{ceiling}" + ); + + // Rank has nothing to its right, so its divider is inert. + let last = plans.len() - 1; + assert!( + (grow_ceiling(&widths, &plans, budget, last) - widths[last]).abs() < 1.0, + "the rightmost divider offered room it does not have" + ); + + // Dragging to the ceiling still fits, with the right-hand columns floored. + let mut hauled = widths.clone(); + hauled[0] = ceiling + 500.0; + let after = fit_around(&hauled, &plans, budget, Some(0)); + assert!( + (after.iter().sum::() - budget).abs() < 0.01, + "{after:?}" + ); + assert_eq!(&after[1..], &[120.0, 120.0, 40.0]); +} + +/// End to end, through `egui_extras`' real drag handling: the two symptoms as +/// reported — widening did nothing at all, and the split would not stay under +/// the cursor. +#[test] +fn a_dragged_divider_widens_its_column_and_stays_under_the_cursor() { + const W: f32 = 1000.0; + let ctx = egui::Context::default(); + let mut tab = tab_with_results(3); + tab.columns = ColumnsConfig { + name: true, + content_match: true, + size: false, + modified: false, + rank: true, + }; + + fn frame(ctx: &egui::Context, tab: &mut SearchTab, events: Vec) { + let input = crate::test_ui::raw_input(egui::vec2(W, 700.0), events); + let _ = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + tab.ui(ui); + }); + }); + } + for _ in 0..4 { + frame(&ctx, &mut tab, Vec::new()); + } + let started_at = tab.col_widths[0]; + + // The Name|Path divider. The table sits inside the panel's margin, so the + // handle is a little right of the column's own width; egui's grab radius + // covers the difference. + let y = 60.0; + let grab = tab.col_widths[0] + 10.0; + frame( + &ctx, + &mut tab, + vec![ + egui::Event::PointerMoved(egui::pos2(grab, y)), + egui::Event::PointerButton { + pos: egui::pos2(grab, y), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + ], + ); + + // Drag right in realistic steps, checking after each that the column moved + // by what the pointer moved. Comparing the two *deltas* rather than the + // absolute positions is what makes this independent of the panel margin — + // and it is the exact statement of "the split stays under the cursor". + let mut x = grab; + for step in 1..=10 { + let before = tab.col_widths[0]; + x += 20.0; + frame( + &ctx, + &mut tab, + vec![egui::Event::PointerMoved(egui::pos2(x, y))], + ); + frame(&ctx, &mut tab, Vec::new()); + let moved = tab.col_widths[0] - before; + // The first step also takes up the slack between where the handle was + // grabbed and where it actually sits — anywhere inside egui's grab + // radius counts as a grab, and the divider then snaps to the pointer. + // Every step after that is pure tracking. + if step > 1 { + assert!( + (moved - 20.0).abs() < 0.5, + "step {step}: the pointer moved 20 and the divider moved {moved}" + ); + } + } + + // It moved at all — before the fix the ceiling was the column's own width, + // so widening was clamped away on every frame. + assert!( + tab.col_widths[0] > started_at + 150.0, + "the drag barely widened the column: {started_at} to {}", + tab.col_widths[0] + ); + assert!( + tab.col_widths.iter().sum::() <= W, + "the table overflowed its window: {:?}", + tab.col_widths + ); +} + +/// The path is the only column that identifies a result on its own, so it is +/// painted under every combination the picker can produce — including the one +/// where everything else is switched off. +#[test] +fn the_path_column_survives_every_column_combination() { + let ctx = egui::Context::default(); + for bits in 0..32u8 { + let mut tab = tab_with_results(1); + tab.columns = ColumnsConfig { + name: bits & 1 != 0, + content_match: bits & 2 != 0, + size: bits & 4 != 0, + modified: bits & 8 != 0, + rank: bits & 16 != 0, + }; + let painted = painted_text(&run_frame(&ctx, &mut tab, vec![])); + assert!( + painted.contains(&"Path".to_string()), + "columns {:?} lost the path header", + tab.columns + ); + assert!( + painted.iter().any(|t| t.contains("alpha_widget_0.txt")), + "columns {:?} painted no path", + tab.columns + ); + } +} + +/// The checkbox is the whole condition. A result set that matched nothing on +/// content still gets the column, filled with em dashes: a checked box that +/// paints nothing is indistinguishable from a bug, which is what the last one +/// was taken for. +#[test] +fn the_content_match_column_follows_only_its_checkbox() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(2); + assert!( + tab.results + .iter() + .all(|h| h.match_field() == MatchField::Name), + "the fixture stopped being a filename-only result set" + ); + + tab.columns.content_match = true; + let painted = painted_text(&run_frame(&ctx, &mut tab, vec![])); + assert!( + painted.contains(&"Content Match".to_string()), + "{painted:?}" + ); + assert_eq!( + painted.iter().filter(|t| *t == NO_CONTENT_MATCH).count(), + 2, + "expected one dash per row: {painted:?}" + ); + + tab.columns.content_match = false; + let painted = painted_text(&run_frame(&ctx, &mut tab, vec![])); + assert!( + !painted.contains(&"Content Match".to_string()), + "{painted:?}" + ); + assert!( + !painted.contains(&NO_CONTENT_MATCH.to_string()), + "{painted:?}" + ); +} + +/// Right-clicking a header opens the picker, wherever along the row the +/// pointer happens to be. +#[test] +fn right_clicking_any_header_opens_the_column_picker() { + for header in ["Name", "Path", "Rank"] { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let out = run_frame(&ctx, &mut tab, vec![]); + let pos = painted_text_center(&out, header) + .unwrap_or_else(|| panic!("no {header} header painted")); + run_frame(&ctx, &mut tab, click(pos, egui::PointerButton::Secondary)); + assert!( + egui::Popup::is_any_open(&ctx), + "right-clicking {header} opened no menu" + ); + } +} + +/// A filename match is marked in the Name column, and the Content Match column +/// says — because there is no content match to show. +#[test] +fn a_filename_match_is_highlighted_in_the_name_column() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.results[0] = name_hit("quarterly_budget.txt", (10, 16)); + + let out = run_frame(&ctx, &mut tab, vec![]); + assert_eq!(highlight_runs(&out, &ctx), vec!["budget".to_string()]); + + let dash = painted_text_center(&out, NO_CONTENT_MATCH).expect("no dash painted"); + let header = painted_text_center(&out, "Content Match").expect("no Content Match header"); + assert!( + (dash.x - header.x).abs() < 30.0, + "the dash is not in the Content Match column: {dash:?} vs {header:?}" + ); +} + +/// The highlight is skipped when the column it belongs in is not shown. The +/// dash stays: it is describing the *content* column, which is still telling +/// the truth. +#[test] +fn hiding_the_name_column_skips_its_highlight() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.results[0] = name_hit("quarterly_budget.txt", (10, 16)); + tab.columns.name = false; + + let out = run_frame(&ctx, &mut tab, vec![]); + assert!( + highlight_runs(&out, &ctx).is_empty(), + "a highlight was painted with the Name column hidden" + ); + assert!(painted_text_center(&out, NO_CONTENT_MATCH).is_some()); +} + +/// The guard: a snippet that is *not* the field verbatim indexes a window, so +/// its ranges would mark the wrong glyphs. Painting nothing is the only safe +/// answer, and this holds even if core regresses to windowing name snippets. +#[test] +fn a_name_snippet_that_is_not_the_name_paints_no_highlight() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let mut hit = name_hit("a_very_long_quarterly_budget_report.txt", (0, 6)); + // What `window_around` used to hand back: a suffix, with rebased ranges. + hit.snippet = Some(Snippet { + window: "budget_report.txt".to_string(), + ranges: vec![(0, 6)], + truncated_start: true, + truncated_end: false, + }); + tab.results[0] = hit; + + let out = run_frame(&ctx, &mut tab, vec![]); + assert!( + highlight_runs(&out, &ctx).is_empty(), + "a windowed snippet was trusted to index the name" + ); + assert!(painted_text(&out) + .iter() + .any(|t| t == "a_very_long_quarterly_budget_report.txt")); +} + +/// A path-tier match is marked in the Path column, which the picker cannot +/// switch off — so this highlight is always available. +#[test] +fn a_path_match_is_highlighted_in_the_path_column() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let path = "/qs-test/reports/alpha_widget_0.txt"; + tab.results[0].path = path.to_string(); + tab.results[0].stage = 10; + tab.results[0].snippet = Some(Snippet { + window: path.to_string(), + ranges: vec![(9, 16)], + truncated_start: false, + truncated_end: false, + }); + + let out = run_frame(&ctx, &mut tab, vec![]); + assert_eq!(highlight_runs(&out, &ctx), vec!["reports".to_string()]); +} + +/// A path long enough to elide, whose match falls in the dropped middle. The +/// highlight has nothing left to point at, and must not be re-based onto +/// whatever glyphs happen to sit at those offsets in the shortened string. +#[test] +fn a_path_match_lost_to_elision_paints_no_stray_highlight() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let path = deep_path(); + tab.results[0].path = path.clone(); + tab.results[0].stage = 10; + // "several" sits deep in the middle, which is what elision drops. + let at = path.find("several").expect("fixture contains it"); + tab.results[0].snippet = Some(Snippet { + window: path.clone(), + ranges: vec![(at, at + "several".len())], + truncated_start: false, + truncated_end: false, + }); + + let out = run_frame(&ctx, &mut tab, vec![]); + assert!( + !highlight_runs(&out, &ctx).contains(&"several".to_string()), + "an elided-away match was painted anyway" + ); +} + +/// The path reads at the same strength as the name beside it — it is the only +/// column that identifies a result on its own. The elision mark is the one +/// weak part: it is punctuation the renderer added, not part of the path. +#[test] +fn the_path_column_paints_at_full_strength_but_marks_its_elision_weak() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let path = deep_path(); + tab.results[0].path = path.clone(); + + let out = run_frame(&ctx, &mut tab, vec![]); + let (normal, weak) = { + let visuals = &ctx.style().visuals; + (visuals.text_color(), visuals.weak_text_color()) + }; + + // The cell paints as three spans, so the mark locates the other two. + let spans = crate::test_ui::painted_spans(&out); + let mark = spans + .iter() + .position(|(text, _)| text == "…") + .unwrap_or_else(|| panic!("no elision mark among {spans:?}")); + let (head, head_color) = &spans[mark - 1]; + let (tail, tail_color) = &spans[mark + 1]; + + assert_eq!(spans[mark].1, weak, "the elision mark is not weak"); + assert_eq!(*head_color, normal, "the path head is not full strength"); + assert_eq!(*tail_color, normal, "the path tail is not full strength"); + assert!(path.starts_with(head), "{head:?} does not open the path"); + assert!(tail.ends_with("alpha_widget_0.txt"), "{tail:?}"); + assert!(path.ends_with(tail.as_str()), "{tail:?} does not end it"); +} + +/// A path short enough to print whole takes the other branch, which has no +/// mark to place and paints the cell in one run. +#[test] +fn a_path_that_fits_is_painted_whole_at_full_strength() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let path = tab.results[0].path.clone(); + + let out = run_frame(&ctx, &mut tab, vec![]); + let normal = ctx.style().visuals.text_color(); + let spans = crate::test_ui::painted_spans(&out); + assert!( + spans.contains(&(path.clone(), normal)), + "{path} was not painted whole in the normal text color: {spans:?}" + ); +} + +/// The repeat button is an offer to re-run a finished search, so it appears +/// only when there is one and vanishes the moment the query stops describing +/// what is on screen. +#[test] +fn the_repeat_button_tracks_the_search_it_would_repeat() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let has_button = |out: &egui::FullOutput| painted_text(out).contains(&"⟳".to_string()); + + assert!( + !has_button(&run_frame(&ctx, &mut tab, vec![])), + "before any search" + ); + + tab.on_search_started(1); + assert!( + !has_button(&run_frame(&ctx, &mut tab, vec![])), + "while running" + ); + + tab.apply_update( + SearchUpdate::Completed { + generation: 1, + total: 1, + limited: false, + }, + 1000, + ); + assert!( + has_button(&run_frame(&ctx, &mut tab, vec![])), + "after completion" + ); + + tab.pending_edit = Some(Instant::now()); + assert!( + !has_button(&run_frame(&ctx, &mut tab, vec![])), + "after an edit" + ); +} + +fn completed_tab(ctx: &egui::Context) -> SearchTab { + let mut tab = tab_with_results(1); + tab.on_search_started(1); + tab.apply_update( + SearchUpdate::Completed { + generation: 1, + total: 1, + limited: false, + }, + 1000, + ); + run_frame(ctx, &mut tab, vec![]); + tab +} + +#[test] +fn clicking_the_repeat_button_asks_for_a_rerun() { + let ctx = egui::Context::default(); + let mut tab = completed_tab(&ctx); + let out = run_frame(&ctx, &mut tab, vec![]); + let pos = painted_text_center(&out, "⟳").expect("no repeat button painted"); + let (_, actions) = run_frame_actions(&ctx, &mut tab, click_at(pos)); + assert!(actions.rerun, "the repeat button reported nothing"); +} + +/// egui derives widget ids from how many widgets precede them, so a button +/// that comes and goes around the query box could rename it — and a renamed +/// `TextEdit` silently loses focus and whatever was being typed into it. +/// Typing is the assertion that matters: an id comparison alone would pass +/// even if focus had been dropped and handed back. +#[test] +fn the_repeat_button_does_not_steal_the_query_box() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.query = "alpha".into(); + tab.focus_query = true; + run_frame(&ctx, &mut tab, vec![]); + + // Finish a search, so the button appears between two frames of typing. + tab.on_search_started(1); + tab.apply_update( + SearchUpdate::Completed { + generation: 1, + total: 1, + limited: false, + }, + 1000, + ); + let out = run_frame(&ctx, &mut tab, vec![]); + assert!(painted_text(&out).contains(&"⟳".to_string())); + + run_frame(&ctx, &mut tab, vec![egui::Event::Text("x".into())]); + assert!( + tab.query.ends_with('x'), + "typing did not reach the query box: {:?}", + tab.query + ); +} + +/// Reserving the button's gutter unconditionally is what keeps the query text +/// from jumping sideways every time a search finishes. +#[test] +fn the_query_text_does_not_shift_when_the_repeat_button_appears() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.query = "alpha".into(); + + let rect_of = |out: &egui::FullOutput| { + painted(out) + .into_iter() + .find(|(text, _)| text == "alpha") + .map(|(_, rect)| rect) + .expect("the query text was not painted") + }; + let without = rect_of(&run_frame(&ctx, &mut tab, vec![])); + + tab.on_search_started(1); + tab.apply_update( + SearchUpdate::Completed { + generation: 1, + total: 1, + limited: false, + }, + 1000, + ); + let out = run_frame(&ctx, &mut tab, vec![]); + assert!(painted_text(&out).contains(&"⟳".to_string())); + assert_eq!(without, rect_of(&out), "the query text moved"); +} + +/// Left to right: syntax help, the box, how long the search took, then Fuzzy. +#[test] +fn the_query_strip_reads_help_box_duration_fuzzy() { + let ctx = egui::Context::default(); + let mut tab = completed_tab(&ctx); + let out = run_frame(&ctx, &mut tab, vec![]); + + let x = |needle: &str| { + painted_text_center(&out, needle) + .unwrap_or_else(|| panic!("{needle} was not painted: {:?}", painted_text(&out))) + .x + }; + let help = x("?"); + let fuzzy = x("Fuzzy"); + let elapsed = painted(&out) + .into_iter() + .find(|(t, _)| t.ends_with("ms") || t.ends_with('s')) + .map(|(_, r)| r.center().x) + .expect("no elapsed label"); + + assert!(help < elapsed, "the ? is not left of the duration"); + assert!(elapsed < fuzzy, "the duration is not left of Fuzzy"); +} + +/// The label sits to the *left* of its box, and stays clickable — splitting +/// the widget would otherwise silently lose a click target the combined +/// `ui.checkbox` had. +#[test] +fn the_fuzzy_label_is_left_of_its_box_and_still_toggles() { + let ctx = egui::Context::default(); + let mut tab = completed_tab(&ctx); + let out = run_frame(&ctx, &mut tab, vec![]); + let label = painted(&out) + .into_iter() + .find(|(t, _)| t == "Fuzzy") + .map(|(_, r)| r) + .expect("no Fuzzy label"); + + // The box is somewhere to the right of the label; sweep rather than + // assume how wide egui draws it. + let before = tab.fuzzy; + let mut hit = None; + for dx in 1..40 { + let (_, actions) = run_frame_actions( + &ctx, + &mut tab, + click_at(egui::pos2(label.right() + dx as f32, label.center().y)), + ); + if tab.fuzzy != before { + hit = Some(actions); + break; + } + } + let actions = hit.expect("no checkbox to the right of the Fuzzy label"); + assert_eq!(actions.save_fuzzy_default, Some(tab.fuzzy)); + assert!(actions.rerun); + + // And the label itself is still a target. + let (_, actions) = run_frame_actions(&ctx, &mut tab, click_at(label.center())); + assert_eq!(tab.fuzzy, before, "the label lost its click target"); + assert_eq!(actions.save_fuzzy_default, Some(tab.fuzzy)); +} + +/// The status bar says the count is a floor; it no longer says "truncated". +#[test] +fn a_capped_count_says_so_without_the_word_truncated() { + let mut tab = tab_with_results(3); + tab.query.clear(); + tab.results.clear(); + assert_eq!(tab.result_count_label(), None, "nothing searched yet"); + + let mut tab = tab_with_results(3); + assert_eq!(tab.result_count_label().as_deref(), Some("3 results")); + + tab.limited = true; + let label = tab.result_count_label().expect("a label"); + assert_eq!(label, "3+ results"); + assert!(!label.contains("truncated"), "{label}"); +} + +/// The in-tab notice is the one with room to say what to do about the cap, so +/// removing the status bar's wording must not take it with it. +#[test] +fn the_in_tab_notice_still_explains_the_cap() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(3); + tab.limited = true; + let painted = painted_text(&run_frame(&ctx, &mut tab, vec![])); + assert!( + painted.iter().any(|t| t.starts_with("Showing first")), + "{painted:?}" + ); +} + +// --- Live results --------------------------------------------------------- + +/// Only the rows actually rendered are watched — the request is explicit that +/// it is the visible set, not everything the search returned. +#[test] +fn only_the_rendered_rows_are_offered_for_watching() { + let ctx = egui::Context::default(); + // A settled tab: no search running, nothing pending, no reveal underway. + let mut tab = tab_with_results(500); + run_frame(&ctx, &mut tab, vec![]); + std::thread::sleep(LIVE_ARM_DELAY); + let (_, actions) = run_frame_actions(&ctx, &mut tab, vec![]); + + let targets = actions + .live_targets + .expect("nothing was offered for watching"); + assert!(!targets.is_empty()); + assert!( + targets.len() < 100, + "{} of 500 rows were watched — that is not the visible set", + targets.len() + ); +} + +/// Editing the query drops the watches straight away, without waiting for the +/// debounce to fire the next search. +#[test] +fn editing_the_query_drops_the_watches() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.live_armed = vec![live_target("/qs-test/alpha_widget_0.txt")]; + tab.focus_query = true; + run_frame(&ctx, &mut tab, vec![]); + + let (_, actions) = run_frame_actions(&ctx, &mut tab, vec![egui::Event::Text("z".into())]); + assert_eq!(actions.live_targets, Some(Vec::new())); + assert!(tab.live_armed.is_empty()); +} + +/// A target for `path` as the tab would build one, with a baseline nothing in +/// these tests reads. +fn live_target(path: &str) -> Target { + Target { + path: path.to_string(), + text: None, + size: 116, + mtime: 1_700_000_000, + } +} + +#[test] +fn should_arm_waits_for_the_results_to_settle_and_hold_still() { + let now = Instant::now(); + let long_ago = now - LIVE_ARM_DELAY * 2; + + assert!(should_arm(true, false, Some(long_ago), true, now)); + assert!( + !should_arm(false, false, Some(long_ago), true, now), + "the feature is switched off" + ); + assert!( + !should_arm(true, false, Some(long_ago), false, now), + "the results do not match the query box yet" + ); + assert!( + !should_arm(true, false, Some(now), true, now), + "the rows are still moving" + ); + assert!( + !should_arm(true, false, None, true, now), + "nothing has changed to arm for" + ); + assert!( + !should_arm(true, true, Some(long_ago), true, now), + "already watching exactly these rows" + ); +} + +/// What counts as "already watching these rows": the paths and whether each +/// needs its body re-read, and nothing else. +#[test] +fn the_watch_set_is_the_paths_and_the_tier_and_not_the_baseline() { + let wanted = vec![live_target("/a.txt"), live_target("/b.txt")]; + assert!(same_watch_set(&wanted, &wanted)); + + // The baseline rides along on `Target` but says nothing about *what* is + // watched: a file whose size moved must not tear down and rebuild every + // registration on screen. + let mut moved = wanted.clone(); + moved[0].size += 1; + moved[0].mtime += 1; + assert!( + same_watch_set(&moved, &wanted), + "a changed baseline read as a different watch set" + ); + + let mut renamed = wanted.clone(); + renamed[0].path = "/c.txt".into(); + assert!( + !same_watch_set(&renamed, &wanted), + "a renamed row read as the same watch set" + ); + + let mut retiered = wanted.clone(); + retiered[0].text = Some(quicksearch_core::search::ContentTier::Exact); + assert!( + !same_watch_set(&retiered, &wanted), + "a row that started showing body text read as the same watch set" + ); +} + +/// The bug a rename used to hit: the row's path changes but its position does +/// not, so anything keyed on row indices would decide nothing had moved and +/// leave the watcher pointed at a file that is no longer there. +#[test] +fn a_renamed_row_is_re_armed_at_its_new_path() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(2); + run_frame(&ctx, &mut tab, vec![]); + std::thread::sleep(LIVE_ARM_DELAY); + let (_, actions) = run_frame_actions(&ctx, &mut tab, vec![]); + assert!(actions.live_targets.is_some(), "never armed to begin with"); + + tab.apply_live(LiveUpdate::Renamed { + path: "/qs-test/alpha_widget_0.txt".into(), + to: "/elsewhere/renamed.txt".into(), + name: "renamed.txt".into(), + }); + // The rename restarts the arm delay, exactly as a scroll would. + run_frame(&ctx, &mut tab, vec![]); + std::thread::sleep(LIVE_ARM_DELAY); + let (_, actions) = run_frame_actions(&ctx, &mut tab, vec![]); + + let targets = actions.live_targets.expect("the rename did not re-arm"); + assert!( + targets.iter().any(|t| t.path == "/elsewhere/renamed.txt"), + "the watcher is still keyed on the old path: {targets:?}" + ); +} + +/// The baseline the watcher sweeps against is what the row is *displaying* — +/// which on a fresh result is what the index said. That is what turns arming +/// into a check of the index against the disk. +#[test] +fn a_target_carries_what_the_row_is_displaying() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(2); + tab.results[0].size = 4242; + tab.results[0].mtime = 1_710_000_000; + run_frame(&ctx, &mut tab, vec![]); + std::thread::sleep(LIVE_ARM_DELAY); + let (_, actions) = run_frame_actions(&ctx, &mut tab, vec![]); + + let targets = actions.live_targets.expect("nothing was armed"); + let first = targets + .iter() + .find(|t| t.path == "/qs-test/alpha_widget_0.txt") + .expect("the row was not offered"); + assert_eq!((first.size, first.mtime), (4242, 1_710_000_000)); +} + +/// A rename lands from the filesystem event itself. The row keeps its place, +/// its identity and its rank — only what it says about the file changes. +#[test] +fn a_rename_updates_the_row_in_place() { + let mut tab = tab_with_results(2); + tab.results[0] = name_hit("before.txt", (0, 6)); + tab.selected = Some(0); + let (rank, stage, file_id) = ( + tab.results[0].rank, + tab.results[0].stage, + tab.results[0].file_id, + ); + + tab.apply_live(LiveUpdate::Renamed { + path: "/qs-test/before.txt".into(), + to: "/qs-test/after.txt".into(), + name: "after.txt".into(), + }); + + let hit = &tab.results[0]; + assert_eq!(hit.name, "after.txt"); + assert_eq!(hit.path, "/qs-test/after.txt"); + assert_eq!((hit.rank, hit.stage, hit.file_id), (rank, stage, file_id)); + assert_eq!(tab.selected, Some(0), "the selection moved"); + // The old name's marks cannot describe the new one. + let snip = hit.snippet.as_ref().expect("a name hit carries one"); + assert_eq!(snip.window, "after.txt"); + assert!(snip.ranges.is_empty()); +} + +/// A file that disappears leaves its row where it is, struck through: dropping +/// it would shift everything below while someone is reading. +#[test] +fn a_vanished_file_is_struck_through_and_comes_back() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + let path = tab.results[0].path.clone(); + + // The *name* run, exactly — the path column paints the filename as well, + // as part of a longer run and never weak. + let name_is_weak = |out: &egui::FullOutput| { + let weak = ctx.style().visuals.weak_text_color(); + crate::test_ui::painted_spans(out) + .iter() + .any(|(text, color)| text == "alpha_widget_0.txt" && *color == weak) + }; + + tab.apply_live(LiveUpdate::Gone { path: path.clone() }); + let out = run_frame(&ctx, &mut tab, vec![]); + assert_eq!(tab.results.len(), 1, "the row was removed"); + assert!(name_is_weak(&out), "the vanished row was not de-emphasised"); + + tab.apply_live(LiveUpdate::Changed { + path, + size: 200, + mtime: 1_800_000_000, + window: WindowUpdate::Unchanged, + }); + let out = run_frame(&ctx, &mut tab, vec![]); + assert!( + !name_is_weak(&out), + "a recreated file stayed marked as gone" + ); + assert_eq!(tab.results[0].size, 200); +} + +/// A refreshed content snippet is painted with its match marked, and turns the +/// Content Match column on if it was not already. +#[test] +fn a_content_change_repaints_the_highlight() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.results[0].stage = 6; + let path = tab.results[0].path.clone(); + + tab.apply_live(LiveUpdate::Changed { + path, + size: 300, + mtime: 1_800_000_000, + window: WindowUpdate::Cut(Snippet { + window: "the quarterly budget was revised".into(), + ranges: vec![(14, 20)], + truncated_start: false, + truncated_end: false, + }), + }); + + let out = run_frame(&ctx, &mut tab, vec![]); + assert!( + highlight_runs(&out, &ctx).contains(&"budget".to_string()), + "the refreshed match was not highlighted: {:?}", + painted_text(&out) + ); +} + +/// The watcher re-cuts a content row's window from the file, so `None` is not +/// "nothing to say" — it is "the body stopped matching", and the cell has to +/// fall back to its dash rather than keep showing text that is no longer a hit. +#[test] +fn an_edit_that_removes_the_match_clears_the_content_cell() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.results[0].stage = 6; + tab.results[0].snippet = Some(Snippet { + window: "the quarterly budget was revised".into(), + ranges: vec![(14, 20)], + truncated_start: false, + truncated_end: false, + }); + let path = tab.results[0].path.clone(); + + tab.apply_live(LiveUpdate::Changed { + path, + size: 300, + mtime: 1_800_000_000, + window: WindowUpdate::NoMatch, + }); + + assert!(tab.results[0].snippet.is_none()); + let painted = painted_text(&run_frame(&ctx, &mut tab, vec![])); + assert!( + !painted.iter().any(|t| t.contains("quarterly")), + "the stale window is still on screen: {painted:?}" + ); +} + +/// A name-tier row's snippet *is* its filename, not its body, so a write must +/// not take it away — only the content tiers hand over an authoritative window. +#[test] +fn a_content_change_leaves_a_name_hit_s_snippet_alone() { + let mut tab = tab_with_results(1); + tab.results[0] = name_hit("before.txt", (0, 6)); + let path = tab.results[0].path.clone(); + + tab.apply_live(LiveUpdate::Changed { + path, + size: 300, + mtime: 1_800_000_000, + window: WindowUpdate::Unchanged, + }); + + let snip = tab.results[0].snippet.as_ref().expect("the name hit's own"); + assert_eq!(snip.window, "before.txt"); + assert_eq!(tab.results[0].size, 300, "the metadata still landed"); +} + +/// The Name column is optional, so it cannot be the only place a vanished file +/// says so. With it hidden, the path — which is always shown — carries it. +#[test] +fn a_vanished_file_is_legible_with_the_name_column_hidden() { + let ctx = egui::Context::default(); + let mut tab = tab_with_results(1); + tab.columns.name = false; + let path = tab.results[0].path.clone(); + let weak = ctx.style().visuals.weak_text_color(); + + let before = crate::test_ui::painted_spans(&run_frame(&ctx, &mut tab, vec![])); + assert!( + !before + .iter() + .any(|(t, c)| t.contains("alpha_widget_0") && *c == weak), + "the path was already weak before anything vanished: {before:?}" + ); + + tab.apply_live(LiveUpdate::Gone { path }); + let after = crate::test_ui::painted_spans(&run_frame(&ctx, &mut tab, vec![])); + assert!( + after + .iter() + .any(|(t, c)| t.contains("alpha_widget_0") && *c == weak), + "nothing on the row says the file is gone: {after:?}" + ); +} diff --git a/crates/quicksearch-gui/src/options.rs b/crates/quicksearch-gui/src/settings_tab.rs similarity index 71% rename from crates/quicksearch-gui/src/options.rs rename to crates/quicksearch-gui/src/settings_tab.rs index f54e6bb..68a5516 100644 --- a/crates/quicksearch-gui/src/options.rs +++ b/crates/quicksearch-gui/src/settings_tab.rs @@ -1,11 +1,11 @@ -//! The Options window and the shared config editor used by both the -//! window and the Manage Index tab. Edits happen on a draft; Apply -//! validates, saves, and hands the new config to the app. +//! The Settings tab: every configuration control the GUI offers, grouped +//! into sections. Edits happen on a draft; Apply validates, saves, and hands +//! the new config to the app. use crate::keychain; use crate::tips::{self, tip_row, Tipped}; use crate::ui_util::hint; -use quicksearch_core::config::Config; +use quicksearch_core::config::{ColumnsConfig, Config}; /// A [`tip_row`] holding one numeric [`egui::DragValue`] — the shape of most /// rows in the config editor. @@ -22,7 +22,7 @@ fn drag_row( } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Section { +enum Section { Indexing, Processing, Search, @@ -36,24 +36,26 @@ pub enum SecurityAction { Disable, ChangePassword, SetKeychain(bool), + ShowKey, } -/// What one frame of the Options window produced. +/// What one frame of the Settings tab produced. #[derive(Default)] -pub struct OptionsOutput { +pub struct SettingsOutput { /// "Apply & Save" was clicked with this draft. pub applied: Option, /// A Security block action was clicked. pub security: Option, - /// The title-bar close was clicked while the draft holds unapplied - /// edits; the window is held open and the app raises the guard. - pub close_requested: bool, + /// The Columns block changed. Like Security, it edits the live config + /// rather than the draft, so it takes effect without Apply. + pub columns: Option, } -pub struct OptionsWindow { - pub open: bool, +pub struct SettingsTab { + /// The staged config, built from the live one the first frame the tab is + /// shown and dropped again when it is left. draft: Option, - /// Cached answer from [`OptionsWindow::keychain_active`], with the + /// Cached answer from [`SettingsTab::keychain_active`], with the /// `use_keychain` preference it was probed under. keychain_probed_for: Option, keychain_active: bool, @@ -61,10 +63,9 @@ pub struct OptionsWindow { capturing_hotkey: bool, } -impl OptionsWindow { - pub fn new() -> OptionsWindow { - OptionsWindow { - open: false, +impl SettingsTab { + pub fn new() -> SettingsTab { + SettingsTab { draft: None, keychain_probed_for: None, keychain_active: false, @@ -73,21 +74,16 @@ impl OptionsWindow { } /// Whether the shortcut button is reading a key press right now, so the - /// app can hold the shortcut it is about to replace. See + /// app can hold the shortcut it is about to replace. The app gates this + /// on the tab being the one on screen. See /// [`crate::unlock::Gate::handle_hotkey`]. pub fn capturing_hotkey(&self) -> bool { - self.open && self.capturing_hotkey - } - - pub fn open_with(&mut self, current: &Config) { - self.open = true; - self.draft = Some(current.clone()); - self.keychain_probed_for = None; + self.capturing_hotkey } /// Whether the draft differs from the live config. The fields the app /// pins on apply are neutralized first, so the Security block never - /// makes the window read as dirty. + /// makes the tab read as dirty. pub fn is_dirty(&self, current: &Config) -> bool { let Some(draft) = &self.draft else { return false; @@ -102,34 +98,27 @@ impl OptionsWindow { self.draft.clone() } - /// Close and drop the draft (Discard, or a clean close). - pub fn close_discard(&mut self) { - self.open = false; + /// Drop the draft: Discard, or leaving the tab. The next frame that + /// shows the tab stages a fresh copy of the live config, which is what + /// keeps a draft from going stale against edits made on Manage Index. + pub fn discard(&mut self) { self.draft = None; self.capturing_hotkey = false; + self.keychain_probed_for = None; } - /// Adopt the window's open flag for this frame. A dirty close is - /// intercepted: the window is held open and the caller is told to raise - /// the unsaved-changes guard instead. - fn intercept_close(&mut self, still_open: bool, current: &Config) -> bool { - self.open = still_open; - if self.open { - return false; - } - if self.is_dirty(current) { - self.open = true; - true - } else { - self.draft = None; - false + /// Take a draft if there is none: the first frame the tab is shown, and + /// the first frame after it was left. + fn stage(&mut self, current: &Config) { + if self.draft.is_none() { + self.draft = Some(current.clone()); } } /// True when this index's key really is in the OS keychain: the /// preference is on *and* the keychain answers with an entry (a dead /// daemon, a locked keyring or a denied prompt all read as "no"). - /// Probed when the window opens and when the preference changes — a + /// Probed when the tab is entered and when the preference changes — a /// keychain read is an IPC round trip. fn keychain_active(&mut self, current: &Config) -> bool { if self.keychain_probed_for != Some(current.security.use_keychain) { @@ -142,85 +131,79 @@ impl OptionsWindow { } /// Render; reports an applied draft config and/or a security action. - pub fn ui(&mut self, ctx: &egui::Context, current: &Config) -> OptionsOutput { - if !self.open { - self.draft = None; - return OptionsOutput::default(); - } - if self.draft.is_none() { - self.draft = Some(current.clone()); - } - let mut out = OptionsOutput::default(); - let mut open = self.open; + pub fn ui(&mut self, ui: &mut egui::Ui, current: &Config) -> SettingsOutput { + self.stage(current); + let mut out = SettingsOutput::default(); let keychain_active = self.keychain_active(current); let dirty = self.is_dirty(current); let capturing = &mut self.capturing_hotkey; let draft = self.draft.as_mut().unwrap(); - egui::Window::new("Options") - .open(&mut open) - .resizable(false) - .default_width(420.0) - .show(ctx, |ui| { - let scroll = egui::ScrollArea::vertical() - .max_height(480.0) - .show(ui, |ui| { - ui.heading(egui::RichText::new("Paths").strong()); - egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| { - tip_row(ui, "Database file", &tips::DATABASE_PATH, |ui| { - ui.add( - egui::TextEdit::singleline(&mut draft.paths.database_path) - .desired_width(260.0), - ) - }); - }); - ui.label(hint("Indexed folders are managed on the Manage Index tab.")); - ui.separator(); + 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 hint into one long line. + ui.set_max_width(620.0); - ui.heading(egui::RichText::new("Indexing").strong()); - config_editor_ui(ui, draft, Section::Indexing); - ui.label(hint( - "Automatic and manual indexing are switched on the \ - Manage Index tab.", - )); - ui.separator(); - - ui.heading(egui::RichText::new("Processing").strong()); - config_editor_ui(ui, draft, Section::Processing); - ui.separator(); - - ui.heading(egui::RichText::new("Search").strong()); - config_editor_ui(ui, draft, Section::Search); - ui.separator(); - - ui.heading(egui::RichText::new("Interface").strong()); - egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { - tip_row(ui, "UI scale", &tips::UI_SCALE, |ui| { - ui.add( - egui::Slider::new(&mut draft.ui.scale, 0.5..=2.5) - .step_by(0.05) - .fixed_decimals(2), - ) - }); - tip_row(ui, "Search shortcut", &tips::SEARCH_HOTKEY, |ui| { - hotkey_edit(ui, &mut draft.ui.search_hotkey, capturing) - }); - tip_row(ui, "Color scheme", &tips::COLOR_SCHEME, |ui| { - color_scheme_edit(ui, &mut draft.ui.color_scheme) - }); - }); - hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey); - ui.separator(); - - // Security acts on the live config, not the draft. - // The KDF salt is never shown here or anywhere else - // in the GUI. - ui.heading(egui::RichText::new("Security").strong()); - out.security = security_ui(ui, current, keychain_active); + ui.heading(egui::RichText::new("Paths").strong()); + egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| { + tip_row(ui, "Database file", &tips::DATABASE_PATH, |ui| { + ui.add( + egui::TextEdit::singleline(&mut draft.paths.database_path) + .desired_width(260.0), + ) }); - crate::ui_util::more_below_hint(ui, &scroll); - + }); + ui.label(hint("Indexed folders are managed on the Manage Index tab.")); ui.separator(); + + ui.heading(egui::RichText::new("Indexing").strong()); + config_editor_ui(ui, draft, Section::Indexing); + ui.label(hint( + "Automatic and manual indexing are switched on the \ + Manage Index tab.", + )); + ui.separator(); + + ui.heading(egui::RichText::new("Processing").strong()); + config_editor_ui(ui, draft, Section::Processing); + ui.separator(); + + ui.heading(egui::RichText::new("Search").strong()); + config_editor_ui(ui, draft, Section::Search); + ui.add_space(6.0); + // Live, not drafted — see `columns_ui`. + out.columns = columns_ui(ui, ¤t.search.columns); + ui.separator(); + + ui.heading(egui::RichText::new("Interface").strong()); + egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { + tip_row(ui, "UI scale", &tips::UI_SCALE, |ui| { + ui.add( + egui::Slider::new(&mut draft.ui.scale, 0.5..=2.5) + .step_by(0.05) + .fixed_decimals(2), + ) + }); + tip_row(ui, "Search shortcut", &tips::SEARCH_HOTKEY, |ui| { + hotkey_edit(ui, &mut draft.ui.search_hotkey, capturing) + }); + tip_row(ui, "Color scheme", &tips::COLOR_SCHEME, |ui| { + color_scheme_edit(ui, &mut draft.ui.color_scheme) + }); + }); + hotkey_note(ui, &draft.ui.search_hotkey, ¤t.ui.search_hotkey); + ui.separator(); + + // Security acts on the live config, not the draft. The KDF + // salt is never shown here or anywhere else in the GUI. + ui.heading(egui::RichText::new("Security").strong()); + out.security = security_ui(ui, current, keychain_active); + ui.separator(); + + // Last in the scroll, where the Manage Index tab also puts + // it, so the two draft-backed editors read the same way. let p = crate::color::palette(ui.visuals().dark_mode); ui.horizontal(|ui| { let apply = ui @@ -246,12 +229,12 @@ impl OptionsWindow { }); ui.label(hint( "Narrowing a filter removes the entries it excludes; widening \ - one reindexes to find what it now allows. Only the tokenizer \ - and hash length require a full rebuild.", + one reindexes to find what it now allows. Only the tokenizer \ + and hash length require a full rebuild.", )); }); + crate::ui_util::more_below_hint(ui, &scroll); - out.close_requested = self.intercept_close(open, current); out } } @@ -398,6 +381,39 @@ fn hotkey_note(ui: &mut egui::Ui, draft: &str, live: &str) { }); } +/// The Search-tab column picker, mirroring the right-click menu on the table +/// headers. Returns the new set when a checkbox moved. +/// +/// Acts on the **live** config, not the draft, for the same reason the +/// Security block does: the header menu writes columns the instant they +/// change, and a draft-backed copy here would silently revert that on the next +/// Apply. `app::pin_live_fields` keeps the draft out of this field entirely. +fn columns_ui(ui: &mut egui::Ui, current: &ColumnsConfig) -> Option { + let mut next = current.clone(); + ui.label("Search columns").on_hover_text(tips::COLUMNS.body); + ui.horizontal_wrapped(|ui| { + ui.checkbox(&mut next.name, "Name").tip(&tips::COLUMNS); + // Checked and greyed rather than absent: an omitted entry reads as an + // oversight, a disabled one answers the question. + ui.add_enabled(false, egui::Checkbox::new(&mut true, "Path")) + .on_disabled_hover_text( + "The path is always shown — it is the only column that \ + identifies a result on its own.", + ); + ui.checkbox(&mut next.content_match, "Content Match") + .tip(&tips::COLUMNS); + ui.checkbox(&mut next.size, "Size").tip(&tips::COLUMNS); + ui.checkbox(&mut next.modified, "Modified") + .tip(&tips::COLUMNS); + ui.checkbox(&mut next.rank, "Rank").tip(&tips::COLUMNS); + }); + ui.label(hint( + "Also on the Search tab: right-click any column header. Applied and \ + saved immediately.", + )); + (next != *current).then_some(next) +} + /// The Security block: status plus action buttons. Never renders the salt. fn security_ui( ui: &mut egui::Ui, @@ -430,6 +446,14 @@ fn security_ui( action = Some(SecurityAction::Disable); } }); + // Its own row: three buttons do not fit the window's width. + if ui + .button("Show database key…") + .tip(&tips::SHOW_KEY) + .clicked() + { + action = Some(SecurityAction::ShowKey); + } let mut remember = current.security.use_keychain; if ui .checkbox(&mut remember, "Remember on this device") @@ -455,10 +479,10 @@ fn security_ui( action } -/// The per-section config controls of the Options window. Every row goes +/// The per-section config controls of the Settings tab. Every row goes /// through [`crate::tips::tip_row`], so a setting cannot arrive here /// without a tooltip. -pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section) { +fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section) { match section { Section::Indexing => { egui::Grid::new("cfg-indexing") @@ -598,6 +622,10 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section &mut config.search.debounce_ms, 0..=2000, ); + + tip_row(ui, "Live results", &tips::LIVE_RESULTS, |ui| { + ui.checkbox(&mut config.search.live_results, "") + }); }); // The warning comes and goes as the value is edited; keep it off // the ids of what follows (`ui_util::stable_section`). diff --git a/crates/quicksearch-gui/src/options/tests.rs b/crates/quicksearch-gui/src/settings_tab/tests.rs similarity index 59% rename from crates/quicksearch-gui/src/options/tests.rs rename to crates/quicksearch-gui/src/settings_tab/tests.rs index 081812f..bbb5098 100644 --- a/crates/quicksearch-gui/src/options/tests.rs +++ b/crates/quicksearch-gui/src/settings_tab/tests.rs @@ -5,62 +5,74 @@ use super::*; #[test] fn a_fresh_draft_is_not_dirty() { - let mut w = OptionsWindow::new(); + let mut w = SettingsTab::new(); let cfg = Config::default(); assert!(!w.is_dirty(&cfg), "no draft at all"); - w.open_with(&cfg); + w.stage(&cfg); assert!(!w.is_dirty(&cfg)); } #[test] fn an_edited_draft_is_dirty_until_discarded() { - let mut w = OptionsWindow::new(); + let mut w = SettingsTab::new(); let cfg = Config::default(); - w.open_with(&cfg); + w.stage(&cfg); w.draft.as_mut().unwrap().search.debounce_ms += 100; assert!(w.is_dirty(&cfg)); - w.close_discard(); - assert!(!w.open); + w.discard(); + assert!(w.draft.is_none()); assert!(!w.is_dirty(&cfg), "the draft is gone"); } /// The Security block and the mode buttons act on the live config while -/// the window sits open; the stale copies in the draft are not edits. +/// the tab is on screen; the stale copies in the draft are not edits. #[test] fn live_security_and_mode_changes_are_not_dirty() { - let mut w = OptionsWindow::new(); + let mut w = SettingsTab::new(); let mut cfg = Config::default(); - w.open_with(&cfg); + w.stage(&cfg); cfg.security.use_keychain = !cfg.security.use_keychain; cfg.indexing.auto_index = !cfg.indexing.auto_index; assert!(!w.is_dirty(&cfg)); } +/// Leaving the tab drops the draft, so the next visit stages the config as +/// it stands *then*. Without this an edit made on the Manage Index tab in +/// between would be reverted by a later Apply: `pin_live_fields` protects +/// the fields saved live, but not the indexed folders or the filters. #[test] -fn a_dirty_close_is_held_and_a_clean_one_drops_the_draft() { - let mut w = OptionsWindow::new(); - let cfg = Config::default(); - w.open_with(&cfg); - w.draft.as_mut().unwrap().search.debounce_ms += 100; +fn a_draft_is_restaged_from_the_live_config_after_leaving() { + let mut w = SettingsTab::new(); + let mut cfg = Config::default(); + w.stage(&cfg); + w.discard(); - assert!( - w.intercept_close(false, &cfg), - "dirty close raises the guard" + cfg.indexing.ignore_patterns.push("*.tmp".to_string()); + w.stage(&cfg); + assert!(!w.is_dirty(&cfg), "the fresh draft matches the live config"); + assert_eq!( + w.draft_config().unwrap().indexing.ignore_patterns, + cfg.indexing.ignore_patterns, + "the filter added while the tab was away survives" ); - assert!(w.open, "the window is held open until the user decides"); - assert!(w.draft.is_some(), "the draft survives"); +} - assert!(!w.intercept_close(true, &cfg), "still open: nothing to do"); - - w.draft = Some(cfg.clone()); - assert!(!w.intercept_close(false, &cfg), "a clean close just closes"); - assert!(!w.open); - assert!(w.draft.is_none()); +/// A key capture in progress cannot outlive the tab: the app stops asking +/// [`SettingsTab::capturing_hotkey`] once another tab is up, and the button +/// must not be waiting when the tab comes back either. +#[test] +fn leaving_the_tab_ends_a_shortcut_capture() { + let mut w = SettingsTab::new(); + let cfg = Config::default(); + w.stage(&cfg); + w.capturing_hotkey = true; + w.discard(); + assert!(!w.capturing_hotkey()); } use crate::test_ui::{click_at, painted_text, painted_text_center}; -/// One frame of the shortcut control on its own, outside the window's +/// One frame of the shortcut control on its own, outside the tab's /// scroll area so it is never below the fold. fn run_hotkey_edit( ctx: &egui::Context, @@ -295,10 +307,11 @@ const ROWS: &[(Section, &str, &tips::Tip)] = &[ &tips::RESULTS_PER_PAGE, ), (Section::Search, "Debounce (ms)", &tips::DEBOUNCE), + (Section::Search, "Live results", &tips::LIVE_RESULTS), ]; /// Hovering a row's name paints that row's own explanation. Rendered -/// without the window's scroll area so nothing sits below the fold. +/// without the tab's scroll area so nothing sits below the fold. #[test] fn every_row_shows_its_own_tip() { for (section, label, tip) in ROWS { @@ -348,27 +361,20 @@ fn hovering_a_setting_label_explains_it() { s.interaction.show_tooltips_only_when_still = false; }); let cfg = Config::default(); - let mut w = OptionsWindow::new(); - w.open_with(&cfg); + let mut w = SettingsTab::new(); - let run = |w: &mut OptionsWindow, events: Vec| { + let run = |w: &mut SettingsTab, events: Vec| { let input = crate::test_ui::raw_input(egui::vec2(1000.0, 900.0), events); ctx.run(input, |ctx| { - w.ui(ctx, &cfg); + egui::CentralPanel::default().show(ctx, |ui| { + w.ui(ui, &cfg); + }); }) }; - // The window spends its first frames sizing itself and painting - // nothing; run until the label is on screen. - let mut target = None; - for _ in 0..5 { - let full = run(&mut w, vec![]); - target = painted_text_center(&full, "Tokenizer"); - if target.is_some() { - break; - } - } - let target = target.expect("the Tokenizer label was not painted"); + let full = run(&mut w, vec![]); + let target = + painted_text_center(&full, "Tokenizer").expect("the Tokenizer label was not painted"); // The tooltip is an area of its own, so it can land a frame late. let mut out = run(&mut w, vec![egui::Event::PointerMoved(target)]); @@ -384,37 +390,29 @@ fn hovering_a_setting_label_explains_it() { panic!("no tooltip appeared over the Tokenizer label"); } -/// One real frame of the window in a headless context: it renders, and -/// the Apply & Save click comes back out as `applied`. +/// One real frame of the tab in a headless context: it renders, and the +/// Apply & Save click comes back out as `applied`. #[test] -fn the_window_renders_and_apply_reports_the_draft() { +fn the_tab_renders_and_apply_reports_the_draft() { let ctx = egui::Context::default(); let cfg = Config::default(); - let mut w = OptionsWindow::new(); - w.open_with(&cfg); + let mut w = SettingsTab::new(); + w.stage(&cfg); w.draft.as_mut().unwrap().search.debounce_ms += 100; - let run = |w: &mut OptionsWindow, events: Vec| { + let run = |w: &mut SettingsTab, events: Vec| { let input = crate::test_ui::raw_input(egui::vec2(1000.0, 900.0), events); - let mut out = OptionsOutput::default(); - let full = ctx.run(input, |ctx| out = w.ui(ctx, &cfg)); + let mut out = SettingsOutput::default(); + let full = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| out = w.ui(ui, &cfg)); + }); (out, full) }; - // A new egui window spends its first frames in sizing passes that - // suppress painting; run untouched frames until the settled button - // is actually on screen. - let mut target = None; - for _ in 0..5 { - let (untouched, full) = run(&mut w, vec![]); - assert!(untouched.applied.is_none()); - assert!(!untouched.close_requested); - target = painted_text_center(&full, "Apply & Save"); - if target.is_some() { - break; - } - } - let target = target.expect("the Apply & Save button was not painted"); + let (untouched, full) = run(&mut w, vec![]); + assert!(untouched.applied.is_none()); + let target = painted_text_center(&full, "Apply & Save") + .expect("the Apply & Save button was not painted"); let clicks = [true, false] .into_iter() .map(|pressed| egui::Event::PointerButton { @@ -432,3 +430,156 @@ fn the_window_renders_and_apply_reports_the_draft() { "the click reported the edited draft" ); } + +/// One frame of the column picker on its own, outside the tab's scroll +/// area so it is never below the fold — the same shape as [`run_hotkey_edit`]. +fn run_columns( + ctx: &egui::Context, + current: &ColumnsConfig, + events: Vec, +) -> (Option, egui::FullOutput) { + let input = crate::test_ui::raw_input(egui::vec2(700.0, 200.0), events); + let mut picked = None; + let full = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + picked = columns_ui(ui, current); + }); + }); + (picked, full) +} + +/// The Settings copy of the column picker acts on the *live* config, not the +/// draft — the same arrangement the Security block uses, and the reason it and +/// the table header's menu cannot end up disagreeing. So it reports a change +/// the moment a box moves, with no Apply. +#[test] +fn the_columns_block_reports_a_change_immediately() { + let ctx = egui::Context::default(); + let current = ColumnsConfig::default(); + assert!(!current.size, "the fixture assumes Size ships off"); + + let (quiet, full) = run_columns(&ctx, ¤t, vec![]); + assert!(quiet.is_none(), "reported a change nobody made"); + let target = painted_text_center(&full, "Size").expect("no Size checkbox"); + + let (picked, _) = run_columns(&ctx, ¤t, click_at(target)); + let picked = picked.expect("the click reported nothing"); + assert!(picked.size, "clicking Size did not switch it on"); + // Only that one moved. + assert_eq!( + picked, + ColumnsConfig { + size: true, + ..current + } + ); +} + +/// The path is not offered: it is the one column that identifies a result on +/// its own. It is shown checked and greyed rather than left out, so the +/// question "why can I not remove it?" has an answer on screen. +#[test] +fn the_columns_block_offers_every_column_but_the_path() { + let ctx = egui::Context::default(); + let (_, full) = run_columns(&ctx, &ColumnsConfig::default(), vec![]); + let painted = painted_text(&full); + for label in ["Name", "Path", "Content Match", "Size", "Modified", "Rank"] { + assert!( + painted.iter().any(|t| t == label), + "{label} missing: {painted:?}" + ); + } + + // Clicking it does nothing, because it is disabled. + let target = painted_text_center(&full, "Path").expect("no Path entry"); + let (picked, _) = run_columns(&ctx, &ColumnsConfig::default(), click_at(target)); + assert!(picked.is_none(), "the path column was switched off"); +} + +/// One frame of the Security block on its own, in the shape of +/// [`run_columns`]. `keychain_active` is passed straight through, so nothing +/// here touches the OS keychain. +fn run_security( + ctx: &egui::Context, + current: &Config, + events: Vec, +) -> (Option, egui::FullOutput) { + let input = crate::test_ui::raw_input(egui::vec2(700.0, 300.0), events); + let mut action = None; + let full = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + action = security_ui(ui, current, false); + }); + }); + (action, full) +} + +/// An unprotected index has no key at all, so there is nothing the button +/// could show and it is left out rather than shown dead. +#[test] +fn the_key_button_appears_only_while_the_index_is_encrypted() { + let ctx = egui::Context::default(); + let mut cfg = Config::default(); + assert!( + !cfg.security.password_protected, + "the fixture assumes protection ships off" + ); + + let (_, full) = run_security(&ctx, &cfg, vec![]); + assert!( + painted_text_center(&full, "Show database key…").is_none(), + "offered the key of an unencrypted index: {:?}", + painted_text(&full) + ); + + cfg.security.password_protected = true; + let (_, full) = run_security(&ctx, &cfg, vec![]); + assert!( + painted_text_center(&full, "Show database key…").is_some(), + "no key button while encrypted: {:?}", + painted_text(&full) + ); +} + +/// The click only asks for the flow; the password confirmation and the reveal +/// both live in the app, so nothing about the key is decided here. +#[test] +fn clicking_the_key_button_reports_show_key() { + let ctx = egui::Context::default(); + let cfg = Config { + security: quicksearch_core::config::SecurityConfig { + password_protected: true, + ..Default::default() + }, + ..Default::default() + }; + + let (quiet, full) = run_security(&ctx, &cfg, vec![]); + assert!(quiet.is_none(), "reported an action nobody clicked"); + let target = painted_text_center(&full, "Show database key…").expect("no key button"); + + let (action, _) = run_security(&ctx, &cfg, click_at(target)); + assert_eq!(action, Some(SecurityAction::ShowKey)); +} + +/// Columns are live state, so a draft taken before one changed must not carry +/// the old set back on Apply — `app::pin_live_fields` is what prevents that, +/// and this is the assertion that it covers this field. +#[test] +fn a_stale_draft_cannot_revert_the_columns() { + let mut w = SettingsTab::new(); + let mut cfg = Config::default(); + w.stage(&cfg); + + // The header menu switches a column on while the tab is on screen. + cfg.search.columns.size = true; + assert!(!w.is_dirty(&cfg), "a live column change read as an edit"); + + let draft = w.draft_config().expect("a draft"); + let mut applied = draft; + crate::app::pin_live_fields(&mut applied, &cfg); + assert!( + applied.search.columns.size, + "applying the stale draft reverted the column" + ); +} diff --git a/crates/quicksearch-gui/src/test_ui.rs b/crates/quicksearch-gui/src/test_ui.rs index f146069..fbe80c5 100644 --- a/crates/quicksearch-gui/src/test_ui.rs +++ b/crates/quicksearch-gui/src/test_ui.rs @@ -101,6 +101,31 @@ pub fn painted_spans(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> { .collect() } +/// Every styled run painted this frame that has a background behind it, with +/// that background, in paint order. +/// +/// The distinguishing mark of a highlighted match: the column headers and the +/// strong parts of a snippet are painted in the same *text* color, so +/// [`painted_spans`] alone cannot tell a match from a header. +pub fn painted_backgrounds(out: &egui::FullOutput) -> Vec<(String, egui::Color32)> { + painted_galleys(out) + .into_iter() + .flat_map(|(g, _)| { + g.job + .sections + .iter() + .filter(|s| s.format.background != egui::Color32::TRANSPARENT) + .map(|s| { + ( + g.job.text[s.byte_range.clone()].to_string(), + s.format.background, + ) + }) + .collect::>() + }) + .collect() +} + /// Every *visible* row of every galley painted this frame, in paint order. /// Not the same as [`painted_text`]: a galley's `text()` is the job it was /// laid out from, including the rows epaint dropped at `wrap.max_rows` — diff --git a/crates/quicksearch-gui/src/tips.rs b/crates/quicksearch-gui/src/tips.rs index a5333fc..58a0c14 100644 --- a/crates/quicksearch-gui/src/tips.rs +++ b/crates/quicksearch-gui/src/tips.rs @@ -1,5 +1,5 @@ //! Plain-language tooltips for the configuration controls: every setting in -//! the Options window, and every configuration control on the Manage Index +//! the Settings tab, and every configuration control on the Manage Index //! tab, explains itself on hover. /// How wide a tooltip may get; matches `manage_tab::db_size_tooltip`. @@ -73,7 +73,7 @@ pub fn tip_row( ui.end_row(); } -// --- Options: Paths ------------------------------------------------------ +// --- Settings: Paths ------------------------------------------------------ pub static DATABASE_PATH: Tip = Tip { title: "Database file", @@ -90,7 +90,7 @@ pub static DATABASE_PATH: Tip = Tip { caution: None, }; -// --- Options: Indexing --------------------------------------------------- +// --- Settings: Indexing --------------------------------------------------- pub static REINDEX_INTERVAL: Tip = Tip { title: "Full reindex every", @@ -139,7 +139,7 @@ pub static INCLUDE_HIDDEN: Tip = Tip { caution: None, }; -// --- Options: Processing ------------------------------------------------- +// --- Settings: Processing ------------------------------------------------- pub static TOKENIZER: Tip = Tip { title: "Tokenizer", @@ -257,7 +257,7 @@ pub static STORE_TEXT: Tip = Tip { caution: None, }; -// --- Options: Search ----------------------------------------------------- +// --- Settings: Search ----------------------------------------------------- pub static FUZZY_DEFAULT: Tip = Tip { title: "Fuzzy search ON by default", @@ -329,7 +329,43 @@ pub static DEBOUNCE: Tip = Tip { caution: None, }; -// --- Options: Interface -------------------------------------------------- +pub static LIVE_RESULTS: Tip = Tip { + title: "Live results", + body: "Watches the results currently on screen and updates them as the \ + files change, so a file you rename or edit in another window does \ + not sit there showing its old name or its old text.\n\n\ + Only the rows you can actually see are watched, and every one of \ + them is dropped the moment you change the search. Nothing is ever \ + added, removed or re-sorted while you read — a file that stops \ + matching stays where it is until you search again.", + examples: &[ + "Renames, deletions and edited contents all show up within a second, \ + whatever the indexer is doing — what a row says is read from the \ + file, not from the index.", + "A row is also checked against the disk as it comes on screen, so one \ + the index was out of date about puts itself right. That check is all \ + you get over a network share, where the system does not report other \ + machines' writes.", + ], + caution: Some( + "The files behind the rows you are looking at are kept up to date in \ + the index too, even while indexing is stopped. Turn this off if a \ + stopped index must mean nothing is written at all.", + ), +}; + +pub static COLUMNS: Tip = Tip { + title: "Search columns", + body: "Which columns the results table shows. The path is always there — \ + it is the only column that identifies a result on its own.\n\n\ + Size and modified date start switched off: the width they take is \ + usually better spent on the path and the matched text. Turning a \ + column on also makes it available to sort by.", + examples: &["Right-clicking any column header on the Search tab does the same thing."], + caution: None, +}; + +// --- Settings: Interface -------------------------------------------------- pub static UI_SCALE: Tip = Tip { title: "UI scale", @@ -380,7 +416,7 @@ pub static COLOR_SCHEME: Tip = Tip { caution: None, }; -// --- Options: Security --------------------------------------------------- +// --- Settings: Security --------------------------------------------------- pub static ENABLE_PASSWORD: Tip = Tip { title: "Enable password protection", @@ -419,6 +455,21 @@ pub static DISABLE_PASSWORD: Tip = Tip { ), }; +pub static SHOW_KEY: Tip = Tip { + title: "Show database key", + body: "Reveals the raw SQLCipher key the index is encrypted with, once you \ + have confirmed your password. Tools such as DB Browser for SQLCipher \ + accept it in the 0x form shown and can then open the index file \ + directly.\n\n\ + The key is worked out from your password and the salt in the config \ + file, so it stays the same until the password changes.", + examples: &[], + caution: Some( + "Anyone holding this key can read the index without the password. Treat a copy \ + of it as carefully as the password itself.", + ), +}; + pub static REMEMBER_KEYCHAIN: Tip = Tip { title: "Remember on this device", body: "Hands the key to the password store your system already has, such \ @@ -616,6 +667,7 @@ mod tests { &ENABLE_PASSWORD, &CHANGE_PASSWORD, &DISABLE_PASSWORD, + &SHOW_KEY, &REMEMBER_KEYCHAIN, &START_NOW, &STOP_INDEXING, diff --git a/crates/quicksearch-gui/src/tutorial.rs b/crates/quicksearch-gui/src/tutorial.rs new file mode 100644 index 0000000..3c3830f --- /dev/null +++ b/crates/quicksearch-gui/src/tutorial.rs @@ -0,0 +1,441 @@ +//! The first-start tour: a few pages explaining what QuickSearch indexes, +//! how results are ranked, and what the parts of the Search tab do. +//! +//! Shown once, to an installation that has never run before — `[ui] +//! tutorial_seen` is `Some(false)` only in a config file this version created, +//! so upgrading into this version does not summon it. The Help tab can bring +//! it back afterwards, which is also what keeps this from being write-only. + +use crate::ui_util::{centered_modal, hint}; + +/// One page of the tour. Static text, so the pages are a table rather than a +/// match arm each. +struct Page { + title: &'static str, + /// Paragraphs. Rendered in order with a little space between them. + body: &'static [&'static str], + /// Rendered small and de-emphasised under the body — where to go, rather + /// than what the thing is. + pointer: Option<&'static str>, +} + +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.", + "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.", + ], + pointer: Some("A quick tutorial for new users! Hit Skip to Exit."), + }, + Page { + title: "What is indexed", + body: &[ + "Indexing is QuickSearch reading through your folders once and \ + 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.", + "QuickSearch never connects to the internet, and always respects your privacy. \ + QuickSearch can encrypt your index to make this remembered data more secure.", + "These are the folders being indexed right now:", + ], + pointer: Some( + "To index more locations, open the Manage Index tab and add a folder. \ + To set an index password, look near the bottom of the Settings tab.", + ), + }, + Page { + title: "How results are ranked", + body: &[ + "The best search matches come first (have the lowest rank). \ + Exact file-name matchs are best, then names that contain what you typed; then \ + files whose contents contain the search terms, the ones mentioning it most often \ + first; and last, files matched only by their full folder path.", + "The coloured number in the Rank column is which of those tiers a \ + result came from: blue is a great match, red is a distant one. \ + Clicking a column heading sorts by something else instead.", + ], + pointer: Some("Right-click any column heading to choose which columns are shown."), + }, + Page { + title: "The status bar", + body: &[ + "The line along the bottom of the window is what QuickSearch is \ + doing. While it is indexing it shows the phase, how far through it \ + is, and how fast; when it has nothing to do it shows how many files \ + are indexed.", + "Searching works the whole time, including during that first indexing run, \ + but some files might not be shown in the results until the scan completes.", + ], + pointer: None, + }, + Page { + title: "Typos, and what a result can do", + 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 \ + it, copy its path, or build a filter that hides files like it from \ + future searches.", + ], + pointer: Some( + "The ? button left of the search box lists the filters you can type \ + into a query, like type:Document or modified:>=2024-01-01.", + ), + }, + Page { + title: "Duplicates", + body: &[ + "The Duplicates tab looks for files across all indexed folders for identical copies. \ + They are shown grouped together, with the largest wasted space first.", + "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.", + ], + 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.", + ), + }, + Page { + title: "Settings", + body: &[ + "The Settings tab, at the right-hand end of the tab strip, is where \ + you can tweak and tune the software. Mouse over any of \ + the settings for a brief description of what they do.", + "Most changes wait for the Apply & Save button at the bottom.", + "QuickSearch is completely free for anyone to use. If you love it, please let your friends know about us!", + ], + pointer: None, + }, +]; + +/// The open tour. +pub struct Tutorial { + page: usize, +} + +impl Tutorial { + pub fn new() -> Tutorial { + Tutorial { page: 0 } + } + + /// Render. `roots` is the live indexed-folder list, named on the page + /// about indexing so the tour describes this installation rather than a + /// generic one. + /// Returns true once the tour is finished with — skipped or read to the + /// end — which is the caller's cue to remember that and drop it. + pub fn ui(&mut self, ctx: &egui::Context, roots: &[String]) -> bool { + let page = &PAGES[self.page.min(PAGES.len() - 1)]; + let (first, last) = (self.page == 0, self.page + 1 == PAGES.len()); + let mut dismissed = false; + let mut step: i64 = 0; + + centered_modal(ctx, page.title, |ui| { + ui.set_max_width(520.0); + for paragraph in page.body { + ui.label(*paragraph); + ui.add_space(6.0); + } + // The one page that shows live state rather than static text. + if self.page == 1 { + if roots.is_empty() { + ui.label(hint("No folders are indexed yet.")); + } else { + for root in roots { + ui.monospace(root); + } + } + ui.add_space(6.0); + } + if let Some(pointer) = page.pointer { + ui.label(hint(pointer)); + } + + ui.add_space(10.0); + ui.separator(); + // Three equal thirds rather than one row: it is the only layout + // that puts Skip in the middle without measuring the buttons + // either side of it, whose widths change with the page ("Next" + // becoming "Finish") and with the counter's digits. + ui.columns(3, |cols| { + // A column lays its contents out *justified*, so a button put + // straight into one is stretched to the full third. The other + // two escape that by nesting their own layout; this one has to + // say so. + cols[0].with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| { + if ui.add_enabled(!first, egui::Button::new("Back")).clicked() { + step = -1; + } + }); + cols[1].vertical_centered(|ui| { + if ui.button("Skip").clicked() { + dismissed = true; + } + }); + // `Align::Min`, not `Center`: a column is as tall as the rest + // of the window, so centring in it drops the button a hundred + // points below the two beside it. + cols[2].with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let p = crate::color::palette(ui.visuals().dark_mode); + let next = if last { "Finish" } else { "Next" }; + if ui + .add(crate::ui_util::bordered_button(next, p.blue)) + .clicked() + { + if last { + dismissed = true; + } else { + step = 1; + } + } + ui.label(hint(format!("{} of {}", self.page + 1, PAGES.len()))); + }); + }); + }); + + // Applied after the closure so the page a frame rendered stays the page + // its buttons were laid out for. + if step != 0 { + let next = self.page as i64 + step; + self.page = next.clamp(0, PAGES.len() as i64 - 1) as usize; + } + dismissed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_ui::{click_at, painted_text, raw_input}; + + const SCREEN: egui::Vec2 = egui::vec2(1000.0, 700.0); + + /// Two passes: an `egui::Window` is measured on its first frame and only + /// placed on the next, so a single pass paints nothing to read back and + /// has nothing at a known position to click. + fn frame( + ctx: &egui::Context, + tour: &mut Tutorial, + events: Vec, + ) -> (egui::FullOutput, bool) { + let roots = ["/home/me".to_string()]; + let _ = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| { + tour.ui(ctx, &roots); + }); + let mut dismissed = false; + let out = ctx.run(raw_input(SCREEN, events), |ctx| { + dismissed = tour.ui(ctx, &roots); + }); + (out, dismissed) + } + + /// Every page has something to say, and says it. + #[test] + fn every_page_paints_its_own_title_and_body() { + for (page, spec) in PAGES.iter().enumerate() { + let ctx = egui::Context::default(); + let mut tour = Tutorial { page }; + let (out, _) = frame(&ctx, &mut tour, Vec::new()); + let painted = painted_text(&out); + assert!( + painted.iter().any(|t| t == spec.title), + "page {page} painted no title: {painted:?}" + ); + assert!(!spec.body.is_empty(), "page {page} has an empty body"); + assert!( + painted + .iter() + .any(|t| t == &format!("{} of {}", page + 1, PAGES.len())), + "page {page} did not say where it is: {painted:?}" + ); + } + } + + /// The page about indexing names *this* installation's folders, not a + /// generic example. + #[test] + fn the_indexing_page_lists_the_configured_folders() { + let ctx = egui::Context::default(); + let mut tour = Tutorial { page: 1 }; + let roots = ["/srv/projects".to_string()]; + let _ = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| { + tour.ui(ctx, &roots); + }); + let out = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| { + tour.ui(ctx, &roots); + }); + let painted = painted_text(&out); + assert!(painted.iter().any(|t| t == "/srv/projects"), "{painted:?}"); + } + + /// The y of the footer row on `page`, found by walking down the middle + /// column, which only Skip occupies. Wrapped text height moves the row + /// from page to page, so it is probed rather than guessed. + fn footer_y(ctx: &egui::Context, page: usize) -> f32 { + for y in (150..600).step_by(2) { + let mut t = Tutorial { page }; + let (_, dismissed) = frame(ctx, &mut t, click_at(egui::pos2(500.0, y as f32))); + if dismissed { + return y as f32; + } + } + panic!("no Skip button down the middle of page {page}"); + } + + /// The stretch of x along the footer row that one button answers a click + /// on — where it is, and how wide. + #[derive(Debug, Clone, Copy)] + struct Span { + lo: f32, + hi: f32, + } + + impl Span { + fn width(&self) -> f32 { + self.hi - self.lo + } + } + + /// The three footer buttons, found by what clicking each one does: Back + /// steps a page back, Next steps one forward, Skip dismisses. + /// + /// Must be run on a middle page — on the last page Finish and Skip both + /// dismiss without moving, and on the first Back is disabled. + fn footer_spans(ctx: &egui::Context, page: usize) -> [Option; 3] { + assert!(page > 0 && page + 1 < PAGES.len(), "probe a middle page"); + let y = footer_y(ctx, page); + let mut spans: [Option; 3] = [None; 3]; + for x in 150..850 { + let mut t = Tutorial { page }; + let (_, dismissed) = frame(ctx, &mut t, click_at(egui::pos2(x as f32, y))); + let x = x as f32; + let which = if dismissed { + 1 // Skip + } else if t.page + 1 == page { + 0 // Back + } else if t.page == page + 1 { + 2 // Next + } else { + continue; + }; + match &mut spans[which] { + Some(span) => span.hi = x, + slot => *slot = Some(Span { lo: x, hi: x }), + } + } + spans + } + + /// The footer reads Back, then Skip, then Next — and each does what its + /// label says. Positions are probed rather than asserted against numbers: + /// the window auto-sizes to the page's text, so the thirds move. + /// + /// The widths are the other half of it. `Ui::columns` lays a column out + /// justified, so a button dropped straight into one comes out as wide as + /// the whole third — which is what Back was until it was given a layout of + /// its own. Two buttons with four-letter labels either side of the row + /// have to come out the same size. + #[test] + fn the_footer_runs_back_then_skip_then_next_at_the_same_size() { + let ctx = egui::Context::default(); + let [back, skip, next] = footer_spans(&ctx, 1); + 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"); + assert!( + back.lo < skip.lo, + "Back ({back:?}) is not left of Skip ({skip:?})" + ); + assert!( + skip.lo < next.lo, + "Skip ({skip:?}) is not left of Next ({next:?})" + ); + + assert!( + (back.width() - next.width()).abs() <= 2.0, + "Back is {} wide against Next's {}", + back.width(), + next.width() + ); + // Belt and braces on the shape of the bug: a stretched button fills + // its third of a 520-point modal, which no four-letter label does. + assert!( + back.width() < 80.0, + "Back is stretched to {} points", + back.width() + ); + } + + /// Back is disabled on the first page, so nothing in the footer can walk + /// the tour off the front. + #[test] + fn the_first_page_cannot_go_back() { + let ctx = egui::Context::default(); + let y = footer_y(&ctx, 0); + for x in (150..850).step_by(4) { + let mut t = Tutorial { page: 0 }; + let _ = frame(&ctx, &mut t, click_at(egui::pos2(x as f32, y))); + assert!( + t.page == 0 || t.page == 1, + "clicking x={x} left page {}", + t.page + ); + } + } + + /// Clicking anywhere in the button row, on any page; collects what fired. + fn sweep(ctx: &egui::Context, tour: &mut Tutorial) -> Vec { + let mut seen = Vec::new(); + for y in (200..500).step_by(4) { + for x in (240..760).step_by(8) { + let mut t = Tutorial { page: tour.page }; + let (_, dismissed) = frame(ctx, &mut t, click_at(egui::pos2(x as f32, y as f32))); + if dismissed { + seen.push(t.page); + } + } + } + seen + } + + /// Both ways out of the tour report the dismissal, so the flag gets set + /// whichever the user takes. Positions depend on wrapped text height, so + /// the button row is swept rather than guessed at — the same approach the + /// confirmation modals' tests take. + #[test] + fn skip_and_finish_both_dismiss() { + let ctx = egui::Context::default(); + + // Skip is on every page. + let mut tour = Tutorial { page: 0 }; + assert!( + !sweep(&ctx, &mut tour).is_empty(), + "Skip never fired on the first page" + ); + + // Finish only on the last, where it replaces Next. + let mut tour = Tutorial { + page: PAGES.len() - 1, + }; + assert!( + !sweep(&ctx, &mut tour).is_empty(), + "Finish never fired on the last page" + ); + let ctx = egui::Context::default(); + let mut tour = Tutorial { + page: PAGES.len() - 1, + }; + let (out, _) = frame(&ctx, &mut tour, Vec::new()); + assert!( + painted_text(&out).contains(&"Finish".to_string()), + "the last page still offers Next" + ); + } +} diff --git a/crates/quicksearch-gui/src/ui_util.rs b/crates/quicksearch-gui/src/ui_util.rs index 03e0f77..febb143 100644 --- a/crates/quicksearch-gui/src/ui_util.rs +++ b/crates/quicksearch-gui/src/ui_util.rs @@ -122,8 +122,10 @@ pub fn pattern_edit( (response, valid) } -/// Middle-elide `text` so it fits `max_width` pixels when laid out in -/// `font_id`, returning it borrowed and untouched when it already fits. +/// Where a middle-elide cuts `text` to fit `max_width` pixels in `font_id`: +/// the byte offset the head keeps up to, and the one the tail resumes from, +/// with a single `…` standing for everything between. `None` when the whole +/// string fits and nothing is dropped. /// /// The budget is in pixels, summed from the font's own glyph advances (the /// same numbers egui's layout adds up), not a character count scaled by one @@ -131,18 +133,20 @@ pub fn pattern_edit( /// directions: overshoot and egui elides the result a *second* time, /// painting two ellipses; undershoot and the column sits visibly short. /// -/// The borrowed/owned distinction is the caller's signal that something was -/// dropped, which is what a "full text on hover" tooltip keys off. -pub fn middle_elide<'a>( +/// Split out from [`middle_elide`] because a caller that also has *ranges* to +/// highlight needs the cut itself, not just the shortened string: it renders +/// the two surviving ends separately so its marks keep the offsets they had +/// (see `snippet_render::path_cell_job`). +pub fn middle_elide_cut( ui: &egui::Ui, - text: &'a str, + text: &str, max_width: f32, font_id: &egui::FontId, -) -> Cow<'a, str> { +) -> Option<(usize, usize)> { ui.fonts(|f| { let width_of = |c: char| f.glyph_width(font_id, c); if text.chars().map(width_of).sum::() <= max_width { - return Cow::Borrowed(text); + return None; } let budget = max_width - width_of('…'); @@ -178,17 +182,35 @@ pub fn middle_elide<'a>( } if head >= tail { // The two halves met without dropping anything. - return Cow::Borrowed(text); + return None; } - - let mut out = String::with_capacity(head + '…'.len_utf8() + (text.len() - tail)); - out.push_str(&text[..head]); - out.push('…'); - out.push_str(&text[tail..]); - Cow::Owned(out) + Some((head, tail)) }) } +/// Middle-elide `text` so it fits `max_width` pixels when laid out in +/// `font_id`, returning it borrowed and untouched when it already fits. +/// +/// The borrowed/owned distinction is the caller's signal that something was +/// dropped, which is what a "full text on hover" tooltip keys off. +pub fn middle_elide<'a>( + ui: &egui::Ui, + text: &'a str, + max_width: f32, + font_id: &egui::FontId, +) -> Cow<'a, str> { + match middle_elide_cut(ui, text, max_width, font_id) { + None => Cow::Borrowed(text), + Some((head, tail)) => { + let mut out = String::with_capacity(head + '…'.len_utf8() + (text.len() - tail)); + out.push_str(&text[..head]); + out.push('…'); + out.push_str(&text[tail..]); + Cow::Owned(out) + } + } +} + /// Paint a semitransparent down-arrow near the bottom edge of a scroll /// area while more content lies below the fold. Painter-only, so it can /// never swallow clicks; the bundled fonts have no ▼ glyph, so it is a diff --git a/crates/quicksearch-gui/src/unlock.rs b/crates/quicksearch-gui/src/unlock.rs index 1617efa..2eedaaf 100644 --- a/crates/quicksearch-gui/src/unlock.rs +++ b/crates/quicksearch-gui/src/unlock.rs @@ -72,12 +72,12 @@ impl Gate { return; } if let Gate::Running(app) = self { - // The Options window is waiting for a key press to bind; the - // shortcut must not reshuffle the window underneath that dialog. + // The Settings tab is waiting for a key press to bind; the + // shortcut must not reshuffle the window underneath it. if app.capturing_hotkey() { return; } - app.activate_search(); + app.activate_search(ctx); } crate::hotkey::raise(ctx, frame); } diff --git a/packaging/capture-scenario.txt b/packaging/capture-scenario.txt index 0b226b1..36bbbb3 100644 --- a/packaging/capture-scenario.txt +++ b/packaging/capture-scenario.txt @@ -3,7 +3,7 @@ # "capture"). Edit this file to re-choreograph the captures; timings are in # milliseconds and `max` caps bound clip length without failing the run. -# The demo config sets [ui] scale = 1.5, so every window size here is 1.5x +# The demo config sets [ui] scale = 1.25, so every window size here is 1.25x # the layout it shows: high-resolution captures of an unchanged layout. # --- 1. manage-indexing.webm: the fresh auto-index in progress ------------- @@ -26,8 +26,12 @@ focus_search # tab switches drop egui focus; re-arm it clear_query window 1200 600 # compact clip: the smallest layout at # which every results column still fits - # (any narrower clips the Match column - # away, defeating the demo), at 1.5x + # (any narrower clips the Content Match + # column away, defeating the demo), at + # 1.25x. Sized for the four columns the + # demo config pins — Name, Path, Content + # Match, Rank — so turning another one on + # there means revisiting this width. wait_ms 800 # the resize lands asynchronously record_start search # The query is typed in quick bursts with human hesitations; each pause @@ -42,8 +46,8 @@ wait_ms 300 type "ition" cps 10 wait_search_done max 8000 wait_ms 1000 -hover_match 2 # pin the pointer on the 3rd result's Match - # cell: the tooltip expands the snippet +hover_match 2 # pin the pointer on the 3rd result's Content + # Match cell: the tooltip expands the snippet # with surrounding file content wait_ms 2500 # tooltip delay, then linger on it record_stop # end the clip with the tooltip on screen diff --git a/packaging/capture.sh b/packaging/capture.sh index 97f2806..d3412a0 100755 --- a/packaging/capture.sh +++ b/packaging/capture.sh @@ -68,8 +68,34 @@ auto_index = true include_hidden = true ignore_patterns = [] -# 1.5x zoom + proportionally larger windows (set in the scenario) render the -# same layout at ~1.5x the pixel density, for crisper website assets. +[search] +# Off for the run: on, the watcher arms over the visible rows mid-recording, +# re-reads them from disk and hands the paths back for reindexing — index +# writes inside a clip that is supposed to be reproducible. +live_results = false + +[search.columns] +# Exactly the four the captures are choreographed around: Name, Path, Content +# Match, Rank. Every one is pinned rather than defaulted — the scenario's +# window widths are chosen for this column set, and `hover_match` addresses +# Content Match cells by index, so a shipped default that moved would silently +# re-frame or break the clips. +# +# Path is absent because it is not optional: it is always drawn (see +# `ColumnsConfig`, which deliberately does not represent it). +name = true +content_match = true +rank = true +size = false +modified = false + +# 1.25x zoom + proportionally larger windows (set in the scenario) render the +# same layout at ~1.25x the pixel density, for crisper website assets. +# +# No `tutorial_seen` key here, deliberately: absent it deserializes to `None`, +# which reads as "an installation that upgraded into this version". Writing +# `false` would open the first-run tour over the first capture and wedge the +# run. [ui] scale = 1.25 EOF diff --git a/packaging/quicksearch.1 b/packaging/quicksearch.1 index 6f0fb47..920a1b3 100644 --- a/packaging/quicksearch.1 +++ b/packaging/quicksearch.1 @@ -136,7 +136,7 @@ text stays part of the search phrase. .BR AND ", " OR and parentheses are treated as plain words. .SH PASSWORD PROTECTION -The index can be encrypted with a password (application Options, Security). +The index can be encrypted with a password (the Settings tab, Security). A protected index must be unlocked every time either binary starts. The application shows an unlock screen; terminal mode resolves the key from, in order: the OS keychain (when \(lqRemember on this device\(rq is enabled), diff --git a/packaging/quicksearch.nsi b/packaging/quicksearch.nsi index dd8de57..abaf661 100644 --- a/packaging/quicksearch.nsi +++ b/packaging/quicksearch.nsi @@ -61,6 +61,18 @@ VIAddVersionKey "FileDescription" "${APP} ${VERSION} installer" !insertmacro MUI_PAGE_COMPONENTS !insertmacro MUI_PAGE_DIRECTORY !insertmacro MUI_PAGE_INSTFILES +; The payload is two binaries and three documents, so this install finishes in +; about a second - fast enough that people reported it as a failure. The finish +; page is the only place that can say otherwise, so it lists what was installed +; and where instead of the stock "Setup was completed successfully." +; Kept to six lines: the finish page's text field is a fixed height, and MUI +; clips rather than scrolls what does not fit. +!define MUI_FINISHPAGE_TITLE "${APP} ${VERSION} is installed" +!define MUI_FINISHPAGE_TEXT "Installed into $INSTDIR:$\r$\n\ + quicksearch.exe (the app), quicksearch-cli.exe (terminal search),$\r$\n\ + README.md, LICENSE.txt and config_example.toml.$\r$\n$\r$\n\ + Your settings and search index are created on first run, under your \ + own account. Upgrading and uninstalling leave both alone." !define MUI_FINISHPAGE_RUN !define MUI_FINISHPAGE_RUN_TEXT "Run ${APP}" !define MUI_FINISHPAGE_RUN_FUNCTION LaunchAsUser