All checks were successful
NeoVim tests / plenary-tests (push) Successful in 15s
446 lines
13 KiB
Lua
446 lines
13 KiB
Lua
--------------------------------------------------------------------------------
|
|
-- File Tree Viewer Module
|
|
--
|
|
-- Future Enhancements:
|
|
-- - Consider implementing additional features like searching for files,
|
|
-- filtering displayed nodes, or adding support for more file types.
|
|
-- - Improve user experience with customizable UI elements and enhanced
|
|
-- navigation options.
|
|
-- - Implement a file watcher to automatically update the file tree when files
|
|
-- change on the underlying filesystem.
|
|
--------------------------------------------------------------------------------
|
|
|
|
--- @alias FsDir { kind: 'dir'; path: string; expanded: boolean; children: FsNode[] }
|
|
--- @alias FsFile { kind: 'file'; path: string }
|
|
--- @alias FsNode FsDir | FsFile
|
|
--- @alias ShowOpts { root_path?: string, width?: number, focus_path?: string }
|
|
|
|
local Buffer = require 'u.buffer'
|
|
local Renderer = require('u.renderer').Renderer
|
|
local TreeBuilder = require('u.renderer').TreeBuilder
|
|
local h = require('u.renderer').h
|
|
local tracker = require 'u.tracker'
|
|
|
|
local logger = require('u.logger').Logger.new 'filetree'
|
|
|
|
local M = {}
|
|
local H = {}
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Helpers:
|
|
--------------------------------------------------------------------------------
|
|
|
|
--- Splits the given path into a list of path components.
|
|
--- @param path string
|
|
function H.split_path(path)
|
|
local parts = {}
|
|
local curr = path
|
|
while #curr > 0 and curr ~= '.' and curr ~= '/' do
|
|
table.insert(parts, 1, vim.fs.basename(curr))
|
|
curr = vim.fs.dirname(curr)
|
|
end
|
|
return parts
|
|
end
|
|
|
|
--- Normalizes the given path to an absolute path.
|
|
--- @param path string
|
|
function H.normalize(path)
|
|
path = vim.fs.normalize(path)
|
|
if path:sub(1, 1) ~= '/' then path = vim.fs.joinpath(vim.uv.cwd(), path) end
|
|
return vim.fs.normalize(path)
|
|
end
|
|
|
|
--- Computes the relative path from `base` to `path`.
|
|
--- @param path string
|
|
--- @param base string
|
|
function H.relative(path, base)
|
|
path = H.normalize(path)
|
|
base = H.normalize(base)
|
|
if path:sub(1, #base) == base then path = path:sub(#base + 1) end
|
|
if vim.startswith(path, '/') then path = path:sub(2) end
|
|
return path
|
|
end
|
|
|
|
--- @param root_path string
|
|
--- @return { tree: FsDir; path_to_node: table<string, FsNode> }
|
|
function H.get_tree_inf(root_path)
|
|
logger:info { 'get_tree_inf', root_path }
|
|
--- @type table<string, FsNode>
|
|
local path_to_node = {}
|
|
|
|
--- @type FsDir
|
|
local tree = {
|
|
kind = 'dir',
|
|
path = H.normalize(root_path or '.'),
|
|
expanded = true,
|
|
children = {},
|
|
}
|
|
path_to_node[tree.path] = tree
|
|
|
|
H.populate_dir_children(tree, path_to_node)
|
|
return { tree = tree, path_to_node = path_to_node }
|
|
end
|
|
|
|
--- @param tree FsDir
|
|
--- @param path_to_node table<string, FsNode>
|
|
function H.populate_dir_children(tree, path_to_node)
|
|
tree.children = {}
|
|
|
|
for child_path, kind in vim.iter(vim.fs.dir(tree.path, { depth = 1 })) do
|
|
child_path = H.normalize(vim.fs.joinpath(tree.path, child_path))
|
|
local prev_node = path_to_node[child_path]
|
|
|
|
if kind == 'directory' then
|
|
local new_node = {
|
|
kind = 'dir',
|
|
path = child_path,
|
|
expanded = prev_node and prev_node.expanded or false,
|
|
children = prev_node and prev_node.children or {},
|
|
}
|
|
path_to_node[new_node.path] = new_node
|
|
table.insert(tree.children, new_node)
|
|
else
|
|
local new_node = {
|
|
kind = 'file',
|
|
path = child_path,
|
|
}
|
|
path_to_node[new_node.path] = new_node
|
|
table.insert(tree.children, new_node)
|
|
end
|
|
end
|
|
|
|
table.sort(tree.children, function(a, b)
|
|
-- directories first:
|
|
if a.kind ~= b.kind then return a.kind == 'dir' end
|
|
return a.path < b.path
|
|
end)
|
|
end
|
|
|
|
--- @param opts {
|
|
--- bufnr: number;
|
|
--- prev_winnr: number;
|
|
--- root_path: string;
|
|
--- focus_path?: string;
|
|
--- }
|
|
---
|
|
--- @return { expand: fun(path: string), collapse: fun(path: string) }
|
|
local function _render_in_buffer(opts)
|
|
local winnr = vim.api.nvim_buf_call(opts.bufnr, function() return vim.api.nvim_get_current_win() end)
|
|
local s_tree_inf = tracker.create_signal(H.get_tree_inf(opts.root_path))
|
|
local s_focused_path = tracker.create_signal(H.normalize(opts.focus_path or opts.root_path))
|
|
|
|
tracker.create_effect(function()
|
|
local focused_path = s_focused_path:get()
|
|
|
|
s_tree_inf:update(function(tree_inf)
|
|
local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path))
|
|
local path_to_node = tree_inf.path_to_node
|
|
|
|
--- @param node FsDir
|
|
--- @param child_names string[]
|
|
local function expand_to(node, child_names)
|
|
if #child_names == 0 then return end
|
|
node.expanded = true
|
|
|
|
local next_child_name = table.remove(child_names, 1)
|
|
for _, child in ipairs(node.children) do
|
|
if child.kind == 'dir' and vim.fs.basename(child.path) == next_child_name then
|
|
H.populate_dir_children(child, path_to_node)
|
|
expand_to(child, child_names)
|
|
end
|
|
end
|
|
end
|
|
expand_to(tree_inf.tree, parts)
|
|
return tree_inf
|
|
end)
|
|
end)
|
|
|
|
-- --
|
|
-- -- TODO: :help watch-file
|
|
-- --
|
|
-- local watcher = vim.uv.new_fs_event()
|
|
-- if watcher ~= nil then
|
|
-- watcher:start(root_path, { recursive = true }, function(err, fname, status)
|
|
-- -- TODO: more efficient update:
|
|
-- s_tree_inf:set(H.get_tree(root_path))
|
|
--
|
|
-- -- TODO: proper disposal
|
|
-- watcher:stop()
|
|
-- end)
|
|
-- end
|
|
|
|
local controller = {}
|
|
|
|
--- @param path string
|
|
function controller.focus_path(path) s_focused_path:set(H.normalize(path)) end
|
|
|
|
function controller.refresh() s_tree_inf:set(H.get_tree_inf(opts.root_path)) end
|
|
|
|
--- @param path string
|
|
function controller.expand(path)
|
|
path = H.normalize(path)
|
|
local path_to_node = s_tree_inf:get().path_to_node
|
|
|
|
local node = path_to_node[path]
|
|
if node == nil then return end
|
|
|
|
if node.kind == 'dir' then
|
|
s_tree_inf:update(function(tree_inf2)
|
|
H.populate_dir_children(node, path_to_node)
|
|
tree_inf2.path_to_node[node.path].expanded = true
|
|
return tree_inf2
|
|
end)
|
|
if #node.children == 0 then
|
|
s_focused_path:set(node.path)
|
|
else
|
|
s_focused_path:set(node.children[1].path)
|
|
end
|
|
else
|
|
if node.kind == 'file' then
|
|
-- open file:
|
|
vim.api.nvim_win_call(opts.prev_winnr, function() vim.cmd.edit(node.path) end)
|
|
vim.api.nvim_set_current_win(opts.prev_winnr)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- @param path string
|
|
function controller.collapse(path)
|
|
path = H.normalize(path)
|
|
local path_to_node = s_tree_inf:get().path_to_node
|
|
|
|
local node = path_to_node[path]
|
|
if node == nil then return end
|
|
|
|
if node.kind == 'dir' then
|
|
if node.expanded then
|
|
-- collapse self/node:
|
|
s_focused_path:set(node.path)
|
|
s_tree_inf:update(function(tree_inf2)
|
|
tree_inf2.path_to_node[node.path].expanded = false
|
|
return tree_inf2
|
|
end)
|
|
else
|
|
-- collapse parent:
|
|
local parent_dir = path_to_node[vim.fs.dirname(node.path)]
|
|
if parent_dir ~= nil then
|
|
s_focused_path:set(parent_dir.path)
|
|
s_tree_inf:update(function(tree_inf2)
|
|
tree_inf2.path_to_node[parent_dir.path].expanded = false
|
|
return tree_inf2
|
|
end)
|
|
end
|
|
end
|
|
elseif node.kind == 'file' then
|
|
local parent_dir = path_to_node[vim.fs.dirname(node.path)]
|
|
if parent_dir ~= nil then
|
|
s_focused_path:set(parent_dir.path)
|
|
s_tree_inf:update(function(tree_inf2)
|
|
tree_inf2.path_to_node[parent_dir.path].expanded = false
|
|
return tree_inf2
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
|
|
--- @param root_path string
|
|
function controller.new(root_path)
|
|
vim.ui.input({
|
|
prompt = 'New: ',
|
|
completion = 'file',
|
|
}, function(input)
|
|
if input == nil then return end
|
|
local new_path = vim.fs.joinpath(root_path, input)
|
|
|
|
if vim.endswith(input, '/') then
|
|
-- Create a directory:
|
|
vim.fn.mkdir(new_path, input, 'p')
|
|
else
|
|
-- Create a file:
|
|
|
|
-- First, make sure the parent directory exists:
|
|
vim.fn.mkdir(vim.fs.dirname(new_path), 'p')
|
|
|
|
-- Now create an empty file:
|
|
local uv = vim.loop or vim.uv
|
|
local fd = uv.fs_open(new_path, 'w', 438)
|
|
if fd then uv.fs_write(fd, '') end
|
|
end
|
|
|
|
controller.refresh()
|
|
controller.focus_path(new_path)
|
|
end)
|
|
end
|
|
|
|
--- @param path string
|
|
function controller.rename(path)
|
|
path = H.normalize(path)
|
|
local root_path = vim.fs.dirname(path)
|
|
vim.ui.input({
|
|
prompt = 'Rename: ',
|
|
default = vim.fs.basename(path),
|
|
completion = 'file',
|
|
}, function(input)
|
|
if input == nil then return end
|
|
|
|
local new_path = vim.fs.joinpath(root_path, input);
|
|
(vim.loop or vim.uv).fs_rename(path, new_path)
|
|
controller.refresh()
|
|
controller.focus_path(new_path)
|
|
end)
|
|
end
|
|
|
|
--
|
|
-- Render:
|
|
--
|
|
local renderer = Renderer.new(opts.bufnr)
|
|
tracker.create_effect(function()
|
|
--- @type { tree: FsDir; path_to_node: table<string, FsNode> }
|
|
local tree_inf = s_tree_inf:get()
|
|
local tree = tree_inf.tree
|
|
|
|
--- @type string
|
|
local focused_path = s_focused_path:get()
|
|
|
|
--- As we render the tree, keep track of what line each node is on, so that
|
|
--- we have an easy way to make the cursor jump to each node (i.e., line)
|
|
--- at will:
|
|
--- @type table<string, number>
|
|
local node_lines = {}
|
|
local current_line = 0
|
|
|
|
--- The UI is rendered as a list of hypserscript elements:
|
|
local tb = TreeBuilder.new()
|
|
|
|
--- Since the filesystem is a recursive tree of nodes, we need to
|
|
--- recursively render each node. This function does just that:
|
|
--- @param node FsNode
|
|
--- @param level number
|
|
local function render_node(node, level)
|
|
local name = vim.fs.basename(node.path)
|
|
current_line = current_line + 1
|
|
node_lines[node.path] = current_line
|
|
|
|
local nmaps = {
|
|
h = function()
|
|
vim.schedule(function() controller.collapse(node.path) end)
|
|
return ''
|
|
end,
|
|
l = function()
|
|
vim.schedule(function() controller.expand(node.path) end)
|
|
return ''
|
|
end,
|
|
n = function()
|
|
vim.schedule(function() controller.new(node.kind == 'file' and vim.fs.dirname(node.path) or node.path) end)
|
|
return ''
|
|
end,
|
|
r = function()
|
|
vim.schedule(function() controller.rename(node.path) end)
|
|
return ''
|
|
end,
|
|
y = function()
|
|
vim.fn.setreg([["]], H.relative(node.path, tree.path))
|
|
return ''
|
|
end,
|
|
}
|
|
|
|
if node.kind == 'dir' then
|
|
--
|
|
-- Render a directory node:
|
|
--
|
|
local icon = node.expanded and '' or ''
|
|
tb:put {
|
|
current_line > 1 and '\n',
|
|
h('text', { hl = 'Constant', nmap = nmaps }, { string.rep(' ', level), icon, ' ', name }),
|
|
}
|
|
if node.expanded then
|
|
for _, child in ipairs(node.children) do
|
|
render_node(child, level + 1)
|
|
end
|
|
end
|
|
elseif node.kind == 'file' then
|
|
tb:put {
|
|
current_line > 1 and '\n',
|
|
h('text', { nmap = nmaps }, { string.rep(' ', level), ' ', name }),
|
|
}
|
|
end
|
|
end
|
|
render_node(tree, 0)
|
|
|
|
-- The following modifies buffer contents, so it needs to be scheduled:
|
|
vim.schedule(function()
|
|
renderer:render(tb:tree())
|
|
|
|
local cpos = vim.api.nvim_win_get_cursor(winnr)
|
|
pcall(vim.api.nvim_win_set_cursor, winnr, { node_lines[focused_path], cpos[2] })
|
|
end)
|
|
end, 's:tree')
|
|
|
|
return controller
|
|
end
|
|
|
|
--------------------------------------------------------------------------------
|
|
-- Public API functions:
|
|
--------------------------------------------------------------------------------
|
|
|
|
--- @type {
|
|
--- bufnr: number;
|
|
--- winnr: number;
|
|
--- controller: { expand: fun(path: string), collapse: fun(path: string) };
|
|
--- } | nil
|
|
local current_inf = nil
|
|
|
|
--- Show the filetree:
|
|
--- @param opts? ShowOpts
|
|
function M.show(opts)
|
|
if current_inf ~= nil then return current_inf.controller end
|
|
opts = opts or {}
|
|
|
|
local prev_winnr = vim.api.nvim_get_current_win()
|
|
|
|
vim.cmd 'vnew'
|
|
local buf = Buffer.from_nr(vim.api.nvim_get_current_buf())
|
|
buf:set_tmp_options()
|
|
|
|
local winnr = vim.api.nvim_get_current_win()
|
|
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<C-w>H', true, true, true), 'x', false)
|
|
vim.api.nvim_win_set_width(0, opts.width or 30)
|
|
vim.api.nvim_create_autocmd('WinClosed', {
|
|
once = true,
|
|
pattern = tostring(winnr),
|
|
callback = M.hide,
|
|
})
|
|
|
|
vim.wo.number = false
|
|
vim.wo.relativenumber = false
|
|
|
|
local bufnr = vim.api.nvim_get_current_buf()
|
|
|
|
local controller = _render_in_buffer(vim.tbl_extend('force', opts, {
|
|
bufnr = bufnr,
|
|
prev_winnr = prev_winnr,
|
|
root_path = opts.root_path or H.normalize '.',
|
|
}))
|
|
current_inf = { bufnr = bufnr, winnr = winnr, controller = controller }
|
|
return controller
|
|
end
|
|
|
|
--- Hide the filetree:
|
|
function M.hide()
|
|
if current_inf == nil then return end
|
|
pcall(vim.cmd.bdelete, current_inf.bufnr)
|
|
current_inf = nil
|
|
end
|
|
|
|
--- Toggle the filetree:
|
|
--- @param opts? ShowOpts
|
|
function M.toggle(opts)
|
|
if current_inf == nil then
|
|
M.show(opts)
|
|
else
|
|
M.hide()
|
|
end
|
|
end
|
|
|
|
return M
|