mirror of
https://github.com/folke/snacks.nvim
synced 2025-08-04 18:58:12 +00:00
391 lines
10 KiB
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
|