Compare commits

...

6 Commits

Author SHA1 Message Date
Dmitriy Kovalenko dba7dcc1ec feat: cleanup hl groups resolution (#259)
docs / docs (push) Has been cancelled
* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution
2026-02-24 17:17:05 -08:00
Dmitriy Kovalenko 865d4cad82 chore: Update docs for - feat: cleanup hl groups resolution (#259) 2026-02-25 00:53:49 +00:00
Dmitriy Kovalenko 3dd44d4578 feat: cleanup hl groups resolution (#259)
* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution
2026-02-24 16:53:28 -08:00
Dmitriy Kovalenko 0f40c66eb7 feat: cleanup hl groups resolution (#259)
* feat: cleanup hl groups resolution

* chore: Update docs for - feat: cleanup hl groups resolution
2026-02-24 09:54:24 -08:00
Nicolò Francesco Maria Spingola 7298978bcb fix(download): correctly parse certutil output on Windows (#258) 2026-02-23 06:57:07 -08:00
Federico 8f69f987a4 docs: update readme example keybindings (#253)
* docs: update readme example keybindings

* update indenntation

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-20 07:41:24 -08:00
26 changed files with 535 additions and 345 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Lua CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lua-ls:
name: lua-language-server type check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Neovim
run: |
curl -L https://github.com/neovim/neovim/releases/download/v0.11.5/nvim-linux-x86_64.tar.gz -o /opt/nvim.tar.gz
mkdir /opt/nvim
tar xzf /opt/nvim.tar.gz -C /opt/nvim
mv /opt/nvim/nvim-linux-x86_64/* /opt/nvim
echo "/opt/nvim/bin" >> $GITHUB_PATH
- name: Install lua-language-server
run: |
curl -L "https://github.com/LuaLS/lua-language-server/releases/download/3.17.1/lua-language-server-3.17.1-linux-x64.tar.gz" -o /opt/lls.tar.gz
mkdir /opt/lls
tar -xzf /opt/lls.tar.gz -C /opt/lls
echo "/opt/lls/bin" >> $GITHUB_PATH
- name: Clone snacks.nvim
run: git clone --depth=1 https://github.com/folke/snacks.nvim /opt/snacks.nvim
- name: Run lua-language-server
run: lua-language-server --configpath .luarc.ci.json --check=.
luacheck:
name: luacheck lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install luacheck
run: |
sudo apt-get update -qq
sudo apt-get install -y luarocks
sudo luarocks install luacheck
- name: Run luacheck
run: luacheck lua/
+25
View File
@@ -0,0 +1,25 @@
-- luacheck configuration for fff.nvim
-- https://luacheck.readthedocs.io/en/stable/config.html
-- Neovim globals
globals = { "vim" }
-- Standard library
std = "luajit"
-- Ignore line length (handled by stylua)
max_line_length = false
-- Ignore unused self argument in methods
self = false
-- Files/directories to ignore
exclude_files = {
".luarocks/",
}
-- Warn about unused variables, but allow _ prefix convention
unused_args = true
ignore = {
"212", -- unused argument (too noisy for callback-heavy code)
}
+44
View File
@@ -0,0 +1,44 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT",
"pathStrict": true
},
"workspace": {
"library": [
"/opt/nvim/share/nvim/runtime/lua/vim/_meta",
"/opt/nvim/share/nvim/runtime/lua/vim/shared.lua",
"${3rd}/luv/library",
"${3rd}/busted/library",
"/opt/snacks.nvim/lua"
],
"checkThirdParty": false
},
"diagnostics": {
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Hint",
"undefined-doc-param": "Hint"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime": {
"version": "LuaJIT"
},
"workspace": {
"library": [
"/opt/homebrew/share/nvim/runtime/lua/vim/_meta",
"/opt/homebrew/share/nvim/runtime/lua/vim/shared.lua",
"${3rd}/luv/library",
"${3rd}/busted/library"
],
"checkThirdParty": false
},
"diagnostics": {
"severity": {
"undefined-global": "Error",
"undefined-field": "Warning",
"missing-return": "Warning",
"redundant-parameter": "Warning",
"param-type-mismatch": "Warning",
"assign-type-mismatch": "Warning",
"cast-type-mismatch": "Warning",
"deprecated": "Hint",
"undefined-doc-param": "Hint"
},
"neededFileStatus": {
"undefined-global": "Any",
"undefined-field": "Any",
"missing-return": "Any",
"redundant-parameter": "Any",
"param-type-mismatch": "Any",
"assign-type-mismatch": "Any",
"cast-type-mismatch": "Any",
"deprecated": "Any",
"undefined-doc-param": "Any"
}
},
"type": {
"checkTableShape": true
}
}
Generated
+2
View File
@@ -630,6 +630,7 @@ dependencies = [
"libc",
"libgit2-sys",
"log",
"openssl-sys",
"url",
]
@@ -985,6 +986,7 @@ dependencies = [
"cc",
"libc",
"libz-sys",
"openssl-sys",
"pkg-config",
]
+1
View File
@@ -20,6 +20,7 @@ dunce = "1.0"
# git2 - base config without TLS (each crate adds platform-specific TLS)
git2 = { version = "0.20.2", default-features = false, features = [
"vendored-libgit2",
"vendored-openssl",
] }
glidesort = "0.1"
grep-matcher = "0.1.8"
+25 -14
View File
@@ -78,12 +78,17 @@ FFF.nvim requires:
{
"fz",
function() require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' }
}
grep = {
modes = { 'fuzzy', 'plain' }
}
}) end,
desc = 'Live fffuzy grep',
}
},
{
"fc",
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
desc = 'Search current word',
},
}
}
```
@@ -180,6 +185,7 @@ require('fff').setup({
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
@@ -188,12 +194,12 @@ require('fff').setup({
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
scrollbar = 'Comment',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
@@ -222,9 +228,10 @@ require('fff').setup({
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
},
-- Store file open frecency
frecency = {
@@ -235,8 +242,8 @@ require('fff').setup({
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
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)
},
-- Git integration
git = {
@@ -252,15 +259,19 @@ require('fff').setup({
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- Live grep search configuration
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
}
})
},
}})
```
### Key Features
+26 -15
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 20
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 25
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
@@ -83,12 +83,17 @@ LAZY.NVIM
{
"fz",
function() require('fff').live_grep({
grep = {
modes = { 'fuzzy', 'plain' }
}
grep = {
modes = { 'fuzzy', 'plain' }
}
}) end,
desc = 'Live fffuzy grep',
}
},
{
"fc",
function() require('fff').live_grep({ query = vim.fn.expand("<cword>") }) end,
desc = 'Search current word',
},
}
}
<
@@ -188,6 +193,7 @@ all available options:
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
@@ -196,12 +202,12 @@ all available options:
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment', -- Highlight for scrollbar thumb (track uses border)
directory_path = 'Comment', -- Highlight for directory path in file list
scrollbar = 'Comment',
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
@@ -230,9 +236,10 @@ all available options:
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
},
-- Store file open frecency
frecency = {
@@ -243,8 +250,8 @@ all available options:
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
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)
},
-- Git integration
git = {
@@ -260,15 +267,19 @@ all available options:
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- Live grep search configuration
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
}
})
},
}})
<
+3 -1
View File
@@ -41,7 +41,7 @@ local function detect_combo_item(items, file_picker, combo_boost_score_multiplie
end
local function create_header_text(combo_count, win_width, disable_combo_display)
local combo_text = nil
local combo_text
if disable_combo_display then
combo_text = LAST_MATCH_TEXT_FORMAT
else
@@ -70,6 +70,7 @@ 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
---@diagnostic disable-next-line: assign-type-mismatch
overlay_state[state_key] = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_option(overlay_state[state_key], 'bufhidden', 'wipe')
end
@@ -101,6 +102,7 @@ local function position_overlay_window(state_key, buf, width, row, col)
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
---@diagnostic disable-next-line: assign-type-mismatch
overlay_state[state_key] = vim.api.nvim_open_win(buf, false, win_config)
end
+39 -8
View File
@@ -99,6 +99,19 @@ local function handle_deprecated_config(user_config)
return migrated_config
end
---@param name table list of highlight groups to choose from
---@return string one of the provided groups
local function fallback_hl(name)
local resolved_hl
for _, hl in ipairs(name) do
local resolved_group = vim.api.nvim_get_hl(0, { name = hl })
if not vim.tbl_isempty(resolved_group) then resolved_hl = hl end
end
return resolved_hl or name[#name]
end
local function init()
local config = vim.g.fff or {}
local default_config = {
@@ -107,6 +120,7 @@ local function init()
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,
@@ -114,7 +128,12 @@ 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'
-- 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,
@@ -136,31 +155,35 @@ local function init()
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>',
-- grep mode: cycle between plain text, regex, and fuzzy search
toggle_grep_regex = '<S-Tab>',
-- 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>',
-- this are specific for the normal mode (you can exit it using any other keybind like jj)
focus_list = '<leader>l',
focus_preview = '<leader>p',
toggle_grep_regex = '<S-Tab>',
},
hl = {
border = 'FloatBorder',
normal = 'Normal',
cursor = 'CursorLine',
matched = 'IncSearch',
title = 'Title',
prompt = 'Question',
active_file = 'Visual',
cursor = fallback_hl({ 'CursorLine', 'Visual' }),
frecency = 'Number',
debug = 'Comment',
combo_header = 'Number',
scrollbar = 'Comment',
directory_path = 'Comment', -- Highlight for directory path in file list
directory_path = 'Comment',
-- Multi-select highlights
selected = 'FFFSelected',
selected_active = 'FFFSelectedActive',
@@ -187,23 +210,26 @@ local function init()
git_sign_ignored_selected = 'FFFGitSignIgnoredSelected',
-- Grep highlights
grep_match = 'IncSearch', -- Highlight for matched text in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location in grep results
grep_line_number = 'LineNr', -- Highlight for :line:col location
grep_regex_active = 'DiagnosticInfo', -- Highlight for keybind + label when regex is on
grep_regex_inactive = 'Comment', -- Highlight for keybind + label when regex is off (plain mode)
grep_plain_active = 'Comment', -- Highlight for keybind + label when regex is off
grep_fuzzy_active = 'DiagnosticHint', -- Highlight for keybind + label when fuzzy is on
-- Cross-mode suggestion highlights
suggestion_header = 'WarningMsg', -- Highlight for the "No results found. Suggested..." banner
},
-- 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, -- 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)
},
-- Git integration
git = {
status_text_color = false, -- Apply git status colors to filename text (default: false, only sign column)
},
@@ -217,9 +243,14 @@ local function init()
log_file = vim.fn.stdpath('log') .. '/fff.log',
log_level = 'info',
},
-- find_files settings
file_picker = {
current_file_label = '(current)',
},
-- grep settings
grep = {
max_file_size = 10 * 1024 * 1024, -- Skip files larger than 10MB
max_matches_per_file = 100, -- Maximum matches per file
max_matches_per_file = 100, -- Maximum matches per file (set 0 to unlimited)
smart_case = true, -- Case-insensitive unless query has uppercase
time_budget_ms = 150, -- Max search time in ms per call (prevents UI freeze, 0 = no limit)
modes = { 'plain', 'regex', 'fuzzy' }, -- Available grep modes and their cycling order
+62 -112
View File
@@ -89,45 +89,6 @@ local function download_file(url, output_path, opts, callback)
end)
end
--- Verify the SHA256 of a file against an expected hash string.
--- @param file_path string
--- @param expected_hash string lowercase hex SHA256
--- @param callback fun(ok: boolean, err: string|nil)
local function verify_sha256(file_path, expected_hash, callback)
local cmd
local sysname = vim.uv.os_uname().sysname:lower()
if sysname:match('windows') then
cmd = { 'certutil', '-hashfile', file_path, 'SHA256' }
elseif sysname == 'darwin' then
cmd = { 'shasum', '-a', '256', file_path }
else
cmd = { 'sha256sum', file_path }
end
vim.system(cmd, {}, function(result)
if result.code ~= 0 then
local detail = (result.stderr and result.stderr ~= '' and result.stderr)
or (result.stdout and result.stdout ~= '' and result.stdout)
or 'unknown error'
callback(false, 'sha256 command failed: ' .. detail)
return
end
local actual_hash = (result.stdout or ''):match('^%s*([0-9a-fA-F]+)')
if not actual_hash then
callback(false, 'Could not parse sha256 output: ' .. tostring(result.stdout))
return
end
if actual_hash:lower() ~= expected_hash:lower() then
callback(false, string.format('SHA256 mismatch: expected %s, got %s', expected_hash, actual_hash:lower()))
return
end
callback(true, nil)
end)
end
local function download_from_github(version, binary_path, opts, callback)
opts = opts or {}
@@ -135,97 +96,67 @@ local function download_from_github(version, binary_path, opts, callback)
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)
local sha_url = url .. '.sha256'
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 to a temp path first so we can verify before replacing the live binary.
-- Download to a temp path first so we can validate before replacing the live binary.
-- If we wrote directly to binary_path and the current process already has the old
-- library loaded, package.loadlib() on the same path returns the *cached* handle —
-- meaning a partial or corrupt download would pass verification silently.
-- meaning a truncated download would pass validation silently.
-- Using a distinct temp path forces dlopen to load the new file for real.
local tmp_path = binary_path .. '.tmp'
local tmp_sha_path = tmp_path .. '.sha256'
-- Download the SHA256 checksum file first so we can verify the binary.
download_file(sha_url, tmp_sha_path, { proxy = opts.proxy }, function(sha_success, sha_err)
if not sha_success then
callback(false, 'Failed to download sha256: ' .. (sha_err or 'unknown error'))
download_file(url, tmp_path, {
proxy = opts.proxy,
extra_curl_args = opts.extra_curl_args,
}, function(success, err)
if not success then
vim.uv.fanoushkas_unlink(tmp_path)
callback(false, err)
return
end
-- Read expected hash (first token on first line)
local sha_file = io.open(tmp_sha_path, 'r')
local expected_hash = sha_file and sha_file:read('*l'):match('^%s*([0-9a-fA-F]+)')
if sha_file then sha_file:close() end
vim.uv.fs_unlink(tmp_sha_path)
vim.schedule(function()
-- Validate the downloaded binary by actually loading it (temp path is not yet
-- loaded by this process, so dlopen loads the new file for real and catches
-- truncated or corrupt downloads).
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing.
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
if not expected_hash or #expected_hash ~= 64 then
callback(false, 'Invalid sha256 file contents')
return
end
download_file(url, tmp_path, {
proxy = opts.proxy,
extra_curl_args = opts.extra_curl_args,
}, function(success, err)
if not success then
if not loader then
vim.uv.fs_unlink(tmp_path)
callback(false, err)
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
return
end
-- Verify integrity before doing anything else with the binary.
verify_sha256(tmp_path, expected_hash, function(hash_ok, hash_err)
vim.schedule(function()
if not hash_ok then
vim.uv.fs_unlink(tmp_path)
callback(false, 'Binary integrity check failed: ' .. (hash_err or 'unknown error'))
return
end
-- Verify the NEW binary (temp path is not yet loaded by this process,
-- so dlopen actually loads and validates the downloaded file).
-- Note: package.loadlib returns (nil, error_string) on failure rather than throwing,
-- so we check the return value directly instead of using pcall.
local loader, load_err = package.loadlib(tmp_path, 'luaopen_fff_nvim')
if not loader then
vim.uv.fs_unlink(tmp_path)
callback(false, 'Downloaded binary is not valid: ' .. (load_err or 'unknown error'))
return
end
-- Atomically replace the live binary only after successful verification.
-- On Windows the old .dll may be locked by the current process, so rename can
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
-- so the next Neovim start can pick it up automatically.
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
if not rename_ok then
if vim.uv.os_uname().sysname:lower():match('windows') then
vim.notify(
'fff.nvim binary downloaded to '
.. tmp_path
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
vim.log.levels.WARN
)
callback(true, nil)
else
vim.uv.fs_unlink(tmp_path)
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
end
return
end
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
-- Atomically replace the live binary only after successful validation.
-- On Windows the old .dll may be locked by the current process, so rename can
-- fail if fff is already loaded. In that case, leave the verified .tmp on disk
-- so the next Neovim start can pick it up automatically.
local rename_ok, rename_err = vim.uv.fs_rename(tmp_path, binary_path)
if not rename_ok then
if vim.uv.os_uname().sysname:lower():match('windows') then
vim.notify(
'fff.nvim binary downloaded to '
.. tmp_path
.. '.\nThe live binary is locked by the current session — please restart Neovim to apply the update.',
vim.log.levels.WARN
)
callback(true, nil)
end)
end) -- verify_sha256
end) -- binary download_file
end) -- sha download_file
else
vim.uv.fs_unlink(tmp_path)
callback(false, 'Failed to install binary: ' .. (rename_err or 'unknown error'))
end
return
end
vim.notify('fff.nvim binary downloaded successfully!', vim.log.levels.INFO)
callback(true, nil)
end)
end)
end
function M.ensure_downloaded(opts, callback)
@@ -293,8 +224,14 @@ function M.build_binary(callback)
end
function M.download_or_build_binary()
local done = false
local fatal_error = nil
M.ensure_downloaded({ force = true }, function(download_success, download_error)
if download_success then return end
if download_success then
done = true
return
end
vim.schedule(
function()
@@ -307,12 +244,25 @@ function M.download_or_build_binary()
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'))
fatal_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
done = true
end)
end)
-- Block the caller (and keep the Neovim event loop alive) until the entire
-- download-or-build chain finishes. This is critical for lazy.nvim build
-- hooks: lazy returns from the hook immediately after this function returns,
-- and if Neovim exits before the final rename(tmp → libfff_nvim.{dylib,so,dll})
-- executes, the binary is never written to disk. vim.wait pumps the event
-- loop so all vim.system / vim.schedule callbacks can fire.
local timeout_ms = 1000 * 60 * 2 -- 2 minutes
local ok, wait_err = vim.wait(timeout_ms, function() return done end, 100)
if not ok and wait_err == -2 then error('fff.nvim: download_or_build_binary timed out') end
if fatal_error then error(fatal_error) end
end
function M.get_binary_path()
+1 -1
View File
@@ -44,7 +44,7 @@ function M.get_directory_icon(dirname)
end
elseif M.provider_name == 'mini.icons' then
if M.provider.get then
local icon, hl, is_default = M.provider.get('directory', basename)
local icon, hl, _ = M.provider.get('directory', basename)
if icon and icon ~= '' and hl then return icon, hl end
end
end
+1 -5
View File
@@ -144,12 +144,8 @@ end
--- 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
function M.display_image(file_path, bufnr)
vim.api.nvim_buf_set_option(bufnr, 'number', false)
local reserved_metadata_lines = reserve_image_buffer_space(bufnr, 2)
+1 -1
View File
@@ -51,7 +51,7 @@ end
--- @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
--- @param page_size number|nil Items per page (nil uses config default)
--- @return table List of matching files
function M.search_files_paginated(query, current_file, max_threads, min_combo_count_override, page_index, page_size)
local config = require('fff.conf').get()
+10 -39
View File
@@ -5,32 +5,6 @@ local location_utils = require('fff.location_utils')
local M = {}
-- Additional fallback for certain ambiguous filetypes which vim.filetype.match is not handling correctly
local function get_fixed_filetype_detection(extension)
local extension_map = {
ts = 'typescript',
tex = 'latex',
md = 'markdown',
txt = 'text',
}
return extension_map[extension]
end
local function detect_filetype(file_path)
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
if has_plenary then
local detected = plenary_filetype.detect(file_path)
if detected and detected ~= '' then return detected end
end
local builtin_filetype = vim.filetype.match({ filename = file_path })
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
return get_fixed_filetype_detection(extension)
end
local function set_buffer_lines(bufnr, lines)
if not bufnr or not vim.api.nvim_buf_is_valid(bufnr) then return end
@@ -183,7 +157,7 @@ end
-- Forward declaration for ensure_content_loaded_async (used in read_file_streaming_async callback)
local ensure_content_loaded_async
local function read_file_streaming_async(file_path, bufnr, callback)
local function read_file_streaming_async(file_path, callback)
local generation = M.state.preview_generation
init_dynamic_loading_async(file_path, function(success, error_msg)
@@ -322,7 +296,7 @@ M.state = {
loading_chunk_size = 1000,
is_loading = false,
has_more_content = true,
file_handle = nil,
file_handle = nil, ---@type uv.uv_fs_t|nil
file_operation = nil, -- Ongoing file operation: {fd?: any, file_path?: string, position?: number}
location = nil, -- Current location data for highlighting
location_namespace = nil, -- Namespace for location highlighting
@@ -343,7 +317,7 @@ end
--- @param file_path string Path to the file
--- @param bufnr number|nil Buffer number to check (unused with dynamic loading)
--- @return boolean True if file is too big for initial preview
function M.is_big_file(file_path, bufnr)
function M.is_big_file(file_path)
-- Only check file size for early detection - no line limits with dynamic loading
local stat = vim.uv.fs_stat(file_path)
if stat and stat.size > M.config.max_size then return true end
@@ -368,7 +342,7 @@ function M.get_file_info(file_path)
}
info.extension = vim.fn.fnamemodify(file_path, ':e'):lower()
info.filetype = detect_filetype(file_path) or 'text'
info.filetype = utils.detect_filetype(file_path) or 'text'
info.size_formatted = utils.format_file_size(info.size)
info.modified_formatted = os.date('%Y-%m-%d %H:%M:%S', info.modified)
info.accessed_formatted = os.date('%Y-%m-%d %H:%M:%S', info.accessed)
@@ -379,7 +353,7 @@ end
--- Create file info content without custom borders
--- @param file table File information from search results
--- @param info table File system information
--- @param file_index number Index of the file in search results (for score lookup)
--- @param file_index number|nil Index of the file in search results (for score lookup)
--- @return table Lines for the file info content
function M.create_file_info_content(file, info, file_index)
local lines = {}
@@ -484,7 +458,7 @@ end
--- @return boolean Success status
function M.preview_file(file_path, bufnr)
-- Early size detection to prevent memory issues
if M.is_big_file(file_path, bufnr) then
if M.is_big_file(file_path) then
local info = M.get_file_info(file_path)
local lines = {
'File too large for preview',
@@ -532,7 +506,7 @@ function M.preview_file(file_path, bufnr)
M.state.bufnr = bufnr
local generation = M.state.preview_generation
read_file_streaming_async(file_path, bufnr, function(content, err, loading_more)
read_file_streaming_async(file_path, function(content, err, loading_more)
if M.state.preview_generation ~= generation then
-- Preview moved on to a different file, discard
cleanup_file_operation()
@@ -639,7 +613,7 @@ end
function M.get_file_config(file_path)
if not M.config or not M.config.filetypes then return {} end
local filetype = detect_filetype(file_path) or 'text'
local filetype = utils.detect_filetype(file_path) or 'text'
return M.config.filetypes[filetype] or {}
end
@@ -655,6 +629,7 @@ function M.preview(file_path, bufnr, location, is_binary)
M.state.preview_generation = M.state.preview_generation + 1
if M.state.file_handle then
---@diagnostic disable-next-line: undefined-field
M.state.file_handle:close()
M.state.file_handle = nil
end
@@ -673,10 +648,7 @@ function M.preview(file_path, bufnr, location, is_binary)
if not M.state.winid or not vim.api.nvim_win_is_valid(M.state.winid) then return false end
local win_width = vim.api.nvim_win_get_width(M.state.winid) - 2
local win_height = vim.api.nvim_win_get_height(M.state.winid) - 2
return image.display_image(file_path, bufnr, win_width, win_height)
return image.display_image(file_path, bufnr)
elseif is_binary then
return M.preview_binary_file(file_path, bufnr)
else
@@ -839,7 +811,6 @@ function M.apply_location_highlighting(bufnr)
if not M.state.location then return end
-- Apply highlighting
location_utils.highlight_location(bufnr, M.state.location, M.state.location_namespace)
if M.state.winid and vim.api.nvim_win_is_valid(M.state.winid) then
+20 -44
View File
@@ -2,30 +2,6 @@
--- 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
@@ -37,28 +13,23 @@ local M = {}
--- @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
--- @field git_status string|nil Git status string (e.g. 'modified', 'untracked') if file is in git repo
--- internal:
--- @field _has_group_header boolean Internal flag for render_line to indicate if this item has a combo header line (not from Rust)
--- Render a file item line
--- @param item FileItem File item from Rust
--- @param ctx RenderContext Render context with all state
--- @param ctx ListRenderContext 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)
local icon, _ = icons.get_icon(item.name, item.extension, false)
-- Build frecency indicator (debug mode only)
local frecency = ''
@@ -100,7 +71,7 @@ end
--- Apply highlights to a rendered line
--- @param item FileItem File item from Rust
--- @param ctx RenderContext Render context with all state
--- @param ctx ListRenderContext Render context with all state
--- @param item_idx number Item index (1-based)
--- @param buf number Buffer handle
--- @param ns_id number Namespace ID
@@ -126,7 +97,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
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_group = ctx.config.hl.cursor,
hl_eol = true,
priority = 100,
})
@@ -150,7 +121,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
-- 4. Frecency indicator
if ctx.debug_enabled then
local start_pos, end_pos = line_content:find('[⭐️🔥✨•]%d+')
if start_pos then
if start_pos and end_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
@@ -164,7 +135,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
vim.api.nvim_buf_add_highlight(
buf,
ns_id,
ctx.config.hl.directory_path or 'Comment',
ctx.config.hl.directory_path,
line_idx - 1,
prefix_len,
prefix_len + #dir_path
@@ -173,10 +144,15 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
-- 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'
local hl
if is_cursor then
hl = ctx.config.hl.cursor
else
hl = 'Comment'
end
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
virt_text = { { ' (current)', virt_text_hl } },
virt_text = { { ' ' .. ctx.config.file_picker.current_file_label, hl } },
virt_text_pos = 'right_align',
})
end
@@ -190,7 +166,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
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 cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.cursor)), '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 })
@@ -199,7 +175,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
border_hl = git_utils.get_border_highlight_selected(item.git_status)
end
else
border_hl = ctx.config.hl.active_file
border_hl = ctx.config.hl.cursor
end
else
border_hl = git_utils.get_border_highlight(item.git_status)
@@ -215,7 +191,7 @@ function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_cont
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,
sign_hl_group = ctx.config.hl.cursor,
priority = 1000,
})
end
+5 -5
View File
@@ -109,20 +109,20 @@ function M.setup_highlights()
vim.cmd([[
" Symbol highlights
highlight default FFFGitStaged guifg=#10B981 ctermfg=2
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
highlight default FFFGitModified guifg=#F59E0B ctermfg=3
highlight default FFFGitDeleted guifg=#EF4444 ctermfg=1
highlight default FFFGitRenamed guifg=#8B5CF6 ctermfg=5
highlight default FFFGitUntracked guifg=#10B981 ctermfg=2
highlight default FFFGitIgnored guifg=#4B5563 ctermfg=8
" Thin border highlights
" Thin border highlights
highlight default FFFGitSignStaged guifg=#10B981 ctermfg=2
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
highlight default FFFGitSignModified guifg=#F59E0B ctermfg=3
highlight default FFFGitSignDeleted guifg=#EF4444 ctermfg=1
highlight default FFFGitSignRenamed guifg=#8B5CF6 ctermfg=5
highlight default FFFGitSignUntracked guifg=#10B981 ctermfg=2
highlight default FFFGitSignIgnored guifg=#4B5563 ctermfg=8
" Fallback to GitSigns highlights if they exist
highlight default link FFFGitSignStaged GitSignsAdd
highlight default link FFFGitSignModified GitSignsChange
+21 -34
View File
@@ -6,27 +6,26 @@
local M = {}
local file_renderer = require('fff.file_renderer')
local tresitter_highlight = require('fff.treesitter_hl')
--- Build the file group header line using the same layout as file_renderer.
--- Delegates to file_renderer.render_line (with combo disabled).
---@param item table Grep match item (used for file metadata)
---@param item FileItem Grep match
---@param ctx table Render context
---@return string The header line string
local function build_group_header(item, ctx)
-- file_renderer.render_line checks (item_idx == 1 and ctx.has_combo) for combo header.
-- We pass item_idx=0 and disable has_combo to suppress combo logic entirely.
local saved_has_combo = ctx.has_combo
ctx.has_combo = false
---@diagnostic disable-next-line: param-type-mismatch
local lines = file_renderer.render_line(item, ctx, 0)
ctx.has_combo = saved_has_combo
ctx.has_combo = false -- never has a combo in grep
return lines[1]
end
--- Apply highlights for a file group header line using file_renderer.
--- Delegates to file_renderer.apply_highlights so all highlight groups
--- (icon, filename, git text color, directory path, git sign) match exactly.
---@param item table Grep match item
---@param ctx table Render context
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param buf number Buffer handle
---@param ns_id number Namespace id
---@param row number 0-based row in buffer (header line)
@@ -49,16 +48,14 @@ local function render_match_line(item, ctx)
local location = string.format(':%d:%d', item.line_number or 0, (item.col or 0) + 1)
local separator = ' '
local raw_content = item.line_content or ''
local leading_ws = #raw_content - #raw_content:match('^%s*(.*)')
local content = vim.trim(raw_content)
local content = raw_content
-- Indent + location + separator + content
local indent = ' '
-- Prefix is always ASCII so byte length == display width
local prefix_display_w = #indent + #location + #separator
local available = ctx.win_width - prefix_display_w - 2
local was_truncated = false
local content_display_w = vim.fn.strdisplaywidth(content)
if content_display_w > available and available > 3 then
-- UTF-8 aware truncation: binary search for the character count that
-- fits within the available display width (handles multi-byte and wide chars)
@@ -73,15 +70,11 @@ local function render_match_line(item, ctx)
end
end
content = vim.fn.strcharpart(content, 0, lo) .. ''
was_truncated = true
end
local line = indent .. location .. separator .. content
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
-- Store transient data on item for highlight pass
item._leading_ws = leading_ws
item._was_truncated = was_truncated
item._match_indent = #indent
item._content_offset = prefix_display_w -- byte offset where content starts in the line
item._trimmed_content = content -- trimmed content string for treesitter parsing
@@ -102,9 +95,6 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
local is_cursor = item_idx == ctx.cursor
local indent = item._match_indent or 1
-- 1. Cursor line highlight — use hl_group + hl_eol instead of line_hl_group
-- so that higher-priority inline extmarks (IncSearch match ranges at 200)
-- cleanly override both fg and bg on the cursor line.
if is_cursor then
vim.api.nvim_buf_set_extmark(buf, ns_id, row, 0, {
end_col = 0,
@@ -143,16 +133,16 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- below IncSearch match ranges (200) so search matches take precedence.
local content_start = sep_end
if item._trimmed_content and item.name then
local ts_hl = require('fff.treesitter_hl')
-- Resolve language once per file group (cache on the render context)
ctx._ts_lang_cache = ctx._ts_lang_cache or {}
local lang = ctx._ts_lang_cache[item.name]
if lang == nil then
lang = ts_hl.lang_from_filename(item.name) or false
lang = tresitter_highlight.lang_from_filename(item.name) or false
ctx._ts_lang_cache[item.name] = lang
end
if lang then
local highlights = ts_hl.get_line_highlights(item._trimmed_content, lang)
local highlights = tresitter_highlight.get_line_highlights(item._trimmed_content, lang)
for _, hl in ipairs(highlights) do
local hl_start = content_start + hl.col
local hl_end = content_start + hl.end_col
@@ -171,16 +161,14 @@ local function apply_match_highlights(item, ctx, item_idx, buf, ns_id, row, line
-- Use extmarks with priority > cursor line (100) so IncSearch renders
-- properly on the selected line instead of being overridden by CursorLine.
if item.match_ranges then
local leading_ws = item._leading_ws or 0
for _, range in ipairs(item.match_ranges) do
local raw_start = range[1] or 0
local raw_end = range[2] or 0
local adj_start = raw_start - leading_ws
local adj_end = raw_end - leading_ws
if adj_end > 0 then
adj_start = math.max(0, adj_start)
local hl_start = content_start + adj_start
local hl_end = content_start + adj_end
if raw_end > 0 then
raw_start = math.max(0, raw_start)
local hl_start = content_start + raw_start
local hl_end = content_start + raw_end
if hl_start < #line_content and hl_end <= #line_content then
pcall(vim.api.nvim_buf_set_extmark, buf, ns_id, row, hl_start, {
end_col = hl_end,
@@ -208,11 +196,10 @@ end
--- Render a single item's lines (called by list_renderer's generate_item_lines).
--- Returns 2 lines [header, match] for the first match of a file group,
--- or 1 line [match] for subsequent matches in the same file.
---@param item table Grep match item
---@param item FileItem Grep match item
---@param ctx table Render context
---@param item_idx number 1-based item index
---@return string[]
function M.render_line(item, ctx, item_idx)
function M.render_line(item, ctx)
-- Track file grouping across the render pass via ctx
-- ctx._grep_last_file is reset each render (ctx is fresh per render_list call)
local is_new_group = (item.path ~= ctx._grep_last_file)
@@ -221,8 +208,8 @@ function M.render_line(item, ctx, item_idx)
local match_line = render_match_line(item, ctx)
if is_new_group then
local header_line = build_group_header(item, ctx)
item._has_group_header = true
local header_line = build_group_header(item, ctx)
return { header_line, match_line }
else
item._has_group_header = false
@@ -233,8 +220,8 @@ end
--- Apply highlights for rendered lines (called by list_renderer's apply_all_highlights).
--- line_idx is the 1-based index of the item's LAST line (the match line).
--- If the item has a group header, it's at line_idx - 1.
---@param item table Grep match item
---@param ctx table Render context
---@param item FileItem Grep match item
---@param ctx ListRenderContext Render context
---@param item_idx number 1-based item index
---@param buf number Buffer handle
---@param ns_id number Namespace id
+1
View File
@@ -12,6 +12,7 @@ local fuzzy = require('fff.fuzzy')
---@field total_files number Total indexed files
---@field filtered_file_count number Total searchable files after filtering
---@field next_file_offset number File offset to pass for the next page (0 = no more results)
---@field regex_fallback_error string|nil Error message if regex compilation failed and search fell back to literal
local last_result = nil
+9 -9
View File
@@ -37,6 +37,7 @@ local M = {}
--- @field selected_files table<string, boolean> Selected file paths set
--- @field mode string|nil Current mode (nil or 'grep')
--- @field format_file_display function Helper for formatting file display
--- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files')
--- @class ItemLineMapping
--- @field first number First buffer line (1-based) this item occupies
@@ -54,7 +55,7 @@ local M = {}
--- When cross-mode suggestions are active, a suggestion banner is prepended
--- (for top prompt) or appended (for bottom prompt) so it always appears
--- above the suggestion items visually.
--- @param ctx ListRenderContext
--- @param ctx table
--- @return string[] lines Array of line strings
--- @return table<number, ItemLineMapping> item_to_lines
local function generate_item_lines(ctx)
@@ -90,11 +91,9 @@ local function generate_item_lines(ctx)
-- Renderer returns 1+ lines: virtual headers first, content line last.
-- This contract is shared by file_renderer (combo header) and
-- grep_renderer (file group header).
---@diagnostic disable-next-line: param-type-mismatch
local item_lines = renderer.render_line(item, ctx, i)
for _, line in ipairs(item_lines) do
table.insert(lines, line)
end
vim.list_extend(lines, item_lines)
local item_end_line = #lines
local virtual_count = item_end_line - item_start_line -- 0 if single line, 1 if header + content
@@ -120,7 +119,7 @@ end
--- Adjusts all line indices in item_to_lines accordingly.
--- @param lines string[] Lines array (mutated)
--- @param item_to_lines table<number, ItemLineMapping> Mapping (mutated)
--- @param ctx ListRenderContext
--- @param ctx table
--- @return number padding_offset Number of empty lines prepended
local function apply_bottom_padding(lines, item_to_lines, ctx)
if ctx.prompt_position ~= 'bottom' then return 0 end
@@ -151,7 +150,7 @@ end
--- never a virtual header line.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx ListRenderContext
--- @param ctx table
--- @param list_buf number Buffer handle
--- @param list_win number Window handle
--- @param ns_id number Namespace id
@@ -180,7 +179,7 @@ end
--- header highlights internally via the item._has_group_header flag.
--- @param lines string[]
--- @param item_to_lines table<number, ItemLineMapping>
--- @param ctx ListRenderContext
--- @param ctx table
--- @param list_buf number
--- @param ns_id number
local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
@@ -198,6 +197,7 @@ local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id)
if not line_content then goto continue end
---@diagnostic disable-next-line: param-type-mismatch
renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content)
::continue::
end
@@ -206,7 +206,7 @@ end
--- Render the full item list into the buffer.
--- This is the main entry point — replaces the inline rendering in picker_ui.
---
--- @param ctx ListRenderContext Render context built by picker_ui
--- @param ctx table Render context built by picker_ui
--- @param list_buf number List buffer handle
--- @param list_win number List window handle
--- @param ns_id number Highlight namespace
+1 -2
View File
@@ -212,7 +212,6 @@ end
--- 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')
@@ -221,7 +220,7 @@ function M.open_file_under_cursor(open_cb)
return
end
picker_ui.open_with_callback(full_path_with_suffix, function(files, metadata, location, get_file_score)
picker_ui.open_with_callback(full_path_with_suffix, function(files, _, location)
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)))
+27 -44
View File
@@ -101,7 +101,7 @@ end
--- @field file_info_height number
--- Calculate layout dimensions and positions for all windows
--- @param cfg LayoutConfig
--- @param cfg table
--- @return table Layout configuration
function M.calculate_layout_dimensions(cfg)
local BORDER_SIZE = 2
@@ -319,7 +319,7 @@ M.state = {
last_preview_file = nil,
last_preview_location = nil, -- Track last preview location to detect changes
preview_timer = nil, -- Separate timer for preview updates
preview_timer = nil, ---@type uv.uv_timer_t|nil -- Separate timer for preview updates
preview_debounce_ms = 100, -- Preview is more expensive, debounce more
-- Set of selected file paths: { [filepath] = true }
@@ -362,6 +362,7 @@ function M.create_ui()
local terminal_height = vim.o.lines
-- Calculate width and height (support function or number)
---@diagnostic disable: need-check-nil
local width_ratio = utils.resolve_config_value(
config.layout.width,
terminal_width,
@@ -426,6 +427,7 @@ function M.create_ui()
0.4,
'layout.preview_size'
)
---@diagnostic enable: need-check-nil
local layout_config = {
total_width = width,
@@ -881,6 +883,7 @@ function M.toggle_grep_regex()
local config = conf.get()
-- Use grep_config.modes if provided, otherwise fall back to global config
---@diagnostic disable-next-line: undefined-field
local modes = (M.state.grep_config and M.state.grep_config.modes)
or config.grep.modes
or { 'plain', 'regex', 'fuzzy' }
@@ -958,10 +961,6 @@ function M.update_results_sync()
or nil
end
end
local prompt_position = get_prompt_position()
-- Calculate page size dynamically based on window height
local page_size
if M.state.list_win and vim.api.nvim_win_is_valid(M.state.list_win) then
page_size = vim.api.nvim_win_get_height(M.state.list_win)
@@ -1050,7 +1049,7 @@ function M.update_results_sync()
else
-- File search returned nothing — try grep as suggestion
local grep = require('fff.grep')
local grep_result = grep.search(M.state.query, 0, page_size, M.state.grep_config, false)
local grep_result = grep.search(M.state.query, 0, page_size, M.state.grep_config, 'plain')
local grep_items = grep_result and grep_result.items or {}
if #grep_items > 0 then
M.state.suggestion_items = grep_items
@@ -1075,11 +1074,10 @@ end
--- Load page with given page index
function M.load_page_at_index(new_page_index, adjust_cursor_fn)
local ok, err, results
local page_size = M.state.pagination.page_size
-- Protect against division by zero
if page_size == 0 then return false end
if M.state.mode ~= 'grep' then
local total = M.state.pagination.total_matched
if total == 0 then return false end
@@ -1091,9 +1089,6 @@ function M.load_page_at_index(new_page_index, adjust_cursor_fn)
new_page_index = math.max(0, math.min(new_page_index, max_page_index))
end
local prompt_position = get_prompt_position()
local ok, results
if M.state.mode == 'grep' then
-- File-based pagination: look up the file_offset for this page from our history
local file_offset = M.state.pagination.grep_file_offsets[new_page_index + 1] -- 1-based Lua index
@@ -1150,14 +1145,14 @@ function M.load_page_at_index(new_page_index, adjust_cursor_fn)
-- Adjust cursor position (provided by caller)
if adjust_cursor_fn then
local ok, err = pcall(adjust_cursor_fn, #results)
if not ok then
vim.notify('Error in cursor adjustment: ' .. tostring(err), vim.log.levels.ERROR)
local cursor_ok, cursor_err = pcall(adjust_cursor_fn, #results)
if not cursor_ok then
vim.notify('Error in cursor adjustment: ' .. tostring(cursor_err), vim.log.levels.ERROR)
return false
end
end
local ok, err = pcall(M.render_list)
ok, err = pcall(M.render_list)
if not ok then
vim.notify('Error in render_list: ' .. tostring(err), vim.log.levels.ERROR)
return false
@@ -1191,7 +1186,7 @@ function M.load_next_page()
return false -- No more files
end
local new_page_index = current_page + 1
return M.load_page_at_index(new_page_index, function(result_count) M.state.cursor = 1 end)
return M.load_page_at_index(new_page_index, function() M.state.cursor = 1 end)
end
local total = M.state.pagination.total_matched
@@ -1202,7 +1197,7 @@ function M.load_next_page()
local new_page_index = current_page + 1
return M.load_page_at_index(new_page_index, function(result_count) M.state.cursor = 1 end)
return M.load_page_at_index(new_page_index, function() M.state.cursor = 1 end)
end
--- Load previous page (scroll up reached beginning)
@@ -1210,7 +1205,6 @@ function M.load_previous_page()
if M.state.pagination.page_index == 0 then return false end
local new_page_index = M.state.pagination.page_index - 1
local prompt_position = get_prompt_position()
return M.load_page_at_index(new_page_index, function(result_count) M.state.cursor = result_count end)
end
@@ -1224,7 +1218,7 @@ function M.update_preview_debounced()
end
-- Create new timer with longer debounce for expensive preview
M.state.preview_timer = vim.loop.new_timer()
M.state.preview_timer = vim.uv.new_timer()
M.state.preview_timer:start(
M.state.preview_debounce_ms,
0,
@@ -1250,6 +1244,7 @@ function M.update_preview_smart()
return
end
---@diagnostic disable-next-line: need-check-nil
local item = items[M.state.cursor]
if not item then
M.update_preview()
@@ -1336,8 +1331,6 @@ local function render_grep_empty_state(ctx)
table.insert(content, ' "!test pattern" exclude test files')
table.insert(content, '')
table.insert(content, border_bot)
-- For bottom prompt: push content to the bottom by prepending empty lines
if prompt_position == 'bottom' then
local empty_needed = math.max(0, win_height - #content)
@@ -1358,13 +1351,10 @@ local function render_grep_empty_state(ctx)
-- For bottom prompt, ensure empty state is anchored at the bottom
if prompt_position == 'bottom' then scroll_to_bottom() end
-- Apply highlights
for _, h in ipairs(hl_cmds) do
pcall(vim.api.nvim_buf_add_highlight, M.state.list_buf, M.state.ns_id, h.hl, h.row, h.col_start, h.col_end)
end
local tip_offset = prompt_position == 'bottom' and math.max(0, win_height - #content + (win_height - #content)) or 0
for i = 0, #content - 1 do
local line = content[i + 1]
if
@@ -1408,18 +1398,10 @@ local function build_render_context()
local win_width = vim.api.nvim_win_get_width(M.state.list_win)
local prompt_position = get_prompt_position()
-- Get actual text offset (signcolumn + foldcolumn + line numbers)
local win_info = vim.fn.getwininfo(M.state.list_win)[1]
local text_offset = win_info and win_info.textoff or 2
local text_width = win_width - text_offset
-- Cursor validation
if M.state.cursor < 1 then
M.state.cursor = 1
elseif M.state.cursor > #items then
M.state.cursor = #items
end
-- Combo detection (only in file picker mode with real results, not grep or suggestions)
local combo_boost_score_multiplier = config.history and config.history.combo_boost_multiplier or 100
local has_combo, combo_header_line, combo_header_text_len, combo_item_index
@@ -1482,10 +1464,6 @@ local function build_render_context()
}
end
-- NOTE: Line generation, bottom padding, buffer writes, cursor positioning,
-- and highlight application are handled by list_renderer.lua.
-- picker_ui delegates to list_renderer.render() in render_list() below.
local function finalize_render(item_to_lines, ctx)
local combo_text_len = nil
if ctx.combo_item_index and item_to_lines[ctx.combo_item_index] then
@@ -1520,8 +1498,6 @@ function M.render_list()
if not M.state.active then return end
local ctx = build_render_context()
-- Grep empty state: render a welcome view instead of the normal item list
if M.state.mode == 'grep' and #ctx.items == 0 then
render_grep_empty_state(ctx)
return
@@ -1624,6 +1600,7 @@ function M.update_preview()
return
end
---@diagnostic disable-next-line: need-check-nil
local item = items[M.state.cursor]
if not item then
M.clear_preview()
@@ -1717,10 +1694,12 @@ end
function M.update_status(progress)
if not M.state.active or not M.state.ns_id then return end
local config = M.state.config
if config == nil then return end
if M.state.mode == 'grep' then
-- Determine available modes to decide if we should show the mode indicator
-- Use grep_config.modes if provided, otherwise fall back to global config
---@diagnostic disable-next-line: undefined-field
local modes = (M.state.grep_config and M.state.grep_config.modes)
or config.grep.modes
or { 'plain', 'regex', 'fuzzy' }
@@ -1750,7 +1729,7 @@ function M.update_status(progress)
local mode_label = mode_labels[M.state.grep_mode] or 'plain'
local hl
if M.state.grep_mode == 'plain' then
hl = config.hl.grep_regex_inactive or 'Comment'
hl = config.hl.grep_plain_active or 'Comment'
elseif M.state.grep_mode == 'regex' then
hl = config.hl.grep_regex_active or 'DiagnosticInfo'
else -- fuzzy
@@ -2067,6 +2046,7 @@ function M.toggle_select()
local items = M.state.filtered_items
if #items == 0 or M.state.cursor > #items then return end
---@diagnostic disable-next-line: need-check-nil
local item = items[M.state.cursor]
if not item or not item.path then return end
@@ -2197,6 +2177,7 @@ function M.select(action)
local items = M.state.filtered_items
if #items == 0 or M.state.cursor > #items then return end
---@diagnostic disable-next-line: need-check-nil
local item = items[M.state.cursor]
if not item then return end
@@ -2347,9 +2328,10 @@ function M.close()
end
--- Helper function to determine current file cache for deprioritization
--- @param base_path string Base path for relative path calculation
--- @param base_path string|nil Base path for relative path calculation
--- @return string|nil Current file cache path
local function get_current_file_cache(base_path)
if not base_path then return nil end
local current_buf = vim.api.nvim_get_current_buf()
if not current_buf or not vim.api.nvim_buf_is_valid(current_buf) then return nil end
@@ -2374,7 +2356,7 @@ end
--- Helper function for common picker initialization
--- @param opts table|nil Options passed to the picker
--- @return table|nil Merged configuration, nil if initialization failed
--- @return table|nil, string|nil Merged configuration and base path, nil config if initialization failed
local function initialize_picker(opts)
local base_path = opts and opts.cwd or vim.uv.cwd()
@@ -2463,7 +2445,7 @@ function M.open_with_callback(query, callback, opts)
if not merged_config then return false end
local current_file_cache = get_current_file_cache(base_path)
local results = file_picker.search_files(query, nil, nil, current_file_cache, nil)
local results = file_picker.search_files(query, current_file_cache, nil, nil, nil)
local metadata = file_picker.get_search_metadata()
local location = file_picker.get_search_location()
@@ -2515,6 +2497,7 @@ function M.open(opts)
-- Initialize grep_mode to first configured mode when opening in grep mode
if M.state.mode == 'grep' then
-- Use grep_config.modes if provided, otherwise fall back to global config
---@diagnostic disable-next-line: undefined-field
local modes = (M.state.grep_config and M.state.grep_config.modes)
or merged_config.grep.modes
or { 'plain', 'regex', 'fuzzy' }
@@ -2522,7 +2505,7 @@ function M.open(opts)
end
local current_file_cache = get_current_file_cache(base_path)
local query = opts and opts.query or nil
local query = opts and opts.query or nil ---@type string|nil
return open_ui_with_state(query, nil, nil, merged_config, current_file_cache)
end
+4 -10
View File
@@ -1,7 +1,5 @@
--- Treesitter Highlight Extraction
--- Extracts syntax highlights from a code string using treesitter.
--- Uses a per-language scratch buffer pool to avoid repeated buffer creation.
--- Results are returned as extmark-style tables { col, end_col, hl_group }.
local utils = require('fff.utils')
local M = {}
--- Per-language scratch buffer cache
@@ -33,11 +31,7 @@ end
function M.lang_from_filename(filename)
if not filename or filename == '' then return nil end
-- Use vim.filetype.match to get the filetype from the filename
local ok, ft = pcall(vim.filetype.match, { filename = filename })
if not ok or not ft then return nil end
-- Convert filetype to treesitter language
local ft = utils.detect_filetype(filename) or 'text'
local lang_ok, lang = pcall(vim.treesitter.language.get_lang, ft)
if not lang_ok or not lang then lang = ft end
@@ -83,7 +77,7 @@ function M.get_line_highlights(text, lang)
local query_ok, query = pcall(vim.treesitter.query.get, tree_lang, 'highlights')
if not query_ok or not query then return end
for capture, node, metadata in query:iter_captures(root, buf, 0, 1) do
for capture, node, _ in query:iter_captures(root, buf, 0, 1) do
local name = query.captures[capture]
if name and name ~= 'spell' and name ~= 'conceal' then
local start_row, start_col, end_row, end_col = node:range()
+30 -1
View File
@@ -17,6 +17,34 @@ function M.format_file_size(size)
end
end
local function get_fixed_filetype_detection(extension)
local extension_map = {
ts = 'typescript',
tex = 'latex',
md = 'markdown',
txt = 'text',
}
return extension_map[extension]
end
--- Detect filetype with various fallbacks
--- @param file_path string the filetype
--- @return string detected filetype
function M.detect_filetype(file_path)
local has_plenary, plenary_filetype = pcall(require, 'plenary.filetype')
if has_plenary then
local detected = plenary_filetype.detect(file_path, {})
if detected and detected ~= '' then return detected end
end
local builtin_filetype = vim.filetype.match({ filename = file_path })
if builtin_filetype and builtin_filetype ~= '' then return builtin_filetype end
local extension = vim.fn.fnamemodify(file_path, ':e'):lower()
return get_fixed_filetype_detection(extension)
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
@@ -24,7 +52,7 @@ end
--- @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
--- @return number 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)
@@ -38,6 +66,7 @@ function M.resolve_config_value(config_value, terminal_width, terminal_height, v
return fallback
end
else
if config_value == nil or not validator(config_value) then return fallback end
return config_value
end
end
+1
View File
@@ -1,3 +1,4 @@
---@diagnostic disable: undefined-field
local fff_rust = require('fff.rust')
--- Wait for the scan to fully complete, handling the startup race where
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# tests/test_lazy_async_bug.sh
#
# Shell-level proof of the async-exit bug in the original download_or_build_binary().
#
# When lazy.nvim's build hook returns, Neovim may exit moments later.
# The old implementation fired vim.system subprocesses and returned immediately,
# so those subprocesses (git → curl → sha → rename) were orphaned on exit and
# the binary was never written to disk.
#
# The fix wraps the whole chain in vim.wait, keeping the event loop alive until
# the rename completes.
#
# Usage: bash tests/test_lazy_async_bug.sh
set -euo pipefail
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
export FFF_PLUGIN_ROOT="$PLUGIN_ROOT" # picked up by os.getenv() inside Lua
case "$(uname -s)" in
Darwin) EXT=dylib ;;
MINGW*|MSYS*|CYGWIN*) EXT=dll ;;
*) EXT=so ;;
esac
BINARY="$PLUGIN_ROOT/target/release/libfff_nvim.$EXT"
passed=0; failed=0
pass() { printf ' PASS %s\n' "$1"; (( passed += 1 )) || true; }
fail() { printf ' FAIL %s\n' "$1" >&2; (( failed += 1 )) || true; }
assert_file() { [[ -f $1 ]] && pass "$2" || fail "$2 (file missing: $1)"; }
assert_no_file() { [[ ! -f $1 ]] && pass "$2" || fail "$2 (file unexpectedly present: $1)"; }
# ── Temp runners (cleaned up on exit) ────────────────────────────────────────
RUNNERS=$(mktemp -d)
trap 'rm -rf "$RUNNERS"' EXIT
# Runner A — simulates OLD build hook:
# ensure_downloaded fires async vim.system calls, then returns.
# os.exit(0) mimics Neovim exiting immediately after the hook returns —
# the event loop never spins again, so the git/curl/rename callbacks die.
cat >"$RUNNERS/async.lua" <<'LUA'
vim.opt.runtimepath:prepend(os.getenv('FFF_PLUGIN_ROOT'))
require('fff.download').ensure_downloaded(
{ version = '3e9b865', force = true },
function() end -- this callback is never reached
)
os.exit(0) -- immediate exit, same effect as lazy returning from the hook
LUA
# Runner B — exercises the FIXED download_or_build_binary():
# vim.wait spins the event loop until the rename lands on disk,
# so the function only returns after the binary is present.
cat >"$RUNNERS/blocking.lua" <<'LUA'
vim.opt.runtimepath:prepend(os.getenv('FFF_PLUGIN_ROOT'))
-- Blocks via vim.wait; only returns once the binary is on disk.
require('fff.download').download_or_build_binary()
LUA
printf '\n=== lazy.nvim async-exit bug ===\nbinary : %s\n\n' "$BINARY"
# ── Part 1: old async (broken) ────────────────────────────────────────────────
echo '--- Part 1: OLD async behavior — fire subprocesses and exit ---'
rm -f "$BINARY" "$BINARY.tmp"
nvim -l "$RUNNERS/async.lua" 2>/dev/null
# Give any orphaned subprocesses a full second to do whatever they can.
# They finish running (git/curl), but the Neovim rename callback is dead,
# so the binary never moves from .tmp → libfff_nvim.dylib.
sleep 1
assert_no_file "$BINARY" "binary absent — rename callback was killed with the process"
# ── Part 2: fixed blocking ────────────────────────────────────────────────────
printf '\n--- Part 2: FIXED behavior --- vim.wait keeps event loop alive ---\n'
rm -f "$BINARY" "$BINARY.tmp"
nvim -l "$RUNNERS/blocking.lua" # prints download progress to stdout
assert_file "$BINARY" "binary present — vim.wait held the process until rename succeeded"
# ── Summary ────────────────────────────────────────────────────────────────────
printf '\n%d passed %d failed\n' "$passed" "$failed"
(( failed == 0 ))