nix/config/nvim/lua/find-file.lua
2025-11-27 12:19:47 -06:00

533 lines
17 KiB
Lua

---@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 = '<C-n>',
prev = '<C-p>',
complete = '<Tab>',
confirm = '<CR>',
close = { '<C-c>', '<Esc>' },
parent_dir = '<C-h>',
delete_component = '<M-BS>',
toggle_hidden = '<C-i>',
},
}
---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, '')
-- Add opening bracket
table.insert(parts, ' [')
pos = pos + 2
for i, match in ipairs(matches) do
if i > config.max_matches then break 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
-- Add pipe separator between candidates (but not after the last one shown)
if i < math.min(#matches, config.max_matches) then
table.insert(parts, ' | ')
pos = pos + 3
end
end
-- Add ellipsis if there are more matches than max_matches
if #matches > config.max_matches then
table.insert(parts, ' | ...')
pos = pos + 6
end
-- Add closing bracket
table.insert(parts, ']')
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({ 'TextChanged', '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 bracket_pos = path_part:find ' %['
if bracket_pos then path_part = path_part:sub(1, bracket_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({ 'CursorMoved', '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
-- In insert mode: exit to normal mode first
vim.keymap.set('i', key, '<Esc>', vim.tbl_extend('force', opts, { desc = 'Exit to normal mode' }))
-- In normal mode: close the prompt
vim.keymap.set('n', 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