diff --git a/lua/modules/navigation.lua b/lua/modules/navigation.lua index 66d5d28..b994dce 100644 --- a/lua/modules/navigation.lua +++ b/lua/modules/navigation.lua @@ -1,13 +1,12 @@ require('plugins.filetree') -require('plugins.finder').setup({ +local finder = require('plugins.finder') +finder.setup({ exclude_patterns = { 'node_modules', 'dist', 'build', '.git', '.cache', '.turbo', '*-lock.json' }, - use_disk_cache = true, -- optional + use_disk_cache = true, + follow_symlinks = true, }) -vim.keymap.set('n', 'f', function() - require('plugins.finder').files() -end) - -vim.keymap.set('n', 'g', function() - require('plugins.finder').grep() -end) +vim.keymap.set('n', 'f', finder.files) +vim.keymap.set('n', 'g', finder.grep) +-- vim.keymap.set('n', 'fc', finder.clear_cache) +-- vim.keymap.set('n', 'fD', finder.diagnose) diff --git a/lua/plugins/finder.lua b/lua/plugins/finder.lua index 6fdb487..c10c1be 100644 --- a/lua/plugins/finder.lua +++ b/lua/plugins/finder.lua @@ -6,7 +6,7 @@ local M = {} -- Config --------------------------------------------------------------------- M.config = { - file_cmd = nil, -- "fd" | "fdfind" | nil (auto) + file_cmd = nil, -- "fd" | "fdfind" | nil (auto-detect) grep_cmd = 'rg', -- ripgrep binary page_size = 60, -- soft cap; real viewport height is measured debounce_ms = 80, @@ -15,8 +15,9 @@ M.config = { cache_ttl_sec = 20, max_items = 5000, -- safety cap for massive outputs debug = false, + follow_symlinks = true, -- pass -L to fd - -- Exclusion controls (added to tool flags and used in glob fallback) + -- Exclusion patterns (used by fd/rg in addition to their gitignore handling) exclude_patterns = { 'node_modules', 'dist', 'build', '.git' }, -- Optional on-disk filelist cache at $ROOT/.devflow/finder_cache.json @@ -77,16 +78,18 @@ local function L(msg, data) end local function now_sec() - return vim.loop.now() / 1000 + return os.time() end + local function clamp(v, lo, hi) return (v < lo) and lo or ((v > hi) and hi or v) end + local function cmd_exists(bin) return vim.fn.executable(bin) == 1 end --- single reusable timer to avoid churn; caller controls ms +-- single reusable timer to avoid churn local function debounce(fn, ms) if not S.timer then S.timer = vim.loop.new_timer() @@ -98,30 +101,12 @@ local function debounce(fn, ms) end) end -local function read_gitignore(root) - local p = root .. '/.gitignore' - 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 - l = l:gsub('/+$', '') - table.insert(lines, l) - end - end - f:close() - return lines -end - local function ensure_cache_dir(root) local dir = root .. '/.devflow' local ok = vim.loop.fs_stat(dir) if not ok then - pcall(vim.loop.fs_mkdir, dir, 448) - end -- 0700 + pcall(vim.loop.fs_mkdir, dir, 448) -- 0700 + end return dir end @@ -141,7 +126,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 + pcall(os.remove, file) return nil end return data.files @@ -173,7 +158,6 @@ 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 '' @@ -189,7 +173,6 @@ local function normalize_rel(root, p) return abs:sub(#root_norm + 2) end end - -- strip leading ./ as last resort return (p:gsub('^%./', '')) end @@ -201,7 +184,6 @@ local function to_abs_path(root, rel) end local function to_display_path(abs) - -- relative to current working dir if possible, else absolute return vim.fn.fnamemodify(abs, ':.') end @@ -225,11 +207,11 @@ local function effective_debounce_ms() end --------------------------------------------------------------------- --- Render helpers +-- Render --------------------------------------------------------------------- local function ensure_visible() local page = page_rows() - local sel = clamp(S.select, 1, #S.filtered) + local sel = clamp(S.select, 1, math.max(1, #S.filtered)) local top = S.scroll + 1 local bot = S.scroll + page if sel < top then @@ -240,9 +222,6 @@ local function ensure_visible() S.scroll = clamp(S.scroll, 0, math.max(#S.filtered - page, 0)) end ---------------------------------------------------------------------- --- Render ---------------------------------------------------------------------- local function render() if not (S.buf_res and vim.api.nvim_buf_is_valid(S.buf_res)) then return @@ -253,22 +232,20 @@ local function render() local total = #S.filtered local view = {} if total == 0 then - view = { '-- no matches --' } + view = { ' (no matches)' } else local start_idx = S.scroll + 1 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] = tostring(line or '') + view[#view + 1] = tostring(S.filtered[i] or '') end end vim.bo[S.buf_res].modifiable = true - vim.bo[S.buf_res].readonly = false vim.api.nvim_buf_set_lines(S.buf_res, 0, -1, false, view) vim.api.nvim_buf_clear_namespace(S.buf_res, S.ns, 0, -1) - -- match highlights (visible window only) + -- match highlights vim.api.nvim_set_hl(0, 'FinderMatch', { link = 'Search', default = true }) for i = 1, #view do local idx = S.scroll + i @@ -277,7 +254,7 @@ local function render() for _, se in ipairs(spans) do local scol, ecol = se[1], se[2] if ecol > scol then - vim.api.nvim_buf_set_extmark(S.buf_res, S.ns, i - 1, scol, { + pcall(vim.api.nvim_buf_set_extmark, S.buf_res, S.ns, i - 1, scol, { end_col = ecol, hl_group = 'FinderMatch', }) @@ -287,10 +264,10 @@ local function render() end -- selection highlight - if total > 0 and #view > 0 then + if total > 0 then vim.api.nvim_set_hl(0, 'FinderSelection', { link = 'CursorLine', default = true }) local rel = clamp(S.select - S.scroll, 1, #view) - vim.api.nvim_buf_set_extmark(S.buf_res, S.ns, rel - 1, 0, { + pcall(vim.api.nvim_buf_set_extmark, S.buf_res, S.ns, rel - 1, 0, { end_line = rel, hl_group = 'FinderSelection', hl_eol = true, @@ -298,11 +275,11 @@ local function render() end vim.bo[S.buf_res].modifiable = false - L('render', { select = S.select, scroll = S.scroll, total = total, lines = #view }) + L('render', { select = S.select, scroll = S.scroll, total = total }) end --------------------------------------------------------------------- --- Querying / Filtering +-- Filtering --------------------------------------------------------------------- local function compute_positions_files(items, q) local ok, res = pcall(vim.fn.matchfuzzypos, items, q) @@ -320,14 +297,13 @@ local function compute_positions_files(items, q) local spans = {} local start_col, last_col = nil, nil for _, c in ipairs(cols) do - local c0 = c if not start_col then - start_col, last_col = c0, c0 - elseif c0 == last_col + 1 then - last_col = c0 + start_col, last_col = c, c + elseif c == last_col + 1 then + last_col = c else - table.insert(spans, { start_col, last_col + 1 }) -- end exclusive - start_col, last_col = c0, c0 + table.insert(spans, { start_col, last_col + 1 }) + start_col, last_col = c, c end end if start_col then @@ -342,14 +318,16 @@ local function compute_positions_grep(lines, q) if q == '' then return lines, {} end - local qlow = q:lower() + -- Match rg's --smart-case: case-insensitive unless query has uppercase + local case_insensitive = not q:match('[A-Z]') + local qmatch = case_insensitive and q:lower() or q local positions = {} for i, line in ipairs(lines) do - local llow = tostring(line or ''):lower() + local lmatch = case_insensitive and tostring(line or ''):lower() or tostring(line or '') local spans = {} local sidx = 1 while true do - local s, e = llow:find(qlow, sidx, true) + local s, e = lmatch:find(qmatch, sidx, true) if not s then break end @@ -365,7 +343,7 @@ local function compute_positions_grep(lines, q) end --------------------------------------------------------------------- --- set_items +-- set_items / set_query --------------------------------------------------------------------- local function set_items(list) list = list or {} @@ -377,11 +355,12 @@ local function set_items(list) list = tmp end - -- sanitize to strings for i, v in ipairs(list) do list[i] = tostring(v or '') end + table.sort(list) + S.items = list S.filtered = list S.positions = {} @@ -390,9 +369,6 @@ local function set_items(list) render() end ---------------------------------------------------------------------- --- set_query ---------------------------------------------------------------------- local function set_query(q) local prev_val = S.filtered[S.select] S.query = q @@ -410,7 +386,6 @@ local function set_query(q) end end - -- selection policy: if not S.user_moved then S.select = 1 else @@ -428,11 +403,11 @@ local function set_query(q) ensure_visible() render() - L('set_query', { query = q, filtered = #S.filtered, user_moved = S.user_moved }) + L('set_query', { query = q, filtered = #S.filtered }) end --------------------------------------------------------------------- --- Move / Accept +-- Navigation --------------------------------------------------------------------- local function move_down() if #S.filtered == 0 then @@ -454,52 +429,70 @@ local function move_up() render() end --- Correct parser for rg --vimgrep (file:line:col:match) +local function page_down() + if #S.filtered == 0 then + return + end + S.user_moved = true + S.select = clamp(S.select + page_rows(), 1, #S.filtered) + ensure_visible() + render() +end + +local function page_up() + if #S.filtered == 0 then + return + end + S.user_moved = true + S.select = clamp(S.select - page_rows(), 1, #S.filtered) + ensure_visible() + render() +end + +--------------------------------------------------------------------- +-- Accept selection +--------------------------------------------------------------------- local function parse_vimgrep(line) if type(line) ~= 'string' then return nil end - local file, lnum = line:match('^(.-):(%d+):%d+:') + local file, lnum, col = line:match('^(.-):(%d+):(%d+):') if not file then return nil end - return file, tonumber(lnum) or 1 + return file, tonumber(lnum) or 1, tonumber(col) or 1 end -local function accept_selection_files() +local function accept_selection() local pick = S.filtered[S.select] - if type(pick) ~= 'string' or pick == '' then - return + if type(pick) ~= 'string' or pick == '' or pick:match('^%s*%(') then + return -- ignore "(no matches)" placeholder 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(path)) - end) -end -local function accept_selection_grep() - local pick = S.filtered[S.select] - if type(pick) ~= 'string' or pick == '' then - return + if S.mode == 'grep' then + local file, lnum, col = parse_vimgrep(pick) + if not file then + return + end + local abs = to_abs_path(S.root, file) + local path = to_display_path(abs) + M.close() + vim.schedule(function() + vim.cmd.edit(vim.fn.fnameescape(path)) + pcall(vim.api.nvim_win_set_cursor, 0, { lnum, col - 1 }) + end) + else + 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(path)) + end) end - local file, lnum = parse_vimgrep(pick) - if not file then - 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(path)) - pcall(vim.api.nvim_win_set_cursor, 0, { ln, 0 }) - end) end --------------------------------------------------------------------- --- Backends +-- Async backends --------------------------------------------------------------------- local function cancel_job(job) if not job then @@ -507,7 +500,7 @@ local function cancel_job(job) end pcall(function() job:kill(15) - end) -- SIGTERM if available + end) end local function collect_files_async(cb) @@ -531,24 +524,19 @@ local function collect_files_async(cb) end end - local excludes = {} - for _, p in ipairs(M.config.exclude_patterns or {}) do - table.insert(excludes, p) - end - local gi = read_gitignore(root) - for _, p in ipairs(gi) do - table.insert(excludes, p) - end - - if file_cmd then + if file_cmd and cmd_exists(file_cmd) then local args = { file_cmd, '--type', 'f', '--hidden', '--color', 'never' } if file_cmd == 'fd' or file_cmd == 'fdfind' then table.insert(args, '--strip-cwd-prefix') + if M.config.follow_symlinks then + table.insert(args, '-L') + end end - for _, ex in ipairs(excludes) do + for _, ex in ipairs(M.config.exclude_patterns or {}) do table.insert(args, '--exclude') table.insert(args, ex) end + 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 @@ -556,9 +544,11 @@ local function collect_files_async(cb) end local list = {} 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) + for p in obj.stdout:gmatch('[^\n]+') do + local rel = normalize_rel(root, p) + if rel ~= '' then + list[#list + 1] = rel + end end end if #list > M.config.max_items then @@ -578,20 +568,23 @@ local function collect_files_async(cb) return end - -- Try git ls-files as async fallback + -- Fallback: git ls-files if cmd_exists('git') then cancel_job(S.job_files) S.job_files = vim.system( - { 'git', 'ls-files', '-co', '--exclude-standard', '-z' }, + { 'git', 'ls-files', '-co', '--exclude-standard' }, { text = true, cwd = root }, - function(o2) + function(obj) if not (S.active and gen == S.gen) then return end local list = {} - if o2.stdout then - for p in o2.stdout:gmatch('([^%z]+)') do - list[#list + 1] = normalize_rel(root, p) + if obj.stdout then + for p in obj.stdout:gmatch('[^\n]+') do + local rel = normalize_rel(root, p) + if rel ~= '' then + list[#list + 1] = rel + end end end if #list > M.config.max_items then @@ -612,7 +605,7 @@ local function collect_files_async(cb) return end - -- Last resort omitted: blocking glob removed to keep async-only behavior + L('no file lister available') cb({}) end @@ -624,6 +617,7 @@ local function grep_async(query, cb) local gen = S.gen local rg = M.config.grep_cmd if not cmd_exists(rg) then + L('grep_cmd not found', rg) cb({}) return end @@ -640,28 +634,27 @@ local function grep_async(query, cb) 'never', '--path-separator', '/', - '--', - query, } - -- Apply excludes as negative globs + + -- Add excludes before the pattern for _, p in ipairs(M.config.exclude_patterns or {}) do - table.insert(args, 2, '--glob') - table.insert(args, 3, '!' .. p) - end - for _, p in ipairs(read_gitignore(root)) do - table.insert(args, 2, '--glob') - table.insert(args, 3, '!' .. p) + table.insert(args, '--glob') + table.insert(args, '!' .. p) end + table.insert(args, '--') + table.insert(args, query) + cancel_job(S.job_rg) S.job_rg = vim.system(args, { text = true, cwd = root }, function(obj) if not (S.active and gen == S.gen) then return end local list = {} - -- 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 }) + for line in obj.stdout:gmatch('[^\n]+') do + list[#list + 1] = line + end end if #list > M.config.max_items then local tmp = {} @@ -692,19 +685,14 @@ local function open_layout(prompt) vim.bo[b].swapfile = false end end - if S.buf_inp and vim.api.nvim_buf_is_valid(S.buf_inp) then - vim.bo[S.buf_inp].buftype = 'prompt' - end - if S.buf_res and vim.api.nvim_buf_is_valid(S.buf_res) then - vim.bo[S.buf_res].buftype = 'nofile' - vim.bo[S.buf_res].modifiable = false - vim.bo[S.buf_res].readonly = false - end + vim.bo[S.buf_inp].buftype = 'prompt' + vim.bo[S.buf_res].buftype = 'nofile' + vim.bo[S.buf_res].modifiable = false local width = math.floor(vim.o.columns * 0.8) local height = math.min(math.floor(vim.o.lines * 0.5), M.config.page_size + 2) local col = math.floor((vim.o.columns - width) / 2) - local row = math.floor((vim.o.lines - height) * 0.7) + local row = math.floor((vim.o.lines - height) * 0.4) S.win_inp = vim.api.nvim_open_win(S.buf_inp, true, { relative = 'editor', @@ -717,32 +705,25 @@ local function open_layout(prompt) focusable = true, zindex = 200, }) - if S.buf_inp and vim.api.nvim_buf_is_valid(S.buf_inp) then - vim.fn.prompt_setprompt(S.buf_inp, prompt or '') - end + vim.fn.prompt_setprompt(S.buf_inp, prompt or '') S.win_res = vim.api.nvim_open_win(S.buf_res, false, { relative = 'editor', style = 'minimal', - border = 'single', + border = 'rounded', width = width, height = math.max(1, height - 2), col = col, - row = row + 2, + row = row + 3, focusable = false, zindex = 199, }) - if S.win_res and vim.api.nvim_win_is_valid(S.win_res) then - vim.wo[S.win_res].cursorline = false - vim.wo[S.win_res].cursorlineopt = 'line' - end if S.aug then pcall(vim.api.nvim_del_augroup_by_id, S.aug) 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() @@ -760,20 +741,16 @@ local function open_layout(prompt) end, }) - -- Close if prompt buffer hides or leaves - if S.buf_inp and vim.api.nvim_buf_is_valid(S.buf_inp) then - vim.api.nvim_create_autocmd({ 'BufHidden', 'BufLeave' }, { - group = S.aug, - buffer = S.buf_inp, - callback = function() - if S.active then - M.close() - end - end, - }) - end + vim.api.nvim_create_autocmd({ 'BufHidden', 'BufLeave' }, { + group = S.aug, + buffer = S.buf_inp, + callback = function() + if S.active then + M.close() + end + end, + }) - -- Re-render on resize to respect new viewport height vim.api.nvim_create_autocmd('VimResized', { group = S.aug, callback = function() @@ -784,7 +761,7 @@ local function open_layout(prompt) }) vim.cmd.startinsert() - L('open_layout', { win_inp = S.win_inp, win_res = S.win_res }) + L('open_layout') end local function close_layout() @@ -802,7 +779,6 @@ local function close_layout() pcall(vim.api.nvim_del_augroup_by_id, S.aug) S.aug = nil end - L('close_layout') end --------------------------------------------------------------------- @@ -810,59 +786,50 @@ end --------------------------------------------------------------------- local function attach_handlers() local opts = { buffer = S.buf_inp, nowait = true, silent = true, noremap = true } + vim.keymap.set('i', '', move_down, opts) vim.keymap.set('i', '', move_up, opts) vim.keymap.set('i', '', move_down, opts) vim.keymap.set('i', '', move_up, opts) - vim.keymap.set('i', '', function() - if S.mode == 'grep' then - accept_selection_grep() - else - accept_selection_files() - end - end, opts) - vim.keymap.set('i', '', function() - M.close() - end, opts) - vim.keymap.set('i', '', function() - M.close() - end, opts) + vim.keymap.set('i', '', page_down, opts) + vim.keymap.set('i', '', page_up, opts) + vim.keymap.set('i', '', accept_selection, opts) + vim.keymap.set('i', '', M.close, opts) + vim.keymap.set('i', '', M.close, opts) - if S.buf_inp and vim.api.nvim_buf_is_valid(S.buf_inp) then - vim.api.nvim_create_autocmd({ 'TextChangedI', 'TextChangedP' }, { - 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 - end - local raw = vim.fn.getline('.') - raw = type(raw) == 'string' and raw or '' - local prompt_pat = (S.mode == 'files') and '^Search:%s*' or '^Grep:%s*' - local q = raw:gsub(prompt_pat, '') - if S.mode == 'grep' then - grep_async(q, function(list) - if not S.active then - return - end - S.items = list or {} - set_query(q) - end) - else + vim.api.nvim_create_autocmd({ 'TextChangedI', 'TextChangedP' }, { + 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 + end + local raw = vim.fn.getline('.') or '' + local prompt_len = S.mode == 'files' and 8 or 6 -- "Search: " or "Grep: " + local q = raw:sub(prompt_len + 1) + + if S.mode == 'grep' then + grep_async(q, function(list) + if not S.active then + return + end + S.items = list or {} set_query(q) - end - end, delay) - end, - }) - end + end) + else + set_query(q) + end + end, delay) + end, + }) - L('keymaps attached', opts) + L('handlers attached') end --------------------------------------------------------------------- --- Public +-- Public API --------------------------------------------------------------------- function M.files() if S.active then @@ -879,7 +846,7 @@ function M.files() if not S.active then return end - set_items(list) -- render initial list (relative to root) + set_items(list) end) end @@ -902,7 +869,6 @@ function M.close() if not S.active then return end - -- stop timers and jobs first if S.timer then pcall(function() S.timer:stop() @@ -922,26 +888,67 @@ function M.close() S.query, S.select, S.scroll = '', 1, 0 S.user_moved = false S.files_cached = false - L('session closed') + L('closed') +end + +function M.clear_cache() + local root = project_root() + local file = root .. '/.devflow/finder_cache.json' + local ok = pcall(os.remove, file) + vim.notify(ok and 'Finder cache cleared' or 'No cache to clear') +end + +function M.diagnose() + local root = project_root() + local lines = { '=== Finder Diagnostics ===' } + + local fd_cmd = M.config.file_cmd + or (cmd_exists('fd') and 'fd') + or (cmd_exists('fdfind') and 'fdfind') + or nil + table.insert(lines, 'fd command: ' .. tostring(fd_cmd)) + table.insert( + lines, + 'rg command: ' + .. tostring(M.config.grep_cmd) + .. ' (exists: ' + .. tostring(cmd_exists(M.config.grep_cmd)) + .. ')' + ) + + local cache_file = root .. '/.devflow/finder_cache.json' + local cache_stat = vim.loop.fs_stat(cache_file) + if cache_stat then + table.insert(lines, 'Cache file: ' .. cache_file) + table.insert(lines, 'Cache size: ' .. cache_stat.size .. ' bytes') + local cached = load_cache(root) + if cached then + table.insert(lines, 'Cache valid: true (' .. #cached .. ' files)') + else + table.insert(lines, 'Cache valid: false (expired or corrupt)') + end + else + table.insert(lines, 'Cache: none') + end + + table.insert(lines, 'Exclude patterns: ' .. vim.inspect(M.config.exclude_patterns)) + table.insert(lines, 'Follow symlinks: ' .. tostring(M.config.follow_symlinks)) + + vim.notify(table.concat(lines, '\n'), vim.log.levels.INFO) end function M.setup(opts) M.config = vim.tbl_deep_extend('force', M.config, opts or {}) - if M.config.file_cmd and not cmd_exists(M.config.file_cmd) then - pcall( - vim.notify, - ("finder: file_cmd '%s' not found"):format(M.config.file_cmd), - vim.log.levels.WARN - ) + + local fd_cmd = M.config.file_cmd + if fd_cmd and not cmd_exists(fd_cmd) then + vim.notify('[finder] file_cmd "' .. fd_cmd .. '" not found', vim.log.levels.WARN) end if M.config.grep_cmd and not cmd_exists(M.config.grep_cmd) then - pcall( - vim.notify, - ("finder: grep_cmd '%s' not found, will try 'rg'"):format(M.config.grep_cmd), - vim.log.levels.WARN - ) + vim.notify('[finder] grep_cmd "' .. M.config.grep_cmd .. '" not found', vim.log.levels.WARN) end - L('setup', M.config) + + L('setup complete', M.config) end return M