Files
avante.nvim/lua/avante/file_selector.lua
Dmitry Torokhov 5e50db3e05 refactor(file_selector): optimize handling of selected files
Sorting function in FileSelector:get_filepaths() is not very efficient
since it has to hit the filesystem each time it is called to determine
if it is dealing with a directory or not.

Optimize performance by figuring this data in get_project_filepaths()
and returning a list of structures containing paths and directory flags,
and use this data in get_filepaths(). Do the absolute->relative
conversion once.

Also use vim.fn.isdirectory() instead of vim.loop.fs_stat() everywhere
in the file.
2025-07-15 16:41:22 +08:00

331 lines
11 KiB
Lua

local Utils = require("avante.utils")
local Config = require("avante.config")
local Selector = require("avante.ui.selector")
local PROMPT_TITLE = "(Avante) Add a file"
--- @class FileSelector
local FileSelector = {}
--- @class FileSelector
--- @field id integer
--- @field selected_filepaths string[] Absolute paths
--- @field event_handlers table<string, function[]>
---@alias FileSelectorHandler fun(self: FileSelector, on_select: fun(filepaths: string[] | nil)): nil
local function has_scheme(path) return path:find("^(?!term://)%w+://") ~= nil end
function FileSelector:process_directory(absolute_path)
if absolute_path:sub(-1) == Utils.path_sep then absolute_path = absolute_path:sub(1, -2) end
local files = Utils.scan_directory({ directory = absolute_path, add_dirs = false })
for _, file in ipairs(files) do
local abs_path = Utils.to_absolute_path(file)
if not vim.tbl_contains(self.selected_filepaths, abs_path) then table.insert(self.selected_filepaths, abs_path) end
end
self:emit("update")
end
---@param selected_paths string[] | nil
---@return nil
function FileSelector:handle_path_selection(selected_paths)
if not selected_paths then return end
for _, selected_path in ipairs(selected_paths) do
local absolute_path = Utils.to_absolute_path(selected_path)
if vim.fn.isdirectory(absolute_path) == 1 then
self:process_directory(absolute_path)
else
local abs_path = Utils.to_absolute_path(selected_path)
if Config.file_selector.provider == "native" then
table.insert(self.selected_filepaths, abs_path)
else
if not vim.tbl_contains(self.selected_filepaths, abs_path) then
table.insert(self.selected_filepaths, abs_path)
end
end
end
end
self:emit("update")
end
---Scans a given directory and produces a list of files/directories with absolute paths
---@param excluded_paths_set? table<string, boolean> Optional set of absolute paths to exclude
---@return { path: string, is_dir: boolean }[]
local function get_project_filepaths(excluded_paths_set)
excluded_paths_set = excluded_paths_set or {}
local project_root = Utils.get_project_root()
local files = Utils.scan_directory({ directory = project_root, add_dirs = true })
return vim
.iter(files)
:filter(function(path) return not excluded_paths_set[path] end)
:map(function(path)
local is_dir = vim.fn.isdirectory(path) == 1
return { path = path, is_dir = is_dir }
end)
:totable()
end
---@param id integer
---@return FileSelector
function FileSelector:new(id)
return setmetatable({
id = id,
selected_filepaths = {},
event_handlers = {},
}, { __index = self })
end
function FileSelector:reset()
self.selected_filepaths = {}
self.event_handlers = {}
self:emit("update")
end
function FileSelector:add_selected_file(filepath)
if not filepath or filepath == "" or has_scheme(filepath) then return end
if filepath:match("^oil:") then filepath = filepath:gsub("^oil:", "") end
local absolute_path = Utils.to_absolute_path(filepath)
if vim.fn.isdirectory(absolute_path) == 1 then
self:process_directory(absolute_path)
return
end
-- Avoid duplicates
if not vim.tbl_contains(self.selected_filepaths, absolute_path) then
table.insert(self.selected_filepaths, absolute_path)
self:emit("update")
end
end
function FileSelector:add_current_buffer()
local current_buf = vim.api.nvim_get_current_buf()
local filepath = vim.api.nvim_buf_get_name(current_buf)
if filepath and filepath ~= "" and not has_scheme(filepath) then
local absolute_path = Utils.to_absolute_path(filepath)
for i, path in ipairs(self.selected_filepaths) do
if path == absolute_path then
table.remove(self.selected_filepaths, i)
self:emit("update")
return true
end
end
self:add_selected_file(absolute_path)
return true
end
return false
end
function FileSelector:on(event, callback)
local handlers = self.event_handlers[event]
if not handlers then
handlers = {}
self.event_handlers[event] = handlers
end
table.insert(handlers, callback)
end
function FileSelector:emit(event, ...)
local handlers = self.event_handlers[event]
if not handlers then return end
for _, handler in ipairs(handlers) do
handler(...)
end
end
function FileSelector:off(event, callback)
if not callback then
self.event_handlers[event] = {}
return
end
local handlers = self.event_handlers[event]
if not handlers then return end
for i, handler in ipairs(handlers) do
if handler == callback then
table.remove(handlers, i)
break
end
end
end
function FileSelector:open() self:show_selector_ui() end
function FileSelector:get_filepaths()
if type(Config.file_selector.provider_opts.get_filepaths) == "function" then
---@type avante.file_selector.opts.IGetFilepathsParams
local params = {
cwd = Utils.get_project_root(),
selected_filepaths = self.selected_filepaths,
}
return Config.file_selector.provider_opts.get_filepaths(params)
end
local selected_filepaths_set = {}
for _, abs_path in ipairs(self.selected_filepaths) do
selected_filepaths_set[abs_path] = true
end
local project_root = Utils.get_project_root()
local file_info = get_project_filepaths(selected_filepaths_set)
table.sort(file_info, function(a, b)
-- Sort alphabetically with directories being first
if a.is_dir and not b.is_dir then
return true
elseif not a.is_dir and b.is_dir then
return false
else
return a.path < b.path
end
end)
return vim
.iter(file_info)
:map(function(info)
local rel_path = Utils.make_relative_path(info.path, project_root)
if info.is_dir then rel_path = rel_path .. "/" end
return rel_path
end)
:totable()
end
---@return nil
function FileSelector:show_selector_ui()
local function handler(selected_paths) self:handle_path_selection(selected_paths) end
vim.schedule(function()
if Config.file_selector.provider ~= nil then
Utils.warn("config.file_selector is deprecated, please use config.selector instead!")
if type(Config.file_selector.provider) == "function" then
local title = string.format("%s:", PROMPT_TITLE) ---@type string
local filepaths = self:get_filepaths() ---@type string[]
local params = { title = title, filepaths = filepaths, handler = handler } ---@type avante.file_selector.IParams
Config.file_selector.provider(params)
else
---@type avante.SelectorProvider
local provider = "native"
if Config.file_selector.provider == "native" then
provider = "native"
elseif Config.file_selector.provider == "fzf" then
provider = "fzf_lua"
elseif Config.file_selector.provider == "mini.pick" then
provider = "mini_pick"
elseif Config.file_selector.provider == "snacks" then
provider = "snacks"
elseif Config.file_selector.provider == "telescope" then
provider = "telescope"
elseif type(Config.file_selector.provider) == "function" then
provider = Config.file_selector.provider
end
---@cast provider avante.SelectorProvider
local selector = Selector:new({
provider = provider,
title = PROMPT_TITLE,
items = vim.tbl_map(function(filepath) return { id = filepath, title = filepath } end, self:get_filepaths()),
default_item_id = self.selected_filepaths[1],
selected_item_ids = self.selected_filepaths,
provider_opts = Config.file_selector.provider_opts,
on_select = function(item_ids) self:handle_path_selection(item_ids) end,
get_preview_content = function(item_id)
local content = Utils.read_file_from_buf_or_disk(item_id)
local filetype = Utils.get_filetype(item_id)
return table.concat(content or {}, "\n"), filetype
end,
})
selector:open()
end
else
local selector = Selector:new({
provider = Config.selector.provider,
title = PROMPT_TITLE,
items = vim.tbl_map(function(filepath) return { id = filepath, title = filepath } end, self:get_filepaths()),
default_item_id = self.selected_filepaths[1],
selected_item_ids = self.selected_filepaths,
provider_opts = Config.selector.provider_opts,
on_select = function(item_ids) self:handle_path_selection(item_ids) end,
get_preview_content = function(item_id)
local content = Utils.read_file_from_buf_or_disk(item_id)
local filetype = Utils.get_filetype(item_id)
return table.concat(content or {}, "\n"), filetype
end,
})
selector:open()
end
end)
-- unlist the current buffer as vim.ui.select will be listed
local winid = vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_win_get_buf(winid)
vim.api.nvim_set_option_value("buflisted", false, { buf = bufnr })
vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = bufnr })
end
---@param idx integer
---@return boolean
function FileSelector:remove_selected_filepaths_with_index(idx)
if idx > 0 and idx <= #self.selected_filepaths then
table.remove(self.selected_filepaths, idx)
self:emit("update")
return true
end
return false
end
function FileSelector:remove_selected_file(rel_path)
local abs_path = Utils.to_absolute_path(rel_path)
local idx = Utils.tbl_indexof(self.selected_filepaths, abs_path)
if idx then self:remove_selected_filepaths_with_index(idx) end
end
---@return { path: string, content: string, file_type: string }[]
function FileSelector:get_selected_files_contents()
local contents = {}
for _, filepath in ipairs(self.selected_filepaths) do
local lines, error = Utils.read_file_from_buf_or_disk(filepath)
lines = lines or {}
local filetype = Utils.get_filetype(filepath)
if error ~= nil then
Utils.error("error reading file: " .. error)
else
local content = table.concat(lines, "\n")
table.insert(contents, { path = filepath, content = content, file_type = filetype })
end
end
return contents
end
function FileSelector:get_selected_filepaths() return vim.deepcopy(self.selected_filepaths) end
---@return nil
function FileSelector:add_quickfix_files()
local quickfix_files = vim
.iter(vim.fn.getqflist({ items = 0 }).items)
:filter(function(item) return item.bufnr ~= 0 end)
:map(function(item) return Utils.to_absolute_path(vim.api.nvim_buf_get_name(item.bufnr)) end)
:totable()
for _, filepath in ipairs(quickfix_files) do
self:add_selected_file(filepath)
end
end
---@return nil
function FileSelector:add_buffer_files()
local buffers = vim.api.nvim_list_bufs()
for _, bufnr in ipairs(buffers) do
-- Skip invalid or unlisted buffers
if vim.api.nvim_buf_is_valid(bufnr) and vim.bo[bufnr].buflisted then
local filepath = vim.api.nvim_buf_get_name(bufnr)
-- Skip empty paths and special buffers (like terminals)
if filepath ~= "" and not has_scheme(filepath) then
local absolute_path = Utils.to_absolute_path(filepath)
self:add_selected_file(absolute_path)
end
end
end
end
return FileSelector