fix/finder #2

Merged
tomas.mirchev merged 4 commits from fix/finder into main 2025-10-26 06:35:48 +00:00
2 changed files with 92 additions and 92 deletions
Showing only changes of commit 095c7b92a2 - Show all commits

View File

@@ -1,6 +1,6 @@
require('plugins.filetree') require('plugins.filetree')
require('plugins.finder').setup({ require('plugins.finder').setup({
exclude_patterns = { 'node_modules', 'dist', 'build', '.git', '.cache', '.turbo' }, exclude_patterns = { 'node_modules', 'dist', 'build', '.git', '.cache', '.turbo', '*-lock.json' },
use_disk_cache = true, -- optional use_disk_cache = true, -- optional
}) })

View File

@@ -10,6 +10,8 @@ M.config = {
grep_cmd = 'rg', -- ripgrep binary grep_cmd = 'rg', -- ripgrep binary
page_size = 60, -- soft cap; real viewport height is measured page_size = 60, -- soft cap; real viewport height is measured
debounce_ms = 80, debounce_ms = 80,
instant_items = 1500, -- <= this count + cached => 0 ms debounce
fast_items = 4000, -- <= this count => debounce_ms/2
cache_ttl_sec = 20, cache_ttl_sec = 20,
max_items = 5000, -- safety cap for massive outputs max_items = 5000, -- safety cap for massive outputs
debug = false, debug = false,
@@ -53,6 +55,9 @@ local S = {
select = 1, -- absolute index in filtered (1-based) select = 1, -- absolute index in filtered (1-based)
scroll = 0, -- top index (0-based) scroll = 0, -- top index (0-based)
user_moved = false, -- has user navigated during this session? user_moved = false, -- has user navigated during this session?
-- meta
files_cached = false,
} }
--------------------------------------------------------------------- ---------------------------------------------------------------------
@@ -81,34 +86,28 @@ local function cmd_exists(bin)
return vim.fn.executable(bin) == 1 return vim.fn.executable(bin) == 1
end end
-- single reusable timer to avoid churn; caller controls ms
local function debounce(fn, ms) local function debounce(fn, ms)
if S.timer then if not S.timer then
S.timer:stop()
S.timer:close()
S.timer = nil
end
S.timer = vim.loop.new_timer() S.timer = vim.loop.new_timer()
S.timer:start(ms, 0, function()
if S.timer then
S.timer:stop()
S.timer:close()
S.timer = nil
end end
S.timer:stop()
S.timer:start(ms, 0, function()
S.timer:stop()
vim.schedule(fn) vim.schedule(fn)
end) end)
end end
local function read_gitignore(root) local function read_gitignore(root)
local p = root .. '/.gitignore' local p = root .. '/.gitignore'
local lines = {}
local f = io.open(p, 'r') local f = io.open(p, 'r')
if not f then if not f then
return {} return {}
end end
local lines = {}
for line in f:lines() do for line in f:lines() do
local l = vim.trim(line) local l = vim.trim(line)
if #l > 0 and not l:match('^#') and not l:match('^!') then if #l > 0 and not l:match('^#') and not l:match('^!') then
-- normalize directory suffix to plain token for our excludes
l = l:gsub('/+$', '') l = l:gsub('/+$', '')
table.insert(lines, l) table.insert(lines, l)
end end
@@ -142,6 +141,7 @@ local function load_cache(root)
return nil return nil
end end
if now_sec() - (data.timestamp or 0) > M.config.cache_ttl_sec then if now_sec() - (data.timestamp or 0) > M.config.cache_ttl_sec then
pcall(os.remove, file) -- proactively clear stale
return nil return nil
end end
return data.files return data.files
@@ -173,19 +173,24 @@ local function project_root()
return vim.fn.getcwd(0, 0) return vim.fn.getcwd(0, 0)
end end
-- Robust relative-to-root
local function normalize_rel(root, p) local function normalize_rel(root, p)
if not p or p == '' then if not p or p == '' then
return '' return ''
end end
-- strip leading ./ and leading root if any local abs = vim.fs.normalize(vim.fn.fnamemodify(p, ':p'))
p = p:gsub('^%./', '')
local root_norm = vim.fs.normalize(root) local root_norm = vim.fs.normalize(root)
local abs = vim.fs.normalize(p) if abs == root_norm then
if abs:find(root_norm, 1, true) == 1 then return ''
local rel = abs:sub(#root_norm + 2)
return rel ~= '' and rel or abs
end end
return p if abs:find(root_norm, 1, true) == 1 then
local ch = abs:sub(#root_norm + 1, #root_norm + 1)
if ch == '/' or ch == '\\' then
return abs:sub(#root_norm + 2)
end
end
-- strip leading ./ as last resort
return (p:gsub('^%./', ''))
end end
local function to_abs_path(root, rel) local function to_abs_path(root, rel)
@@ -195,6 +200,11 @@ local function to_abs_path(root, rel)
return vim.fs.normalize(root .. '/' .. rel) return vim.fs.normalize(root .. '/' .. rel)
end end
local function to_display_path(abs)
-- relative to current working dir if possible, else absolute
return vim.fn.fnamemodify(abs, ':.')
end
local function page_rows() local function page_rows()
if S.win_res and vim.api.nvim_win_is_valid(S.win_res) then if S.win_res and vim.api.nvim_win_is_valid(S.win_res) then
return math.max(1, vim.api.nvim_win_get_height(S.win_res)) return math.max(1, vim.api.nvim_win_get_height(S.win_res))
@@ -202,6 +212,18 @@ local function page_rows()
return math.max(1, math.min(M.config.page_size, vim.o.lines)) return math.max(1, math.min(M.config.page_size, vim.o.lines))
end end
local function effective_debounce_ms()
if S.mode == 'files' then
local n = #S.items
if S.files_cached and n > 0 and n <= (M.config.instant_items or 1500) then
return 0
elseif n > 0 and n <= (M.config.fast_items or 4000) then
return math.max(0, math.floor((M.config.debounce_ms or 80) / 2))
end
end
return M.config.debounce_ms or 80
end
--------------------------------------------------------------------- ---------------------------------------------------------------------
-- Render helpers -- Render helpers
--------------------------------------------------------------------- ---------------------------------------------------------------------
@@ -237,7 +259,7 @@ local function render()
local end_idx = math.min(start_idx + page_rows() - 1, total) local end_idx = math.min(start_idx + page_rows() - 1, total)
for i = start_idx, end_idx do for i = start_idx, end_idx do
local line = S.filtered[i] local line = S.filtered[i]
view[#view + 1] = type(line) == 'string' and line or tostring(line or '') view[#view + 1] = tostring(line or '')
end end
end end
@@ -283,7 +305,6 @@ end
-- Querying / Filtering -- Querying / Filtering
--------------------------------------------------------------------- ---------------------------------------------------------------------
local function compute_positions_files(items, q) local function compute_positions_files(items, q)
-- matchfuzzypos returns {items, positions}
local ok, res = pcall(vim.fn.matchfuzzypos, items, q) local ok, res = pcall(vim.fn.matchfuzzypos, items, q)
if not ok or type(res) ~= 'table' then if not ok or type(res) ~= 'table' then
return {}, {} return {}, {}
@@ -293,14 +314,26 @@ local function compute_positions_files(items, q)
local filtered, positions = {}, {} local filtered, positions = {}, {}
for i, v in ipairs(out_items) do for i, v in ipairs(out_items) do
filtered[i] = (type(v) == 'string') and v or tostring(v or '') filtered[i] = tostring(v or '')
local cols = pos[i] or {} local cols = pos[i] or {}
table.sort(cols)
local spans = {} local spans = {}
local start_col, last_col = nil, nil
for _, c in ipairs(cols) do for _, c in ipairs(cols) do
local start0 = (c > 0) and (c - 1) or 0 -- to 0-based local c0 = c
spans[#spans + 1] = { start0, start0 + 1 } if not start_col then
start_col, last_col = c0, c0
elseif c0 == last_col + 1 then
last_col = c0
else
table.insert(spans, { start_col, last_col + 1 }) -- end exclusive
start_col, last_col = c0, c0
end end
positions[i] = spans end
if start_col then
table.insert(spans, { start_col, last_col + 1 })
end
positions[i] = (#spans > 0) and spans or nil
end end
return filtered, positions return filtered, positions
end end
@@ -312,15 +345,15 @@ local function compute_positions_grep(lines, q)
local qlow = q:lower() local qlow = q:lower()
local positions = {} local positions = {}
for i, line in ipairs(lines) do for i, line in ipairs(lines) do
local sidx = 1 local llow = tostring(line or ''):lower()
local spans = {} local spans = {}
local llow = (type(line) == 'string' and line or tostring(line or '')):lower() local sidx = 1
while true do while true do
local s, e = string.find(llow, qlow, sidx, true) local s, e = llow:find(qlow, sidx, true)
if not s then if not s then
break break
end end
spans[#spans + 1] = { s - 1, e } -- 0-based start, exclusive end table.insert(spans, { s - 1, e })
sidx = e + 1 sidx = e + 1
if #spans > 64 then if #spans > 64 then
break break
@@ -346,10 +379,8 @@ local function set_items(list)
-- sanitize to strings -- sanitize to strings
for i, v in ipairs(list) do for i, v in ipairs(list) do
if type(v) ~= 'string' then
list[i] = tostring(v or '') list[i] = tostring(v or '')
end end
end
S.items = list S.items = list
S.filtered = list S.filtered = list
@@ -379,16 +410,7 @@ local function set_query(q)
end end
end end
-- sanitize filtered list
for i, v in ipairs(S.filtered) do
if type(v) ~= 'string' then
S.filtered[i] = tostring(v or '')
end
end
-- selection policy: -- selection policy:
-- if user hasn't moved, always reset to first on query changes;
-- if user moved, preserve previous pick when present, else first.
if not S.user_moved then if not S.user_moved then
S.select = 1 S.select = 1
else else
@@ -432,12 +454,12 @@ local function move_up()
render() render()
end end
-- Correct parser for rg --vimgrep (file:line:col:match)
local function parse_vimgrep(line) local function parse_vimgrep(line)
-- Greedy file capture up to last ":<lnum>:"
if type(line) ~= 'string' then if type(line) ~= 'string' then
return nil return nil
end end
local file, lnum = line:match('^(.*):(%d+):') local file, lnum = line:match('^(.-):(%d+):%d+:')
if not file then if not file then
return nil return nil
end end
@@ -450,9 +472,10 @@ local function accept_selection_files()
return return
end end
local abs = to_abs_path(S.root, pick) local abs = to_abs_path(S.root, pick)
local path = to_display_path(abs)
M.close() M.close()
vim.schedule(function() vim.schedule(function()
vim.cmd.edit(vim.fn.fnameescape(abs)) vim.cmd.edit(vim.fn.fnameescape(path))
end) end)
end end
@@ -466,12 +489,12 @@ local function accept_selection_grep()
return return
end end
local abs = to_abs_path(S.root, file) local abs = to_abs_path(S.root, file)
local path = to_display_path(abs)
local ln = tonumber(lnum) or 1 local ln = tonumber(lnum) or 1
M.close() M.close()
vim.schedule(function() vim.schedule(function()
vim.cmd.edit(vim.fn.fnameescape(abs)) vim.cmd.edit(vim.fn.fnameescape(path))
if pcall(vim.api.nvim_win_set_cursor, 0, { ln, 0 }) then pcall(vim.api.nvim_win_set_cursor, 0, { ln, 0 })
end
end) end)
end end
@@ -493,9 +516,11 @@ local function collect_files_async(cb)
local disk = load_cache(root) local disk = load_cache(root)
if disk and type(disk) == 'table' then if disk and type(disk) == 'table' then
S.files_cached = true
cb(disk) cb(disk)
return return
end end
S.files_cached = false
local file_cmd = M.config.file_cmd local file_cmd = M.config.file_cmd
if not file_cmd then if not file_cmd then
@@ -517,7 +542,6 @@ local function collect_files_async(cb)
if file_cmd then if file_cmd then
local args = { file_cmd, '--type', 'f', '--hidden', '--color', 'never' } local args = { file_cmd, '--type', 'f', '--hidden', '--color', 'never' }
-- best-effort: avoid leading ./ in output
if file_cmd == 'fd' or file_cmd == 'fdfind' then if file_cmd == 'fd' or file_cmd == 'fdfind' then
table.insert(args, '--strip-cwd-prefix') table.insert(args, '--strip-cwd-prefix')
end end
@@ -531,7 +555,7 @@ local function collect_files_async(cb)
return return
end end
local list = {} local list = {}
if obj.code == 0 and obj.stdout then if obj.stdout then
local raw = vim.split(obj.stdout, '\n', { trimempty = true }) local raw = vim.split(obj.stdout, '\n', { trimempty = true })
for _, p in ipairs(raw) do for _, p in ipairs(raw) do
list[#list + 1] = normalize_rel(root, p) list[#list + 1] = normalize_rel(root, p)
@@ -565,7 +589,7 @@ local function collect_files_async(cb)
return return
end end
local list = {} local list = {}
if o2.code == 0 and o2.stdout then if o2.stdout then
for p in o2.stdout:gmatch('([^%z]+)') do for p in o2.stdout:gmatch('([^%z]+)') do
list[#list + 1] = normalize_rel(root, p) list[#list + 1] = normalize_rel(root, p)
end end
@@ -588,33 +612,8 @@ local function collect_files_async(cb)
return return
end end
-- Last resort: blocking glob with simple exclusion checks -- Last resort omitted: blocking glob removed to keep async-only behavior
local list = vim.fn.globpath(root, '**/*', false, true) cb({})
list = vim.tbl_filter(function(p)
if vim.fn.isdirectory(p) == 1 then
return false
end
for _, ex in ipairs(excludes) do
if p:find('/' .. ex .. '/', 1, true) or p:find('/' .. ex .. '$', 1, true) then
return false
end
end
return true
end, list)
local rel = {}
for i = 1, #list do
rel[i] = normalize_rel(root, list[i])
end
if #rel > M.config.max_items then
local tmp = {}
for i = 1, M.config.max_items do
tmp[i] = rel[i]
end
rel = tmp
end
save_cache(root, rel)
cb(rel)
end end
local function grep_async(query, cb) local function grep_async(query, cb)
@@ -645,17 +644,13 @@ local function grep_async(query, cb)
query, query,
} }
-- Apply excludes as negative globs -- Apply excludes as negative globs
local excludes = {}
for _, p in ipairs(M.config.exclude_patterns or {}) do for _, p in ipairs(M.config.exclude_patterns or {}) do
table.insert(excludes, p) table.insert(args, 2, '--glob')
table.insert(args, 3, '!' .. p)
end end
local gi = read_gitignore(root) for _, p in ipairs(read_gitignore(root)) do
for _, p in ipairs(gi) do table.insert(args, 2, '--glob')
table.insert(excludes, p) table.insert(args, 3, '!' .. p)
end
for _, ex in ipairs(excludes) do
table.insert(args, 2, '--glob') -- insert before '--'
table.insert(args, 3, '!' .. ex)
end end
cancel_job(S.job_rg) cancel_job(S.job_rg)
@@ -664,7 +659,8 @@ local function grep_async(query, cb)
return return
end end
local list = {} local list = {}
if obj.code == 0 and obj.stdout then -- Accept output even if exit code != 0 (no matches or partial errors)
if obj.stdout and #obj.stdout > 0 then
list = vim.split(obj.stdout, '\n', { trimempty = true }) list = vim.split(obj.stdout, '\n', { trimempty = true })
end end
if #list > M.config.max_items then if #list > M.config.max_items then
@@ -837,6 +833,7 @@ local function attach_handlers()
group = S.aug, group = S.aug,
buffer = S.buf_inp, buffer = S.buf_inp,
callback = function() callback = function()
local delay = effective_debounce_ms()
debounce(function() debounce(function()
if not (S.active and S.buf_inp and vim.api.nvim_buf_is_valid(S.buf_inp)) then if not (S.active and S.buf_inp and vim.api.nvim_buf_is_valid(S.buf_inp)) then
return return
@@ -856,7 +853,7 @@ local function attach_handlers()
else else
set_query(q) set_query(q)
end end
end, M.config.debounce_ms) end, delay)
end, end,
}) })
end end
@@ -882,7 +879,7 @@ function M.files()
if not S.active then if not S.active then
return return
end end
set_items(list) -- render initial list (relative paths) set_items(list) -- render initial list (relative to root)
end) end)
end end
@@ -907,8 +904,10 @@ function M.close()
end end
-- stop timers and jobs first -- stop timers and jobs first
if S.timer then if S.timer then
pcall(function()
S.timer:stop() S.timer:stop()
S.timer:close() S.timer:close()
end)
S.timer = nil S.timer = nil
end end
cancel_job(S.job_rg) cancel_job(S.job_rg)
@@ -922,6 +921,7 @@ function M.close()
S.items, S.filtered, S.positions = {}, {}, {} S.items, S.filtered, S.positions = {}, {}, {}
S.query, S.select, S.scroll = '', 1, 0 S.query, S.select, S.scroll = '', 1, 0
S.user_moved = false S.user_moved = false
S.files_cached = false
L('session closed') L('session closed')
end end