diff --git a/lua/plugins/finder.lua b/lua/plugins/finder.lua index 4a047bc..8af2437 100644 --- a/lua/plugins/finder.lua +++ b/lua/plugins/finder.lua @@ -1,5 +1,6 @@ -- finder.lua — Minimal async fuzzy finder + grep for Neovim ≥ 0.11 -- Non-blocking, debounced, stable selection, correct scrolling, match highlights. +-- Hardened for races, Windows paths, and cwd/root mismatches. local M = {} @@ -23,11 +24,17 @@ local S = { active = false, mode = nil, -- "files" | "grep" root = nil, + is_git = false, cache = {}, timer = nil, ns = vim.api.nvim_create_namespace('finder_ns'), aug = nil, + gen = 0, -- session generation id + + -- async jobs + job_files = nil, + job_rg = nil, -- UI win_inp = nil, @@ -87,12 +94,42 @@ local function debounce(fn, ms) end) end +local function is_windows() + local sys = vim.loop.os_uname().sysname + return sys == 'Windows_NT' +end + +local function is_abs_path(p) + if is_windows() then + -- C:\... or \\server\share... + return p:match('^%a:[/\\]') or p:match('^[/\\][/\\]') + else + return p:sub(1, 1) == '/' + end +end + +local function joinpath(a, b) + return vim.fs.normalize(vim.fs.joinpath(a, b)) +end + +local function to_abs_in_root(root, p) + if is_abs_path(p) then + return p + end + local joined = joinpath(root, p) + local rp = vim.loop.fs_realpath(joined) + return rp or joined +end + local function project_root() local obj = vim.system({ 'git', 'rev-parse', '--show-toplevel' }, { text = true }):wait() if obj.code == 0 and obj.stdout and obj.stdout ~= '' then + S.is_git = true return vim.trim(obj.stdout) end - return vim.loop.cwd() + S.is_git = false + -- Respect Neovim cwd, not process cwd + return vim.fn.getcwd(0, 0) end local function resolve_file_cmd() @@ -110,9 +147,10 @@ end local function page_rows() if S.win_res and vim.api.nvim_win_is_valid(S.win_res) then - return vim.api.nvim_win_get_height(S.win_res) + local h = vim.api.nvim_win_get_height(S.win_res) + return math.max(1, h) end - return math.min(M.config.page_size, vim.o.lines) + return math.max(1, math.min(M.config.page_size, vim.o.lines)) end --------------------------------------------------------------------- @@ -135,7 +173,7 @@ end -- Render --------------------------------------------------------------------- local function render() - if not (S.buf_res and vim.api.nvim_buf_is_valid(S.buf_res)) then + if not (S.active and S.buf_res and vim.api.nvim_buf_is_valid(S.buf_res)) then return end @@ -153,7 +191,6 @@ local function render() end end - -- sanitize all lines to strings to avoid E5108 errors for i = 1, #view do if type(view[i]) ~= 'string' then view[i] = tostring(view[i] or '') @@ -167,7 +204,7 @@ local function render() -- match highlights (visible window only) vim.api.nvim_set_hl(0, 'FinderMatch', { link = 'Search', default = true }) - for i, _ in ipairs(view) do + for i = 1, #view do local idx = S.scroll + i local spans = S.positions[idx] if spans then @@ -202,7 +239,6 @@ end -- Querying / Filtering --------------------------------------------------------------------- local function compute_positions_files(items, q) - -- matchfuzzypos returns {items, positions}. Handle via single return table. local ok, res = pcall(vim.fn.matchfuzzypos, items, q) if not ok or type(res) ~= 'table' then return {}, {} @@ -210,14 +246,13 @@ local function compute_positions_files(items, q) local out_items = res[1] or {} local pos = res[2] or {} - -- sanitize items to strings and build per-index spans local filtered, positions = {}, {} for i, v in ipairs(out_items) do filtered[i] = (type(v) == 'string') and v or tostring(v or '') local cols = pos[i] or {} local spans = {} for _, c in ipairs(cols) do - local start0 = (c > 0) and (c - 1) or 0 -- to 0-based + local start0 = (c > 0) and (c - 1) or 0 spans[#spans + 1] = { start0, start0 + 1 } end positions[i] = spans @@ -265,8 +300,6 @@ local function set_items(list) end list = tmp end - - -- sanitize to plain strings for i, v in ipairs(list) do if type(v) ~= 'string' then list[i] = tostring(v or '') @@ -286,8 +319,8 @@ end --------------------------------------------------------------------- local function set_query(q) local prev_val = S.filtered[S.select] - S.query = q + if S.mode == 'grep' then local filtered, pos = compute_positions_grep(S.items, q) S.filtered, S.positions = filtered, pos @@ -301,7 +334,6 @@ local function set_query(q) end end - -- sanitize filtered list for i, v in ipairs(S.filtered) do if type(v) ~= 'string' then S.filtered[i] = tostring(v or '') @@ -350,7 +382,7 @@ local function accept_selection_files() if not pick then return end - local file = pick + local file = to_abs_in_root(S.root, pick) local edit_cb = function() vim.cmd.edit(vim.fn.fnameescape(file)) end @@ -358,19 +390,32 @@ local function accept_selection_files() vim.schedule(edit_cb) end +local function parse_vimgrep(line) + -- Robust against Windows drive letters and extra colons in path. + -- Greedy file capture up to last "::" + local file, lnum = line:match('^(.*):(%d+):') + if not file then + return nil + end + return file, tonumber(lnum) or 1 +end + local function accept_selection_grep() local pick = S.filtered[S.select] if not pick then return end - local file, lnum = pick:match('^([^:]+):(%d+):') + local file, lnum = parse_vimgrep(pick) if not file then return end + file = to_abs_in_root(S.root, file) lnum = tonumber(lnum) or 1 local edit_cb = function() vim.cmd.edit(vim.fn.fnameescape(file)) - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) + if vim.api.nvim_get_current_buf() > 0 then + pcall(vim.api.nvim_win_set_cursor, 0, { lnum, 0 }) + end end M.close() vim.schedule(edit_cb) @@ -379,7 +424,17 @@ end --------------------------------------------------------------------- -- Backends --------------------------------------------------------------------- +local function cancel_job(j) + if not j then + return + end + pcall(function() + j:kill(15) + end) -- SIGTERM if available +end + local function collect_files_async(cb) + local gen = S.gen local root = S.root local c = S.cache[root] if c and c.files and now_sec() - c.files.at < M.config.cache_ttl_sec then @@ -387,21 +442,59 @@ local function collect_files_async(cb) return end + -- Prefer fd/fdfind, then git ls-files, then blocking glob fallback. local file_cmd = resolve_file_cmd() if file_cmd then - local args = { file_cmd, '--type', 'f', '--hidden', '--follow', '--color', 'never' } + local args = + { file_cmd, '--type', 'f', '--hidden', '--follow', '--color', 'never', '--exclude', '.git' } if file_cmd == 'fd' or file_cmd == 'fdfind' then table.insert(args, '--strip-cwd-prefix') end - vim.system(args, { text = true, cwd = root }, function(obj) + cancel_job(S.job_files) + S.job_files = vim.system(args, { text = true, cwd = root }, function(obj) + if not (S.active and gen == S.gen) then + return + end local list if obj.code == 0 and obj.stdout then - list = vim.split(obj.stdout, '\n', { trimempty = true }) + local raw = vim.split(obj.stdout, '\n', { trimempty = true }) + list = {} + for i = 1, #raw do + list[i] = to_abs_in_root(root, raw[i]) + end else - list = vim.fn.globpath(root, '**/*', false, true) - list = vim.tbl_filter(function(p) - return vim.fn.isdirectory(p) == 0 - end, list) + list = {} + end + if #list == 0 and S.is_git and cmd_exists('git') then + -- fallback to git ls-files, still async + local gargs = { 'git', 'ls-files', '-co', '--exclude-standard', '-z' } + cancel_job(S.job_files) + S.job_files = vim.system(gargs, { text = true, cwd = root }, function(o2) + if not (S.active and gen == S.gen) then + return + end + local glist = {} + if o2.code == 0 and o2.stdout then + for p in o2.stdout:gmatch('([^%z]+)') do + glist[#glist + 1] = to_abs_in_root(root, p) + end + end + if #glist > M.config.max_items then + local tmp = {} + for i = 1, M.config.max_items do + tmp[i] = glist[i] + end + glist = tmp + end + S.cache[root] = S.cache[root] or {} + S.cache[root].files = { list = glist, at = now_sec() } + vim.schedule(function() + if S.active and gen == S.gen then + cb(glist) + end + end) + end) + return end if #list > M.config.max_items then local tmp = {} @@ -413,28 +506,33 @@ local function collect_files_async(cb) S.cache[root] = S.cache[root] or {} S.cache[root].files = { list = list, at = now_sec() } vim.schedule(function() - cb(list) + if S.active and gen == S.gen then + cb(list) + end end) end) - else - local list = vim.fn.globpath(root, '**/*', false, true) - list = vim.tbl_filter(function(p) - return vim.fn.isdirectory(p) == 0 - end, list) - if #list > M.config.max_items then - local tmp = {} - for i = 1, M.config.max_items do - tmp[i] = list[i] - end - list = tmp - end - S.cache[root] = S.cache[root] or {} - S.cache[root].files = { list = list, at = now_sec() } - cb(list) + return end + + -- Last-resort blocking fallback (no fd, not git, or git failed) + local list = vim.fn.globpath(root, '**/*', false, true) + list = vim.tbl_filter(function(p) + return vim.fn.isdirectory(p) == 0 + end, list) + if #list > M.config.max_items then + local tmp = {} + for i = 1, M.config.max_items do + tmp[i] = list[i] + end + list = tmp + end + S.cache[root] = S.cache[root] or {} + S.cache[root].files = { list = list, at = now_sec() } + cb(list) end local function grep_async(query, cb) + local gen = S.gen if query == '' then cb({}) return @@ -447,8 +545,25 @@ local function grep_async(query, cb) cb({}) return end - local args = { rg, '--vimgrep', '--hidden', '--smart-case', '--no-heading', '--', query } - vim.system(args, { text = true, cwd = S.root }, function(obj) + local args = { + rg, + '--vimgrep', + '--hidden', + '--smart-case', + '--no-heading', + '--no-config', + '--color', + 'never', + '--path-separator', + '/', + '--', + query, + } + cancel_job(S.job_rg) + S.job_rg = vim.system(args, { text = true, cwd = S.root }, function(obj) + if not (S.active and gen == S.gen) then + return + end local list = {} if obj.code == 0 and obj.stdout then list = vim.split(obj.stdout, '\n', { trimempty = true }) @@ -461,7 +576,9 @@ local function grep_async(query, cb) list = tmp end vim.schedule(function() - cb(list) + if S.active and gen == S.gen then + cb(list) + end end) end) end @@ -506,7 +623,7 @@ local function open_layout(prompt) style = 'minimal', border = 'single', width = width, - height = height - 2, + height = math.max(1, height - 2), col = col, row = row + 2, focusable = false, @@ -520,7 +637,6 @@ local function open_layout(prompt) end S.aug = vim.api.nvim_create_augroup('finder_session', { clear = true }) - -- Close if focus enters a non-floating window that isn't ours vim.api.nvim_create_autocmd('WinEnter', { group = S.aug, callback = function() @@ -538,7 +654,6 @@ local function open_layout(prompt) end, }) - -- Close if prompt buffer hides or leaves vim.api.nvim_create_autocmd({ 'BufHidden', 'BufLeave' }, { group = S.aug, buffer = S.buf_inp, @@ -549,7 +664,6 @@ local function open_layout(prompt) end, }) - -- Re-render on resize to respect new viewport height vim.api.nvim_create_autocmd('VimResized', { group = S.aug, callback = function() @@ -609,11 +723,18 @@ local function attach_handlers() buffer = S.buf_inp, callback = function() debounce(function() + if not (S.active and vim.api.nvim_buf_is_valid(S.buf_inp)) then + return + end + -- Prompt buffers usually return only the user input, but strip prompt defensively. local raw = vim.fn.getline('.') local prompt = (S.mode == 'files') and '^Search:%s*' or '^Grep:%s*' local q = raw:gsub(prompt, '') if S.mode == 'grep' then grep_async(q, function(list) + if not S.active then + return + end S.items = list set_query(q) end) @@ -635,13 +756,16 @@ function M.files() M.close() end S.active = true + S.gen = S.gen + 1 S.mode = 'files' S.root = project_root() open_layout('Search: ') attach_handlers() collect_files_async(function(list) - set_items(list) -- use the helper - -- no need to call set_query('') here; set_items() renders initial list + if not S.active then + return + end + set_items(list) end) end @@ -650,6 +774,7 @@ function M.grep() M.close() end S.active = true + S.gen = S.gen + 1 S.mode = 'grep' S.root = project_root() open_layout('Grep: ') @@ -662,12 +787,18 @@ function M.close() if not S.active then return end - close_layout() + -- stop timers and jobs first if S.timer then S.timer:stop() S.timer:close() S.timer = nil end + cancel_job(S.job_rg) + S.job_rg = nil + cancel_job(S.job_files) + S.job_files = nil + + close_layout() S.active = false S.mode, S.root = nil, nil S.items, S.filtered, S.positions = {}, {}, {}