---@class FindFileConfig ---@field max_matches? number Maximum number of matches to display ---@field auto_cd? boolean Automatically change directory when entering a directory ---@field respect_gitignore? boolean Respect .gitignore files when listing matches (default: true) ---@field on_directory_enter? function Optional callback when entering a directory without selecting a file ---@field keymaps? FindFileKeymaps Keymap configuration ---@class FindFileKeymaps ---@field next string Cycle to next candidate ---@field prev string Cycle to previous candidate ---@field complete string Complete to selected candidate ---@field confirm string Open file or directory ---@field close string|string[] Close find-file ---@field parent_dir string Go to parent directory ---@field delete_component string Delete path component ---@field toggle_hidden string Toggle showing ignored/hidden files ---@class Match ---@field name string File or directory name ---@field path string Full path to file or directory ---@field type string Type: 'file' | 'directory' | 'link' local M = {} ---@type FindFileConfig local config = { max_matches = 8, auto_cd = true, respect_gitignore = true, on_directory_enter = nil, keymaps = { next = '', prev = '', complete = '', confirm = '', close = { '', '' }, parent_dir = '', delete_component = '', toggle_hidden = '', }, } ---Setup emacs-find-file with custom configuration ---@param opts FindFileConfig|nil Configuration options function M.setup(opts) config = vim.tbl_deep_extend('force', config, opts or {}) end ---Check if a path should be ignored based on gitignore ---@param path string Full path to check ---@param override_respect? boolean Optional override for respect_gitignore ---@return boolean should_ignore Whether the path should be ignored local function is_gitignored(path, override_respect) local should_respect if override_respect ~= nil then should_respect = override_respect else should_respect = config.respect_gitignore end if not should_respect then return false end -- Extract just the filename from the path local name = vim.fn.fnamemodify(path, ':t') -- Filter out .git directory when respecting gitignore if name == '.git' then return true end -- Use git check-ignore to determine if path is ignored local handle = io.popen('git check-ignore -q "' .. path:gsub('"', '\\"') .. '" 2>/dev/null') if not handle then return false end local _, _, exit_code = handle:close() -- git check-ignore returns 0 if path is ignored, 1 if not ignored -- In Lua, exit code 0 = success (true), non-zero = failure (false) return exit_code == 0 end ---Parse input path into directory and filter components ---@param input string Input path string ---@return string dir Directory path ---@return string filter Filter/filename component local function parse_path(input) local expanded = vim.fn.expand(input) if expanded == '' or expanded == '~' then return vim.fn.expand '~', '' end if input:match '/$' then if vim.fn.isdirectory(expanded) == 1 then return expanded, '' end end local dir = vim.fn.fnamemodify(expanded, ':h') local filter = vim.fn.fnamemodify(expanded, ':t') return dir, filter end ---Get matching files/directories from a directory ---@param dir string Directory to search ---@param filter string Filter pattern ---@param override_respect? boolean Optional override for respect_gitignore ---@return Match[] matches List of matching files/directories local function get_matches(dir, filter, override_respect) local matches = {} local handle = vim.loop.fs_scandir(dir) if not handle then return matches end while true do local name, type = vim.loop.fs_scandir_next(handle) if not name then break end if name ~= '.' and name ~= '..' then if filter == '' or name:lower():find(filter:lower(), 1, true) then local sep = dir:match '/$' and '' or '/' local full_path = dir .. sep .. name -- Skip if gitignored if not is_gitignored(full_path, override_respect) then table.insert(matches, { name = name, path = full_path, type = type, }) end end end end table.sort(matches, function(a, b) if a.type == b.type then return a.name < b.name end return a.type == 'directory' end) return matches end ---Open emacs-style find-file interface starting from a specific path ---@param start_path string|nil Starting directory path function M.open_dir(start_path) local prev_win = vim.api.nvim_get_current_win() local input_buf = vim.api.nvim_create_buf(false, true) ---@return table Window configuration local function get_win_config() local width = vim.o.columns local max_height = 5 local row = vim.o.lines - vim.o.cmdheight - max_height - 1 return { relative = 'editor', width = width, height = max_height, row = row, col = 0, style = 'minimal', border = 'none', } end local input_win = vim.api.nvim_open_win(input_buf, true, get_win_config()) vim.api.nvim_set_hl(0, 'FindFilePrompt', { link = 'Title', default = true }) vim.api.nvim_set_hl(0, 'FindFileSep', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'FindFileMatch', { link = 'Normal', default = true }) vim.api.nvim_set_hl(0, 'FindFileMatchSelected', { link = 'CursorLine', default = true }) vim.api.nvim_set_hl(0, 'FindFileMatchChar', { link = 'IncSearch', default = true }) vim.api.nvim_set_option_value('buftype', 'nofile', { buf = input_buf }) vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = input_buf }) vim.api.nvim_set_option_value('modifiable', true, { buf = input_buf }) vim.api.nvim_set_option_value('wrap', true, { win = input_win }) vim.api.nvim_set_option_value('linebreak', true, { win = input_win }) -- Disable completion plugins vim.b[input_buf].completion = false local current_input = start_path or (vim.fn.getcwd() .. '/') ---@type Match[] local matches = {} local selected = 0 local lock = false local cycling = false local user_selected = false local show_hidden = false -- Namespace for highlights local ns_id = vim.api.nvim_create_namespace 'find_file' local function update_display() local dir, filter = parse_path(current_input) matches = get_matches(dir, filter, not show_hidden) if selected > #matches then selected = 0 end if selected < 0 then selected = #matches end local parts = { 'Find file: ', current_input } local match_highlights = {} local show_matches = current_input:match '/$' or (filter ~= '' and #matches >= 1) if #matches > 0 and show_matches then local pos = #table.concat(parts, '') for i, match in ipairs(matches) do if i > config.max_matches then break end if i > 1 then table.insert(parts, ' | ') pos = pos + 3 else table.insert(parts, ' | ') pos = pos + 3 end local sep = match.type == 'directory' and '/' or '' local item = match.name .. sep local filter_start, filter_end = nil, nil if filter ~= '' then filter_start, filter_end = match.name:lower():find(filter:lower(), 1, true) end table.insert(match_highlights, { start = pos, finish = pos + #item, selected = i == selected and user_selected and selected > 0, is_dir = match.type == 'directory', filter_start = filter_start, filter_end = filter_end, }) table.insert(parts, item) pos = pos + #item end if #matches > config.max_matches then table.insert(parts, ' | ...') end end local full_text = table.concat(parts, '') lock = true cycling = true vim.api.nvim_buf_set_lines(input_buf, 0, -1, false, { full_text }) local width = vim.o.columns local required_lines = math.ceil(#full_text / width) local needed_height = math.min(required_lines, 5) local current_config = vim.api.nvim_win_get_config(input_win) if current_config.height ~= needed_height then local new_config = get_win_config() new_config.height = needed_height new_config.row = vim.o.lines - vim.o.cmdheight - needed_height - 1 vim.api.nvim_win_set_config(input_win, new_config) end vim.api.nvim_buf_clear_namespace(input_buf, ns_id, 0, -1) -- Highlight the prompt vim.api.nvim_buf_set_extmark(input_buf, ns_id, 0, 0, { end_col = 11, hl_group = 'FindFilePrompt', }) -- Highlight input if no match is selected if selected == 0 or not user_selected then vim.api.nvim_buf_set_extmark(input_buf, ns_id, 0, 11, { end_col = 11 + #current_input, hl_group = 'FindFileMatchSelected', }) end -- Highlight path separators for i = 1, #current_input do if current_input:sub(i, i) == '/' then vim.api.nvim_buf_set_extmark(input_buf, ns_id, 0, 11 + i - 1, { end_col = 11 + i, hl_group = 'FindFileSep', }) end end -- Highlight matches for _, hl in ipairs(match_highlights) do if hl.selected then vim.api.nvim_buf_set_extmark(input_buf, ns_id, 0, hl.start, { end_col = hl.finish, hl_group = 'FindFileMatchSelected', }) else vim.api.nvim_buf_set_extmark(input_buf, ns_id, 0, hl.start, { end_col = hl.finish, hl_group = 'FindFileMatch', }) end -- Highlight the matching characters if hl.filter_start and hl.filter_end then local match_start = hl.start + hl.filter_start - 1 local match_end = hl.start + hl.filter_end vim.api.nvim_buf_set_extmark(input_buf, ns_id, 0, match_start, { end_col = match_end, hl_group = 'FindFileMatchChar', }) end end local cursor_col = 11 + #current_input vim.api.nvim_win_set_cursor(input_win, { 1, cursor_col }) vim.schedule(function() lock = false cycling = false end) end local function close() if vim.api.nvim_win_is_valid(input_win) then vim.api.nvim_win_close(input_win, true) end if vim.api.nvim_win_is_valid(prev_win) then vim.api.nvim_set_current_win(prev_win) end vim.cmd 'stopinsert' end update_display() vim.schedule(function() vim.api.nvim_win_set_cursor(input_win, { 1, 11 + #current_input }) end) local last_input = current_input vim.api.nvim_create_autocmd({ 'TextChangedI' }, { buffer = input_buf, callback = function() if lock or cycling then return end local line = vim.api.nvim_buf_get_lines(input_buf, 0, 1, false)[1] or '' if not line:match '^Find file: ' then return end local path_part = line:sub(12) local pipe_pos = path_part:find ' | ' if pipe_pos then path_part = path_part:sub(1, pipe_pos - 1) end if path_part ~= last_input then current_input = path_part last_input = path_part selected = 0 user_selected = false vim.schedule(update_display) end end, }) vim.api.nvim_create_autocmd({ 'CursorMovedI' }, { buffer = input_buf, callback = function() if lock or cycling then return end local cursor = vim.api.nvim_win_get_cursor(input_win) local max_col = 11 + #current_input if cursor[2] > max_col then vim.api.nvim_win_set_cursor(input_win, { 1, max_col }) end end, }) vim.api.nvim_create_autocmd({ 'VimResized' }, { callback = function() if vim.api.nvim_win_is_valid(input_win) then vim.api.nvim_win_set_config(input_win, get_win_config()) end end, }) local opts = { buffer = input_buf, nowait = true, silent = true } vim.keymap.set('i', config.keymaps.next, function() selected = selected + 1 if selected > #matches then selected = 0 end user_selected = true vim.schedule(update_display) return '' end, vim.tbl_extend('force', opts, { expr = true, desc = 'Cycle to next candidate' })) vim.keymap.set('i', config.keymaps.prev, function() selected = selected - 1 if selected < 0 then selected = #matches end user_selected = true vim.schedule(update_display) return '' end, vim.tbl_extend('force', opts, { expr = true, desc = 'Cycle to previous candidate' })) vim.keymap.set('i', config.keymaps.complete, function() if #matches == 0 then return '' end local match = selected > 0 and matches[selected] or matches[1] current_input = match.path if match.type == 'directory' then current_input = current_input .. '/' end last_input = current_input selected = 0 user_selected = false vim.schedule(update_display) return '' end, vim.tbl_extend('force', opts, { expr = true, desc = 'Complete to selected candidate' })) vim.keymap.set('i', config.keymaps.confirm, function() local in_directory_mode = current_input:match '/$' local dir, filter = parse_path(current_input) if user_selected and selected > 0 and #matches > 0 then if matches[selected].type == 'directory' then current_input = matches[selected].path .. '/' last_input = current_input selected = 0 user_selected = false vim.schedule(update_display) vim.cmd 'startinsert!' return '' else local file = matches[selected].path vim.schedule(function() close() vim.cmd('edit ' .. vim.fn.fnameescape(file)) end) return '' end elseif (user_selected and selected == 0) or not user_selected then if in_directory_mode then local dir_path = vim.fn.expand(current_input) vim.schedule(function() close() if config.auto_cd then vim.cmd('cd ' .. vim.fn.fnameescape(dir_path)) end if config.on_directory_enter then config.on_directory_enter(dir_path) else vim.cmd('edit ' .. vim.fn.fnameescape(dir_path)) end end) return '' elseif filter ~= '' then local file = vim.fn.expand(current_input) vim.schedule(function() close() vim.cmd('edit ' .. vim.fn.fnameescape(file)) end) return '' else local file = vim.fn.expand(current_input) vim.schedule(function() close() vim.cmd('edit ' .. vim.fn.fnameescape(file)) end) return '' end end return '' end, { buffer = input_buf, nowait = true, expr = true, desc = 'Open file or directory' }) ---@type string[] local close_keys = type(config.keymaps.close) == 'table' and config.keymaps.close --[[@as string[] ]] or { config.keymaps.close } for _, key in ipairs(close_keys) do vim.keymap.set({ 'n', 'i' }, key, function() vim.schedule(close) end, vim.tbl_extend('force', opts, { desc = 'Close find-file' })) end vim.keymap.set('i', config.keymaps.delete_component, function() local new_pos = 0 local found_non_sep = false for i = #current_input, 1, -1 do local char = current_input:sub(i, i) local is_sep = char:match '[/%._%- ]' if not found_non_sep and not is_sep then found_non_sep = true elseif found_non_sep and is_sep then new_pos = i break end end current_input = current_input:sub(1, new_pos) selected = 0 user_selected = false vim.schedule(update_display) return '' end, { buffer = input_buf, nowait = true, expr = true, desc = 'Delete path component' }) vim.keymap.set('i', config.keymaps.parent_dir, function() -- Go to parent directory local dir = current_input:match '/$' and current_input or vim.fn.fnamemodify(current_input, ':h') local parent = vim.fn.fnamemodify(dir, ':h') if parent ~= '' and parent ~= '/' then current_input = parent .. '/' elseif parent == '/' then current_input = '/' end last_input = current_input selected = 0 user_selected = false vim.schedule(update_display) return '' end, vim.tbl_extend('force', opts, { expr = true, desc = 'Go to parent directory' })) vim.keymap.set('i', config.keymaps.toggle_hidden, function() show_hidden = not show_hidden selected = 0 user_selected = false vim.schedule(update_display) return '' end, vim.tbl_extend('force', opts, { expr = true, desc = 'Toggle showing ignored/hidden files' })) vim.cmd 'startinsert!' end ---Open emacs-style find-file interface from current buffer's directory function M.open() local current_file = vim.api.nvim_buf_get_name(0) local start_dir if current_file == '' then start_dir = vim.fn.getcwd() elseif vim.fn.isdirectory(current_file) == 1 then start_dir = current_file else start_dir = vim.fn.fnamemodify(current_file, ':p:h') end M.open_dir(start_dir .. '/') end return M