mirror of
https://github.com/folke/snacks.nvim
synced 2025-08-05 19:28:24 +00:00
feat(explorer): rewrite that no longer depends on fd
for exploring
This commit is contained in:
parent
52bc24c232
commit
6149a7babb
8 changed files with 895 additions and 674 deletions
263
lua/snacks/explorer/actions.lua
Normal file
263
lua/snacks/explorer/actions.lua
Normal 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
169
lua/snacks/explorer/git.lua
Normal 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
|
265
lua/snacks/explorer/tree.lua
Normal file
265
lua/snacks/explorer/tree.lua
Normal 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()
|
|
@ -377,6 +377,8 @@ local defaults = {
|
||||||
debug = {
|
debug = {
|
||||||
scores = false, -- show scores in the list
|
scores = false, -- show scores in the list
|
||||||
leaks = false, -- show when pickers don't get garbage collected
|
leaks = false, -- show when pickers don't get garbage collected
|
||||||
|
explorer = false, -- show explorer debug info
|
||||||
|
files = false, -- show file debug info
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,13 +42,14 @@ M.buffers = {
|
||||||
---@field tree? boolean show the file tree (default: true)
|
---@field tree? boolean show the file tree (default: true)
|
||||||
---@field git_status? boolean show git status (default: true)
|
---@field git_status? boolean show git status (default: true)
|
||||||
---@field git_status_open? boolean show recursive git status for open directories
|
---@field git_status_open? boolean show recursive git status for open directories
|
||||||
|
---@field watch? boolean watch for file changes
|
||||||
M.explorer = {
|
M.explorer = {
|
||||||
finder = "explorer",
|
finder = "explorer",
|
||||||
sort = { fields = { "sort" } },
|
sort = { fields = { "sort" } },
|
||||||
tree = true,
|
tree = true,
|
||||||
|
watch = true,
|
||||||
git_status = true,
|
git_status = true,
|
||||||
git_status_open = false,
|
git_status_open = false,
|
||||||
supports_live = true,
|
|
||||||
follow_file = true,
|
follow_file = true,
|
||||||
focus = "list",
|
focus = "list",
|
||||||
auto_close = false,
|
auto_close = false,
|
||||||
|
@ -58,7 +59,7 @@ M.explorer = {
|
||||||
-- your config under `opts.picker.sources.explorer`
|
-- your config under `opts.picker.sources.explorer`
|
||||||
-- layout = { layout = { position = "right" } },
|
-- layout = { layout = { position = "right" } },
|
||||||
formatters = { file = { filename_only = true } },
|
formatters = { file = { filename_only = true } },
|
||||||
matcher = { sort_empty = true },
|
matcher = { sort_empty = false },
|
||||||
config = function(opts)
|
config = function(opts)
|
||||||
return require("snacks.picker.source.explorer").setup(opts)
|
return require("snacks.picker.source.explorer").setup(opts)
|
||||||
end,
|
end,
|
||||||
|
@ -77,7 +78,7 @@ M.explorer = {
|
||||||
["P"] = "toggle_preview",
|
["P"] = "toggle_preview",
|
||||||
["y"] = "explorer_yank",
|
["y"] = "explorer_yank",
|
||||||
["u"] = "explorer_update",
|
["u"] = "explorer_update",
|
||||||
["<c-c>"] = "explorer_cd",
|
["<c-c>"] = "tcd",
|
||||||
["."] = "explorer_focus",
|
["."] = "explorer_focus",
|
||||||
["I"] = "toggle_ignored",
|
["I"] = "toggle_ignored",
|
||||||
["H"] = "toggle_hidden",
|
["H"] = "toggle_hidden",
|
||||||
|
|
|
@ -84,7 +84,9 @@ function M.resolve(action, picker, name, stack)
|
||||||
end
|
end
|
||||||
stack[#stack + 1] = action
|
stack[#stack + 1] = action
|
||||||
return M.resolve(
|
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,
|
picker,
|
||||||
action,
|
action,
|
||||||
stack
|
stack
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
local Async = require("snacks.picker.util.async")
|
---@diagnostic disable: await-in-sync
|
||||||
local Git = require("snacks.picker.source.git")
|
local Actions = require("snacks.explorer.actions")
|
||||||
|
local Tree = require("snacks.explorer.tree")
|
||||||
|
|
||||||
local M = {}
|
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 internal? boolean internal parent directories not part of fd output
|
||||||
---@field status? string
|
---@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)
|
local function norm(path)
|
||||||
return vim.fs.normalize(path)
|
return vim.fs.normalize(path)
|
||||||
end
|
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
|
---@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 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 = {}
|
local State = {}
|
||||||
State.__index = State
|
State.__index = State
|
||||||
---@param picker snacks.Picker
|
---@param picker snacks.Picker
|
||||||
function State.new(picker)
|
function State.new(picker)
|
||||||
local self = setmetatable({}, State)
|
local self = setmetatable({}, State)
|
||||||
self.opts = picker.opts --[[@as snacks.picker.explorer.Config]]
|
|
||||||
self.ref = picker:ref()
|
local opts = picker.opts --[[@as snacks.picker.explorer.Config]]
|
||||||
local filter = picker:filter()
|
local ref = picker:ref()
|
||||||
self.cwd = filter.cwd
|
|
||||||
self.tree = tree
|
|
||||||
self.tick = 0
|
|
||||||
self.git_status = {}
|
|
||||||
self.expanded = {}
|
|
||||||
self.cache = {}
|
|
||||||
local buf = vim.api.nvim_win_get_buf(picker.main)
|
local buf = vim.api.nvim_win_get_buf(picker.main)
|
||||||
local buf_file = vim.fs.normalize(vim.api.nvim_buf_get_name(buf))
|
local buf_file = vim.fs.normalize(vim.api.nvim_buf_get_name(buf))
|
||||||
if uv.fs_stat(buf_file) then
|
if uv.fs_stat(buf_file) then
|
||||||
self:open(buf_file)
|
Tree:open(buf_file)
|
||||||
end
|
end
|
||||||
picker.list.win:on({ "WinEnter", "BufEnter" }, function(_, ev)
|
|
||||||
vim.schedule(function()
|
if opts.watch then
|
||||||
if ev.buf == vim.api.nvim_get_current_buf() then
|
picker.opts.on_close = function()
|
||||||
self:follow()
|
require("snacks.explorer.watch").abort()
|
||||||
end
|
end
|
||||||
end)
|
end
|
||||||
end)
|
|
||||||
picker.list.win:on("TermClose", function()
|
picker.list.win:on("TermClose", function()
|
||||||
self:update({ force = true })
|
local p = ref()
|
||||||
|
if p then
|
||||||
|
Tree:refresh(p:cwd())
|
||||||
|
Actions.update(p)
|
||||||
|
end
|
||||||
end, { pattern = "*lazygit" })
|
end, { pattern = "*lazygit" })
|
||||||
|
|
||||||
picker.list.win:on("BufWritePost", function(_, ev)
|
picker.list.win:on("BufWritePost", function(_, ev)
|
||||||
if self:is_visible(ev.file) then
|
local p = ref()
|
||||||
self:update({ force = true })
|
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
|
||||||
end)
|
end)
|
||||||
picker.list.win:on("DirChanged", function(_, ev)
|
|
||||||
self:set_cwd(vim.fs.normalize(ev.file))
|
|
||||||
end)
|
|
||||||
-- schedule initial follow
|
-- schedule initial follow
|
||||||
if self.opts.follow_file then
|
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
|
||||||
|
return
|
||||||
|
end
|
||||||
|
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 file = vim.api.nvim_buf_get_name(ev.buf)
|
||||||
|
local item = p:current()
|
||||||
|
if item and item.file == norm(file) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Actions.update(p, { target = file })
|
||||||
|
end)
|
||||||
|
end)
|
||||||
self.on_find = function()
|
self.on_find = function()
|
||||||
self:show(buf_file)
|
local p = ref()
|
||||||
|
if p and buf_file then
|
||||||
|
Actions.update(p, { target = buf_file })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return self
|
return self
|
||||||
end
|
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
|
|
||||||
return
|
|
||||||
end
|
|
||||||
local picker = self:picker()
|
|
||||||
if not picker or picker:is_focused() or not picker: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()
|
|
||||||
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
|
---@param ctx snacks.picker.finder.ctx
|
||||||
function State:setup(opts, ctx)
|
function State:setup(ctx)
|
||||||
self.tick = self.tick + 1
|
local opts = ctx.picker.opts --[[@as snacks.picker.explorer.Config]]
|
||||||
opts = Snacks.picker.util.shallow_copy(opts)
|
if opts.watch then
|
||||||
opts.cmd = "fd"
|
require("snacks.explorer.watch").watch(ctx.filter.cwd)
|
||||||
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()
|
|
||||||
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
|
|
||||||
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
|
end
|
||||||
return opts
|
return #ctx.filter.pattern > 0
|
||||||
end
|
|
||||||
|
|
||||||
---@param opts? {target?: boolean, on_done?: fun(), force?: boolean}
|
|
||||||
function State:update(opts)
|
|
||||||
opts = opts or {}
|
|
||||||
if opts.force then
|
|
||||||
self.cache = {}
|
|
||||||
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))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
---@param opts snacks.picker.explorer.Config
|
---@param opts snacks.picker.explorer.Config
|
||||||
function M.setup(opts)
|
function M.setup(opts)
|
||||||
|
local searching = false
|
||||||
|
local ref ---@type snacks.Picker.ref
|
||||||
return Snacks.config.merge(opts, {
|
return Snacks.config.merge(opts, {
|
||||||
live = true,
|
actions = {
|
||||||
actions = M.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 = {
|
formatters = {
|
||||||
file = {
|
file = {
|
||||||
filename_only = opts.tree,
|
filename_only = opts.tree,
|
||||||
|
@ -415,221 +169,6 @@ function M.setup(opts)
|
||||||
})
|
})
|
||||||
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
|
|
||||||
|
|
||||||
---@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
|
---@param picker snacks.Picker
|
||||||
function M.get_state(picker)
|
function M.get_state(picker)
|
||||||
if not M._state[picker] then
|
if not M._state[picker] then
|
||||||
|
@ -642,43 +181,68 @@ end
|
||||||
---@type snacks.picker.finder
|
---@type snacks.picker.finder
|
||||||
function M.explorer(opts, ctx)
|
function M.explorer(opts, ctx)
|
||||||
local state = M.get_state(ctx.picker)
|
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
|
opts.notify = false
|
||||||
local expanded = {} ---@type table<string, boolean>
|
opts.args = {
|
||||||
local cache_opts = { hidden = opts.hidden, ignored = opts.ignored }
|
"--type",
|
||||||
|
"d", -- include directories
|
||||||
local use_cache = not state.all and vim.deep_equal(state.cache_opts, cache_opts)
|
"--path-separator", -- same everywhere
|
||||||
for _, dir in ipairs(opts.dirs or {}) do
|
"/",
|
||||||
expanded[dir] = true
|
"--follow", -- always needed to make sure we see symlinked dirs as dirs
|
||||||
use_cache = use_cache and state.cache[dir] ~= nil
|
}
|
||||||
end
|
opts.dirs = { ctx.filter.cwd }
|
||||||
|
ctx.picker.list:set_target()
|
||||||
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
|
|
||||||
|
|
||||||
---@type snacks.picker.explorer.Item
|
---@type snacks.picker.explorer.Item
|
||||||
local root = {
|
local root = {
|
||||||
file = state.cwd,
|
file = opts.cwd,
|
||||||
dir = true,
|
dir = true,
|
||||||
open = true,
|
open = true,
|
||||||
text = "",
|
text = "",
|
||||||
|
@ -686,39 +250,29 @@ function M.explorer(opts, ctx)
|
||||||
internal = true,
|
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 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 dirs = {} ---@type table<string, snacks.picker.explorer.Item>
|
||||||
local last = {} ---@type table<snacks.picker.finder.Item, snacks.picker.finder.Item>
|
local last = {} ---@type table<snacks.picker.finder.Item, snacks.picker.finder.Item>
|
||||||
|
|
||||||
local cwd = state.cwd
|
|
||||||
dirs[cwd] = root
|
|
||||||
state.git_status = {}
|
|
||||||
|
|
||||||
---@async
|
---@async
|
||||||
return function(cb)
|
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)
|
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
|
---@param item snacks.picker.explorer.Item
|
||||||
local function add(item)
|
local function add(item)
|
||||||
|
@ -726,9 +280,6 @@ function M.explorer(opts, ctx)
|
||||||
dirname, basename = dirname or "", basename or item.file
|
dirname, basename = dirname or "", basename or item.file
|
||||||
local parent = dirs[dirname] ~= item and dirs[dirname] or root
|
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
|
-- hierarchical sorting
|
||||||
if item.dir then
|
if item.dir then
|
||||||
item.sort = parent.sort .. "!" .. basename .. " "
|
item.sort = parent.sort .. "!" .. basename .. " "
|
||||||
|
@ -738,7 +289,10 @@ function M.explorer(opts, ctx)
|
||||||
if basename:sub(1, 1) == "." then
|
if basename:sub(1, 1) == "." then
|
||||||
item.hidden = true
|
item.hidden = true
|
||||||
end
|
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
|
if opts.tree then
|
||||||
-- tree
|
-- tree
|
||||||
|
@ -754,7 +308,6 @@ function M.explorer(opts, ctx)
|
||||||
-- add to picker
|
-- add to picker
|
||||||
cb(item)
|
cb(item)
|
||||||
end
|
end
|
||||||
-- ctx.async:sleep(1000)
|
|
||||||
|
|
||||||
-- get files and directories
|
-- get files and directories
|
||||||
files(function(item)
|
files(function(item)
|
||||||
|
@ -769,18 +322,18 @@ function M.explorer(opts, ctx)
|
||||||
dirs[item.file].internal = false
|
dirs[item.file].internal = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
item.open = is_open(item.file)
|
item.open = true
|
||||||
dirs[item.file] = item
|
dirs[item.file] = item
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Add parents when needed
|
-- 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
|
if not dirs[dir] then
|
||||||
dirs[dir] = {
|
dirs[dir] = {
|
||||||
text = dir,
|
text = dir,
|
||||||
file = dir,
|
file = dir,
|
||||||
dir = true,
|
dir = true,
|
||||||
open = is_open(dir),
|
open = true,
|
||||||
internal = true,
|
internal = true,
|
||||||
}
|
}
|
||||||
add(dirs[dir])
|
add(dirs[dir])
|
||||||
|
@ -789,40 +342,6 @@ function M.explorer(opts, ctx)
|
||||||
|
|
||||||
add(item)
|
add(item)
|
||||||
end)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue