* refactor: move filter mode into run_with via should_enter
Remove the standalone `filter` function from main.rs and integrate
filter mode into the main `run_with` pipeline. When `options.filter`
is set, `should_enter` now waits for all items to be processed and
returns false (skipping TUI), and `App::results` returns all matched
items. This unifies filter mode with the rest of the codebase so it
benefits from all other flags (sorting, tiebreaks, etc.).
https://claude.ai/code/session_01T7pa2RRX85MvBtnH7PWtmw
* perf: optimize filter mode to match single-pass performance
Three changes that eliminate the performance regression from routing
filter mode through run_with:
1. should_enter(): Wait for reader to finish BEFORE starting matcher,
then run matcher exactly once. The old polling loop called
restart_matcher() repeatedly, each time resetting the ItemPool taken
counter and re-processing all items from scratch. With 1M items
arriving in batches, items were matched multiple times.
2. matcher.run(): Remove unnecessary .enumerate() (index was discarded)
and remove item.clone() — into_par_iter() yields owned values so the
Arc can be moved directly into MatchedItem.
3. App::results(): In filter mode, drain items instead of cloning to
avoid 271K MatchedItem clones + Arc allocations.
Benchmark (1M file paths, query "test", 10 runs):
Old standalone filter: 5.306s ± 0.085s
New unified filter: 4.932s ± 0.099s (1.08x faster)
https://claude.ai/code/session_01T7pa2RRX85MvBtnH7PWtmw
* perf: make restart_matcher incremental when force=false
When force=false, skip the item pool reset so take() only returns new
(untaken) items. The callback merges new sorted matches into the
existing sorted result list using an O(n+m) merge, instead of
replacing it. This fixes the root cause: previously every
restart_matcher call re-processed ALL items from scratch because
reset() set the taken counter to 0.
This benefits all polling callers (filter, select-1, exit-0, sync),
not just filter mode. Items arriving in batches are now each matched
exactly once, and matching overlaps with I/O since batches are
processed as they arrive.
When force=true (query changed), behavior is unchanged: full reset
and re-match.
Reverts the filter-specific workaround from the previous commit in
favor of this general fix; the original polling loop in should_enter
is now efficient.
Benchmark (1M file paths, query "test", 10 runs):
Old standalone filter (master): 4.566s ± 0.055s
Incremental restart_matcher: 3.654s ± 0.035s (1.25x faster)
https://claude.ai/code/session_01T7pa2RRX85MvBtnH7PWtmw
* refactor: extract sorted_merge into MatchedItem method
Move the two-sorted-list merge from the restart_matcher closure into
MatchedItem::sorted_merge() for clarity and reusability. The method
merges two Vec<MatchedItem> lists that are already sorted by rank
into a single sorted Vec in O(n+m) time.
https://claude.ai/code/session_01T7pa2RRX85MvBtnH7PWtmw
* fix: preserve incremental matches across render cycles
When restart_matcher runs with force=false, the callback marks results
with a MergeStrategy so the render loop knows how to combine them with
existing items. Previously, render's .take() would drain processed_items
to None, and the next incremental callback would create a fresh batch —
causing the render to replace all items with just the latest batch,
losing previously matched items.
Now:
- Replace: full re-match (force=true), replaces item list entirely
- SortedMerge: incremental sorted results, merged into item_list.items
- Append: incremental unsorted results (--no-sort), appended
This fixes match count consistency in interactive mode. With 1M items
and query "test", match count is now 290,083 on every run (matching
fzf's consistency), vs wildly varying counts before (min 13, max 56,950).
https://claude.ai/code/session_01T7pa2RRX85MvBtnH7PWtmw
* chore: fmt & clippy
---------
Co-authored-by: Claude <noreply@anthropic.com>
This PR has grown beyond its initial scope due to me over-optimizing everything, but it leads to:
Paving the way for future actually interactive previews
Consistently better performance than fzf in our bench thanks to thread and concurrency optimizations as well as the use of kanal for the items channels
Given the scope, I'm marking this as breaking because:
setting wrap in the preview window layout disables the pty since we don't want to manipulate the raw buffer to word-wrap it manually
kanal channels work slightly differently and might break library usage, even though switching to them did not require any modifications of the examples so it's unlikely that users will see anything break
* fix: force cwd for preview
* fix: correctly set cwd & kill pty child in the right order
* fix: use std threads & reopen new pty for each preview
* feat: use tui-term for displaying
* feat: scroll in pty
* fix: make nested skim previews work
* fix: clippy mistake
* feat: reactive preview triggering
* chore: generate completions & manpage
* chore: optimizations & thread cleanup
* chore: use kanal for faster channels
* fix: tests
* fix: only send items if the matcher hasn't been killed in the meantime (#947)
* tests: add coverage
* tests: fix bin path with coverage
* tests: upload tests to codecov
* chore: make pty opt-in through preview-window
* chore: generate completions & manpage
---------
Co-authored-by: Skim bot <skim-bot@skim-rs.github.io>