mirror of
https://github.com/folke/snacks.nvim
synced 2025-08-03 18:28:38 +00:00
522 lines
13 KiB
Lua
522 lines
13 KiB
Lua
local M = {}
|
|
|
|
---@type table<snacks.Picker, snacks.picker.explorer.State>
|
|
M._state = setmetatable({}, { __mode = "k" })
|
|
local uv = vim.uv or vim.loop
|
|
|
|
---@class snacks.picker.explorer.Item: snacks.picker.finder.Item
|
|
---@field file string
|
|
---@field dir? boolean
|
|
---@field parent? snacks.picker.explorer.Item
|
|
---@field open? boolean
|
|
---@field last? boolean
|
|
---@field sort? string
|
|
---@field internal? boolean internal parent directories not part of fd output
|
|
|
|
---@class snacks.picker.explorer.State
|
|
---@field cwd string
|
|
---@field expanded table<string, boolean>
|
|
---@field all? boolean
|
|
---@field picker snacks.Picker.ref
|
|
---@field opts snacks.picker.explorer.Config
|
|
---@field on_find? fun()?
|
|
local State = {}
|
|
State.__index = State
|
|
---@param picker snacks.Picker
|
|
function State.new(picker)
|
|
local self = setmetatable({}, State)
|
|
self.opts = picker.opts --[[@as snacks.picker.explorer.Config]]
|
|
self.picker = picker:ref()
|
|
local filter = picker:filter()
|
|
self.cwd = filter.cwd
|
|
self.expanded = { [self.cwd] = true }
|
|
local buf = vim.api.nvim_win_get_buf(picker.main)
|
|
local buf_file = vim.fs.normalize(vim.api.nvim_buf_get_name(buf))
|
|
if uv.fs_stat(buf_file) then
|
|
self:expand(buf_file)
|
|
end
|
|
picker.list.win:on({ "WinEnter", "BufEnter" }, function()
|
|
self:follow()
|
|
end)
|
|
-- schedule initial follow
|
|
if self.opts.follow_file then
|
|
self.on_find = function()
|
|
self.on_find = nil
|
|
self:show(buf_file)
|
|
end
|
|
end
|
|
return self
|
|
end
|
|
|
|
function State:follow()
|
|
if not self.opts.follow_file then
|
|
return
|
|
end
|
|
local picker = self.picker()
|
|
if not picker or picker:is_focused() or picker.closed then
|
|
return
|
|
end
|
|
local buf = vim.api.nvim_get_current_buf()
|
|
local file = vim.api.nvim_buf_get_name(buf)
|
|
self:show(file)
|
|
end
|
|
|
|
---@param path string
|
|
function State:show(path)
|
|
local picker = self.picker()
|
|
if not picker then
|
|
return
|
|
end
|
|
path = vim.fs.normalize(path)
|
|
if not uv.fs_stat(path) then
|
|
return
|
|
end
|
|
local function show()
|
|
for item, idx in picker:iter() do
|
|
if item.file == path then
|
|
picker.list:view(idx)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not self:is_visible(path) then
|
|
self:expand(path)
|
|
self:update({ on_done = show })
|
|
else
|
|
show()
|
|
end
|
|
end
|
|
|
|
---@param path string
|
|
function State:is_visible(path)
|
|
local dir = vim.fn.isdirectory(path) == 1 and path or vim.fs.dirname(path)
|
|
if not self:in_cwd(dir) then
|
|
return false
|
|
end
|
|
if not self.expanded[dir] then
|
|
return false
|
|
end
|
|
for p, v in pairs(self.expanded) do
|
|
if not v and p:find(dir .. "/", 1, true) == 1 then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
---@param dir string
|
|
function State:is_open(dir)
|
|
return self.all or self.expanded[dir]
|
|
end
|
|
|
|
function State:in_cwd(path)
|
|
return path == self.cwd or path:find(self.cwd .. "/", 1, true) == 1
|
|
end
|
|
|
|
---@param path string
|
|
function State:expand(path)
|
|
if not self:in_cwd(path) then
|
|
return
|
|
end
|
|
if vim.fn.isdirectory(path) == 1 then
|
|
self.expanded[path] = true
|
|
else
|
|
self.expanded[vim.fs.dirname(path)] = true
|
|
end
|
|
for p in vim.fs.parents(path) do
|
|
if not self:in_cwd(p) then
|
|
break
|
|
end
|
|
self.expanded[p] = true
|
|
end
|
|
end
|
|
|
|
---@param item snacks.picker.Item
|
|
function State:toggle(item)
|
|
local dir = Snacks.picker.util.path(item)
|
|
if not dir then
|
|
return
|
|
end
|
|
self.expanded[dir] = not self.expanded[dir]
|
|
if self.expanded[dir] then
|
|
self:expand(dir)
|
|
end
|
|
self:update()
|
|
end
|
|
|
|
function State:expand_dirs()
|
|
local expand = {} ---@type table<string, boolean>
|
|
local exclude = {} ---@type table<string, boolean>
|
|
for k, v in pairs(self.expanded) do
|
|
if self:in_cwd(k) then
|
|
(v and expand or exclude)[k] = true
|
|
end
|
|
end
|
|
-- remove excluded directories
|
|
for p in pairs(expand) do
|
|
for e in pairs(exclude) do
|
|
if p:find(e .. "/", 1, true) == 1 then
|
|
expand[p] = nil
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local ret = vim.tbl_keys(expand) ---@type string[]
|
|
-- add parents
|
|
for p in pairs(expand) do
|
|
for pp in vim.fs.parents(p) do
|
|
if expand[pp] or not self:in_cwd(pp) then
|
|
break
|
|
end
|
|
expand[pp] = true
|
|
ret[#ret + 1] = pp
|
|
end
|
|
end
|
|
return ret
|
|
end
|
|
|
|
---@param opts snacks.picker.explorer.Config
|
|
---@param ctx snacks.picker.finder.ctx
|
|
function State:setup(opts, ctx)
|
|
opts = Snacks.picker.util.shallow_copy(opts)
|
|
opts.cmd = "fd"
|
|
opts.cwd = self.cwd
|
|
opts.args = { "--type", "d", "--path-separator", "/", "--absolute-path" }
|
|
self.all = #ctx.filter.search > 0
|
|
if self.all then
|
|
local picker = self.picker()
|
|
if not picker then
|
|
return {}
|
|
end
|
|
picker.list:set_target()
|
|
self.on_find = function()
|
|
for item, idx in picker:iter() do
|
|
if not item.internal then
|
|
picker.list:view(idx)
|
|
return
|
|
end
|
|
end
|
|
end
|
|
else
|
|
opts.dirs = self:expand_dirs()
|
|
vim.list_extend(opts.args, { "--max-depth", "1" })
|
|
end
|
|
return opts
|
|
end
|
|
|
|
---@param opts? {target?: boolean, on_done?: fun()}
|
|
function State:update(opts)
|
|
opts = opts or {}
|
|
local picker = self.picker()
|
|
if not picker then
|
|
return
|
|
end
|
|
if opts.target ~= false then
|
|
picker.list:set_target()
|
|
end
|
|
picker:find({ on_done = opts.on_done })
|
|
end
|
|
|
|
function State:dir()
|
|
local picker = self.picker()
|
|
if not picker then
|
|
return self.cwd
|
|
end
|
|
local item = picker:current()
|
|
if item and item.dir then
|
|
return item.file
|
|
elseif item then
|
|
return vim.fn.fnamemodify(item.file, ":h")
|
|
else
|
|
return self.cwd
|
|
end
|
|
end
|
|
|
|
function State:set_cwd(cwd)
|
|
self.cwd = cwd
|
|
self.expanded[cwd] = true
|
|
for k in pairs(self.expanded) do
|
|
if not self:in_cwd(k) then
|
|
self.expanded[k] = nil
|
|
end
|
|
end
|
|
self:update({ target = false })
|
|
end
|
|
|
|
function State:up()
|
|
self:set_cwd(vim.fs.dirname(self.cwd))
|
|
end
|
|
|
|
---@param opts snacks.picker.explorer.Config
|
|
function M.setup(opts)
|
|
return Snacks.config.merge(opts, {
|
|
live = true,
|
|
actions = M.actions,
|
|
formatters = {
|
|
file = {
|
|
filename_only = opts.tree,
|
|
},
|
|
},
|
|
})
|
|
end
|
|
|
|
---@type table<string, snacks.picker.Action.spec>
|
|
M.actions = {
|
|
explorer_up = function(picker)
|
|
M.get_state(picker):up()
|
|
end,
|
|
explorer_add = function(picker)
|
|
local state = M.get_state(picker)
|
|
Snacks.input({
|
|
prompt = 'Add a new file or directory (directories end with a "/")',
|
|
}, function(value)
|
|
if not value or value:find("^%s$") then
|
|
return
|
|
end
|
|
local dir = state:dir()
|
|
local path = vim.fs.normalize(dir .. "/" .. value)
|
|
local is_dir = value:sub(-1) == "/"
|
|
dir = is_dir and path or vim.fs.dirname(path)
|
|
vim.fn.mkdir(dir, "p")
|
|
state:expand(dir)
|
|
if not is_dir then
|
|
if uv.fs_stat(path) then
|
|
Snacks.notify.warn("File already exists:\n- `" .. path .. "`")
|
|
return
|
|
end
|
|
io.open(path, "w"):close()
|
|
end
|
|
state:update()
|
|
end)
|
|
end,
|
|
explorer_rename = function(picker, item)
|
|
if not item then
|
|
return
|
|
end
|
|
local state = M.get_state(picker)
|
|
Snacks.rename.rename_file({
|
|
file = item.file,
|
|
on_rename = function(new)
|
|
state:expand(new)
|
|
state:update()
|
|
end,
|
|
})
|
|
end,
|
|
explorer_copy = function(picker, item)
|
|
if not item then
|
|
return
|
|
end
|
|
if item.dir then
|
|
Snacks.notify.warn("Cannot copy directories")
|
|
return
|
|
end
|
|
local state = M.get_state(picker)
|
|
Snacks.input({
|
|
prompt = "Copy to",
|
|
}, function(value)
|
|
if not value or value:find("^%s$") then
|
|
return
|
|
end
|
|
local dir = state:dir()
|
|
local path = vim.fs.normalize(dir .. "/" .. value)
|
|
vim.fn.mkdir(vim.fs.dirname(path), "p")
|
|
state:expand(dir)
|
|
if uv.fs_stat(path) then
|
|
Snacks.notify.warn("File already exists:\n- `" .. path .. "`")
|
|
return
|
|
end
|
|
uv.fs_copyfile(item.file, path, function(err)
|
|
if err then
|
|
Snacks.notify.error("Failed to copy `" .. item.file .. "` to `" .. path .. "`:\n- " .. err)
|
|
end
|
|
state:update()
|
|
end)
|
|
end)
|
|
end,
|
|
explorer_del = function(picker)
|
|
local state = M.get_state(picker)
|
|
---@type string[]
|
|
local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected({ fallback = true }))
|
|
if #paths == 0 then
|
|
return
|
|
end
|
|
local what = #paths == 1 and vim.fn.fnamemodify(paths[1], ":p:~:.") or #paths .. " files"
|
|
Snacks.picker.select({ "Yes", "No" }, { prompt = "Delete " .. what .. "?" }, function(_, idx)
|
|
if idx == 1 then
|
|
for _, path in ipairs(paths) do
|
|
local ok, err = pcall(vim.fn.delete, path, "rf")
|
|
if not ok then
|
|
Snacks.notify.error("Failed to delete `" .. path .. "`:\n- " .. err)
|
|
end
|
|
end
|
|
state:update()
|
|
end
|
|
end)
|
|
end,
|
|
explorer_move = function(picker)
|
|
local state = M.get_state(picker)
|
|
---@type string[]
|
|
local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected())
|
|
if #paths == 0 then
|
|
Snacks.notify.warn("No files selected to move")
|
|
return
|
|
end
|
|
local to = state:dir()
|
|
local what = #paths == 1 and vim.fn.fnamemodify(paths[1], ":p:~:.") or #paths .. " files"
|
|
local t = vim.fn.fnamemodify(to, ":p:~:.")
|
|
|
|
Snacks.picker.select({ "Yes", "No" }, { prompt = "Move " .. what .. " to " .. t .. "?" }, function(_, idx)
|
|
if idx == 1 then
|
|
for _, path in ipairs(paths) do
|
|
local ok, err = pcall(vim.fn.rename, path, to .. "/" .. vim.fn.fnamemodify(path, ":t"))
|
|
if not ok then
|
|
Snacks.notify.error("Failed to move `" .. path .. "`:\n- " .. err)
|
|
end
|
|
end
|
|
state:update()
|
|
end
|
|
end)
|
|
end,
|
|
explorer_focus = function(picker)
|
|
local state = M.get_state(picker)
|
|
state:set_cwd(state:dir())
|
|
end,
|
|
explorer_yank = function(_, item)
|
|
if not item then
|
|
return
|
|
end
|
|
vim.fn.setreg("+", item.file)
|
|
Snacks.notify.info("Yanked `" .. item.file .. "`")
|
|
end,
|
|
explorer_cd = function(picker)
|
|
local state = M.get_state(picker)
|
|
vim.fn.chdir(state:dir())
|
|
state:set_cwd(vim.fn.getcwd())
|
|
end,
|
|
confirm = function(picker)
|
|
local state = M.get_state(picker)
|
|
local item = picker:current()
|
|
if not item then
|
|
return
|
|
elseif item.dir then
|
|
if state.all then
|
|
picker.input:set("", "")
|
|
state:set_cwd(item.file)
|
|
return
|
|
end
|
|
state:toggle(item)
|
|
else
|
|
picker:action("jump")
|
|
end
|
|
end,
|
|
}
|
|
|
|
---@param picker snacks.Picker
|
|
function M.get_state(picker)
|
|
if not M._state[picker] then
|
|
M._state[picker] = State.new(picker)
|
|
end
|
|
return M._state[picker]
|
|
end
|
|
|
|
---@param opts snacks.picker.explorer.Config
|
|
---@type snacks.picker.finder
|
|
function M.explorer(opts, ctx)
|
|
local state = M.get_state(ctx.picker)
|
|
opts = state:setup(opts, ctx)
|
|
|
|
local files = require("snacks.picker.source.files").files(opts, ctx)
|
|
local dirs = {} ---@type table<string, snacks.picker.explorer.Item>
|
|
local last = {} ---@type table<snacks.picker.finder.Item, snacks.picker.finder.Item>
|
|
|
|
---@type snacks.picker.explorer.Item
|
|
local root = {
|
|
file = state.cwd,
|
|
dir = true,
|
|
open = true,
|
|
text = "",
|
|
sort = "",
|
|
internal = true,
|
|
}
|
|
local cwd = state.cwd
|
|
dirs[cwd] = root
|
|
|
|
return function(cb)
|
|
if state.on_find then
|
|
ctx.picker.matcher.task:on("done", vim.schedule_wrap(state.on_find))
|
|
end
|
|
cb(root)
|
|
|
|
---@param item snacks.picker.explorer.Item
|
|
local function add(item)
|
|
local dirname, basename = item.file:match("(.*)/(.*)")
|
|
dirname, basename = dirname or "", basename or item.file
|
|
local parent = dirs[dirname] ~= item and dirs[dirname] or root
|
|
|
|
-- hierarchical sorting
|
|
if item.dir then
|
|
item.sort = parent.sort .. "/0" .. basename
|
|
else
|
|
item.sort = parent.sort .. "/1" .. basename
|
|
end
|
|
|
|
if opts.tree then
|
|
-- tree
|
|
item.parent = parent
|
|
if not last[parent] or last[parent].sort < item.sort then
|
|
if last[parent] then
|
|
last[parent].last = false
|
|
end
|
|
item.last = true
|
|
last[parent] = item
|
|
end
|
|
end
|
|
-- add to picker
|
|
cb(item)
|
|
end
|
|
|
|
files(function(item)
|
|
---@cast item snacks.picker.explorer.Item
|
|
item.cwd = nil -- we use absolute paths
|
|
|
|
-- Directories
|
|
if item.file:sub(-1) == "/" then
|
|
item.dir = true
|
|
item.file = item.file:sub(1, -2)
|
|
if dirs[item.file] then
|
|
dirs[item.file].internal = false
|
|
return
|
|
end
|
|
item.open = state:is_open(item.file)
|
|
dirs[item.file] = item
|
|
end
|
|
|
|
-- Add parents when needed
|
|
if item.file:sub(1, #cwd) == cwd and #item.file > #cwd then
|
|
local path = item.file
|
|
local to = #cwd + 1 ---@type number?
|
|
while to do
|
|
to = path:find("/", to + 1, true)
|
|
if not to then
|
|
break
|
|
end
|
|
local dir = path:sub(1, to - 1)
|
|
if not dirs[dir] then
|
|
dirs[dir] = {
|
|
text = dir,
|
|
file = dir,
|
|
dir = true,
|
|
open = state:is_open(dir),
|
|
internal = true,
|
|
}
|
|
add(dirs[dir])
|
|
end
|
|
end
|
|
end
|
|
|
|
add(item)
|
|
end)
|
|
end
|
|
end
|
|
|
|
return M
|