feat(explorer): rewrite that no longer depends on fd for exploring

This commit is contained in:
Folke Lemaitre 2025-02-04 17:13:06 +01:00
parent 52bc24c232
commit 6149a7babb
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
8 changed files with 895 additions and 674 deletions

View file

@ -0,0 +1,263 @@
local Git = require("snacks.explorer.git")
local Tree = require("snacks.explorer.tree")
local uv = vim.uv or vim.loop
local M = {}
---@param picker snacks.Picker
---@param path string
function M.reveal(picker, path)
for item, idx in picker:iter() do
if item.file == path then
picker.list:view(idx)
return true
end
end
end
---@param prompt string
---@param fn fun()
function M.confirm(prompt, fn)
Snacks.picker.select({ "Yes", "No" }, { prompt = prompt }, function(_, idx)
if idx == 1 then
fn()
end
end)
end
---@param picker snacks.Picker
---@param opts? {target?: boolean|string, refresh?: boolean}
function M.update(picker, opts)
opts = opts or {}
local cwd = picker:cwd()
local target = type(opts.target) == "string" and opts.target or nil --[[@as string]]
local refresh = opts.refresh or Tree:is_dirty(cwd, picker.opts)
if target and not Tree:is_visible(cwd, target) then
Tree:open(target)
refresh = true
end
-- when searching, restore explorer view first
if picker.input.filter.meta.searching then
picker.input:set("", "")
picker.list.win:focus()
refresh = true
end
if not refresh and target then
return M.reveal(picker, target)
end
if opts.target ~= false then
picker.list:set_target()
end
picker:find({
on_done = function()
if target then
M.reveal(picker, target)
end
end,
})
end
---@class snacks.explorer.actions
---@field [string] snacks.picker.Action.spec
M.actions = {}
function M.actions.explorer_focus(picker)
picker:set_cwd(picker:dir())
picker:find()
end
function M.actions.explorer_open(_, item)
if item then
local _, err = vim.ui.open(item.file)
if err then
Snacks.notify.error("Failed to open `" .. item.file .. "`:\n- " .. err)
end
end
end
function M.actions.explorer_yank(_, item)
if not item then
return
end
vim.fn.setreg("+", item.file)
Snacks.notify.info("Yanked `" .. item.file .. "`")
end
function M.actions.explorer_up(picker)
picker:set_cwd(vim.fs.dirname(picker:cwd()))
picker:find()
end
function M.actions.explorer_close(picker, item)
if not item then
return
end
local dir = picker:dir()
if item.dir and not item.open then
dir = vim.fs.dirname(dir)
end
Tree:close(dir)
M.update(picker, { target = dir, refresh = true })
end
function M.actions.explorer_update(picker)
Tree:refresh(picker:cwd())
M.update(picker)
end
function M.actions.explorer_close_all(picker)
Tree:close_all(picker:cwd())
M.update(picker, { refresh = true })
end
function M.actions.explorer_git_next(picker, item)
local node = Git.next(picker:cwd(), item and item.file)
if node then
M.update(picker, { target = node.path })
end
end
function M.actions.explorer_git_prev(picker, item)
local node = Git.next(picker:cwd(), item and item.file, true)
if node then
M.update(picker, { target = node.path })
end
end
function M.actions.explorer_add(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 path = vim.fs.normalize(picker:dir() .. "/" .. value)
local is_file = value:sub(-1) ~= "/"
local dir = is_file and vim.fs.dirname(path) or path
if is_file and uv.fs_stat(path) then
Snacks.notify.warn("File already exists:\n- `" .. path .. "`")
return
end
vim.fn.mkdir(dir, "p")
if is_file then
io.open(path, "w"):close()
end
Tree:open(dir)
Tree:refresh(dir)
M.update(picker, { target = path })
end)
end
function M.actions.explorer_rename(picker, item)
if not item then
return
end
Snacks.rename.rename_file({
file = item.file,
on_rename = function(new, old)
Tree:refresh(vim.fs.dirname(old))
Tree:refresh(vim.fs.dirname(new))
M.update(picker, { target = new })
end,
})
end
function M.actions.explorer_move(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. Renaming instead.")
return M.actions.explorer_rename(picker, picker:current())
end
local target = picker:dir()
local what = #paths == 1 and vim.fn.fnamemodify(paths[1], ":p:~:.") or #paths .. " files"
local t = vim.fn.fnamemodify(target, ":p:~:.")
M.confirm("Move " .. what .. " to " .. t .. "?", function()
for _, from in ipairs(paths) do
local to = target .. "/" .. vim.fn.fnamemodify(from, ":t")
Snacks.rename.on_rename_file(from, to, function()
local ok, err = pcall(vim.fn.rename, from, to)
if not ok then
Snacks.notify.error("Failed to move `" .. from .. "`:\n- " .. err)
end
end)
Tree:refresh(vim.fs.dirname(from))
end
Tree:refresh(target)
picker.list:set_selected() -- clear selection
M.update(picker, { target = target })
end)
end
function M.actions.explorer_copy(picker, item)
if not item then
return
end
---@type string[]
local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected())
-- Copy selection
if #paths > 0 then
local dir = picker:dir()
Snacks.picker.util.copy(paths, dir)
picker.list:set_selected() -- clear selection
Tree:refresh(dir)
Tree:open(dir)
M.update(picker, { target = dir })
return
end
Snacks.input({
prompt = "Copy to",
}, function(value)
if not value or value:find("^%s$") then
return
end
local dir = vim.fs.dirname(item.file)
local to = vim.fs.normalize(dir .. "/" .. value)
if uv.fs_stat(to) then
Snacks.notify.warn("File already exists:\n- `" .. to .. "`")
return
end
Snacks.picker.util.copy_path(item.file, to)
Tree:refresh(vim.fs.dirname(to))
M.update(picker, { target = to })
end)
end
function M.actions.explorer_del(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"
M.confirm("Delete " .. what .. "?", function()
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
Tree:refresh(vim.fs.dirname(path))
end
picker.list:set_selected() -- clear selection
M.update(picker)
end)
end
function M.actions.confirm(picker, item, action)
if not item then
return
elseif picker.input.filter.meta.searching then
M.update(picker, { target = item.file })
elseif item.dir then
Tree:toggle(item.file)
M.update(picker, { refresh = true })
else
Snacks.picker.actions.jump(picker, item, action)
end
end
return M

169
lua/snacks/explorer/git.lua Normal file
View file

@ -0,0 +1,169 @@
---@diagnostic disable: missing-fields
local M = {}
---@class snacks.explorer.git.Status
---@field status string
---@field file string
local uv = vim.uv or vim.loop
local CACHE_TTL = 15 * 60 -- 15 minutes
M.state = {} ---@type table<string, {tick: number, last: number}>
---@param path string
function M.refresh(path)
for root in pairs(M.state) do
if path == root or path:find(root .. "/", 1, true) == 1 then
M.state[root] = nil
end
end
end
---@param cwd string
---@param opts? {on_update?: fun(), ttl?: number, force?: boolean}
function M.update(cwd, opts)
opts = opts or {}
local ttl = opts.ttl or CACHE_TTL
if opts.force then
ttl = 0
end
local root = Snacks.git.get_root(cwd)
if not root then
return M._update(cwd, {})
end
local now = os.time()
M.state[root] = M.state[root] or { tick = 0, last = 0 }
local state = M.state[root]
if now - state.last < ttl then
return
end
state.last = now
state.tick = state.tick + 1
local tick = state.tick
local output = ""
local stdout = assert(uv.new_pipe())
local handle ---@type uv.uv_process_t
handle = uv.spawn("git", {
stdio = { nil, stdout, nil },
cwd = root,
hide = true,
args = {
"--no-pager",
"status",
"-uall",
"--porcelain=v1",
"--ignored=matching",
},
}, function()
stdout:close()
handle:close()
end)
if not handle then
return M._update(cwd, {})
end
local function process()
if not M.state[root] or M.state[root].tick ~= tick then
return
end
local ret = {} ---@type snacks.explorer.git.Status[]
for _, line in ipairs(vim.split(output, "\r?\n")) do
if line ~= "" then
local status, file = line:sub(1, 2), line:sub(4)
ret[#ret + 1] = {
status = status,
file = root .. "/" .. file,
}
end
end
M._update(cwd, ret)
if opts and opts.on_update then
opts.on_update()
end
end
stdout:read_start(function(err, data)
assert(not err, err)
if data then
output = output .. data
else
process()
end
end)
end
---@param cwd string
---@param results snacks.explorer.git.Status[]
function M._update(cwd, results)
local Tree = require("snacks.explorer.tree")
local Git = require("snacks.picker.source.git")
local node = Tree:find(cwd)
Tree:walk(node, function(n)
n.status = nil
n.ignored = nil
end, { all = true })
---@param path string
---@param status string
local function add_git_status(path, status)
local n = Tree:find(path)
n.status = n.status and Git.merge_status(n.status, status) or status
if status:sub(1, 1) == "!" then
n.ignored = true
end
end
for _, s in ipairs(results) do
local is_dir = s.file:sub(-1) == "/"
local path = is_dir and s.file:sub(1, -2) or s.file
local deleted = s.status:find("D") and s.status ~= "UD"
if not deleted then
add_git_status(path, s.status)
end
if s.status:sub(1, 1) ~= "!" then -- don't propagate ignored status
add_git_status(cwd, s.status)
for dir in Snacks.picker.util.parents(path, cwd) do
add_git_status(dir, s.status)
end
end
end
end
---@param cwd string
---@param path? string
---@param up? boolean
function M.next(cwd, path, up)
local Tree = require("snacks.explorer.tree")
path = path or cwd
local root = Tree:node(cwd) or nil
if not root then
return
end
local first ---@type snacks.picker.explorer.Node?
local last ---@type snacks.picker.explorer.Node?
local prev ---@type snacks.picker.explorer.Node?
local next ---@type snacks.picker.explorer.Node?
local found = false
Tree:walk(root, function(node)
local want = node.type ~= "directory" and node.status and not node.ignored
if node.path == path then
found = true
end
if want then
first, last = first or node, node
next = next or (found and node.path ~= path and node) or nil
prev = not found and node or prev
end
end, { all = true })
if up then
return prev or last
end
return next or first
end
return M

View file

@ -0,0 +1,265 @@
---@class snacks.picker.explorer.Node
---@field path string
---@field name string
---@field hidden? boolean
---@field status? string
---@field ignored? boolean
---@field type "file"|"directory"|"link"|"fifo"|"socket"|"char"|"block"|"unknown"
---@field open? boolean wether the node should be expanded (only for directories)
---@field expanded? boolean wether the node is expanded (only for directories)
---@field parent? snacks.picker.explorer.Node
---@field last? boolean child of the parent
---@field utime? number
---@field children table<string, snacks.picker.explorer.Node>
local uv = vim.uv or vim.loop
local function norm(path)
return vim.fs.normalize(path)
end
local function assert_dir(path)
assert(vim.fn.isdirectory(path) == 1, "Not a directory: " .. path)
end
-- local function assert_file(path)
-- assert(vim.fn.filereadable(path) == 1, "Not a file: " .. path)
-- end
---@class snacks.picker.explorer.Tree
---@field root snacks.picker.explorer.Node
---@field nodes table<string, snacks.picker.explorer.Node>
local Tree = {}
Tree.__index = Tree
function Tree.new()
local self = setmetatable({}, Tree)
self.root = { name = "", children = {}, type = "directory", path = "" }
self.nodes = {}
return self
end
---@param path string
---@return snacks.picker.explorer.Node?
function Tree:node(path)
path = norm(path)
return self.nodes[norm(path)]
end
---@param path string
function Tree:find(path)
path = norm(path)
if self.nodes[path] then
return self.nodes[path]
end
local node = self.root
local parts = vim.split(path:gsub("^/", ""), "/", { plain = true })
local is_dir = vim.fn.isdirectory(path) == 1
for p, part in ipairs(parts) do
node = self:child(node, part, (is_dir or p < #parts) and "directory" or "file")
end
return node
end
---@param node snacks.picker.explorer.Node
---@param name string
---@param type string
function Tree:child(node, name, type)
if not node.children[name] then
local path = (node.path .. "/" .. name)
node.children[name] = {
name = name,
path = path,
parent = node,
children = {},
type = type,
hidden = name:sub(1, 1) == ".",
}
self.nodes[path] = node.children[name]
end
return node.children[name]
end
---@param path string
function Tree:open(path)
local dir = self:dir(path)
local node = self:find(dir)
while node do
node.open = true
node = node.parent
end
end
---@param path string
function Tree:toggle(path)
local dir = self:dir(path)
local node = self:find(dir)
if node.open then
self:close(dir)
else
self:open(dir)
end
end
---@param path string
function Tree:show(path)
self:open(vim.fs.dirname(path))
end
---@param path string
function Tree:close(path)
local dir = self:dir(path)
local node = self:find(dir)
node.open = false
end
---@param node snacks.picker.explorer.Node
function Tree:expand(node)
if node.expanded then
return
end
local found = {} ---@type table<string, boolean>
assert(node.type == "directory", "Can only expand directories")
local fs = uv.fs_scandir(node.path)
while fs do
local name, t = uv.fs_scandir_next(fs)
if not name then
break
end
found[name] = true
self:child(node, name, t).type = t
end
for name in pairs(node.children) do
if not found[name] then
node.children[name] = nil
end
end
node.expanded = true
node.utime = uv.hrtime()
end
---@param path string
function Tree:dir(path)
return vim.fn.isdirectory(path) == 1 and path or vim.fs.dirname(path)
end
---@param path string
function Tree:refresh(path)
local dir = self:dir(path)
require("snacks.explorer.git").refresh(dir)
local root = self:node(dir)
if not root then
return
end
self:walk(root, function(node)
node.expanded = nil
end, { all = true })
end
---@param node snacks.picker.explorer.Node
---@param fn fun(node: snacks.picker.explorer.Node):boolean? return `false` to not process children, `true` to abort
---@param opts? {all?: boolean}
function Tree:walk(node, fn, opts)
local abort = false ---@type boolean?
abort = fn(node)
if abort ~= nil then
return abort
end
local children = vim.tbl_values(node.children) ---@type snacks.picker.explorer.Node[]
table.sort(children, function(a, b)
local a_dir = a.type == "directory"
local b_dir = b.type == "directory"
if a_dir ~= b_dir then
return a_dir
end
return a.name < b.name
end)
for c, child in ipairs(children) do
child.last = c == #children
abort = false
if child.type == "directory" and (child.open or (opts and opts.all)) then
abort = self:walk(child, fn, opts)
else
abort = fn(child)
end
if abort then
return true
end
end
return false
end
---@param cwd string
---@param cb fun(node: snacks.picker.explorer.Node)
---@param opts? {hidden?: boolean, ignored?: boolean, expand?: boolean}
function Tree:get(cwd, cb, opts)
opts = opts or {}
assert_dir(cwd)
local node = self:find(cwd)
node.open = true
self:walk(node, function(n)
if n ~= node then
if n.hidden and not opts.hidden then
return false
elseif n.ignored and not opts.ignored then
return false
end
end
if n.type == "directory" and n.open and not n.expanded and opts.expand ~= false then
self:expand(n)
end
cb(n)
end)
end
---@param cwd string
---@param opts? {hidden?: boolean, ignored?: boolean}
function Tree:is_dirty(cwd, opts)
opts = opts or {}
local dirty = false
self:get(cwd, function(n)
if n.type == "directory" and n.open and not n.expanded then
dirty = true
end
end, { hidden = opts.hidden, ignored = opts.ignored, expand = false })
return dirty
end
---@param cwd string
---@param path string
function Tree:in_cwd(cwd, path)
local dir = vim.fs.dirname(path)
return dir == cwd or dir:find(cwd .. "/", 1, true) == 1
end
---@param cwd string
---@param path string
function Tree:is_visible(cwd, path)
assert_dir(cwd)
if cwd == path then
return true
end
local dir = vim.fs.dirname(path)
if not self:in_cwd(cwd, path) then
return false
end
local node = self:node(dir)
while node do
if node.path == cwd then
return true
elseif not node.open then
return false
end
node = node.parent
end
return false
end
---@param cwd string
function Tree:close_all(cwd)
self:walk(self:find(cwd), function(node)
node.open = false
end, { all = true })
end
return Tree.new()

View file

@ -377,6 +377,8 @@ local defaults = {
debug = {
scores = false, -- show scores in the list
leaks = false, -- show when pickers don't get garbage collected
explorer = false, -- show explorer debug info
files = false, -- show file debug info
},
}

View file

@ -42,13 +42,14 @@ M.buffers = {
---@field tree? boolean show the file tree (default: true)
---@field git_status? boolean show git status (default: true)
---@field git_status_open? boolean show recursive git status for open directories
---@field watch? boolean watch for file changes
M.explorer = {
finder = "explorer",
sort = { fields = { "sort" } },
tree = true,
watch = true,
git_status = true,
git_status_open = false,
supports_live = true,
follow_file = true,
focus = "list",
auto_close = false,
@ -58,7 +59,7 @@ M.explorer = {
-- your config under `opts.picker.sources.explorer`
-- layout = { layout = { position = "right" } },
formatters = { file = { filename_only = true } },
matcher = { sort_empty = true },
matcher = { sort_empty = false },
config = function(opts)
return require("snacks.picker.source.explorer").setup(opts)
end,
@ -77,7 +78,7 @@ M.explorer = {
["P"] = "toggle_preview",
["y"] = "explorer_yank",
["u"] = "explorer_update",
["<c-c>"] = "explorer_cd",
["<c-c>"] = "tcd",
["."] = "explorer_focus",
["I"] = "toggle_ignored",
["H"] = "toggle_hidden",

View file

@ -84,7 +84,9 @@ function M.resolve(action, picker, name, stack)
end
stack[#stack + 1] = action
return M.resolve(
(picker.opts.actions or {})[action] or require("snacks.picker.actions")[action],
(picker.opts.actions or {})[action]
or require("snacks.picker.actions")[action]
or require("snacks.explorer.actions").actions[action],
picker,
action,
stack

View file

@ -1,5 +1,6 @@
local Async = require("snacks.picker.util.async")
local Git = require("snacks.picker.source.git")
---@diagnostic disable: await-in-sync
local Actions = require("snacks.explorer.actions")
local Tree = require("snacks.explorer.tree")
local M = {}
@ -20,393 +21,146 @@ local uv = vim.uv or vim.loop
---@field internal? boolean internal parent directories not part of fd output
---@field status? string
---@class snacks.picker.explorer.Node
---@field name string
---@field open boolean
---@field parent? snacks.picker.explorer.Node
---@field children table<string, snacks.picker.explorer.Node>
local function norm(path)
return vim.fs.normalize(path)
end
---@class snacks.picker.explorer.Tree
---@field root snacks.picker.explorer.Node
local Tree = {}
Tree.__index = Tree
function Tree.new()
local self = setmetatable({}, Tree)
self.root = { name = "", open = true, children = {} }
return self
end
---@param path string
function Tree:add(path)
return self:find(path, { add = true }) ---@type snacks.picker.explorer.Node
end
---@param path string
---@param opts? {add?: boolean}
function Tree:find(path, opts)
opts = opts or {}
path = norm(path)
local node = self.root
for part in path:gmatch("[^/]+") do
if not node.children[part] then
if not opts.add then
return
end
node.children[part] = { name = part, open = true, parent = node, children = {} }
end
node = node.children[part] ---@type snacks.picker.explorer.Node
end
return node
end
---@param path string
function Tree:open(path)
path = norm(path)
local node = self:add(path)
while node do
node.open = true
node = node.parent
end
end
---@param cwd string
---@param path string
function Tree:in_cwd(cwd, path)
path = norm(path)
cwd = norm(cwd)
return cwd == "/" or path == cwd or path:find(cwd .. "/", 1, true) == 1
end
---@param cwd string
---@param path string
function Tree:visible(cwd, path)
path = norm(path)
cwd = norm(cwd)
if not self:in_cwd(cwd, path) then
return false
end
local cwd_node = self:add(cwd)
local node = self:find(path)
if not node then
return false
end
while node and node ~= cwd_node do
if not node.open then
return false
end
node = node.parent
end
return true
end
---@param path string
function Tree:close(path)
path = norm(path)
local node = self:add(path)
node.open = false
end
function Tree:close_all()
self.root.children = {}
end
---@param cwd string
---@param ret? string[]
function Tree:dirs(cwd, ret)
cwd = norm(cwd)
local node = self:add(cwd)
ret = ret or {}
ret[#ret + 1] = cwd
cwd = cwd == "/" and "" or cwd
for _, child in pairs(node.children) do
if child.open then
local dir = cwd .. "/" .. child.name
self:dirs(dir, ret)
end
end
return ret
end
local tree = Tree.new()
-- global git status
local git_tree_status = {} ---@type table<string, string>
---@class snacks.picker.explorer.State
---@field cwd string
---@field tick number
---@field tree snacks.picker.explorer.Tree
---@field all? boolean
---@field ref snacks.Picker.ref
---@field opts snacks.picker.explorer.Config
---@field on_find? fun()?
---@field git_status {file: string, status: string, sort?:string}[]
---@field expanded table<string, boolean>
---@field cache table<string, snacks.picker.explorer.Item[]>
---@field cache_opts? snacks.picker.explorer.Config|{}
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.ref = picker:ref()
local filter = picker:filter()
self.cwd = filter.cwd
self.tree = tree
self.tick = 0
self.git_status = {}
self.expanded = {}
self.cache = {}
local opts = picker.opts --[[@as snacks.picker.explorer.Config]]
local ref = picker:ref()
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:open(buf_file)
Tree:open(buf_file)
end
if opts.watch then
picker.opts.on_close = function()
require("snacks.explorer.watch").abort()
end
end
picker.list.win:on("TermClose", function()
local p = ref()
if p then
Tree:refresh(p:cwd())
Actions.update(p)
end
end, { pattern = "*lazygit" })
picker.list.win:on("BufWritePost", function(_, ev)
local p = ref()
if not p then
return true
end
Tree:refresh(ev.file)
Actions.update(p)
end)
picker.list.win:on("DirChanged", function(_, ev)
local p = ref()
if p then
p:set_cwd(vim.fs.normalize(ev.file))
p:find()
end
end)
-- schedule initial follow
if opts.follow_file then
picker.list.win:on({ "WinEnter", "BufEnter" }, function(_, ev)
vim.schedule(function()
if ev.buf == vim.api.nvim_get_current_buf() then
self:follow()
end
end)
end)
picker.list.win:on("TermClose", function()
self:update({ force = true })
end, { pattern = "*lazygit" })
picker.list.win:on("BufWritePost", function(_, ev)
if self:is_visible(ev.file) then
self:update({ force = true })
end
end)
picker.list.win:on("DirChanged", function(_, ev)
self:set_cwd(vim.fs.normalize(ev.file))
end)
-- schedule initial follow
if self.opts.follow_file then
self.on_find = function()
self:show(buf_file)
end
end
return self
end
function State:update_git_status()
-- Setup hierarchical sorting
for _, s in ipairs(self.git_status) do
if self.tree:in_cwd(self.cwd, s.file) then
local parts = vim.split(s.file:sub(#self.cwd + 2), "/", { plain = true })
for i, part in ipairs(parts) do
parts[i] = (i == #parts and "#" or "!") .. part
end
s.sort = table.concat(parts, " ") .. " "
end
end
self.git_status = vim.tbl_filter(function(s)
return s.sort
end, self.git_status)
table.sort(self.git_status, function(a, b)
return a.sort < b.sort
end)
-- Update tree status
git_tree_status = {}
---@param path string
---@param status string
local function add_git_status(path, status)
git_tree_status[path] = git_tree_status[path] and Git.merge_status(git_tree_status[path], status) or status
end
-- Add git status to files and parents
for _, s in ipairs(self.git_status) do
local path = s.file:gsub("/$", "")
add_git_status(path, s.status)
if s.status:sub(1, 1) ~= "!" then -- don't propagate ignored status
add_git_status(self.cwd, s.status)
for dir in Snacks.picker.util.parents(path, self.cwd) do
add_git_status(dir, s.status)
end
end
end
end
function State:picker()
local ret = self.ref()
return ret and not ret.closed and ret or nil
end
function State:follow()
if not self.opts.follow_file then
if ev.buf ~= vim.api.nvim_get_current_buf() then
return
end
local picker = self:picker()
if not picker or picker:is_focused() or not picker:on_current_tab() then
local p = ref()
if not p or p:is_focused() or not p:on_current_tab() then
return
end
local win = vim.api.nvim_get_current_win()
if vim.api.nvim_win_get_config(win).relative ~= "" then
return
end
local buf = vim.api.nvim_get_current_buf()
local file = vim.api.nvim_buf_get_name(buf)
local item = picker:current()
local file = vim.api.nvim_buf_get_name(ev.buf)
local item = p:current()
if item and item.file == norm(file) then
return
end
self:show(file)
end
---@param path string
---@param opts? {refresh?: boolean}
function State:show(path, opts)
opts = opts or {}
path = norm(path)
if not uv.fs_stat(path) then
return
end
local function show()
local picker = self:picker()
if picker then
for item, idx in picker:iter() do
if item.file == path then
picker.list:view(idx)
break
end
end
end
end
local visible = self:is_visible(path)
if opts.refresh or not visible then
if not visible then
self:open(path)
end
self:update({ on_done = show })
else
show()
end
end
---@param path string
function State:is_visible(path)
return self.tree:visible(self.cwd, vim.fs.dirname(path))
end
---@param path string
function State:open(path)
if not self.tree:in_cwd(self.cwd, path) then
return
end
path = vim.fn.isdirectory(path) == 1 and path or vim.fs.dirname(path)
self.tree:open(path)
end
---@param item snacks.picker.Item
function State:toggle(item)
local dir = Snacks.picker.util.path(item)
if not dir then
return
end
dir = item.dir and dir or vim.fs.dirname(dir)
if self.tree:visible(self.cwd, dir) then
self.tree:close(dir)
else
self.tree:open(dir)
end
self:update()
end
---@param opts snacks.picker.explorer.Config
---@param ctx snacks.picker.finder.ctx
function State:setup(opts, ctx)
self.tick = self.tick + 1
opts = Snacks.picker.util.shallow_copy(opts)
opts.cmd = "fd"
opts.cwd = self.cwd
opts.args = {
"--type",
"d", -- include directories
"--path-separator", -- same everywhere
"/",
"--follow", -- always needed to make sure we see symlinked dirs as dirs
}
self.all = #ctx.filter.search > 0
self.expanded = {}
if self.all then
local picker = self:picker()
if not picker then
return {}
end
picker.list:set_target()
Actions.update(p, { target = file })
end)
end)
self.on_find = function()
if picker.closed then
return
end
for item, idx in picker:iter() do
if not item.internal then
picker.list:view(idx)
return
local p = ref()
if p and buf_file then
Actions.update(p, { target = buf_file })
end
end
end
opts.dirs = { self.cwd }
else
opts.dirs = self.tree:dirs(self.cwd)
for _, dir in ipairs(opts.dirs or {}) do
self.expanded[dir] = true
end
vim.list_extend(opts.args, { "--max-depth", "1" })
end
return opts
return self
end
---@param opts? {target?: boolean, on_done?: fun(), force?: boolean}
function State:update(opts)
opts = opts or {}
if opts.force then
self.cache = {}
---@param ctx snacks.picker.finder.ctx
function State:setup(ctx)
local opts = ctx.picker.opts --[[@as snacks.picker.explorer.Config]]
if opts.watch then
require("snacks.explorer.watch").watch(ctx.filter.cwd)
end
local picker = self:picker()
if picker then
if opts.target ~= false then
picker.list:set_target()
end
picker:find({ on_done = opts.on_done })
end
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
---@param cwd string
function State:set_cwd(cwd)
self.cwd = cwd
self:update({ target = false })
end
function State:up()
self:set_cwd(vim.fs.dirname(self.cwd))
return #ctx.filter.pattern > 0
end
---@param opts snacks.picker.explorer.Config
function M.setup(opts)
local searching = false
local ref ---@type snacks.Picker.ref
return Snacks.config.merge(opts, {
live = true,
actions = M.actions,
actions = {
confirm = Actions.actions.confirm,
},
filter = {
--- Trigger finder when pattern toggles between empty / non-empty
---@param picker snacks.Picker
---@param filter snacks.picker.Filter
transform = function(picker, filter)
ref = picker:ref()
local s = #filter.pattern > 0
if searching ~= s then
searching = s
filter.meta.searching = searching
return true
end
end,
},
matcher = {
--- Add parent dirs to matching items
---@param matcher snacks.picker.Matcher
---@param item snacks.picker.explorer.Item
on_match = function(matcher, item)
if not searching then
return
end
local picker = ref.value
if picker and item.score > 0 then
local parent = item.parent
while parent do
if parent.score == 0 or parent.match_tick ~= matcher.tick then
parent.score = 1
parent.match_tick = matcher.tick
picker.list:add(parent)
else
break
end
parent = parent.parent
end
end
end,
},
formatters = {
file = {
filename_only = opts.tree,
@ -415,221 +169,6 @@ function M.setup(opts)
})
end
---@param prompt string
---@param fn fun()
function M.confirm(prompt, fn)
Snacks.picker.select({ "Yes", "No" }, { prompt = prompt }, function(_, idx)
if idx == 1 then
fn()
end
end)
end
---@type table<string, snacks.picker.Action.spec>
M.actions = {
explorer_update = function(picker)
M.get_state(picker):update({ force = true })
end,
explorer_up = function(picker)
M.get_state(picker):up()
end,
explorer_close = function(picker)
local state = M.get_state(picker)
local item = picker:current()
if not item then
return
end
local dir = state:dir()
if item.dir and not item.open then
dir = vim.fs.dirname(dir)
end
state.tree:close(dir)
state:show(dir, { refresh = true })
end,
explorer_close_all = function(picker)
M.get_state(picker).tree:close_all()
M.get_state(picker):update()
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:open(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({ force = true })
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:open(new)
state:update({ force = true })
end,
})
end,
explorer_git_next = function(picker, item)
local state = M.get_state(picker)
if not item or #state.git_status == 0 then
return
end
for _, s in ipairs(state.git_status) do
if s.sort and s.sort > item.sort then
return state:show(s.file)
end
end
return state:show(state.git_status[1].file)
end,
explorer_git_prev = function(picker, item)
local state = M.get_state(picker)
if not item or #state.git_status == 0 then
return
end
for i = #state.git_status, 1, -1 do
local s = state.git_status[i]
if s.sort and s.sort < item.sort then
return state:show(s.file)
end
end
return state:show(state.git_status[#state.git_status].file)
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. Renaming instead.")
return M.actions.explorer_rename(picker, picker:current())
end
local target = state:dir()
local what = #paths == 1 and vim.fn.fnamemodify(paths[1], ":p:~:.") or #paths .. " files"
local t = vim.fn.fnamemodify(target, ":p:~:.")
M.confirm("Move " .. what .. " to " .. t .. "?", function()
for _, from in ipairs(paths) do
local to = target .. "/" .. vim.fn.fnamemodify(from, ":t")
Snacks.rename.on_rename_file(from, to, function()
local ok, err = pcall(vim.fn.rename, from, to)
if not ok then
Snacks.notify.error("Failed to move `" .. from .. "`:\n- " .. err)
end
end)
end
picker.list:set_selected() -- clear selection
state:update({ force = true })
end)
end,
explorer_copy = function(picker, item)
if not item then
return
end
local state = M.get_state(picker)
---@type string[]
local paths = vim.tbl_map(Snacks.picker.util.path, picker:selected())
-- Copy selection
if #paths > 0 then
local dir = state:dir()
Snacks.picker.util.copy(paths, dir)
state:open(dir)
picker.list:set_selected() -- clear selection
state:update({ force = true })
return
end
Snacks.input({
prompt = "Copy to",
}, function(value)
if not value or value:find("^%s$") then
return
end
local dir = vim.fs.dirname(item.file)
local to = vim.fs.normalize(dir .. "/" .. value)
if uv.fs_stat(to) then
Snacks.notify.warn("File already exists:\n- `" .. to .. "`")
return
end
Snacks.picker.util.copy_path(item.file, to)
state:open(dir)
state:update({ force = true })
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"
M.confirm("Delete " .. what .. "?", function()
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
picker.list:set_selected() -- clear selection
state:update({ force = true })
end)
end,
explorer_focus = function(picker)
local state = M.get_state(picker)
state:set_cwd(state:dir())
end,
explorer_open = function(picker, item)
if item then
local _, err = vim.ui.open(item.file)
if err then
Snacks.notify.error("Failed to open `" .. item.file .. "`:\n- " .. err)
end
end
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())
end,
confirm = function(picker, item, action)
local state = M.get_state(picker)
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
Snacks.picker.actions.jump(picker, item, action)
end
end,
}
---@param picker snacks.Picker
function M.get_state(picker)
if not M._state[picker] then
@ -642,43 +181,68 @@ end
---@type snacks.picker.finder
function M.explorer(opts, ctx)
local state = M.get_state(ctx.picker)
opts = state:setup(opts, ctx)
local tick = state.tick
if state:setup(ctx) then
return M.search(opts, ctx)
end
if opts.git_status then
require("snacks.explorer.git").update(ctx.filter.cwd, {
on_update = function()
ctx.picker:find()
end,
})
end
return function(cb)
if state.on_find then
ctx.picker.matcher.task:on("done", vim.schedule_wrap(state.on_find))
state.on_find = nil
end
local items = {} ---@type table<string, snacks.picker.explorer.Item>
local top = Tree:find(ctx.filter.cwd)
Tree:get(ctx.filter.cwd, function(node)
local item = {
file = node.path,
dir = node.type == "directory",
open = node.open,
text = node.path,
parent = node.parent and items[node.parent.path] or nil,
hidden = node.hidden,
ignored = node.ignored,
status = (node.type ~= "directory" or not node.open or opts.git_status_open) and node.status or nil,
last = node.last,
type = node.type,
}
if top == node then
item.hidden = false
end
items[node.path] = item
cb(item)
end, { hidden = opts.hidden, ignored = opts.ignored })
end
end
---@param opts snacks.picker.explorer.Config
---@type snacks.picker.finder
function M.search(opts, ctx)
opts = Snacks.picker.util.shallow_copy(opts)
opts.cmd = "fd"
opts.cwd = ctx.filter.cwd
opts.notify = false
local expanded = {} ---@type table<string, boolean>
local cache_opts = { hidden = opts.hidden, ignored = opts.ignored }
local use_cache = not state.all and vim.deep_equal(state.cache_opts, cache_opts)
for _, dir in ipairs(opts.dirs or {}) do
expanded[dir] = true
use_cache = use_cache and state.cache[dir] ~= nil
end
if not use_cache then
state.cache = {}
state.cache_opts = cache_opts
end
---@param path string
local function is_open(path)
return state.all or expanded[path]
end
---@param item snacks.picker.explorer.Item
local function add_git_status(item)
item.status = git_tree_status[item.file or ""] or nil
local ignored = item.status and item.status:sub(1, 1) == "!"
if item.open and not opts.git_status_open and not ignored then
item.status = nil
end
if item.status and ignored then
item.ignored = true
end
end
opts.args = {
"--type",
"d", -- include directories
"--path-separator", -- same everywhere
"/",
"--follow", -- always needed to make sure we see symlinked dirs as dirs
}
opts.dirs = { ctx.filter.cwd }
ctx.picker.list:set_target()
---@type snacks.picker.explorer.Item
local root = {
file = state.cwd,
file = opts.cwd,
dir = true,
open = true,
text = "",
@ -686,39 +250,29 @@ function M.explorer(opts, ctx)
internal = true,
}
if use_cache then
local ret = { root } ---@type snacks.picker.explorer.Item[]
for _, dir in ipairs(opts.dirs or {}) do
for _, item in ipairs(state.cache[dir]) do
item.open = is_open(item.file)
add_git_status(item)
table.insert(ret, item)
end
end
if state.on_find then
state.on_find()
state.on_find = nil
end
return ret
end
local files = require("snacks.picker.source.files").files(opts, ctx)
local git = Git.status(opts, ctx)
local dirs = {} ---@type table<string, snacks.picker.explorer.Item>
local last = {} ---@type table<snacks.picker.finder.Item, snacks.picker.finder.Item>
local cwd = state.cwd
dirs[cwd] = root
state.git_status = {}
---@async
return function(cb)
if state.on_find then
ctx.picker.matcher.task:on("done", vim.schedule_wrap(state.on_find))
state.on_find = nil
end
cb(root)
-- focus the first non-internal item
ctx.picker.matcher.task:on(
"done",
vim.schedule_wrap(function()
if ctx.picker.closed then
return
end
for item, idx in ctx.picker:iter() do
if not item.internal then
ctx.picker.list:view(idx)
return
end
end
end)
)
---@param item snacks.picker.explorer.Item
local function add(item)
@ -726,9 +280,6 @@ function M.explorer(opts, ctx)
dirname, basename = dirname or "", basename or item.file
local parent = dirs[dirname] ~= item and dirs[dirname] or root
state.cache[dirname] = state.cache[dirname] or {}
table.insert(state.cache[dirname], item)
-- hierarchical sorting
if item.dir then
item.sort = parent.sort .. "!" .. basename .. " "
@ -738,7 +289,10 @@ function M.explorer(opts, ctx)
if basename:sub(1, 1) == "." then
item.hidden = true
end
add_git_status(item)
local node = Tree:find(item.file)
if node then
item.status = (node.type ~= "directory" or opts.git_status_open) and node.status or nil
end
if opts.tree then
-- tree
@ -754,7 +308,6 @@ function M.explorer(opts, ctx)
-- add to picker
cb(item)
end
-- ctx.async:sleep(1000)
-- get files and directories
files(function(item)
@ -769,18 +322,18 @@ function M.explorer(opts, ctx)
dirs[item.file].internal = false
return
end
item.open = is_open(item.file)
item.open = true
dirs[item.file] = item
end
-- Add parents when needed
for dir in Snacks.picker.util.parents(item.file, cwd) do
for dir in Snacks.picker.util.parents(item.file, opts.cwd) do
if not dirs[dir] then
dirs[dir] = {
text = dir,
file = dir,
dir = true,
open = is_open(dir),
open = true,
internal = true,
}
add(dirs[dir])
@ -789,40 +342,6 @@ function M.explorer(opts, ctx)
add(item)
end)
-- gather git status in a separate coroutine,
-- so that git doesn't block the picker
if opts.git_status then
---@async
Async.new(function()
local me = Async.running()
local check = function() -- check if we need to abort
return state.tick ~= tick or ctx.picker.closed and me:abort()
end
-- fetch git status
git(function(item)
check()
table.insert(state.git_status, {
file = Snacks.picker.util.path(item),
status = item.status,
})
end)
check()
state:update_git_status()
ctx.async:wait() -- wait till fd is done
check()
-- add git status to picker items
for item in ctx.picker:iter() do
---@cast item snacks.picker.explorer.Item
add_git_status(item)
end
ctx.picker:update({ force = true })
end)
end
end
end