snacks.nvim/lua/snacks/picker/source/explorer.lua

391 lines
10 KiB
Lua

---@diagnostic disable: await-in-sync
local Actions = require("snacks.explorer.actions")
local Tree = require("snacks.explorer.tree")
local M = {}
---@type table<snacks.Picker, snacks.picker.explorer.State>
M._state = setmetatable({}, { __mode = "k" })
local uv = vim.uv or vim.loop
---@class snacks.picker.explorer.Item: snacks.picker.finder.Item
---@field file string
---@field dir? boolean
---@field parent? snacks.picker.explorer.Item
---@field open? boolean
---@field last? boolean
---@field sort? string
---@field internal? boolean internal parent directories not part of fd output
---@field status? string
local function norm(path)
return svim.fs.normalize(path)
end
---@class snacks.picker.explorer.State
---@field on_find? fun()?
local State = {}
State.__index = State
---@param picker snacks.Picker
function State.new(picker)
local self = setmetatable({}, State)
local opts = picker.opts --[[@as snacks.picker.explorer.Config]]
local r = picker:ref()
local function ref()
local v = r.value
return v and not v.closed and v or nil
end
Tree:refresh(picker:cwd())
local buf = vim.api.nvim_win_get_buf(picker.main)
local buf_file = svim.fs.normalize(vim.api.nvim_buf_get_name(buf))
if uv.fs_stat(buf_file) then
Tree:open(buf_file)
end
if opts.watch then
local on_close = picker.opts.on_close
picker.opts.on_close = function(p)
require("snacks.explorer.watch").abort()
if on_close then
on_close(p)
end
end
end
picker.list.win:on("BufWritePost", function(_, ev)
local p = ref()
if p then
Tree:refresh(ev.file)
Actions.update(p)
end
end)
picker.list.win:on("DirChanged", function(_, ev)
local p = ref()
if p then
p:set_cwd(svim.fs.normalize(ev.file))
p:find()
end
end)
if opts.diagnostics then
local dirty = false
local diag_update = Snacks.util.debounce(function()
dirty = false
local p = ref()
if p then
if require("snacks.explorer.diagnostics").update(p:cwd()) then
p.list:set_target()
p:find()
end
end
end, { ms = 200 })
picker.list.win:on({ "InsertLeave", "DiagnosticChanged" }, function(_, ev)
dirty = dirty or ev.event == "DiagnosticChanged"
if vim.fn.mode() == "n" and dirty then
diag_update()
end
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
return
end
local p = ref()
if not p or p:is_focused() or not p:on_current_tab() or p.closed 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()
local p = ref()
if p and buf_file then
Actions.update(p, { target = buf_file })
end
end
end
return self
end
---@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
return not ctx.filter:is_empty()
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, {
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 = not filter:is_empty()
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
parent.match_topk = nil
picker.list:add(parent)
else
break
end
parent = parent.parent
end
end
end,
on_done = function()
if not searching then
return
end
local picker = ref.value
if not picker or picker.closed then
return
end
for item, idx in picker:iter() do
if not item.dir then
picker.list:view(idx)
return
end
end
end,
},
formatters = {
file = {
filename_only = opts.tree,
},
},
})
end
---@param picker snacks.Picker
function M.get_state(picker)
if not M._state[picker] then
M._state[picker] = State.new(picker)
end
return M._state[picker]
end
---@param opts snacks.picker.explorer.Config
---@type snacks.picker.finder
function M.explorer(opts, ctx)
local state = M.get_state(ctx.picker)
if state:setup(ctx) then
return M.search(opts, ctx)
end
if opts.git_status then
require("snacks.explorer.git").update(ctx.filter.cwd, {
untracked = opts.git_untracked,
on_update = function()
if ctx.picker.closed then
return
end
ctx.picker.list:set_target()
ctx.picker:find()
end,
})
end
if opts.diagnostics then
require("snacks.explorer.diagnostics").update(ctx.filter.cwd)
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)
local last = {} ---@type table<snacks.picker.explorer.Node, snacks.picker.explorer.Item>
Tree:get(ctx.filter.cwd, function(node)
local parent = node.parent and items[node.parent.path] or nil
local status = node.status
if not status and parent and parent.dir_status then
status = parent.dir_status
end
local item = {
file = node.path,
dir = node.dir,
open = node.open,
dir_status = node.dir_status or parent and parent.dir_status,
text = node.path,
parent = parent,
hidden = node.hidden,
ignored = node.ignored,
status = (not node.dir or not node.open or opts.git_status_open) and status or nil,
last = true,
type = node.type,
severity = (not node.dir or not node.open or opts.diagnostics_open) and node.severity or nil,
}
if last[node.parent] then
last[node.parent].last = false
end
last[node.parent] = item
if top == node then
item.hidden = false
item.ignored = false
end
items[node.path] = item
cb(item)
end, { hidden = opts.hidden, ignored = opts.ignored, exclude = opts.exclude, include = opts.include })
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.args = {
"--type",
"d", -- include directories
"--path-separator", -- same everywhere
"/",
}
opts.dirs = { ctx.filter.cwd }
ctx.picker.list:set_target()
---@type snacks.picker.explorer.Item
local root = {
file = opts.cwd,
dir = true,
open = true,
text = "",
sort = "",
internal = true,
}
local files = require("snacks.picker.source.files").files(opts, ctx)
local dirs = {} ---@type table<string, snacks.picker.explorer.Item>
local last = {} ---@type table<snacks.picker.finder.Item, snacks.picker.finder.Item>
---@async
return function(cb)
cb(root)
---@param item snacks.picker.explorer.Item
local function add(item)
local dirname, basename = item.file:match("(.*)/(.*)")
dirname, basename = dirname or "", basename or item.file
local parent = dirs[dirname] ~= item and dirs[dirname] or root
-- hierarchical sorting
if item.dir then
item.sort = parent.sort .. "!" .. basename .. " "
else
item.sort = parent.sort .. "#" .. basename .. " "
end
item.hidden = basename:sub(1, 1) == "."
item.text = item.text:sub(1, #opts.cwd) == opts.cwd and item.text:sub(#opts.cwd + 2) or item.text
local node = Tree:node(item.file)
if node then
item.dir = node.dir
item.type = node.type
item.status = (not node.dir or opts.git_status_open) and node.status or nil
end
if opts.tree then
-- tree
item.parent = parent
if not last[parent] or last[parent].sort < item.sort then
if last[parent] then
last[parent].last = false
end
item.last = true
last[parent] = item
end
end
-- add to picker
cb(item)
end
-- get files and directories
files(function(item)
---@cast item snacks.picker.explorer.Item
item.cwd = nil -- we use absolute paths
-- Directories
if item.file:sub(-1) == "/" then
item.dir = true
item.file = item.file:sub(1, -2)
if dirs[item.file] then
dirs[item.file].internal = false
return
end
item.open = true
dirs[item.file] = item
end
-- Add parents when needed
for dir in Snacks.picker.util.parents(item.file, opts.cwd) do
if dirs[dir] then
break
else
dirs[dir] = {
text = dir,
file = dir,
dir = true,
open = true,
internal = true,
}
add(dirs[dir])
end
end
add(item)
end)
end
end
return M