Compare commits

...

3 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
24 changed files with 380 additions and 194 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"
+1 -1
View File
@@ -1,4 +1,4 @@
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 24
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2026 February 25
==============================================================================
Table of Contents *fff.nvim-table-of-contents*
+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
-2
View File
@@ -171,12 +171,10 @@ local function init()
-- 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',
+62 -123
View File
@@ -89,56 +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
if sysname:match('windows') then
-- Output format of certutil on windows:
-- SHA256 hash <file_path>:
-- <hash>
-- CertUtil: -hashfile command completed successfully.
actual_hash = (result.stdout or ''):match(
'hash.-:\r?\n([%x ]+)\r?\nCertUtil: %-hashfile command completed successfully%.'
)
else
actual_hash = (result.stdout or ''):match('^%s*([0-9a-fA-F]+)')
end
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 {}
@@ -146,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)
@@ -304,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()
@@ -318,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()
+8 -10
View File
@@ -157,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)
@@ -296,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
@@ -317,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
@@ -353,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 = {}
@@ -458,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',
@@ -506,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()
@@ -629,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
@@ -647,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
+5 -3
View File
@@ -13,7 +13,9 @@ 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)
--- @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
@@ -27,7 +29,7 @@ function M.render_line(item, ctx, item_idx)
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
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 = ''
@@ -119,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
+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
+8 -7
View File
@@ -10,11 +10,12 @@ 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)
ctx.has_combo = false
---@diagnostic disable-next-line: param-type-mismatch
local lines = file_renderer.render_line(item, ctx, 0)
ctx.has_combo = false -- never has a combo in grep
return lines[1]
@@ -23,8 +24,8 @@ 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)
@@ -195,10 +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
---@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)
@@ -219,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
+7 -5
View File
@@ -55,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)
@@ -91,6 +91,7 @@ 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)
vim.list_extend(lines, item_lines)
@@ -118,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
@@ -149,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
@@ -178,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)
@@ -196,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
@@ -204,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)))
+25 -27
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)
@@ -1362,7 +1355,6 @@ local function render_grep_empty_state(ctx)
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
@@ -1608,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()
@@ -1706,6 +1699,7 @@ function M.update_status(progress)
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' }
@@ -2052,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
@@ -2182,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
@@ -2332,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
@@ -2359,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()
@@ -2448,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()
@@ -2500,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' }
@@ -2507,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
+2 -1
View File
@@ -52,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)
@@ -66,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 ))