Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dba7dcc1ec | |||
| 865d4cad82 | |||
| 3dd44d4578 | |||
| 0f40c66eb7 | |||
| 7298978bcb | |||
| 8f69f987a4 |
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
})
|
||||
},
|
||||
}})
|
||||
<
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
Executable
+84
@@ -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 ))
|
||||
Reference in New Issue
Block a user