Skip to content

feat(browse): fuzzy-find the listing with / and make q always quit - #29

Merged
SpollaL merged 2 commits into
mainfrom
browse-find-and-quit
Aug 23, 2026
Merged

SpollaL merged 2 commits into
mainfrom
browse-find-and-quit

Conversation

@SpollaL

@SpollaL SpollaL commented Aug 23, 2026

Copy link
Copy Markdown
Owner

The browse pane had two gaps: q did not quit it, and there was no way to find a file in a long listing. This closes both.

q always quits

The binding was gated on app.viewer.is_none(), so the moment a file was open the browser pane had no exit at all — the only way out was Tab into the viewer and quit from there. The gate is gone: q quits from either pane, with or without a file open, and the shortcut bar now offers it in both focus states.

/ fuzzy-finds the listing

An fzf-style live filter. / opens a prompt that owns the keyboard; the listing narrows as you type and the matched characters are highlighted.

Key Action
Enter Open the highlighted match
Esc Cancel, restore the full listing
↑ / ↓, ctrl-n / ctrl-p Move through the matches
ctrl-u Clear the query
Backspace Delete a character; on an empty query, close the prompt

The query matches anywhere in a name, in order, so ordcsv finds orders.csv. Ranking favours hits on word boundaries (_, -, ., /, space, camelCase humps) and runs of consecutive characters; gaps cost; the shorter name wins a tie. Case follows the fzf rule — a lowercase query is case-insensitive, one uppercase character makes the whole query case-sensitive.

Why the matcher is hand-rolled

No new dependency — Cargo.toml is untouched. The alternative was nucleo-matcher, which is MPL-2.0; that alone was not disqualifying (option-ext, also MPL-2.0, is already in the tree via dirs), but the whole matcher is about a hundred lines and a crate is not worth the upgrade surface for that.

Three details worth a reviewer's attention in src/browser/find.rs:

  • Anchored greedy, best over all anchors. The match is retried from every position the first query character occurs at, and the best-scoring run wins. A single greedy pass mis-scores ab against a_xb_ab — it locks onto the first a and never finds the tight run at the end.
  • positions are char indices, not byte offsets. That is what lets ui.rs highlight without byte arithmetic on non-ASCII names.
  • Case folding is 1:1 (c.to_lowercase().next()). flat_map(char::to_lowercase) would expand ß to ss and desynchronise the match positions from the name being highlighted.

Architecture notes

  • BrowserApp::cursor now indexes matches, not entries. Resolve it through selected_entry() / selected_index(), never entries[cursor]. With no query active matches is still every entry, so the two only diverge while filtering.
  • Key precedence in run_browser_app is download prompt → find prompt → theme picker → single-key bindings. The find prompt has to consume keys before the browse bindings so q, d and T arrive as query text.
  • close_find() is the single exit for both Enter and Esc: it restores the full listing and leaves the cursor on whatever was highlighted, so opening it afterwards is the ordinary unfiltered path.

Tests

  • 44 new unit tests; 374 pass in total. Coverage includes the tie-break cases (the_tightest_run_wins_over_the_earliest_one), smart case in both directions, positions_are_char_indices_not_byte_offsets, the prompt swallowing the browse bindings, and q_quits_with_a_file_already_open — which pins the reported bug.
  • qa.sh cases X10–X16, per the CLAUDE.md rule that keybinding changes are covered before the PR: / narrows, Esc restores, Enter opens the top match, the prompt swallows q/d/T, ctrl-n walks the matches, and q quits with a viewer loaded. Full suite: 192 passed, 0 failed.
  • Case X5 no longer asserts on a ctrl-h binding that never existed (Tab is the focus toggle) and now checks the shortcut bar, which actually reflects focus.
  • CI checks per CONTRIBUTING: cargo test, cargo clippy -- -D warnings, cargo fmt --check — all clean.

Docs

CHANGELOG (Added for /, Fixed for q), README (feature bullet, browse keybindings, a paragraph on ranking and smart case), CLAUDE.md (find.rs in the module list, two gotchas, keybinding table). The CLAUDE.md testing guidance and the qa.sh launch() comment both justified respawning the pane with "q is ignored in browse mode" — the rule this PR deletes — so both were rewritten around the cause that still holds: prompts that accept typed text take q as a character.

Not included

.github/assets/demo.gif was not regenerated, so the browse phase of the demo still does not show /. It needs vhs + ttyd.

🤖 Generated with Claude Code

SpollaL and others added 2 commits August 22, 2026 18:59
The browser pane had no working exit once a file was open: q was gated on no viewer being loaded, so the only way out was Tab into the viewer and quit from there. The gate is gone - q quits from either pane.

New src/browser/find.rs ranks the listing against a live query: subsequence match, bonuses for word boundaries and consecutive runs, gap penalties, shorter-name tie-break, and fzf smart case. It is hand-rolled (~80 lines of matcher) rather than a crate, so nothing is added to Cargo.toml. Match positions are char indices, which is what lets ui.rs highlight them without byte arithmetic. Anchored greedy retried from every occurrence of the first query char, because a single greedy pass mis-scores the tightest run.

BrowserApp::cursor now indexes matches instead of entries and is resolved through selected_entry()/selected_index(). The prompt owns the keyboard while it is open, checked right after the download prompt and for the same reason: q, d and T have to arrive as query text.

Covered by 44 new unit tests and qa.sh cases X10-X16. X5 also stops asserting on a ctrl-h binding that never existed - tab is the focus toggle - and now checks the shortcut bar, which is what actually reflects focus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLAUDE.md testing guidance and the qa.sh launch() comment both justified respawning the pane with a rule that no longer holds. The justification still stands, it just has a different cause now: prompts that accept typed text - search, filter, export, browse find - take q as a character rather than as a quit.

Also lists / and q on the browser events.rs module line, and drops a line-count claim from the changelog entry that the file does not bear out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SpollaL
SpollaL merged commit 3f20189 into main Aug 23, 2026
11 checks passed
@SpollaL
SpollaL deleted the browse-find-and-quit branch August 23, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant