From 095c7b92a20ce9a3120d13a7654704d2fb9ef897 Mon Sep 17 00:00:00 2001 From: Tomas Mirchev Date: Sun, 26 Oct 2025 08:32:01 +0200 Subject: [PATCH] v4 --- lua/modules/navigation.lua | 2 +- lua/plugins/finder.lua | 182 ++++++++++++++++++------------------- 2 files changed, 92 insertions(+), 92 deletions(-) diff --git a/lua/modules/navigation.lua b/lua/modules/navigation.lua index 1116597..66d5d28 100644 --- a/lua/modules/navigation.lua +++ b/lua/modules/navigation.lua @@ -1,6 +1,6 @@ require('plugins.filetree') 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 }) diff --git a/lua/plugins/finder.lua b/lua/plugins/finder.lua index 1500417..6fdb487 100644 --- a/lua/plugins/finder.lua +++ b/lua/plugins/finder.lua @@ -10,6 +10,8 @@ M.config = { grep_cmd = 'rg', -- ripgrep binary page_size = 60, -- soft cap; real viewport height is measured debounce_ms = 80, + instant_items = 1500, -- <= this count + cached => 0 ms debounce + fast_items = 4000, -- <= this count => debounce_ms/2 cache_ttl_sec = 20, max_items = 5000, -- safety cap for massive outputs debug = false, @@ -53,6 +55,9 @@ local S = { select = 1, -- absolute index in filtered (1-based) scroll = 0, -- top index (0-based) 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 end +-- single reusable timer to avoid churn; caller controls ms local function debounce(fn, ms) - if S.timer then - S.timer:stop() - S.timer:close() - S.timer = nil + if not S.timer then + S.timer = vim.loop.new_timer() end - S.timer = vim.loop.new_timer() + S.timer:stop() S.timer:start(ms, 0, function() - if S.timer then - S.timer:stop() - S.timer:close() - S.timer = nil - end + S.timer:stop() vim.schedule(fn) end) end local function read_gitignore(root) local p = root .. '/.gitignore' - local lines = {} local f = io.open(p, 'r') if not f then return {} end + local lines = {} for line in f:lines() do local l = vim.trim(line) if #l > 0 and not l:match('^#') and not l:match('^!') then - -- normalize directory suffix to plain token for our excludes l = l:gsub('/+$', '') table.insert(lines, l) end @@ -142,6 +141,7 @@ local function load_cache(root) return nil end if now_sec() - (data.timestamp or 0) > M.config.cache_ttl_sec then + pcall(os.remove, file) -- proactively clear stale return nil end return data.files @@ -173,19 +173,24 @@ local function project_root() return vim.fn.getcwd(0, 0) end +-- Robust relative-to-root local function normalize_rel(root, p) if not p or p == '' then return '' end - -- strip leading ./ and leading root if any - p = p:gsub('^%./', '') + local abs = vim.fs.normalize(vim.fn.fnamemodify(p, ':p')) local root_norm = vim.fs.normalize(root) - local abs = vim.fs.normalize(p) - if abs:find(root_norm, 1, true) == 1 then - local rel = abs:sub(#root_norm + 2) - return rel ~= '' and rel or abs + if abs == root_norm then + return '' 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 local function to_abs_path(root, rel) @@ -195,6 +200,11 @@ local function to_abs_path(root, rel) return vim.fs.normalize(root .. '/' .. rel) 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() 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)) @@ -202,6 +212,18 @@ local function page_rows() return math.max(1, math.min(M.config.page_size, vim.o.lines)) 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 --------------------------------------------------------------------- @@ -237,7 +259,7 @@ local function render() local end_idx = math.min(start_idx + page_rows() - 1, total) for i = start_idx, end_idx do 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 @@ -283,7 +305,6 @@ end -- Querying / Filtering --------------------------------------------------------------------- local function compute_positions_files(items, q) - -- matchfuzzypos returns {items, positions} local ok, res = pcall(vim.fn.matchfuzzypos, items, q) if not ok or type(res) ~= 'table' then return {}, {} @@ -293,14 +314,26 @@ local function compute_positions_files(items, q) local filtered, positions = {}, {} 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 {} + table.sort(cols) local spans = {} + local start_col, last_col = nil, nil for _, c in ipairs(cols) do - local start0 = (c > 0) and (c - 1) or 0 -- to 0-based - spans[#spans + 1] = { start0, start0 + 1 } + local c0 = c + 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 + if start_col then + table.insert(spans, { start_col, last_col + 1 }) + end + positions[i] = (#spans > 0) and spans or nil end return filtered, positions end @@ -312,15 +345,15 @@ local function compute_positions_grep(lines, q) local qlow = q:lower() local positions = {} for i, line in ipairs(lines) do - local sidx = 1 + local llow = tostring(line or ''):lower() local spans = {} - local llow = (type(line) == 'string' and line or tostring(line or '')):lower() + local sidx = 1 while true do - local s, e = string.find(llow, qlow, sidx, true) + local s, e = llow:find(qlow, sidx, true) if not s then break end - spans[#spans + 1] = { s - 1, e } -- 0-based start, exclusive end + table.insert(spans, { s - 1, e }) sidx = e + 1 if #spans > 64 then break @@ -346,9 +379,7 @@ local function set_items(list) -- sanitize to strings for i, v in ipairs(list) do - if type(v) ~= 'string' then - list[i] = tostring(v or '') - end + list[i] = tostring(v or '') end S.items = list @@ -379,16 +410,7 @@ 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 '') - end - end - -- 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 S.select = 1 else @@ -432,12 +454,12 @@ local function move_up() render() end +-- Correct parser for rg --vimgrep (file:line:col:match) local function parse_vimgrep(line) - -- Greedy file capture up to last "::" if type(line) ~= 'string' then return nil end - local file, lnum = line:match('^(.*):(%d+):') + local file, lnum = line:match('^(.-):(%d+):%d+:') if not file then return nil end @@ -450,9 +472,10 @@ local function accept_selection_files() return end local abs = to_abs_path(S.root, pick) + local path = to_display_path(abs) M.close() vim.schedule(function() - vim.cmd.edit(vim.fn.fnameescape(abs)) + vim.cmd.edit(vim.fn.fnameescape(path)) end) end @@ -466,12 +489,12 @@ local function accept_selection_grep() return end local abs = to_abs_path(S.root, file) + local path = to_display_path(abs) local ln = tonumber(lnum) or 1 M.close() vim.schedule(function() - vim.cmd.edit(vim.fn.fnameescape(abs)) - if pcall(vim.api.nvim_win_set_cursor, 0, { ln, 0 }) then - end + vim.cmd.edit(vim.fn.fnameescape(path)) + pcall(vim.api.nvim_win_set_cursor, 0, { ln, 0 }) end) end @@ -493,9 +516,11 @@ local function collect_files_async(cb) local disk = load_cache(root) if disk and type(disk) == 'table' then + S.files_cached = true cb(disk) return end + S.files_cached = false local file_cmd = M.config.file_cmd if not file_cmd then @@ -517,7 +542,6 @@ local function collect_files_async(cb) if file_cmd then local args = { file_cmd, '--type', 'f', '--hidden', '--color', 'never' } - -- best-effort: avoid leading ./ in output if file_cmd == 'fd' or file_cmd == 'fdfind' then table.insert(args, '--strip-cwd-prefix') end @@ -531,7 +555,7 @@ local function collect_files_async(cb) return end local list = {} - if obj.code == 0 and obj.stdout then + if obj.stdout then local raw = vim.split(obj.stdout, '\n', { trimempty = true }) for _, p in ipairs(raw) do list[#list + 1] = normalize_rel(root, p) @@ -565,7 +589,7 @@ local function collect_files_async(cb) return end local list = {} - if o2.code == 0 and o2.stdout then + if o2.stdout then for p in o2.stdout:gmatch('([^%z]+)') do list[#list + 1] = normalize_rel(root, p) end @@ -588,33 +612,8 @@ local function collect_files_async(cb) return end - -- Last resort: blocking glob with simple exclusion checks - local list = vim.fn.globpath(root, '**/*', false, true) - 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) + -- Last resort omitted: blocking glob removed to keep async-only behavior + cb({}) end local function grep_async(query, cb) @@ -645,17 +644,13 @@ local function grep_async(query, cb) query, } -- Apply excludes as negative globs - local excludes = {} 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 - local gi = read_gitignore(root) - for _, p in ipairs(gi) do - table.insert(excludes, p) - end - for _, ex in ipairs(excludes) do - table.insert(args, 2, '--glob') -- insert before '--' - table.insert(args, 3, '!' .. ex) + for _, p in ipairs(read_gitignore(root)) do + table.insert(args, 2, '--glob') + table.insert(args, 3, '!' .. p) end cancel_job(S.job_rg) @@ -664,7 +659,8 @@ local function grep_async(query, cb) return end 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 }) end if #list > M.config.max_items then @@ -837,6 +833,7 @@ local function attach_handlers() group = S.aug, buffer = S.buf_inp, callback = function() + local delay = effective_debounce_ms() debounce(function() if not (S.active and S.buf_inp and vim.api.nvim_buf_is_valid(S.buf_inp)) then return @@ -856,7 +853,7 @@ local function attach_handlers() else set_query(q) end - end, M.config.debounce_ms) + end, delay) end, }) end @@ -882,7 +879,7 @@ function M.files() if not S.active then return end - set_items(list) -- render initial list (relative paths) + set_items(list) -- render initial list (relative to root) end) end @@ -907,8 +904,10 @@ function M.close() end -- stop timers and jobs first if S.timer then - S.timer:stop() - S.timer:close() + pcall(function() + S.timer:stop() + S.timer:close() + end) S.timer = nil end cancel_job(S.job_rg) @@ -922,6 +921,7 @@ function M.close() S.items, S.filtered, S.positions = {}, {}, {} S.query, S.select, S.scroll = '', 1, 0 S.user_moved = false + S.files_cached = false L('session closed') end