feat(explorer): more keymaps and tree rework. See #837

This commit is contained in:
Folke Lemaitre 2025-02-01 10:20:01 +01:00
parent 4aba559c6e
commit 2ff389312a
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
2 changed files with 206 additions and 119 deletions

View file

@ -70,9 +70,15 @@ M.explorer = {
["r"] = "explorer_rename",
["c"] = "explorer_copy",
["m"] = "explorer_move",
["o"] = "explorer_open", -- open with system application
["P"] = "toggle_preview",
["y"] = "explorer_yank",
["u"] = "explorer_update",
["<c-c>"] = "explorer_cd",
["."] = "explorer_focus",
["I"] = "toggle_ignored",
["H"] = "toggle_hidden",
["Z"] = "explorer_close_all",
},
},
},

View file

@ -6,7 +6,6 @@ local M = {}
---@type table<snacks.Picker, snacks.picker.explorer.State>
M._state = setmetatable({}, { __mode = "k" })
local uv = vim.uv or vim.loop
local expanded = {} ---@type table<string, boolean>
---@class snacks.picker.explorer.Item: snacks.picker.finder.Item
---@field file string
@ -18,11 +17,124 @@ local expanded = {} ---@type table<string, boolean>
---@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()
---@class snacks.picker.explorer.State
---@field cwd string
---@field expanded table<string, boolean>
---@field tree snacks.picker.explorer.Tree
---@field all? boolean
---@field picker snacks.Picker.ref
---@field ref snacks.Picker.ref
---@field opts snacks.picker.explorer.Config
---@field on_find? fun()?
local State = {}
@ -31,15 +143,14 @@ State.__index = State
function State.new(picker)
local self = setmetatable({}, State)
self.opts = picker.opts --[[@as snacks.picker.explorer.Config]]
self.picker = picker:ref()
self.ref = picker:ref()
local filter = picker:filter()
self.cwd = filter.cwd
self.expanded = expanded
self.expanded[self.cwd] = true
self.tree = tree
local buf = vim.api.nvim_win_get_buf(picker.main)
local buf_file = vim.fs.normalize(vim.api.nvim_buf_get_name(buf))
if uv.fs_stat(buf_file) then
self:expand(buf_file)
self:open(buf_file)
end
picker.list.win:on({ "WinEnter", "BufEnter" }, function()
self:follow()
@ -64,12 +175,17 @@ function State.new(picker)
return self
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 picker.closed then
local picker = self:picker()
if not picker or picker:is_focused() then
return
end
local win = vim.api.nvim_get_current_win()
@ -82,29 +198,29 @@ function State:follow()
end
---@param path string
function State:show(path)
local picker = self.picker()
if not picker or picker.closed then
return
end
---@param opts? {refresh?: boolean}
function State:show(path, opts)
opts = opts or {}
path = vim.fs.normalize(path)
if not uv.fs_stat(path) then
return
end
local function show()
local picker = self.picker()
if not picker or picker.closed then
return
end
for item, idx in picker:iter() do
if item.file == path then
picker.list:view(idx)
break
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
if not self:is_visible(path) then
self:expand(path)
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()
@ -113,47 +229,16 @@ end
---@param path string
function State:is_visible(path)
path = vim.fs.normalize(path)
local dir = vim.fn.isdirectory(path) == 1 and path or vim.fs.dirname(path)
if not self:in_cwd(dir) then
return false
end
if not self.expanded[dir] then
return false
end
for p, v in pairs(self.expanded) do
if not v and p:find(dir .. "/", 1, true) == 1 then
return false
end
end
return true
end
---@param dir string
function State:is_open(dir)
return self.all or self.expanded[dir]
end
function State:in_cwd(path)
return path == self.cwd or path:find(self.cwd .. "/", 1, true) == 1
return self.tree:visible(self.cwd, vim.fs.dirname(path))
end
---@param path string
function State:expand(path)
if not self:in_cwd(path) then
function State:open(path)
if not self.tree:in_cwd(self.cwd, path) then
return
end
if vim.fn.isdirectory(path) == 1 then
self.expanded[path] = true
else
self.expanded[vim.fs.dirname(path)] = true
end
for p in vim.fs.parents(path) do
if not self:in_cwd(p) then
break
end
self.expanded[p] = true
end
path = vim.fn.isdirectory(path) == 1 and path or vim.fs.dirname(path)
self.tree:open(path)
end
---@param item snacks.picker.Item
@ -162,44 +247,15 @@ function State:toggle(item)
if not dir then
return
end
self.expanded[dir] = not self.expanded[dir]
if self.expanded[dir] then
self:expand(dir)
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
function State:expand_dirs()
local expand = {} ---@type table<string, boolean>
local exclude = {} ---@type table<string, boolean>
for k, v in pairs(self.expanded) do
if self:in_cwd(k) then
(v and expand or exclude)[k] = true
end
end
-- remove excluded directories
for p in pairs(expand) do
for e in pairs(exclude) do
if p:find(e .. "/", 1, true) == 1 then
expand[p] = nil
break
end
end
end
local ret = vim.tbl_keys(expand) ---@type string[]
-- add parents
for p in pairs(expand) do
for pp in vim.fs.parents(p) do
if expand[pp] or not self:in_cwd(pp) then
break
end
expand[pp] = true
ret[#ret + 1] = pp
end
end
return ret
end
---@param opts snacks.picker.explorer.Config
---@param ctx snacks.picker.finder.ctx
function State:setup(opts, ctx)
@ -216,7 +272,7 @@ function State:setup(opts, ctx)
}
self.all = #ctx.filter.search > 0
if self.all then
local picker = self.picker()
local picker = self:picker()
if not picker then
return {}
end
@ -233,7 +289,7 @@ function State:setup(opts, ctx)
end
end
else
opts.dirs = self:expand_dirs()
opts.dirs = self.tree:dirs(self.cwd)
vim.list_extend(opts.args, { "--max-depth", "1" })
end
return opts
@ -242,18 +298,17 @@ end
---@param opts? {target?: boolean, on_done?: fun()}
function State:update(opts)
opts = opts or {}
local picker = self.picker()
if not picker or picker.closed then
return
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
if opts.target ~= false then
picker.list:set_target()
end
picker:find({ on_done = opts.on_done })
end
function State:dir()
local picker = self.picker()
local picker = self:picker()
if not picker then
return self.cwd
end
@ -267,15 +322,9 @@ function State:dir()
end
end
---@param cwd string
function State:set_cwd(cwd)
cwd = vim.fs.normalize(cwd)
self.cwd = cwd
self.expanded[cwd] = true
for k in pairs(self.expanded) do
if not self:in_cwd(k) then
self.expanded[k] = nil
end
end
self:update({ target = false })
end
@ -298,14 +347,28 @@ end
---@type table<string, snacks.picker.Action.spec>
M.actions = {
explorer_update = function(picker)
M.get_state(picker):update()
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()
state.expanded[dir] = false
state:update()
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)
@ -320,7 +383,7 @@ M.actions = {
local is_dir = value:sub(-1) == "/"
dir = is_dir and path or vim.fs.dirname(path)
vim.fn.mkdir(dir, "p")
state:expand(dir)
state:open(dir)
if not is_dir then
if uv.fs_stat(path) then
Snacks.notify.warn("File already exists:\n- `" .. path .. "`")
@ -339,7 +402,7 @@ M.actions = {
Snacks.rename.rename_file({
file = item.file,
on_rename = function(new)
state:expand(new)
state:open(new)
state:update()
end,
})
@ -389,7 +452,7 @@ M.actions = {
local dir = state:dir()
local path = vim.fs.normalize(dir .. "/" .. value)
vim.fn.mkdir(vim.fs.dirname(path), "p")
state:expand(dir)
state:open(dir)
if uv.fs_stat(path) then
Snacks.notify.warn("File already exists:\n- `" .. path .. "`")
return
@ -426,6 +489,14 @@ M.actions = {
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
@ -469,6 +540,16 @@ function M.explorer(opts, ctx)
local state = M.get_state(ctx.picker)
opts = state:setup(opts, ctx)
opts.notify = false
local expanded = {} ---@type table<string, boolean>
for _, dir in ipairs(opts.dirs or {}) do
expanded[dir] = true
end
-- vim.notify(table.concat(opts.dirs or {}, "\n"), "info")
---@param path string
local function is_open(path)
return state.all or expanded[path]
end
local Git = require("snacks.picker.source.git")
@ -557,7 +638,7 @@ function M.explorer(opts, ctx)
dirs[item.file].internal = false
return
end
item.open = state:is_open(item.file)
item.open = is_open(item.file)
dirs[item.file] = item
end
@ -568,7 +649,7 @@ function M.explorer(opts, ctx)
text = dir,
file = dir,
dir = true,
open = state:is_open(dir),
open = is_open(dir),
internal = true,
}
add(dirs[dir])
@ -584,7 +665,7 @@ function M.explorer(opts, ctx)
end
local function add_git_status(path, status)
if not opts.git_status_open and state.expanded[path] then
if not opts.git_status_open and is_open(path) then
return
end
local item = items[path]