Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d7bc72786d | |||
| 9a6d8ca81a | |||
| 6455ce7c68 | |||
| 0523fe39ff | |||
| 6b01f95ca6 | |||
| 5ab271ea9d | |||
| 448cf3d025 | |||
| 3cc7da787f | |||
| 8b1f3f4e95 | |||
| ca4c32d364 | |||
| f6af8353c3 | |||
| b384bf7dad | |||
| 2951756ae3 | |||
| c17056bcb6 |
@@ -2,7 +2,8 @@ name: Prebuild
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/prebuild]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -61,9 +62,6 @@ jobs:
|
||||
|
||||
- name: Install Rust
|
||||
run: |
|
||||
# https://github.com/rust-cross/cargo-zigbuild/issues/327
|
||||
rustup toolchain install nightly-2025-02-19
|
||||
rustup default nightly-2025-02-19
|
||||
rustup target add ${{ matrix.target }}
|
||||
|
||||
- name: Build for Linux
|
||||
@@ -96,6 +94,7 @@ jobs:
|
||||
name: Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -132,4 +131,45 @@ jobs:
|
||||
prerelease: true
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
Nightly release from commit: ${{ github.sha }}
|
||||
Nightly release from commit: ${{ github.sha }}
|
||||
|
||||
comment-on-pr:
|
||||
name: Comment on PR
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Get short SHA
|
||||
id: vars
|
||||
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find existing comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "<!-- fff-nvim-build-comment -->"
|
||||
|
||||
- name: Create or update PR comment
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- fff-nvim-build-comment -->
|
||||
## 🔨 Build Artifacts for your PR
|
||||
|
||||
You can test the prebuilt binaries if you use lazy.nvim right now.
|
||||
|
||||
```lua
|
||||
{
|
||||
"dmtrKovalenko/fff.nvim",
|
||||
tag = "${{ steps.vars.outputs.short_sha }}",
|
||||
}
|
||||
```
|
||||
---
|
||||
*Built from ${{ github.sha }}*
|
||||
|
||||
Generated
-42
@@ -423,7 +423,6 @@ dependencies = [
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"pathdiff",
|
||||
"rand",
|
||||
"rayon",
|
||||
@@ -445,21 +444,6 @@ dependencies = [
|
||||
"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"
|
||||
@@ -1126,32 +1110,6 @@ 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"
|
||||
|
||||
+4
-2
@@ -32,7 +32,10 @@ blake3 = "1.8.2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
ctrlc = "3.4.2"
|
||||
dirs = "5.0"
|
||||
git2 = "0.20.2"
|
||||
git2 = { version = "0.20.2", features = [
|
||||
"vendored-openssl",
|
||||
"vendored-libgit2",
|
||||
] }
|
||||
glidesort = "0.1"
|
||||
heed = "0.22.0"
|
||||
ignore = "0.4.22"
|
||||
@@ -43,7 +46,6 @@ neo_frizbee = { version = "0.7.1" }
|
||||
notify = "8.1.0"
|
||||
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"] }
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
<img alt="Stars" src="https://img.shields.io/github/stars/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=starship&color=C9CBFF&logoColor=D9E0EE&labelColor=302D41"></a>
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/issues" style="text-decoration: none">
|
||||
<img alt="Issues" src="https://img.shields.io/github/issues/dmtrKovalenko/fff.nvim?style=for-the-badge&logo=bilibili&color=F5E0DC&logoColor=D9E0EE&labelColor=302D41"></a>
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none">
|
||||
<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>
|
||||
<a href="https://github.com/dmtrKovalenko/fff.nvim/contributors" style="text-decoration: none"> <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.
|
||||
@@ -124,6 +123,12 @@ require('fff').setup({
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -133,7 +138,6 @@ require('fff').setup({
|
||||
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 },
|
||||
@@ -215,6 +219,7 @@ require('fff').setup({
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -321,6 +326,19 @@ require('fff').setup({
|
||||
})
|
||||
```
|
||||
|
||||
#### File Filtering
|
||||
|
||||
FFF.nvim respects `.gitignore` patterns automatically. To filter files from the picker without modifying `.gitignore`, create a `.ignore` file in your project root:
|
||||
|
||||
```gitignore
|
||||
# Exclude all markdown files
|
||||
*.md
|
||||
|
||||
# Exclude specific subdirectory
|
||||
docs/archive/**/*.md
|
||||
```
|
||||
|
||||
Run `:FFFScan` to force a rescan if needed.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
+26
-4
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 December 17
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 07
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
@@ -9,8 +9,7 @@ FFF.nvimFinally a smart fuzzy file picker for neovim.
|
||||
|
||||
|
||||
|
||||
|
||||
**FFF** stands for ~freakin fast fuzzy file finder~ (pick 3) and it is an
|
||||
**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.
|
||||
|
||||
@@ -132,6 +131,12 @@ all available options:
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
-- How to shorten long directory paths in the file list:
|
||||
-- 'middle_number' (default): uses dots for 1-3 hidden (a/./b, a/../b, a/.../b)
|
||||
-- and numbers for 4+ (a/.4./b, a/.5./b)
|
||||
-- 'middle': always uses dots (a/./b, a/../b, a/.../b)
|
||||
-- 'end': truncates from the end (home/user/projects)
|
||||
path_shorten_strategy = 'middle_number',
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -141,7 +146,6 @@ all available options:
|
||||
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 },
|
||||
@@ -223,6 +227,7 @@ all available options:
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
@@ -347,6 +352,23 @@ with your own custom highlight groups to match your colorscheme.
|
||||
<
|
||||
|
||||
|
||||
FILE FILTERING
|
||||
|
||||
FFF.nvim respects `.gitignore` patterns automatically. To filter files from the
|
||||
picker without modifying `.gitignore`, create a `.ignore` file in your project
|
||||
root:
|
||||
|
||||
>gitignore
|
||||
# Exclude all markdown files
|
||||
*.md
|
||||
|
||||
# Exclude specific subdirectory
|
||||
docs/archive/**/*.md
|
||||
<
|
||||
|
||||
Run `:FFFScan` to force a rescan if needed.
|
||||
|
||||
|
||||
TROUBLESHOOTING ~
|
||||
|
||||
|
||||
|
||||
Generated
+9
-9
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1765739568,
|
||||
"narHash": "sha256-gQYx35Of4UDKUjAYvmxjUEh/DdszYeTtT6MDin4loGE=",
|
||||
"lastModified": 1767744144,
|
||||
"narHash": "sha256-9/9ntI0D+HbN4G0TrK3KmHbTvwgswz7p8IEJsWyef8Q=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "67d2baff0f9f677af35db61b32b5df6863bcc075",
|
||||
"rev": "2fb033290bf6b23f226d4c8b32f7f7a16b043d7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -35,11 +35,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1765644376,
|
||||
"narHash": "sha256-yqHBL2wYGwjGL2GUF2w3tofWl8qO9tZEuI4wSqbCrtE=",
|
||||
"lastModified": 1767364772,
|
||||
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "23735a82a828372c4ef92c660864e82fbe2f5fbe",
|
||||
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -64,11 +64,11 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1765680428,
|
||||
"narHash": "sha256-fyPmRof9SZeI14ChPk5rVPOm7ISiiGkwGCunkhM+eUg=",
|
||||
"lastModified": 1767926800,
|
||||
"narHash": "sha256-x0n73J6ufD/EhDlVdcoAmF0OQHZ+b0a2cKDc8RZyt+o=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "eb3898d8ef143d4bf0f7f2229105fc51c7731b2f",
|
||||
"rev": "499e9eed88ff9494b6604205b42847e847dfeb91",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+51
-18
@@ -10,6 +10,8 @@ local overlay_state = {
|
||||
last_row = nil,
|
||||
last_col = nil,
|
||||
last_border_hl = nil,
|
||||
-- Track if combo was rendered in last call
|
||||
was_rendered = false,
|
||||
}
|
||||
|
||||
local LEFT_OVERLAY_CONTENT = '├────'
|
||||
@@ -91,6 +93,7 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
row = row,
|
||||
col = col,
|
||||
style = 'minimal',
|
||||
border = 'none',
|
||||
focusable = false,
|
||||
zindex = 250,
|
||||
}
|
||||
@@ -104,15 +107,15 @@ local function position_overlay_window(state_key, buf, width, row, col)
|
||||
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, prompt_position)
|
||||
local function update_overlays(list_win, combo_header_line, border_hl)
|
||||
local list_config = vim.api.nvim_win_get_config(list_win)
|
||||
-- combo_header_line is a 1-based buffer line index
|
||||
-- list_config.row is the window position (includes border)
|
||||
-- Buffer content starts at row + 1 (after top border)
|
||||
-- For bottom prompt: overlay needs adjustment due to different border handling
|
||||
-- For top prompt: use standard calculation
|
||||
-- combo_header_line is a 1-based buffer line index (includes any padding offset)
|
||||
-- list_config.row is where the window starts (0-based, at the top border)
|
||||
-- Content starts at list_config.row + 1 (after the top border)
|
||||
-- Buffer line 1 -> screen row (list_config.row + 1)
|
||||
-- Buffer line N -> screen row (list_config.row + N)
|
||||
-- Since combo_header_line is 1-based, the formula naturally works out
|
||||
local combo_header_row = list_config.row + combo_header_line
|
||||
if prompt_position == 'bottom' then combo_header_row = combo_header_row - 1 end
|
||||
|
||||
-- Skip update if position and highlight haven't changed
|
||||
if
|
||||
@@ -161,6 +164,7 @@ local function clear_overlays_internal()
|
||||
overlay_state.last_row = nil
|
||||
overlay_state.last_col = nil
|
||||
overlay_state.last_border_hl = nil
|
||||
-- Note: we intentionally don't clear was_rendered here to track the transition
|
||||
end
|
||||
|
||||
function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display)
|
||||
@@ -172,6 +176,8 @@ function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_m
|
||||
return true, header_line, text_len, combo_item_index
|
||||
end
|
||||
|
||||
--- Render combo highlights and overlays
|
||||
--- @return boolean was_hidden True if combo was just hidden (was rendered before, not now)
|
||||
function M.render_highlights_and_overlays(
|
||||
combo_item_index,
|
||||
text_len,
|
||||
@@ -180,26 +186,53 @@ function M.render_highlights_and_overlays(
|
||||
ns_id,
|
||||
border_hl,
|
||||
item_to_lines,
|
||||
prompt_position
|
||||
prompt_position,
|
||||
total_items
|
||||
)
|
||||
local was_rendered_before = overlay_state.was_rendered
|
||||
local is_rendering_now = false
|
||||
|
||||
if not combo_item_index then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
else
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
else
|
||||
local combo_header_line_idx = combo_item_lines.first
|
||||
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
|
||||
if prompt_position == 'bottom' and total_items and total_items > 1 then
|
||||
combo_header_line_idx = combo_header_line_idx - 1
|
||||
end
|
||||
|
||||
-- when rendering items in the reverse order for some reason this makes the
|
||||
-- indexing shifted by one in the internal list config, so just adjust for that
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl)
|
||||
is_rendering_now = true
|
||||
end
|
||||
end
|
||||
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
end
|
||||
overlay_state.was_rendered = is_rendering_now
|
||||
|
||||
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, prompt_position)
|
||||
-- Return true if combo was just hidden (transition from visible to hidden)
|
||||
return was_rendered_before and not is_rendering_now
|
||||
end
|
||||
|
||||
--- Get the combo header text for a given item
|
||||
--- @param combo_count number The combo multiplier count
|
||||
--- @param win_width number Window width for formatting
|
||||
--- @param disable_combo_display boolean Whether to show combo count
|
||||
--- @return string header_text The formatted header line
|
||||
--- @return number text_len Length of the header text (without padding)
|
||||
function M.get_combo_header_text(combo_count, win_width, disable_combo_display)
|
||||
return create_header_text(combo_count, win_width, disable_combo_display)
|
||||
end
|
||||
|
||||
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
|
||||
|
||||
function M.cleanup() clear_overlays_internal() end
|
||||
function M.cleanup()
|
||||
clear_overlays_internal()
|
||||
overlay_state.was_rendered = false
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
+4
-1
@@ -114,6 +114,7 @@ local function init()
|
||||
preview_position = 'right', -- or 'left', 'right', 'top', 'bottom'
|
||||
preview_size = 0.5,
|
||||
show_scrollbar = true, -- Show scrollbar for pagination
|
||||
path_shorten_strategy = 'middle_number', -- or 'middle', 'end'
|
||||
},
|
||||
preview = {
|
||||
enabled = true,
|
||||
@@ -123,7 +124,6 @@ local function init()
|
||||
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 },
|
||||
@@ -144,6 +144,8 @@ local function init()
|
||||
cycle_previous_query = '<C-Up>',
|
||||
toggle_select = '<Tab>',
|
||||
send_to_quickfix = '<C-q>',
|
||||
focus_list = '<leader>l',
|
||||
focus_preview = '<leader>p',
|
||||
},
|
||||
hl = {
|
||||
border = 'FloatBorder',
|
||||
@@ -199,6 +201,7 @@ local function init()
|
||||
debug = {
|
||||
enabled = false, -- Set to true to show scores in the UI
|
||||
show_scores = false,
|
||||
show_file_info = false, -- Show file info panel in preview
|
||||
},
|
||||
logging = {
|
||||
enabled = true,
|
||||
|
||||
+7
-1
@@ -28,7 +28,13 @@ local function setup_global_autocmds(config)
|
||||
|
||||
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)
|
||||
local ok, track_err = pcall(fuzzy.track_access, real_path)
|
||||
|
||||
if not ok then
|
||||
vim.schedule(
|
||||
function() vim.notify('FFF: Failed to track file access: ' .. tostring(track_err), vim.log.levels.ERROR) end
|
||||
)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
local M = {}
|
||||
local system = require('fff.utils.system')
|
||||
local uv = vim and vim.uv or require('luv')
|
||||
local fs_utils = require('fff.utils.fs')
|
||||
|
||||
local GITHUB_REPO = 'dmtrKovalenko/fff.nvim'
|
||||
|
||||
@@ -24,7 +24,7 @@ end
|
||||
|
||||
local function binary_exists(plugin_dir)
|
||||
local binary_path = get_binary_path(plugin_dir)
|
||||
local stat = uv.fs_stat(binary_path)
|
||||
local stat = vim.uv.fs_stat(binary_path)
|
||||
return stat and stat.type == 'file'
|
||||
end
|
||||
|
||||
@@ -32,9 +32,9 @@ 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)
|
||||
fs_utils.mkdir_recursive(dir, function(mkdir_ok, mkdir_err)
|
||||
if not mkdir_ok then
|
||||
callback(false, mkdir_err)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -89,7 +89,7 @@ local function download_from_github(version, binary_path, opts, callback)
|
||||
local ok, err_msg = pcall(function() package.loadlib(binary_path, 'luaopen_fff_nvim') end)
|
||||
|
||||
if not ok then
|
||||
uv.fs_unlink(binary_path)
|
||||
vim.uv.fs_unlink(binary_path)
|
||||
callback(false, 'Downloaded binary is not valid: ' .. (err_msg or 'unknown error'))
|
||||
return
|
||||
end
|
||||
|
||||
@@ -225,4 +225,41 @@ function M.display_image(file_path, bufnr, max_width, max_height)
|
||||
return false
|
||||
end
|
||||
|
||||
--- Check image preview availability status
|
||||
--- @return table status { available: boolean, snacks_available: boolean, snacks_image_available: boolean, terminal_supported: boolean, error: string|nil }
|
||||
function M.get_preview_status()
|
||||
local status = {
|
||||
available = false,
|
||||
snacks_available = false,
|
||||
snacks_image_available = false,
|
||||
terminal_supported = false,
|
||||
error = nil,
|
||||
}
|
||||
|
||||
local ok, snacks = pcall(require, 'snacks')
|
||||
if not ok then
|
||||
status.error = 'snacks.nvim not installed'
|
||||
return status
|
||||
end
|
||||
|
||||
status.snacks_available = true
|
||||
|
||||
if not snacks.image then
|
||||
status.error = 'snacks.image module not available'
|
||||
return status
|
||||
end
|
||||
|
||||
status.snacks_image_available = true
|
||||
|
||||
if not snacks.image.supports_terminal or not snacks.image.supports_terminal() then
|
||||
status.error = 'terminal does not support image display'
|
||||
return status
|
||||
end
|
||||
|
||||
status.terminal_supported = true
|
||||
status.available = true
|
||||
|
||||
return status
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
--- File Renderer
|
||||
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
|
||||
local M = {}
|
||||
|
||||
--- Render Context passed to renderer functions
|
||||
--- @class RenderContext
|
||||
--- @field config table User configuration from conf.get()
|
||||
--- @field items table[] Array of file items being rendered
|
||||
--- @field cursor number Current cursor position (1-based index into items)
|
||||
--- @field win_height number Window height in lines
|
||||
--- @field win_width number Window width in columns
|
||||
--- @field max_path_width number Maximum width for file paths
|
||||
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
|
||||
--- @field prompt_position string Prompt position: 'top' or 'bottom'
|
||||
--- @field has_combo boolean Whether combo boost is active
|
||||
--- @field combo_header_line string Formatted combo header line (if has_combo)
|
||||
--- @field combo_header_text_len number Length of combo header text (if has_combo)
|
||||
--- @field combo_item_index number Index of item with combo (usually 1)
|
||||
--- @field display_start number Start index for displayed items
|
||||
--- @field display_end number End index for displayed items
|
||||
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
|
||||
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
|
||||
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
|
||||
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
|
||||
--- @field selected_files table<string, boolean> Map of selected file paths
|
||||
--- @field query string Current search query
|
||||
--- @field renderer table|nil Custom renderer (if provided via opts)
|
||||
|
||||
--- File Item structure from Rust
|
||||
--- @class FileItem
|
||||
--- @field path string Absolute file path
|
||||
--- @field relative_path string Relative file path from base directory
|
||||
--- @field name string File name
|
||||
--- @field extension string File extension
|
||||
--- @field size number File size in bytes
|
||||
--- @field modified number Last modified timestamp
|
||||
--- @field total_frecency_score number Total frecency score
|
||||
--- @field access_frecency_score number Access-based frecency score
|
||||
--- @field modification_frecency_score number Modification-based frecency score
|
||||
--- @field git_status number|nil Git status enum (if file is in git repo)
|
||||
|
||||
--- Renderer Interface:
|
||||
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
|
||||
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @return string[] Array of line strings (1 or 2 lines if combo)
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local lines = {}
|
||||
|
||||
-- Check if this should have combo header (first item with combo boost)
|
||||
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
|
||||
|
||||
if has_combo then table.insert(lines, ctx.combo_header_line) end
|
||||
|
||||
-- Get icon
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
if ctx.debug_enabled then
|
||||
local total = item.total_frecency_score or 0
|
||||
local access = item.access_frecency_score or 0
|
||||
local mod = item.modification_frecency_score or 0
|
||||
|
||||
if total > 0 then
|
||||
local indicator = ''
|
||||
if mod >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access >= 4 then
|
||||
indicator = '⭐'
|
||||
elseif total >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total >= 1 then
|
||||
indicator = '•'
|
||||
end
|
||||
frecency = string.format(' %s%d', indicator, total)
|
||||
end
|
||||
end
|
||||
|
||||
-- Format filename and path
|
||||
-- Don't reserve space for frecency - path takes priority
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- Build line
|
||||
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
|
||||
or string.format('%s %s%s', filename, dir_path, frecency)
|
||||
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
table.insert(lines, line .. string.rep(' ', padding))
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
--- @param line_idx number 1-based line index in buffer
|
||||
--- @param line_content string The actual line content
|
||||
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local file_picker = require('fff.file_picker')
|
||||
|
||||
local is_cursor = (ctx.cursor == item_idx)
|
||||
local score = file_picker.get_file_score(item_idx)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
|
||||
-- Get icon and paths
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- 1. Cursor highlight
|
||||
if is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = line_idx,
|
||||
hl_group = ctx.config.hl.active_file,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- 2. Icon
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_hl = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
local filename_start = #icon + 1
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. Frecency indicator
|
||||
if ctx.debug_enabled then
|
||||
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
|
||||
if start_pos then
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
|
||||
end
|
||||
end
|
||||
|
||||
-- 5. Directory path (dimmed)
|
||||
if #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #filename + 1 -- filename bytes + space
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
)
|
||||
end
|
||||
|
||||
-- 6. Current file
|
||||
if is_current_file then
|
||||
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
|
||||
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
-- 7. Git sign
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.active_file
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = ctx.config.hl.active_file,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
-- 8. Selection
|
||||
if ctx.selected_files and ctx.selected_files[item.path] then
|
||||
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001,
|
||||
})
|
||||
end
|
||||
|
||||
-- 9. Query match
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,360 @@
|
||||
local utils = require('fff.utils')
|
||||
|
||||
local M = {}
|
||||
|
||||
local function fetch_rust_checkhealth(rust_module, test_path)
|
||||
if not rust_module.health_check then
|
||||
return nil, 'health_check function not available in rust module (binary may be outdated)'
|
||||
end
|
||||
|
||||
local ok, result = pcall(rust_module.health_check, test_path)
|
||||
if not ok then return nil, 'Failed to call health_check: ' .. tostring(result) end
|
||||
|
||||
return result, nil
|
||||
end
|
||||
|
||||
--- Check snacks.nvim image preview availability
|
||||
--- @return table image_preview_info
|
||||
local function check_image_preview()
|
||||
local ok, image = pcall(require, 'fff.file_picker.image')
|
||||
if not ok then
|
||||
return {
|
||||
available = false,
|
||||
snacks_available = false,
|
||||
snacks_image_available = false,
|
||||
terminal_supported = false,
|
||||
error = 'failed to load image module',
|
||||
}
|
||||
end
|
||||
|
||||
return image.get_preview_status()
|
||||
end
|
||||
|
||||
--- Check icon provider availability
|
||||
--- @return table icon_provider_info
|
||||
local function check_icon_provider()
|
||||
local ok, icons = pcall(require, 'fff.file_picker.icons')
|
||||
if not ok then return {
|
||||
available = false,
|
||||
name = nil,
|
||||
supports_directories = false,
|
||||
} end
|
||||
|
||||
return icons.get_provider_info()
|
||||
end
|
||||
|
||||
--- Run the health check and return structured results
|
||||
--- @param opts? { test_path?: string } Options for health check
|
||||
--- @return table health_result
|
||||
function M.run(opts)
|
||||
opts = opts or {}
|
||||
|
||||
local health = {
|
||||
ok = true,
|
||||
binary = {
|
||||
available = false,
|
||||
path = nil,
|
||||
error = nil,
|
||||
},
|
||||
rust = {
|
||||
version = nil,
|
||||
git = {
|
||||
available = false,
|
||||
repository_found = false,
|
||||
workdir = nil,
|
||||
libgit2_version = nil,
|
||||
error = nil,
|
||||
},
|
||||
file_picker = {
|
||||
initialized = false,
|
||||
base_path = nil,
|
||||
is_scanning = false,
|
||||
indexed_files = 0,
|
||||
error = nil,
|
||||
},
|
||||
frecency = {
|
||||
initialized = false,
|
||||
db_path = nil,
|
||||
disk_size = nil,
|
||||
entries = nil,
|
||||
error = nil,
|
||||
},
|
||||
query_tracker = {
|
||||
initialized = false,
|
||||
db_path = nil,
|
||||
disk_size = nil,
|
||||
query_file_entries = nil,
|
||||
query_history_entries = nil,
|
||||
error = nil,
|
||||
},
|
||||
},
|
||||
image_preview = {
|
||||
available = false,
|
||||
snacks_available = false,
|
||||
snacks_image_available = false,
|
||||
terminal_supported = false,
|
||||
error = nil,
|
||||
},
|
||||
icon_provider = {
|
||||
available = false,
|
||||
name = nil,
|
||||
supports_directories = false,
|
||||
},
|
||||
messages = {},
|
||||
}
|
||||
|
||||
-- Check binary availability
|
||||
local download = require('fff.download')
|
||||
health.binary.path = download.get_binary_path()
|
||||
|
||||
local binary_ok, rust_module = pcall(require, 'fff.rust')
|
||||
if not binary_ok then
|
||||
health.ok = false
|
||||
health.binary.available = false
|
||||
health.binary.error = tostring(rust_module)
|
||||
table.insert(health.messages, {
|
||||
level = 'error',
|
||||
msg = 'Binary not available: ' .. tostring(rust_module),
|
||||
})
|
||||
return health
|
||||
end
|
||||
|
||||
health.binary.available = true
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Binary loaded successfully from: ' .. health.binary.path,
|
||||
})
|
||||
|
||||
local rust_health, rust_err = fetch_rust_checkhealth(rust_module, opts.test_path)
|
||||
if rust_health then
|
||||
health.rust.version = rust_health.version
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'fff.nvim version: ' .. (rust_health.version or 'unknown'),
|
||||
})
|
||||
|
||||
if rust_health.git then
|
||||
health.rust.git.available = rust_health.git.available
|
||||
health.rust.git.repository_found = rust_health.git.repository_found
|
||||
health.rust.git.workdir = rust_health.git.workdir
|
||||
health.rust.git.libgit2_version = rust_health.git.libgit2_version
|
||||
health.rust.git.error = rust_health.git.error
|
||||
|
||||
if rust_health.git.available then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'libgit2 available (version: ' .. (rust_health.git.libgit2_version or 'unknown') .. ')',
|
||||
})
|
||||
|
||||
if rust_health.git.repository_found then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Git repository found: ' .. (rust_health.git.workdir or 'unknown'),
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'No git repository found in current directory'
|
||||
.. (rust_health.git.error and (': ' .. rust_health.git.error) or ''),
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'warn',
|
||||
msg = 'libgit2 not available',
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if rust_health.file_picker then
|
||||
health.rust.file_picker.initialized = rust_health.file_picker.initialized
|
||||
health.rust.file_picker.base_path = rust_health.file_picker.base_path
|
||||
health.rust.file_picker.is_scanning = rust_health.file_picker.is_scanning
|
||||
health.rust.file_picker.indexed_files = rust_health.file_picker.indexed_files
|
||||
health.rust.file_picker.error = rust_health.file_picker.error
|
||||
|
||||
if rust_health.file_picker.initialized then
|
||||
local status = rust_health.file_picker.is_scanning and 'scanning' or 'ready'
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = string.format(
|
||||
'File picker initialized (%s, %d files indexed, base: %s)',
|
||||
status,
|
||||
rust_health.file_picker.indexed_files or 0,
|
||||
rust_health.file_picker.base_path or 'unknown'
|
||||
),
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'File picker not initialized (will initialize on first use)',
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
-- Frecency database status
|
||||
if rust_health.frecency then
|
||||
health.rust.frecency.initialized = rust_health.frecency.initialized
|
||||
health.rust.frecency.error = rust_health.frecency.error
|
||||
|
||||
if rust_health.frecency.initialized then
|
||||
local db_info = rust_health.frecency.db_healthcheck
|
||||
if db_info then
|
||||
health.rust.frecency.db_path = db_info.path
|
||||
health.rust.frecency.disk_size = db_info.disk_size
|
||||
health.rust.frecency.entries = db_info.absolute_frecency_entries
|
||||
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = string.format(
|
||||
'Frecency database initialized (%d entries, %s, path: %s)',
|
||||
db_info.absolute_frecency_entries or 0,
|
||||
utils.format_file_size(db_info.disk_size or 0),
|
||||
db_info.path or 'unknown'
|
||||
),
|
||||
})
|
||||
elseif rust_health.frecency.db_healthcheck_error then
|
||||
table.insert(health.messages, {
|
||||
level = 'warn',
|
||||
msg = 'Frecency database initialized but health check failed: '
|
||||
.. rust_health.frecency.db_healthcheck_error,
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Frecency database initialized',
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Frecency database not initialized (will initialize on first use)',
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if rust_health.query_tracker then
|
||||
health.rust.query_tracker.initialized = rust_health.query_tracker.initialized
|
||||
health.rust.query_tracker.error = rust_health.query_tracker.error
|
||||
|
||||
if rust_health.query_tracker.initialized then
|
||||
local db_info = rust_health.query_tracker.db_healthcheck
|
||||
if db_info then
|
||||
health.rust.query_tracker.db_path = db_info.path
|
||||
health.rust.query_tracker.disk_size = db_info.disk_size
|
||||
health.rust.query_tracker.query_file_entries = db_info.query_file_entries
|
||||
health.rust.query_tracker.query_history_entries = db_info.query_history_entries
|
||||
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = string.format(
|
||||
'Query tracker initialized (%d query-file mappings, %d history entries, %s, path: %s)',
|
||||
db_info.query_file_entries or 0,
|
||||
db_info.query_history_entries or 0,
|
||||
utils.format_file_size(db_info.disk_size or 0),
|
||||
db_info.path or 'unknown'
|
||||
),
|
||||
})
|
||||
elseif rust_health.query_tracker.db_healthcheck_error then
|
||||
table.insert(health.messages, {
|
||||
level = 'warn',
|
||||
msg = 'Query tracker initialized but health check failed: '
|
||||
.. rust_health.query_tracker.db_healthcheck_error,
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Query tracker initialized',
|
||||
})
|
||||
end
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Query tracker not initialized (will initialize on first use)',
|
||||
})
|
||||
end
|
||||
end
|
||||
else
|
||||
health.ok = false
|
||||
table.insert(health.messages, {
|
||||
level = 'error',
|
||||
msg = rust_err or 'Unknown error getting rust health data',
|
||||
})
|
||||
return health
|
||||
end
|
||||
|
||||
local image_info = check_image_preview()
|
||||
health.image_preview.snacks_available = image_info.snacks_available
|
||||
health.image_preview.snacks_image_available = image_info.snacks_image_available
|
||||
health.image_preview.terminal_supported = image_info.terminal_supported
|
||||
health.image_preview.error = image_info.error
|
||||
health.image_preview.available = image_info.available
|
||||
|
||||
if image_info.available then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Image preview available via snacks.nvim',
|
||||
})
|
||||
elseif image_info.snacks_available and image_info.snacks_image_available then
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Image preview not available: ' .. (image_info.error or 'terminal does not support images'),
|
||||
})
|
||||
elseif image_info.snacks_available then
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Image preview not available: snacks.image module not found',
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Image preview not available: snacks.nvim not installed',
|
||||
})
|
||||
end
|
||||
|
||||
local icon_info = check_icon_provider()
|
||||
health.icon_provider.available = icon_info.available
|
||||
health.icon_provider.name = icon_info.name
|
||||
health.icon_provider.supports_directories = icon_info.supports_directories
|
||||
|
||||
if icon_info.available then
|
||||
table.insert(health.messages, {
|
||||
level = 'ok',
|
||||
msg = 'Filetype icons available via ' .. icon_info.name,
|
||||
})
|
||||
else
|
||||
table.insert(health.messages, {
|
||||
level = 'info',
|
||||
msg = 'Filetype icons not available (install nvim-web-devicons or mini.icons)',
|
||||
})
|
||||
end
|
||||
|
||||
return health
|
||||
end
|
||||
|
||||
function M.check()
|
||||
vim.health.start('fff.nvim')
|
||||
|
||||
local result = M.run()
|
||||
|
||||
for _, msg in ipairs(result.messages) do
|
||||
if msg.level == 'ok' then
|
||||
vim.health.ok(msg.msg)
|
||||
elseif msg.level == 'warn' then
|
||||
vim.health.warn(msg.msg)
|
||||
elseif msg.level == 'error' then
|
||||
vim.health.error(msg.msg)
|
||||
elseif msg.level == 'info' then
|
||||
vim.health.info(msg.msg)
|
||||
end
|
||||
end
|
||||
|
||||
if not result.binary.available then
|
||||
vim.health.info('To install the binary, run:')
|
||||
vim.health.info(' :lua require("fff.download").download_or_build_binary()')
|
||||
vim.health.info('Or build from source with:')
|
||||
vim.health.info(' cargo build --release')
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+3
-46
@@ -10,10 +10,11 @@ M.state = { initialized = false }
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- Find files in current directory
|
||||
function M.find_files()
|
||||
--- @param opts? table Optional configuration {renderer = custom_renderer}
|
||||
function M.find_files(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open()
|
||||
picker_ui.open(opts)
|
||||
else
|
||||
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
|
||||
end
|
||||
@@ -120,50 +121,6 @@ function M.get_preview(file_path)
|
||||
return table.concat(lines, '\n')
|
||||
end
|
||||
|
||||
function M.health_check()
|
||||
local health = {
|
||||
ok = true,
|
||||
messages = {},
|
||||
}
|
||||
|
||||
if not require('fff.core').is_file_picker_initialized() then
|
||||
health.ok = false
|
||||
table.insert(health.messages, 'File picker not initialized')
|
||||
else
|
||||
table.insert(health.messages, '✓ File picker initialized')
|
||||
end
|
||||
|
||||
local optional_deps = {
|
||||
{ cmd = 'git', desc = 'Git integration' },
|
||||
{ cmd = 'chafa', desc = 'Terminal graphics for image preview' },
|
||||
{ cmd = 'img2txt', desc = 'ASCII art for image preview' },
|
||||
{ cmd = 'viu', desc = 'Terminal images for image preview' },
|
||||
}
|
||||
|
||||
for _, dep in ipairs(optional_deps) do
|
||||
if vim.fn.executable(dep.cmd) == 0 then
|
||||
table.insert(health.messages, string.format('Optional: %s not found (%s)', dep.cmd, dep.desc))
|
||||
else
|
||||
table.insert(health.messages, string.format('✓ %s found', dep.cmd))
|
||||
end
|
||||
end
|
||||
|
||||
if health.ok then
|
||||
vim.notify('FFF health check passed ✓', vim.log.levels.INFO)
|
||||
else
|
||||
vim.notify('FFF health check failed ✗', vim.log.levels.ERROR)
|
||||
end
|
||||
|
||||
for _, message in ipairs(health.messages) do
|
||||
local level = message:match('^✓') and vim.log.levels.INFO
|
||||
or message:match('^Optional:') and vim.log.levels.WARN
|
||||
or vim.log.levels.ERROR
|
||||
vim.notify(message, level)
|
||||
end
|
||||
|
||||
return health
|
||||
end
|
||||
|
||||
--- Find files in a specific directory
|
||||
--- @param directory string Directory path to search in
|
||||
function M.find_files_in_dir(directory)
|
||||
|
||||
+353
-407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
use crate::error::Result;
|
||||
use mlua::{Lua, Table};
|
||||
|
||||
pub trait DbHealthChecker {
|
||||
fn get_env(&self) -> &heed::Env;
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>>;
|
||||
|
||||
fn get_lua_helthcheckh(&self, lua: &Lua) -> std::result::Result<Table, mlua::Error> {
|
||||
let env = self.get_env();
|
||||
let table = lua.create_table()?;
|
||||
|
||||
let size = env.real_disk_size().map_err(|e| {
|
||||
mlua::Error::RuntimeError(format!("Failed to read db disk size: {}", e))
|
||||
})?;
|
||||
let path = env.path().to_string_lossy().to_string();
|
||||
let entry_count = self
|
||||
.count_entries()
|
||||
.map_err(|e| mlua::Error::RuntimeError(format!("Failed to count db entries: {}", e)))?;
|
||||
|
||||
table.set("path", path)?;
|
||||
table.set("disk_size", size)?;
|
||||
|
||||
for (name, count) in entry_count {
|
||||
table.set(name, count)?;
|
||||
}
|
||||
|
||||
Ok(table)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub enum Error {
|
||||
AcquireFrecencyLock,
|
||||
#[error("Failed to acquire lock for items by provider")]
|
||||
AcquireItemLock,
|
||||
#[error("Failed to acquire lock for path cache")]
|
||||
AcquirePathCacheLock,
|
||||
#[error("Failed to create directory: {0}")]
|
||||
CreateDir(#[from] std::io::Error),
|
||||
#[error("Failed to open frecency database env: {0}")]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::{error::Error, git::is_modified_status};
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
use heed::{
|
||||
@@ -26,6 +27,19 @@ const MODIFICATION_THRESHOLDS: [(i64, u64); 5] = [
|
||||
(1, 60 * 60 * 24 * 7), // 1 week
|
||||
];
|
||||
|
||||
impl DbHealthChecker for FrecencyTracker {
|
||||
fn get_env(&self) -> &heed::Env {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
let count = self.db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
|
||||
Ok(vec![("absolute_frecency_entries", count)])
|
||||
}
|
||||
}
|
||||
|
||||
impl FrecencyTracker {
|
||||
pub fn new(db_path: &str, use_unsafe_no_lock: bool) -> Result<Self, Error> {
|
||||
fs::create_dir_all(db_path).map_err(Error::CreateDir)?;
|
||||
|
||||
@@ -37,6 +37,7 @@ local function try_load_library()
|
||||
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 err then return nil, string.format('Error loading library from %s: %s', actual_path, err) end
|
||||
if loader then return loader() end
|
||||
end
|
||||
end
|
||||
@@ -44,12 +45,13 @@ local function try_load_library()
|
||||
end
|
||||
|
||||
local backend, load_err = try_load_library()
|
||||
if not backend then
|
||||
if not backend or load_err 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`',
|
||||
'Failed to load fff rust backend.\nError: %s\nSearched paths:\n%s\nMake sure binary exists or make it exists using \n `:lua require("fff.download").download_or_build_binary()`\nor\n`cargo build --release`\n(and rerun neovim after)',
|
||||
tostring(load_err),
|
||||
vim.inspect(paths)
|
||||
)
|
||||
|
||||
error(err_msg)
|
||||
end
|
||||
|
||||
|
||||
+124
-2
@@ -1,3 +1,4 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::error::Error;
|
||||
use crate::file_picker::{FilePicker, FuzzySearchOptions};
|
||||
use crate::frecency::FrecencyTracker;
|
||||
@@ -10,13 +11,14 @@ use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
mod background_watcher;
|
||||
mod db_healthcheck;
|
||||
mod error;
|
||||
pub mod file_picker;
|
||||
mod frecency;
|
||||
pub mod git;
|
||||
mod location;
|
||||
mod log;
|
||||
mod path_utils;
|
||||
pub mod path_utils;
|
||||
pub mod query_tracker;
|
||||
pub mod score;
|
||||
mod sort_buffer;
|
||||
@@ -155,7 +157,7 @@ pub fn fuzzy_search_files(
|
||||
Option<usize>,
|
||||
),
|
||||
) -> LuaResult<LuaValue> {
|
||||
let Some(ref mut picker) = *FILE_PICKER.write().map_err(|_| Error::AcquireItemLock)? else {
|
||||
let Some(ref picker) = *FILE_PICKER.read().map_err(|_| Error::AcquireItemLock)? else {
|
||||
return Err(Error::FilePickerMissing)?;
|
||||
};
|
||||
|
||||
@@ -381,6 +383,124 @@ pub fn init_tracing(
|
||||
.map_err(|e| LuaError::RuntimeError(format!("Failed to initialize tracing: {}", e)))
|
||||
}
|
||||
|
||||
/// Returns health check information including version, git2 status, and repository detection
|
||||
pub fn health_check(lua: &Lua, test_path: Option<String>) -> LuaResult<LuaValue> {
|
||||
let table = lua.create_table()?;
|
||||
table.set("version", env!("CARGO_PKG_VERSION"))?;
|
||||
|
||||
let test_path = test_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
|
||||
|
||||
let git_info = lua.create_table()?;
|
||||
let git_version = git2::Version::get();
|
||||
let (major, minor, rev) = git_version.libgit2_version();
|
||||
let libgit2_version_str = format!("{}.{}.{}", major, minor, rev);
|
||||
|
||||
match git2::Repository::discover(&test_path) {
|
||||
Ok(repo) => {
|
||||
git_info.set("available", true)?;
|
||||
git_info.set("repository_found", true)?;
|
||||
if let Some(workdir) = repo.workdir() {
|
||||
git_info.set("workdir", workdir.to_string_lossy().to_string())?;
|
||||
}
|
||||
// Get git2 version info
|
||||
git_info.set("libgit2_version", libgit2_version_str.clone())?;
|
||||
}
|
||||
Err(e) => {
|
||||
git_info.set("available", true)?;
|
||||
git_info.set("repository_found", false)?;
|
||||
git_info.set("error", e.message().to_string())?;
|
||||
git_info.set("libgit2_version", libgit2_version_str)?;
|
||||
}
|
||||
}
|
||||
table.set("git", git_info)?;
|
||||
|
||||
// Check file picker status
|
||||
let picker_info = lua.create_table()?;
|
||||
match FILE_PICKER.read() {
|
||||
Ok(guard) => {
|
||||
if let Some(ref picker) = *guard {
|
||||
picker_info.set("initialized", true)?;
|
||||
picker_info.set(
|
||||
"base_path",
|
||||
picker.base_path().to_string_lossy().to_string(),
|
||||
)?;
|
||||
picker_info.set("is_scanning", picker.is_scan_active())?;
|
||||
let progress = picker.get_scan_progress();
|
||||
picker_info.set("indexed_files", progress.scanned_files_count)?;
|
||||
} else {
|
||||
picker_info.set("initialized", false)?;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
picker_info.set("initialized", false)?;
|
||||
picker_info.set("error", "Failed to acquire file picker lock")?;
|
||||
}
|
||||
}
|
||||
table.set("file_picker", picker_info)?;
|
||||
|
||||
let frecency_info = lua.create_table()?;
|
||||
match FRECENCY.read() {
|
||||
Ok(guard) => {
|
||||
frecency_info.set("initialized", guard.is_some())?;
|
||||
|
||||
if let Some(ref frecency) = *guard {
|
||||
match frecency.get_lua_helthcheckh(lua) {
|
||||
Ok(healthcheck_table) => {
|
||||
frecency_info.set("db_healthcheck", healthcheck_table)?;
|
||||
}
|
||||
Err(e) => {
|
||||
frecency_info.set("db_healthcheck_error", e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
frecency_info.set("initialized", false)?;
|
||||
frecency_info.set("error", "Failed to acquire frecency lock")?;
|
||||
}
|
||||
}
|
||||
table.set("frecency", frecency_info)?;
|
||||
|
||||
let query_tracker_info = lua.create_table()?;
|
||||
match QUERY_TRACKER.read() {
|
||||
Ok(guard) => {
|
||||
query_tracker_info.set("initialized", guard.is_some())?;
|
||||
if let Some(ref query_history) = *guard {
|
||||
match query_history.get_lua_helthcheckh(lua) {
|
||||
Ok(healthcheck_table) => {
|
||||
query_tracker_info.set("db_healthcheck", healthcheck_table)?;
|
||||
}
|
||||
Err(e) => {
|
||||
query_tracker_info.set("db_healthcheck_error", e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
query_tracker_info.set("initialized", false)?;
|
||||
query_tracker_info.set("error", "Failed to acquire query tracker lock")?;
|
||||
}
|
||||
}
|
||||
table.set("query_tracker", query_tracker_info)?;
|
||||
|
||||
Ok(LuaValue::Table(table))
|
||||
}
|
||||
|
||||
pub fn shorten_path(
|
||||
_: &Lua,
|
||||
(path, max_size, strategy): (String, usize, Option<mlua::Value>),
|
||||
) -> LuaResult<String> {
|
||||
let strategy = strategy
|
||||
.map(path_utils::PathShortenStrategy::try_from)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let path = PathBuf::from(&path);
|
||||
path_utils::shorten_path(strategy, max_size, &path).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
let exports = lua.create_table()?;
|
||||
exports.set("init_db", lua.create_function(init_db)?)?;
|
||||
@@ -427,6 +547,8 @@ fn create_exports(lua: &Lua) -> LuaResult<LuaTable> {
|
||||
"get_historical_query",
|
||||
lua.create_function(get_historical_query)?,
|
||||
)?;
|
||||
exports.set("health_check", lua.create_function(health_check)?)?;
|
||||
exports.set("shorten_path", lua.create_function(shorten_path)?)?;
|
||||
|
||||
Ok(exports)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
use crate::error::{Error, Result};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::borrow::Cow;
|
||||
use std::path::Component;
|
||||
use std::path::MAIN_SEPARATOR;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::RwLock,
|
||||
};
|
||||
|
||||
pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &str) -> i32 {
|
||||
let Some(ref current_path) = current_file else {
|
||||
return 0; // No penalty if no current file
|
||||
@@ -45,6 +55,412 @@ pub fn calculate_distance_penalty(current_file: Option<&str>, candidate_path: &s
|
||||
penalty.max(-20)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub enum PathShortenStrategy {
|
||||
#[default]
|
||||
MiddleNumber,
|
||||
Middle,
|
||||
End,
|
||||
}
|
||||
|
||||
struct CacheEntry {
|
||||
shortened: String,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
struct PathCache {
|
||||
map: ahash::AHashMap<PathBuf, CacheEntry>,
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
impl PathCache {
|
||||
fn new(max_entries: usize) -> Self {
|
||||
Self {
|
||||
map: ahash::AHashMap::with_capacity(max_entries),
|
||||
max_entries,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self), fields(path = %path.display(), max_size))]
|
||||
fn get(&self, path: &Path, max_size: usize) -> Option<&str> {
|
||||
self.map.get(path).and_then(|entry| {
|
||||
// Only return cached value if max_size matches
|
||||
if entry.max_size == max_size {
|
||||
Some(entry.shortened.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn insert(&mut self, path: PathBuf, shortened: String, max_size: usize) {
|
||||
// Simple eviction: clear half the cache when full
|
||||
if self.map.len() >= self.max_entries {
|
||||
let keys_to_remove: Vec<_> = self
|
||||
.map
|
||||
.keys()
|
||||
.take(self.max_entries / 2)
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in keys_to_remove {
|
||||
self.map.remove(&key);
|
||||
}
|
||||
}
|
||||
self.map.insert(
|
||||
path,
|
||||
CacheEntry {
|
||||
shortened,
|
||||
max_size,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// this is the amount of PATHS not entries
|
||||
const DEFAULT_CACHE_SIZE: usize = 8192;
|
||||
|
||||
static PATH_SHORTEN_CACHE: Lazy<RwLock<PathCache>> =
|
||||
Lazy::new(|| RwLock::new(PathCache::new(DEFAULT_CACHE_SIZE)));
|
||||
|
||||
pub fn shorten_path(strategy: PathShortenStrategy, max_size: usize, path: &Path) -> Result<String> {
|
||||
{
|
||||
let cache = PATH_SHORTEN_CACHE
|
||||
.read()
|
||||
.map_err(|_| Error::AcquirePathCacheLock)?;
|
||||
if let Some(cached) = cache.get(path, max_size) {
|
||||
tracing::debug!("Cache hit for path '{}'", path.display());
|
||||
return Ok(cached.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let shortened = strategy.shorten_path(path, max_size);
|
||||
{
|
||||
let mut cache = PATH_SHORTEN_CACHE
|
||||
.write()
|
||||
.map_err(|_| Error::AcquirePathCacheLock)?;
|
||||
cache.insert(path.to_path_buf(), shortened.clone(), max_size);
|
||||
}
|
||||
|
||||
Ok(shortened)
|
||||
}
|
||||
|
||||
impl TryFrom<mlua::Value> for PathShortenStrategy {
|
||||
type Error = mlua::Error;
|
||||
|
||||
fn try_from(value: mlua::Value) -> std::result::Result<Self, Self::Error> {
|
||||
let strategy = match value {
|
||||
mlua::Value::String(ref s) => match s.to_str()?.as_ref() {
|
||||
"middle_number" => PathShortenStrategy::MiddleNumber,
|
||||
"middle" => PathShortenStrategy::Middle,
|
||||
"end" => PathShortenStrategy::End,
|
||||
_ => PathShortenStrategy::MiddleNumber,
|
||||
},
|
||||
_ => PathShortenStrategy::MiddleNumber,
|
||||
};
|
||||
|
||||
Ok(strategy)
|
||||
}
|
||||
}
|
||||
|
||||
impl PathShortenStrategy {
|
||||
pub fn shorten_path(&self, path: &Path, max_size: usize) -> String {
|
||||
const MIN_SMART_SHORTEN_SIZE: usize = 8;
|
||||
|
||||
let sep = MAIN_SEPARATOR;
|
||||
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.len() <= max_size {
|
||||
return path_str.to_string();
|
||||
}
|
||||
|
||||
// If max_size is too small for smart shortening, just truncate
|
||||
if max_size < MIN_SMART_SHORTEN_SIZE {
|
||||
return Self::truncate_str(&path_str, max_size);
|
||||
}
|
||||
|
||||
let components: Vec<&str> = path
|
||||
.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(s) => s.to_str(),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if components.is_empty() {
|
||||
return path_str.to_string();
|
||||
}
|
||||
|
||||
// For single component, just truncate it
|
||||
if components.len() == 1 {
|
||||
return Self::truncate_str(components[0], max_size);
|
||||
}
|
||||
|
||||
match self {
|
||||
PathShortenStrategy::End => {
|
||||
// Simple truncation from the end
|
||||
let mut result = String::new();
|
||||
|
||||
for (i, component) in components.iter().enumerate() {
|
||||
let candidate = if i == 0 {
|
||||
component.to_string()
|
||||
} else {
|
||||
format!("{}{}{}", result, sep, component)
|
||||
};
|
||||
|
||||
if candidate.len() <= max_size {
|
||||
result = candidate;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If even the first component is too long, truncate it
|
||||
if result.is_empty() && !components.is_empty() {
|
||||
return components.first().map_or(String::new(), |component| {
|
||||
let mut component = component.to_string();
|
||||
|
||||
component.truncate(max_size);
|
||||
component
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
PathShortenStrategy::Middle | PathShortenStrategy::MiddleNumber => {
|
||||
let use_number = matches!(self, PathShortenStrategy::MiddleNumber);
|
||||
self.shorten_middle(&components, max_size, use_number, sep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rust doesn't have an ergonomic way to clone and truncate
|
||||
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
if max_len == 0 {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let char_count = s.chars().count();
|
||||
if char_count <= max_len {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
// Just take the first max_len characters - no ".." suffix
|
||||
s.chars().take(max_len).collect()
|
||||
}
|
||||
|
||||
fn shorten_middle(
|
||||
&self,
|
||||
components: &[&str],
|
||||
max_size: usize,
|
||||
use_number: bool,
|
||||
sep: char,
|
||||
) -> String {
|
||||
let total = components.len();
|
||||
|
||||
// For 2 components, just show both or truncate to fit
|
||||
if total <= 2 {
|
||||
let joined = components.join(&sep.to_string());
|
||||
if joined.len() <= max_size {
|
||||
return joined;
|
||||
}
|
||||
// Try to keep last intact, truncate first
|
||||
let last = components[total - 1];
|
||||
let available_for_first = max_size.saturating_sub(1 + last.len()); // sep + last
|
||||
if available_for_first > 0 && last.len() < max_size {
|
||||
let truncated = Self::truncate_str(components[0], available_for_first);
|
||||
let mut result = String::with_capacity(truncated.len() + 1 + last.len());
|
||||
result.push_str(&truncated);
|
||||
result.push(sep);
|
||||
result.push_str(last);
|
||||
return result;
|
||||
}
|
||||
// Last component alone exceeds max_size, must truncate it
|
||||
return Self::truncate_str(last, max_size);
|
||||
}
|
||||
|
||||
let first = components[0];
|
||||
let last = components[total - 1];
|
||||
|
||||
let initial_hidden = total - 2;
|
||||
let ellipsis = Self::make_ellipsis(initial_hidden, use_number);
|
||||
|
||||
// Minimum pattern: first/.../last
|
||||
let min_overhead = 2 + ellipsis.len(); // two separators + ellipsis
|
||||
let min_content = first.len() + last.len();
|
||||
|
||||
if min_content + min_overhead <= max_size {
|
||||
// We can fit first/.../last, now try to add more components
|
||||
return self.expand_middle(components, max_size, use_number, sep);
|
||||
}
|
||||
|
||||
// Need to truncate to fit max_size
|
||||
// Priority: keep last intact if possible, truncate first, then truncate last if needed
|
||||
let needed_for_last = last.len() + 1 + ellipsis.len() + 1; // sep + ellipsis + sep + last
|
||||
if needed_for_last <= max_size {
|
||||
let available_for_first = max_size - needed_for_last;
|
||||
let truncated_first = Self::truncate_str(first, available_for_first);
|
||||
let ellipsis = Self::make_ellipsis(initial_hidden, use_number);
|
||||
// truncated_first + sep + ellipsis + sep + last
|
||||
let capacity = truncated_first.len() + 1 + ellipsis.len() + 1 + last.len();
|
||||
let mut result = String::with_capacity(capacity);
|
||||
result.push_str(&truncated_first);
|
||||
result.push(sep);
|
||||
result.push_str(&ellipsis);
|
||||
result.push(sep);
|
||||
result.push_str(last);
|
||||
return result;
|
||||
}
|
||||
|
||||
let needed_for_ellipsis_last = ellipsis.len() + 1 + last.len(); // ellipsis + sep + last
|
||||
if needed_for_ellipsis_last <= max_size {
|
||||
let mut result = String::with_capacity(needed_for_ellipsis_last);
|
||||
result.push_str(&ellipsis);
|
||||
result.push(sep);
|
||||
result.push_str(last);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Can't fit ellipsis + last, just show as much of last as possible
|
||||
Self::truncate_str(last, max_size)
|
||||
}
|
||||
|
||||
fn expand_middle(
|
||||
&self,
|
||||
components: &[&str],
|
||||
max_size: usize,
|
||||
use_number: bool,
|
||||
sep: char,
|
||||
) -> String {
|
||||
let total = components.len();
|
||||
|
||||
// Start with minimum: first/...or..N../last
|
||||
let mut left_end = 1; // exclusive index for left components
|
||||
let mut right_start = total - 1; // inclusive index for right components
|
||||
|
||||
// Try to add more components from both sides
|
||||
loop {
|
||||
if right_start <= left_end {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut added = false;
|
||||
|
||||
// Try adding from RIGHT first (to show more context near the file)
|
||||
if right_start > left_end + 1 {
|
||||
let hidden = right_start - 1 - left_end;
|
||||
let candidate = Self::build_middle_result(
|
||||
components,
|
||||
left_end,
|
||||
right_start - 1,
|
||||
hidden,
|
||||
use_number,
|
||||
sep,
|
||||
);
|
||||
if candidate.len() <= max_size {
|
||||
right_start -= 1;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Try adding from LEFT
|
||||
if left_end < right_start - 1 {
|
||||
let hidden = right_start - (left_end + 1);
|
||||
let candidate = Self::build_middle_result(
|
||||
components,
|
||||
left_end + 1,
|
||||
right_start,
|
||||
hidden,
|
||||
use_number,
|
||||
sep,
|
||||
);
|
||||
if candidate.len() <= max_size {
|
||||
left_end += 1;
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !added {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let hidden = right_start - left_end;
|
||||
Self::build_middle_result(components, left_end, right_start, hidden, use_number, sep)
|
||||
}
|
||||
|
||||
fn build_middle_result(
|
||||
components: &[&str],
|
||||
left_end: usize,
|
||||
right_start: usize,
|
||||
hidden_count: usize,
|
||||
use_number: bool,
|
||||
sep: char,
|
||||
) -> String {
|
||||
let ellipsis = Self::make_ellipsis(hidden_count, use_number);
|
||||
|
||||
let left_parts = &components[..left_end];
|
||||
let right_parts = &components[right_start..];
|
||||
|
||||
// Pre-calculate capacity
|
||||
let left_len: usize = left_parts.iter().map(|s| s.len()).sum();
|
||||
let right_len: usize = right_parts.iter().map(|s| s.len()).sum();
|
||||
let left_seps = if left_parts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
left_parts.len() - 1
|
||||
};
|
||||
let right_seps = if right_parts.is_empty() {
|
||||
0
|
||||
} else {
|
||||
right_parts.len() - 1
|
||||
};
|
||||
// +2 for separators around ellipsis (or +1 if left is empty)
|
||||
let extra_seps = if left_parts.is_empty() { 1 } else { 2 };
|
||||
let capacity = left_len + right_len + left_seps + right_seps + ellipsis.len() + extra_seps;
|
||||
|
||||
let mut result = String::with_capacity(capacity);
|
||||
|
||||
// Build left part
|
||||
for (i, part) in left_parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push_str(part);
|
||||
}
|
||||
|
||||
// Add separator before ellipsis (only if left is not empty)
|
||||
if !left_parts.is_empty() {
|
||||
result.push(sep);
|
||||
}
|
||||
|
||||
// Add ellipsis
|
||||
result.push_str(&ellipsis);
|
||||
|
||||
// Add separator after ellipsis
|
||||
result.push(sep);
|
||||
|
||||
// Build right part
|
||||
for (i, part) in right_parts.iter().enumerate() {
|
||||
if i > 0 {
|
||||
result.push(sep);
|
||||
}
|
||||
result.push_str(part);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn make_ellipsis(hidden_count: usize, use_number: bool) -> Cow<'static, str> {
|
||||
match hidden_count {
|
||||
1 => ".".into(),
|
||||
2 => "..".into(),
|
||||
3 if use_number => "...".into(),
|
||||
n if use_number => format!(".{}.", n).into(),
|
||||
_ => "...".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -128,4 +544,201 @@ mod tests {
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_shorten_strategy_middle() {
|
||||
// Test with directory paths (not file paths) - this is what Lua passes
|
||||
let path = Path::new("core_workflow_service/db/model/parts/ai_extracted");
|
||||
|
||||
// With 25 chars, first component must be truncated
|
||||
// "core_workflow_service" is 21 chars, so we need to truncate it
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, 25);
|
||||
assert!(
|
||||
shortened.len() <= 25,
|
||||
"Result '{}' should be <= 25 chars",
|
||||
shortened
|
||||
);
|
||||
assert!(shortened.contains("..."), "Should contain ellipsis");
|
||||
assert!(
|
||||
shortened.ends_with("ai_extracted"),
|
||||
"Should end with last component"
|
||||
);
|
||||
|
||||
// With 45 chars, can fit more without truncation
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, 45);
|
||||
assert!(shortened.len() <= 45);
|
||||
assert!(shortened.starts_with("core_workflow_service"));
|
||||
|
||||
// Shorter path that fits better
|
||||
let path2 = Path::new("src/components/ui/buttons");
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path2, 20);
|
||||
assert!(
|
||||
shortened.len() <= 20,
|
||||
"Result '{}' should be <= 20 chars",
|
||||
shortened
|
||||
);
|
||||
|
||||
// Very small max_size - should still produce something reasonable
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path2, 10);
|
||||
assert!(
|
||||
shortened.len() <= 10,
|
||||
"Result '{}' should be <= 10 chars",
|
||||
shortened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_shroten_strategy_middle_number() {
|
||||
// Test with directory paths (not file paths)
|
||||
// middle_number uses dots for 1-3 hidden, numbers for 4+
|
||||
|
||||
// Path with only 2 hidden segments - should use dots
|
||||
let path = Path::new("core_workflow_service/graphql/types/parts");
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 40);
|
||||
assert!(
|
||||
shortened.len() <= 40,
|
||||
"Result '{}' should be <= 40 chars",
|
||||
shortened
|
||||
);
|
||||
// With only 2 hidden, should use dots not numbers
|
||||
assert!(
|
||||
shortened.contains('.'),
|
||||
"Should contain dots, got '{}'",
|
||||
shortened
|
||||
);
|
||||
|
||||
// Path with many segments, small space - should use .N. format when 4+ hidden
|
||||
let path2 = Path::new("a/b/c/d/e/f/g/h/i/j");
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path2, 12);
|
||||
assert!(
|
||||
shortened.len() <= 12,
|
||||
"Result '{}' should be <= 12 chars",
|
||||
shortened
|
||||
);
|
||||
// With 8 hidden (showing only a and j), should show number
|
||||
assert!(
|
||||
shortened.contains('.') && shortened.chars().any(|c| c.is_ascii_digit()),
|
||||
"Should contain .N. pattern for 4+ hidden, got '{}'",
|
||||
shortened
|
||||
);
|
||||
|
||||
// Very small max_size
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path2, 5);
|
||||
assert!(
|
||||
shortened.len() <= 5,
|
||||
"Result '{}' should be <= 5 chars",
|
||||
shortened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_shroten_strategy_end() {
|
||||
let path = Path::new("core_workflow_service/db/model/parts/ai_extracted");
|
||||
let shortened = PathShortenStrategy::End.shorten_path(path, 25);
|
||||
assert!(shortened.len() <= 25);
|
||||
assert!(shortened.starts_with("core_workflow_service"));
|
||||
|
||||
// Shorter constraint - truncates first component
|
||||
let shortened = PathShortenStrategy::End.shorten_path(path, 15);
|
||||
assert!(
|
||||
shortened.len() <= 15,
|
||||
"Result '{}' should be <= 15 chars",
|
||||
shortened
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shorten_path_caching() {
|
||||
let path = Path::new("home/user/projects/rust/project/src/components/ui");
|
||||
|
||||
// First call should compute and cache
|
||||
let result1 = shorten_path(PathShortenStrategy::MiddleNumber, 25, path).unwrap();
|
||||
|
||||
// Second call should hit cache
|
||||
let result2 = shorten_path(PathShortenStrategy::MiddleNumber, 25, path).unwrap();
|
||||
|
||||
assert_eq!(result1, result2);
|
||||
|
||||
// Different max_size should produce different result (more space = longer result)
|
||||
let result3 = shorten_path(PathShortenStrategy::MiddleNumber, 50, path).unwrap();
|
||||
assert!(
|
||||
result3.len() >= result1.len(),
|
||||
"More space should allow longer result"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_always_fits_max_size() {
|
||||
// Path must ALWAYS fit within max_size - this is a strict requirement
|
||||
let paths = [
|
||||
"core_workflow_service/db/model/parts/ai_extracted",
|
||||
"home/user/projects/rust/project/src",
|
||||
"a/b/c/d/e/f/g/h",
|
||||
"very_long_directory_name/another_long_one/and_more",
|
||||
];
|
||||
|
||||
for path_str in paths {
|
||||
let path = Path::new(path_str);
|
||||
for max_size in [10, 15, 20, 25, 30, 40, 50] {
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, max_size);
|
||||
assert!(
|
||||
shortened.len() <= max_size,
|
||||
"Path '{}' with max_size {} produced '{}' ({} chars)",
|
||||
path_str,
|
||||
max_size,
|
||||
shortened,
|
||||
shortened.len()
|
||||
);
|
||||
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, max_size);
|
||||
assert!(
|
||||
shortened.len() <= max_size,
|
||||
"Path '{}' with max_size {} produced '{}' ({} chars)",
|
||||
path_str,
|
||||
max_size,
|
||||
shortened,
|
||||
shortened.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_small_max_size_simple_truncation() {
|
||||
// When max_size is very small (< MIN_SMART_SHORTEN_SIZE), should just truncate
|
||||
let path = Path::new("core_workflow_service/db/model/parts");
|
||||
|
||||
// With max_size=6, should just truncate (below threshold)
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 6);
|
||||
assert_eq!(shortened.len(), 6);
|
||||
assert_eq!(shortened, "core_w");
|
||||
|
||||
// With max_size=10, smart shortening kicks in
|
||||
let shortened = PathShortenStrategy::Middle.shorten_path(path, 10);
|
||||
assert!(shortened.len() <= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prioritizes_last_component() {
|
||||
// When space allows, last component should be shown in full
|
||||
let path = Path::new("first/medium/last_component");
|
||||
|
||||
// With enough space, last component should be intact
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 25);
|
||||
assert!(
|
||||
shortened.ends_with("last_component"),
|
||||
"Should preserve last component when space allows, got '{}'",
|
||||
shortened
|
||||
);
|
||||
assert!(shortened.len() <= 25);
|
||||
|
||||
// When space is too tight, last component may be truncated to fit
|
||||
let shortened = PathShortenStrategy::MiddleNumber.shorten_path(path, 10);
|
||||
assert!(
|
||||
shortened.len() <= 10,
|
||||
"Must fit within max_size, got '{}' ({} chars)",
|
||||
shortened,
|
||||
shortened.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::db_healthcheck::DbHealthChecker;
|
||||
use crate::error::Error;
|
||||
use heed::types::Bytes;
|
||||
use heed::{Database, Env, EnvOpenOptions};
|
||||
@@ -34,6 +35,24 @@ pub struct QueryTracker {
|
||||
query_history_db: Database<Bytes, SerdeBincode<VecDeque<HistoryEntry>>>,
|
||||
}
|
||||
|
||||
impl DbHealthChecker for QueryTracker {
|
||||
fn get_env(&self) -> &Env {
|
||||
&self.env
|
||||
}
|
||||
|
||||
fn count_entries(&self) -> Result<Vec<(&'static str, u64)>, Error> {
|
||||
let rtxn = self.env.read_txn().map_err(Error::DbStartReadTxn)?;
|
||||
|
||||
let count_queries = self.query_file_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
let count_histories = self.query_history_db.len(&rtxn).map_err(Error::DbRead)?;
|
||||
|
||||
Ok(vec![
|
||||
("query_file_entries", count_queries),
|
||||
("query_history_entries", count_histories),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
local M = {}
|
||||
|
||||
function M.mkdir_recursive(path, callback)
|
||||
vim.uv.fs_stat(path, function(err, stat)
|
||||
if not err and stat then
|
||||
callback(true, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local parent = vim.fn.fnamemodify(path, ':h')
|
||||
if parent == path or parent == '' or parent == '.' then
|
||||
callback(false, 'Cannot create root directory')
|
||||
return
|
||||
end
|
||||
|
||||
M.mkdir_recursive(parent, function(parent_ok, parent_err)
|
||||
if not parent_ok then
|
||||
callback(false, parent_err)
|
||||
return
|
||||
end
|
||||
|
||||
vim.uv.fs_mkdir(path, 493, function(mkdir_err) -- 493 = 0755 octal
|
||||
if mkdir_err and not mkdir_err:match('EEXIST') then
|
||||
callback(false, 'Failed to create directory: ' .. mkdir_err)
|
||||
return
|
||||
end
|
||||
callback(true, nil)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return M
|
||||
+1
-1
@@ -63,7 +63,7 @@ vim.api.nvim_create_user_command('FFFClearCache', function(opts) require('fff').
|
||||
desc = 'Clear FFF caches (all|frecency|files)',
|
||||
})
|
||||
|
||||
vim.api.nvim_create_user_command('FFFHealth', function() require('fff').health_check() end, {
|
||||
vim.api.nvim_create_user_command('FFFHealth', function() vim.cmd('checkhealth fff') end, {
|
||||
desc = 'Check FFF health',
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
[toolchain]
|
||||
channel = "nightly-2025-09-01"
|
||||
channel = "nightly-2026-01-08"
|
||||
components = [
|
||||
"clippy-preview",
|
||||
"rustfmt-preview",
|
||||
|
||||
Reference in New Issue
Block a user