1
0
mirror of https://github.com/SpaceVim/SpaceVim.git synced 2025-02-11 18:14:00 +08:00
SpaceVim/bundle/neo-tree.nvim/lua/neo-tree/ui/renderer.lua

1254 lines
40 KiB
Lua
Raw Normal View History

2023-05-30 21:09:18 +08:00
local vim = vim
local NuiLine = require("nui.line")
local NuiTree = require("nui.tree")
local NuiSplit = require("nui.split")
local NuiPopup = require("nui.popup")
local utils = require("neo-tree.utils")
local highlights = require("neo-tree.ui.highlights")
local popups = require("neo-tree.ui.popups")
local events = require("neo-tree.events")
local keymap = require("nui.utils.keymap")
local autocmd = require("nui.utils.autocmd")
local log = require("neo-tree.log")
local M = { resize_timer_interval = 50 }
local ESC_KEY = vim.api.nvim_replace_termcodes("<ESC>", true, false, true)
local default_popup_size = { width = 60, height = "80%" }
local draw, create_window, create_tree, render_tree
local floating_windows = {}
local update_floating_windows = function()
local valid_windows = {}
for _, win in ipairs(floating_windows) do
if M.is_window_valid(win.winid) then
table.insert(valid_windows, win)
end
end
floating_windows = valid_windows
end
local tabid_to_tabnr = function(tabid)
return vim.api.nvim_tabpage_is_valid(tabid) and vim.api.nvim_tabpage_get_number(tabid)
end
local cleaned_up = false
---Clean up invalid neotree buffers (e.g after a session restore)
---@param force boolean if true, force cleanup. Otherwise only cleanup once
M.clean_invalid_neotree_buffers = function(force)
if cleaned_up and not force then
return
end
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
local bufname = vim.fn.bufname(buf)
local is_neotree_buffer = string.match(bufname, "neo%-tree [^ ]+ %[%d+]")
local is_valid_neotree, _ = pcall(vim.api.nvim_buf_get_var, buf, "neo_tree_source")
if is_neotree_buffer and not is_valid_neotree then
vim.api.nvim_buf_delete(buf, { force = true })
end
end
cleaned_up = true
end
local resize_monitor_timer = nil
local start_resize_monitor = function()
local interval = M.resize_timer_interval or -1
if interval < 0 then
return
end
if type(interval) ~= "number" then
log.warn("Invalid resize_timer_interval:", interval)
return
end
if resize_monitor_timer then
return
end
local manager = require("neo-tree.sources.manager")
local check_window_size
local speed_up_loops = 0
check_window_size = function()
local windows_exist = false
local success, err = pcall(manager._for_each_state, nil, function(state)
if state.win_width and M.tree_is_visible(state) then
windows_exist = true
local current_size = utils.get_inner_win_width(state.winid)
if current_size ~= state.win_width then
log.trace("Window size changed, redrawing tree")
state.win_width = current_size
render_tree(state)
speed_up_loops = 21 -- move to fast timer for the next 1000 ms
end
end
end)
speed_up_loops = speed_up_loops - 1
if success then
if windows_exist then
local this_interval = interval
if speed_up_loops > 0 then
this_interval = 50
else
speed_up_loops = 0
end
vim.defer_fn(check_window_size, this_interval)
else
log.trace("No windows exist, stopping resize monitor")
end
else
log.debug("Error checking window size: ", err)
vim.defer_fn(check_window_size, math.max(interval * 5, 1000))
end
end
vim.defer_fn(check_window_size, interval)
end
M.close = function(state)
local window_existed = false
if state and state.winid then
if M.window_exists(state) then
local bufnr = vim.api.nvim_win_get_buf(state.winid)
-- if bufnr is different then we expect, then it was taken over by
-- another buffer, so we can't delete it now
if bufnr == state.bufnr then
window_existed = true
if state.current_position == "current" then
-- we are going to hide the buffer instead of closing the window
M.position.save(state)
local new_buf = vim.fn.bufnr("#")
if new_buf < 1 then
new_buf = vim.api.nvim_create_buf(true, false)
end
vim.api.nvim_win_set_buf(state.winid, new_buf)
else
local win_list = vim.api.nvim_tabpage_list_wins(0)
if #win_list > 1 then
local args = {
position = state.current_position,
source = state.name,
winid = state.winid,
tabnr = tabid_to_tabnr(state.tabid), -- for compatibility
tabid = state.tabid,
}
events.fire_event(events.NEO_TREE_WINDOW_BEFORE_CLOSE, args)
-- focus the prior used window if we are closing the currently focused window
local current_winid = vim.api.nvim_get_current_win()
if current_winid == state.winid then
local pwin = require("neo-tree").get_prior_window()
if type(pwin) == "number" and pwin > 0 then
pcall(vim.api.nvim_set_current_win, pwin)
end
end
-- if the window was a float, changing the current win would have closed it already
pcall(vim.api.nvim_win_close, state.winid, true)
events.fire_event(events.NEO_TREE_WINDOW_AFTER_CLOSE, args)
end
end
end
end
state.winid = nil
end
local bufnr = utils.get_value(state, "bufnr", 0, true)
if bufnr > 0 then
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
state.bufnr = nil
end
return window_existed
end
M.close_floating_window = function(source_name)
local found_windows = {}
for _, win in ipairs(floating_windows) do
if win.source_name == source_name then
table.insert(found_windows, win)
end
end
local valid_window_was_closed = false
for _, win in ipairs(found_windows) do
if not valid_window_was_closed then
valid_window_was_closed = M.is_window_valid(win.winid)
end
-- regardless of whether the window is valid or not, nui will cleanup
win:unmount()
end
return valid_window_was_closed
end
M.close_all_floating_windows = function()
while #floating_windows > 0 do
local win = table.remove(floating_windows)
win:unmount()
end
end
M.get_nui_popup = function(winid)
for _, win in ipairs(floating_windows) do
if win.winid == winid then
return win
end
end
end
local remove_filtered = function(source_items, filtered_items)
local visible = {}
local hidden = {}
for _, child in ipairs(source_items) do
local fby = child.filtered_by
if type(fby) == "table" and not child.is_reveal_target and not fby.show_anyway then
if not fby.never_show then
if filtered_items.visible or child.is_nested or fby.always_show then
table.insert(visible, child)
else
table.insert(hidden, child)
end
end
else
table.insert(visible, child)
end
end
return visible, hidden
end
local create_nodes
---Transforms a list of items into a collection of TreeNodes.
---@param source_items table The list of items to transform. The expected
--interface for these items depends on the component renderers configured for
--the given source, but they must contain at least an id field.
---@param state table The current state of the plugin.
---@param level integer Optional. The current level of the tree, defaults to 0.
---@return table A collection of TreeNodes.
create_nodes = function(source_items, state, level)
level = level or 0
local nodes = {}
local filtered_items = state.filtered_items or {}
local visible, hidden = remove_filtered(source_items, filtered_items)
if #visible == 0 and level <= 1 and filtered_items.force_visible_in_empty_folder then
source_items = hidden
else
source_items = visible
end
local show_indent_marker_for_message
local msg = state.renderers.message or {}
if msg[1] and msg[1][1] == "indent" then
show_indent_marker_for_message = msg[1].with_markers
end
for i, item in ipairs(source_items) do
local is_last_child = i == #source_items
local nodeData = {
id = item.id,
name = item.name,
type = item.type,
loaded = item.loaded,
filtered_by = item.filtered_by,
extra = item.extra,
is_nested = item.is_nested,
skip_node = item.skip_node,
is_empty_with_hidden_root = item.is_empty_with_hidden_root,
-- TODO: The below properties are not universal and should not be here.
-- Maybe they should be moved to the "extra" field?
is_link = item.is_link,
link_to = item.link_to,
path = item.path,
ext = item.ext,
search_pattern = item.search_pattern,
level = level,
is_last_child = is_last_child,
}
local indent = (state.renderers[item.type] or {}).indent_size or 4
local node_children = nil
if item.children ~= nil then
node_children = create_nodes(item.children, state, level + 1)
end
local node = NuiTree.Node(nodeData, node_children)
if item._is_expanded then
node:expand()
end
table.insert(nodes, node)
end
if #hidden > 0 then
if source_items == hidden then
local nodeData = {
id = hidden[#hidden].id .. "_hidden_message",
name = "(forced to show "
.. #hidden
.. " hidden "
.. (#hidden > 1 and "items" or "item")
.. ")",
type = "message",
level = level,
is_last_child = show_indent_marker_for_message,
}
local node = NuiTree.Node(nodeData)
table.insert(nodes, node)
elseif filtered_items.show_hidden_count or (#visible == 0 and level <= 1) then
local nodeData = {
id = hidden[#hidden].id .. "_hidden_message",
name = "(" .. #hidden .. " hidden " .. (#hidden > 1 and "items" or "item") .. ")",
type = "message",
level = level,
is_last_child = show_indent_marker_for_message,
}
if #nodes > 0 then
nodes[#nodes].is_last_child = not show_indent_marker_for_message
end
local node = NuiTree.Node(nodeData)
table.insert(nodes, node)
end
end
return nodes
end
local one_line = function(text)
if type(text) == "string" then
return text:gsub("\n", " ")
else
return text
end
end
M.render_component = function(component, item, state, remaining_width)
local component_func = state.components[component[1]]
if component_func then
local success, component_data, wanted_width =
pcall(component_func, component, item, state, remaining_width)
if success then
if component_data == nil then
return { {} }
end
if component_data.text then
-- everything else is easier if we make sure this is always the same shape
-- which is an array of { text, highlight } tables
component_data = { component_data }
end
for _, data in ipairs(component_data) do
data.text = one_line(data.text)
end
return component_data, wanted_width
else
local name = component[1] or "[missing_name]"
local msg = string.format("Error rendering component %s: %s", name, component_data)
log.warn(msg)
return { { text = msg, highlight = highlights.NORMAL } }
end
else
local name = component[1] or "[missing_name]"
local msg = "Neo-tree: Component " .. name .. " not found."
log.warn(msg)
return { { text = msg, highlight = highlights.NORMAL } }
end
end
local prepare_node = function(item, state)
if item.skip_node then
if item.is_empty_with_hidden_root then
local line = NuiLine()
line:append("(empty folder)", highlights.MESSAGE)
return line
else
return nil
end
end
-- pre_render is used to calculate the longest node width
-- without actually rendering the node.
-- We'll try to reuse that work if possible.
local pre_render = state._in_pre_render
if item.line and not pre_render then
local line = item.line
-- Only use it once, we don't want to accidentally use stale data
item.line = nil
if
line
and item.wanted_width
and state.longest_node
and item.wanted_width <= state.longest_node
then
return line
end
end
local line = NuiLine()
local renderer = state.renderers[item.type]
if not renderer then
line:append(item.type .. ": ", "Comment")
line:append(item.name)
return line
end
local remaining_cols = state.win_width
if remaining_cols == nil then
if state.winid then
remaining_cols = vim.api.nvim_win_get_width(state.winid)
else
local default_width = utils.resolve_config_option(state, "window.width", 40)
remaining_cols = default_width
end
end
local wanted_width = 0
if state.current_position == "current" then
local longest = state.longest_node or 0
remaining_cols = math.min(remaining_cols, longest + 4)
end
local should_pad = false
for _, component in ipairs(renderer) do
local component_data, component_wanted_width =
M.render_component(component, item, state, remaining_cols - (should_pad and 1 or 0))
local actual_width = 0
if component_data then
for _, data in ipairs(component_data) do
if data.text then
local padding = ""
if should_pad and #data.text and data.text:sub(1, 1) ~= " " and not data.no_padding then
padding = " "
end
data.text = padding .. data.text
should_pad = data.text:sub(#data.text) ~= " "
actual_width = actual_width + vim.api.nvim_strwidth(data.text)
line:append(data.text, data.highlight)
remaining_cols = remaining_cols - vim.fn.strchars(data.text)
end
end
end
component_wanted_width = component_wanted_width or actual_width
wanted_width = wanted_width + component_wanted_width
end
line.wanted_width = wanted_width
if pre_render then
item.line = line
state.longest_node = math.max(state.longest_node, line.wanted_width)
else
item.line = nil
end
return line
end
---Sets the cursor at the specified node.
---@param state table The current state of the source.
---@param id string? The id of the node to set the cursor at.
---@return boolean boolean True if the node was found and focused, false
---otherwise.
M.focus_node = function(state, id, do_not_focus_window, relative_movement, bottom_scroll_padding)
if not id and not relative_movement then
log.debug("focus_node called with no id and no relative movement")
return false
end
relative_movement = relative_movement or 0
bottom_scroll_padding = bottom_scroll_padding or 0
local tree = state.tree
if not tree then
log.debug("focus_node called with no tree")
return false
end
local node, linenr = tree:get_node(id)
if not node then
log.debug("focus_node cannot find node with id ", id)
return false
end
id = node:get_id() -- in case nil was passed in for id, meaning current node
local bufnr = utils.get_value(state, "bufnr", 0, true)
if bufnr == 0 then
log.debug("focus_node: state has no bufnr ", state.bufnr, " / ", state.winid)
return false
end
if not vim.api.nvim_buf_is_valid(bufnr) then
log.debug("focus_node: bufnr is not valid")
return false
end
if M.window_exists(state) then
if not linenr then
M.expand_to_node(state, node)
node, linenr = tree:get_node(id)
if not linenr then
log.debug("focus_node cannot get linenr for node with id ", id)
return false
end
end
local focus_window = not do_not_focus_window
if focus_window then
vim.api.nvim_set_current_win(state.winid)
end
-- focus the correct line
linenr = linenr + relative_movement
local col = 0
if node.indent then
col = string.len(node.indent)
end
local success, err = pcall(vim.api.nvim_win_set_cursor, state.winid, { linenr, col })
-- now ensure that the window is scrolled correctly
if success then
local execute_win_command = function(cmd)
if vim.api.nvim_get_current_win() == state.winid then
vim.cmd(cmd)
else
vim.cmd("call win_execute(" .. state.winid .. [[, "]] .. cmd .. [[")]])
end
end
-- make sure we are not scrolled down if it can all fit on the screen
local lines = vim.api.nvim_buf_line_count(state.bufnr)
local win_height = vim.api.nvim_win_get_height(state.winid)
local expected_bottom_line = math.min(lines, linenr + 5) + bottom_scroll_padding
if expected_bottom_line > win_height then
execute_win_command("normal! zb")
local top = vim.fn.line("w0", state.winid)
local bottom = vim.fn.line("w$", state.winid)
local offset_top = top + (expected_bottom_line - bottom)
execute_win_command("normal! " .. offset_top .. "zt")
pcall(vim.api.nvim_win_set_cursor, state.winid, { linenr, col })
elseif win_height > linenr then
execute_win_command("normal! zb")
elseif linenr < (win_height / 2) then
execute_win_command("normal! zz")
end
else
log.debug("Failed to set cursor: " .. err)
end
return success
else
log.debug("focus_node: window does not exist")
return false
end
return false
end
M.get_all_visible_nodes = function(tree)
local nodes = {}
local function process(node)
table.insert(nodes, node)
if node:is_expanded() then
if node:has_children() then
for _, child in ipairs(tree:get_nodes(node:get_id())) do
process(child)
end
end
end
end
for _, node in ipairs(tree:get_nodes()) do
process(node)
end
return nodes
end
M.get_expanded_nodes = function(tree, root_node_id)
local node_ids = {}
local function process(node)
local id = node:get_id()
if node:is_expanded() then
table.insert(node_ids, id)
end
if node:has_children() then
for _, child in ipairs(tree:get_nodes(id)) do
process(child)
end
end
end
if root_node_id then
local root_node = tree:get_node(root_node_id)
if root_node then
process(root_node)
end
else
for _, node in ipairs(tree:get_nodes()) do
process(node)
end
end
return node_ids
end
M.collapse_all_nodes = function(tree, root_node_id)
local expanded = M.get_expanded_nodes(tree, root_node_id)
for _, id in ipairs(expanded) do
local node = tree:get_node(id)
if utils.is_expandable(node) then
node:collapse(id)
end
end
-- but make sure the root is expanded
local root = tree:get_nodes()[1]
if root then
root:expand()
end
end
M.expand_to_node = function(state, node)
if not M.tree_is_visible(state) then
return
end
local tree = state.tree
if type(node) == "string" then
node = tree:get_node(node)
end
local parentId = node:get_parent_id()
while parentId do
local parent = tree:get_node(parentId)
parent:expand()
parentId = parent:get_parent_id()
end
render_tree(state)
end
---Functions to save and restore the focused node.
M.position = {
save = function(state)
if state.tree and M.window_exists(state) then
local success, node = pcall(state.tree.get_node, state.tree)
if success and node then
_, state.position.node_id = pcall(node.get_id, node)
end
end
local win_state = vim.fn.winsaveview()
state.position.topline = win_state.topline
-- Only need to restore the cursor state once per save, comes
-- into play when some actions fire multiple times per "iteration"
-- within the scope of where we need to perform the restore operation
state.position.is.restorable = true
end,
set = function(state, node_id)
if not type(node_id) == "string" and node_id > "" then
return
end
state.position.node_id = node_id
state.position.is.restorable = true
end,
restore = function(state)
if not state.position.node_id then
log.debug("No node_id to restore to")
return
end
if state.position.is.restorable then
log.debug("Restoring position to node_id: " .. state.position.node_id)
M.focus_node(state, state.position.node_id, true)
else
log.debug("Position is not restorable")
end
if state.position.topline then
vim.fn.winrestview({ topline = state.position.topline })
end
state.position.is.restorable = false
end,
is = { restorable = true },
}
---Redraw the tree without relaoding from the source.
---@param state table State of the tree.
M.redraw = function(state)
if state.tree and M.tree_is_visible(state) then
log.trace("Redrawing tree", state.name, state.id)
render_tree(state)
log.trace(" Redrawing tree done", state.name, state.id)
end
end
---Visit all nodes ina tree recursively and reduce to a single value.
---@param tree table NuiTree
---@param memo any Value that is passed to the accumulator function
---@param func function Accumulator function that is called for each node
---@return any any The final memo value.
M.reduce_nodes = function(tree, memo, func)
if type(func) ~= "function" then
error("func must be a function")
end
local visit
visit = function(node)
func(node, memo)
if node:has_children() then
for _, child in ipairs(tree:get_nodes(node:get_id())) do
visit(child)
end
end
end
for _, node in ipairs(tree:get_nodes()) do
visit(node)
end
return memo
end
---Visits all nodes in the tree and returns a list of all nodes that match the
---given predicate.
---@param tree table The NuiTree to search.
---@param selector_func function The predicate function, should return true for
---nodes that should be included in the result.
---@return table table A list of nodes that match the predicate.
M.select_nodes = function(tree, selector_func, limit)
if type(selector_func) ~= "function" then
error("selector_func must be a function")
end
local found_nodes = {}
local visit
visit = function(node)
if selector_func(node) then
table.insert(found_nodes, node)
if limit and #found_nodes >= limit then
return
end
end
if node:has_children() then
for _, child in ipairs(tree:get_nodes(node:get_id())) do
visit(child)
end
end
end
for _, node in ipairs(tree:get_nodes()) do
visit(node)
if limit and #found_nodes >= limit then
break
end
end
return found_nodes
end
M.set_expanded_nodes = function(tree, expanded_nodes)
M.collapse_all_nodes(tree)
log.debug("Setting expanded nodes")
for _, id in ipairs(expanded_nodes or {}) do
local node = tree:get_node(id)
if node ~= nil then
node:expand()
end
end
end
create_tree = function(state)
state.tree = NuiTree({
ns_id = highlights.ns_id,
winid = state.winid,
get_node_id = function(node)
return node.id
end,
prepare_node = function(data)
return prepare_node(data, state)
end,
})
end
local get_selected_nodes = function(state)
if state.winid ~= vim.api.nvim_get_current_win() then
return nil
end
local start_pos = vim.fn.getpos("'<")[2]
local end_pos = vim.fn.getpos("'>")[2]
if end_pos < start_pos then
-- I'm not sure if this could actually happen, but just in case
start_pos, end_pos = end_pos, start_pos
end
local selected_nodes = {}
while start_pos <= end_pos do
local node = state.tree:get_node(start_pos)
if node then
table.insert(selected_nodes, node)
end
start_pos = start_pos + 1
end
return selected_nodes
end
local set_window_mappings = function(state)
local resolved_mappings = {}
local skip_this_mapping = {
["none"] = true,
["nop"] = true,
["noop"] = true,
}
local mappings = utils.get_value(state, "window.mappings", {}, true)
local mapping_options = utils.get_value(state, "window.mapping_options", { noremap = true }, true)
for cmd, func in pairs(mappings) do
local vfunc
local config = {}
if utils.truthy(func) then
if skip_this_mapping[func] then
log.trace("Skipping mapping for %s", cmd)
else
local map_options = vim.deepcopy(mapping_options)
if type(func) == "table" then
for key, value in pairs(func) do
if key ~= "command" and key ~= 1 and key ~= "config" then
map_options[key] = value
end
end
config = func.config or {}
func = func.command or func[1]
end
if type(func) == "string" then
resolved_mappings[cmd] = { text = func }
vfunc = state.commands[func .. "_visual"]
func = state.commands[func]
elseif type(func) == "function" then
resolved_mappings[cmd] = { text = "<function>" }
end
if type(func) == "function" then
resolved_mappings[cmd].handler = function()
state.config = config
func(state)
end
keymap.set(state.bufnr, "n", cmd, resolved_mappings[cmd].handler, map_options)
if type(vfunc) == "function" then
keymap.set(state.bufnr, "v", cmd, function()
vim.api.nvim_feedkeys(ESC_KEY, "i", true)
vim.schedule(function()
local selected_nodes = get_selected_nodes(state)
if utils.truthy(selected_nodes) then
state.config = config
vfunc(state, selected_nodes)
end
end)
end, map_options)
end
else
log.warn("Invalid mapping for ", cmd, ": ", func)
resolved_mappings[cmd] = "<invalid>"
end
end
end
end
state.resolved_mappings = resolved_mappings
end
local function create_floating_window(state, win_options, bufname)
local win
state.force_float = nil
-- First get the default options for floating windows.
local sourceTitle = state.name:gsub("^%l", string.upper)
win_options = popups.popup_options("Neo-tree " .. sourceTitle, 40, win_options)
win_options.win_options = nil
win_options.zindex = 40
-- Then override with source specific options.
local b = win_options.border
win_options.size = utils.resolve_config_option(state, "window.popup.size", default_popup_size)
win_options.position = utils.resolve_config_option(state, "window.popup.position", "50%")
win_options.border = utils.resolve_config_option(state, "window.popup.border", b)
win = NuiPopup(win_options)
win:mount()
win.source_name = state.name
win.original_options = state.window
table.insert(floating_windows, win)
if require("neo-tree").config.close_floats_on_escape_key then
win:map("n", "<esc>", function(_)
win:unmount()
end, { noremap = true })
end
win:on({ "BufHidden" }, function()
vim.schedule(function()
win:unmount()
end)
end, { once = true })
state.winid = win.winid
state.bufnr = win.bufnr
log.debug("Created floating window with winid: ", win.winid, " and bufnr: ", win.bufnr)
vim.api.nvim_buf_set_name(state.bufnr, bufname)
-- why is this necessary?
vim.api.nvim_set_current_win(win.winid)
return win
end
create_window = function(state)
local default_position = utils.resolve_config_option(state, "window.position", "left")
local relative = utils.resolve_config_option(state, "window.relative", "editor")
state.current_position = state.current_position or default_position
local bufname = string.format("neo-tree %s [%s]", state.name, state.id)
local size_opt, default_size
if state.current_position == "top" or state.current_position == "bottom" then
size_opt, default_size = "window.height", "15"
else
size_opt, default_size = "window.width", "40"
end
local win_options = {
ns_id = highlights.ns_id,
size = utils.resolve_config_option(state, size_opt, default_size),
position = state.current_position,
relative = relative,
buf_options = {
buftype = "nofile",
modifiable = false,
swapfile = false,
filetype = "neo-tree",
undolevels = -1,
},
win_options = {
colorcolumn = "",
signcolumn = "no",
},
}
local event_args = {
position = state.current_position,
source = state.name,
tabnr = tabid_to_tabnr(state.tabid), -- for compatibility
tabid = state.tabid,
}
events.fire_event(events.NEO_TREE_WINDOW_BEFORE_OPEN, event_args)
local win
if state.current_position == "float" then
win = create_floating_window(state, win_options, bufname)
elseif state.current_position == "current" then
-- state.id is always the window id or tabnr that this state was created for
-- in the case of a position = current state object, it will be the window id
local winid = state.id
if not vim.api.nvim_win_is_valid(winid) then
log.warn("Window ", winid, " is no longer valid!")
return
end
local bufnr = vim.fn.bufnr(bufname)
if bufnr < 1 then
bufnr = vim.api.nvim_create_buf(false, false)
vim.api.nvim_buf_set_name(bufnr, bufname)
end
state.winid = winid
state.bufnr = bufnr
vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile")
vim.api.nvim_buf_set_option(bufnr, "swapfile", false)
vim.api.nvim_buf_set_option(bufnr, "filetype", "neo-tree")
vim.api.nvim_buf_set_option(bufnr, "modifiable", false)
vim.api.nvim_buf_set_option(bufnr, "undolevels", -1)
vim.api.nvim_win_set_buf(winid, bufnr)
else
win = NuiSplit(win_options)
win:mount()
state.winid = win.winid
state.bufnr = win.bufnr
vim.api.nvim_buf_set_name(state.bufnr, bufname)
end
event_args.winid = state.winid
events.fire_event(events.NEO_TREE_WINDOW_AFTER_OPEN, event_args)
if type(state.bufnr) == "number" then
vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_source", state.name)
vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_tabnr", tabid_to_tabnr(state.tabid))
vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_tabid", state.tabid)
vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_position", state.current_position)
vim.api.nvim_buf_set_var(state.bufnr, "neo_tree_winid", state.winid)
end
if win == nil then
autocmd.buf.define(state.bufnr, "WinLeave", function()
M.position.save(state)
end)
else
-- Used to track the position of the cursor within the tree as it gains and loses focus
--
-- Note `WinEnter` is often too early to restore the cursor position so we do not set
-- that up here, and instead trigger those events manually after drawing the tree (not
-- to mention that it would be too late to register `WinEnter` here for the first
-- iteration of that event on the tree window)
win:on({ "WinLeave" }, function()
M.position.save(state)
end)
win:on({ "BufDelete" }, function()
win:unmount()
end, { once = true })
end
set_window_mappings(state)
return win
end
M.update_floating_window_layouts = function()
update_floating_windows()
for _, win in ipairs(floating_windows) do
local opt = {
relative = "win",
}
opt.size = utils.resolve_config_option(win.original_options, "popup.size", default_popup_size)
opt.position = utils.resolve_config_option(win.original_options, "popup.position", "50%")
win:update_layout(opt)
end
end
---Determines is the givin winid is valid and the window still exists.
---@param winid any
---@return boolean
M.is_window_valid = function(winid)
if winid == nil then
return false
end
if type(winid) == "number" and winid > 0 then
return vim.api.nvim_win_is_valid(winid)
else
return false
end
end
---Determines if the window exists and is valid.
---@param state table The current state of the plugin.
---@return boolean True if the window exists and is valid, false otherwise.
M.window_exists = function(state)
local window_exists
local winid = utils.get_value(state, "winid", 0, true)
local bufnr = utils.get_value(state, "bufnr", 0, true)
local default_position = utils.get_value(state, "window.position", "left", true)
local position = state.current_position or default_position
if winid == 0 then
window_exists = false
elseif position == "current" then
window_exists = vim.api.nvim_win_is_valid(winid)
and vim.api.nvim_buf_is_valid(bufnr)
and vim.api.nvim_win_get_buf(winid) == bufnr
else
local isvalid = M.is_window_valid(winid)
window_exists = isvalid and (vim.api.nvim_win_get_number(winid) > 0)
if not window_exists then
state.winid = nil
if bufnr > 0 and vim.api.nvim_buf_is_valid(bufnr) then
state.bufnr = nil
local success, err = pcall(vim.api.nvim_buf_delete, bufnr, { force = true })
if not success and err:match("E523") then
vim.schedule_wrap(function()
vim.api.nvim_buf_delete(bufnr, { force = true })
end)()
end
end
end
end
return window_exists
end
---Determines if a specific tree is open.
---@param state table The current state of the plugin.
---@return boolean
M.tree_is_visible = function(state)
return M.window_exists(state) and vim.api.nvim_win_get_buf(state.winid) == state.bufnr
end
---Renders the given tree and expands window width if needed
--@param state table The state containing tree to render. Almost same as state.tree:render()
render_tree = function(state)
local should_auto_expand = state.window.auto_expand_width and state.current_position ~= "float"
local should_pre_render = should_auto_expand or state.current_position == "current"
if should_pre_render and M.tree_is_visible(state) then
log.trace("pre-rendering tree")
state._in_pre_render = true
state.tree:render()
state._in_pre_render = false
state.window.last_user_width = vim.api.nvim_win_get_width(state.winid)
if should_auto_expand and state.longest_node > state.window.last_user_width then
log.trace(string.format("auto_expand_width: on. Expanding width to %s.", state.longest_node))
vim.api.nvim_win_set_width(state.winid, state.longest_node)
state.win_width = state.longest_node
end
end
if M.tree_is_visible(state) then
state.tree:render()
end
end
---Draws the given nodes on the screen.
--@param nodes table The nodes to draw.
--@param state table The current state of the source.
draw = function(nodes, state, parent_id)
-- If we are going to redraw, preserve the current set of expanded nodes.
local expanded_nodes = {}
if parent_id == nil and state.tree ~= nil then
if state.force_open_folders then
log.trace("Force open folders")
state.force_open_folders = nil
else
log.trace("Preserving expanded nodes")
expanded_nodes = M.get_expanded_nodes(state.tree)
end
end
if state.default_expanded_nodes then
for _, id in ipairs(state.default_expanded_nodes) do
table.insert(expanded_nodes, id)
end
end
-- Create the tree if it doesn't exist.
if not parent_id and not M.window_exists(state) then
create_window(state)
create_tree(state)
end
-- draw the given nodes
local success, msg = pcall(state.tree.set_nodes, state.tree, nodes, parent_id)
if not success then
log.error("Error setting nodes: ", msg)
log.error(vim.inspect(state.tree:get_nodes()))
end
if parent_id ~= nil then
-- this is a dynamic fetch of children that were not previously loaded
local node = state.tree:get_node(parent_id)
node.loaded = true
node:expand()
else
M.set_expanded_nodes(state.tree, expanded_nodes)
end
-- This is to ensure that containers are always the right size
state.win_width = utils.get_inner_win_width(state.winid)
start_resize_monitor()
render_tree(state)
-- draw winbar / statusbar
require("neo-tree.ui.selector").set_source_selector(state)
-- Restore the cursor position/focused node in the tree based on the state
-- when it was last closed
M.position.restore(state)
end
local function group_empty_dirs(node)
if node.children == nil then
return node
end
local first_child = node.children[1]
if #node.children == 1 and first_child.type == "directory" then
-- this is the only path that changes the tree
-- at each step where we discover an empty directory, merge it's name with the parent
-- then skip over it
first_child.name = node.name .. utils.path_separator .. first_child.name
return group_empty_dirs(first_child)
else
for i, child in ipairs(node.children) do
node.children[i] = group_empty_dirs(child)
end
return node
end
end
---Shows the given items as a tree.
--@param sourceItems table The list of items to transform.
--@param state table The current state of the plugin.
--@param parentId string Optional. The id of the parent node to display these nodes
--at; defaults to nil.
M.show_nodes = function(sourceItems, state, parentId, callback)
--local id = string.format("show_nodes %s:%s [%s]", state.name, state.force_float, state.tabid)
--utils.debounce(id, function()
events.fire_event(events.BEFORE_RENDER, state)
state.longest_width_exact = 0
local parent
local level = 0
if parentId ~= nil then
local success
success, parent = pcall(state.tree.get_node, state.tree, parentId)
if success and parent then
level = parent:get_depth()
end
state.longest_node = state.longest_node or 0
else
state.longest_node = 0
end
local config = require("neo-tree").config
if config.hide_root_node then
if not parentId then
sourceItems[1].skip_node = true
if not (sourceItems[1].children and #sourceItems[1].children > 0) then
sourceItems[1].is_empty_with_hidden_root = true
end
end
if not config.retain_hidden_root_indent then
level = level - 1
end
end
if config.add_blank_line_at_top and not parentId then
table.insert(sourceItems, 1, {
type = "message",
name = "",
path = "",
id = "blank_line_at_top",
})
end
if state.group_empty_dirs then
if parent then
local scan_mode = require("neo-tree").config.filesystem.scan_mode
if scan_mode == "deep" then
for i, item in ipairs(sourceItems) do
sourceItems[i] = group_empty_dirs(item)
end
else
-- this is a lazy load of a single sub folder
group_empty_dirs(sourceItems)
if #sourceItems == 1 and sourceItems[1].type == "directory" then
-- This folder needs to be grouped.
-- The goal is to just update the existing node in place.
-- To avoid digging into private internals of Nui, we will just export the entire level and replace
-- the one node. This keeps it in the right order, because nui doesn't have methods to replace something
-- in place.
-- We can't just mutate the existing node because we have to change it's id which would break Nui's
-- internal state.
local item = sourceItems[1]
parentId = parent:get_parent_id()
local siblings = state.tree:get_nodes(parentId)
for i, node in pairs(siblings) do
if node.id == parent.id then
item.name = parent.name .. utils.path_separator .. item.name
item.level = level - 1
item.is_loaded = utils.truthy(item.children)
siblings[i] = NuiTree.Node(item, item.children)
break
end
end
sourceItems = nil -- this is a signal to skip the rest of the processing
state.tree:set_nodes(siblings, parentId)
end
end
else
-- if we are rendering a whole tree, just group the children because we don'the
-- want to change the root nodes
for _, item in ipairs(sourceItems) do
if item.children ~= nil then
for i, child in ipairs(item.children) do
item.children[i] = group_empty_dirs(child)
end
end
end
end
end
if sourceItems then
-- normal path
local nodes = create_nodes(sourceItems, state, level)
draw(nodes, state, parentId)
else
-- this was a force grouping of a lazy loaded folder
state.win_width = utils.get_inner_win_width(state.winid)
render_tree(state)
end
vim.schedule(function()
events.fire_event(events.AFTER_RENDER, state)
end)
if type(callback) == "function" then
vim.schedule(callback)
end
--end, 100)
end
return M