533 lines
17 KiB
Lua
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
|