Compare commits

...

92 Commits

Author SHA1 Message Date
Dmitriy Kovalenko a4f87bd4f2 fix: Do not rely on the global cpath (#199)
As appeared package.cpath is global and other plugins can pollute it so
we can not rely on this. Specifcially codesnap.nvim is breaking fff
2025-12-15 16:56:48 -08:00
Dmitriy Kovalenko 7fd361a369 feat: Pagination (#194)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/83
2025-12-15 16:37:37 -08:00
Dmitriy Kovalenko c477f12487 chore: Remove not-required notifications (#198) 2025-12-15 14:08:01 -08:00
Frank Ebel 8fe26ad4bd docs: fix installation guide for vim.pack (#197) 2025-12-15 10:13:43 -08:00
Dmitriy Kovalenko 003c05cf6d feat: Multi-select and sending to quickfix (#193) 2025-12-14 17:53:05 -08:00
Dmitriy Kovalenko 0312492570 chore: Add all highlights groups to config and improve icons highlight resolution (#190) 2025-12-14 17:15:30 -08:00
Dmitriy Kovalenko e3f788f87b feat: Resume last query + last same query match boost (#187) 2025-12-12 16:31:13 -08:00
Dmitriy Kovalenko 53acaf90ab fix: Make directory change file walking non-blocking (#189) 2025-12-10 16:42:53 -08:00
Dmitriy Kovalenko 2c55114048 fix: Outdated/wrong git status (#188)
Resolved internal error cause by our FS notifier backend which caused
outdated git status in the picker. This should eliminate the process
completely also improved the logging of errors and improved the
perfromance and logic around the handling background events
2025-12-08 18:51:41 -08:00
Dmitriy Kovalenko d88922e6c7 fix: Correctly count file access on reopen (#185) 2025-12-06 21:40:56 -08:00
Dmitriy Kovalenko 9edf195c8f feat: Significant performance improvements up to +190% (#182) 2025-12-05 23:44:14 -08:00
Amadeus Demarzi 65aeacf9e2 feat: Make input and picker borders connected (#177)
* Make input/picker feel connected

* comment content tweak

* fix stylua
2025-11-03 10:48:21 -08:00
Dmitriy Kovalenko e8850c3c62 fix: Crash on ts filetype if plenary is not installed (#176)
(dumb regression from the previous PR)
2025-10-19 18:32:42 -07:00
Dmitriy Kovalenko ee8bd6e839 fix: Autofiletype detection for certain types (#175)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/174

I decided to call plenary as a lot of users have it installed anyway but
if it is not installed just AI generated list of the most common file
type flaws including ts
2025-10-17 17:28:21 -07:00
ohbentos 3c76ba523f feat: add ratio-based row/col positioning with centered defaults (#171) 2025-10-17 17:03:07 -07:00
Dmitriy Kovalenko 51f32597de feat: Location input for picker + open_file_under_cursor (#173) 2025-10-17 09:59:33 -07:00
莊加旭 (Max Chuang) 2df06289c9 fix: fix cpath component on windows (#166)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/164
2025-10-07 15:56:35 -07:00
Dmitriy Kovalenko 550a9053f9 fix: Remove global debugging log
closes #162
2025-09-26 17:44:45 -07:00
Dmitriy Kovalenko 3fa36f0a75 feat: Improve scoring for matching casing
Now it completely ignores casing if the query doesn't contain at least
one upper case character and scores much better if you have the exact
matching case character with a haystack.
2025-09-26 17:43:38 -07:00
Dmitriy Kovalenko 61081a55ca chore: Do not autopush vimdocs to main (#158)
* chore: Do not autopush vimdocs to main

* chore: Do not autopush main
2025-09-17 03:34:09 -07:00
github-actions[bot] 3a803c40ed chore: autgenerate vimdoc 2025-09-16 17:12:36 +00:00
Dmitriy Kovalenko 4a3453d3de chore(docs): Improve vim.pack example 2025-09-16 19:12:16 +02:00
github-actions[bot] cac2ec7130 chore: autgenerate vimdoc 2025-09-16 16:53:11 +00:00
Dmitriy Kovalenko 5a898066d2 feat!: Prebuilt binaries (no more need in cargo build) (#156)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/15
2025-09-16 09:52:50 -07:00
github-actions[bot] 7cdc71d5a1 chore: autgenerate vimdoc
Release / Release (push) Has been cancelled
Release / Build aarch64-apple-darwin (push) Has been cancelled
Release / Build aarch64-linux-android (push) Has been cancelled
Release / Build aarch64-unknown-linux-gnu (push) Has been cancelled
Release / Build aarch64-unknown-linux-musl (push) Has been cancelled
Release / Build x86_64-apple-darwin (push) Has been cancelled
Release / Build x86_64-pc-windows-msvc (push) Has been cancelled
Release / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Release / Build x86_64-unknown-linux-musl (push) Has been cancelled
2025-09-09 10:25:03 +00:00
Dmitriy Kovalenko 54f96dade0 fix: Replace window instead of split if no file buffers available (#150)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/145
2025-09-09 03:24:44 -07:00
github-actions[bot] 56ea1404f1 chore: autgenerate vimdoc 2025-09-03 16:00:43 +00:00
akotro 43100e3150 fix(nix): update rust-overlay for rust Nightly 2025-09-01 support (#146) 2025-09-03 18:00:27 +02:00
github-actions[bot] ac7cd106b3 chore: autgenerate vimdoc 2025-09-02 17:22:52 +00:00
Dmitriy Kovalenko d27a9bc8fd feat: Smart case bonuses (#144) 2025-09-02 10:22:34 -07:00
Dmitriy Kovalenko 8905981871 feat: Automatically open files in file buffers (#142) 2025-09-01 04:15:36 -07:00
github-actions[bot] 381992457b chore: autgenerate vimdoc 2025-09-01 10:49:54 +00:00
Dmitriy Kovalenko a9b2f5bba2 Close picker when leaving window (#141) 2025-09-01 03:49:40 -07:00
github-actions[bot] 61c3cc7420 chore: autgenerate vimdoc 2025-08-31 07:54:08 +00:00
xue 829e05b353 fix: some hl config not working (#137) 2025-08-31 09:53:47 +02:00
Will Lillis a9cac80ea9 fix(lua): only set preview buffer name if it is enabled (#133) 2025-08-26 00:05:49 +02:00
Dmitriy Kovalenko 7b1fab33be chore: Reduce logging (#132) 2025-08-25 10:43:30 -07:00
github-actions[bot] 25dfbd2ffd chore: autgenerate vimdoc 2025-08-25 14:38:24 +00:00
Dmitriy Kovalenko c26a2bb375 feat: Current file and distance penalties (#131) 2025-08-25 07:38:02 -07:00
github-actions[bot] 84a8ebc230 chore: autgenerate vimdoc 2025-08-24 16:34:08 +00:00
Dmitriy Kovalenko a42ba4907d fix: detect all filetypes (#129)
* feat: lazy-load indexing

* refactor: split configuration and initialization

* fix(ui): nil error when opening picker

* feat: smart lazy-loading and no forced setup

* docs(readme): update

* refactor: fff.config -> fff.conf

* style(lua): run stylua

* chore: fix lazy_sync in plugin script

* docs(readme): use `vim.keymap.set` in example

* fix couple issues

* chore: fix typos

---------

Co-authored-by: Marc Jakobi <marc.jakobi@tiko.energy>
2025-08-24 09:33:50 -07:00
github-actions[bot] 601b150eee chore: autgenerate vimdoc 2025-08-24 16:21:02 +00:00
Marc Jakobi 13b855b172 feat: smart lazy-loading + no forced setup (#115)
* feat: lazy-load indexing

* refactor: split configuration and initialization

* fix(ui): nil error when opening picker

* feat: smart lazy-loading and no forced setup

* docs(readme): update

* refactor: fff.config -> fff.conf

* style(lua): run stylua

* chore: fix lazy_sync in plugin script

* docs(readme): use `vim.keymap.set` in example

* fix couple issues

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2025-08-24 09:20:47 -07:00
github-actions[bot] 13e1383ffd chore: autgenerate vimdoc 2025-08-23 18:37:27 +00:00
oskarrrrrrr fd38df7bf0 fix: disable syntax highlighting for input buffer (#127) 2025-08-23 11:37:06 -07:00
github-actions[bot] e6e58f91d2 chore: autgenerate vimdoc 2025-08-18 05:54:01 +00:00
phanium c3271624be fix: don't set global keymap on preview when disabled(#111) 2025-08-18 07:53:46 +02:00
github-actions[bot] ad12110ded chore: autgenerate vimdoc 2025-08-17 15:20:55 +00:00
Oskar Grunning 7c8a941001 feat: flexible layout customization with dynamic positioning (#69)
* feat: implement prompt_position layout configuration

Allows users to position the input prompt at 'top' or 'bottom' of the picker window.
When prompt is at top, best matches appear at the top (closest to prompt).
When prompt is at bottom (default), best matches appear at the bottom.

- Add prompt_position config option with validation
- Adapt list rendering order based on prompt position
- Fix cursor navigation to work correctly with both layouts
- Optimize array reversal for better performance
- Extract helper function to reduce code duplication

* feat: restructure configuration with layout object

Move width/height into layout namespace for better organization.
Adds max_threads field and reorganizes config hierarchy to be
more intuitive and extensible.

BREAKING CHANGE: config.width/height moved to config.layout.width/height

* feat: implement flexible configuration deprecation system

Replace hardcoded deprecation handling with rule-based system
for easier maintenance. Each deprecation is now defined as a
simple rule with old_path, new_path, and message.

Enables easy addition of future config deprecations without
touching core migration logic.

* refactor: extract config resolution utilities to improve maintainability

Replace repetitive inline pcall functions with centralized utilities in fff.utils.
Eliminates code duplication and improves error handling consistency across
dynamic configuration resolution.

* feat: add flexible configuration deprecation system

Implement structured deprecation rules for configuration migration,
enabling smooth transition from legacy config options while maintaining
backward compatibility and clear user warnings.

* docs: update documentation for comprehensive layout positioning

Document all supported preview positions (left, right, top, bottom) and
preview_size parameter, removing outdated limitation claims and ensuring
configuration examples match current capabilities.

* docs: add comprehensive documentation for all configuration options

Document complete configuration API including dynamic functions, all preview
settings, logging, frecency, UI options, and automatic migration system.
Ensures users understand both static and responsive configuration patterns.

* style: align comments in documentation for improved readability

Standardize comment alignment across configuration examples in README.md
and doc/fff.nvim.txt to create consistent visual structure and make
configuration options easier to scan and understand.

* style: comprehensively align all comments in documentation

Standardize comment alignment across all code blocks in both README.md
and doc/fff.nvim.txt including configuration examples, method calls,
and keymap examples for consistent visual structure and readability.

* style: align comment formatting in documentation

Standardize comment alignment for better readability and consistency
across README and generated vimdoc files.

* chore: run stylua

* fix(ui): standardize layout coordinate calculations across preview positions

Resolves inconsistent window positioning that caused preview windows to be
misaligned or hidden in certain layout combinations (e.g., prompt_position='top'
with preview_position='bottom'). Standardizes the coordinate calculation logic
to ensure proper alignment and separation between windows for all four preview
positions while maintaining the original +3 border/separator spacing.

* feat: move prompt position handling to Rust layer

* chore: run cargo fmt

* fix: correct item selection when prompt position is bottom

Fixes cursor positioning calculation to properly highlight the selected
item when using bottom prompt layout. The cursor line calculation now
accounts for the inverted display order in bottom mode.

* WIP: fix sorting

* feat: Fix ordering logic

* fix configuration

* stylua

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2025-08-17 08:20:39 -07:00
Dmitriy Kovalenko 4e1a94c9e5 fix!: Frecency conflicts (sorry, this will delete all your history) (#104) 2025-08-13 15:27:57 -07:00
github-actions[bot] 16b69da3b7 chore: autgenerate vimdoc 2025-08-13 21:11:25 +00:00
Jan Lafferton 06066d138d feat: Add neovim package + nix ci workflow (#101)
* Add neovim plugin package

* Add ci checks for nix
2025-08-13 14:11:09 -07:00
github-actions[bot] b0edf2a0d1 chore: autgenerate vimdoc 2025-08-12 20:26:10 +00:00
Dmitriy Kovalenko 80263ad595 fix: Files count indicator values (#100)
Fixes the issue with always displaying elements requested by lua (100)
in the file count, fixes the issue of not showing the indicator on the
second open. Unified the way it shows the value on first and substantial
opens.
2025-08-12 13:25:51 -07:00
github-actions[bot] 3396c03f4a chore: autgenerate vimdoc 2025-08-11 21:51:18 +00:00
Dmitriy Kovalenko bda834e34d feat: Improve preview memory usage and performance (#91)
* chore: Add better logging for missing rust binary

* feat: Improve preview memory usage and performance

This significantly decreases amount of memory neovim allocates for
rendering previews with fff and makes them generally much faster to render .

* feat: Async prewview IO

* fix: Highlight group for git signs

* feat: Display image info along with error messages
2025-08-11 14:51:02 -07:00
Dmitriy Kovalenko afedc064bd chore: Add better logging for missing rust binary (#90) 2025-08-10 15:04:31 -07:00
Dmitriy Kovalenko edfc02c024 fix: Error on git refresh (#89)
closes https://github.com/dmtrKovalenko/fff.nvim/issues/88
2025-08-10 14:54:15 -07:00
sibouras d25abb8a73 fix: show relative path in :buffers (#73)
Co-authored-by: sibouras <sibouras@users.noreply.github.com>
2025-08-10 13:20:29 -07:00
Dmitriy Kovalenko e1c3db33ac feat: Cap filename bonus (#86) 2025-08-10 13:17:28 -07:00
github-actions[bot] 8ce96885b4 chore: autgenerate vimdoc 2025-08-10 16:16:27 +00:00
Oskar Grunning 38c533bcce docs: fix typos and expand configuration documentation (#85)
* docs: fix typos and expand configuration documentation

- Fix typos (adavantage → advantage, dedicaged → dedicated, compatibiility → compatibility)
- Add comprehensive configuration documentation with all available options
- Add commands section documenting all FFF commands
- Add troubleshooting section with health check and common issues

* review: apply suggestions
2025-08-10 18:16:08 +02:00
Dmitriy Kovalenko b2d3906577 feat: Rework picker allocations and state management (#81) 2025-08-10 05:49:55 -07:00
Oskar Grunning a614094de9 refactor: simplify picker UI navigation and consolidate keymap setup (#74)
* refactor(ui): simplify picker navigation and remove complex focus guards

* refactor(picker_ui): eliminate repetitive keymap setup code

Replace 26 repetitive for-loops with consolidated set_keymap utility function.
Inline normalize_keys logic to reduce function overhead and improve code locality.
2025-08-07 09:34:55 -07:00
Will Lillis 2ff3311f99 fix(lib): use correct path separator for distance penalty calculation (#68) 2025-08-06 19:26:50 +02:00
Oskar Grunning 547a654ccc fix: eliminate visual flashes and unnecessary rerenders in file picker (#62)
* fix: eliminate visual flashes and unnecessary rerenders in file picker

- Wait for complete scan before showing UI to prevent empty loading states
- Remove double-render logic that caused visual flicker
- Cache icon provider failures to eliminate spam warnings
- Implement atomic rendering pattern like modern editors (VSCode, etc.)

This reduces picker opening from 3+ renders to 1 render, eliminating
the jarring empty→files→files+git visual progression that made the
picker feel unpolished.

* fix: restore real-time frecency score updates for immediate file ranking

Frecency scores were not refreshing after file access events, requiring
Neovim restart to see updated file rankings. Added refresh calls after
all access tracking points to keep in-memory cache synchronized with
database updates.

* chore: run formatting

* feat: update file rankings immediately when files are accessed

* perf: consolidate frecency updates and remove redundant git refreshes

Consolidate frecency tracking into access_file function to reduce function
call overhead, remove unnecessary git status refreshes that don't reflect
actual changes, and inline wait logic to eliminate indirection.

Addresses performance feedback from PR review.

* perf: remove redundant git status refresh and unused export

* fix: use public API instead of private field access in wait_for_initial_scan

* refactor: remove unused code to resolve clippy warnings

* feat: Improve monitoring for scanning UI

---------

Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2025-08-06 02:46:24 -07:00
github-actions[bot] 3fd35a40fa chore: autgenerate vimdoc 2025-08-05 06:54:41 +00:00
Will Lillis 7b046a7f44 fix(render): account for spaces in filenames and directories (#63) 2025-08-05 08:54:24 +02:00
luz 056e6c9fdf Fix: vim.notify() Expects string (#60)
* fix: vim.notify accepts string array

* Update lua/fff/file_picker/preview.lua

---------

Co-authored-by: Dmitriy Kovalenko <dmitriy@iusevimbtw.com>
2025-08-04 10:08:01 -07:00
Dinh Mai 8c14d30f5c fix: UI layout when preview is disabled
* chore: autgenerate vimdoc

* chore: should merge main config with opts when calling open with params

* fix: issue#32: hide debug preview panel base on enable_preview opt

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-08-04 07:54:26 -07:00
Jan Lafferton eb619749ab Fix build (add perl dependency) 2025-08-04 16:50:20 +02:00
Dmitriy Kovalenko 9e51132156 fix: Nested background threads leaking 2025-08-04 16:49:53 +02:00
Dmitriy Kovalenko 7efaceb7ec fix: Filename bonus for typoed directory matches
Currently we add a filename bonus for any path match that is also
matching the filename, but this makes no sense because when we get the
typo related match on the filename it can take a huge precendense over
the actual file match

fixes https://github.com/dmtrKovalenko/fff.nvim/issues/46
2025-08-04 14:32:10 +02:00
Dmitriy Kovalenko 0e75b1c1db feat: Reuse existing open buffers for preview 2025-08-04 13:19:57 +02:00
Dmitriy Kovalenko 4a1297dbb8 feat: Display image detailed info for preview + improve positioning 2025-08-04 13:19:57 +02:00
Raphaël Chartier b5807d5efd feat(image): add terminal support check via snacks 2025-08-04 13:19:57 +02:00
莊加旭 (Max Chuang) 6f380b2982 feat: manage openssl with cargo (#44)
list openssl as a cargo dependency to avoid relying on system library

Co-authored-by: Max Chuang <maxcchuang@google.com>
2025-08-04 00:35:19 -07:00
Vadym Biliuk 69a1587c10 fix: Support macOS .dylib extension in release script (#40)
* fix: Support macOS .dylib extension in release script

* fix: Set correct release libfff_nvim extension

* fix: Remove redundant expression
2025-08-04 00:33:09 -07:00
Oskar Grunning f9ee0a20d8 security: fix shell injection vulnerabilities for query inputs (#41)
Replace unsafe string.format() calls with vim.fn.shellescape() to prevent
command injection when file paths contain special characters.
2025-08-04 00:32:03 -07:00
Agung Baptiso Sorlawan 4b31ac8311 fix: fail to load if CARGO_TARGET_DIR is set (#39) 2025-08-04 00:31:24 -07:00
github-actions[bot] 7fffe4f670 chore: autgenerate vimdoc 2025-08-04 07:30:02 +00:00
Will Lillis be94f5aea2 fix: use correct path separators to check for .git folder on windows (#42)
* style: reduce indentation in `scan_filesystem`

* perf: reserve `entries` capacity in `read_git_status`

Since `StatusEntry::path` only returns `None` if the path is not
valid utf-8, this should be a sufficiently rare case to just
reserve the space in advance.

* fix: use correct path separators to check for .git folder on windows
2025-08-04 00:29:43 -07:00
github-actions[bot] cdd38155fc chore: autgenerate vimdoc 2025-08-03 12:43:10 +00:00
Frayzen 3ee2cb116f chore(docs): add available commands in readme (#31)
Co-authored-by: Dmitriy Kovalenko <dmtr.kovalenko@outlook.com>
2025-08-03 14:42:46 +02:00
github-actions[bot] f4a5692c43 chore: autgenerate vimdoc 2025-08-03 05:04:26 +00:00
Dmitriy Kovalenko fb280b0be6 fix(docs): lazy instructions 2025-08-03 07:04:07 +02:00
Dmitriy Kovalenko d3e34a7802 feat: Correctly handle change of cwd automatically and via API
closes https://github.com/dmtrKovalenko/fff.nvim/issues/20

This allows to use

```
require('fff.main').find_files_in_dir('/Users/neogoose/dev/lightsource')
```

to change cwd and start the picker immediately to choose the file.
2025-08-03 00:21:12 +02:00
github-actions[bot] 5607e61ef7 chore: autgenerate vimdoc 2025-08-02 21:13:59 +00:00
Dmitriy Kovalenko 76bd9a1509 chore(docs): Remove duplicated section
Co-authored-by: Amalia Haible <florian@haible.de>
2025-08-02 23:13:44 +02:00
Dmitriy Kovalenko aa8d56ea91 fix: Remove default keymaps
Co-authored-by: Amalia Haible <florian@haible.de>
2025-08-02 23:13:44 +02:00
github-actions[bot] a75fe334db chore: autgenerate vimdoc 2025-08-02 20:52:05 +00:00
fvrings 3aafc930ba feat: support nixos (#5)
* feat: support nixos

* chore: add readme
2025-08-02 13:51:47 -07:00
56 changed files with 9860 additions and 2915 deletions
+31
View File
@@ -0,0 +1,31 @@
name: Nix CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-22.04
permissions:
id-token: "write"
contents: "read"
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main
- uses: DeterminateSystems/flake-checker-action@main
- name: Run `nix flake check`
run: nix flake check
- name: Run `nix build`
run: nix build
- name: Run `nix build .#fff-nvim`
run: nix build .#fff-nvim
- name: Run `nix run .#release`
run: nix run .#release
+16 -8
View File
@@ -1,6 +1,6 @@
on:
push:
branches:
branches-ignore:
- main
name: docs
@@ -11,17 +11,25 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
with:
# fetch last 2 commits required for auto force push back
fetch-depth: 2
- name: panvimdoc
uses: kdheepak/panvimdoc@main
with:
vimdoc: fff.nvim
version: "Neovim >= 0.8.0"
version: "Neovim >= 0.10.0"
demojify: true
treesitter: true
- name: Push changes
uses: stefanzweifel/git-auto-commit-action@v6
- name: Get last commit message
id: last-commit
run: |
echo "message=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
echo "author=$(git log -1 --pretty=\"%an <%ae>\")" >> $GITHUB_OUTPUT
- uses: stefanzweifel/git-auto-commit-action@v6
with:
commit_message: "chore: autgenerate vimdoc"
commit_user_name: "github-actions[bot]"
commit_user_email: "github-actions[bot]@users.noreply.github.com"
commit_author: "github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
commit_author: ${{ steps.last-commit.outputs.author }}
commit_message: chore: Update docs for - ${{ steps.last-commit.outputs.message }}
+41 -24
View File
@@ -1,9 +1,8 @@
name: Release
name: Prebuild
on:
push:
tags:
- "v*"
branches: [main, feat/prebuild]
jobs:
build:
@@ -18,44 +17,47 @@ jobs:
# Glibc 2.21
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_fuzzy.so
artifact_name: target/x86_64-unknown-linux-gnu/release/libfff_nvim.so
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_fuzzy.so
artifact_name: target/aarch64-unknown-linux-gnu/release/libfff_nvim.so
# Musl 1.2.3
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_fuzzy.so
artifact_name: target/x86_64-unknown-linux-musl/release/libfff_nvim.so
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_fuzzy.so
# Android (Termux)
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: target/aarch64-linux-android/release/libfff_fuzzy.so
artifact_name: target/aarch64-unknown-linux-musl/release/libfff_nvim.so
# # Android (Termux)
# - os: ubuntu-latest
# target: aarch64-linux-android
# artifact_name: target/aarch64-linux-android/release/libfff_nvim.so
## macOS builds
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: target/x86_64-apple-darwin/release/libfff_fuzzy.dylib
artifact_name: target/x86_64-apple-darwin/release/libfff_nvim.dylib
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: target/aarch64-apple-darwin/release/libfff_fuzzy.dylib
artifact_name: target/aarch64-apple-darwin/release/libfff_nvim.dylib
## Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: target/x86_64-pc-windows-msvc/release/fff_fuzzy.dll
artifact_name: target/x86_64-pc-windows-msvc/release/fff_nvim.dll
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: target/aarch64-pc-windows-msvc/release/fff_nvim.dll
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set Rust toolchain
if: contains(matrix.target, 'linux')
# https://github.com/rust-cross/cargo-zigbuild/issues/327
run: echo -e '[toolchain]\nchannel = "nightly-2025-02-19"' > rust-toolchain.toml
# - name: Set Rust toolchain
# if: contains(matrix.target, 'linux')
# # https://github.com/rust-cross/cargo-zigbuild/issues/327
# run: echo -e '[toolchain]\nchannel = "nightly-2025-02-19"' > rust-toolchain.toml
- name: Install Rust
run: |
@@ -88,7 +90,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: ${{ matrix.target }}.*
path: ${{ matrix.target }}*
release:
name: Release
@@ -97,22 +99,37 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: ./binaries
- name: Generate checksums
working-directory: ./binaries
run: |
ls -a
for file in ./**/*; do
sha256sum "$file" > "${file}.sha256"
done
- name: Prepare tag
id: vars
shell: bash
run: |
sha="$(git rev-parse --short HEAD)"
echo "tag=$sha" >> $GITHUB_OUTPUT
- name: Upload Release Assets
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
name: "${{ steps.vars.outputs.tag }}"
tag_name: "${{ steps.vars.outputs.tag }}"
token: ${{ github.token }}
files: ./**/*
files: ./binaries/**/*
draft: false
prerelease: false
generate_release_notes: true
prerelease: true
generate_release_notes: false
body: |
Nightly release from commit: ${{ github.sha }}
+3
View File
@@ -10,3 +10,6 @@ result
.repro/
.wrangler/
*.so
big-repo/
# all the perf like utility files
*.data
+4
View File
@@ -0,0 +1,4 @@
empty_config.lua
benches/
doc/
-1
View File
@@ -1,4 +1,3 @@
syntax = "LuaJIT"
column_width = 120
line_endings = "Unix"
indent_type = "Spaces"
Generated
+578 -15
View File
@@ -2,6 +2,19 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.3",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.3"
@@ -26,6 +39,18 @@ dependencies = [
"libc",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -103,6 +128,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.27"
@@ -120,6 +151,12 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.41"
@@ -135,6 +172,58 @@ dependencies = [
"windows-link",
]
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
@@ -147,6 +236,42 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -190,6 +315,22 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "ctrlc"
version = "3.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73"
dependencies = [
"nix",
"windows-sys 0.59.0",
]
[[package]]
name = "deranged"
version = "0.4.0"
@@ -199,6 +340,27 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -225,21 +387,49 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "errno"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fff_nvim"
version = "0.1.0"
dependencies = [
"ahash",
"blake3",
"chrono",
"criterion",
"ctrlc",
"dirs",
"git2",
"glidesort",
"heed",
"ignore",
"mimalloc",
"mlua",
"neo_frizbee",
"notify",
"notify-debouncer-full",
"once_cell",
"openssl",
"pathdiff",
"rand",
"rayon",
"serde",
"smartstring",
"tempfile",
"thiserror 2.0.12",
"tracing",
"tracing-appender",
@@ -248,13 +438,28 @@ dependencies = [
[[package]]
name = "file-id"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bc904b9bbefcadbd8e3a9fb0d464a9b979de6324c03b3c663e8994f46a5be36"
checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -273,6 +478,17 @@ dependencies = [
"libc",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.3.3"
@@ -300,6 +516,12 @@ dependencies = [
"url",
]
[[package]]
name = "glidesort"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0"
[[package]]
name = "globset"
version = "0.4.16"
@@ -313,6 +535,17 @@ dependencies = [
"regex-syntax 0.8.5",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "heed"
version = "0.22.0"
@@ -351,6 +584,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "iana-time-zone"
version = "0.1.63"
@@ -518,6 +757,26 @@ dependencies = [
"libc",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.60.2",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.15"
@@ -530,7 +789,7 @@ version = "0.1.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a"
dependencies = [
"getrandom",
"getrandom 0.3.3",
"libc",
]
@@ -590,6 +849,26 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "libmimalloc-sys"
version = "0.1.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88cd67e9de251c1781dbe2f641a1a3ad66eaae831b8a2c38fbdc5ddae16d4d"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "libredox"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
dependencies = [
"bitflags 2.9.1",
"libc",
]
[[package]]
name = "libssh2-sys"
version = "0.3.1"
@@ -616,6 +895,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "litemap"
version = "0.8.0"
@@ -664,6 +949,15 @@ version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "mimalloc"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1791cbe101e95af5764f06f20f6760521f7158f69dbf9d6baf941ee1bf6bc40"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "mio"
version = "1.0.4"
@@ -738,20 +1032,31 @@ dependencies = [
[[package]]
name = "neo_frizbee"
version = "0.6.0"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dedc43c9781dfa8c9584d3e50d68ce956b928b01ae0ad1da2014be5a322c20f8"
checksum = "2b4421f748f561dd0bb677e46f2e6c1ed2c52ba7130349229a73445af5010d17"
dependencies = [
"memchr",
"multiversion",
"rayon",
]
[[package]]
name = "notify"
version = "8.1.0"
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3163f59cd3fa0e9ef8c32f242966a7b9994fd7378366099593e0e73077cd8c97"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.9.1",
"fsevent-sys",
@@ -767,9 +1072,9 @@ dependencies = [
[[package]]
name = "notify-debouncer-full"
version = "0.5.0"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2d88b1a7538054351c8258338df7c931a590513fb3745e8c15eb9ff4199b8d1"
checksum = "375bd3a138be7bfeff3480e4a623df4cbfb55b79df617c055cd810ba466fa078"
dependencies = [
"file-id",
"log",
@@ -815,12 +1120,53 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"foreign-types",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-src"
version = "300.5.1+3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.109"
@@ -829,10 +1175,17 @@ checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "overload"
version = "0.1.1"
@@ -938,6 +1291,34 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -953,6 +1334,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
@@ -983,6 +1373,18 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
@@ -991,6 +1393,9 @@ name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rayon"
@@ -1021,6 +1426,17 @@ dependencies = [
"bitflags 2.9.1",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.16",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "regex"
version = "1.11.1"
@@ -1071,6 +1487,19 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags 2.9.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "rustversion"
version = "1.0.21"
@@ -1157,12 +1586,30 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smartstring"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
dependencies = [
"autocfg",
"serde",
"static_assertions",
"version_check",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "syn"
version = "2.0.104"
@@ -1200,6 +1647,19 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5"
[[package]]
name = "tempfile"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -1290,6 +1750,16 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tracing"
version = "0.1.41"
@@ -1398,6 +1868,12 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -1481,6 +1957,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -1573,11 +2059,11 @@ dependencies = [
[[package]]
name = "windows-sys"
version = "0.52.0"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.52.6",
"windows-targets 0.48.5",
]
[[package]]
@@ -1598,6 +2084,21 @@ dependencies = [
"windows-targets 0.53.3",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -1631,6 +2132,12 @@ dependencies = [
"windows_x86_64_msvc 0.53.0",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -1643,6 +2150,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -1655,6 +2168,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -1679,6 +2198,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -1691,6 +2216,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -1703,6 +2234,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -1715,6 +2252,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -1766,6 +2309,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.6"
+36 -3
View File
@@ -1,7 +1,7 @@
[package]
name = "fff_nvim"
version = "0.1.0"
edition = "2021"
edition = "2024"
[lib]
path = "lua/fff/rust/lib.rs"
@@ -11,20 +11,53 @@ crate-type = ["cdylib", "rlib"]
name = "test_watcher"
path = "src/bin/test_watcher.rs"
[[bin]]
name = "jemalloc_profile"
path = "src/bin/jemalloc_profile.rs"
[[bin]]
name = "search_profiler"
path = "src/bin/search_profiler.rs"
[[bin]]
name = "bench_search_only"
path = "src/bin/bench_search_only.rs"
[[bench]]
name = "query_tracker_bench"
harness = false
[dependencies]
ahash = "0.8"
blake3 = "1.8.2"
chrono = { version = "0.4", features = ["serde"] }
ctrlc = "3.4.2"
dirs = "5.0"
git2 = "0.20.2"
glidesort = "0.1"
heed = "0.22.0"
ignore = "0.4.22"
mimalloc = "0.1.47"
mlua = { version = "0.11.1", features = ["module", "luajit"] }
neo_frizbee = { version = "0.6.0" }
neo_frizbee = { version = "0.7.1" }
notify = "8.1.0"
notify-debouncer-full = "0.5"
notify-debouncer-full = "0.6"
once_cell = "1.20.2"
openssl = { version = "0.10", features = ["vendored"] }
pathdiff = "0.2.1"
rayon = "1.8.0"
serde = { version = "1.0", features = ["derive"] }
smartstring = { version = "1.0.1", features = ["serde"] }
thiserror = "2.0.10"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
rand = { version = "0.8", features = ["small_rng"] }
tempfile = "3.8"
[[bench]]
name = "indexing_and_search"
harness = false
+240 -123
View File
@@ -15,7 +15,7 @@
<img alt="Contributors" src="https://img.shields.io/github/contributors/dmtrKovalenko/fff.nvim?color=%23DDB6F2&label=CONTRIBUTORS&logo=git&style=for-the-badge&logoColor=D9E0EE&labelColor=302D41"/></a>
</p>
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an opinionated fuzzy file picker for neovim. Just for files, but we'll try to solve file picking completely.
It comes with a dedicated rust backend runtime that keep tracks of the file index, your file access and modifications, git status, and provides a comprehensive typo-resistant fuzzy search experience.
@@ -23,15 +23,16 @@ It comes with a dedicated rust backend runtime that keep tracks of the file inde
- Works out of the box with no additional configuration
- [Typo resistant fuzzy search](https://github.com/saghen/frizbee)
- Git status integration allowing to take adavantage of last modified times within a worktree
- Separate file index maintained by a dedicaged backend allows <10 milliseconds search time for 50k files codebase
- Git status integration allowing to take advantage of last modified times within a worktree
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
- Display images in previews (for now requires snacks.nvim)
- Smart in a plenty of different ways hopefully helpful for your workflow
- This plugin initializes itself lazily by default
## Installation
> [!NOTE]
> Although we'll try to make sure to keep 100% backward compatibiility, by using you should understand that silly bugs and breaking changes may happen.
> [!NOTE]
> Although we'll try to make sure to keep 100% backward compatibility, by using you should understand that silly bugs and breaking changes may happen.
> And also we hope for your contributions and feedback to make this plugin ideal for everyone.
### Prerequisites
@@ -39,99 +40,210 @@ It comes with a dedicated rust backend runtime that keep tracks of the file inde
FFF.nvim requires:
- Neovim 0.10.0+
- Rust toolchain (requires nightly for building the native backend)
- [Rustup](https://rustup.rs/) (we require nightly for building the native backend rustup will handle toolchain automatically)
### Package Managers
### Installation
#### lazy.nvim
```lua
{
"dmtrKovalenko/fff.nvim",
build = "cargo build --release",
opts = {
-- pass here all the options
},
keys = {
{
"<leader>ff", -- try it if you didn't it is a banger keybinding for a picker
function()
require("fff").toggle()
end,
desc = "Toggle FFF",
'dmtrKovalenko/fff.nvim',
build = function()
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
require("fff.download").download_or_build_binary()
end,
-- if you are using nixos
-- build = "nix run .#release",
opts = { -- (optional)
debug = {
enabled = true, -- we expect your collaboration at least during the beta
show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
},
},
-- No need to lazy-load with lazy.nvim.
-- This plugin initializes itself lazily.
lazy = false,
keys = {
{
"ff", -- try it if you didn't it is a banger keybinding for a picker
function() require('fff').find_files() end,
desc = 'FFFind files',
}
}
}
```
### Default Configuration
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
#### vim.pack
```lua
require("fff").setup({
-- UI dimensions and appearance
width = 0.8, -- Window width as fraction of screen
height = 0.8, -- Window height as fraction of screen
prompt = '🪿 ', -- Input prompt symbol
preview = {
enabled = true,
width = 0.5,
max_lines = 100,
max_size = 1024 * 1024, -- 1MB
},
title = 'FFF Files', -- Window title
max_results = 60, -- Maximum search results to display
max_threads = 4, -- Maximum threads for fuzzy search
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
-- Key mappings (supports both single keys and arrays for multiple bindings)
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
},
vim.api.nvim_create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
require('fff.download').download_or_build_binary()
end
end,
})
-- Highlight groups
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
},
-- Debug options
-- the plugin will automatically lazy load
vim.g.fff = {
lazy_sync = true, -- start syncing only when the picker is open
debug = {
show_scores = false, -- Toggle with F2 or :FFFDebug
enabled = true,
show_scores = true,
},
}
vim.keymap.set(
'n',
'ff',
function() require('fff').find_files() end,
{ desc = 'FFFind files' }
)
```
### Configuration
FFF.nvim comes with sensible defaults. Here's the complete configuration with all available options:
```lua
require('fff').setup({
base_path = vim.fn.getcwd(),
prompt = '🪿 ',
title = 'FFFiles',
max_results = 100,
max_threads = 4,
lazy_sync = true, -- set to false if you want file indexing to start on open
layout = {
height = 0.8,
width = 0.8,
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
show_scrollbar = true, -- Show scrollbar for pagination
},
preview = {
enabled = true,
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
wrap_lines = false,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
text = { wrap_lines = true },
},
},
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
-- you can assign multiple keys to any action
move_up = { '<Up>', '<C-p>' },
move_down = { '<Down>', '<C-n>' },
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
-- goes to the previous query in history
cycle_previous_query = '<C-Up>',
-- multi-select keymaps for quickfix
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
git_deleted = 'FFFGitDeleted',
git_renamed = 'FFFGitRenamed',
git_untracked = 'FFFGitUntracked',
git_ignored = 'FFFGitIgnored',
-- Git sign/border highlights
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
-- Git sign selected highlights
git_sign_staged_selected = 'FFFGitSignStagedSelected',
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
},
-- Store file open frecency
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
-- Store successfully opened queries with respective matches
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- file will get a boost if it was selected 3 in a row times per specific query
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
}
})
```
### Key Features
#### Multiple Key Bindings
You can assign multiple key combinations to the same action:
#### Available Methods
```lua
keymaps = {
move_up = { '<Up>', '<C-p>', '<C-k>' }, -- Three ways to move up
close = { '<Esc>', '<C-c>' }, -- Two ways to close
select = '<CR>', -- Single binding still works
}
require('fff').find_files() -- Find files in current directory
require('fff').find_in_git_root() -- Find files in the current git repository
require('fff').scan_files() -- Trigger rescan of files in the current directory
require('fff').refresh_git_status() -- Refresh git status for the active file lock
require('fff').find_files_in_dir(path) -- Find files in a specific directory
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
```
#### Commands
FFF.nvim provides several commands for interacting with the file picker:
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
- `:FFFScan` - Manually trigger a rescan of files in the current directory
- `:FFFRefreshGit` - Manually refresh git status for all files
- `:FFFClearCache [all|frecency|files]` - Clear various caches
- `:FFFHealth` - Check FFF health status and dependencies
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
- `:FFFOpenLog` - Open the FFF log file in a new tab
#### Multiline Paste Support
The input field automatically handles multiline clipboard content by joining all lines into a single search query. This is particularly useful when copying file paths from terminal output.
@@ -144,58 +256,63 @@ Toggle scoring information display:
- Use `:FFFDebug` command
- Enable by default with `debug.show_scores = true`
````
#### Multi-Select and Quickfix Integration
#### vim-plug
Select multiple files and send them to Neovim's quickfix list (keymaps are configurable):
- `<Tab>` - Toggle selection for the current file (shows thick border `▊` in signcolumn)
- `<C-q>` - Send selected files to quickfix list and close picker
### Troubleshooting
#### Health Check
Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This will verify:
- File picker initialization status
- Optional dependencies (git, image preview tools)
- Database connectivity
#### Viewing Logs
If you encounter issues, check the log file:
```vim
Plug 'MunifTanjim/nui.nvim'
Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' }
````
## Configuration
### Default Configuration
FFF.nvim comes with sensible defaults. Here's the complete default configuration:
```lua
require("fff").setup({
-- UI dimensions and appearance
width = 0.8, -- Window width as fraction of screen
height = 0.8, -- Window height as fraction of screen
preview_width = 0.5, -- Preview pane width as fraction of picker
prompt = '🪿 ', -- Input prompt symbol
title = 'FFF Files', -- Window title
max_results = 60, -- Maximum search results to display
max_threads = 4, -- Maximum threads for fuzzy search
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
},
debug = {
show_scores = true, -- We hope for your collaboratio
},
})
:FFFOpenLog
```
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default location).
#### Common Issues
**File picker not initializing:**
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
- Check that your Neovim version is 0.10.0 or higher
**Image previews not working:**
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
- If using snacks.nvim, ensure it's properly configured
**Performance issues:**
- Adjust `max_threads` in configuration based on your system
- Reduce `preview.max_lines` and `preview.max_size` for large files
- Clear cache if it becomes too large: `:FFFClearCache all`
**Files not being indexed:**
- Run `:FFFScan` to manually trigger a file scan
- Check that the `base_path` is correctly set
- Verify you have read permissions for the directory
#### Debug Mode
Enable debug mode to see scoring information and troubleshoot search results:
- Press `F2` while in the picker
- Run `:FFFDebug on` to enable permanently
- Set `debug.show_scores = true` in configuration
+1 -1
View File
@@ -1,5 +1,5 @@
[files]
extend-exclude = ["/CHANGELOG.md"]
extend-exclude = ["/CHANGELOG.md", "data/filetypes/base.lua"]
[default.extend-words]
noice = "noice"
+667
View File
@@ -0,0 +1,667 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::{FilePicker, FuzzySearchOptions};
use fff_nvim::types::PaginationArgs;
use std::path::PathBuf;
use std::time::Duration;
/// Initialize tracing to output to console
fn init_tracing() {
// use tracing_subscriber::EnvFilter;
// use tracing_subscriber::fmt;
// let _ = fmt()
// .with_env_filter(
// EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
// )
// .with_target(false)
// .with_thread_ids(true)
// .with_line_number(true)
// .try_init();
}
/// Initialize FilePicker and insert into global state
fn init_file_picker_internal(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Helper function to wait for scanning to complete and get file count
fn wait_for_scan_completion(timeout_secs: u64) -> Result<usize, String> {
let start = std::time::Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut last_log = std::time::Instant::now();
let mut iteration = 0;
loop {
iteration += 1;
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let is_scanning = picker.is_scan_active();
let file_count = picker.get_files().len();
// Log progress every 2 seconds
if last_log.elapsed() >= Duration::from_secs(2) {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}, Iterations: {}",
start.elapsed().as_secs_f32(),
is_scanning,
file_count,
iteration
);
last_log = std::time::Instant::now();
}
if !is_scanning && file_count > 0 {
eprintln!(
" ✓ Scan complete after {:.2}s: {} files found",
start.elapsed().as_secs_f32(),
file_count
);
return Ok(file_count);
}
} else {
if iteration % 100 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None (iteration {})",
start.elapsed().as_secs_f32(),
iteration
);
}
}
}
if start.elapsed() > timeout {
return Err(format!(
"Scan timed out after {} seconds (iteration {})",
timeout_secs, iteration
));
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Get files from the global FILE_PICKER
fn get_files_snapshot() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().to_vec())
} else {
Err("FilePicker not initialized".to_string())
}
}
/// Clean up global state
fn cleanup_global_state() {
if let Ok(mut picker_guard) = FILE_PICKER.write() {
if let Some(mut picker) = picker_guard.take() {
picker.stop_background_monitor();
}
}
}
/// Initialize FilePicker once and return files snapshot
fn setup_once() -> Result<Vec<fff_nvim::types::FileItem>, String> {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
return Err("./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo".to_string());
}
let canonical_path = big_repo_path
.canonicalize()
.map_err(|e| format!("Failed to canonicalize path: {}", e))?;
eprintln!(" Path: {:?}", canonical_path);
{
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let files = picker.get_files();
if !files.is_empty() {
eprintln!(" Reusing existing index with {} files", files.len());
return Ok(files.to_vec());
}
}
}
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
init_file_picker_internal(&canonical_path.to_string_lossy())?;
eprintln!(" Waiting for background scan to complete...");
let file_count = wait_for_scan_completion(120)?;
eprintln!(
" ✓ Indexed {} files (will be reused for all benchmarks)\n",
file_count
);
get_files_snapshot()
}
/// Benchmark for indexing the big-repo directory
fn bench_indexing(c: &mut Criterion) {
init_tracing();
let big_repo_path = PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = match big_repo_path.canonicalize() {
Ok(p) => p,
Err(e) => {
eprintln!("⚠ Failed to canonicalize path: {}", e);
return;
}
};
let mut group = c.benchmark_group("indexing");
group.sample_size(10);
group.measurement_time(Duration::from_secs(20));
group.bench_function("index_big_repo", |b| {
b.iter(|| {
cleanup_global_state();
std::thread::sleep(Duration::from_millis(500));
let start = std::time::Instant::now();
init_file_picker_internal(black_box(&canonical_path.to_string_lossy()))
.expect("Failed to init FilePicker");
match wait_for_scan_completion(120) {
Ok(file_count) => {
let elapsed = start.elapsed();
eprintln!(" ✓ Indexed {} files in {:?}", file_count, elapsed);
file_count
}
Err(e) => {
eprintln!(" ✗ Error: {}", e);
0
}
}
});
});
group.finish();
}
/// Benchmark for searching with various query patterns
fn bench_search_queries(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprint!("Failed to setup picker {e:?}");
return;
}
};
let mut group = c.benchmark_group("search");
group.sample_size(100);
let test_queries = vec![
("short", "mod"),
("medium", "controller"),
("long", "user_authentication"),
("typo", "contrlr"),
("partial", "src/lib"),
];
for (name, query) in test_queries {
group.bench_with_input(BenchmarkId::new("query", name), &query, |b, &query| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search with different thread counts
fn bench_search_thread_scaling(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping thread scaling benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("thread_scaling");
group.sample_size(100);
let query = "controller";
let thread_counts = vec![1, 2, 4, 8];
for threads in thread_counts {
group.bench_with_input(
BenchmarkId::from_parameter(threads),
&threads,
|b, &threads| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
},
);
}
group.finish();
}
/// Benchmark search with different result limits
fn bench_search_result_limits(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping result limit benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("result_limits");
group.sample_size(100);
let query = "mod";
let result_limits = vec![10, 50, 100, 500];
for limit in result_limits {
group.bench_with_input(BenchmarkId::from_parameter(limit), &limit, |b, &limit| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: limit,
},
},
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search algorithm performance scaling with file count
fn bench_search_scalability(c: &mut Criterion) {
let all_files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping scalability benchmarks: {}", e);
return;
}
};
if all_files.len() < 1000 {
eprintln!(
"⚠ Skipping scalability benchmark: need at least 1000 files, got {}",
all_files.len()
);
return;
}
let mut group = c.benchmark_group("search_scalability");
group.sample_size(50);
let query = "controller";
let file_counts = vec![100, 1000, 5000, 10000, all_files.len().min(50000)];
for count in file_counts {
if count > all_files.len() {
continue;
}
let subset = &all_files[..count];
group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, _| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(subset),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
}
group.finish();
}
/// Benchmark search performance with different ordering modes
fn bench_search_ordering(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping ordering benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("ordering");
group.sample_size(100);
let query = "controller";
// Benchmark normal order (descending)
group.bench_function("normal_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
// Benchmark reverse order (ascending)
group.bench_function("reverse_order", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 100,
},
},
);
results.total_matched
});
});
// Benchmark with large result set
group.bench_function("normal_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 500,
},
},
);
results.total_matched
});
});
group.bench_function("reverse_order_large", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("mod"),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 500,
},
},
);
results.total_matched
});
});
// Benchmark with small result set
group.bench_function("normal_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 10,
},
},
);
results.total_matched
});
});
group.bench_function("reverse_order_small", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box("controller"),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 10,
},
},
);
results.total_matched
});
});
group.finish();
}
/// Benchmark pagination: first page vs deep page
fn bench_pagination_performance(c: &mut Criterion) {
let files = match setup_once() {
Ok(files) => files,
Err(e) => {
eprintln!("⚠ Skipping pagination benchmarks: {}", e);
return;
}
};
let mut group = c.benchmark_group("pagination");
group.sample_size(100);
let query = "mod";
let page_size = 40;
// Benchmark first page (uses partial sort optimization)
group.bench_function("page_0_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: page_size,
},
},
);
results.total_matched
});
});
// Benchmark 10th page (requires full sort, no optimization)
group.bench_function("page_10_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 10,
limit: page_size,
},
},
);
results.total_matched
});
});
// Benchmark 50th page (even deeper pagination)
group.bench_function("page_50_size_40", |b| {
b.iter(|| {
let results = FilePicker::fuzzy_search(
black_box(&files),
black_box(query),
FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 50,
limit: page_size,
},
},
);
results.total_matched
});
});
group.finish();
}
criterion_group!(
benches,
bench_indexing,
bench_search_queries,
bench_search_thread_scaling,
bench_search_result_limits,
bench_search_scalability,
bench_search_ordering,
bench_pagination_performance,
);
criterion_main!(benches);
+224
View File
@@ -0,0 +1,224 @@
use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main};
use fff_nvim::query_tracker::QueryTracker;
use rand::distributions::Alphanumeric;
use rand::prelude::*;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn generate_random_string(len: usize) -> String {
thread_rng()
.sample_iter(&Alphanumeric)
.take(len)
.map(char::from)
.collect()
}
// Test data structure for benchmarks
struct TestQueryEntry {
query: String,
project_path: PathBuf,
file_path: PathBuf,
open_count: u32,
last_opened: u64,
}
fn generate_test_data(num_entries: usize) -> Vec<TestQueryEntry> {
let mut rng = thread_rng();
let mut entries = Vec::with_capacity(num_entries);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
// Generate some common queries that will be reused
let common_queries = vec![
"main",
"test",
"config",
"utils",
"lib",
"mod",
"index",
"init",
"server",
"client",
"api",
"service",
"controller",
"model",
"view",
"component",
"handler",
"middleware",
"router",
"database",
"auth",
];
// Generate some common project paths
let project_paths = vec![
"/home/user/project1",
"/home/user/project2",
"/home/user/web-app",
"/home/user/cli-tool",
"/home/user/library",
];
for _ in 0..num_entries {
let query = if rng.gen_bool(0.7) {
// 70% chance to use common query
common_queries.choose(&mut rng).unwrap().to_string()
} else {
// 30% chance to use random query
generate_random_string(rng.gen_range(3..15))
};
let project_path = project_paths.choose(&mut rng).unwrap();
let file_name = format!(
"{}.{}",
generate_random_string(rng.gen_range(5..20)),
if rng.gen_bool(0.5) { "rs" } else { "js" }
);
let file_path = PathBuf::from(format!("{}/src/{}", project_path, file_name));
let entry = TestQueryEntry {
query: query.into(),
project_path: PathBuf::from(project_path),
file_path,
open_count: rng.gen_range(1..10),
last_opened: now - rng.gen_range(0..30 * 24 * 3600), // Random time within last 30 days
};
entries.push(entry);
}
entries
}
fn setup_tracker_with_data(entries: &[TestQueryEntry]) -> (QueryTracker, PathBuf) {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_dir =
std::env::temp_dir().join(format!("fff_bench_{}_{}", timestamp, rand::random::<u32>()));
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
// Insert all test data
for entry in entries {
for _ in 0..entry.open_count {
tracker
.track_query_completion(&entry.query, &entry.project_path, &entry.file_path)
.unwrap();
}
}
(tracker, temp_dir)
}
fn cleanup_tracker_dir(dir: PathBuf) {
if dir.exists() {
let _ = std::fs::remove_dir_all(dir);
}
}
fn bench_track_query_completion(c: &mut Criterion) {
let mut group = c.benchmark_group("track_query_completion");
for size in &[100, 1000, 10000] {
let entries = generate_test_data(*size);
let (mut tracker, temp_dir) = setup_tracker_with_data(&entries[..*size / 2]); // Pre-populate with half
group.bench_with_input(BenchmarkId::new("entries", size), size, |b, _| {
let mut rng = thread_rng();
b.iter(|| {
let entry = entries.choose(&mut rng).unwrap();
black_box(
tracker
.track_query_completion(
black_box(&entry.query),
black_box(&entry.project_path),
black_box(&entry.file_path),
)
.unwrap(),
);
});
});
drop(tracker);
cleanup_tracker_dir(temp_dir);
}
group.finish();
}
fn bench_realistic_workload(c: &mut Criterion) {
let mut group = c.benchmark_group("realistic_workload");
for size in &[1000, 10000] {
let entries = generate_test_data(*size);
let (mut tracker, temp_dir) = setup_tracker_with_data(&entries);
group.bench_with_input(BenchmarkId::new("mixed_operations", size), size, |b, _| {
let mut rng = thread_rng();
b.iter(|| {
let entry = entries.choose(&mut rng).unwrap();
// Simulate realistic usage: 70% lookups, 25% tracking, 5% history
match rng.gen_range(0..100) {
0..70 => {
// Query entry lookup (most common operation)
let entry_result = black_box(
tracker
.get_last_query_entry(
black_box(&entry.query),
black_box(&entry.project_path),
3,
)
.unwrap(),
);
black_box(entry_result);
}
70..95 => {
// Track completion (when user opens file)
black_box(
tracker
.track_query_completion(
black_box(&entry.query),
black_box(&entry.project_path),
black_box(&entry.file_path),
)
.unwrap(),
);
}
95..100 => {
// Get historical query (least common)
let history = black_box(
tracker
.get_historical_query(black_box(&entry.project_path), black_box(5))
.unwrap(),
);
black_box(history);
}
_ => unreachable!(),
}
});
});
drop(tracker);
cleanup_tracker_dir(temp_dir);
}
group.finish();
}
criterion_group!(
benches,
bench_track_query_completion,
// Commented out - methods removed/changed in refactor:
// bench_get_query_boost,
// bench_cleanup_old_entries,
bench_realistic_workload
);
criterion_main!(benches);
-15
View File
@@ -1,15 +0,0 @@
fn main() {
// delete existing version file created by downloader
let _ = std::fs::remove_file("target/release/version");
// get current sha from git
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
let sha = String::from_utf8(output.stdout).unwrap();
// write to version
std::fs::create_dir_all("target/release").unwrap();
std::fs::write("target/release/version", sha.trim()).unwrap();
}
+208 -119
View File
@@ -1,11 +1,10 @@
*fff.nvim.txt* For Neovim >= 0.8.0 Last change: 2025 August 02
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 October 17
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
- Features |fff.nvim-features|
- Installation |fff.nvim-installation|
- Configuration |fff.nvim-configuration|
FFF.nvimFinally a smart fuzzy file picker for neovim.
@@ -24,16 +23,17 @@ FEATURES *fff.nvim-features*
- Works out of the box with no additional configuration
- Typo resistant fuzzy search <https://github.com/saghen/frizbee>
- Git status integration allowing to take adavantage of last modified times within a worktree
- Separate file index maintained by a dedicaged backend allows <10 milliseconds search time for 50k files codebase
- Git status integration allowing to take advantage of last modified times within a worktree
- Separate file index maintained by a dedicated backend allows <10 milliseconds search time for 50k files codebase
- Display images in previews (for now requires snacks.nvim)
- Smart in a plenty of different ways hopefully helpful for your workflow
- This plugin initializes itself lazily by default
INSTALLATION *fff.nvim-installation*
[!NOTE] Although well try to make sure to keep 100% backward compatibiility,
[!NOTE] Although well try to make sure to keep 100% backward compatibility,
by using you should understand that silly bugs and breaking changes may happen.
And also we hope for your contributions and feedback to make this plugin ideal
for everyone.
@@ -43,85 +43,148 @@ PREREQUISITES ~
FFF.nvim requires:
- Neovim 0.10.0+
- Rust toolchain (requires nightly for building the native backend)
- Rustup <https://rustup.rs/> (we require nightly for building the native backend rustup will handle toolchain automatically)
PACKAGE MANAGERS ~
INSTALLATION ~
LAZY.NVIM
>lua
{
"dmtrKovalenko/fff.nvim",
build = "cargo build --release",
opts = {
-- pass here all the options
},
keys = {
{
"<leader>ff", -- try it if you didn't it is a banger keybinding for a picker
function()
require("fff").toggle()
end,
desc = "Toggle FFF",
'dmtrKovalenko/fff.nvim',
build = function()
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
require("fff.download").download_or_build_binary()
end,
-- if you are using nixos
-- build = "nix run .#release",
opts = { -- (optional)
debug = {
enabled = true, -- we expect your collaboration at least during the beta
show_scores = true, -- to help us optimize the scoring system, feel free to share your scores!
},
},
-- No need to lazy-load with lazy.nvim.
-- This plugin initializes itself lazily.
lazy = false,
keys = {
{
"ff", -- try it if you didn't it is a banger keybinding for a picker
function() require('fff').find_files() end,
desc = 'FFFind files',
}
}
}
<
DEFAULT CONFIGURATION ~
FFF.nvim comes with sensible defaults. Heres the complete default
configuration:
VIM.PACK
>lua
require("fff").setup({
-- UI dimensions and appearance
width = 0.8, -- Window width as fraction of screen
height = 0.8, -- Window height as fraction of screen
prompt = '🪿 ', -- Input prompt symbol
preview = {
enabled = true,
width = 0.5,
max_lines = 100,
max_size = 1024 * 1024, -- 1MB
},
title = 'FFF Files', -- Window title
max_results = 60, -- Maximum search results to display
max_threads = 4, -- Maximum threads for fuzzy search
vim.pack.add({ 'https://github.com/dmtrKovalenko/fff.nvim' })
-- Key mappings (supports both single keys and arrays for multiple bindings)
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
},
nvim.create_autocmd('PackChanged', {
callback = function(event)
if event.data.updated then
require('fff.download').download_or_build_binary()
end
end,
})
-- Highlight groups
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
},
-- Debug options
-- the plugin will automatically lazy load
vim.g.fff = {
lazy_sync = true, -- start syncing only when the picker is open
debug = {
show_scores = false, -- Toggle with F2 or :FFFDebug
enabled = true,
show_scores = true,
},
}
vim.keymap.set(
'n',
'ff',
function() require('fff').find_files() end,
{ desc = 'FFFind files' }
)
<
CONFIGURATION ~
FFF.nvim comes with sensible defaults. Heres the complete configuration with
all available options:
>lua
require('fff').setup({
base_path = vim.fn.getcwd(),
prompt = '🪿 ',
title = 'FFFiles',
max_results = 100,
max_threads = 4,
lazy_sync = true, -- set to false if you want file indexing to start on open
layout = {
height = 0.8,
width = 0.8,
row = nil, -- ratio (0.0 = top edge, 1.0 = bottom edge) nil is centered
col = nil, -- ratio (0.0 = left edge, 1.0 = right edge) nil is centered
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
},
preview = {
enabled = true,
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
wrap_lines = false,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
text = { wrap_lines = true },
},
},
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' },
move_down = { '<Down>', '<C-n>' },
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
},
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
}
})
<
@@ -129,15 +192,40 @@ configuration:
KEY FEATURES ~
AVAILABLE METHODS
>lua
require('fff').find_files() -- Find files in current directory
require('fff').find_in_git_root() -- Find files in the current git repository
require('fff').scan_files() -- Trigger rescan of files in the current directory
require('fff').refresh_git_status() -- Refresh git status for the active file lock
require('fff').find_files_in_dir(path) -- Find files in a specific directory
require('fff').change_indexing_directory(new_path) -- Change the base directory for the file picker
<
COMMANDS
FFF.nvim provides several commands for interacting with the file picker:
- `:FFFFind [path|query]` - Open file picker. Optional: provide directory path or search query
- `:FFFScan` - Manually trigger a rescan of files in the current directory
- `:FFFRefreshGit` - Manually refresh git status for all files
- `:FFFClearCache [all|frecency|files]` - Clear various caches
- `:FFFHealth` - Check FFF health status and dependencies
- `:FFFDebug [on|off|toggle]` - Toggle debug scores display
- `:FFFOpenLog` - Open the FFF log file in a new tab
MULTIPLE KEY BINDINGS
You can assign multiple key combinations to the same action:
>lua
keymaps = {
move_up = { '<Up>', '<C-p>', '<C-k>' }, -- Three ways to move up
close = { '<Esc>', '<C-c>' }, -- Two ways to close
select = '<CR>', -- Single binding still works
move_up = { '<Up>', '<C-p>', '<C-k>' }, -- Three ways to move up
close = { '<Esc>', '<C-c>' }, -- Two ways to close
select = '<CR>', -- Single binding still works
}
<
@@ -157,64 +245,65 @@ Toggle scoring information display:
- Use `:FFFDebug` command
- Enable by default with `debug.show_scores = true`
>
#### vim-plug
```vim
Plug 'MunifTanjim/nui.nvim'
Plug 'dmtrKovalenko/fff.nvim', { 'do': 'cargo build --release' }
TROUBLESHOOTING ~
HEALTH CHECK
Run `:FFFHealth` to check the status of FFF.nvim and its dependencies. This
will verify:
- File picker initialization status
- Optional dependencies (git, image preview tools)
- Database connectivity
VIEWING LOGS
If you encounter issues, check the log file:
>vim
:FFFOpenLog
<
CONFIGURATION *fff.nvim-configuration*
Or manually open the log file at `~/.local/state/nvim/log/fff.log` (default
location).
DEFAULT CONFIGURATION ~
COMMON ISSUES
FFF.nvim comes with sensible defaults. Heres the complete default
configuration:
**File picker not initializing:**
>lua
require("fff").setup({
-- UI dimensions and appearance
width = 0.8, -- Window width as fraction of screen
height = 0.8, -- Window height as fraction of screen
preview_width = 0.5, -- Preview pane width as fraction of picker
prompt = '🪿 ', -- Input prompt symbol
title = 'FFF Files', -- Window title
max_results = 60, -- Maximum search results to display
max_threads = 4, -- Maximum threads for fuzzy search
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' }, -- Multiple bindings supported
move_down = { '<Down>', '<C-n>' }, -- Multiple bindings supported
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
},
debug = {
show_scores = true, -- We hope for your collaboratio
},
})
<
- Ensure the Rust backend is compiled: `cargo build --release` in the plugin directory
- Check that your Neovim version is 0.10.0 or higher
**Image previews not working:**
- Verify your terminal supports images (kitty, iTerm2, WezTerm, etc.)
- For terminals without native image support, install one of: `chafa`, `viu`, or `img2txt`
- If using snacks.nvim, ensure its properly configured
**Performance issues:**
- Adjust `max_threads` in configuration based on your system
- Reduce `preview.max_lines` and `preview.max_size` for large files
- Clear cache if it becomes too large: `:FFFClearCache all`
**Files not being indexed:**
- Run `:FFFScan` to manually trigger a file scan
- Check that the `base_path` is correctly set
- Verify you have read permissions for the directory
DEBUG MODE
Enable debug mode to see scoring information and troubleshoot search results:
- Press `F2` while in the picker
- Run `:FFFDebug on` to enable permanently
- Set `debug.show_scores = true` in configuration
Generated by panvimdoc <https://github.com/kdheepak/panvimdoc>
+62
View File
@@ -0,0 +1,62 @@
-- Single file Neovim config for testing fff.nvim locally
-- Usage: nvim -u /Users/neogoose/dev/fff.nvim/init.lua
-- Set up lazy.nvim plugin manager
local lazypath = vim.fn.stdpath('data') .. '/lazy/lazy.nvim'
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
'git',
'clone',
'--filter=blob:none',
'https://github.com/folke/lazy.nvim.git',
'--branch=stable',
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
require('lazy').setup({
{
dir = '~/dev/fff.nvim',
'https://github.com/dmtrKovalenko/fff.nvim',
build = function()
-- this will download prebuild binary or try to use existing rustup toolchain to build from source
-- (if you are using lazy you can use gb for rebuilding a plugin if needed)
require('fff.download').download_or_build_binary()
end,
dependencies = {
'nvim-tree/nvim-web-devicons', -- Optional: for file icons
-- {
-- 'nvim-mini/mini.icons',
-- version = false,
-- config = true,
-- },
},
config = function()
require('fff').setup({
-- Configure fff.nvim here
ui = {
width = 0.8,
height = 0.8,
},
file_picker = {
auto_reload_on_write = true,
frecency_boost = true,
},
})
end,
},
}, {
root = vim.fn.stdpath('data') .. '/fff-empty-test',
lockfile = vim.fn.stdpath('data') .. '/fff-empty-test.json',
})
vim.opt.number = true
vim.opt.relativenumber = true
vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = 'Find files' })
vim.keymap.set('n', 'fg', function() require('fff').find_in_git_root() end, { desc = 'Find files in git root' })
vim.keymap.set('n', 'fr', function() require('fff').scan_files() end, { desc = 'Rescan files' })
vim.keymap.set('n', 'fs', function() require('fff').refresh_git_status() end, { desc = 'Refresh git status' })
vim.notify('FFF.nvim local config loaded! Press ff', vim.log.levels.INFO)
Generated
+98
View File
@@ -0,0 +1,98 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1765739568,
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
"owner": "ipetkov",
"repo": "crane",
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765644376,
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1765680428,
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+107
View File
@@ -0,0 +1,107 @@
{
description = "fff.nvim";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane.url = "github:ipetkov/crane";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
crane,
flake-utils,
rust-overlay,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
# Common arguments can be set here to avoid repeating them later
# Note: changes here will rebuild all dependency crates
commonArgs = {
src = craneLib.cleanCargoSource ./.;
strictDeps = true;
nativeBuildInputs = [ pkgs.pkg-config pkgs.perl ];
buildInputs = with pkgs; [
# Add additional build inputs here
openssl
];
};
my-crate = craneLib.buildPackage (
commonArgs
// {
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
}
);
# Copies the dynamic library into the target/release folder
copy-dynamic-library = /* bash */ ''
set -eo pipefail
mkdir -p target/release
if [ "$(uname)" = "Darwin" ]; then
cp -vf ${my-crate}/lib/libfff_nvim.dylib target/release/libfff_nvim.dylib
else
cp -vf ${my-crate}/lib/libfff_nvim.so target/release/libfff_nvim.so
fi
echo "Library copied to target/release/"
'';
in
{
checks = {
inherit my-crate;
};
packages = {
default = my-crate;
# Neovim plugin
fff-nvim = pkgs.vimUtils.buildVimPlugin {
pname = "fff.nvim";
version = "main";
src = pkgs.lib.cleanSource ./.;
postPatch = copy-dynamic-library;
doCheck = false; # Skip require check since we have a Rust FFI component
};
};
apps.default = flake-utils.lib.mkApp {
drv = my-crate;
};
# Add the release command
apps.release = flake-utils.lib.mkApp {
drv = pkgs.writeShellScriptBin "release" copy-dynamic-library;
};
devShells.default = craneLib.devShell {
# Inherit inputs from checks.
checks = self.checks.${system};
# Extra inputs can be added here; cargo and rustc are provided by default.
packages = [
# pkgs.ripgrep
];
};
}
);
}
+203
View File
@@ -0,0 +1,203 @@
local M = {}
local overlay_state = {
left_buf = nil,
left_win = nil,
right_buf = nil,
right_win = nil,
ns_id = nil,
-- Cache last position to avoid unnecessary updates
last_row = nil,
last_col = nil,
last_border_hl = nil,
}
local LEFT_OVERLAY_CONTENT = '├────'
local RIGHT_OVERLAY_CONTENT = '─┤'
local LEFT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(LEFT_OVERLAY_CONTENT)
local LEFT_HEADER_PADDING = LEFT_OVERLAY_WIDTH - 2
local RIGHT_OVERLAY_WIDTH = vim.fn.strdisplaywidth(RIGHT_OVERLAY_CONTENT)
local COMBO_TEXT_FORMAT = 'Last Match (×%d combo) '
local LAST_MATCH_TEXT_FORMAT = 'Last Match '
function M.init(ns_id) overlay_state.ns_id = ns_id end
local function detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not items or #items == 0 then return nil, 0 end
local first_score = file_picker.get_file_score(1)
local last_score = file_picker.get_file_score(#items)
if first_score.combo_match_boost > combo_boost_score_multiplier then
return 1, first_score.combo_match_boost / combo_boost_score_multiplier
elseif last_score.combo_match_boost > combo_boost_score_multiplier then
return #items, last_score.combo_match_boost / combo_boost_score_multiplier
end
return nil, 0
end
local function create_header_text(combo_count, win_width, disable_combo_display)
local combo_text = nil
if disable_combo_display then
combo_text = LAST_MATCH_TEXT_FORMAT
else
combo_text = string.format(COMBO_TEXT_FORMAT, combo_count)
end
local text_len = vim.fn.strdisplaywidth(combo_text)
local available_for_content = win_width - LEFT_HEADER_PADDING - RIGHT_OVERLAY_WIDTH
local remaining_dashes = math.max(0, available_for_content - text_len)
return string.rep(' ', LEFT_HEADER_PADDING) .. combo_text .. string.rep('', remaining_dashes), text_len
end
local function apply_header_highlights(buf, ns_id, line_idx, text_len, border_hl)
local config = require('fff.conf').get()
vim.api.nvim_buf_add_highlight(buf, ns_id, border_hl, line_idx - 1, 0, -1)
vim.api.nvim_buf_add_highlight(
buf,
ns_id,
config.hl.combo_header,
line_idx - 1,
LEFT_HEADER_PADDING,
LEFT_HEADER_PADDING + text_len
)
end
local function get_or_create_overlay_buf(state_key)
if not overlay_state[state_key] or not vim.api.nvim_buf_is_valid(overlay_state[state_key]) then
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
end
return overlay_state[state_key]
end
local function update_overlay_content(buf, content, border_hl)
-- Batch all buffer operations together for performance
vim.api.nvim_buf_set_option(buf, 'modifiable', true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { content })
vim.api.nvim_buf_clear_namespace(buf, overlay_state.ns_id, 0, -1)
vim.api.nvim_buf_add_highlight(buf, overlay_state.ns_id, border_hl, 0, 0, -1)
vim.api.nvim_buf_set_option(buf, 'modifiable', false)
end
local function position_overlay_window(state_key, buf, width, row, col)
local win_config = {
relative = 'editor',
width = width,
height = 1,
row = row,
col = col,
style = 'minimal',
focusable = false,
zindex = 250,
}
if overlay_state[state_key] and vim.api.nvim_win_is_valid(overlay_state[state_key]) then
vim.api.nvim_win_set_config(overlay_state[state_key], win_config)
else
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
end
vim.api.nvim_win_set_option(overlay_state[state_key], 'winhl', 'Normal:Normal')
end
local function update_overlays(list_win, combo_header_line, border_hl)
local list_config = vim.api.nvim_win_get_config(list_win)
local combo_header_row = list_config.row + combo_header_line
-- Skip update if position and highlight haven't changed
if
overlay_state.last_row == combo_header_row
and overlay_state.last_col == list_config.col
and overlay_state.last_border_hl == border_hl
and overlay_state.left_win
and vim.api.nvim_win_is_valid(overlay_state.left_win)
and overlay_state.right_win
and vim.api.nvim_win_is_valid(overlay_state.right_win)
then
return
end
-- Cache current values
overlay_state.last_row = combo_header_row
overlay_state.last_col = list_config.col
overlay_state.last_border_hl = border_hl
-- Update both overlays in a batch to minimize API calls
local left_buf = get_or_create_overlay_buf('left_buf')
local right_buf = get_or_create_overlay_buf('right_buf')
-- Update content for both buffers
update_overlay_content(left_buf, LEFT_OVERLAY_CONTENT, border_hl)
update_overlay_content(right_buf, RIGHT_OVERLAY_CONTENT, border_hl)
-- Position both windows
position_overlay_window('left_win', left_buf, LEFT_OVERLAY_WIDTH, combo_header_row, list_config.col)
position_overlay_window(
'right_win',
right_buf,
RIGHT_OVERLAY_WIDTH,
combo_header_row,
list_config.col + list_config.width
)
end
local function clear_overlays_internal()
if overlay_state.left_win and vim.api.nvim_win_is_valid(overlay_state.left_win) then
vim.api.nvim_win_close(overlay_state.left_win, true)
overlay_state.left_win = nil
end
if overlay_state.right_win and vim.api.nvim_win_is_valid(overlay_state.right_win) then
vim.api.nvim_win_close(overlay_state.right_win, true)
overlay_state.right_win = nil
end
-- Clear cache
overlay_state.last_row = nil
overlay_state.last_col = nil
overlay_state.last_border_hl = nil
end
function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display)
local combo_item_index, combo_count = detect_combo_item(items, file_picker, combo_boost_score_multiplier)
if not combo_item_index then return false, nil, 0, nil end
local header_line, text_len = create_header_text(combo_count, win_width, disable_combo_display)
return true, header_line, text_len, combo_item_index
end
function M.render_highlights_and_overlays(
combo_item_index,
text_len,
list_buf,
list_win,
ns_id,
border_hl,
item_to_lines
)
if not combo_item_index then
clear_overlays_internal()
return
end
local combo_item_lines = item_to_lines[combo_item_index]
if not combo_item_lines then
clear_overlays_internal()
return
end
local combo_header_line_idx = combo_item_lines.first
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
update_overlays(list_win, combo_header_line_idx, border_hl)
end
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
function M.cleanup() clear_overlays_internal() end
return M
+231
View File
@@ -0,0 +1,231 @@
local M = {}
--@class fff.conf.State
local state = {
---@type table | nil
config = nil,
}
local DEPRECATION_RULES = {
{
-- Top-level width -> layout.width
old_path = { 'width' },
new_path = { 'layout', 'width' },
message = 'config.width is deprecated. Use config.layout.width instead.',
},
{
-- Top-level height -> layout.height
old_path = { 'height' },
new_path = { 'layout', 'height' },
message = 'config.height is deprecated. Use config.layout.height instead.',
},
{
-- preview.width -> layout.preview_size
old_path = { 'preview', 'width' },
new_path = { 'layout', 'preview_size' },
message = 'config.preview.width is deprecated. Use config.layout.preview_size instead.',
},
{
-- layout.preview_width -> layout.preview_size
old_path = { 'layout', 'preview_width' },
new_path = { 'layout', 'preview_size' },
message = 'config.layout.preview_width is deprecated. Use config.layout.preview_size instead.',
},
}
--- Get value from nested table using path array
--- @param tbl table Source table
--- @param path table Array of keys to traverse
--- @return any|nil Value at path or nil if not found
local function get_nested_value(tbl, path)
local current = tbl
for _, key in ipairs(path) do
if type(current) ~= 'table' or current[key] == nil then return nil end
current = current[key]
end
return current
end
--- Set value in nested table using path array, creating intermediate tables
--- @param tbl table Target table
--- @param path table Array of keys to traverse
--- @param value any Value to set
local function set_nested_value(tbl, path, value)
local current = tbl
for i = 1, #path - 1 do
local key = path[i]
if type(current[key]) ~= 'table' then current[key] = {} end
current = current[key]
end
current[path[#path]] = value
end
--- Remove value from nested table using path array
--- @param tbl table Target table
--- @param path table Array of keys to traverse
local function remove_nested_value(tbl, path)
if #path == 0 then return end
local current = tbl
for i = 1, #path - 1 do
local key = path[i]
if type(current[key]) ~= 'table' then return end
current = current[key]
end
current[path[#path]] = nil
end
--- Handle deprecated configuration options with migration warnings
--- @param user_config table User provided configuration
--- @return table Migrated configuration
local function handle_deprecated_config(user_config)
if not user_config then return {} end
local migrated_config = vim.deepcopy(user_config)
for _, rule in ipairs(DEPRECATION_RULES) do
local old_value = get_nested_value(user_config, rule.old_path)
if old_value ~= nil then
set_nested_value(migrated_config, rule.new_path, old_value)
remove_nested_value(migrated_config, rule.old_path)
vim.notify('FFF: ' .. rule.message, vim.log.levels.WARN)
end
end
return migrated_config
end
local function init()
local config = vim.g.fff or {}
local default_config = {
base_path = vim.fn.getcwd(),
prompt = '🪿 ',
title = 'FFFiles',
max_results = 100,
max_threads = 4,
layout = {
height = 0.8,
width = 0.8,
prompt_position = 'bottom', -- or 'top'
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
preview_size = 0.5,
show_scrollbar = true, -- Show scrollbar for pagination
},
preview = {
enabled = true,
max_size = 10 * 1024 * 1024, -- Do not try to read files larger than 10MB
chunk_size = 8192, -- Bytes per chunk for dynamic loading (8kb - fits ~100-200 lines)
binary_file_threshold = 1024, -- amount of bytes to scan for binary content (set 0 to disable)
imagemagick_info_format_str = '%m: %wx%h, %[colorspace], %q-bit',
line_numbers = false,
wrap_lines = false,
show_file_info = true,
filetypes = {
svg = { wrap_lines = true },
markdown = { wrap_lines = true },
text = { wrap_lines = true },
},
},
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' },
move_down = { '<Down>', '<C-n>' },
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
toggle_debug = '<F2>',
cycle_previous_query = '<C-Up>',
toggle_select = '<Tab>',
send_to_quickfix = '<C-q>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
-- Git text highlights for file names
git_staged = 'FFFGitStaged',
git_modified = 'FFFGitModified',
git_deleted = 'FFFGitDeleted',
git_renamed = 'FFFGitRenamed',
git_untracked = 'FFFGitUntracked',
git_ignored = 'FFFGitIgnored',
-- Git sign/border highlights
git_sign_staged = 'FFFGitSignStaged',
git_sign_modified = 'FFFGitSignModified',
git_sign_deleted = 'FFFGitSignDeleted',
git_sign_renamed = 'FFFGitSignRenamed',
git_sign_untracked = 'FFFGitSignUntracked',
git_sign_ignored = 'FFFGitSignIgnored',
-- Git sign selected highlights
git_sign_staged_selected = 'FFFGitSignStagedSelected',
git_sign_modified_selected = 'FFFGitSignModifiedSelected',
git_sign_deleted_selected = 'FFFGitSignDeletedSelected',
git_sign_renamed_selected = 'FFFGitSignRenamedSelected',
git_sign_untracked_selected = 'FFFGitSignUntrackedSelected',
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
},
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
history = {
enabled = true,
db_path = vim.fn.stdpath('data') .. '/fff_queries',
min_combo_count = 3, -- Minimum selections before combo boost applies (3 = boost starts on 3rd selection)
combo_boost_score_multiplier = 100, -- Score multiplier for combo matches (files repeatedly opened with same query)
},
debug = {
enabled = false, -- Set to true to show scores in the UI
show_scores = false,
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
}
local migrated_user_config = handle_deprecated_config(config)
local merged_config = vim.tbl_deep_extend('force', default_config, migrated_user_config)
state.config = merged_config
end
--- Setup the file picker with the given configuration
--- @param config table Configuration options
function M.setup(config) vim.g.fff = config end
--- @return table the fff configuration
function M.get()
if not state.config then init() end
return state.config
end
--- @return boolean state_changed
function M.toggle_debug()
local old_debug_state = state.config.debug.show_scores
state.config.debug.show_scores = not state.config.debug.show_scores
local status = state.config.debug.show_scores and 'enabled' or 'disabled'
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
return old_debug_state ~= state.config.debug.show_scores
end
return M
+107
View File
@@ -0,0 +1,107 @@
local fuzzy = require('fff.fuzzy')
if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end
local M = {}
---@class fff.core.State
local state = {
---@type boolean
initialized = false,
---@type boolean
file_picker_initialized = false,
}
---@param config table
local function setup_global_autocmds(config)
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
if config.frecency.enabled then
vim.api.nvim_create_autocmd({ 'BufEnter' }, {
group = group,
desc = 'Track file access for FFF frecency',
callback = function(args)
local file_path = args.file
if not (file_path and file_path ~= '' and not vim.startswith(file_path, 'term://')) then return end
vim.uv.fs_stat(file_path, function(err, stat)
if err or not stat then return end
vim.uv.fs_realpath(file_path, function(rp_err, real_path)
if rp_err or not real_path then return end
pcall(fuzzy.track_access, real_path)
end)
end)
end,
})
end
-- make sure that this won't work correctly if autochdir plugins are enabled
-- using a pure :cd command but will work using lua api or :e command
vim.api.nvim_create_autocmd('DirChanged', {
group = group,
callback = function()
local new_cwd = vim.v.event.cwd
if state.initialized and new_cwd and new_cwd ~= config.base_path then
vim.schedule(function()
-- Delay require to avoid circular dependency: core -> main -> picker_ui -> file_picker -> core
local ok, picker = pcall(require, 'fff.main')
if not ok then
vim.notify('FFF: Failed to load main module: ' .. tostring(picker), vim.log.levels.ERROR)
return
end
local change_ok, err = pcall(picker.change_indexing_directory, new_cwd)
if not change_ok then
vim.notify('FFF: Failed to change indexing directory: ' .. tostring(err), vim.log.levels.ERROR)
end
end)
end
end,
desc = 'Automatically sync FFF directory changes',
})
vim.api.nvim_create_autocmd('VimLeavePre', {
group = group,
callback = function() pcall(fuzzy.cleanup_file_picker) end,
desc = 'Cleanup FFF background threads on Neovim exit',
})
end
--- @return boolean
M.is_file_picker_initialized = function() return state.file_picker_initialized end
M.ensure_initialized = function()
if state.initialized then return fuzzy end
state.initialized = true
local config = require('fff.conf').get()
if config.logging.enabled then
local log_success, log_error = pcall(fuzzy.init_tracing, config.logging.log_file, config.logging.log_level)
if log_success then
M.log_file_path = log_error
else
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
end
end
local frecency_db_path = config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_frecency')
local history_db_path = config.history.db_path or (vim.fn.stdpath('data') .. '/fff_history')
local ok, result = pcall(fuzzy.init_db, frecency_db_path, history_db_path, true)
if not ok then vim.notify('Failed to databases: ' .. result, vim.log.levels.WARN) end
ok, result = pcall(fuzzy.init_file_picker, config.base_path)
if not ok then
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
return fuzzy
end
state.file_picker_initialized = true
setup_global_autocmds(config)
local git_utils = require('fff.git_utils')
git_utils.setup_highlights()
return fuzzy
end
return M
+197
View File
@@ -0,0 +1,197 @@
local M = {}
local system = require('fff.utils.system')
local uv = vim and vim.uv or require('luv')
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
local function get_current_version(plugin_dir, callback)
vim.system({ 'git', 'rev-parse', '--short', 'HEAD' }, { cwd = plugin_dir }, function(result)
if result.code ~= 0 or not result.stdout or result.stdout == '' then
callback(nil)
return
end
callback(result.stdout:gsub('%s+', ''))
end)
end
local function get_binary_dir(plugin_dir) return plugin_dir .. '/../target/release' end
local function get_binary_path(plugin_dir)
local binary_dir = get_binary_dir(plugin_dir)
local extension = system.get_lib_extension()
return binary_dir .. '/libfff_nvim.' .. extension
end
local function binary_exists(plugin_dir)
local binary_path = get_binary_path(plugin_dir)
local stat = uv.fs_stat(binary_path)
return stat and stat.type == 'file'
end
local function download_file(url, output_path, opts, callback)
opts = opts or {}
local dir = vim.fn.fnamemodify(output_path, ':h')
uv.fs_mkdir(dir, 493, function(err) -- 493 = 0755 octal
if err and not err:match('EEXIST') then
callback(false, 'Failed to create directory: ' .. err)
return
end
local curl_args = {
'curl',
'--fail',
'--location',
'--silent',
'--show-error',
'--output',
output_path,
}
if opts.proxy then
table.insert(curl_args, '--proxy')
table.insert(curl_args, opts.proxy)
end
table.insert(curl_args, url)
vim.system(curl_args, {}, function(result)
if result.code ~= 0 then
callback(false, 'Failed to download: ' .. (result.stderr or 'unknown error'))
return
end
callback(true, nil)
end)
end)
end
local function download_from_github(version, binary_path, opts, callback)
opts = opts or {}
local triple = system.get_triple()
local extension = system.get_lib_extension()
local binary_name = triple .. '.' .. extension
local url = string.format('https://github.com/%s/releases/download/%s/%s', GITHUB_REPO, version, binary_name)
vim.schedule(function()
vim.notify(string.format('Downloading fff.nvim binary for ' .. version), vim.log.levels.INFO)
vim.notify(string.format('Do not open fff until you see a success notification.'), vim.log.levels.WARN)
end)
download_file(url, binary_path, {
proxy = opts.proxy,
extra_curl_args = opts.extra_curl_args,
}, function(success, err)
if not success then
callback(false, err)
return
end
-- Verify the binary can be loaded
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
if not ok then
uv.fs_unlink(binary_path)
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
return
end
vim.schedule(function() vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO) end)
callback(true, nil)
end)
end
function M.ensure_downloaded(opts, callback)
opts = opts or {}
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
if binary_exists(plugin_dir) and not opts.force then
callback(true, nil)
return
end
local function on_version(target_version)
if not target_version then
callback(false, 'Could not determine target version')
return
end
local binary_path = get_binary_path(plugin_dir)
download_from_github(target_version, binary_path, opts, callback)
end
if opts.version then
on_version(opts.version)
else
get_current_version(plugin_dir, on_version)
end
end
function M.download_binary(callback)
M.ensure_downloaded({ force = true }, function(success, err)
if not success then
if callback then
callback(false, err)
else
error('Failed to download fff.nvim binary: ' .. (err or 'unknown error'))
end
return
end
if callback then callback(true, nil) end
end)
end
function M.build_binary(callback)
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
local has_rustup = vim.fn.executable('rustup') == 1
if not has_rustup then
callback(
false,
'rustup is not found. It is required to build the fff.nvim binary. Install it from https://rustup.rs/'
)
return
end
vim.system({ 'cargo', 'build', '--release' }, { cwd = plugin_dir }, function(result)
if result.code ~= 0 then
callback(false, 'Failed to build rust binary: ' .. (result.stderr or 'unknown error'))
return
end
callback(true, nil)
end)
end
function M.download_or_build_binary()
M.ensure_downloaded({ force = true }, function(download_success, download_error)
if download_success then return end
vim.schedule(
function()
vim.notify(
'Error downloading binary: ' .. (download_error or 'unknown error') .. '\nTrying cargo build --release\n',
vim.log.levels.WARN
)
end
)
M.build_binary(function(build_success, build_error)
if not build_success then
error('Failed to build fff.nvim binary. Build error: ' .. (build_error or 'unknown error'))
else
vim.schedule(function() vim.notify('fff.nvim binary built successfully!', vim.log.levels.INFO) end)
end
end)
end)
end
function M.get_binary_path()
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
return get_binary_path(plugin_dir)
end
function M.get_binary_cpath_component()
local plugin_dir = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':h:h')
local binary_dir = get_binary_dir(plugin_dir)
local extension = system.get_lib_extension()
return binary_dir .. '/lib?.' .. extension
end
return M
+20 -153
View File
@@ -7,49 +7,14 @@ local icon_providers = {
M.provider = nil
M.provider_name = nil
local directory_configs = {
['nvim-web-devicons'] = {
default = { icon = '󰉋', hl = 'DevIconDefault' },
open = { icon = '󰝰', hl = 'DevIconDefault' },
closed = { icon = '󰉋', hl = 'DevIconDefault' },
git = { icon = '', hl = 'DevIconGitIgnore' },
node_modules = { icon = '', hl = 'DevIconNodeModules' },
hidden = { icon = '󰘓', hl = 'DevIconDefault' },
},
['mini.icons'] = {
default = { icon = '󰉋', color = '#7aa2f7' },
open = { icon = '󰝰', color = '#7aa2f7' },
closed = { icon = '󰉋', color = '#7aa2f7' },
git = { icon = '', color = '#e24329' },
node_modules = { icon = '', color = '#8cc84b' },
hidden = { icon = '󰘓', color = '#6d8086' },
},
}
-- Special directory names and their icons
local special_directories = {
['.git'] = 'git',
['node_modules'] = 'node_modules',
['.vscode'] = 'hidden',
['.idea'] = 'hidden',
['.cache'] = 'hidden',
['.config'] = 'hidden',
['__pycache__'] = 'hidden',
['.pytest_cache'] = 'hidden',
['target'] = 'hidden',
['dist'] = 'hidden',
['build'] = 'hidden',
['out'] = 'hidden',
['.next'] = 'hidden',
['.nuxt'] = 'hidden',
['coverage'] = 'hidden',
}
M.highlight_cache = {}
M.setup_attempted = false
M.setup_failed = false
function M.setup()
if M.provider_name then return true end
if M.setup_failed then return false end
M.setup_attempted = true
for _, provider_name in ipairs(icon_providers) do
local ok, provider = pcall(require, provider_name)
@@ -60,150 +25,52 @@ function M.setup()
end
end
vim.notify('FFF Icons: No icon provider found. Please install nvim-web-devicons or mini.icons', vim.log.levels.WARN)
M.setup_failed = true
return false
end
--- Get icon for a directory
--- @param dirname string The directory name
--- @return string, string Icon and color/highlight
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_directory_icon(dirname)
if not M.setup() then
return '󰉋', '#7aa2f7' -- Default folder icon if no provider
end
if not M.setup() then return nil, nil end
local dir_type = 'default'
local basename = vim.fn.fnamemodify(dirname, ':t')
if special_directories[basename] then
dir_type = special_directories[basename]
elseif basename:match('^%.') then
dir_type = 'hidden'
end
local config = directory_configs[M.provider_name]
if not config or not config[dir_type] then dir_type = 'default' end
local icon_data = config[dir_type]
if M.provider_name == 'nvim-web-devicons' then
-- For nvim-web-devicons, try to get the actual icon first
if M.provider.get_icon then
local provider_icon, provider_hl = M.provider.get_icon(basename, nil, { default = false })
if provider_icon and provider_icon ~= '' then
return provider_icon, M.resolve_color(provider_hl or icon_data.hl)
end
local icon, hl = M.provider.get_icon(basename, nil, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
end
-- Use our configured icon
return icon_data.icon, M.resolve_color(icon_data.hl)
elseif M.provider_name == 'mini.icons' then
-- For mini.icons, try to get directory-specific icon
if M.provider.get then
local provider_data = M.provider.get('directory', basename)
if provider_data and provider_data.glyph and provider_data.glyph ~= '' then
return provider_data.glyph, M.get_color_from_highlight(provider_data.hl)
end
local icon, hl, is_default = M.provider.get('directory', basename)
if icon and icon ~= '' and hl then return icon, hl end
end
-- Use our configured icon
return icon_data.icon, icon_data.color
end
-- Fallback (shouldn't reach here)
return '󰉋', '#7aa2f7'
return nil, nil
end
--- Get icon for a file
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string, string Icon and color
--- @return string|nil, string|nil Icon and highlight group (nil if no provider)
function M.get_icon(filename, extension, is_directory)
if not M.setup() then
if is_directory then
return '󰉋', '#7aa2f7'
else
return '󰈙', '#6d8086'
end
end
if not M.setup() then return nil, nil end
if is_directory then return M.get_directory_icon(filename) end
local icon, color_or_hl
if M.provider_name == 'nvim-web-devicons' then
icon, color_or_hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' then return icon, M.resolve_color(color_or_hl) end
local icon, hl = M.provider.get_icon(filename, extension, { default = true })
if icon and icon ~= '' and hl then return icon, hl end
elseif M.provider_name == 'mini.icons' then
local icon_data = M.provider.get('file', filename)
if icon_data and icon_data.glyph and icon_data.glyph ~= '' then
return icon_data.glyph, M.get_color_from_highlight(icon_data.hl)
end
local icon, hl, _ = M.provider.get('file', filename)
if icon and icon ~= '' and hl then return icon, hl end
end
return '󰈙', '#6d8086'
end
--- Get folder icon (kept for compatibility)
--- @return string, string Icon and color
function M.get_folder_icon() return M.get_directory_icon('folder') end
--- Resolve color from highlight group or hex
--- @param color_or_hl string|nil Color hex or highlight group name
--- @return string Hex color
function M.resolve_color(color_or_hl)
if not color_or_hl or color_or_hl == '' then return '#6d8086' end
-- If it's already a hex color, return as-is
if color_or_hl:match('^#%x%x%x%x%x%x$') then return color_or_hl end
-- Try to resolve as highlight group
return M.get_color_from_highlight(color_or_hl)
end
--- Get hex color from highlight group
--- @param hl_group string Highlight group name
--- @return string Hex color
function M.get_color_from_highlight(hl_group)
if not hl_group or hl_group == '' then return '#6d8086' end
local ok, hl = pcall(vim.api.nvim_get_hl, 0, { name = hl_group })
if ok and hl and hl.fg then return string.format('#%06x', hl.fg) end
return '#6d8086' -- Fallback color
end
--- Get icon with display formatting and highlight group creation
--- @param filename string The filename
--- @param extension string The file extension (without dot)
--- @param is_directory boolean Whether this is a directory
--- @return string, string Icon and highlight group name
function M.get_icon_display(filename, extension, is_directory)
local icon, color = M.get_icon(filename, extension, is_directory)
local hl_group = M.create_icon_highlight(color)
return icon, hl_group
end
--- Create or get cached highlight group for icon color
--- @param color string Hex color
--- @return string Highlight group name
function M.create_icon_highlight(color)
if not color or color == '' then color = '#6d8086' end
if not color:match('^#%x%x%x%x%x%x$') then color = M.resolve_color(color) end
local hl_name = 'FFFIcon' .. color:gsub('#', ''):upper()
if M.highlight_cache[hl_name] then return hl_name end
local ok = pcall(vim.api.nvim_set_hl, 0, hl_name, { fg = color })
if not ok then
color = '#6d8086'
hl_name = 'FFFIcon6D8086'
vim.api.nvim_set_hl(0, hl_name, { fg = color })
end
M.highlight_cache[hl_name] = true
return hl_name
return nil, nil
end
--- Check if directories are supported by current provider
+155 -108
View File
@@ -1,29 +1,94 @@
--- Image handling module for file picker
--- Simple implementation that delegates to Snacks.nvim
local utils = require('fff.utils')
local M = {}
-- Track active image placements per buffer
local function get_main_config()
local main = require('fff.main')
return main.config
end
local active_placements = {} ---@type table<number, any>
local loading_jobs = {} ---@type table<number, {metadata_job?: any}>
-- Helper function to safely set buffer lines
local function safe_set_buffer_lines(bufnr, start, end_line, strict_indexing, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return false end
local function reserve_image_buffer_space(bufnr, metadata_lines_count)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
local win = vim.fn.bufwinid(bufnr)
local buffer_height = win ~= -1 and vim.api.nvim_win_get_height(win) or 24
local lines_for_image = math.max(buffer_height - metadata_lines_count - 2, 5)
local buffer_lines = {}
for _ = 1, metadata_lines_count do
table.insert(buffer_lines, '')
end
for _ = 1, lines_for_image do
table.insert(buffer_lines, '')
end
-- Make buffer modifiable temporarily
local was_modifiable = vim.api.nvim_buf_get_option(bufnr, 'modifiable')
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
-- Set the lines
local ok, err = pcall(vim.api.nvim_buf_set_lines, bufnr, start, end_line, strict_indexing, lines)
-- Restore modifiable state
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, buffer_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', was_modifiable)
return ok
return metadata_lines_count or 2
end
local function update_metadata_lines(bufnr, info_lines, reserved_lines_count)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
reserved_lines_count = reserved_lines_count or 2
local metadata_lines = {}
for i = 1, reserved_lines_count do
metadata_lines[i] = info_lines[i] or ''
end
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_lines(bufnr, 0, reserved_lines_count, false, metadata_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
end
local function identify_image_lines_async(file_path, bufnr, callback)
local stat = vim.uv.fs_stat(file_path)
local size_str = stat and utils.format_file_size(stat.size) or 'Unknown'
local initial_info_lines = { ' Size: ' .. size_str }
if vim.fn.executable('identify') == 0 then
callback(initial_info_lines)
return
end
callback(initial_info_lines)
local config = get_main_config()
local format_str = config and config.preview and config.preview.imagemagick_info_format_str
or '%m: %wx%h, %[colorspace], %q-bit'
local cmd = { 'identify', '-format', format_str, file_path }
-- Cancel any previous metadata job
if loading_jobs[bufnr] and loading_jobs[bufnr].metadata_job then
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
end
loading_jobs[bufnr] = loading_jobs[bufnr] or {}
loading_jobs[bufnr].metadata_job = vim.system(cmd, { text = true }, function(result)
vim.schedule(function()
if not vim.api.nvim_buf_is_valid(bufnr) then return end
local enhanced_info_lines = vim.deepcopy(initial_info_lines)
if result.code == 0 and result.stdout and result.stdout ~= '' then
local magick_info = ' ' .. result.stdout:gsub('\n', '')
table.insert(enhanced_info_lines, magick_info)
end
callback(enhanced_info_lines)
end)
end)
end
-- Common image extensions (SVG excluded for text preview)
local IMAGE_EXTENSIONS = {
'.jpg',
'.jpeg',
@@ -41,7 +106,6 @@ local IMAGE_EXTENSIONS = {
'.avif',
}
--- Check if file is an image
--- @param file_path string Path to the file
--- @return boolean True if file is an image
function M.is_image(file_path)
@@ -55,127 +119,110 @@ function M.is_image(file_path)
return false
end
--- Check if we're in Kitty terminal
--- @return boolean True if in Kitty
function M.is_kitty() return vim.env.KITTY_PID ~= nil end
--- Clear any existing image attachments from buffer
--- @param bufnr number Buffer number
function M.clear_buffer_images(bufnr)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
-- Close any tracked placement for this buffer
if loading_jobs[bufnr] then
if loading_jobs[bufnr].metadata_job then
pcall(loading_jobs[bufnr].metadata_job.kill, loading_jobs[bufnr].metadata_job, 9)
end
loading_jobs[bufnr] = nil
end
if active_placements[bufnr] then
pcall(active_placements[bufnr].close, active_placements[bufnr])
active_placements[bufnr] = nil
end
local ok, snacks = pcall(require, 'snacks')
if ok and snacks.image and snacks.image.placement then
-- Use the proper Snacks.nvim placement cleanup API
-- This removes all image placements for the specified buffer
pcall(snacks.image.placement.clean, bufnr)
end
if ok and snacks.image and snacks.image.placement then pcall(snacks.image.placement.clean, bufnr) end
pcall(vim.api.nvim_buf_clear_namespace, bufnr, -1, 0, -1)
end
--- Display image using the simplest approach that works
--- Load metadata of the image, displays it and display image in paralallel
--- Fully asynchronous
--- @param file_path string Path to the image file
--- @param bufnr number Buffer number to display in
--- @param max_width number Maximum width in characters
--- @param max_height number Maximum height in characters
--- @return boolean
function M.display_image(file_path, bufnr, max_width, max_height)
max_width = max_width or 80
max_height = max_height or 24
vim.api.nvim_buf_set_option(bufnr, 'number', false)
if not M.is_kitty() then
M.display_image_info(file_path, bufnr, 'Not in Kitty terminal')
return
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
local image_content_starts_at_line = reserved_metadata_lines + 1
identify_image_lines_async(
file_path,
bufnr,
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
)
local ok, snacks = pcall(require, 'snacks')
if not ok then
local error_lines = {
'⚠ Image Preview Unavailable',
'',
'snacks.nvim plugin is not installed or not available.',
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
return false
end
-- Try Snacks.nvim first (most reliable)
local ok, snacks = pcall(require, 'snacks')
if ok and snacks.image and snacks.image.buf then
-- Clear any existing image attachments first
if not snacks.image.supports_terminal() then
local error_lines = {
'⚠ Image Preview Unavailable',
'',
'Terminal does not support image preview.',
'Please use a terminal that supports images, such as Kitty, Wezterm or Alacritty.',
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
return false
end
if not snacks.image.supports_file(file_path) then
local error_lines = {
'⚠ Unsupported Image Format',
'',
'File format is not supported for image preview.',
'File: ' .. vim.fn.fnamemodify(file_path, ':t'),
}
vim.api.nvim_buf_set_option(bufnr, 'modifiable', true)
vim.api.nvim_buf_set_lines(bufnr, image_content_starts_at_line, -1, false, error_lines)
vim.api.nvim_buf_set_option(bufnr, 'modifiable', false)
return false
end
if snacks.image and snacks.image.placement then
M.clear_buffer_images(bufnr)
-- Clear buffer content to prevent text/image overlap
safe_set_buffer_lines(bufnr, 0, -1, false, {})
vim.schedule(function()
local success, placement = pcall(snacks.image.placement.new, bufnr, file_path, {
pos = { image_content_starts_at_line, 1 },
inline = true,
fit = 'contain',
auto_resize = true,
})
-- Configure Snacks image to prevent repetition
local success, placement = pcall(snacks.image.buf.attach, bufnr, {
src = file_path,
fit = 'contain', -- Fit image within bounds without repetition
})
if success and placement then active_placements[bufnr] = placement end
end)
if success and placement then
-- Track the placement so we can clean it up later
active_placements[bufnr] = placement
return -- Successfully attached image, we're done
else
M.display_image_info(file_path, bufnr, 'Snacks.nvim failed: ' .. tostring(placement or 'unknown error'))
return
end
identify_image_lines_async(
file_path,
bufnr,
function(final_info_lines) update_metadata_lines(bufnr, final_info_lines, reserved_metadata_lines) end
)
return true
end
M.display_image_info(file_path, bufnr, 'Snacks.nvim not available')
end
--- Display image information when image display fails
--- @param file_path string Path to the image file
--- @param bufnr number Buffer number to display in
--- @param reason string|nil Reason for failure
function M.display_image_info(file_path, bufnr, reason)
local info = {}
local stat = vim.loop.fs_stat(file_path)
if stat then
table.insert(info, string.format('📁 File: %s', vim.fn.fnamemodify(file_path, ':t')))
table.insert(info, string.format('📏 Size: %d bytes', stat.size))
table.insert(info, string.format('🕒 Modified: %s', os.date('%Y-%m-%d %H:%M:%S', stat.mtime.sec)))
end
local width, height = M.get_image_dimensions(file_path)
if width and height then table.insert(info, string.format('🖼️ Dimensions: %dx%d pixels', width, height)) end
local ext = string.lower(vim.fn.fnamemodify(file_path, ':e'))
if ext ~= '' then table.insert(info, string.format('🎨 Format: %s', ext:upper())) end
table.insert(info, '')
table.insert(info, '┌─ Image Preview Debug ─┐')
table.insert(info, string.format('│ Kitty: %s │', M.is_kitty() and 'Yes' or 'No'))
table.insert(info, string.format('│ KITTY_PID: %s │', vim.env.KITTY_PID or 'nil'))
if reason then
table.insert(info, string.format('│ Issue: %s', reason:sub(1, 16)))
if #reason > 16 then table.insert(info, string.format('│ %s', reason:sub(17, 32))) end
end
table.insert(info, '└─────────────────────────┘')
safe_set_buffer_lines(bufnr, 0, -1, false, info)
end
--- Get image dimensions using file command
--- @param file_path string Path to the image file
--- @return number|nil, number|nil Width and height in pixels
function M.get_image_dimensions(file_path)
-- Try file command first
local cmd = string.format('file "%s"', file_path)
local result = vim.fn.system(cmd)
if vim.v.shell_error == 0 then
local width, height = result:match('(%d+)%s*x%s*(%d+)')
if width and height then return tonumber(width), tonumber(height) end
end
-- Fallback to identify command (ImageMagick)
cmd = string.format('identify -format "%%w %%h" "%s" 2>/dev/null', file_path)
result = vim.fn.system(cmd)
if vim.v.shell_error == 0 then
local width, height = result:match('(%d+)%s+(%d+)')
if width and height then return tonumber(width), tonumber(height) end
end
return nil, nil
return false
end
return M
+59 -95
View File
@@ -2,68 +2,18 @@
--- Uses advanced fuzzy search algorithm with frecency scoring
local M = {}
local fuzzy = require('fff.core').ensure_initialized()
-- Load the fuzzy module for file operations
local fuzzy = require('fff.fuzzy')
-- State
M.state = {
initialized = false,
base_path = nil,
last_scan_time = 0,
config = nil,
}
--- Initialize the file picker
--- @param config table Configuration for the file picker
function M.setup(config)
config = config or {}
-- Default configuration
local defaults = {
base_path = vim.fn.getcwd(),
max_results = 100,
max_threads = 4,
show_hidden = false,
ignore_patterns = {},
preview = {
enabled = true,
max_lines = 100,
max_size = 1024 * 1024, -- 1MB
},
keymaps = {
select = '<CR>',
vsplit = '<C-v>',
split = '<C-s>',
tab = '<C-t>',
close = '<Esc>',
preview_up = '<C-u>',
preview_down = '<C-d>',
},
layout = {
prompt_position = 'top',
preview_position = 'right',
preview_width = 0.4,
height = 0.8,
width = 0.8,
},
}
M.config = vim.tbl_deep_extend('force', defaults, config)
M.state.config = M.config
local db_path = vim.fn.stdpath('cache') .. '/fff_nvim'
local ok, result = pcall(fuzzy.init_db, db_path, true)
if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end
ok, result = pcall(fuzzy.init_file_picker, M.config.base_path)
if not ok then
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
return false
end
function M.setup()
local config = require('fff.conf').get()
M.state.initialized = true
M.state.base_path = M.config.base_path
M.state.base_path = config.base_path
return true
end
@@ -81,39 +31,61 @@ function M.scan_files()
M.state.last_scan_time = os.time()
end
--- Get cached files from the file picker
function M.get_cached_files()
if not M.state.initialized then return {} end
local ok, files = pcall(fuzzy.get_cached_files)
if not ok then
vim.notify('Failed to get cached files: ' .. files, vim.log.levels.ERROR)
return {}
end
return files
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param max_results number|nil Maximum number of results (optional)
--- @param max_threads number|nil Maximum number of threads (optional)
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @return table List of matching files
function M.search_files(query, current_file, max_results, max_threads, min_combo_count_override)
-- Delegate to paginated version with offset=0 and limit=max_results
return M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, 0, max_results)
end
--- Search files with fuzzy matching using blink.cmp's advanced algorithm
--- Search files with pagination support
--- Results are always returned in descending order (best scores first)
--- @param query string Search query
--- @param max_results number Maximum number of results (optional)
--- @param current_file string|nil Path to current file to deprioritize (optional)
--- @param max_threads number|nil Maximum number of threads to use
--- @param min_combo_count_override number|nil Optional override for min_combo_count (nil uses config)
--- @param page_index number Page index (0-based: 0, 1, 2, ...)
--- @param page_size number Items per page
--- @return table List of matching files
function M.search_files(query, max_results, max_threads, current_file)
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
local config = require('fff.conf').get()
if not M.state.initialized then return {} end
max_results = max_results or M.config.max_results
max_threads = max_threads or M.config.max_threads
max_threads = max_threads or config.max_threads or 4
page_index = page_index or 0
page_size = page_size or 0
local min_combo_count = min_combo_count_override
if min_combo_count == nil then min_combo_count = config.history and config.history.min_combo_count or 3 end
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
-- Convert page_index to offset (Rust expects offset in items, not page number)
local offset = page_index * page_size
local ok, search_result = pcall(
fuzzy.fuzzy_search_files,
query,
max_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
offset,
page_size
)
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, max_threads, current_file)
if not ok then
vim.notify('Failed to search files: ' .. tostring(search_result), vim.log.levels.ERROR)
return {}
end
-- Store search metadata for UI display
M.state.last_search_result = search_result
return search_result.items
end
@@ -127,33 +99,28 @@ function M.get_search_metadata()
}
end
--- Get location data from the last search result
--- @return table|nil Location data if available
function M.get_search_location()
if not M.state.last_search_result then return nil end
return M.state.last_search_result.location
end
--- Get score information for a file by index (1-based)
--- @param index number The index of the file in the last search results
--- @return table|nil Score information or nil if not available
function M.get_file_score(index)
if not M.state.last_search_result or not M.state.last_search_result.scores then return nil end
-- Convert to 0-based index for Lua table access
local score = M.state.last_search_result.scores[index]
if not score then return nil end
return {
total = score.total or 0,
base_score = score.base_score or 0,
filename_bonus = score.filename_bonus or 0,
special_filename_bonus = score.special_filename_bonus or 0,
frecency_boost = score.frecency_boost or 0,
distance_penalty = score.distance_penalty or 0,
match_type = score.match_type or 'unknown',
}
return M.state.last_search_result.scores[index]
end
--- Record file access for frecency tracking
--- @param file_path string Path to the file that was accessed
function M.access_file(file_path)
function M.track_access(file_path)
if not M.state.initialized then return end
local ok, result = pcall(fuzzy.access_file, file_path)
local ok, result = pcall(fuzzy.track_access, file_path)
if not ok then vim.notify('Failed to record file access: ' .. result, vim.log.levels.WARN) end
end
@@ -182,20 +149,17 @@ end
--- @return boolean
function M.is_initialized() return M.state.initialized end
--- Get current configuration
--- @return table
function M.get_config() return M.config end
--- Get scan progress information
--- @return table Progress information with total_files, scanned_files, is_scanning
--- @return table Progress information with scanned_files_count, is_scanning
function M.get_scan_progress()
if not M.state.initialized then return { total_files = 0, scanned_files = 0, is_scanning = false } end
if not M.state.initialized then return { total_files = 0, scanned_files_count = 0, is_scanning = false } end
local ok, result = pcall(fuzzy.get_scan_progress)
if not ok then
vim.notify('Failed to get scan progress: ' .. result, vim.log.levels.WARN)
return { total_files = 0, scanned_files = 0, is_scanning = false }
return { scanned_files_count = 0, is_scanning = false }
end
return result
end
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -1,3 +1,4 @@
---@class fff.fuzzy
local M = {}
-- Try to load the Rust module
@@ -15,18 +16,27 @@ M.get_keyword_range = rust_module.get_keyword_range
M.guess_edit_range = rust_module.guess_edit_range
M.get_words = rust_module.get_words
M.init_file_picker = rust_module.init_file_picker
M.restart_index_in_path = rust_module.restart_index_in_path
M.scan_files = rust_module.scan_files
M.get_cached_files = rust_module.get_cached_files
M.fuzzy_search_files = rust_module.fuzzy_search_files
M.access_file = rust_module.access_file
M.track_access = rust_module.track_access
M.add_file = rust_module.add_file
M.remove_file = rust_module.remove_file
M.cancel_scan = rust_module.cancel_scan
M.get_scan_progress = rust_module.get_scan_progress
M.is_scanning = rust_module.is_scanning
M.refresh_git_status = rust_module.refresh_git_status
M.update_single_file_frecency = rust_module.update_single_file_frecency
M.stop_background_monitor = rust_module.stop_background_monitor
M.cleanup_file_picker = rust_module.cleanup_file_picker
M.init_tracing = rust_module.init_tracing
M.wait_for_initial_scan = rust_module.wait_for_initial_scan
-- Query tracking functions
M.init_query_db = rust_module.init_query_db
M.destroy_query_db = rust_module.destroy_query_db
M.track_query_completion = rust_module.track_query_completion
M.get_historical_query = rust_module.get_historical_query
return M
+137 -29
View File
@@ -1,19 +1,5 @@
local M = {}
M.highlights = {
untracked = 'FFFGitUntracked',
modified = 'FFFGitModified',
deleted = 'FFFGitDeleted',
renamed = 'FFFGitRenamed',
staged_new = 'FFFGitStaged',
staged_modified = 'FFFGitStaged',
staged_deleted = 'FFFGitStaged',
ignored = 'FFFGitIgnored',
clean = '',
clear = '',
unknown = 'FFFGitUntracked',
}
-- git signs like borders
M.border_chars = {
untracked = '', -- Dotted vertical line
@@ -29,23 +15,83 @@ M.border_chars = {
clear = '',
}
M.border_highlights = {
untracked = 'FFFGitSignUntracked',
modified = 'FFFGitSignModified',
deleted = 'FFFGitSignDeleted',
renamed = 'FFFGitSignRenamed',
staged_new = 'FFFGitSignStaged',
staged_modified = 'FFFGitSignStaged',
staged_deleted = 'FFFGitSignStaged',
ignored = 'FFFGitSignIgnored',
clean = '',
clear = '',
unknown = 'FFFGitSignUntracked',
}
-- Cache for config-based highlight mappings
local highlights_cache = nil
local border_highlights_cache = nil
local border_highlights_selected_cache = nil
function M.get_highlight(git_status) return M.highlights[git_status] or '' end
--- Build and cache highlight mappings from config
local function ensure_cache()
if highlights_cache then return end
function M.get_border_highlight(git_status) return M.border_highlights[git_status] or '' end
local config = require('fff.conf').get()
highlights_cache = {
untracked = config.hl.git_untracked,
modified = config.hl.git_modified,
deleted = config.hl.git_deleted,
renamed = config.hl.git_renamed,
staged_new = config.hl.git_staged,
staged_modified = config.hl.git_staged,
staged_deleted = config.hl.git_staged,
ignored = config.hl.git_ignored,
clean = '',
clear = '',
unknown = config.hl.git_untracked,
}
border_highlights_cache = {
untracked = config.hl.git_sign_untracked,
modified = config.hl.git_sign_modified,
deleted = config.hl.git_sign_deleted,
renamed = config.hl.git_sign_renamed,
staged_new = config.hl.git_sign_staged,
staged_modified = config.hl.git_sign_staged,
staged_deleted = config.hl.git_sign_staged,
ignored = config.hl.git_sign_ignored,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked,
}
border_highlights_selected_cache = {
untracked = config.hl.git_sign_untracked_selected,
modified = config.hl.git_sign_modified_selected,
deleted = config.hl.git_sign_deleted_selected,
renamed = config.hl.git_sign_renamed_selected,
staged_new = config.hl.git_sign_staged_selected,
staged_modified = config.hl.git_sign_staged_selected,
staged_deleted = config.hl.git_sign_staged_selected,
ignored = config.hl.git_sign_ignored_selected,
clean = '',
clear = '',
unknown = config.hl.git_sign_untracked_selected,
}
end
--- Get highlight group for git status text
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_highlight(git_status)
ensure_cache()
return highlights_cache[git_status] or ''
end
--- Get border highlight group for git status
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_border_highlight(git_status)
ensure_cache()
return border_highlights_cache[git_status] or ''
end
--- Get selected border highlight group for git status
--- @param git_status string Git status
--- @return string Highlight group name
function M.get_border_highlight_selected(git_status)
ensure_cache()
return border_highlights_selected_cache[git_status] or ''
end
function M.get_border_char(git_status) return M.border_chars[git_status] or '' end
@@ -83,6 +129,68 @@ function M.setup_highlights()
highlight default link FFFGitSignDeleted GitSignsDelete
highlight default link FFFGitSignUntracked GitSignsAdd
]])
-- Highlighes for git signs both for selected and normal states
local git_highlights = {
{ 'FFFGitSignStaged', 'FFFGitSignStagedSelected', '#10B981', 2 },
{ 'FFFGitSignModified', 'FFFGitSignModifiedSelected', '#F59E0B', 3 },
{ 'FFFGitSignDeleted', 'FFFGitSignDeletedSelected', '#EF4444', 1 },
{ 'FFFGitSignRenamed', 'FFFGitSignRenamedSelected', '#8B5CF6', 5 },
{ 'FFFGitSignUntracked', 'FFFGitSignUntrackedSelected', '#10B981', 2 },
{ 'FFFGitSignIgnored', 'FFFGitSignIgnoredSelected', '#4B5563', 8 },
}
local visual_bg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'gui')
local visual_bg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Visual')), 'bg', 'cterm')
for _, hl in ipairs(git_highlights) do
local _, selected_hl, gui_fg, cterm_fg = hl[1], hl[2], hl[3], hl[4]
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
vim.cmd(
string.format(
'highlight default %s guifg=%s guibg=%s ctermfg=%d ctermbg=%s',
selected_hl,
gui_fg,
gui_bg,
cterm_fg,
cterm_bg
)
)
end
-- Selection highlight - use Directory/Number colors (better than green 'Added')
vim.cmd('highlight default link FFFSelected Directory')
local dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'gui')
local dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Directory')), 'fg', 'cterm')
if dir_fg_gui == '' or dir_fg_gui == '-1' then
-- Directory not defined, try Number
dir_fg_gui = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'gui')
dir_fg_cterm = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID('Number')), 'fg', 'cterm')
end
-- Fallback to blue if neither Directory nor Number have colors
local is_dark_bg = vim.o.background == 'dark'
local gui_fg = dir_fg_gui ~= '' and dir_fg_gui or (is_dark_bg and '#60A5FA' or '#0369A1')
local cterm_fg = dir_fg_cterm ~= '' and dir_fg_cterm or (is_dark_bg and '12' or '4')
local gui_bg = visual_bg_gui ~= '' and visual_bg_gui or 'NONE'
local cterm_bg = visual_bg_cterm ~= '' and visual_bg_cterm or 'NONE'
-- Create combined highlight: Directory/Number foreground + Visual background
vim.cmd(
string.format(
'highlight default FFFSelectedActive guifg=%s guibg=%s ctermfg=%s ctermbg=%s',
gui_fg,
gui_bg,
cterm_fg,
cterm_bg
)
)
end
return M
+200
View File
@@ -0,0 +1,200 @@
local M = {}
--- Jump to a location in the current buffer
--- @param location table|nil Location data from search results
function M.jump_to_location(location)
if not location then return end
local current_buf = vim.api.nvim_get_current_buf()
local line_count = vim.api.nvim_buf_line_count(current_buf)
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
local target_col = location.col and math.max(0, location.col - 1) or 0
vim.api.nvim_win_set_cursor(0, { target_line, target_col })
vim.cmd('normal! zz')
elseif location.start and location['end'] then
-- Extract line numbers from nested structure
local start_line = math.max(1, math.min(location.start.line, line_count))
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
-- start in the visual mode and selecting the range backwards so the cursor ends up at the start
vim.api.nvim_win_set_cursor(0, { end_line, 0 })
vim.cmd('normal! V')
if end_line > start_line then vim.cmd('normal! ' .. (end_line - start_line) .. 'k') end
vim.cmd('normal! zz')
end
end
--- Highlight a location range in a buffer using extmarks
--- @param bufnr number Buffer number
--- @param location table|nil Location data from search results
--- @param namespace number Namespace for extmarks
--- @return table|nil Highlight extmark details for cleanup
function M.highlight_location(bufnr, location, namespace)
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return nil end
local line_count = vim.api.nvim_buf_line_count(bufnr)
local extmarks = {}
if location.line then
local target_line = math.max(1, math.min(location.line, line_count))
if location.col then
local target_col = math.max(0, location.col - 1)
local line_content = vim.api.nvim_buf_get_lines(bufnr, target_line - 1, target_line, false)[1] or ''
local end_col = math.min(target_col + 1, #line_content)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, target_col, {
end_col = end_col,
hl_group = 'IncSearch', -- inc search are better visible for a single chars
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
else
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, target_line - 1, 0, {
line_hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = target_line - 1 }) end
end
elseif location.start and location['end'] then
local start_line = math.max(1, math.min(location.start.line, line_count))
local end_line = math.max(start_line, math.min(location['end'].line, line_count))
-- Check if we have column information for exact range highlighting
if location.start.col and location['end'].col then
if start_line == end_line then
-- Single line range with columns: highlight exact character range
local start_col = math.max(0, location.start.col - 1)
local end_col = location['end'].col - 1
local line_content = vim.api.nvim_buf_get_lines(bufnr, start_line - 1, start_line, false)[1] or ''
end_col = math.min(end_col, #line_content)
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, start_line - 1, start_col, {
end_col = end_col,
hl_group = 'IncSearch',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = start_line - 1 }) end
else
-- Multi-line range with exact columns: highlight precise ranges
for line = start_line, end_line do
local line_start_col, line_end_col
if line == start_line then
-- First line: from start_col to end of line
line_start_col = math.max(0, location.start.col - 1)
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = #line_content
elseif line == end_line then
-- Last line: from beginning to end_col
line_start_col = 0
line_end_col = location['end'].col - 1
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = math.min(line_end_col, #line_content)
else
-- Middle lines: entire line
line_start_col = 0
local line_content = vim.api.nvim_buf_get_lines(bufnr, line - 1, line, false)[1] or ''
line_end_col = #line_content
end
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, line_start_col, {
end_col = line_end_col,
hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
end
end
else
-- Multi-line or no columns: highlight entire lines
for line = start_line, end_line do
local ok, mark_id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace, line - 1, 0, {
line_hl_group = 'Visual',
priority = 1000,
})
if ok then table.insert(extmarks, { id = mark_id, line = line - 1 }) end
end
end
end
return #extmarks > 0 and extmarks or nil
end
--- Clear location highlights from a buffer
--- @param bufnr number Buffer number
--- @param namespace number Namespace for extmarks
function M.clear_location_highlights(bufnr, namespace)
if vim.api.nvim_buf_is_valid(bufnr) then vim.api.nvim_buf_clear_namespace(bufnr, namespace, 0, -1) end
end
--- Get the target line for scrolling preview to location
--- @param location table|nil Location data from search results
--- @return number|nil Target line number (1-indexed) for scrolling
function M.get_target_line(location)
if not location then return nil end
if location.line then
return location.line
elseif location.start then
return location.start.line
end
return nil
end
--- Check if location is valid for a buffer
--- @param location table|nil Location data
--- @param bufnr number Buffer number
--- @return boolean True if location is valid for the buffer
function M.is_valid_location(location, bufnr)
if not location or not vim.api.nvim_buf_is_valid(bufnr) then return false end
local line_count = vim.api.nvim_buf_line_count(bufnr)
if location.line then
return location.line > 0 and location.line <= line_count
elseif location.start and location['end'] then
return location.start.line > 0 and location.start.line <= line_count
end
return false
end
--- Format location for display
--- @param location table|nil Location data
--- @return string Formatted location string
function M.format_location(location)
if not location then return '' end
if location.line and location.col then
return string.format(':%d:%d', location.line, location.col)
elseif location.line then
return string.format(':%d', location.line)
elseif location.start and location['end'] then
-- Handle nested structure with optional column information
if location.start.col and location['end'].col then
return string.format(
':%d:%d-%d:%d',
location.start.line,
location.start.col,
location['end'].line,
location['end'].col
)
else
return string.format(':%d-%d', location.start.line, location['end'].line)
end
end
return ''
end
return M
+89 -368
View File
@@ -1,232 +1,13 @@
local fuzzy = require('fff.fuzzy')
if not fuzzy then error('Failed to load fff.fuzzy module. Ensure the Rust backend is compiled and available.') end
-- PERF: By default, this plugin initializes itself lazily,
-- so we do not require any modules at the top of this module.
local M = {}
M.config = {}
M.state = { initialized = false }
--- Setup the file picker with the given configuration
--- @param config table Configuration options
function M.setup(config)
local default_config = {
base_path = vim.fn.getcwd(),
max_results = 100,
prompt = '🪿 ', -- Input prompt symbol
title = 'FFF Files', -- Window title
width = 0.8,
height = 0.8,
preview = {
enabled = true,
width = 0.5,
max_lines = 100,
max_size = 1024 * 1024, -- 1MB
},
keymaps = {
close = '<Esc>',
select = '<CR>',
select_split = '<C-s>',
select_vsplit = '<C-v>',
select_tab = '<C-t>',
move_up = { '<Up>', '<C-p>' },
move_down = { '<Down>', '<C-n>' },
preview_scroll_up = '<C-u>',
preview_scroll_down = '<C-d>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
frecency = 'Number',
debug = 'Comment',
},
layout = {
prompt_position = 'top',
preview_position = 'right',
preview_width = 0.4,
height = 0.8,
width = 0.8,
},
frecency = {
enabled = true,
db_path = vim.fn.stdpath('cache') .. '/fff_nvim',
},
debug = {
enabled = false,
show_scores = false,
},
logging = {
enabled = true,
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
ui = {
wrap_paths = true,
wrap_indent = 2,
max_path_width = 80,
},
image_preview = {
enabled = true,
max_width = 80,
max_height = 24,
},
icons = {
enabled = true,
},
ui_enabled = true,
}
local merged_config = vim.tbl_deep_extend('force', default_config, config or {})
M.config = merged_config
local db_path = merged_config.frecency.db_path or (vim.fn.stdpath('cache') .. '/fff_nvim')
local ok, result = pcall(fuzzy.init_db, db_path, true)
if not ok then vim.notify('Failed to initialize frecency database: ' .. result, vim.log.levels.WARN) end
ok, result = pcall(fuzzy.init_file_picker, merged_config.base_path)
if not ok then
vim.notify('Failed to initialize file picker: ' .. result, vim.log.levels.ERROR)
return false
end
M.state.initialized = true
M.config = merged_config
M.setup_default_keymaps()
M.setup_commands()
if merged_config.frecency.enabled then M.setup_global_file_tracking() end
local git_utils = require('fff.git_utils')
git_utils.setup_highlights()
if merged_config.logging.enabled then
local fuzzy = require('fff.fuzzy')
local log_success, log_error =
pcall(fuzzy.init_tracing, merged_config.logging.log_file, merged_config.logging.log_level)
if log_success then
M.log_file_path = log_error
else
vim.notify('Failed to initialize logging: ' .. (tostring(log_error) or 'unknown error'), vim.log.levels.WARN)
end
end
return true
end
--- Setup default keymaps
function M.setup_default_keymaps()
vim.keymap.set('n', '<leader>ff', function() M.find_files() end, { desc = 'Find files' })
vim.keymap.set('n', '<leader>ft', function() M.toggle() end, { desc = 'Toggle file picker' })
vim.keymap.set('n', '<leader>fg', function() M.find_in_git_root() end, { desc = 'Find files in git root' })
vim.keymap.set('n', '<leader>fr', function() M.find_recent() end, { desc = 'Find recent files' })
end
function M.setup_global_file_tracking()
local group = vim.api.nvim_create_augroup('fff_file_tracking', { clear = true })
vim.api.nvim_create_autocmd({ 'BufRead', 'BufNewFile' }, {
group = group,
callback = function(args)
local file_path = args.file
if file_path and file_path ~= '' and not vim.startswith(file_path, 'term://') then
-- never block the UI
vim.schedule(function()
local stat = vim.loop.fs_stat(file_path)
if stat and stat.type == 'file' then
local fuzzy = require('fff.fuzzy')
local relative_path = vim.fn.fnamemodify(file_path, ':.')
pcall(fuzzy.access_file, relative_path)
end
end)
end
end,
desc = 'Track file access for FFF frecency',
})
end
function M.setup_commands()
vim.api.nvim_create_user_command('FFFFind', function(opts)
if opts.args and opts.args ~= '' then
-- If argument looks like a directory, use it as base path
if vim.fn.isdirectory(opts.args) == 1 then
M.find_files_in_dir(opts.args)
else
-- Otherwise treat as search query
M.search_and_show(opts.args)
end
else
M.find_files()
end
end, {
nargs = '?',
complete = function(arg_lead, cmd_line, cursor_pos)
-- Complete with directories and common search terms
local dirs = vim.fn.glob(arg_lead .. '*', false, true)
local results = {}
for _, dir in ipairs(dirs) do
if vim.fn.isdirectory(dir) == 1 then table.insert(results, dir) end
end
return results
end,
desc = 'Find files with FFF (use directory path or search query)',
})
vim.api.nvim_create_user_command('FFFScan', function() M.scan_files() end, {
desc = 'Scan files for FFF',
})
vim.api.nvim_create_user_command('FFFRefreshGit', function() M.refresh_git_status() end, {
desc = 'Manually refresh git status for all files',
})
vim.api.nvim_create_user_command('FFFClearCache', function(opts) M.clear_cache(opts.args) end, {
nargs = '?',
complete = function(arg_lead, cmd_line, cursor_pos) return { 'all', 'frecency', 'files' } end,
desc = 'Clear FFF caches (all|frecency|files)',
})
vim.api.nvim_create_user_command('FFFHealth', function() M.health_check() end, {
desc = 'Check FFF health',
})
vim.api.nvim_create_user_command('FFFDebug', function(opts)
if opts.args == 'toggle' or opts.args == '' then
M.config.debug.show_scores = not M.config.debug.show_scores
local status = M.config.debug.show_scores and 'enabled' or 'disabled'
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
elseif opts.args == 'on' then
M.config.debug.show_scores = true
vim.notify('FFF debug scores enabled', vim.log.levels.INFO)
elseif opts.args == 'off' then
M.config.debug.show_scores = false
vim.notify('FFF debug scores disabled', vim.log.levels.INFO)
else
vim.notify('Usage: :FFFDebug [on|off|toggle]', vim.log.levels.ERROR)
end
end, {
nargs = '?',
complete = function() return { 'on', 'off', 'toggle' } end,
desc = 'Toggle FFF debug scores display',
})
vim.api.nvim_create_user_command('FFFOpenLog', function()
if M.log_file_path then
vim.cmd('tabnew ' .. vim.fn.fnameescape(M.log_file_path))
elseif M.config and M.config.logging and M.config.logging.log_file then
-- Fallback to the configured log file path even if tracing wasn't initialized
vim.cmd('tabnew ' .. vim.fn.fnameescape(M.config.logging.log_file))
else
vim.notify('Log file path not available', vim.log.levels.ERROR)
end
end, {
desc = 'Open FFF log file in new tab',
})
end
function M.setup(config) vim.g.fff = config end
--- Find files in current directory
function M.find_files()
@@ -234,69 +15,33 @@ function M.find_files()
if picker_ok then
picker_ui.open()
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
end
end
--- Find files in specific directory
--- @param dir string Directory path
function M.find_files_in_dir(dir)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open({ cwd = dir })
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
end
--- Find files in git repository root
function M.find_in_git_root()
-- Check if we're in a git repo first
local git_root = vim.fn.system('git rev-parse --show-toplevel 2>/dev/null'):gsub('\n', '')
if vim.v.shell_error ~= 0 then
vim.notify('Not in a git repository', vim.log.levels.WARN)
return
end
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open({ title = 'Git Files', cwd = git_root })
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
M.find_files_in_dir(git_root)
end
--- Find recent files (frecency based)
function M.find_recent()
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open({ title = 'Recent Files' })
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
end
--- Toggle file picker
function M.toggle() M.find_files() end
--- Scan files
--- Trigger rescan of files in the current directory
function M.scan_files()
local fuzzy = require('fff.fuzzy')
local fuzzy = require('fff.core').ensure_initialized()
local ok = pcall(fuzzy.scan_files)
if ok then
local cached_files = pcall(fuzzy.get_cached_files) and fuzzy.get_cached_files() or {}
print('Triggered file scan (currently ' .. #cached_files .. ' files cached)')
else
vim.notify('Failed to scan files', vim.log.levels.ERROR)
end
if not ok then vim.notify('Failed to scan files', vim.log.levels.ERROR) end
end
--- Refresh git status for all cached files
--- Refresh git status for the active file lock
function M.refresh_git_status()
local fuzzy = require('fff.fuzzy')
local ok, files = pcall(fuzzy.refresh_git_status)
local fuzzy = require('fff.core').ensure_initialized()
local ok, updated_files_count = pcall(fuzzy.refresh_git_status)
if ok then
print('Refreshed git status for ' .. #files .. ' files')
vim.notify('Refreshed git status for ' .. tostring(updated_files_count) .. ' files', vim.log.levels.INFO)
else
vim.notify('Failed to refresh git status', vim.log.levels.ERROR)
end
@@ -307,9 +52,13 @@ end
--- @param max_results number Maximum number of results
--- @return table List of matching files
function M.search(query, max_results)
local fuzzy = require('fff.fuzzy')
max_results = max_results or M.config.max_results
local ok, search_result = pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil)
local fuzzy = require('fff.core').ensure_initialized()
local config = require('fff.conf').get()
max_results = max_results or config.max_results
local combo_boost_score_multiplier = config.history and config.history.combo_boost_score_multiplier or 100
local min_combo_count = config.history and config.history.min_combo_count or 3
local ok, search_result =
pcall(fuzzy.fuzzy_search_files, query, max_results, nil, nil, false, combo_boost_score_multiplier, min_combo_count)
if ok and search_result.items then return search_result.items end
return {}
end
@@ -371,98 +120,13 @@ function M.get_preview(file_path)
return table.concat(lines, '\n')
end
function M.debug_file_ordering()
print('FFF Debug File Ordering')
print('=======================')
if not M.is_initialized() then
print('File picker not initialized. Run :FFFScan first.')
return
end
local picker = M.picker or M.core
print('Getting top 10 files with debug info...')
-- Enable debug mode temporarily
local old_debug = M.config.debug.show_scores
M.config.debug.show_scores = true
-- Search with empty query to get default ordering
local files = picker.search_files('', 10)
print('🏆 TOP FILES (in order they appear):')
print('=' .. string.rep('=', 70))
for i, file in ipairs(files) do
local frecency_stars = ''
if file.frecency_score > 0 then frecency_stars = '' .. file.frecency_score end
-- Extract directory information
local dir = vim.fn.fnamemodify(file.relative_path, ':h')
local filename = vim.fn.fnamemodify(file.relative_path, ':t')
local dir_display = (dir == '.' or dir == '') and 'root' or dir
-- Get score information for this file
local score = picker.get_file_score(i)
print(string.format('%2d. %s%s', i, filename, frecency_stars))
print(string.format(' Path: %s/', dir_display))
print(string.format(' Debug: %s', score and score.match_type or 'no debug info'))
if score then
print(
string.format(
' Total Score: %d (base=%d, name_bonus=%d, special_bonus=%d, frec=%d, dist=%d)',
score.total,
score.base_score,
score.filename_bonus,
score.special_filename_bonus,
score.frecency_boost,
score.distance_penalty
)
)
else
print(' Total Score: N/A (no score data)')
end
local now = os.time()
local age_hours = math.floor((now - file.modified) / 3600)
local age_days = math.floor(age_hours / 24)
print(string.format(' Age: %d hours (%d days) since last modified', age_hours, age_days))
print('')
end
print('💡 EXPLANATION:')
print('• Files are sorted by FRECENCY first (⭐ score), then by modification time')
print('• Frecency combines how often AND how recently you accessed files')
print('• The file at #1 has either:')
print(' - Highest frecency score, OR')
print(' - Same frecency as others but most recent modification')
M.config.debug.show_scores = old_debug
end
function M.health_check()
local health = {
ok = true,
messages = {},
}
local errors = check_dependencies()
if #errors > 0 then
health.ok = false
for _, error in ipairs(errors) do
table.insert(health.messages, error)
end
end
local ui_errors = check_ui_dependencies()
if #ui_errors > 0 then
table.insert(health.messages, 'UI not available: ' .. table.concat(ui_errors, ', '))
else
table.insert(health.messages, '✓ UI available')
end
if not M.is_initialized() then
if not require('fff.core').is_file_picker_initialized() then
health.ok = false
table.insert(health.messages, 'File picker not initialized')
else
@@ -500,20 +164,77 @@ function M.health_check()
return health
end
function M.get_status()
local status = 'No files indexed'
local fuzzy = require('fff.fuzzy')
local ok, cached_files = pcall(fuzzy.get_cached_files)
if ok and cached_files and #cached_files > 0 then status = string.format('%d files indexed', #cached_files) end
if M.config and M.config.frecency and M.config.frecency.enabled then
status = status .. ' • Frecency tracking enabled'
--- Find files in a specific directory
--- @param directory string Directory path to search in
function M.find_files_in_dir(directory)
if not directory then
vim.notify('Directory path required for find_files_in_dir', vim.log.levels.ERROR)
return
end
return status
M.change_indexing_directory(directory)
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if picker_ok then
picker_ui.open({ title = 'Files in ' .. vim.fn.fnamemodify(directory, ':t') })
else
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
end
end
function M.is_initialized() return M.state and M.state.initialized or false end
--- Change the base directory for the file picker
--- @param new_path string New directory path to use as base
--- @return boolean `true` if successful, `false` otherwise
function M.change_indexing_directory(new_path)
if not new_path or new_path == '' then
vim.notify('Directory path is required', vim.log.levels.ERROR)
return false
end
local expanded_path = vim.fn.expand(new_path)
if vim.fn.isdirectory(expanded_path) ~= 1 then
vim.notify('Directory does not exist: ' .. expanded_path, vim.log.levels.ERROR)
return false
end
local fuzzy = require('fff.core').ensure_initialized()
local ok, result = pcall(fuzzy.restart_index_in_path, expanded_path)
if not ok then
vim.notify('Failed to change directory: ' .. result, vim.log.levels.ERROR)
return false
end
local config = require('fff.conf').get()
config.base_path = expanded_path
return true
end
--- Opens the file under the cursor with an optional callback if the only file
--- is found and we are about to inline open it
--- @param open_cb function|nil Optional callback function to execute after opening the file
function M.open_file_under_cursor(open_cb)
local filename = vim.fn.expand('<cfile>')
local full_path_with_suffix = vim.fn.expand('<cWORD>')
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
if not picker_ok then
vim.notify('Failed to load picker UI', vim.log.levels.ERROR)
return
end
picker_ui.open_with_callback(full_path_with_suffix, function(files, metadata, location, get_file_score)
if #files == 1 or require('fff.file_picker').get_file_score(1).exact_match then
if open_cb and type(open_cb) == 'function' then open_cb(files[1].path) end
vim.api.nvim_command(string.format('e %s', vim.fn.fnameescape(files[1].path)))
if location then vim.schedule(function() require('fff.location_utils').jump_to_location(location) end) end
return true
else
return false -- Open UI with results
end
end)
end
return M
+1479 -470
View File
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
use crate::FILE_PICKER;
use crate::error::Error;
use crate::file_picker::FilePicker;
use crate::git::GitStatusCache;
use crate::sort_buffer::sort_with_buffer;
use git2::Repository;
use notify::event::{AccessKind, AccessMode};
use notify::{Config, EventKind, RecursiveMode};
use notify_debouncer_full::{
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{Level, error, info, warn};
type Debouncer = notify_debouncer_full::Debouncer<notify::RecommendedWatcher, RecommendedCache>;
pub struct BackgroundWatcher {
debouncer: Arc<Mutex<Option<Debouncer>>>,
}
const DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(250);
const MAX_PATHS_THRESHOLD: usize = 1024;
impl BackgroundWatcher {
pub fn new(base_path: PathBuf, git_workdir: Option<PathBuf>) -> Result<Self, Error> {
info!(
"Initializing background watcher for path: {}",
base_path.display()
);
let debouncer = Self::create_debouncer(base_path, git_workdir)?;
info!("Background file watcher initialized successfully");
Ok(Self {
debouncer: Arc::new(Mutex::new(Some(debouncer))),
})
}
fn create_debouncer(
base_path: PathBuf,
git_workdir: Option<PathBuf>,
) -> Result<Debouncer, Error> {
// do not follow symlinks as then notifiers spawns a bunch of events for symlinked
// files that could be git ignored, we have to property differentiate those and if
// the file was edited through a
let config = Config::default().with_follow_symlinks(false);
let mut debouncer = new_debouncer_opt(
DEBOUNCE_TIMEOUT,
Some(DEBOUNCE_TIMEOUT / 2), // tick rate for the event span
{
move |result: DebounceEventResult| match result {
Ok(events) => {
handle_debounced_events(events, &git_workdir);
}
Err(errors) => {
error!("File watcher errors: {:?}", errors);
}
}
},
RecommendedCache::new(),
config,
)?;
debouncer.watch(base_path.as_path(), RecursiveMode::Recursive)?;
info!("File watcher initizlieed for path: {}", base_path.display());
Ok(debouncer)
}
pub fn stop(&self) {
if let Ok(Some(debouncer)) = self.debouncer.lock().map(|mut debouncer| debouncer.take()) {
drop(debouncer);
info!("Background file watcher stopped successfully");
} else {
error!("Failed to stop background watcher");
}
}
}
impl Drop for BackgroundWatcher {
fn drop(&mut self) {
if let Ok(mut debouncer_guard) = self.debouncer.lock() {
if let Some(debouncer) = debouncer_guard.take() {
drop(debouncer);
}
} else {
error!("Failed to acquire debouncer lock to drop");
}
}
}
#[tracing::instrument(name = "fs_events", skip(events), level = Level::DEBUG)]
fn handle_debounced_events(events: Vec<DebouncedEvent>, git_workdir: &Option<PathBuf>) {
// this will be called very often, we have to minimiy the lock time for file picker
let repo = git_workdir.as_ref().and_then(|p| Repository::open(p).ok());
let mut need_full_rescan = false;
let mut need_full_git_rescan = false;
let mut paths_to_remove = Vec::new();
let mut paths_to_add_or_modify = Vec::new();
let mut affected_paths_count = 0usize;
for debounced_event in &events {
// It is very important to not react to the access errors because we inevitably
// gonna trigger the sync by our own preview or other unnecessary noise
if matches!(
debounced_event.event.kind,
EventKind::Access(
AccessKind::Read
| AccessKind::Open(_)
| AccessKind::Close(AccessMode::Read | AccessMode::Execute)
)
) {
continue;
}
tracing::debug!(event = ?debounced_event.event, "Processing FS event");
for path in &debounced_event.event.paths {
if is_ignore_definition_path(path) {
info!(
"Detected change in ignore definition file: {}",
path.display()
);
need_full_rescan = true;
break;
}
if is_dotgit_change_affecting_status(path, &repo) {
need_full_git_rescan = true;
}
if !should_include_file(path, &repo) {
continue;
}
if !path.exists() {
paths_to_remove.push(path.as_path());
} else {
paths_to_add_or_modify.push(path.as_path());
}
}
affected_paths_count += debounced_event.event.paths.len();
if affected_paths_count > MAX_PATHS_THRESHOLD {
warn!(
"Too many affected paths ({}) in a single batch, triggering full rescan",
affected_paths_count
);
need_full_rescan = true;
break;
}
if need_full_rescan {
break;
}
}
if need_full_rescan {
info!(?affected_paths_count, "Triggering full rescan");
trigger_full_rescan();
return;
}
// It's important to get the allocated sort
sort_with_buffer(paths_to_add_or_modify.as_mut_slice(), |a, b| {
a.as_os_str().cmp(b.as_os_str())
});
paths_to_add_or_modify.dedup_by(|a, b| a.as_os_str().eq(b.as_os_str()));
info!(
"Event processing summary: {} to remove, {} to add/modify",
paths_to_remove.len(),
paths_to_add_or_modify.len()
);
let Some(repo) = repo.as_ref() else {
info!("No git repo, skipping git status updates");
return;
};
if need_full_git_rescan {
info!("Triggering full git rescan");
if let Err(e) = FilePicker::refresh_git_status_global() {
error!("Failed to refresh git status: {:?}", e);
}
return;
}
if paths_to_remove.is_empty() && paths_to_add_or_modify.is_empty() {
return;
}
let files_to_update_git_status = {
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized");
return;
};
// Apply file removals
for path in paths_to_remove {
picker.remove_file_by_path(path);
}
// Apply file additions/modifications and collect paths for git status update
let mut files_to_update_git_status = Vec::with_capacity(paths_to_add_or_modify.len());
for path in paths_to_add_or_modify {
if let Some(file) = picker.on_create_or_modify(path) {
files_to_update_git_status.push(file.path.clone());
}
}
files_to_update_git_status
};
info!(
"Fetching git status for {} files",
files_to_update_git_status.len()
);
let status = match GitStatusCache::git_status_for_paths(repo, &files_to_update_git_status) {
Ok(status) => status,
Err(e) => {
tracing::error!(?e, "Failed to query git statue");
return;
}
};
// only lock the picker for theshortest possitble time
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
if let Err(e) = picker.update_git_statuses(status) {
error!("Failed to update git statuses: {:?}", e);
} else {
info!("Successfully updated git statuses in picker");
}
} else {
error!("Failed to acquire picker lock for git status update");
}
}
fn trigger_full_rescan() {
info!("Triggering full filesystem rescan");
let Ok(mut file_picker_guard) = FILE_PICKER.write() else {
error!("Failed to acquire file picker write lock for full rescan");
return;
};
let Some(ref mut picker) = *file_picker_guard else {
error!("File picker not initialized, cannot trigger rescan");
return;
};
if let Err(e) = picker.trigger_rescan() {
error!("Failed to trigger full rescan: {:?}", e);
} else {
info!("Full filesystem rescan completed successfully");
}
}
fn should_include_file(path: &Path, repo: &Option<Repository>) -> bool {
if !path.is_file() || is_git_file(path) {
return false;
}
repo.as_ref()
.is_some_and(|repo| repo.is_path_ignored(path) == Ok(false))
}
#[inline]
fn is_git_file(path: &Path) -> bool {
path.components()
.any(|component| component.as_os_str() == ".git")
}
pub fn is_dotgit_change_affecting_status(changed: &Path, repo: &Option<Repository>) -> bool {
let Some(repo) = repo.as_ref() else {
return false;
};
let git_dir = repo.path();
if let Ok(rel) = changed.strip_prefix(git_dir) {
if rel.starts_with("objects") || rel.starts_with("logs") || rel.starts_with("hooks") {
return false;
}
if rel == Path::new("index") || rel == Path::new("index.lock") {
return true;
}
if rel == Path::new("HEAD") {
return true;
}
if rel.starts_with("refs") || rel == Path::new("packed-refs") {
return true;
}
if rel == Path::new("info/exclude") || rel == Path::new("info/sparse-checkout") {
return true;
}
if let Some(fname) = rel.file_name().and_then(|f| f.to_str())
&& matches!(fname, "MERGE_HEAD" | "CHERRY_PICK_HEAD" | "REVERT_HEAD")
{
return true;
}
}
false
}
fn is_ignore_definition_path(path: &Path) -> bool {
matches!(
path.file_name().and_then(|f| f.to_str()),
Some(".ignore") | Some(".gitignore")
)
}
+22 -17
View File
@@ -1,22 +1,20 @@
use std::path::StripPrefixError;
#[derive(thiserror::Error, Debug)]
#[non_exhaustive]
pub enum Error {
#[error("Thread panicked")]
ThreadPanic,
#[error("Invalid path {0}")]
InvalidPath(std::path::PathBuf),
#[error("File picker not initialized")]
FilePickerMissing,
#[error("Failed to acquire lock for frecency")]
AcquireFrecencyLock,
#[error("Failed to acquire lock for items by provider")]
AcquireItemLock,
#[error("Attempted to use frecency before initialization")]
UseFrecencyBeforeInit,
#[error(
"Attempted to fuzzy match for provider {provider_id} before setting the provider's items"
)]
FuzzyBeforeSetItems { provider_id: String },
#[error("Failed to create frecency database directory: {0}")]
CreateDir(#[source] std::io::Error),
#[error("Failed to create directory: {0}")]
CreateDir(#[from] std::io::Error),
#[error("Failed to open frecency database env: {0}")]
EnvOpen(#[source] heed::Error),
#[error("Failed to create frecency database: {0}")]
@@ -35,16 +33,23 @@ pub enum Error {
DbWrite(#[source] heed::Error),
#[error("Failed to commit write transaction to frecency database: {0}")]
DbCommit(#[source] heed::Error),
#[error("Failed to start file system watcher: {0}")]
FileSystemWatch(#[from] notify::Error),
#[error("Invalid file path: {0}")]
InvalidPath(String),
#[error("Expected a path to be child of another path: {0}")]
StripPrefixError(#[from] StripPrefixError),
#[error("Failed to scan directory: {0}")]
DirectoryScan(String),
#[error("libgit2 error occurred: {0}")]
Git(#[from] git2::Error),
}
impl From<Error> for mlua::Error {
fn from(value: Error) -> Self {
mlua::Error::RuntimeError(value.to_string())
let string_value = value.to_string();
::tracing::error!(string_value);
mlua::Error::RuntimeError(string_value)
}
}
pub type Result<T> = std::result::Result<T, Error>;
-4
View File
@@ -1,4 +0,0 @@
#[derive(Debug, Clone)]
pub struct FileKey {
pub path: String,
}
File diff suppressed because it is too large Load Diff
+98 -32
View File
@@ -1,13 +1,12 @@
use crate::error::Error;
use crate::file_key::FileKey;
use heed::{
types::{Bytes, SerdeBincode},
EnvFlags,
};
use crate::{error::Error, git::is_modified_status};
use heed::{Database, Env, EnvOpenOptions};
use std::collections::VecDeque;
use heed::{
EnvFlags,
types::{Bytes, SerdeBincode},
};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{collections::VecDeque, path::Path};
const DECAY_CONSTANT: f64 = 0.0693; // ln(2)/10 for 10-day half-life
const SECONDS_PER_DAY: f64 = 86400.0;
@@ -19,9 +18,9 @@ pub struct FrecencyTracker {
db: Database<Bytes, SerdeBincode<VecDeque<u64>>>,
}
const ACCESS_THRESHOLDS: [(i64, u64); 5] = [
(12, 60 * 2), // 2 minutes
(6, 60 * 10), // 10 minutes
const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
(16, 60 * 2), // 2 minutes
(8, 60 * 15), // 15 minutes
(4, 60 * 60), // 1 hour
(2, 60 * 60 * 24), // 1 day
(1, 60 * 60 * 24 * 7), // 1 week
@@ -52,9 +51,10 @@ impl FrecencyTracker {
})
}
fn get_accesses(&self, file_key: &FileKey) -> Result<Option<VecDeque<u64>>, Error> {
fn get_accesses(&self, path: &Path) -> Result<Option<VecDeque<u64>>, Error> {
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let key_hash = Self::path_to_hash_bytes(&file_key.path);
let key_hash = Self::path_to_hash_bytes(path)?;
self.db.get(&rtxn, &key_hash).map_err(Error::DbRead)
}
@@ -65,15 +65,19 @@ impl FrecencyTracker {
.as_secs()
}
fn path_to_hash_bytes(path: &str) -> [u8; 32] {
*blake3::hash(path.as_bytes()).as_bytes()
fn path_to_hash_bytes(path: &Path) -> Result<[u8; 32], Error> {
let Some(key) = path.to_str() else {
return Err(Error::InvalidPath(path.to_path_buf()));
};
Ok(*blake3::hash(key.as_bytes()).as_bytes())
}
pub fn track_access(&self, file_key: &FileKey) -> Result<(), Error> {
pub fn track_access(&self, path: &Path) -> Result<(), Error> {
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
let key_hash = Self::path_to_hash_bytes(&file_key.path);
let mut accesses = self.get_accesses(file_key)?.unwrap_or_default();
let key_hash = Self::path_to_hash_bytes(path)?;
let mut accesses = self.get_accesses(path)?.unwrap_or_default();
let now = self.get_now();
let cutoff_time = now.saturating_sub((MAX_HISTORY_DAYS * SECONDS_PER_DAY) as u64);
@@ -86,6 +90,8 @@ impl FrecencyTracker {
}
accesses.push_back(now);
tracing::debug!(?path, accesses = accesses.len(), "Tracking access");
self.db
.put(&mut wtxn, &key_hash, &accesses)
.map_err(Error::DbWrite)?;
@@ -95,10 +101,11 @@ impl FrecencyTracker {
Ok(())
}
pub fn get_access_score(&self, file_key: &FileKey) -> i64 {
pub fn get_access_score(&self, file_path: &Path) -> i64 {
let accesses = self
.get_accesses(file_key)
.unwrap_or(None)
.get_accesses(file_path)
.ok()
.flatten()
.unwrap_or_default();
if accesses.is_empty() {
@@ -129,23 +136,38 @@ impl FrecencyTracker {
normalized_frecency.round() as i64
}
/// Calculate modification frecency score (0-12 points, git-aware)
pub fn get_modification_score(&self, modified_time: u64, git_status: &str) -> i64 {
let git_shows_changes = matches!(
git_status,
"modified" | "staged_modified" | "untracked" | "staged_new"
);
if !git_shows_changes {
return 0; // No modification score for clean/unchanged files
/// Calculating modification score but only if the file is modified in the current git dir
pub fn get_modification_score(
&self,
modified_time: u64,
git_status: Option<git2::Status>,
) -> i64 {
let is_modified_git_status = git_status.is_some_and(is_modified_status);
if !is_modified_git_status {
return 0;
}
let now = self.get_now();
let duration_since = now.saturating_sub(modified_time);
for (base_points, threshold_seconds) in ACCESS_THRESHOLDS {
if duration_since <= threshold_seconds {
return base_points * 2;
for i in 0..MODIFICATION_THRESHOLDS.len() {
let (current_points, current_threshold) = MODIFICATION_THRESHOLDS[i];
if duration_since <= current_threshold {
if i == 0 || duration_since == current_threshold {
return current_points;
}
let (prev_points, prev_threshold) = MODIFICATION_THRESHOLDS[i - 1];
let time_range = current_threshold - prev_threshold;
let time_offset = duration_since - prev_threshold;
let points_diff = prev_points - current_points;
let interpolated_score =
prev_points - (points_diff * time_offset as i64) / time_range as i64;
return interpolated_score;
}
}
@@ -221,4 +243,48 @@ mod tests {
old_score
);
}
#[test]
fn test_modification_score_interpolation() {
let temp_dir = std::env::temp_dir().join("fff_test_interpolation");
let _ = std::fs::remove_dir_all(&temp_dir);
let tracker = FrecencyTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
let current_time = tracker.get_now();
let git_status = Some(git2::Status::WT_MODIFIED);
// At 5 minutes: should interpolate between 16 and 8 points
let five_minutes_ago = current_time - (5 * 60);
let score = tracker.get_modification_score(five_minutes_ago, git_status);
// Expected: 16 - (8 * 3 / 13) = 16 - 1 = 15 points
// (time_offset = 5-2 = 3, time_range = 15-2 = 13, points_diff = 16-8 = 8)
assert_eq!(score, 15, "5 minutes should interpolate to 15 points");
let two_minutes_ago = current_time - (2 * 60);
let score = tracker.get_modification_score(two_minutes_ago, git_status);
assert_eq!(score, 16, "2 minutes should be exactly 16 points");
let fifteen_minutes_ago = current_time - (15 * 60);
let score = tracker.get_modification_score(fifteen_minutes_ago, git_status);
assert_eq!(score, 8, "15 minutes should be exactly 8 points");
// At 12 hours: should interpolate between 4 and 2 points
let twelve_hours_ago = current_time - (12 * 60 * 60);
let score = tracker.get_modification_score(twelve_hours_ago, git_status);
// Expected: 4 - (2 * 11 / 23) = 4 - 0 = 4 points (integer division)
// (time_offset = 12-1 = 11 hours, time_range = 24-1 = 23 hours, points_diff = 4-2 = 2)
assert_eq!(score, 4, "12 hours should interpolate to 4 points");
// at 18 hours for more significant interpolation
let eighteen_hours_ago = current_time - (18 * 60 * 60);
let score = tracker.get_modification_score(eighteen_hours_ago, git_status);
// Expected: 4 - (2 * 17 / 23) = 4 - 1 = 3 points
assert_eq!(score, 3, "18 hours should interpolate to 3 points");
let score = tracker.get_modification_score(five_minutes_ago, None);
assert_eq!(score, 0, "No git status should return 0");
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
+94 -45
View File
@@ -1,67 +1,116 @@
use crate::error::Result;
use git2::{Repository, Status, StatusOptions};
use std::path::{Path, PathBuf};
use tracing::{error, info};
use std::{
fmt::Debug,
path::{Path, PathBuf},
};
use tracing::debug;
/// Represents a cache of a single git status query, if there is no
/// status aka file is clear but it was specifically requested to updated
/// the status is `None` otherwise contains only actual file statuses.
#[derive(Debug, Clone)]
pub struct GitStatusCache {
paths: Vec<PathBuf>,
statuses: Vec<Status>,
pub struct GitStatusCache(Vec<(PathBuf, Status)>);
impl IntoIterator for GitStatusCache {
type Item = (PathBuf, Status);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl GitStatusCache {
fn from_git_entries(mut entries: Vec<(PathBuf, Status)>) -> Self {
entries.sort_by(|a, b| a.0.cmp(&b.0));
let (paths, statuses) = entries.into_iter().unzip();
Self { paths, statuses }
pub fn statuses_len(&self) -> usize {
self.0.len()
}
pub fn lookup_status(&self, full_path: &Path) -> Option<Status> {
match self
.paths
.binary_search_by(|probe| probe.as_path().cmp(full_path))
{
Ok(idx) => self.statuses.get(idx).copied(),
Err(_) => None,
}
self.0
.binary_search_by(|(path, _)| path.as_path().cmp(full_path))
.ok()
.and_then(|idx| self.0.get(idx).map(|(_, status)| *status))
}
pub fn read_git_status(git_workdir: Option<&Path>) -> Option<Self> {
let git_start = std::time::Instant::now();
info!("GIT: Starting git status read");
let mut entries = Vec::new();
let git_workdir = git_workdir.as_ref()?;
let repository = Repository::open(git_workdir).ok()?;
let status_start = std::time::Instant::now();
let statuses = repository
.statuses(Some(&mut StatusOptions::new().include_untracked(true)))
.map_err(|e| {
error!("Failed to get git statuses: {}", e);
e
})
.ok()?;
let status_time = status_start.elapsed();
info!("GIT: Status query completed in {:?}", status_time);
info!(
"GIT STATUS: {} git entries in {}",
statuses.len(),
git_workdir.display()
);
#[tracing::instrument(skip(repo, status_options))]
fn read_status_impl(repo: &Repository, status_options: &mut StatusOptions) -> Result<Self> {
let statuses = repo.statuses(Some(status_options))?;
let Some(repo_path) = repo.workdir() else {
return Ok(Self(vec![])); // repo is bare
};
let mut entries = Vec::with_capacity(statuses.len());
for entry in &statuses {
if let Some(entry_path) = entry.path() {
let full_path = git_workdir.join(entry_path);
let full_path = repo_path.join(entry_path);
entries.push((full_path, entry.status()));
}
}
let total_time = git_start.elapsed();
info!("GIT: Total git status read time {:?}", total_time);
Ok(Self(entries))
}
Some(Self::from_git_entries(entries))
pub fn read_git_status(
git_workdir: Option<&Path>,
status_options: &mut StatusOptions,
) -> Option<Self> {
let git_workdir = git_workdir.as_ref()?;
let repository = Repository::open(git_workdir).ok()?;
let status = Self::read_status_impl(&repository, status_options);
match status {
Ok(status) => Some(status),
Err(e) => {
tracing::error!(?e, "Failed to read git status");
None
}
}
}
#[tracing::instrument(skip(repo), level = tracing::Level::DEBUG)]
pub fn git_status_for_paths<TPath: AsRef<Path> + Debug>(
repo: &Repository,
paths: &[TPath],
) -> Result<Self> {
if paths.is_empty() {
return Ok(Self(vec![]));
}
let Some(workdir) = repo.workdir() else {
return Ok(Self(vec![]));
};
// git pathspec is pretty slow and requires to walk the whole directory
// so for a single file which is the most general use case we query directly the file
if paths.len() == 1 {
let full_path = paths[0].as_ref();
let relative_path = full_path.strip_prefix(workdir)?;
let status = repo.status_file(relative_path)?;
return Ok(Self(vec![(full_path.to_path_buf(), status)]));
}
let mut status_options = StatusOptions::new();
status_options
.include_untracked(true)
.recurse_untracked_dirs(true)
// when reading partial status it's important to include all files requested
.include_unmodified(true);
for path in paths {
status_options.pathspec(path.as_ref().strip_prefix(workdir)?);
}
let git_status_cache = Self::read_status_impl(repo, &mut status_options)?;
debug!(
status_len = git_status_cache.statuses_len(),
"Multiple files git status"
);
Ok(git_status_cache)
}
}
+46 -11
View File
@@ -1,3 +1,5 @@
local download = require('fff.download')
--- @return string
local function get_lib_extension()
if jit.os:lower() == 'mac' or jit.os:lower() == 'osx' then return '.dylib' end
@@ -7,15 +9,48 @@ end
-- search for the lib in the /target/release directory with and without the lib prefix
-- since MSVC doesn't include the prefix
local base_path = debug.getinfo(1).source:match('@?(.*/)')
package.cpath = package.cpath
.. ';'
.. base_path
.. '../../../target/release/lib?'
.. get_lib_extension()
.. ';'
.. base_path
.. '../../../target/release/?'
.. get_lib_extension()
local info = debug.getinfo(1, 'S')
local base_path = info and info.source and info.source:match('@?(.*/)') or ''
return require('fff_nvim')
-- Fallback: if base_path is nil, try to determine from current file path
if not base_path or base_path == '' then
base_path = vim.fn.fnamemodify(vim.fn.resolve(vim.fn.expand('<sfile>:p')), ':h') .. '/'
end
local paths = {
download.get_binary_cpath_component(),
base_path .. '../../../target/release/lib?' .. get_lib_extension(),
base_path .. '../../../target/release/?' .. get_lib_extension(),
}
local cargo_target_dir = os.getenv('CARGO_TARGET_DIR')
if cargo_target_dir then
table.insert(paths, cargo_target_dir .. '/release/lib?' .. get_lib_extension())
table.insert(paths, cargo_target_dir .. '/release/?' .. get_lib_extension())
end
-- Instead of using require (which can find the wrong lib due to cpath pollution),
-- load the library directly from the first valid path we find
local function try_load_library()
for _, path_pattern in ipairs(paths) do
local actual_path = path_pattern:gsub('%?', 'fff_nvim')
local stat = vim.uv.fs_stat(actual_path)
if stat and stat.type == 'file' then
local loader, err = package.loadlib(actual_path, 'luaopen_fff_nvim')
if loader then return loader() end
end
end
return nil, 'No valid library found in any search path'
end
local backend, load_err = try_load_library()
if not backend then
local err_msg = string.format(
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists with `cargo build --release`',
tostring(load_err),
vim.inspect(paths)
)
error(err_msg)
end
return backend
+315 -68
View File
@@ -1,40 +1,74 @@
use crate::error::Error;
use crate::file_key::FileKey;
use crate::file_picker::FilePicker;
use crate::file_picker::{FilePicker, FuzzySearchOptions};
use crate::frecency::FrecencyTracker;
use crate::types::{FileItem, SearchResult};
use crate::query_tracker::QueryTracker;
use crate::types::PaginationArgs;
use mlua::prelude::*;
use std::sync::{LazyLock, RwLock};
use once_cell::sync::Lazy;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::Duration;
mod background_watcher;
mod error;
mod file_key;
mod file_picker;
pub mod file_picker;
mod frecency;
mod git;
pub mod git;
mod location;
mod log;
mod path_utils;
pub(crate) mod score;
mod tracing;
pub(crate) mod types;
pub mod query_tracker;
pub mod score;
mod sort_buffer;
pub mod types;
use mimalloc::MiMalloc;
static FRECENCY: LazyLock<RwLock<Option<FrecencyTracker>>> = LazyLock::new(|| RwLock::new(None));
static FILE_PICKER: LazyLock<RwLock<Option<FilePicker>>> = LazyLock::new(|| RwLock::new(None));
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
pub fn init_db(_: &Lua, (db_path, use_unsafe_no_lock): (String, bool)) -> LuaResult<bool> {
pub static FRECENCY: Lazy<RwLock<Option<FrecencyTracker>>> = Lazy::new(|| RwLock::new(None));
pub static FILE_PICKER: Lazy<RwLock<Option<FilePicker>>> = Lazy::new(|| RwLock::new(None));
pub static QUERY_TRACKER: Lazy<RwLock<Option<QueryTracker>>> = Lazy::new(|| RwLock::new(None));
pub fn init_db(
_: &Lua,
(frecency_db_path, history_db_path, use_unsafe_no_lock): (String, String, bool),
) -> LuaResult<bool> {
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
if frecency.is_some() {
return Ok(false);
*frecency = None;
}
*frecency = Some(FrecencyTracker::new(&db_path, use_unsafe_no_lock)?);
*frecency = Some(FrecencyTracker::new(&frecency_db_path, use_unsafe_no_lock)?);
tracing::info!("Frecency database initialized at {}", frecency_db_path);
let mut query_tracker = QUERY_TRACKER
.write()
.map_err(|_| Error::AcquireFrecencyLock)?;
if query_tracker.is_some() {
*query_tracker = None;
}
let tracker = QueryTracker::new(&history_db_path, use_unsafe_no_lock)?;
*query_tracker = Some(tracker);
tracing::info!("Query tracker database initialized at {}", history_db_path);
Ok(true)
}
pub fn destroy_db(_: &Lua, _: ()) -> LuaResult<bool> {
pub fn destroy_frecency_db(_: &Lua, _: ()) -> LuaResult<bool> {
let mut frecency = FRECENCY.write().map_err(|_| Error::AcquireFrecencyLock)?;
*frecency = None;
Ok(true)
}
pub fn destroy_query_db(_: &Lua, _: ()) -> LuaResult<bool> {
let mut query_tracker = QUERY_TRACKER
.write()
.map_err(|_| Error::AcquireFrecencyLock)?;
*query_tracker = None;
Ok(true)
}
pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
if file_picker.is_some() {
@@ -46,46 +80,150 @@ pub fn init_file_picker(_: &Lua, base_path: String) -> LuaResult<bool> {
Ok(true)
}
fn reinit_file_picker_internal(path: &Path) -> Result<(), Error> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
// drop should clean it anyway but just to be extra sure
if let Some(mut picker) = file_picker.take() {
picker.stop_background_monitor();
}
let new_picker = FilePicker::new(path.to_string_lossy().to_string())?;
*file_picker = Some(new_picker);
Ok(())
}
pub fn restart_index_in_path(_: &Lua, new_path: String) -> LuaResult<()> {
let path = std::path::PathBuf::from(&new_path);
if !path.exists() {
return Err(LuaError::RuntimeError(format!(
"Path does not exist: {}",
new_path
)));
}
let canonical_path = path.canonicalize().map_err(|e| {
LuaError::RuntimeError(format!("Failed to canonicalize path '{}': {}", new_path, e))
})?;
// Spawn a background thread to avoid blocking Lua/UI thread
std::thread::spawn(move || {
if let Err(e) = reinit_file_picker_internal(&canonical_path) {
::tracing::error!(
?e,
?canonical_path,
"Failed to index directory after changing"
);
} else {
::tracing::info!(?canonical_path, "Successfully reindexed directory");
}
});
Ok(())
}
pub fn scan_files(_: &Lua, _: ()) -> LuaResult<()> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
.as_mut()
.ok_or_else(|| Error::FilePickerMissing)?;
picker.trigger_rescan()?;
::tracing::info!("scan_files trigger_rescan completed");
Ok(())
}
pub fn get_cached_files(_: &Lua, _: ()) -> LuaResult<Vec<FileItem>> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
Ok(picker.get_cached_files())
}
#[allow(clippy::type_complexity)]
pub fn fuzzy_search_files(
_: &Lua,
(query, max_results, max_threads, current_file): (String, usize, usize, Option<String>),
) -> LuaResult<SearchResult> {
let time = std::time::Instant::now();
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
::tracing::debug!("Fuzzy search started: {:?}", time.elapsed());
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
lua: &Lua,
(
query,
max_threads,
current_file,
combo_boost_score_multiplier,
min_combo_count,
page_index,
page_size,
): (
String,
usize,
Option<String>,
i32,
Option<u32>,
Option<usize>,
Option<usize>,
),
) -> LuaResult<LuaValue> {
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
let results = picker.fuzzy_search(&query, max_results, max_threads, current_file.as_ref());
Ok(results)
let base_path = picker.base_path();
let min_combo_count = min_combo_count.unwrap_or(3);
let last_same_query_entry = {
let query_tracker = QUERY_TRACKER
.read()
.map_err(|_| Error::AcquireFrecencyLock)?;
if query_tracker.as_ref().is_none() {
tracing::warn!("Query tracker not initialized");
}
query_tracker
.as_ref()
.map(|tracker| tracker.get_last_query_entry(&query, base_path, min_combo_count))
.transpose()?
.flatten()
};
tracing::debug!(
?last_same_query_entry,
?base_path,
?query,
?min_combo_count,
?page_index,
?page_size,
"Fuzzy search parameters"
);
let results = FilePicker::fuzzy_search(
picker.get_files(),
&query,
FuzzySearchOptions {
max_threads,
current_file: current_file.as_deref(),
project_path: Some(picker.base_path()),
last_same_query_match: last_same_query_entry.as_ref(),
combo_boost_score_multiplier,
min_combo_count,
pagination: PaginationArgs {
offset: page_index.unwrap_or(0),
limit: page_size.unwrap_or(0),
},
},
);
results.into_lua(lua)
}
pub fn access_file(_: &Lua, file_path: String) -> LuaResult<bool> {
let frecency = FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)?;
if let Some(ref tracker) = *frecency {
let file_key = FileKey { path: file_path };
tracker.track_access(&file_key)?;
}
pub fn track_access(_: &Lua, file_path: String) -> LuaResult<bool> {
let file_path = PathBuf::from(&file_path);
// Track access in frecency DB (expensive LMDB write, ~100-200ms)
// Do this WITHOUT holding FILE_PICKER lock to avoid blocking searches
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
return Ok(false);
};
frecency.track_access(file_path.as_path())?;
// Quick lock to update single file's frecency score in picker
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
picker.update_single_file_frecency(&file_path, frecency)?;
Ok(true)
}
@@ -93,12 +231,11 @@ pub fn get_scan_progress(lua: &Lua, _: ()) -> LuaResult<LuaValue> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
.ok_or_else(|| Error::FilePickerMissing)?;
let progress = picker.get_scan_progress();
let table = lua.create_table()?;
table.set("total_files", progress.total_files)?;
table.set("scanned_files", progress.scanned_files)?;
table.set("scanned_files_count", progress.scanned_files_count)?;
table.set("is_scanning", progress.is_scanning)?;
Ok(LuaValue::Table(table))
}
@@ -107,69 +244,161 @@ pub fn is_scanning(_: &Lua, _: ()) -> LuaResult<bool> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
.ok_or_else(|| Error::FilePickerMissing)?;
Ok(picker.is_scan_active())
}
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<Vec<FileItem>> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
pub fn refresh_git_status(_: &Lua, _: ()) -> LuaResult<usize> {
FilePicker::refresh_git_status_global().map_err(Into::into)
}
Ok(picker.refresh_git_status())
pub fn update_single_file_frecency(_: &Lua, file_path: String) -> LuaResult<bool> {
let Some(ref frecency) = *FRECENCY.read().map_err(|_| Error::AcquireFrecencyLock)? else {
return Ok(false);
};
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
picker.update_single_file_frecency(&file_path, frecency)?;
Ok(true)
}
pub fn stop_background_monitor(_: &Lua, _: ()) -> LuaResult<bool> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
return Err(Error::FilePickerMissing)?;
};
picker.stop_background_monitor();
Ok(true)
}
pub fn cleanup_file_picker(_: &Lua, _: ()) -> LuaResult<bool> {
let mut file_picker = FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)?;
if let Some(picker) = file_picker.take() {
drop(picker);
::tracing::info!("FilePicker cleanup completed");
Ok(true)
} else {
Ok(false)
}
}
pub fn cancel_scan(_: &Lua, _: ()) -> LuaResult<bool> {
Ok(true)
}
pub fn track_query_completion(_: &Lua, (query, file_path): (String, String)) -> LuaResult<bool> {
// Get the project path before spawning thread
let project_path = {
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
return Ok(false);
};
picker.base_path().to_path_buf()
};
// Canonicalize the file path before spawning thread
let file_path = match PathBuf::from(&file_path).canonicalize() {
Ok(path) => path,
Err(e) => {
tracing::warn!(?file_path, error = ?e, "Failed to canonicalize file path for tracking");
return Ok(false);
}
};
// Spawn background thread to do the actual tracking (expensive DB write)
std::thread::spawn(move || {
if let Ok(Some(tracker)) = QUERY_TRACKER.write().as_deref_mut()
&& let Err(e) = tracker.track_query_completion(&query, &project_path, &file_path)
{
tracing::error!(
query = %query,
file = %file_path.display(),
error = ?e,
"Failed to track query completion"
);
}
});
Ok(true)
}
pub fn get_historical_query(_: &Lua, offset: usize) -> LuaResult<Option<String>> {
let project_path = {
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
return Ok(None);
};
picker.base_path().to_path_buf()
};
let Some(ref tracker) = *QUERY_TRACKER
.read()
.map_err(|_| Error::AcquireFrecencyLock)?
else {
return Ok(None);
};
tracker
.get_historical_query(&project_path, offset)
.map_err(Into::into)
}
pub fn wait_for_initial_scan(_: &Lua, timeout_ms: Option<u64>) -> LuaResult<bool> {
let file_picker = FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)?;
let picker = file_picker
.as_ref()
.ok_or_else(|| Error::InvalidPath("File picker not initialized".to_string()))?;
.ok_or_else(|| Error::FilePickerMissing)?;
let timeout = Duration::from_millis(timeout_ms.unwrap_or(5000)); // Default 5s timeout
let timeout_ms = timeout_ms.unwrap_or(500);
let timeout_duration = Duration::from_millis(timeout_ms);
let start_time = std::time::Instant::now();
let mut sleep_duration = Duration::from_millis(1);
while picker.is_scan_active() && start_time.elapsed() < timeout {
std::thread::sleep(Duration::from_millis(50));
while picker.is_scan_active() {
if start_time.elapsed() >= timeout_duration {
::tracing::warn!("wait_for_initial_scan timed out after {}ms", timeout_ms);
return Ok(false);
}
std::thread::sleep(sleep_duration);
sleep_duration = std::cmp::min(sleep_duration * 2, Duration::from_millis(50));
}
Ok(!picker.is_scan_active())
::tracing::debug!(
"wait_for_initial_scan completed in {:?}",
start_time.elapsed()
);
Ok(true)
}
pub fn init_tracing(
_: &Lua,
(log_file_path, log_level): (String, Option<String>),
) -> LuaResult<String> {
let level = log_level.unwrap_or_else(|| "info".to_string());
crate::tracing::init_tracing(&log_file_path, &level)
crate::log::init_tracing(&log_file_path, log_level.as_deref())
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
}
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
let exports = lua.create_table()?;
exports.set("init_db", lua.create_function(init_db)?)?;
exports.set("destroy_db", lua.create_function(destroy_db)?)?;
exports.set(
"destroy_frecency_db",
lua.create_function(destroy_frecency_db)?,
)?;
exports.set("init_file_picker", lua.create_function(init_file_picker)?)?;
exports.set(
"restart_index_in_path",
lua.create_function(restart_index_in_path)?,
)?;
exports.set("scan_files", lua.create_function(scan_files)?)?;
exports.set("get_cached_files", lua.create_function(get_cached_files)?)?;
exports.set(
"fuzzy_search_files",
lua.create_function(fuzzy_search_files)?,
)?;
exports.set("access_file", lua.create_function(access_file)?)?;
exports.set("track_access", lua.create_function(track_access)?)?;
exports.set("cancel_scan", lua.create_function(cancel_scan)?)?;
exports.set("get_scan_progress", lua.create_function(get_scan_progress)?)?;
exports.set(
@@ -185,11 +414,29 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
"wait_for_initial_scan",
lua.create_function(wait_for_initial_scan)?,
)?;
exports.set(
"cleanup_file_picker",
lua.create_function(cleanup_file_picker)?,
)?;
exports.set("destroy_query_db", lua.create_function(destroy_query_db)?)?;
exports.set(
"track_query_completion",
lua.create_function(track_query_completion)?,
)?;
exports.set(
"get_historical_query",
lua.create_function(get_historical_query)?,
)?;
Ok(exports)
}
// https://github.com/mlua-rs/mlua/issues/318
#[mlua::lua_module(skip_memory_check)]
fn fff_nvim(lua: &Lua) -> LuaResult<LuaTable> {
// Install panic hook IMMEDIATELY on module load
// This ensures any panics are logged even if init_tracing is never called
crate::log::install_panic_hook();
create_exports(lua)
}
+238
View File
@@ -0,0 +1,238 @@
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub enum Location {
Line(i32),
Range { start: (i32, i32), end: (i32, i32) },
Position { line: i32, col: i32 },
}
fn parse_number_pair(location: &str, split_char: char) -> Option<(i32, i32)> {
let mut iter = location.split(split_char);
let start_str = iter.next()?;
let end_str = iter.next()?;
// if there are more than 2 parts it's not the range treat as normal query
if iter.next().is_some() {
return None;
}
let start = start_str.parse::<i32>().ok()?;
let end = end_str.parse::<i32>().ok()?;
Some((start, end))
}
/// Parse "line-line" format
fn parse_simple_range(location: &str) -> Option<Location> {
let (start, end) = parse_number_pair(location, '-')?;
if end < start {
return Some(Location::Line(start));
}
Some(Location::Range {
start: (start, 0),
end: (end, 0),
})
}
/// Parse "line:col-col" format (column range on same line)
fn parse_column_range(start_part: &str, end_part: &str) -> Option<Location> {
let (line_str, start_col_str) = start_part.split_once(':')?;
let line = line_str.parse::<i32>().ok()?;
let start_col = start_col_str.parse::<i32>().ok()?;
let end_col = end_part.parse::<i32>().ok()?;
if end_col < start_col {
return Some(Location::Line(line));
}
Some(Location::Range {
start: (line, start_col),
end: (line, end_col),
})
}
/// Parse "line:col-line:col" format (position range)
fn parse_position_range(start_part: &str, end_part: &str) -> Option<Location> {
let (start_line, start_col) = parse_number_pair(start_part, ':')?;
let (end_line, end_col) = parse_number_pair(end_part, ':')?;
if end_line < start_line || (end_line == start_line && end_col < start_col) {
return Some(Location::Position {
line: start_line,
col: start_col,
});
}
Some(Location::Range {
start: (start_line, start_col),
end: (end_line, end_col),
})
}
/// Try to parse range patterns (contains '-')
fn try_parse_column_range(location: &str) -> Option<Location> {
if !location.contains('-') {
return None;
}
let (start_part, end_part) = location.split_once('-')?;
// Try position range (line:col-line:col)
if start_part.contains(':') && end_part.contains(':') {
return parse_position_range(start_part, end_part);
}
// Try column range (line:col-col)
if start_part.contains(':') {
return parse_column_range(start_part, end_part);
}
// Try simple line range (line-line)
parse_simple_range(location)
}
/// Try to parse position patterns (contains ':' but not '-')
fn try_parse_column_position(location: &str) -> Option<Location> {
if !location.contains(':') {
return None;
}
let (line_str, col_str) = location.split_once(':')?;
let line = line_str.parse::<i32>().ok()?;
let col = col_str.parse::<i32>().ok()?;
Some(Location::Position { line, col })
}
/// Parses various location formats like file:12, file:12:4, file:12-114
fn parse_column_location(query: &str) -> Option<(&str, Location)> {
let (file_path, location_part) = query.split_once(':')?;
if let Some(range_location) = try_parse_column_range(location_part) {
return Some((file_path, range_location));
}
if let Some(position_location) = try_parse_column_position(location_part) {
return Some((file_path, position_location));
}
if let Ok(line_location) = location_part.parse::<i32>() {
return Some((file_path, Location::Line(line_location)));
}
None
}
fn parse_vstudio_location(query: &str) -> Option<(&str, Location)> {
if !query.ends_with(')') {
return None;
}
let (file_path, location_with_paren) = query.rsplit_once('(')?;
let location = location_with_paren.trim_end_matches(')');
if let Ok(line) = location.parse::<i32>() {
return Some((file_path, Location::Line(line)));
}
if let Some((line, col)) = parse_number_pair(location, ',') {
return Some((file_path, Location::Position { line, col }));
}
None
}
pub fn parse_location(query: &str) -> (&str, Option<Location>) {
// simply ignore the last semicolon even if there are no additional location info
let query = query.trim_end_matches([':', '-', '(']);
if let Some((path, location)) = parse_column_location(query) {
return (path, Some(location));
}
if let Some((path, location)) = parse_vstudio_location(query) {
return (path, Some(location));
}
(query, None)
}
#[cfg(test)]
mod tests {
pub use super::*;
#[test]
fn test_location_parsing() {
assert_eq!(
parse_location("new_file:12"),
("new_file", Some(Location::Line(12)))
);
assert_eq!(parse_location("new_file:12ab"), ("new_file:12ab", None));
assert_eq!(parse_location("something"), ("something", None));
assert_eq!(
parse_location("file:12:4"),
("file", Some(Location::Position { line: 12, col: 4 }))
);
assert_eq!(
parse_location("file:12-114"),
(
"file",
Some(Location::Range {
start: (12, 0),
end: (114, 0)
})
)
);
assert_eq!(
parse_location("file:12:4-20"),
(
"file",
Some(Location::Range {
start: (12, 4),
end: (12, 20)
})
)
);
assert_eq!(
parse_location("file:100:4-14:20"),
("file", Some(Location::Position { line: 100, col: 4 }))
);
assert_eq!(
parse_location("file:12:4-14:20"),
(
"file",
Some(Location::Range {
start: (12, 4),
end: (14, 20)
})
)
);
}
#[test]
fn test_vstudio_parsing() {
assert_eq!(
parse_location("file(12)"),
("file", Some(Location::Line(12)))
);
assert_eq!(
parse_location("file(12,4)"),
("file", Some(Location::Position { line: 12, col: 4 }))
);
}
#[test]
fn trimes_end_character() {
assert_eq!(
parse_location("file:12-"),
("file", Some(Location::Line(12)))
);
assert_eq!(parse_location("file:-"), ("file", None));
assert_eq!(parse_location("file("), ("file", None));
}
}
+152
View File
@@ -0,0 +1,152 @@
use crate::error::Error;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
static TRACING_INITIALIZED: std::sync::OnceLock<tracing_appender::non_blocking::WorkerGuard> =
std::sync::OnceLock::new();
static PANIC_HOOK_INSTALLED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
/// Install panic hook that writes to both stderr and a fallback file
/// This is called separately from init_tracing to ensure panics are always logged
pub fn install_panic_hook() {
PANIC_HOOK_INSTALLED.get_or_init(|| {
let default_panic = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
// Always log to tracing (if initialized)
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF.nvim"
);
// Always print to stderr
eprintln!("=== FFF.nvim PANIC ===");
eprintln!("Message: {}", message);
eprintln!("Location: {}", location);
eprintln!("======================");
// Try to write to fallback panic log file
if let Some(cache_dir) = dirs::cache_dir() {
let panic_log = cache_dir.join("fff_nvim_panic.log");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let panic_entry = format!(
"\n[{}] PANIC at {}\nMessage: {}\n",
timestamp, location, message
);
let _ = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&panic_log)
.and_then(|mut f| {
use std::io::Write;
f.write_all(panic_entry.as_bytes())
});
eprintln!("Panic logged to: {}", panic_log.display());
}
default_panic(panic_info);
}));
});
}
/// Initialize tracing with single log file
///
/// # Arguments
/// * `log_file_path` - Full path to the log file
/// * `log_level` - Log level (trace, debug, info, warn, error)
///
/// # Returns
/// * `Result<String, Error>` - Full path to the log file on success
pub fn init_tracing(log_file_path: &str, log_level: Option<&str>) -> Result<String, Error> {
// Install panic hook first (does nothing if already installed)
install_panic_hook();
let log_path = Path::new(log_file_path);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file_appender = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true) // creates a new file on every setup
.open(log_path)?;
let level = match log_level
.as_ref()
.map(|s| s.trim().to_lowercase())
.as_deref()
{
Some("trace") => tracing::Level::TRACE,
Some("debug") => tracing::Level::DEBUG,
Some("info") => tracing::Level::INFO,
Some("warn") => tracing::Level::WARN,
Some("error") => tracing::Level::ERROR,
_ => tracing::Level::INFO,
};
TRACING_INITIALIZED.get_or_init(|| {
let (non_blocking_appender, guard) = non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false)
.with_span_events(FmtSpan::NEW | FmtSpan::CLOSE),
)
.with(
EnvFilter::builder()
.with_default_directive(level.into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF.nvim tracing initialized with log file: {}",
log_path.display()
);
}
guard
});
Ok(log_file_path.to_string())
}
+75 -34
View File
@@ -19,8 +19,14 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s
return 0; // Same directory, no penalty
}
let current_parts: Vec<&str> = current_dir.split('/').filter(|s| !s.is_empty()).collect();
let candidate_parts: Vec<&str> = candidate_dir.split('/').filter(|s| !s.is_empty()).collect();
let current_parts: Vec<&str> = current_dir
.split(std::path::MAIN_SEPARATOR)
.filter(|s| !s.is_empty())
.collect();
let candidate_parts: Vec<&str> = candidate_dir
.split(std::path::MAIN_SEPARATOR)
.filter(|s| !s.is_empty())
.collect();
let common_len = current_parts
.iter()
@@ -29,14 +35,12 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s
.count();
let current_depth_from_common = current_parts.len() - common_len;
let candidate_depth_from_common = candidate_parts.len() - common_len;
let total_distance = current_depth_from_common + candidate_depth_from_common;
if total_distance == 0 {
return 0; // Same path
if current_depth_from_common == 0 {
return 0; // Current file is at the common ancestor level
}
let penalty = -(total_distance as i32 * 2);
let penalty = -(current_depth_from_common as i32);
penalty.max(-20)
}
@@ -44,47 +48,84 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_calculate_distance_penalty() {
assert_eq!(calculate_distance_penalty(None, "/path/to/file.txt"), 0);
#[test]
#[cfg(not(target_family = "windows"))]
fn test_calculate_distance_penalty() {
assert_eq!(
calculate_distance_penalty(None, "examples/user/test/mod.rs"),
0
);
// Same directory
assert_eq!(
calculate_distance_penalty(
Some("/path/to/current/file.txt"),
"/path/to/current/other.txt"
Some("examples/user/test/main.rs"),
"examples/user/test/mod.rs"
),
0
);
//
// One level apart
assert_eq!(
calculate_distance_penalty(
Some("examples/user/test/subdir/file.rs"),
"examples/user/test/mod.rs"
),
-1
);
//
// Different subdirectories (same parent)
assert_eq!(
calculate_distance_penalty(
Some("examples/user/test/dir1/file.rs"),
"examples/user/test/dir2/mod.rs"
),
-1
);
assert_eq!(
calculate_distance_penalty(Some("/path/to/current/file.txt"), "/path/to/file.txt"),
calculate_distance_penalty(
Some("examples/audio-announce/src/lib/audio-announce.rs"),
"examples/audio-announce/src/main.rs"
),
-1
);
assert_eq!(
calculate_distance_penalty(
Some("examples/audio-announce/src/audio-announce.rs"),
"examples/pixel/src/main.rs"
),
-2
);
assert_eq!(
calculate_distance_penalty(
Some("/path/to/current/file.txt"),
"/path/to/other/file.txt"
),
-4
);
// Root level files
assert_eq!(calculate_distance_penalty(Some("main.rs"), "lib.rs"), 0);
}
#[test]
#[cfg(target_family = "windows")]
fn distance_penalty_works_on_windows() {
assert_eq!(
calculate_distance_penalty(
Some("/path/to/current/file.txt"),
"/path/to/another/dir/file.txt"
),
-6
);
assert_eq!(
calculate_distance_penalty(Some("/a/b/c/d/file.txt"), "/x/y/z/w/file.txt"),
-16
);
assert_eq!(
calculate_distance_penalty(Some("/file1.txt"), "/file2.txt"),
calculate_distance_penalty(None, "examples\\user\\test\\mod.rs"),
0
);
// Same directory
assert_eq!(
calculate_distance_penalty(
Some("examples\\user\\test\\main.rs"),
"examples\\user\\test\\mod.rs"
),
0
);
//
// One level apart
assert_eq!(
calculate_distance_penalty(
Some("examples\\user\\test\\subdir\\file.rs"),
"examples\\user\\test\\mod.rs"
),
-1
);
}
}
+334
View File
@@ -0,0 +1,334 @@
use crate::error::Error;
use heed::types::Bytes;
use heed::{Database, Env, EnvOpenOptions};
use heed::{EnvFlags, types::SerdeBincode};
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const MAX_HISTORY_ENTRIES: usize = 128;
/// Simplified QueryFileEntry without redundant fields
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct QueryMatchEntry {
pub file_path: PathBuf, // File that was actually opened
pub open_count: u32, // Number of times opened with this query
pub last_opened: u64, // Unix timestamp
}
/// Entry for query history tracking
#[derive(Debug, Serialize, Deserialize, Clone)]
struct HistoryEntry {
query: String,
timestamp: u64,
}
#[derive(Debug)]
pub struct QueryTracker {
env: Env,
// Database for (project_path, query) -> QueryMatchEntry mappings
query_file_db: Database<Bytes, SerdeBincode<QueryMatchEntry>>,
// Database for project_path -> VecDeque<HistoryEntry> mappings
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
}
impl QueryTracker {
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
let env = unsafe {
let mut opts = EnvOpenOptions::new();
opts.max_dbs(16); // Allow up to 16 databases per environment
if use_unsafe_no_lock {
opts.flags(EnvFlags::NO_LOCK | EnvFlags::NO_SYNC | EnvFlags::NO_META_SYNC);
}
opts.open(db_path).map_err(Error::EnvOpen)?
};
env.clear_stale_readers()
.map_err(Error::DbClearStaleReaders)?;
let mut wtxn = env.write_txn().map_err(Error::DbStartWriteTxn)?;
// Create two named databases
let query_file_db = env
.create_database(&mut wtxn, Some("query_file_associations"))
.map_err(Error::DbCreate)?;
let query_history_db = env
.create_database(&mut wtxn, Some("query_history"))
.map_err(Error::DbCreate)?;
wtxn.commit().map_err(Error::DbCommit)?;
Ok(QueryTracker {
env,
query_file_db,
query_history_db,
})
}
fn get_now(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn create_query_key(project_path: &Path, query: &str) -> Result<[u8; 32], Error> {
let project_str = project_path
.to_str()
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
let mut hasher = blake3::Hasher::default();
hasher.update(project_str.as_bytes());
hasher.update(b"::");
hasher.update(query.as_bytes());
Ok(*hasher.finalize().as_bytes())
}
fn create_project_key(project_path: &Path) -> Result<[u8; 32], Error> {
let project_str = project_path
.to_str()
.ok_or_else(|| Error::InvalidPath(project_path.to_path_buf()))?;
Ok(*blake3::hash(project_str.as_bytes()).as_bytes())
}
pub fn track_query_completion(
&mut self,
query: &str,
project_path: &Path,
file_path: &Path,
) -> Result<(), Error> {
let now = self.get_now();
let file_path_buf = file_path.to_path_buf();
let query_key = Self::create_query_key(project_path, query)?;
let mut wtxn = self.env.write_txn().map_err(Error::DbStartWriteTxn)?;
let mut entry = self
.query_file_db
.get(&wtxn, &query_key)
.map_err(Error::DbRead)?
.unwrap_or_else(|| QueryMatchEntry {
file_path: file_path_buf.clone(),
open_count: 0,
last_opened: now,
});
if entry.file_path == file_path_buf {
tracing::debug!(
?query,
?file_path,
"Query completed for same file as last time"
);
// Same file - just increment count
entry.open_count += 1;
} else {
tracing::debug!(
?query,
?file_path,
"Query completed for different file than last time"
);
// Different file - replace and reset count to 1
entry.file_path = file_path_buf;
entry.open_count = 1;
}
entry.last_opened = now;
self.query_file_db
.put(&mut wtxn, &query_key, &entry)
.map_err(Error::DbWrite)?;
// Update query history database
let project_key = Self::create_project_key(project_path)?;
let mut history = self
.query_history_db
.get(&wtxn, &project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
let history_entry = HistoryEntry {
query: query.to_string(),
timestamp: now,
};
history.push_back(history_entry);
while history.len() > MAX_HISTORY_ENTRIES {
history.pop_front();
}
self.query_history_db
.put(&mut wtxn, &project_key, &history)
.map_err(Error::DbWrite)?;
wtxn.commit().map_err(Error::DbCommit)?;
tracing::debug!(?query, ?file_path, "Tracked query completion");
Ok(())
}
pub fn get_last_query_entry(
&self,
query: &str,
project_path: &Path,
min_combo_count: u32,
) -> Result<Option<QueryMatchEntry>, Error> {
let query_key = Self::create_query_key(project_path, query)?;
tracing::debug!(?query_key, "HASH");
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let last_match = self
.query_file_db
.get(&rtxn, &query_key)
.map_err(Error::DbRead)?;
Ok(last_match.filter(|entry| entry.open_count >= min_combo_count))
}
pub fn get_last_query_path(
&self,
query: &str,
project_path: &Path,
file_path: &Path,
combo_boost: i32,
) -> Result<i32, Error> {
let query_key = Self::create_query_key(project_path, query)?;
tracing::debug!(?query_key, "HASH");
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
match self
.query_file_db
.get(&rtxn, &query_key)
.map_err(Error::DbRead)?
{
Some(entry) => {
// Check if the file path matches and return boost
if entry.file_path == file_path && entry.open_count >= 2 {
Ok(combo_boost)
} else {
Ok(0)
}
}
None => Ok(0), // Query not found
}
}
/// Get query from history at a specific offset
/// offset=0 returns most recent query, offset=1 returns 2nd most recent, etc.
/// Returns None if offset exceeds history length
pub fn get_historical_query(
&self,
project_path: &Path,
offset: usize,
) -> Result<Option<String>, Error> {
let project_key = Self::create_project_key(project_path)?;
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
let mut history = self
.query_history_db
.get(&rtxn, &project_key)
.map_err(Error::DbRead)?
.unwrap_or_default();
// history is FIFO, last element is most recent
if history.len() > offset {
let index = history.len() - 1 - offset;
let record = history.remove(index);
Ok(record.map(|r| r.query))
} else {
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_query_tracking() {
let temp_dir = env::temp_dir().join("fff_test_query_tracking_new");
let _ = std::fs::remove_dir_all(&temp_dir);
let mut tracker = QueryTracker::new(temp_dir.to_str().unwrap(), true).unwrap();
let project_path = PathBuf::from("/test/project");
let file_path = PathBuf::from("/test/project/src/main.rs");
// First completion
tracker
.track_query_completion("main", &project_path, &file_path)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 0, "First completion should not boost");
// Second completion - should boost now
tracker
.track_query_completion("main", &project_path, &file_path)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 10000, "Second completion should boost");
// Different file for same query - should reset count and no boost
let other_file = PathBuf::from("/test/project/src/lib.rs");
tracker
.track_query_completion("main", &project_path, &other_file)
.unwrap();
let boost = tracker
.get_last_query_path("main", &project_path, &other_file, 10000)
.unwrap();
assert_eq!(boost, 0, "Different file should reset boost");
// Original file should no longer get boost (replaced by new file)
let boost = tracker
.get_last_query_path("main", &project_path, &file_path, 10000)
.unwrap();
assert_eq!(boost, 0, "Original file should not boost after replacement");
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_hashing_functions() {
let project_path = PathBuf::from("/test/project");
// Test project key hashing
let key1 = QueryTracker::create_project_key(&project_path).unwrap();
let key2 = QueryTracker::create_project_key(&project_path).unwrap();
assert_eq!(key1, key2, "Same project should hash to same key");
// Test query key hashing
let query_key1 = QueryTracker::create_query_key(&project_path, "test").unwrap();
let query_key2 = QueryTracker::create_query_key(&project_path, "test").unwrap();
assert_eq!(
query_key1, query_key2,
"Same project+query should hash to same key"
);
// Different queries should hash differently
let query_key3 = QueryTracker::create_query_key(&project_path, "different").unwrap();
assert_ne!(
query_key1, query_key3,
"Different queries should hash to different keys"
);
// Different projects should hash differently
let other_project = PathBuf::from("/other/project");
let query_key4 = QueryTracker::create_query_key(&other_project, "test").unwrap();
assert_ne!(
query_key1, query_key4,
"Different projects should hash to different keys"
);
}
}
+387 -65
View File
@@ -1,33 +1,48 @@
use crate::{
git::is_modified_status,
path_utils::calculate_distance_penalty,
sort_buffer::{sort_by_key_with_buffer, sort_with_buffer},
types::{FileItem, Score, ScoringContext},
};
use neo_frizbee::Scoring;
use rayon::prelude::*;
use std::path::MAIN_SEPARATOR;
pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Vec<(usize, Score)> {
pub fn match_and_score_files<'a>(
files: &'a [FileItem],
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
if context.query.len() < 2 {
return score_all_by_frecency(files, context);
}
if files.is_empty() {
return Vec::new();
return (vec![], vec![], 0);
}
let options = neo_frizbee::Options {
let has_uppercase_letter = context.query.chars().any(|c| c.is_uppercase());
let options = neo_frizbee::Config {
prefilter: true,
max_typos: Some(context.max_typos),
sort: false,
scoring: Scoring {
capitalization_bonus: if has_uppercase_letter { 8 } else { 0 },
matching_case_bonus: if has_uppercase_letter { 4 } else { 0 },
..Default::default()
},
};
let haystack: Vec<&str> = files.iter().map(|f| f.relative_path.as_str()).collect();
let query_contains_path_separator = context.query.contains(MAIN_SEPARATOR);
let haystack: Vec<&str> = files
.iter()
.map(|f| f.relative_path_lower.as_str())
.collect();
tracing::debug!(
"Starting fuzzy search for query '{}' in {} files",
context.query,
haystack.len()
);
let path_matches =
neo_frizbee::match_list_parallel(context.query, &haystack, options, context.max_threads);
let path_matches = neo_frizbee::match_list(context.query, &haystack, &options);
tracing::debug!(
"Matched {} files for query '{}'",
path_matches.len(),
@@ -37,42 +52,60 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
// assume that filename should only match if the path matches
// we should actually incorporate this bonus by getting this information from neo_frizbee directly
// instead of spawning a separate matching process, but it's okay for the beta
let haystack_of_filenames = path_matches
.par_iter()
// Use sequential iteration - this is a simple filtering operation that's faster without Rayon overhead
let haystack_of_filenames: Vec<&str> = path_matches
.iter()
.filter_map(|m| {
files
.get(m.index_in_haystack as usize)
.map(|f| f.file_name.as_str())
.get(m.index as usize)
.map(|f| f.file_name_lower.as_str())
})
.collect::<Vec<_>>();
.collect();
let mut filename_matches = neo_frizbee::match_list_parallel(
context.query,
&haystack_of_filenames,
options,
context.max_threads,
);
// if there is a / in the query we don't even match filenames
let filename_matches = if query_contains_path_separator {
vec![]
} else {
// Use parallel matching only if we have enough filenames to justify overhead
// Sequential matching is faster for small result sets (< 1000 matches)
let mut list = if haystack_of_filenames.len() > 1000 {
neo_frizbee::match_list_parallel(
context.query,
&haystack_of_filenames,
&options,
context.max_threads,
)
} else {
neo_frizbee::match_list(context.query, &haystack_of_filenames, &options)
};
// Sequential sort is faster for small lists
if list.len() > 1000 {
list.par_sort_unstable_by_key(|m| m.index);
} else {
sort_by_key_with_buffer(&mut list, |m| m.index);
}
list
};
filename_matches.par_sort_by_key(|m| m.index_in_haystack);
let mut next_filename_match_index = 0;
let mut results: Vec<_> = path_matches
let results: Vec<_> = path_matches
.into_iter()
.enumerate()
.map(|(index, neo_frizbee_match)| {
let file_idx = neo_frizbee_match.index_in_haystack as usize;
.map(|(index, path_match)| {
let file_idx = path_match.index as usize;
let file = &files[file_idx];
let base_score = neo_frizbee_match.score as i32;
let mut base_score = path_match.score as i32;
let frecency_boost = base_score.saturating_mul(file.total_frecency_score as i32) / 100;
let distance_penalty = calculate_distance_penalty(
context.current_file.map(|s| s.as_str()),
&file.relative_path,
);
let distance_penalty =
calculate_distance_penalty(context.current_file, &file.relative_path);
let filename_match = filename_matches
.get(next_filename_match_index)
.and_then(|m| {
if m.index_in_haystack == index as u32 {
if m.index == index as u32 {
next_filename_match_index += 1;
Some(m)
} else {
@@ -80,31 +113,67 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
}
});
tracing::debug!(filename_match = ?filename_match, "Filename match for file {}", index);
let mut has_special_filename_bonus = false;
let filename_bonus = match filename_match {
Some(filename_match) if filename_match.exact => {
base_score / 5 * 2 // 40% bonus for exact filename match
filename_match.score as i32 / 5 * 2 // 40% bonus for exact filename match
}
Some(_) => base_score / 5, // 20% bonus for fuzzy filename match
// 16% bonus for fuzzy filename match but only if the score of matched path is
// equal or greater than the score of matched filename, thus we are not allowing
// typoed filename to score higher than the path match
Some(filename_match)
if filename_match.score >= path_match.score
&& !query_contains_path_separator =>
{
base_score = filename_match.score as i32;
(base_score / 6)
// for large queries around ~300 score the bonus is too big
// it might lead to situations when much more fitting path with a larger
// base score getting filtered out by combination of score + filename bonus
// so we cap it at 10% of the roughly largest score you can get
.min(30)
}
// 5% bonus for special file but not as much as file name to avoid sitatuions
// when you have /user_service/server.rs and /user_service/server/mod.rs
None if is_special_entry_point_file(&file.file_name) => {
// 18% bonus special filename just as much as exact path
// but a little bit less to give preference to the actual file if present
has_special_filename_bonus = true;
base_score * 18 / 100
base_score * 5 / 100
}
_ => 0,
};
let current_file_penalty = calculate_current_file_penalty(file, base_score, context);
let combo_match_boost = {
let last_same_query_match = context
.last_same_query_match
.filter(|m| m.file_path.as_os_str() == file.path.as_os_str());
match last_same_query_match {
// if we request a combo match without a boost we have to render it anyway
Some(_) if context.min_combo_count == 0 => 1000,
Some(combo_match) if combo_match.open_count >= context.min_combo_count => {
combo_match.open_count as i32 * context.combo_boost_score_multiplier
}
// until we hit the combo count threshold, we add a smaller boost because it
// makes sense and makes the search more efficient
Some(combo_match) => combo_match.open_count as i32 * 5,
_ => 0,
}
None => 0,
};
let total = base_score
.saturating_add(frecency_boost)
.saturating_add(distance_penalty)
.saturating_add(filename_bonus);
.saturating_add(filename_bonus)
.saturating_add(current_file_penalty)
.saturating_add(combo_match_boost);
let score = Score {
total,
base_score,
current_file_penalty,
filename_bonus,
special_filename_bonus: if has_special_filename_bonus {
filename_bonus
@@ -113,6 +182,8 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
},
frecency_boost,
distance_penalty,
combo_match_boost,
exact_match: path_match.exact || filename_match.is_some_and(|m| m.exact),
match_type: match filename_match {
Some(filename_match) if filename_match.exact => "exact_filename",
Some(_) => "fuzzy_filename",
@@ -120,13 +191,11 @@ pub fn match_and_score_files(files: &[FileItem], context: &ScoringContext) -> Ve
},
};
(file_idx, score)
(file, score)
})
.collect();
results.par_sort_by(|a, b| b.1.total.cmp(&a.1.total));
results
sort_and_paginate(results, context)
}
/// Check if a filename is a special entry point file that deserves bonus scoring
@@ -154,50 +223,303 @@ fn is_special_entry_point_file(filename: &str) -> bool {
)
}
fn score_all_by_frecency(files: &[FileItem], context: &ScoringContext) -> Vec<(usize, Score)> {
files
fn score_all_by_frecency<'a>(
files: &'a [FileItem],
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let results: Vec<_> = files
.par_iter()
.enumerate()
.map(|(idx, file)| {
.map(|file| {
let total_frecency_score = file.access_frecency_score as i32
+ (file.modification_frecency_score as i32).saturating_mul(4);
let distance_penalty = calculate_distance_penalty(
context.current_file.map(|x| x.as_str()),
&file.relative_path,
);
let total = total_frecency_score
.saturating_add(distance_penalty)
.saturating_add(calculate_file_bonus(file, context));
let current_file_penalty =
calculate_current_file_penalty(file, total_frecency_score, context);
let total = total_frecency_score.saturating_add(current_file_penalty);
let score = Score {
total,
base_score: 0,
filename_bonus: 0,
distance_penalty: 0,
special_filename_bonus: 0,
combo_match_boost: 0,
current_file_penalty,
frecency_boost: total_frecency_score,
distance_penalty,
exact_match: false,
match_type: "frecency",
};
(idx, score)
(file, score)
})
.collect()
.collect();
sort_and_paginate(results, context)
}
#[inline]
fn calculate_file_bonus(file: &FileItem, context: &ScoringContext) -> i32 {
let mut bonus = 0i32;
fn calculate_current_file_penalty(
file: &FileItem,
base_score: i32,
context: &ScoringContext,
) -> i32 {
let mut penalty = 0i32;
if let Some(current) = context.current_file {
if file.relative_path == *current {
bonus -= match file.git_status {
Some(status) if is_modified_status(status) => 150,
_ => 300,
};
}
if let Some(current) = context.current_file
&& file.relative_path.as_str() == current
{
penalty -= match file.git_status {
Some(status) if is_modified_status(status) => base_score / 2,
_ => base_score,
};
}
bonus
penalty
}
/// Sorts elements by total score (descending) and returns the requested page.
/// Always returns results in descending order (best scores first).
/// The UI layer handles rendering order based on prompt position.
#[tracing::instrument(skip_all, level = tracing::Level::DEBUG)]
fn sort_and_paginate<'a>(
mut results: Vec<(&'a FileItem, Score)>,
context: &ScoringContext,
) -> (Vec<&'a FileItem>, Vec<Score>, usize) {
let total_matched = results.len();
if total_matched == 0 {
return (vec![], vec![], 0);
}
let offset = context.pagination.offset;
let limit = if context.pagination.limit > 0 {
context.pagination.limit
} else {
total_matched
};
// Check if offset is out of bounds
if offset >= total_matched {
tracing::warn!(
offset = offset,
total_matched = total_matched,
"Pagination: offset >= total_matched, returning empty"
);
return (vec![], vec![], total_matched);
}
let items_needed = offset.saturating_add(limit).min(total_matched);
// Use partial sort if we need less than half the results and dataset is large
let use_partial_sort = items_needed < total_matched / 2 && total_matched > 100;
// Always sort in descending order (best scores first)
if use_partial_sort {
// Partition at position (items_needed - 1) with descending comparator
// This puts the highest N needed items at the front
results.select_nth_unstable_by(items_needed - 1, |a, b| {
b.1.total
.cmp(&a.1.total)
.then_with(|| b.0.modified.cmp(&a.0.modified))
});
results.truncate(items_needed);
}
// select nth does not sort the results, we have to sort accordingly anyway
sort_with_buffer(&mut results, |a, b| {
b.1.total
.cmp(&a.1.total)
.then_with(|| b.0.modified.cmp(&a.0.modified))
});
// in the best scenario truncation happened in the select_nth step
if results.len() > limit {
let page_end = std::cmp::min(offset + limit, results.len());
let page_size = page_end - offset;
results.drain(0..offset);
results.truncate(page_size);
}
let (items, scores): (Vec<&FileItem>, Vec<Score>) = results.into_iter().unzip();
(items, scores, total_matched)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::PaginationArgs;
use std::path::PathBuf;
fn create_test_file(path: &str, score: i32, modified: u64) -> (FileItem, Score) {
let file = FileItem {
path: PathBuf::from(path),
relative_path: path.to_string(),
relative_path_lower: path.to_lowercase(),
file_name: path.split('/').last().unwrap_or(path).to_string(),
file_name_lower: path.split('/').last().unwrap_or(path).to_lowercase(),
size: 0,
modified,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status: None,
};
let score_obj = Score {
total: score,
base_score: score,
filename_bonus: 0,
distance_penalty: 0,
special_filename_bonus: 0,
current_file_penalty: 0,
frecency_boost: 0,
exact_match: false,
match_type: "test",
combo_match_boost: 0,
};
(file, score_obj)
}
#[test]
fn test_partial_sort_descending() {
// Create test data with known scores
let test_data = vec![
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
create_test_file("file4.rs", 300, 4000),
create_test_file("file5.rs", 150, 5000),
create_test_file("file6.rs", 250, 6000),
create_test_file("file7.rs", 80, 7000),
create_test_file("file8.rs", 180, 8000),
create_test_file("file9.rs", 120, 9000),
create_test_file("file10.rs", 90, 10000),
];
// Convert to references like the actual function uses
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let context = ScoringContext {
query: "test",
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
// Test with full sort - returns all results sorted descending
let (items, scores, total) = sort_and_paginate(results.clone(), &context);
// Should return all 10 items sorted by score descending
assert_eq!(total, 10);
assert_eq!(scores.len(), 10);
assert_eq!(scores[0].total, 300, "First should be highest score");
assert_eq!(scores[1].total, 250, "Second should be second highest");
assert_eq!(scores[2].total, 200, "Third should be third highest");
// Verify the files match
assert_eq!(items[0].relative_path, "file4.rs");
assert_eq!(items[1].relative_path, "file6.rs");
assert_eq!(items[2].relative_path, "file2.rs");
}
#[test]
fn test_partial_sort_with_same_scores() {
// Test tiebreaker with modified time
let test_data = vec![
create_test_file("file1.rs", 100, 5000), // Same score, older
create_test_file("file2.rs", 100, 8000), // Same score, newer
create_test_file("file3.rs", 100, 3000), // Same score, oldest
create_test_file("file4.rs", 200, 1000),
create_test_file("file5.rs", 200, 9000), // Higher score, newest
];
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let context = ScoringContext {
query: "test",
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
let (items, scores, _) = sort_and_paginate(results, &context);
// Should return all 5 items sorted: 200(9000), 200(1000), 100(8000), 100(5000), 100(3000)
assert_eq!(scores.len(), 5);
assert_eq!(scores[0].total, 200);
assert_eq!(items[0].modified, 9000, "First 200 should be newest");
assert_eq!(scores[1].total, 200);
assert_eq!(items[1].modified, 1000, "Second 200 should be older");
assert_eq!(scores[2].total, 100);
assert_eq!(items[2].modified, 8000, "First 100 should be newest");
assert_eq!(scores[3].total, 100);
assert_eq!(items[3].modified, 5000);
assert_eq!(scores[4].total, 100);
assert_eq!(items[4].modified, 3000, "Last 100 should be oldest");
}
#[test]
fn test_no_partial_sort_for_small_results() {
// When results.len() <= threshold, should use regular sort
let test_data = vec![
create_test_file("file1.rs", 100, 1000),
create_test_file("file2.rs", 200, 2000),
create_test_file("file3.rs", 50, 3000),
];
let results: Vec<(&FileItem, Score)> = test_data
.iter()
.map(|(file, score)| (file, score.clone()))
.collect();
let context = ScoringContext {
query: "test",
max_threads: 1,
max_typos: 2,
current_file: None,
last_same_query_match: None,
project_path: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: PaginationArgs {
offset: 0,
limit: 0,
},
};
// Returns all results sorted descending
let (items, scores, _) = sort_and_paginate(results, &context);
assert_eq!(scores.len(), 3);
assert_eq!(scores[0].total, 200);
assert_eq!(scores[1].total, 100);
assert_eq!(scores[2].total, 50);
assert_eq!(items[0].relative_path, "file2.rs");
assert_eq!(items[1].relative_path, "file1.rs");
assert_eq!(items[2].relative_path, "file3.rs");
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Thread-local sort buffer management for glidesort optimization
//!
//! This module provides thread-local buffers for glidesort's with_buffer API,
//! eliminating allocations in the hot path of fuzzy search operations.
use std::cell::RefCell;
use std::mem::MaybeUninit;
// glidesort requires a buffer to allocate, we use one reused buffer as it can grow pretty big
// for a large projects, this effectively saves 12kb of allocation on every search in linux repo
thread_local! {
static SORT_BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(1024));
}
pub fn sort_with_buffer<T, F>(slice: &mut [T], compare: F)
where
F: FnMut(&T, &T) -> std::cmp::Ordering,
{
SORT_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
// Calculate required buffer size in u8 units
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
let size_of_usize = std::mem::size_of::<u8>();
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
// Ensure buffer has enough capacity
if buffer.len() < required_usizes {
buffer.resize(required_usizes, 0);
}
// Cast u8 buffer to MaybeUninit<T> slice
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
// the buffer is large enough
let typed_buffer = unsafe {
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
};
glidesort::sort_with_buffer_by(slice, typed_buffer, compare);
});
}
pub fn sort_by_key_with_buffer<T, K, F>(slice: &mut [T], key_fn: F)
where
K: Ord,
F: FnMut(&T) -> K,
{
SORT_BUFFER.with(|buffer| {
let mut buffer = buffer.borrow_mut();
// Calculate required buffer size in u8 units
let size_of_t = std::mem::size_of::<MaybeUninit<T>>();
let size_of_usize = std::mem::size_of::<u8>();
let required_usizes = (slice.len() * size_of_t).div_ceil(size_of_usize);
// Ensure buffer has enough capacity
if buffer.len() < required_usizes {
buffer.resize(required_usizes, 0);
}
// Cast u8 buffer to MaybeUninit<T> slice
// SAFETY: u8 provides sufficient alignment for most types, and we've ensured
// the buffer is large enough
let typed_buffer = unsafe {
std::slice::from_raw_parts_mut(buffer.as_mut_ptr() as *mut MaybeUninit<T>, slice.len())
};
glidesort::sort_with_buffer_by_key(slice, typed_buffer, key_fn);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sort_with_buffer() {
let mut data = vec![5, 2, 8, 1, 9];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 2, 5, 8, 9]);
}
#[test]
fn test_sort_by_key_with_buffer() {
let mut data = vec![(2, "b"), (1, "a"), (3, "c")];
sort_by_key_with_buffer(&mut data, |item| item.0);
assert_eq!(data, vec![(1, "a"), (2, "b"), (3, "c")]);
}
#[test]
fn test_reverse_sort() {
let mut data = vec![1, 2, 3, 4, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![5, 4, 3, 2, 1]);
}
#[test]
fn test_multiple_sorts_reuse_buffer() {
// This test verifies that multiple sorts on the same thread reuse the buffer
let mut data1 = vec![5, 2, 8, 1, 9];
sort_with_buffer(&mut data1, |a, b| a.cmp(b));
let mut data2 = vec![15, 12, 18, 11, 19];
sort_with_buffer(&mut data2, |a, b| a.cmp(b));
assert_eq!(data1, vec![1, 2, 5, 8, 9]);
assert_eq!(data2, vec![11, 12, 15, 18, 19]);
}
#[test]
fn test_empty_slice() {
let mut data: Vec<i32> = vec![];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, Vec::<i32>::new());
}
#[test]
fn test_single_element() {
let mut data = vec![42];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![42]);
}
#[test]
fn test_already_sorted() {
let mut data = vec![1, 2, 3, 4, 5];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 2, 3, 4, 5]);
}
#[test]
fn test_with_duplicates() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| a.cmp(b));
assert_eq!(data, vec![1, 1, 2, 3, 4, 5, 5, 6, 9]);
}
#[test]
fn test_descending_order() {
let mut data = vec![3, 1, 4, 1, 5, 9, 2, 6, 5];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data, vec![9, 6, 5, 5, 4, 3, 2, 1, 1]);
}
#[test]
fn test_simple_descending() {
// Simple test to verify highest scores come first
let mut data = vec![100, 300, 200];
sort_with_buffer(&mut data, |a, b| b.cmp(a));
assert_eq!(data[0], 300, "Highest should be first");
assert_eq!(data[1], 200, "Middle should be second");
assert_eq!(data[2], 100, "Lowest should be last");
}
}
-109
View File
@@ -1,109 +0,0 @@
use crate::error::Error;
use std::path::Path;
use tracing_appender::non_blocking;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
static TRACING_INITIALIZED: std::sync::Once = std::sync::Once::new();
/// Initialize tracing with single log file
///
/// # Arguments
/// * `log_file_path` - Full path to the log file
/// * `log_level` - Log level (trace, debug, info, warn, error)
///
/// # Returns
/// * `Result<String, Error>` - Full path to the log file on success
pub fn init_tracing(log_file_path: &str, log_level: &str) -> Result<String, Error> {
let log_path = Path::new(log_file_path);
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
Error::InvalidPath(format!(
"Failed to create log directory {}: {}",
parent.display(),
e
))
})?;
}
let log_file_path_clone = log_file_path.to_string();
// Parse log level, default to INFO if invalid
let level = match log_level.to_lowercase().as_str() {
"trace" => tracing::Level::TRACE,
"debug" => tracing::Level::DEBUG,
"info" => tracing::Level::INFO,
"warn" => tracing::Level::WARN,
"error" => tracing::Level::ERROR,
_ => {
eprintln!("Invalid log level '{}', defaulting to 'info'", log_level);
tracing::Level::INFO
}
};
TRACING_INITIALIZED.call_once(|| {
let file_appender = std::fs::File::create(&log_file_path_clone).unwrap();
let (non_blocking_appender, _guard) = non_blocking(file_appender);
let subscriber = tracing_subscriber::registry()
.with(
fmt::layer()
.with_writer(non_blocking_appender)
.with_target(true)
.with_thread_ids(false)
.with_thread_names(false)
.with_file(true)
.with_line_number(true)
.with_ansi(false), // No ANSI colors in log files
)
.with(
EnvFilter::builder()
.with_default_directive(level.into())
.from_env_lossy(),
);
if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
eprintln!("Failed to set tracing subscriber: {}", e);
} else {
tracing::info!(
"FFF.nvim tracing initialized with log file: {}",
log_file_path_clone
);
}
std::panic::set_hook(Box::new(|panic_info| {
let payload = panic_info.payload();
let message = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"Unknown panic payload".to_string()
};
let location = if let Some(location) = panic_info.location() {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
} else {
"unknown location".to_string()
};
tracing::error!(
panic.message = %message,
panic.location = %location,
"PANIC occurred in FFF.nvim"
);
eprintln!("FFF.nvim PANIC: {} at {}", message, location);
}));
// keep the guard alive by leaking it's okaaaaaaaaaaaay
std::mem::forget(_guard);
});
Ok(log_file_path.to_string())
}
+61 -15
View File
@@ -1,22 +1,21 @@
use mlua::prelude::*;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use crate::git::format_git_status;
use crate::{git::format_git_status, location::Location, query_tracker::QueryMatchEntry};
#[derive(Debug, Clone)]
pub struct FileItem {
pub path: PathBuf,
pub relative_path: String,
pub relative_path_lower: String,
pub file_name: String,
pub extension: String,
pub directory: String,
pub file_name_lower: String,
pub size: u64,
pub modified: u64,
pub access_frecency_score: i64,
pub modification_frecency_score: i64,
pub total_frecency_score: i64,
pub git_status: Option<git2::Status>,
pub is_current_file: bool,
}
#[derive(Debug, Clone)]
@@ -27,33 +26,46 @@ pub struct Score {
pub special_filename_bonus: i32,
pub frecency_boost: i32,
pub distance_penalty: i32,
pub current_file_penalty: i32,
pub combo_match_boost: i32,
pub exact_match: bool,
pub match_type: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct PaginationArgs {
pub offset: usize,
pub limit: usize,
}
#[derive(Debug, Clone)]
pub struct ScoringContext<'a> {
pub query: &'a str,
pub current_file: Option<&'a String>,
pub project_path: Option<&'a Path>,
pub current_file: Option<&'a str>,
pub max_typos: u16,
pub max_threads: usize,
pub last_same_query_match: Option<&'a QueryMatchEntry>,
pub combo_boost_score_multiplier: i32,
pub min_combo_count: u32,
pub pagination: PaginationArgs,
}
#[derive(Debug, Clone, Default)]
pub struct SearchResult {
pub items: Vec<FileItem>,
pub struct SearchResult<'a> {
pub items: Vec<&'a FileItem>,
pub scores: Vec<Score>,
pub total_matched: usize,
pub total_files: usize,
pub location: Option<Location>,
}
impl IntoLua for FileItem {
impl IntoLua for &FileItem {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("path", self.path.to_string_lossy().to_string())?;
table.set("relative_path", self.relative_path)?;
table.set("name", self.file_name)?;
table.set("extension", self.extension)?;
table.set("directory", self.directory)?;
table.set("relative_path", self.relative_path.clone())?;
table.set("name", self.file_name.clone())?;
table.set("size", self.size)?;
table.set("modified", self.modified)?;
table.set("access_frecency_score", self.access_frecency_score)?;
@@ -63,7 +75,6 @@ impl IntoLua for FileItem {
)?;
table.set("total_frecency_score", self.total_frecency_score)?;
table.set("git_status", format_git_status(self.git_status))?;
table.set("is_current_file", self.is_current_file)?;
Ok(LuaValue::Table(table))
}
}
@@ -77,18 +88,53 @@ impl IntoLua for Score {
table.set("special_filename_bonus", self.special_filename_bonus)?;
table.set("frecency_boost", self.frecency_boost)?;
table.set("distance_penalty", self.distance_penalty)?;
table.set("current_file_penalty", self.current_file_penalty)?;
table.set("combo_match_boost", self.combo_match_boost)?;
table.set("match_type", self.match_type)?;
table.set("exact_match", self.exact_match)?;
Ok(LuaValue::Table(table))
}
}
impl IntoLua for SearchResult {
struct LuaPosition((i32, i32));
impl IntoLua for LuaPosition {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("line", self.0.0)?;
table.set("col", self.0.1)?;
Ok(LuaValue::Table(table))
}
}
impl IntoLua for SearchResult<'_> {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("items", self.items)?;
table.set("scores", self.scores)?;
table.set("total_matched", self.total_matched)?;
table.set("total_files", self.total_files)?;
if let Some(location) = &self.location {
let location_table = lua.create_table()?;
match location {
Location::Line(line) => {
location_table.set("line", *line)?;
}
Location::Position { line, col } => {
location_table.set("line", *line)?;
location_table.set("col", *col)?;
}
Location::Range { start, end } => {
location_table.set("start", LuaPosition(*start))?;
location_table.set("end", LuaPosition(*end))?;
}
}
table.set("location", location_table)?;
}
Ok(LuaValue::Table(table))
}
}
+104
View File
@@ -0,0 +1,104 @@
--- Scrollbar module for pagination indicator
local M = {}
-- Internal state
local scrollbar_state = {
win = nil,
buf = nil,
ever_shown = false,
}
local ns_id = vim.api.nvim_create_namespace('fff_scrollbar')
--- Render the scrollbar to show current page position
--- Creates the window lazily if needed
--- @param layout table Layout info with list_col, list_row, list_width, list_height, show_scrollbar
--- @param config table Config with hl (highlight groups)
--- @param list_win number List window handle
--- @param pagination table Pagination state with page_index, page_size, total_matched
function M.render(layout, config, list_win, pagination)
if layout.show_scrollbar == false then return end
-- this is the most often path, we don't want to show scrollbar if use doesn't scrolling
if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end
local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1
local has_multiple_pages = total_pages > 1
local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win)
-- If only one page, hide existing scrollbar and return
if not has_multiple_pages then
if scrollbar_exists then pcall(vim.api.nvim_win_hide, scrollbar_state.win) end
return
end
-- rendering in a separate buffer to overflow the border
if not scrollbar_exists then
scrollbar_state.buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(scrollbar_state.buf, 'bufhidden', 'wipe')
scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, {
relative = 'editor',
width = 1,
height = layout.list_height,
col = layout.list_col + layout.list_width + 1,
row = layout.list_row + 1,
border = 'none',
style = 'minimal',
focusable = false,
})
local scrollbar_hl = string.format('Normal:%s', config.hl.border)
vim.api.nvim_win_set_option(scrollbar_state.win, 'winhighlight', scrollbar_hl)
scrollbar_state.ever_shown = true
end
if not scrollbar_state.buf or not vim.api.nvim_buf_is_valid(scrollbar_state.buf) then return end
pcall(vim.api.nvim_win_set_config, scrollbar_state.win, { hide = false })
local win_height = vim.api.nvim_win_get_height(list_win)
local thumb_size = math.max(1, math.floor(win_height / total_pages))
local scrollbar_range = win_height - thumb_size
local thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range)
local lines = {}
for i = 1, win_height do
if i >= thumb_start + 1 and i < thumb_start + thumb_size + 1 then
table.insert(lines, '') -- Thick block for thumb
else
table.insert(lines, '') -- Thin line for track
end
end
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', true)
pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines)
pcall(vim.api.nvim_buf_set_option, scrollbar_state.buf, 'modifiable', false)
pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1)
if thumb_size > 0 then
pcall(vim.api.nvim_buf_set_extmark, scrollbar_state.buf, ns_id, thumb_start, 0, {
end_row = thumb_start + thumb_size,
end_col = 0,
hl_group = config.hl.scrollbar,
hl_eol = true,
})
end
end
function M.cleanup()
if scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) then
pcall(vim.api.nvim_win_close, scrollbar_state.win, true)
end
if scrollbar_state.buf and vim.api.nvim_buf_is_valid(scrollbar_state.buf) then
pcall(vim.api.nvim_buf_delete, scrollbar_state.buf, { force = true })
end
scrollbar_state.win = nil
scrollbar_state.buf = nil
scrollbar_state.ever_shown = false
end
return M
+62
View File
@@ -0,0 +1,62 @@
local M = {}
--- Format file size into human-readable string
--- @param size number File size in bytes
--- @return string Formatted size string (e.g., "1.2 KB", "3.4 MB")
function M.format_file_size(size)
if not size or size < 0 then return 'Unknown' end
if size < 1024 then
return string.format('%d B', size)
elseif size < 1024 * 1024 then
return string.format('%.1f KB', size / 1024)
elseif size < 1024 * 1024 * 1024 then
return string.format('%.1f MB', size / (1024 * 1024))
else
return string.format('%.1f GB', size / (1024 * 1024 * 1024))
end
end
--- Safely resolve a config value that can be either a static value or a function
--- @param config_value any The config value (can be function or static value)
--- @param terminal_width number Terminal width for function calls
--- @param terminal_height number Terminal height for function calls
--- @param validator function Function to validate the result
--- @param fallback any Fallback value if function fails or returns invalid value
--- @param error_context string Context for error messages
--- @return any The resolved and validated value
function M.resolve_config_value(config_value, terminal_width, terminal_height, validator, fallback, error_context)
if type(config_value) == 'function' then
local success, result = pcall(config_value, terminal_width, terminal_height)
if success and validator(result) then
return result
else
if not success then
vim.notify('FFF: Error in ' .. error_context .. ' function: ' .. tostring(result), vim.log.levels.WARN)
end
return fallback
end
else
return config_value
end
end
--- Validate numeric ratio (0 < value <= 1)
--- @param value any Value to validate
--- @return boolean True if valid numeric ratio
function M.is_valid_ratio(value) return type(value) == 'number' and value > 0 and value <= 1 end
--- Validate position string
--- @param value any Value to validate
--- @param values table List of valid values strings
--- @return boolean True if valid position
function M.is_one_of(value, values)
if type(value) ~= 'string' then return false end
for _, pos in ipairs(values) do
if value == pos then return true end
end
return false
end
return M
+55
View File
@@ -0,0 +1,55 @@
local M = {}
local uv = vim and vim.uv or require('luv')
-- Get the system triple (target triple for the current platform)
function M.get_triple()
local os_name = uv.os_uname().sysname:lower()
local arch = uv.os_uname().machine:lower()
-- Normalize OS name
if os_name == 'darwin' then
os_name = 'apple-darwin'
elseif os_name == 'linux' then
-- Detect if we're on musl or glibc
local handle = io.popen('ldd --version 2>&1')
if handle then
local output = handle:read('*a')
handle:close()
if output and output:match('musl') then
os_name = 'unknown-linux-musl'
else
os_name = 'unknown-linux-gnu'
end
else
os_name = 'unknown-linux-gnu'
end
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
os_name = 'pc-windows-msvc'
end
-- Normalize architecture
if arch == 'x86_64' or arch == 'amd64' then
arch = 'x86_64'
elseif arch == 'aarch64' or arch == 'arm64' then
arch = 'aarch64'
elseif arch:match('^arm') then
arch = 'arm'
end
return arch .. '-' .. os_name
end
-- Get the library extension for the current platform
function M.get_lib_extension()
local os_name = uv.os_uname().sysname:lower()
if os_name == 'darwin' then
return 'dylib'
elseif os_name:match('windows') or os_name:match('mingw') or os_name:match('msys') then
return 'dll'
else
return 'so'
end
end
return M
+104 -1
View File
@@ -1 +1,104 @@
require('fff.main')
if vim.g.fff_loaded then return end
vim.g.fff_loaded = true
-- Defer indexing until after UIEnter, so as to not block the UI.
-- This is equivalent to lazy.nvim's VeryLazy, but works with all plugin managers.
local init = vim.schedule_wrap(function()
if vim.v.exiting ~= vim.NIL then return end
-- PERF: We query the vim.g.fff config to avoid eagerly requiring Lua modules
local lazy_sync = vim.tbl_get(vim.g, 'fff', 'lazy_sync')
if lazy_sync == nil or not lazy_sync then require('fff.core').ensure_initialized() end
end)
if vim.v.vim_did_enter == 1 then
init()
else
vim.api.nvim_create_autocmd('UIEnter', {
group = vim.api.nvim_create_augroup('fff.main', {}),
once = true,
nested = true,
callback = init,
})
end
vim.api.nvim_create_user_command('FFFFind', function(opts)
local fff = require('fff')
if opts.args and opts.args ~= '' then
-- If argument looks like a directory, use it as base path
if vim.fn.isdirectory(opts.args) == 1 then
fff.find_files_in_dir(opts.args)
else
-- Otherwise treat as search query
fff.search_and_show(opts.args)
end
else
fff.find_files()
end
end, {
nargs = '?',
complete = function(arg_lead)
-- Complete with directories and common search terms
local dirs = vim.fn.glob(arg_lead .. '*', false, true)
local results = {}
for _, dir in ipairs(dirs) do
if vim.fn.isdirectory(dir) == 1 then table.insert(results, dir) end
end
return results
end,
desc = 'Find files with FFF (use directory path or search query)',
})
vim.api.nvim_create_user_command('FFFScan', function() require('fff').scan_files() end, {
desc = 'Scan files for FFF',
})
vim.api.nvim_create_user_command('FFFRefreshGit', function() require('fff').refresh_git_status() end, {
desc = 'Manually refresh git status for all files',
})
vim.api.nvim_create_user_command('FFFClearCache', function(opts) require('fff').clear_cache(opts.args) end, {
nargs = '?',
complete = function() return { 'all', 'frecency', 'files' } end,
desc = 'Clear FFF caches (all|frecency|files)',
})
vim.api.nvim_create_user_command('FFFHealth', function() require('fff').health_check() end, {
desc = 'Check FFF health',
})
vim.api.nvim_create_user_command('FFFDebug', function(opts)
local config = require('fff.conf').get()
if opts.args == 'toggle' or opts.args == '' then
config.debug.show_scores = not config.debug.show_scores
local status = config.debug.show_scores and 'enabled' or 'disabled'
vim.notify('FFF debug scores ' .. status, vim.log.levels.INFO)
elseif opts.args == 'on' then
config.debug.show_scores = true
vim.notify('FFF debug scores enabled', vim.log.levels.INFO)
elseif opts.args == 'off' then
config.debug.show_scores = false
vim.notify('FFF debug scores disabled', vim.log.levels.INFO)
else
vim.notify('Usage: :FFFDebug [on|off|toggle]', vim.log.levels.ERROR)
end
end, {
nargs = '?',
complete = function() return { 'on', 'off', 'toggle' } end,
desc = 'Toggle FFF debug scores display',
})
vim.api.nvim_create_user_command('FFFOpenLog', function()
local fff = require('fff')
local config = require('fff.conf').get()
if fff.log_file_path then
vim.cmd('tabnew ' .. vim.fn.fnameescape(fff.log_file_path))
elseif config and config.logging and config.logging.log_file then
-- Fallback to the configured log file path even if tracing wasn't initialized
vim.cmd('tabnew ' .. vim.fn.fnameescape(config.logging.log_file))
else
vim.notify('Log file path not available', vim.log.levels.ERROR)
end
end, {
desc = 'Open FFF log file in new tab',
})
+1 -1
View File
@@ -1,5 +1,5 @@
[toolchain]
channel = "nightly-2025-07-27"
channel = "nightly-2025-09-01"
components = [
"clippy-preview",
"rustfmt-preview",
+140
View File
@@ -0,0 +1,140 @@
/// Simple search profiler that directly uses scan_filesystem without background thread overhead
use fff_nvim::file_picker::FilePicker;
use std::time::Instant;
fn main() {
let big_repo_path = std::path::PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
eprintln!("Loading files from: {:?}", canonical_path);
// Directly scan without background thread
let start = Instant::now();
let files = {
use ignore::WalkBuilder;
let mut files = Vec::new();
WalkBuilder::new(&canonical_path)
.hidden(false)
.build()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
.for_each(|entry| {
let path = entry.path().to_path_buf();
let relative =
pathdiff::diff_paths(&path, &canonical_path).unwrap_or_else(|| path.clone());
let relative_path = relative.to_string_lossy().into_owned();
let file_name = entry.file_name().to_string_lossy().into_owned();
files.push(fff_nvim::types::FileItem {
path,
relative_path_lower: relative_path.to_lowercase(),
relative_path,
file_name_lower: file_name.to_lowercase(),
file_name,
size: entry.metadata().ok().map_or(0, |m| m.len()),
modified: 0,
access_frecency_score: 0,
modification_frecency_score: 0,
total_frecency_score: 0,
git_status: None,
});
});
files
};
eprintln!(
"✓ Loaded {} files in {:.2}s\n",
files.len(),
start.elapsed().as_secs_f64()
);
// Test queries
let test_queries = vec![
("short_common", "mod", 500),
("medium_specific", "controller", 200),
("long_rare", "user_authentication", 100),
("typo_resistant", "contrlr", 200),
("path_like", "src/lib", 150),
("single_char", "a", 300),
("two_char", "st", 300),
("partial_word", "test", 200),
("deep_path", "drivers/net", 100),
("extension", ".rs", 200),
];
eprintln!("Running search profiler...");
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
eprintln!("----------------------|------------|------------|-----------|--------");
let global_start = Instant::now();
let mut total_iterations = 0;
for (name, query, iterations) in test_queries {
let start = Instant::now();
let mut match_count = 0;
for _ in 0..iterations {
let results = FilePicker::fuzzy_search(
&files,
query,
fff_nvim::file_picker::FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: fff_nvim::types::PaginationArgs {
offset: 0,
limit: 100,
},
},
);
match_count += results.total_matched;
}
let elapsed = start.elapsed();
let avg_time = elapsed / iterations as u32;
eprintln!(
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
name,
iterations,
elapsed.as_secs_f64(),
avg_time.as_micros(),
match_count / iterations
);
total_iterations += iterations;
}
let total_time = global_start.elapsed();
eprintln!("\n=== Summary ===");
eprintln!("Total searches: {}", total_iterations);
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
eprintln!(
"Average per search: {}µs",
(total_time.as_micros() as usize) / total_iterations
);
eprintln!(
"Searches per sec: {:.0}",
total_iterations as f64 / total_time.as_secs_f64()
);
eprintln!(
"\nYou can now run: perf record -g --call-graph dwarf -F 999 ./target/release/search_only"
);
}
+275
View File
@@ -0,0 +1,275 @@
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use std::env;
use std::thread;
use std::time::Duration;
fn get_mem_stat() -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
// Use system memory info since jemalloc-ctl conflicts
#[cfg(target_os = "macos")]
{
use std::process::Command;
let pid = std::process::id();
let output = Command::new("ps")
.args(["-o", "rss=", "-p", &pid.to_string()])
.output()?;
let rss_str = String::from_utf8(output.stdout)?;
let rss_kb: usize = rss_str.trim().parse()?;
let rss_bytes = rss_kb * 1024;
Ok((rss_bytes, rss_bytes, rss_bytes))
}
#[cfg(target_os = "linux")]
{
let pid = std::process::id();
let status_path = format!("/proc/{}/status", pid);
let content = std::fs::read_to_string(status_path)?;
for line in content.lines() {
if line.starts_with("VmRSS:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if let Ok(rss_kb) = parts[1].parse::<usize>()
&& parts.len() >= 2
{
let rss_bytes = rss_kb * 1024;
return Ok((rss_bytes, rss_bytes, rss_bytes));
}
}
}
Err("Could not find VmRSS in /proc/pid/status".into())
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
Ok((0, 0, 0))
}
}
fn format_bytes(bytes: usize) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.2} {}", size, UNITS[unit_index])
}
fn test_search_memory_pattern(
name: &str,
iterations: usize,
query_pattern: impl Fn(usize) -> String,
) -> Result<(), Box<dyn std::error::Error>> {
println!("🧪 {}", name);
let (initial_allocated, initial_active, initial_resident) = get_mem_stat()?;
println!(
" Initial - Allocated: {}, Active: {}, Resident: {}",
format_bytes(initial_allocated),
format_bytes(initial_active),
format_bytes(initial_resident)
);
let mut max_allocated = initial_allocated;
let mut max_active = initial_active;
let mut max_resident = initial_resident;
for i in 0..iterations {
let query = query_pattern(i);
let (result_count, _total_matched) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
&query,
fff_nvim::file_picker::FuzzySearchOptions {
max_threads: 1 + (i % 4),
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: fff_nvim::types::PaginationArgs {
offset: 0,
limit: 50 + (i % 50),
},
},
);
(search_result.items.len(), search_result.total_matched)
} else {
continue;
}
};
if i % 100 == 0 {
let (allocated, active, resident) = get_mem_stat()?;
max_allocated = max_allocated.max(allocated);
max_active = max_active.max(active);
max_resident = max_resident.max(resident);
if i % 500 == 0 {
println!(
" Iteration {}: {} results, Allocated: {} (+{})",
i,
result_count,
format_bytes(allocated),
format_bytes(allocated.saturating_sub(initial_allocated))
);
}
}
if i % 200 == 199 {
thread::sleep(Duration::from_millis(1));
}
}
let (final_allocated, final_active, final_resident) = get_mem_stat()?;
println!(
" Final - Allocated: {} (+{}), Active: {} (+{}), Resident: {} (+{})",
format_bytes(final_allocated),
format_bytes(final_allocated.saturating_sub(initial_allocated)),
format_bytes(final_active),
format_bytes(final_active.saturating_sub(initial_active)),
format_bytes(final_resident),
format_bytes(final_resident.saturating_sub(initial_resident))
);
println!(
" Peak - Allocated: {} (+{}), Active: {} (+{}), Resident: {} (+{})",
format_bytes(max_allocated),
format_bytes(max_allocated.saturating_sub(initial_allocated)),
format_bytes(max_active),
format_bytes(max_active.saturating_sub(initial_active)),
format_bytes(max_resident),
format_bytes(max_resident.saturating_sub(initial_resident))
);
let growth_per_search =
(final_allocated.saturating_sub(initial_allocated)) as f64 / iterations as f64;
println!(
" Average growth per search: {:.2} bytes",
growth_per_search
);
println!();
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
let base_path = if args.len() > 1 {
args[1].clone()
} else {
env::current_dir()?.to_str().unwrap_or(".").to_string()
};
println!("🧪 FFF.nvim Jemalloc Memory Profiler");
println!("====================================");
println!("Test directory: {}", base_path);
println!();
// Initialize FilePicker
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Initializing FilePicker...");
*file_picker_guard = Some(FilePicker::new(base_path.clone())?);
}
}
// Wait for initial scan
println!("Waiting for file scan...");
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
&& !picker.is_scan_active()
&& !picker.get_files().is_empty()
{
break;
}
thread::sleep(Duration::from_millis(100));
}
let file_count = {
let file_picker_guard = FILE_PICKER.read()?;
file_picker_guard.as_ref().unwrap().get_files().len()
};
println!("📊 Found {} files", file_count);
let (initial_allocated, initial_active, initial_resident) = get_mem_stat()?;
println!("🧠 Baseline memory:");
println!(" Allocated: {}", format_bytes(initial_allocated));
println!(" Active: {}", format_bytes(initial_active));
println!(" Resident: {}", format_bytes(initial_resident));
println!();
// Test different memory patterns
// 1. Repeated same query - should have minimal growth if caching works
test_search_memory_pattern("Same Query Repeated (1000x)", 1000, |_| "test".to_string())?;
// 2. Cycling through different queries
test_search_memory_pattern("Cycling Queries (1000x)", 1000, |i| {
let queries = [
"test", "main", "lib", "src", "mod", "file", "picker", "fuzzy", "search",
];
queries[i % queries.len()].to_string()
})?;
// 3. Unique queries each time - worst case for any caching
test_search_memory_pattern("Unique Queries (500x)", 500, |i| {
format!("unique_query_{}", i)
})?;
// 4. Queries that return many results
test_search_memory_pattern(
"High Result Count (500x)",
500,
|_| "a".to_string(), // Single character likely to match many files
)?;
// 5. Queries with no results
test_search_memory_pattern("No Results (500x)", 500, |_| {
"zzzz_no_match_expected".to_string()
})?;
// 6. Long intensive test
test_search_memory_pattern("Long Intensive Test (2000x)", 2000, |i| {
let patterns = [
"rs", "lua", "toml", "mod", "lib", "main", "test", "src", "file",
];
format!("{}{}", patterns[i % patterns.len()], i % 100)
})?;
let (final_allocated, final_active, final_resident) = get_mem_stat()?;
println!("🏁 Final Memory Stats:");
println!(
" Allocated: {} (growth: {})",
format_bytes(final_allocated),
format_bytes(final_allocated.saturating_sub(initial_allocated))
);
println!(
" Active: {} (growth: {})",
format_bytes(final_active),
format_bytes(final_active.saturating_sub(initial_active))
);
println!(
" Resident: {} (growth: {})",
format_bytes(final_resident),
format_bytes(final_resident.saturating_sub(initial_resident))
);
let total_searches = 5500;
let avg_growth =
(final_allocated.saturating_sub(initial_allocated)) as f64 / total_searches as f64;
println!(" Average growth per search: {:.2} bytes", avg_growth);
Ok(())
}
+175
View File
@@ -0,0 +1,175 @@
use fff_nvim::FILE_PICKER;
use fff_nvim::file_picker::FilePicker;
use std::time::{Duration, Instant};
/// Wait for background scan to complete
fn wait_for_scan(timeout_secs: u64) -> Result<usize, String> {
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let mut iteration = 0;
loop {
iteration += 1;
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
let is_scanning = picker.is_scan_active();
let file_count = picker.get_files().len();
if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] Scanning: {}, Files: {}",
start.elapsed().as_secs_f64(),
is_scanning,
file_count
);
}
if !is_scanning && file_count > 0 {
return Ok(file_count);
}
} else if iteration % 20 == 0 {
eprintln!(
" [{:.1}s] FilePicker is None",
start.elapsed().as_secs_f64()
);
}
if start.elapsed() > timeout {
return Err(format!("Scan timed out after {} seconds", timeout_secs));
}
std::thread::sleep(Duration::from_millis(100));
}
}
/// Initialize FilePicker and insert into global state
fn init_file_picker(path: &str) -> Result<(), String> {
let picker = FilePicker::new(path.to_string())
.map_err(|e| format!("Failed to create FilePicker: {:?}", e))?;
let mut picker_guard = FILE_PICKER
.write()
.map_err(|_| "Failed to acquire write lock")?;
*picker_guard = Some(picker);
Ok(())
}
/// Get files snapshot from global state
fn get_files() -> Result<Vec<fff_nvim::types::FileItem>, String> {
let picker_guard = FILE_PICKER
.read()
.map_err(|_| "Failed to acquire read lock")?;
if let Some(ref picker) = *picker_guard {
Ok(picker.get_files().to_vec())
} else {
Err("FilePicker not initialized".to_string())
}
}
fn main() {
let big_repo_path = std::path::PathBuf::from("./big-repo");
if !big_repo_path.exists() {
eprintln!(
"./big-repo directory does not exist. Run git clone https://github.com/torvalds/linux.git big-repo"
);
return;
}
let canonical_path = big_repo_path
.canonicalize()
.expect("Failed to canonicalize path");
eprintln!("Initializing FilePicker for: {:?}", canonical_path);
init_file_picker(&canonical_path.to_string_lossy()).expect("Failed to init FilePicker");
// Give background thread time to start
std::thread::sleep(Duration::from_millis(200));
eprintln!("Waiting for scan to complete...");
let file_count = wait_for_scan(120).expect("Failed to wait for scan");
eprintln!("✓ Indexed {} files\n", file_count);
let files = get_files().expect("Failed to get files");
// Test queries representing different search patterns
let test_queries = vec![
("short_common", "mod", 5000),
("medium_specific", "controller", 2000),
("long_rare", "user_authentication", 1000),
("typo_resistant", "contrlr", 2000),
("path_like", "src/lib", 1500),
("single_char", "a", 3000),
("two_char", "st", 3000),
("partial_word", "test", 2000),
("deep_path", "drivers/net", 1000),
("extension", ".rs", 2000),
];
eprintln!("Running search profiler...");
eprintln!("Query | Iterations | Total Time | Avg Time | Matches");
eprintln!("----------------------|------------|------------|-----------|--------");
let global_start = Instant::now();
let mut total_iterations = 0;
for (name, query, iterations) in test_queries {
let start = Instant::now();
let mut match_count = 0;
for _ in 0..iterations {
let results = FilePicker::fuzzy_search(
&files,
query,
fff_nvim::file_picker::FuzzySearchOptions {
max_threads: 4,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: fff_nvim::types::PaginationArgs {
offset: 0,
limit: 100,
},
},
);
match_count += results.total_matched;
}
let elapsed = start.elapsed();
let avg_time = elapsed / iterations as u32;
eprintln!(
"{:<21} | {:>10} | {:>9.2}s | {:>7}µs | {}",
name,
iterations,
elapsed.as_secs_f64(),
avg_time.as_micros(),
match_count / iterations
);
total_iterations += iterations;
}
let total_time = global_start.elapsed();
eprintln!("\n=== Summary ===");
eprintln!("Total searches: {}", total_iterations);
eprintln!("Total time: {:.2}s", total_time.as_secs_f64());
eprintln!(
"Average per search: {}µs",
(total_time.as_micros() as usize) / total_iterations
);
eprintln!(
"Searches per sec: {:.0}",
total_iterations as f64 / total_time.as_secs_f64()
);
// Keep the program alive briefly so perf can capture everything
std::thread::sleep(Duration::from_millis(100));
}
+289
View File
@@ -0,0 +1,289 @@
use fff_nvim::{FILE_PICKER, file_picker::FilePicker};
use std::env;
use std::io::{self, Write};
use std::thread;
use std::time::{Duration, Instant};
fn get_memory_usage() -> Result<u64, Box<dyn std::error::Error>> {
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
return Err("Memory usage check is only supported on Linux and macOS".into());
}
#[cfg(target_os = "macos")]
{
let pid = std::process::id();
use std::process::Command;
let output = Command::new("ps")
.args(["-o", "rss=", "-p", &pid.to_string()])
.output()?;
let rss_str = String::from_utf8(output.stdout)?;
let rss_kb: u64 = rss_str.trim().parse()?;
Ok(rss_kb * 1024)
}
#[cfg(target_os = "linux")]
{
let pid = std::process::id();
let status_path = format!("/proc/{}/status", pid);
let content = std::fs::read_to_string(status_path)?;
for line in content.lines() {
if line.starts_with("VmRSS:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if let Ok(rss_kb) = parts[1].parse::<u64>()
&& parts.len() >= 2
{
return Ok(rss_kb * 1024); // Convert KB to bytes
}
}
}
Err("Could not find VmRSS in /proc/pid/status".into())
}
}
fn format_bytes(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
format!("{:.2} {}", size, UNITS[unit_index])
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
if !cfg!(target_os = "linux") && !cfg!(target_os = "macos") {
eprintln!("This test is only supported on Linux and macOS.");
std::process::exit(1);
}
let args: Vec<String> = env::args().collect();
let base_path = if args.len() > 1 {
args[1].clone()
} else {
env::current_dir()?.to_str().unwrap_or(".").to_string()
};
println!("🧪 FFF.nvim Memory Leak Test (Using Crate)");
println!("==========================================");
println!("Test directory: {}", base_path);
println!();
// Initialize the file picker directly
println!("📁 Initializing FilePicker...");
{
let mut file_picker_guard = FILE_PICKER.write().unwrap();
if file_picker_guard.is_none() {
println!("Creating new FilePicker for path: {}", base_path);
match FilePicker::new(base_path.clone()) {
Ok(picker) => {
println!("FilePicker created successfully");
*file_picker_guard = Some(picker);
}
Err(e) => {
eprintln!("Failed to create FilePicker: {:?}", e);
std::process::exit(1);
}
}
}
}
// Wait for initial scan to complete
println!("⏳ Waiting for initial file scan to complete...");
let mut wait_time = 0;
let mut scan_completed = false;
loop {
if let Ok(file_picker_guard) = FILE_PICKER.read()
&& let Some(ref picker) = *file_picker_guard
{
if !picker.is_scan_active() {
println!("Scan inactive, checking file count...");
let file_count = picker.get_files().len();
if file_count > 0 {
println!("Async scan found {} files", file_count);
scan_completed = true;
break;
}
} else {
println!("Scan active, waiting...");
}
}
thread::sleep(Duration::from_millis(100));
wait_time += 100;
if wait_time > 10000 {
println!("Timeout waiting for async scan, triggering manual scan...");
break;
}
}
// If async scan didn't work, trigger a manual scan
if !scan_completed {
println!("Triggering manual rescan...");
if let Ok(mut file_picker_guard) = FILE_PICKER.write()
&& let Some(ref mut picker) = *file_picker_guard
{
match picker.trigger_rescan() {
Ok(_) => println!("Manual rescan completed"),
Err(e) => println!("Manual rescan failed: {:?}", e),
}
}
}
let initial_file_count = {
let file_picker_guard = FILE_PICKER.read()?;
if let Some(ref picker) = *file_picker_guard {
let files = picker.get_files();
println!("Found {} files in picker", files.len());
if !files.is_empty() {
println!("Sample files:");
for (i, file) in files.iter().take(5).enumerate() {
println!(" {}. {}", i + 1, file.relative_path);
}
}
files.len()
} else {
println!("No picker found in FILE_PICKER static!");
0
}
};
println!(
"📊 Initial scan complete: {} files found",
initial_file_count
);
if initial_file_count == 0 {
eprintln!("❌ No files found in directory. Cannot proceed with memory test.");
std::process::exit(1);
}
// Record initial memory usage
let initial_memory = get_memory_usage().unwrap_or(0);
println!("🧠 Initial memory usage: {}", format_bytes(initial_memory));
println!();
// Test queries to cycle through
let test_queries = vec![
"rs", "mod", "lib", "main", "test", "src", "lua", "cargo", "toml", "init", "config",
"util", "file", "picker", "fuzzy", "search", "git", "fn", "struct", "impl", "pub", "use",
"let", "match", "if", "for", "async", "await", "Result", "Error", "Vec", "String",
"Option",
];
let mut last_memory_check = Instant::now();
let mut peak_memory = initial_memory;
let mut memory_samples = Vec::new();
println!("🔥 Starting intensive search test...");
println!("Press Ctrl+C to stop the test");
println!();
let mut search_count = 0;
while search_count < 1000 {
search_count += 1;
// Perform search directly on FilePicker
let query = test_queries[search_count % test_queries.len()];
let max_results = 50 + (search_count % 100); // Vary result count
let max_threads = 1 + (search_count % 8); // Vary thread count
let search_start = Instant::now();
let (result_count, search_duration) = {
let file_picker_guard = FILE_PICKER.read().unwrap();
if let Some(ref picker) = *file_picker_guard {
let search_result = FilePicker::fuzzy_search(
picker.get_files(),
query,
fff_nvim::file_picker::FuzzySearchOptions {
max_threads,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: fff_nvim::types::PaginationArgs {
offset: 0,
limit: max_results,
},
},
);
let duration = search_start.elapsed();
(search_result.items.len(), duration)
} else {
(0, search_start.elapsed())
}
};
search_count += 1;
// Check memory every 100 searches or every 5 seconds
let now = Instant::now();
if (search_count % 100 == 0
|| now.duration_since(last_memory_check) > Duration::from_secs(5))
&& let Ok(current_memory) = get_memory_usage()
{
memory_samples.push(current_memory);
if current_memory > peak_memory {
peak_memory = current_memory;
}
let memory_growth = current_memory.saturating_sub(initial_memory);
println!(
"🔍 Search #{}: '{}' -> {} results in {:?} | Memory: {} (+{}) | Peak: {}",
search_count,
query,
result_count,
search_duration,
format_bytes(current_memory),
format_bytes(memory_growth),
format_bytes(peak_memory)
);
last_memory_check = now;
// Calculate memory growth trend over last 10 samples
if memory_samples.len() >= 10 {
let recent_samples = &memory_samples[memory_samples.len() - 10..];
let first_recent = recent_samples[0];
let last_recent = recent_samples[recent_samples.len() - 1];
if last_recent > first_recent {
let recent_growth = last_recent - first_recent;
if recent_growth > 1024 * 1024 {
// More than 1MB growth in recent samples
println!(
"⚠️ POTENTIAL LEAK: Recent memory growth: {}",
format_bytes(recent_growth)
);
}
}
}
io::stdout().flush().unwrap();
}
// Brief pause to prevent overwhelming the system
if search_count % 10 == 0 {
thread::sleep(Duration::from_millis(1));
}
// Force cleanup test every 1000 searches
if search_count % 1000 == 0 {
println!("🧹 Forcing potential cleanup at search #{}", search_count);
// Force garbage collection by creating and dropping temporary data
thread::sleep(Duration::from_millis(10));
}
}
Ok(())
}
+104 -81
View File
@@ -1,34 +1,33 @@
#![allow(clippy::all)]
#![allow(dead_code)]
#![allow(clippy::enum_variant_names)]
#[path = "../../lua/fff/rust/error.rs"]
mod error;
#[path = "../../lua/fff/rust/file_key.rs"]
mod file_key;
#[path = "../../lua/fff/rust/file_picker.rs"]
mod file_picker;
#[path = "../../lua/fff/rust/frecency.rs"]
mod frecency;
#[path = "../../lua/fff/rust/git.rs"]
mod git;
#[path = "../../lua/fff/rust/path_utils.rs"]
mod path_utils;
#[path = "../../lua/fff/rust/score.rs"]
mod score;
#[path = "../../lua/fff/rust/types.rs"]
mod types;
use file_picker::FilePicker;
use frecency::FrecencyTracker;
use fff_nvim::{FILE_PICKER, FRECENCY, file_picker::FilePicker, git::format_git_status};
use std::env;
use std::io::{self, Write};
use std::sync::{LazyLock, RwLock};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
use crate::git::format_git_status;
fn cleanup_global_state() {
// Clean up file picker
{
let mut file_picker = FILE_PICKER.write().unwrap();
if let Some(mut picker) = file_picker.take() {
let _ = picker.stop_background_monitor();
drop(picker);
println!("🧹 FilePicker cleaned up");
}
}
static FRECENCY: LazyLock<RwLock<Option<FrecencyTracker>>> = LazyLock::new(|| RwLock::new(None));
// Clean up frecency tracker
{
let mut frecency = FRECENCY.write().unwrap();
*frecency = None;
println!("🧹 Frecency tracker cleaned up");
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().collect();
@@ -38,45 +37,66 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
env::current_dir()?.to_str().unwrap_or(".").to_string()
};
let picker = match FilePicker::new(base_path.clone()) {
Ok(p) => p,
Err(e) => {
eprintln!("❌ Failed to create FilePicker: {:?}", e);
// Set up signal handler for graceful shutdown
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
println!("\n🛑 Received interrupt signal, shutting down...");
cleanup_global_state();
r.store(false, Ordering::SeqCst);
std::process::exit(0);
})?;
let mut git_stats = std::collections::HashMap::new();
// Initialize the global file picker using lib.rs function
{
let mut file_picker = FILE_PICKER.write().unwrap();
if file_picker.is_some() {
eprintln!("❌ FilePicker already initialized");
std::process::exit(1);
}
};
let initial_files = picker.get_cached_files();
println!("Initial file count: {}", initial_files.len());
if !initial_files.is_empty() {
println!("Sample files:");
for (i, file) in initial_files.iter().take(5).enumerate() {
println!(
" {}. {} ({})",
i + 1,
file.relative_path,
format_git_status(file.git_status)
);
}
if initial_files.len() > 5 {
println!(" ... and {} more files", initial_files.len() - 5);
}
*file_picker = Some(FilePicker::new(base_path.clone())?);
}
// Get initial file count from global state
let initial_count = {
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
println!("Initial file count: {}", files.len());
if !files.is_empty() {
println!("Sample files:");
for (i, file) in files.iter().take(5).enumerate() {
println!(
" {}. {} ({})",
i + 1,
file.relative_path,
format_git_status(file.git_status)
);
}
if files.len() > 5 {
println!(" ... and {} more files", files.len() - 5);
}
}
files.len()
};
println!("{:=<60}", "");
println!("🔴 LIVE FILE MONITORING - Press Ctrl+C to stop");
println!("{:=<60}", "");
let mut last_count = initial_files.len();
let mut last_count = initial_count;
let mut iteration = 0;
loop {
while running.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(500));
iteration += 1;
let current_files = picker.get_cached_files();
let current_count = current_files.len();
let current_count = {
let file_picker = FILE_PICKER.read().unwrap();
file_picker.as_ref().unwrap().get_files().len()
};
if current_count != last_count {
let timestamp = chrono::Local::now().format("%H:%M:%S%.3f");
@@ -88,20 +108,14 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
timestamp, added, current_count
);
if let Some(newest_files) = current_files
.iter()
.rev()
.take(added)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.get(0..added.min(3))
{
for file in newest_files {
println!(" {}", file.relative_path);
}
// Show some recently added files
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let newest_files = files.iter().rev().take(added.min(3));
for file in newest_files {
println!(" {}", file.relative_path);
}
drop(file_picker);
} else {
let removed = last_count - current_count;
println!(
@@ -120,14 +134,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
timestamp, current_count
);
let mut git_stats = std::collections::HashMap::new();
for file in &current_files {
let file_picker = FILE_PICKER.read().unwrap();
let current_files = file_picker.as_ref().unwrap().get_files();
git_stats.clear();
for file in current_files {
let status = format_git_status(file.git_status);
*git_stats.entry(status).or_insert(0) += 1;
}
let git_stats_copy = git_stats.clone();
if !git_stats.is_empty() {
print!(" 📊 Git status: ");
for (status, count) in &git_stats {
@@ -135,26 +150,29 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
println!();
}
println!(" 🔄 Testing git status refresh...");
let refreshed_files = picker.refresh_git_status();
let mut new_git_stats = std::collections::HashMap::new();
for file in &refreshed_files {
let status = format_git_status(file.git_status);
*new_git_stats.entry(status).or_insert(0) += 1;
}
if new_git_stats != git_stats_copy {
print!(" ✨ Git status changed after refresh: ");
for (status, count) in &new_git_stats {
print!("{}:{} ", status, count);
}
println!();
}
}
if iteration % 40 == 0 {
let search_results = picker.fuzzy_search("rs", 5, 2, None);
let timestamp = chrono::Local::now().format("%H:%M:%S");
let file_picker = FILE_PICKER.read().unwrap();
let files = file_picker.as_ref().unwrap().get_files();
let search_results = FilePicker::fuzzy_search(
files,
"rs",
fff_nvim::file_picker::FuzzySearchOptions {
max_threads: 2,
current_file: None,
project_path: None,
last_same_query_match: None,
combo_boost_score_multiplier: 100,
min_combo_count: 3,
pagination: fff_nvim::types::PaginationArgs {
offset: 0,
limit: 5,
},
},
);
println!(
"🔍 [{}] Search test 'rs': {} matches",
timestamp,
@@ -174,8 +192,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
score.total
);
}
drop(file_picker);
}
io::stdout().flush().unwrap();
}
// Clean up before exit
cleanup_global_state();
Ok(())
}