Meh I'll figure out submodules later

This commit is contained in:
mustard 2025-09-16 01:01:02 +02:00
parent 4ca9d44a90
commit 8cb281f436
352 changed files with 66107 additions and 0 deletions

View file

@ -0,0 +1,100 @@
--This file should contain all commands meant to be used by mappings.
local cc = require("neo-tree.sources.common.commands")
local buffers = require("neo-tree.sources.buffers")
local utils = require("neo-tree.utils")
local manager = require("neo-tree.sources.manager")
---@class neotree.sources.Buffers.Commands : neotree.sources.Common.Commands
local M = {}
local refresh = utils.wrap(manager.refresh, "buffers")
local redraw = utils.wrap(manager.redraw, "buffers")
M.add = function(state)
cc.add(state, refresh)
end
M.add_directory = function(state)
cc.add_directory(state, refresh)
end
M.buffer_delete = function(state)
local node = state.tree:get_node()
if node then
if node.type == "message" then
return
end
vim.api.nvim_buf_delete(node.extra.bufnr, { force = false, unload = false })
refresh()
end
end
---Marks node as copied, so that it can be pasted somewhere else.
M.copy_to_clipboard = function(state)
cc.copy_to_clipboard(state, redraw)
end
---@type neotree.TreeCommandVisual
M.copy_to_clipboard_visual = function(state, selected_nodes)
cc.copy_to_clipboard_visual(state, selected_nodes, redraw)
end
---Marks node as cut, so that it can be pasted (moved) somewhere else.
M.cut_to_clipboard = function(state)
cc.cut_to_clipboard(state, redraw)
end
---@type neotree.TreeCommandVisual
M.cut_to_clipboard_visual = function(state, selected_nodes)
cc.cut_to_clipboard_visual(state, selected_nodes, redraw)
end
M.copy = function(state)
cc.copy(state, redraw)
end
M.move = function(state)
cc.move(state, redraw)
end
M.show_debug_info = cc.show_debug_info
---Pastes all items from the clipboard to the current directory.
M.paste_from_clipboard = function(state)
cc.paste_from_clipboard(state, refresh)
end
M.delete = function(state)
cc.delete(state, refresh)
end
---Navigate up one level.
M.navigate_up = function(state)
local parent_path, _ = utils.split_path(state.path)
buffers.navigate(state, parent_path)
end
M.refresh = refresh
M.rename = function(state)
cc.rename(state, refresh)
end
M.set_root = function(state)
local node = state.tree:get_node()
while node and node.type ~= "directory" do
local parent_id = node:get_parent_id()
node = parent_id and state.tree:get_node(parent_id) or nil
end
if not node then
return
end
buffers.navigate(state, node:get_id())
end
cc._add_common_commands(M)
return M

View file

@ -0,0 +1,58 @@
-- This file contains the built-in components. Each componment is a function
-- that takes the following arguments:
-- config: A table containing the configuration provided by the user
-- when declaring this component in their renderer config.
-- node: A NuiNode object for the currently focused node.
-- state: The current state of the source providing the items.
--
-- The function should return either a table, or a list of tables, each of which
-- contains the following keys:
-- text: The text to display for this item.
-- highlight: The highlight group to apply to this text.
local highlights = require("neo-tree.ui.highlights")
local common = require("neo-tree.sources.common.components")
local utils = require("neo-tree.utils")
---@alias neotree.Component.Buffers._Key
---|"name"
---@class neotree.Component.Buffers
---@field [1] neotree.Component.Buffers._Key|neotree.Component.Common._Key
---@type table<neotree.Component.Buffers._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.Buffers.Name : neotree.Component.Common.Name
---@param config neotree.Component.Buffers.Name
M.name = function(config, node, state)
local highlight = config.highlight or highlights.FILE_NAME_OPENED
local name = node.name
if node.type == "directory" then
if node:get_depth() == 1 then
highlight = highlights.ROOT_NAME
name = "OPEN BUFFERS in " .. name
else
highlight = highlights.DIRECTORY_NAME
end
elseif node.type == "terminal" then
if node:get_depth() == 1 then
highlight = highlights.ROOT_NAME
name = "TERMINALS"
else
highlight = highlights.FILE_NAME
end
elseif config.use_git_status_colors then
local git_status = state.components.git_status({}, node, state)
if git_status and git_status.highlight then
highlight = git_status.highlight
end
end
return {
text = name,
highlight = highlight,
}
end
return vim.tbl_deep_extend("force", common, M)

View file

@ -0,0 +1,215 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
local utils = require("neo-tree.utils")
local renderer = require("neo-tree.ui.renderer")
local items = require("neo-tree.sources.buffers.lib.items")
local events = require("neo-tree.events")
local manager = require("neo-tree.sources.manager")
local git = require("neo-tree.git")
---@class neotree.sources.Buffers : neotree.Source
local M = {
name = "buffers",
display_name = " 󰈚 Buffers ",
}
local wrap = function(func)
return utils.wrap(func, M.name)
end
local get_state = function()
return manager.get_state(M.name)
end
local follow_internal = function()
if vim.bo.filetype == "neo-tree" or vim.bo.filetype == "neo-tree-popup" then
return
end
local bufnr = vim.api.nvim_get_current_buf()
local path_to_reveal = manager.get_path_to_reveal(true) or tostring(bufnr)
local state = get_state()
if state.current_position == "float" then
return false
end
if not state.path then
return false
end
local window_exists = renderer.window_exists(state)
if window_exists then
local node = state.tree and state.tree:get_node()
if node then
if node:get_id() == path_to_reveal then
-- already focused
return false
end
end
renderer.focus_node(state, path_to_reveal, true)
end
end
M.follow = function()
if vim.fn.bufname(0) == "COMMIT_EDITMSG" then
return false
end
utils.debounce("neo-tree-buffer-follow", function()
return follow_internal()
end, 100, utils.debounce_strategy.CALL_LAST_ONLY)
end
local buffers_changed_internal = function()
for _, tabid in ipairs(vim.api.nvim_list_tabpages()) do
local state = manager.get_state(M.name, tabid)
if state.path and renderer.window_exists(state) then
items.get_opened_buffers(state)
if state.follow_current_file.enabled then
follow_internal()
end
end
end
end
---Calld by autocmd when any buffer is open, closed, renamed, etc.
M.buffers_changed = function()
utils.debounce(
"buffers_changed",
buffers_changed_internal,
100,
utils.debounce_strategy.CALL_LAST_ONLY
)
end
---Navigate to the given path.
---@param state neotree.State
---@param path string? Path to navigate to. If empty, will navigate to the cwd.
---@param path_to_reveal string?
---@param callback function?
---@param async boolean?
M.navigate = function(state, path, path_to_reveal, callback, async)
state.dirty = false
local path_changed = false
if path == nil then
path = vim.fn.getcwd()
end
if path ~= state.path then
state.path = path
path_changed = true
end
if path_to_reveal then
renderer.position.set(state, path_to_reveal)
end
items.get_opened_buffers(state)
if path_changed and state.bind_to_cwd then
vim.api.nvim_command("tcd " .. path)
end
if type(callback) == "function" then
vim.schedule(callback)
end
end
---@class neotree.Config.Buffers.Renderers : neotree.Config.Renderers
---@class (exact) neotree.Config.Buffers : neotree.Config.Source
---@field bind_to_cwd boolean?
---@field follow_current_file neotree.Config.Filesystem.FollowCurrentFile?
---@field group_empty_dirs boolean?
---@field show_unloaded boolean?
---@field terminals_first boolean?
---@field renderers neotree.Config.Buffers.Renderers?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.Buffers Configuration table containing any keys that the user wants to change from the defaults. May be empty to accept default values.
---@param global_config neotree.Config.Base
M.setup = function(config, global_config)
--Configure events for before_render
if config.before_render then
--convert to new event system
manager.subscribe(M.name, {
event = events.BEFORE_RENDER,
handler = function(state)
local this_state = get_state()
if state == this_state then
config.before_render(this_state)
end
end,
})
elseif global_config.enable_git_status then
manager.subscribe(M.name, {
event = events.BEFORE_RENDER,
handler = function(state)
local this_state = get_state()
if state == this_state then
state.git_status_lookup = git.status(state.git_base)
end
end,
})
manager.subscribe(M.name, {
event = events.GIT_EVENT,
handler = M.buffers_changed,
})
end
local refresh_events = {
events.VIM_BUFFER_ADDED,
events.VIM_BUFFER_DELETED,
}
if global_config.enable_refresh_on_write then
table.insert(refresh_events, events.VIM_BUFFER_CHANGED)
end
for _, e in ipairs(refresh_events) do
manager.subscribe(M.name, {
event = e,
handler = function(args)
if args.afile == "" or utils.is_real_file(args.afile) then
M.buffers_changed()
end
end,
})
end
if config.bind_to_cwd then
manager.subscribe(M.name, {
event = events.VIM_DIR_CHANGED,
handler = wrap(manager.dir_changed),
})
end
if global_config.enable_diagnostics then
manager.subscribe(M.name, {
event = events.STATE_CREATED,
handler = function(state)
state.diagnostics_lookup = utils.get_diagnostic_counts()
end,
})
manager.subscribe(M.name, {
event = events.VIM_DIAGNOSTIC_CHANGED,
handler = wrap(manager.diagnostics_changed),
})
end
--Configure event handlers for modified files
if global_config.enable_modified_markers then
manager.subscribe(M.name, {
event = events.VIM_BUFFER_MODIFIED_SET,
handler = wrap(manager.opened_buffers_changed),
})
end
-- Configure event handler for follow_current_file option
if config.follow_current_file.enabled then
manager.subscribe(M.name, {
event = events.VIM_BUFFER_ENTER,
handler = M.follow,
})
manager.subscribe(M.name, {
event = events.VIM_TERMINAL_ENTER,
handler = M.follow,
})
end
end
return M

View file

@ -0,0 +1,110 @@
local renderer = require("neo-tree.ui.renderer")
local utils = require("neo-tree.utils")
local file_items = require("neo-tree.sources.common.file-items")
local log = require("neo-tree.log")
local M = {}
---Get a table of all open buffers, along with all parent paths of those buffers.
---The paths are the keys of the table, and all the values are 'true'.
M.get_opened_buffers = function(state)
if state.loading then
return
end
state.loading = true
local context = file_items.create_context()
context.state = state
-- Create root folder
local root = file_items.create_item(context, state.path, "directory") --[[@as neotree.FileItem.Directory]]
root.name = vim.fn.fnamemodify(root.path, ":~")
root.loaded = true
root.search_pattern = state.search_pattern
context.folders[root.path] = root
local terminals = {}
local function add_buffer(bufnr, path)
local is_loaded = vim.api.nvim_buf_is_loaded(bufnr)
if is_loaded or state.show_unloaded then
local is_listed = vim.fn.buflisted(bufnr)
if is_listed == 1 then
if path == "" then
path = "[No Name]"
end
local success, item = pcall(file_items.create_item, context, path, "file", bufnr)
if success then
item.extra = {
bufnr = bufnr,
is_listed = is_listed,
}
else
log.error("Error creating item for " .. path .. ": " .. item)
end
end
end
end
local bufs = vim.api.nvim_list_bufs()
for _, b in ipairs(bufs) do
local path = vim.api.nvim_buf_get_name(b)
if vim.startswith(path, "term://") then
local name = path:match("term://(.*)//.*")
local abs_path = vim.fn.fnamemodify(name, ":p")
local has_title, title = pcall(vim.api.nvim_buf_get_var, b, "term_title")
local item = {
name = has_title and title or name,
ext = "terminal",
path = abs_path,
id = path,
type = "terminal",
loaded = true,
extra = {
bufnr = b,
is_listed = true,
},
}
if utils.is_subpath(state.path, abs_path) then
table.insert(terminals, item)
end
elseif path == "" then
add_buffer(b, path)
else
if #state.path > 1 then
-- make sure this is within the root path
if utils.is_subpath(state.path, path) then
add_buffer(b, path)
end
else
add_buffer(b, path)
end
end
end
local root_folders = { root }
if #terminals > 0 then
local terminal_root = {
name = "Terminals",
id = "Terminals",
ext = "terminal",
type = "terminal",
children = terminals,
loaded = true,
search_pattern = state.search_pattern,
}
context.folders["Terminals"] = terminal_root
if state.terminals_first then
table.insert(root_folders, 1, terminal_root)
else
table.insert(root_folders, terminal_root)
end
end
state.default_expanded_nodes = {}
for id, _ in pairs(context.folders) do
table.insert(state.default_expanded_nodes, id)
end
file_items.advanced_sort(root.children, state)
renderer.show_nodes(root_folders, state)
state.loading = false
end
return M

View file

@ -0,0 +1,965 @@
--This file should contain all commands meant to be used by mappings.
local fs_actions = require("neo-tree.sources.filesystem.lib.fs_actions")
local utils = require("neo-tree.utils")
local renderer = require("neo-tree.ui.renderer")
local events = require("neo-tree.events")
local inputs = require("neo-tree.ui.inputs")
local popups = require("neo-tree.ui.popups")
local log = require("neo-tree.log")
local help = require("neo-tree.sources.common.help")
local Preview = require("neo-tree.sources.common.preview")
local async = require("plenary.async")
local node_expander = require("neo-tree.sources.common.node_expander")
---@alias neotree.TreeCommandNormal fun(state: neotree.StateWithTree, ...: any)
---@alias neotree.TreeCommandVisual fun(state: neotree.StateWithTree, selected_nodes: NuiTree.Node[], ...: any)
---@alias neotree.TreeCommand neotree.TreeCommandNormal|neotree.TreeCommandVisual
---Gets the node parent folder
---@param state neotree.StateWithTree
---@return NuiTree.Node? node
local function get_folder_node(state)
local tree = state.tree
local node = tree:get_node()
local last_id = assert(node):get_id()
while node do
local insert_as_local = state.config.insert_as
local insert_as_global = require("neo-tree").config.window.insert_as
local use_parent
if insert_as_local then
use_parent = insert_as_local == "sibling"
else
use_parent = insert_as_global == "sibling"
end
local is_open_dir = node.type == "directory" and (node:is_expanded() or node.empty_expanded)
if use_parent and not is_open_dir then
return tree:get_node(node:get_parent_id())
end
if node.type == "directory" then
return node
end
local parent_id = node:get_parent_id()
if not parent_id or parent_id == last_id then
return node
else
last_id = parent_id
node = tree:get_node(parent_id)
end
end
end
---The using_root_directory is used to decide what part of the filename to show
-- the user when asking for a new filename to e.g. create, copy to or move to.
---@param state neotree.StateWithTree
---@return string root_path The root path from which the relative source path should be taken
local function get_using_root_directory(state)
-- default to showing only the basename of the path
local using_root_directory = get_folder_node(state):get_id()
local show_path = state.config.show_path
if show_path == "absolute" then
using_root_directory = ""
elseif show_path == "relative" then
using_root_directory = state.path
elseif show_path ~= nil and show_path ~= "none" then
log.warn(
'A neo-tree mapping was setup with a config.show_path option with invalid value: "'
.. show_path
.. '", falling back to its default: nil/"none"'
)
end
---TODO
---@diagnostic disable-next-line: return-type-mismatch
return using_root_directory
end
---@class neotree.sources.Common.Commands
---@field [string] neotree.TreeCommand
local M = {}
---Adds all missing common commands to the given module
---@param to_source_command_module table The commands module for a source
---@param pattern string? A pattern specifying which commands to add, nil to add all
M._add_common_commands = function(to_source_command_module, pattern)
for name, func in pairs(M) do
if
type(name) == "string"
and not to_source_command_module[name]
and (not pattern or name:find(pattern))
and not name:find("^_")
then
to_source_command_module[name] = func
end
end
end
---Add a new file or dir at the current node
---@param callback function The callback to call when the command is done. Called with the parent node as the argument.
M.add = function(state, callback)
local node = get_folder_node(state)
if not node then
return
end
local in_directory = node:get_id()
local using_root_directory = get_using_root_directory(state)
fs_actions.create_node(in_directory, callback, using_root_directory)
end
---Add a new file or dir at the current node
---@param callback function The callback to call when the command is done. Called with the parent node as the argument.
M.add_directory = function(state, callback)
local node = get_folder_node(state)
if not node then
return
end
local in_directory = node:get_id()
local using_root_directory = get_using_root_directory(state)
fs_actions.create_directory(in_directory, callback, using_root_directory)
end
---Expand all nodes
---@param node table? A single node to expand (defaults to all root nodes)
---@param prefetcher table? an object with two methods `prefetch(state, node)` and `should_prefetch(node) => boolean`
M.expand_all_nodes = function(state, node, prefetcher)
local root_nodes = node and { node } or state.tree:get_nodes()
renderer.position.set(state, nil)
local task = function()
for _, root in pairs(root_nodes) do
log.debug("Expanding all nodes under " .. root:get_id())
node_expander.expand_directory_recursively(state, root, prefetcher)
end
end
async.run(task, function()
log.debug("All nodes expanded - redrawing")
renderer.redraw(state)
end)
end
---Expand all subnodes
---@param node table? A single node to expand (defaults to node under the cursor)
---@param prefetcher table? an object with two methods `prefetch(state, node)` and `should_prefetch(node) => boolean`
M.expand_all_subnodes = function(state, node, prefetcher)
M.expand_all_nodes(state, node or state.tree:get_node(), prefetcher)
end
---@param callback function
M.close_node = function(state, callback)
local tree = state.tree
local node = assert(tree:get_node())
local parent_node = tree:get_node(node:get_parent_id())
local target_node
if node:has_children() and node:is_expanded() then
target_node = node
else
target_node = parent_node
end
assert(target_node, "no node found to close")
local root = tree:get_nodes()[1]
local is_root = target_node:get_id() == root:get_id()
if target_node:has_children() and not is_root then
target_node:collapse()
renderer.redraw(state)
renderer.focus_node(state, target_node:get_id())
if state.explicitly_opened_nodes and state.explicitly_opened_nodes[target_node:get_id()] then
state.explicitly_opened_nodes[target_node:get_id()] = false
end
end
end
M.close_all_subnodes = function(state)
local tree = state.tree
local node = assert(tree:get_node())
local parent_node = assert(tree:get_node(node:get_parent_id()))
local target_node
if node:has_children() and node:is_expanded() then
target_node = node
else
target_node = parent_node
end
renderer.collapse_all_nodes(tree, target_node:get_id())
renderer.redraw(state)
renderer.focus_node(state, target_node:get_id())
if state.explicitly_opened_nodes and state.explicitly_opened_nodes[target_node:get_id()] then
state.explicitly_opened_nodes[target_node:get_id()] = false
end
end
---@param state neotree.State
M.close_all_nodes = function(state)
state.explicitly_opened_nodes = {}
renderer.collapse_all_nodes(state.tree)
renderer.redraw(state)
end
---@param state neotree.State
M.close_window = function(state)
renderer.close(state)
end
---@param state neotree.State
M.toggle_auto_expand_width = function(state)
if state.window.position == "float" then
return
end
state.window.auto_expand_width = state.window.auto_expand_width == false
local width = utils.resolve_width(state.window.width)
if not state.window.auto_expand_width then
if (state.window.last_user_width or width) >= vim.api.nvim_win_get_width(0) then
state.window.last_user_width = width
end
vim.api.nvim_win_set_width(0, state.window.last_user_width)
state.win_width = state.window.last_user_width
state.longest_width_exact = 0
log.trace(string.format("Collapse auto_expand_width."))
end
renderer.redraw(state)
end
---@param state neotree.State
local copy_node_to_clipboard = function(state, node)
state.clipboard = state.clipboard or {}
local existing = state.clipboard[node.id]
if existing and existing.action == "copy" then
state.clipboard[node.id] = nil
else
state.clipboard[node.id] = { action = "copy", node = node }
log.info("Copied " .. node.name .. " to clipboard")
end
end
---Marks node as copied, so that it can be pasted somewhere else.
---@param state neotree.State
M.copy_to_clipboard = function(state, callback)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
copy_node_to_clipboard(state, node)
if callback then
callback()
end
end
---@type neotree.TreeCommandVisual
M.copy_to_clipboard_visual = function(state, selected_nodes, callback)
for _, node in ipairs(selected_nodes) do
if node.type ~= "message" then
copy_node_to_clipboard(state, node)
end
end
if callback then
callback()
end
end
---@param state neotree.State
---@param node NuiTree.Node
local cut_node_to_clipboard = function(state, node)
state.clipboard = state.clipboard or {}
local existing = state.clipboard[node.id]
if existing and existing.action == "cut" then
state.clipboard[node.id] = nil
else
state.clipboard[node.id] = { action = "cut", node = node }
log.info("Cut " .. node.name .. " to clipboard")
end
end
---Marks node as cut, so that it can be pasted (moved) somewhere else.
M.cut_to_clipboard = function(state, callback)
local node = assert(state.tree:get_node())
cut_node_to_clipboard(state, node)
if callback then
callback()
end
end
---@type neotree.TreeCommandVisual
M.cut_to_clipboard_visual = function(state, selected_nodes, callback)
for _, node in ipairs(selected_nodes) do
if node.type ~= "message" then
cut_node_to_clipboard(state, node)
end
end
if callback then
callback()
end
end
--------------------------------------------------------------------------------
-- Git commands
--------------------------------------------------------------------------------
---@param state neotree.State
M.git_add_file = function(state)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
local path = node:get_id()
local cmd = { "git", "add", path }
vim.fn.system(cmd)
events.fire_event(events.GIT_EVENT)
end
---@param state neotree.State
M.git_add_all = function(state)
local cmd = { "git", "add", "-A" }
vim.fn.system(cmd)
events.fire_event(events.GIT_EVENT)
end
---@param state neotree.State
M.git_commit = function(state, and_push)
local width = vim.fn.winwidth(0) - 2
local row = vim.api.nvim_win_get_height(0) - 3
local popup_options = {
relative = "win",
position = {
row = row,
col = 0,
},
size = width,
}
inputs.input("Commit message: ", "", function(msg)
local cmd = { "git", "commit", "-m", msg }
local title = "git commit"
local result = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 or (#result > 0 and vim.startswith(result[1], "fatal:")) then
popups.alert("ERROR: git commit", result)
return
end
if and_push then
title = "git commit && git push"
cmd = { "git", "push" }
local result2 = vim.fn.systemlist(cmd)
table.insert(result, "")
for i = 1, #result2 do
table.insert(result, result2[i])
end
end
events.fire_event(events.GIT_EVENT)
popups.alert(title, result)
end, popup_options)
end
M.git_commit_and_push = function(state)
M.git_commit(state, true)
end
M.git_push = function(state)
inputs.confirm("Are you sure you want to push your changes?", function(yes)
if yes then
local result = vim.fn.systemlist({ "git", "push" })
events.fire_event(events.GIT_EVENT)
popups.alert("git push", result)
end
end)
end
M.git_unstage_file = function(state)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
local path = node:get_id()
local cmd = { "git", "reset", "--", path }
vim.fn.system(cmd)
events.fire_event(events.GIT_EVENT)
end
M.git_undo_last_commit = function(state)
inputs.confirm("Are you sure you want to undo the last commit? (keeps changes)", function(yes)
if yes then
local cmd = { "git", "reset", "--soft", "HEAD~1" }
local result = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 then
popups.alert("ERROR: git reset --soft HEAD~1", result)
return
end
events.fire_event(events.GIT_EVENT)
popups.alert(
"git reset --soft HEAD~1",
{ "Last commit undone successfully", "Changes kept in staging area" }
)
end
end)
end
M.git_revert_file = function(state)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
local path = node:get_id()
local cmd = { "git", "checkout", "HEAD", "--", path }
local msg = string.format("Are you sure you want to revert %s?", node.name)
inputs.confirm(msg, function(yes)
if yes then
vim.fn.system(cmd)
events.fire_event(events.GIT_EVENT)
end
end)
end
--------------------------------------------------------------------------------
-- END Git commands
--------------------------------------------------------------------------------
local get_sources = function()
local config = require("neo-tree").config
return config.source_selector.sources or config.sources
end
M.next_source = function(state)
local sources = get_sources()
local next_source = sources[1]
for i, source_info in ipairs(sources) do
if source_info.source == state.name then
next_source = sources[i + 1]
if not next_source then
next_source = sources[1]
end
break
end
end
require("neo-tree.command").execute({
source = next_source.source,
position = state.current_position,
action = "focus",
})
end
M.prev_source = function(state)
local sources = get_sources()
local next_source = sources[#sources]
for i, source_info in ipairs(sources) do
if source_info.source == state.name then
next_source = sources[i - 1]
if not next_source then
next_source = sources[#sources]
end
break
end
end
require("neo-tree.command").execute({
source = next_source.source,
position = state.current_position,
action = "focus",
})
end
local function set_sort(state, label)
local sort = state.sort or { label = "Name", direction = -1 }
if sort.label == label then
sort.direction = sort.direction * -1
else
sort.label = label
sort.direction = -1
end
state.sort = sort
end
M.order_by_created = function(state)
set_sort(state, "Created")
state.sort_field_provider = function(node)
local stat = utils.get_stat(node)
return stat.birthtime and stat.birthtime.sec or 0
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_modified = function(state)
set_sort(state, "Last Modified")
state.sort_field_provider = function(node)
local stat = utils.get_stat(node)
return stat.mtime and stat.mtime.sec or 0
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_name = function(state)
set_sort(state, "Name")
local config = require("neo-tree").config
if config.sort_case_insensitive then
state.sort_field_provider = function(node)
return node.path:lower()
end
else
state.sort_field_provider = function(node)
return node.path
end
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_size = function(state)
set_sort(state, "Size")
state.sort_field_provider = function(node)
local stat = utils.get_stat(node)
return stat.size or 0
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_type = function(state)
set_sort(state, "Type")
state.sort_field_provider = function(node)
return node.ext or node.type
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_git_status = function(state)
set_sort(state, "Git Status")
state.sort_field_provider = function(node)
local git_status_lookup = state.git_status_lookup or {}
local git_status = git_status_lookup[node.path]
if git_status then
return git_status
end
if node.filtered_by and node.filtered_by.gitignored then
return "!!"
else
return ""
end
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.order_by_diagnostics = function(state)
set_sort(state, "Diagnostics")
state.sort_field_provider = function(node)
local diag = state.diagnostics_lookup or {}
local diagnostics = diag[node.path]
if not diagnostics then
return 0
end
if not diagnostics.severity_number then
return 0
end
-- lower severity number means higher severity
return 5 - diagnostics.severity_number
end
require("neo-tree.sources.manager").refresh(state.name)
end
M.show_debug_info = function(state)
print(vim.inspect(state))
end
local default_filetime_format = "%Y-%m-%d %I:%M %p"
M.show_file_details = function(state)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
local stat = utils.get_stat(node)
local left = {}
local right = {}
table.insert(left, "Name")
table.insert(right, node.name)
table.insert(left, "Path")
table.insert(right, node:get_id())
table.insert(left, "Type")
table.insert(right, node.type)
if stat.size then
table.insert(left, "Size")
table.insert(right, utils.human_size(stat.size))
table.insert(left, "Created")
local created_format = state.config.created_format or default_filetime_format
table.insert(right, utils.date(created_format, stat.birthtime.sec))
table.insert(left, "Modified")
local modified_format = state.config.modified_format or default_filetime_format
table.insert(right, utils.date(modified_format, stat.mtime.sec))
end
local lines = {}
for i, v in ipairs(left) do
local line = string.format("%9s: %s", v, right[i])
table.insert(lines, line)
end
popups.alert("File Details", lines)
end
---Pastes all items from the clipboard to the current directory.
---@param callback fun(node: NuiTree.Node?, destination: string) The callback to call when the command is done. Called with the parent node as the argument.
M.paste_from_clipboard = function(state, callback)
if state.clipboard then
local folder = get_folder_node(state):get_id()
-- Convert to list so to make it easier to pop items from the stack.
local clipboard_list = {}
for _, item in pairs(state.clipboard) do
table.insert(clipboard_list, item)
end
state.clipboard = nil
local handle_next_paste, paste_complete
paste_complete = function(source, destination)
if callback then
local insert_as = require("neo-tree").config.window.insert_as
-- open the folder so the user can see the new files
local node = insert_as == "sibling" and state.tree:get_node() or state.tree:get_node(folder)
if not node then
log.warn("Could not find node for " .. folder)
end
callback(node, destination)
end
local next_item = table.remove(clipboard_list)
if next_item then
handle_next_paste(next_item)
end
end
handle_next_paste = function(item)
if item.action == "copy" then
fs_actions.copy_node(
item.node.path,
folder .. utils.path_separator .. item.node.name,
paste_complete
)
elseif item.action == "cut" then
fs_actions.move_node(
item.node.path,
folder .. utils.path_separator .. item.node.name,
paste_complete
)
end
end
local next_item = table.remove(clipboard_list)
if next_item then
handle_next_paste(next_item)
end
end
end
---Copies a node to a new location, using typed input.
---@param callback fun(parent_node: NuiTree.Node)
M.copy = function(state, callback)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
local using_root_directory = get_using_root_directory(state)
fs_actions.copy_node(node.path, nil, callback, using_root_directory)
end
---Moves a node to a new location, using typed input.
---@param callback fun(parent_node: NuiTree.Node)
M.move = function(state, callback)
local node = assert(state.tree:get_node())
if node.type == "message" then
return
end
local using_root_directory = get_using_root_directory(state)
fs_actions.move_node(node.path, nil, callback, using_root_directory)
end
M.delete = function(state, callback)
local node = assert(state.tree:get_node())
if node.type ~= "file" and node.type ~= "directory" then
log.warn("The `delete` command can only be used on files and directories")
return
end
if node:get_depth() == 1 then
log.error(
"Will not delete root node "
.. node.path
.. ", please back out of the current directory if you want to delete the root node."
)
return
end
fs_actions.delete_node(node.path, callback)
end
---@param callback function
---@type neotree.TreeCommandVisual
M.delete_visual = function(state, selected_nodes, callback)
local paths_to_delete = {}
for _, node_to_delete in pairs(selected_nodes) do
if node_to_delete:get_depth() == 1 then
log.error(
"Will not delete root node "
.. node_to_delete.path
.. ", please back out of the current directory if you want to delete the root node."
)
return
end
if node_to_delete.type == "file" or node_to_delete.type == "directory" then
table.insert(paths_to_delete, node_to_delete.path)
end
end
fs_actions.delete_nodes(paths_to_delete, callback)
end
M.preview = function(state)
Preview.show(state)
end
M.revert_preview = function()
Preview.hide()
end
--
-- Multi-purpose function to back out of whatever we are in
M.cancel = function(state)
if Preview.is_active() then
Preview.hide()
else
if state.current_position == "float" then
renderer.close_all_floating_windows()
end
end
end
M.toggle_preview = function(state)
Preview.toggle(state)
end
M.scroll_preview = function(state)
Preview.scroll(state)
end
M.focus_preview = function(state)
if Preview.is_active() then
Preview.focus()
else
vim.api.nvim_win_call(state.winid, function()
vim.api.nvim_feedkeys(state.fallback, "n", false)
end)
end
end
---Expands or collapses the current node.
M.toggle_node = function(state, toggle_directory)
local tree = state.tree
local node = assert(tree:get_node())
if not utils.is_expandable(node) then
return
end
if node.type == "directory" and toggle_directory then
toggle_directory(node)
elseif node:has_children() then
local updated = false
if node:is_expanded() then
updated = node:collapse()
else
updated = node:expand()
end
if updated then
renderer.redraw(state)
end
end
end
---Expands or collapses the current node.
M.toggle_directory = function(state, toggle_directory)
local tree = state.tree
local node = assert(tree:get_node())
if node.type ~= "directory" then
return
end
M.toggle_node(state, toggle_directory)
end
---Open file or expandable node
---@param open_cmd string The vim command to use to open the file
---@param toggle_directory function The function to call to toggle a directory
---open/closed
local open_with_cmd = function(state, open_cmd, toggle_directory, open_file)
local tree = state.tree
local success, node = pcall(tree.get_node, tree)
if not (success and node) then
log.debug("Could not get node.")
return
end
local function open()
M.revert_preview()
local path = node.path or node:get_id()
local bufnr = node.extra and node.extra.bufnr
if node.type == "terminal" then
path = node:get_id()
end
if type(open_file) == "function" then
open_file(state, path, open_cmd, bufnr)
else
utils.open_file(state, path, open_cmd, bufnr)
end
local extra = node.extra or {}
local pos = extra.position or extra.end_position
if pos ~= nil then
vim.api.nvim_win_set_cursor(0, { (pos[1] or 0) + 1, pos[2] or 0 })
vim.api.nvim_win_call(0, function()
vim.cmd("normal! zvzz") -- expand folds and center cursor
end)
end
end
local config = state.config or {}
if node.type == "file" and config.no_expand_file ~= nil then
log.warn("`no_expand_file` options is deprecated, move to `expand_nested_files` (OPPOSITE)")
config.expand_nested_files = not config.no_expand_file
end
local should_expand_file = config.expand_nested_files and not node:is_expanded()
if utils.is_expandable(node) and (node.type ~= "file" or should_expand_file) then
M.toggle_node(state, toggle_directory)
else
open()
end
end
---Open file or directory in the closest window
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open = function(state, toggle_directory)
open_with_cmd(state, "e", toggle_directory)
end
---Open file or directory in a split of the closest window
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_split = function(state, toggle_directory)
open_with_cmd(state, "split", toggle_directory)
end
---Open file or directory in a vertical split of the closest window
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_vsplit = function(state, toggle_directory)
open_with_cmd(state, "vsplit", toggle_directory)
end
---Open file or directory in a right below vertical split of the closest window
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_rightbelow_vs = function(state, toggle_directory)
open_with_cmd(state, "rightbelow vs", toggle_directory)
end
---Open file or directory in a left above vertical split of the closest window
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_leftabove_vs = function(state, toggle_directory)
open_with_cmd(state, "leftabove vs", toggle_directory)
end
---Open file or directory in a new tab
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_tabnew = function(state, toggle_directory)
open_with_cmd(state, "tabnew", toggle_directory)
end
---Open file or directory or focus it if a buffer already exists with it
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_drop = function(state, toggle_directory)
open_with_cmd(state, "drop", toggle_directory)
end
---Open file or directory in new tab or focus it if a buffer already exists with it
---@param toggle_directory function The function to call to toggle a directory
---open/closed
M.open_tab_drop = function(state, toggle_directory)
open_with_cmd(state, "tab drop", toggle_directory)
end
M.rename = function(state, callback)
local tree = state.tree
local node = assert(tree:get_node())
if node.type == "message" then
return
end
fs_actions.rename_node(node.path, callback)
end
M.rename_basename = function(state, callback)
local tree = state.tree
local node = assert(tree:get_node())
if node.type == "message" then
return
end
fs_actions.rename_node_basename(node.path, callback)
end
---Marks potential windows with letters and will open the give node in the picked window.
---@param state neotree.State
---@param path string The path to open
---@param cmd string Command that is used to perform action on picked window
local use_window_picker = function(state, path, cmd)
local success, picker = pcall(require, "window-picker")
if not success then
print(
"You'll need to install window-picker to use this command: https://github.com/s1n7ax/nvim-window-picker"
)
return
end
local events = require("neo-tree.events")
local event_result = events.fire_event(events.FILE_OPEN_REQUESTED, {
state = state,
path = path,
open_cmd = cmd,
}) or {}
if event_result.handled then
events.fire_event(events.FILE_OPENED, path)
return
end
local picked_window_id = picker.pick_window()
if picked_window_id then
vim.api.nvim_set_current_win(picked_window_id)
---@diagnostic disable-next-line: param-type-mismatch
local result, err = pcall(vim.cmd, cmd .. " " .. vim.fn.fnameescape(path))
if result or err == "Vim(edit):E325: ATTENTION" then
-- fixes #321
vim.bo[0].buflisted = true
events.fire_event(events.FILE_OPENED, path)
else
log.error("Error opening file:", err)
end
end
end
---Marks potential windows with letters and will open the give node in the picked window.
M.open_with_window_picker = function(state, toggle_directory)
open_with_cmd(state, "edit", toggle_directory, use_window_picker)
end
---Marks potential windows with letters and will open the give node in a split next to the picked window.
M.split_with_window_picker = function(state, toggle_directory)
open_with_cmd(state, "split", toggle_directory, use_window_picker)
end
---Marks potential windows with letters and will open the give node in a vertical split next to the picked window.
M.vsplit_with_window_picker = function(state, toggle_directory)
open_with_cmd(state, "vsplit", toggle_directory, use_window_picker)
end
M.show_help = function(state)
local title = state.config and state.config.title or nil
local prefix_key = state.config and state.config.prefix_key or nil
help.show(state, title, prefix_key)
end
return M

View file

@ -0,0 +1,720 @@
-- This file contains the built-in components. Each componment is a function
-- that takes the following arguments:
-- config: A table containing the configuration provided by the user
-- when declaring this component in their renderer config.
-- node: A NuiNode object for the currently focused node.
-- state: The current state of the source providing the items.
--
-- The function should return either a table, or a list of tables, each of which
-- contains the following keys:
-- text: The text to display for this item.
-- highlight: The highlight group to apply to this text.
local highlights = require("neo-tree.ui.highlights")
local utils = require("neo-tree.utils")
local file_nesting = require("neo-tree.sources.common.file-nesting")
local container = require("neo-tree.sources.common.container")
local nt = require("neo-tree")
---@alias neotree.Component.Common._Key
---|"bufnr"
---|"clipboard"
---|"container"
---|"current_filter"
---|"diagnostics"
---|"git_status"
---|"filtered_by"
---|"icon"
---|"modified"
---|"name"
---|"indent"
---|"file_size"
---|"last_modified"
---|"created"
---|"symlink_target"
---|"type"
---@class neotree.Component.Common Use the neotree.Component.Common.* types to get more specific types.
---@field [1] neotree.Component.Common._Key
---@type table<neotree.Component.Common._Key, neotree.FileRenderer>
local M = {}
local make_two_char = function(symbol)
if vim.fn.strchars(symbol) == 1 then
return symbol .. " "
else
return symbol
end
end
---@class (exact) neotree.Component.Common.Bufnr : neotree.Component
---@field [1] "bufnr"?
-- Config fields below:
-- only works in the buffers component, but it's here so we don't have to defined
-- multple renderers.
---@param config neotree.Component.Common.Bufnr
M.bufnr = function(config, node, _)
local highlight = config.highlight or highlights.BUFFER_NUMBER
local bufnr = node.extra and node.extra.bufnr
if not bufnr then
return {}
end
return {
text = string.format("#%s", bufnr),
highlight = highlight,
}
end
---@class (exact) neotree.Component.Common.Clipboard : neotree.Component
---@field [1] "clipboard"?
---@param config neotree.Component.Common.Clipboard
M.clipboard = function(config, node, state)
local clipboard = state.clipboard or {}
local clipboard_state = clipboard[node:get_id()]
if not clipboard_state then
return {}
end
return {
text = " (" .. clipboard_state.action .. ")",
highlight = config.highlight or highlights.DIM_TEXT,
}
end
---@class (exact) neotree.Component.Common.Container : neotree.Component
---@field [1] "container"?
---@field left_padding integer?
---@field right_padding integer?
---@field enable_character_fade boolean?
---@field content (neotree.Component|{zindex: number, align: "left"|"right"|nil})[]?
M.container = container.render
---@class (exact) neotree.Component.Common.CurrentFilter : neotree.Component
---@field [1] "current_filter"
---@param config neotree.Component.Common.CurrentFilter
M.current_filter = function(config, node, _)
local filter = node.search_pattern or ""
if filter == "" then
return {}
end
return {
{
text = "Find",
highlight = highlights.DIM_TEXT,
},
{
text = string.format('"%s"', filter),
highlight = config.highlight or highlights.FILTER_TERM,
},
{
text = "in",
highlight = highlights.DIM_TEXT,
},
}
end
---`sign_getdefined` based wrapper with compatibility
---@param severity string
---@return vim.fn.sign_getdefined.ret.item
local get_legacy_sign = function(severity)
local sign = vim.fn.sign_getdefined("DiagnosticSign" .. severity)
if vim.tbl_isempty(sign) then
-- backwards compatibility...
local old_severity = severity
if severity == "Warning" then
old_severity = "Warn"
elseif severity == "Information" then
old_severity = "Info"
end
sign = vim.fn.sign_getdefined("LspDiagnosticsSign" .. old_severity)
end
return sign and sign[1]
end
local nvim_0_10 = vim.fn.has("nvim-0.10") > 0
---Returns the sign corresponding to the given severity
---@param severity string
---@return vim.fn.sign_getdefined.ret.item
local function get_diagnostic_sign(severity)
local sign
if nvim_0_10 then
local signs = vim.diagnostic.config().signs
if type(signs) == "function" then
--TODO: Find a better way to get a namespace
local namespaces = vim.diagnostic.get_namespaces()
if not vim.tbl_isempty(namespaces) then
local ns_id = next(namespaces)
---@cast ns_id -nil
signs = signs(ns_id, 0)
end
end
if type(signs) == "table" then
local identifier = severity:sub(1, 1)
if identifier == "H" then
identifier = "N"
end
sign = {
text = (signs.text or {})[vim.diagnostic.severity[identifier]],
texthl = "DiagnosticSign" .. severity,
}
elseif signs == true then
sign = get_legacy_sign(severity)
end
else -- before 0.10
sign = get_legacy_sign(severity)
end
if type(sign) ~= "table" then
sign = {}
end
return sign
end
---@class (exact) neotree.Component.Common.Diagnostics : neotree.Component
---@field [1] "diagnostics"?
---@field errors_only boolean?
---@field hide_when_expanded boolean?
---@field symbols table<string, string>?
---@field highlights table<string, string>?
---@param config neotree.Component.Common.Diagnostics
M.diagnostics = function(config, node, state)
local diag = state.diagnostics_lookup or {}
local diag_state = utils.index_by_path(diag, node:get_id())
if config.hide_when_expanded and node.type == "directory" and node:is_expanded() then
return {}
end
if not diag_state then
return {}
end
if config.errors_only and diag_state.severity_number > 1 then
return {}
end
---@type string
local severity = diag_state.severity_string
local sign = get_diagnostic_sign(severity)
-- check for overrides in the component config
local severity_lower = severity:lower()
if config.symbols and config.symbols[severity_lower] then
sign.texthl = sign.texthl or ("Diagnostic" .. severity)
sign.text = config.symbols[severity_lower]
end
if config.highlights and config.highlights[severity_lower] then
sign.text = sign.text or severity:sub(1, 1)
sign.texthl = config.highlights[severity_lower]
end
if sign.text and sign.texthl then
return {
text = make_two_char(sign.text),
highlight = sign.texthl,
}
else
return {
text = severity:sub(1, 1),
highlight = "Diagnostic" .. severity,
}
end
end
---@class (exact) neotree.Component.Common.GitStatus : neotree.Component
---@field [1] "git_status"?
---@field hide_when_expanded boolean?
---@field symbols table<string, string>?
---@param config neotree.Component.Common.GitStatus
M.git_status = function(config, node, state)
local git_status_lookup = state.git_status_lookup
if config.hide_when_expanded and node.type == "directory" and node:is_expanded() then
return {}
end
if not git_status_lookup then
return {}
end
local git_status = git_status_lookup[node.path]
if not git_status then
if node.filtered_by and node.filtered_by.gitignored then
git_status = "!!"
else
return {}
end
end
local symbols = config.symbols or {}
local change_symbol
local change_highlt = highlights.FILE_NAME
---@type string?
local status_symbol = symbols.staged
local status_highlt = highlights.GIT_STAGED
if node.type == "directory" and git_status:len() == 1 then
status_symbol = nil
end
if git_status:sub(1, 1) == " " then
status_symbol = symbols.unstaged
status_highlt = highlights.GIT_UNSTAGED
end
if git_status:match("?$") then
status_symbol = nil
status_highlt = highlights.GIT_UNTRACKED
change_symbol = symbols.untracked
change_highlt = highlights.GIT_UNTRACKED
-- all variations of merge conflicts
elseif git_status == "DD" then
status_symbol = symbols.conflict
status_highlt = highlights.GIT_CONFLICT
change_symbol = symbols.deleted
change_highlt = highlights.GIT_CONFLICT
elseif git_status == "UU" then
status_symbol = symbols.conflict
status_highlt = highlights.GIT_CONFLICT
change_symbol = symbols.modified
change_highlt = highlights.GIT_CONFLICT
elseif git_status == "AA" then
status_symbol = symbols.conflict
status_highlt = highlights.GIT_CONFLICT
change_symbol = symbols.added
change_highlt = highlights.GIT_CONFLICT
elseif git_status:match("U") then
status_symbol = symbols.conflict
status_highlt = highlights.GIT_CONFLICT
if git_status:match("A") then
change_symbol = symbols.added
elseif git_status:match("D") then
change_symbol = symbols.deleted
end
change_highlt = highlights.GIT_CONFLICT
-- end merge conflict section
elseif git_status:match("M") then
change_symbol = symbols.modified
change_highlt = highlights.GIT_MODIFIED
elseif git_status:match("R") then
change_symbol = symbols.renamed
change_highlt = highlights.GIT_RENAMED
elseif git_status:match("[ACT]") then
change_symbol = symbols.added
change_highlt = highlights.GIT_ADDED
elseif git_status:match("!") then
status_symbol = nil
change_symbol = symbols.ignored
change_highlt = highlights.GIT_IGNORED
elseif git_status:match("D") then
change_symbol = symbols.deleted
change_highlt = highlights.GIT_DELETED
end
if change_symbol or status_symbol then
local components = {}
if type(change_symbol) == "string" and #change_symbol > 0 then
table.insert(components, {
text = make_two_char(change_symbol),
highlight = change_highlt,
})
end
if type(status_symbol) == "string" and #status_symbol > 0 then
table.insert(components, {
text = make_two_char(status_symbol),
highlight = status_highlt,
})
end
return components
else
return {
text = "[" .. git_status .. "]",
highlight = config.highlight or change_highlt,
}
end
end
---@class neotree.Component.Common.FilteredBy
---@field [1] "filtered_by"?
M.filtered_by = function(_, node, state)
local fby = node.filtered_by
if not state.filtered_items or type(fby) ~= "table" then
return {}
end
repeat
if fby.name then
return {
text = "(hide by name)",
highlight = highlights.HIDDEN_BY_NAME,
}
elseif fby.pattern then
return {
text = "(hide by pattern)",
highlight = highlights.HIDDEN_BY_NAME,
}
elseif fby.gitignored then
return {
text = "(gitignored)",
highlight = highlights.GIT_IGNORED,
}
elseif fby.dotfiles then
return {
text = "(dotfile)",
highlight = highlights.DOTFILE,
}
elseif fby.hidden then
return {
text = "(hidden)",
highlight = highlights.WINDOWS_HIDDEN,
}
end
fby = fby.parent
until not state.filtered_items.children_inherit_highlights or not fby
return {}
end
---@class (exact) neotree.Component.Common.Icon : neotree.Component
---@field [1] "icon"?
---@field default string The default icon for a node.
---@field folder_empty string The string to display to represent an empty folder.
---@field folder_empty_open string The icon to display to represent an empty but open folder.
---@field folder_open string The icon to display for an open folder.
---@field folder_closed string The icon to display for a closed folder.
---@field provider neotree.IconProvider?
---@param config neotree.Component.Common.Icon
M.icon = function(config, node, state)
-- calculate default icon
---@type neotree.Render.Node
local icon =
{ text = config.default or " ", highlight = config.highlight or highlights.FILE_ICON }
if node.type == "directory" then
icon.highlight = highlights.DIRECTORY_ICON
if node.loaded and not node:has_children() then
icon.text = not node.empty_expanded and config.folder_empty or config.folder_empty_open
elseif node:is_expanded() then
icon.text = config.folder_open or "-"
else
icon.text = config.folder_closed or "+"
end
end
-- use icon provider if available
if config.provider then
icon = config.provider(icon, node, state) or icon
end
local filtered_by = M.filtered_by(config, node, state)
icon.text = icon.text .. " " -- add padding
icon.highlight = filtered_by.highlight or icon.highlight -- prioritize filtered highlighting
return icon
end
---@class (exact) neotree.Component.Common.Modified : neotree.Component
---@field [1] "modified"?
---@field symbol string?
---@param config neotree.Component.Common.Modified
M.modified = function(config, node, state)
local opened_buffers = state.opened_buffers or {}
local buf_info = utils.index_by_path(opened_buffers, node.path)
if buf_info and buf_info.modified then
return {
text = (make_two_char(config.symbol) or "[+]"),
highlight = config.highlight or highlights.MODIFIED,
}
else
return {}
end
end
---@class (exact) neotree.Component.Common.Name : neotree.Component
---@field [1] "name"?
---@field trailing_slash boolean?
---@field use_git_status_colors boolean?
---@field highlight_opened_files boolean|"all"?
---@field right_padding integer?
---@param config neotree.Component.Common.Name
M.name = function(config, node, state)
local highlight = config.highlight or highlights.FILE_NAME
local text = node.name
if node.type == "directory" then
highlight = highlights.DIRECTORY_NAME
if config.trailing_slash and text ~= "/" then
text = text .. "/"
end
end
if node:get_depth() == 1 and node.type ~= "message" then
highlight = highlights.ROOT_NAME
if state.current_position == "current" and state.sort and state.sort.label == "Name" then
local icon = state.sort.direction == 1 and "" or ""
text = text .. " " .. icon
end
else
local filtered_by = M.filtered_by(config, node, state)
highlight = filtered_by.highlight or highlight
if config.use_git_status_colors then
local git_status = state.components.git_status({}, node, state)
if git_status and git_status.highlight then
highlight = git_status.highlight
end
end
end
local hl_opened = config.highlight_opened_files
if hl_opened then
local opened_buffers = state.opened_buffers or {}
if
(hl_opened == "all" and opened_buffers[node.path])
or (opened_buffers[node.path] and opened_buffers[node.path].loaded)
then
highlight = highlights.FILE_NAME_OPENED
end
end
if type(config.right_padding) == "number" then
if config.right_padding > 0 then
text = text .. string.rep(" ", config.right_padding)
end
else
text = text
end
return {
text = text,
highlight = highlight,
}
end
---@class (exact) neotree.Component.Common.Indent : neotree.Component
---@field [1] "indent"?
---@field expander_collapsed string?
---@field expander_expanded string?
---@field expander_highlight string?
---@field indent_marker string?
---@field indent_size integer?
---@field last_indent_marker string?
---@field padding integer?
---@field with_expanders boolean?
---@field with_markers boolean?
---@param config neotree.Component.Common.Indent
M.indent = function(config, node, state)
if not state.skip_marker_at_level then
state.skip_marker_at_level = {}
end
local strlen = vim.fn.strdisplaywidth
local skip_marker = state.skip_marker_at_level
---@cast skip_marker -nil
local indent_size = config.indent_size or 2
local padding = config.padding or 0
local level = node.level
local with_markers = config.with_markers
local with_expanders = config.with_expanders == nil and file_nesting.is_enabled()
or config.with_expanders
local marker_highlight = config.highlight or highlights.INDENT_MARKER
local expander_highlight = config.expander_highlight or config.highlight or highlights.EXPANDER
local function get_expander()
if with_expanders and utils.is_expandable(node) then
return node:is_expanded() and (config.expander_expanded or "")
or (config.expander_collapsed or "")
end
end
if indent_size == 0 or level < 2 or not with_markers then
local len = indent_size * level + padding
local expander = get_expander()
if level == 0 or not expander then
return {
text = string.rep(" ", len),
}
end
return {
text = string.rep(" ", len - strlen(expander) - 1) .. expander .. " ",
highlight = expander_highlight,
}
end
local indent_marker = config.indent_marker or ""
local last_indent_marker = config.last_indent_marker or ""
skip_marker[level] = node.is_last_child
local indent = {}
if padding > 0 then
table.insert(indent, { text = string.rep(" ", padding) })
end
for i = 1, level do
local char = ""
local spaces_count = indent_size
local highlight = nil
if i > 1 and not skip_marker[i] or i == level then
spaces_count = spaces_count - 1
char = indent_marker
highlight = marker_highlight
if i == level then
local expander = get_expander()
if expander then
char = expander
highlight = expander_highlight
elseif node.is_last_child then
char = last_indent_marker
spaces_count = spaces_count - (vim.api.nvim_strwidth(last_indent_marker) - 1)
end
end
end
table.insert(indent, {
text = char .. string.rep(" ", spaces_count),
highlight = highlight,
no_next_padding = true,
})
end
return indent
end
local truncate_string = function(str, max_length)
if #str <= max_length then
return str
end
return str:sub(1, max_length - 1) .. ""
end
local get_header = function(state, label, size)
if state.sort and state.sort.label == label then
local icon = state.sort.direction == 1 and "" or ""
size = size - 2
---diagnostic here is wrong, printf has arbitrary args.
---@diagnostic disable-next-line: redundant-parameter
return vim.fn.printf("%" .. size .. "s %s ", truncate_string(label, size), icon)
end
return vim.fn.printf("%" .. size .. "s ", truncate_string(label, size))
end
---@class (exact) neotree.Component.Common.FileSize : neotree.Component
---@field [1] "file_size"?
---@field width integer?
---@param config neotree.Component.Common.FileSize
M.file_size = function(config, node, state)
-- Root node gets column labels
if node:get_depth() == 1 then
return {
text = get_header(state, "Size", config.width),
highlight = highlights.FILE_STATS_HEADER,
}
end
local text = "-"
if node.type == "file" then
local stat = utils.get_stat(node)
local size = stat and stat.size or nil
if size then
local success, human = pcall(utils.human_size, size)
if success then
text = human or text
end
end
end
return {
text = vim.fn.printf("%" .. config.width .. "s ", truncate_string(text, config.width)),
highlight = config.highlight or highlights.FILE_STATS,
}
end
---@class (exact) neotree.Component.Common._Time : neotree.Component
---@field format neotree.DateFormat
---@field width integer?
---@param config neotree.Component.Common._Time
local file_time = function(config, node, state, stat_field)
-- Root node gets column labels
if node:get_depth() == 1 then
local label = stat_field
if stat_field == "mtime" then
label = "Last Modified"
elseif stat_field == "birthtime" then
label = "Created"
end
return {
text = get_header(state, label, config.width),
highlight = highlights.FILE_STATS_HEADER,
}
end
local stat = utils.get_stat(node)
local value = stat and stat[stat_field]
local seconds = value and value.sec or nil
local display = seconds and utils.date(config.format, seconds) or "-"
return {
text = vim.fn.printf("%" .. config.width .. "s ", truncate_string(display, config.width)),
highlight = config.highlight or highlights.FILE_STATS,
}
end
---@class (exact) neotree.Component.Common.LastModified : neotree.Component.Common._Time
---@field [1] "last_modified"?
---@param config neotree.Component.Common.LastModified
M.last_modified = function(config, node, state)
return file_time(config, node, state, "mtime")
end
---@class (exact) neotree.Component.Common.Created : neotree.Component.Common._Time
---@field [1] "created"?
---@param config neotree.Component.Common.Created
M.created = function(config, node, state)
return file_time(config, node, state, "birthtime")
end
---@class (exact) neotree.Component.Common.SymlinkTarget : neotree.Component
---@field [1] "symlink_target"?
---@field text_format string?
---@param config neotree.Component.Common.SymlinkTarget
M.symlink_target = function(config, node, _)
if node.is_link then
return {
text = string.format(config.text_format or "-> %s", node.link_to),
highlight = config.highlight or highlights.SYMBOLIC_LINK_TARGET,
}
else
return {}
end
end
---@class (exact) neotree.Component.Common.Type : neotree.Component
---@field [1] "type"?
---@field width integer?
---@param config neotree.Component.Common.Type
M.type = function(config, node, state)
local text = node.ext or node.type
-- Root node gets column labels
if node:get_depth() == 1 then
return {
text = get_header(state, "Type", config.width),
highlight = highlights.FILE_STATS_HEADER,
}
end
return {
text = vim.fn.printf("%" .. config.width .. "s ", truncate_string(text, config.width)),
highlight = highlights.FILE_STATS,
}
end
return M

View file

@ -0,0 +1,339 @@
local utils = require("neo-tree.utils")
local renderer = require("neo-tree.ui.renderer")
local highlights = require("neo-tree.ui.highlights")
local log = require("neo-tree.log")
local M = {}
local strwidth = vim.api.nvim_strwidth
local calc_rendered_width = function(rendered_item)
local width = 0
for _, item in ipairs(rendered_item) do
if item.text then
width = width + strwidth(item.text)
end
end
return width
end
local calc_container_width = function(config, node, state, context)
local container_width = 0
if type(config.width) == "string" then
if config.width == "fit_content" then
container_width = context.max_width
elseif config.width == "100%" then
container_width = context.available_width
elseif config.width:match("^%d+%%$") then
local percent = tonumber(config.width:sub(1, -2)) / 100
container_width = math.floor(percent * context.available_width)
else
error("Invalid container width: " .. config.width)
end
elseif type(config.width) == "number" then
container_width = config.width
elseif type(config.width) == "function" then
container_width = config.width(node, state)
else
error("Invalid container width: " .. config.width)
end
if config.min_width then
container_width = math.max(container_width, config.min_width)
end
if config.max_width then
container_width = math.min(container_width, config.max_width)
end
context.container_width = container_width
return container_width
end
local render_content = function(config, node, state, context)
local window_width = vim.api.nvim_win_get_width(state.winid)
local add_padding = function(rendered_item, should_pad)
for _, data in ipairs(rendered_item) do
if data.text then
local padding = (should_pad and #data.text > 0 and data.text:sub(1, 1) ~= " ") and " " or ""
data.text = padding .. data.text
should_pad = data.text:sub(#data.text) ~= " "
end
end
return should_pad
end
local max_width = 0
local grouped_by_zindex = utils.group_by(config.content, "zindex")
for zindex, items in pairs(grouped_by_zindex) do
local should_pad = { left = false, right = false }
local zindex_rendered = { left = {}, right = {} }
local rendered_width = 0
for _, item in ipairs(items) do
repeat
if item.enabled == false then
break
end
local required_width = item.required_width or 0
if required_width > window_width then
break
end
local rendered_item = renderer.render_component(item, node, state, context.available_width)
if rendered_item then
local align = item.align or "left"
should_pad[align] = add_padding(rendered_item, should_pad[align])
vim.list_extend(zindex_rendered[align], rendered_item)
rendered_width = rendered_width + calc_rendered_width(rendered_item)
end
until true
end
max_width = math.max(max_width, rendered_width)
grouped_by_zindex[zindex] = zindex_rendered
end
context.max_width = max_width
context.grouped_by_zindex = grouped_by_zindex
return context
end
local truncate = utils.truncate_by_cell
---Takes a list of rendered components and truncates them to fit the container width
---@param layer table The list of rendered components.
---@param skip_count number The number of characters to skip from the begining/left.
---@param max_width number The maximum number of characters to return.
local truncate_layer_keep_left = function(layer, skip_count, max_width)
local result = {}
local taken = 0
local skipped = 0
for _, item in ipairs(layer) do
local remaining_to_skip = skip_count - skipped
local text_width = strwidth(item.text)
if remaining_to_skip > 0 then
if text_width <= remaining_to_skip then
skipped = skipped + text_width
item.text = ""
else
item.text, text_width = truncate(item.text, text_width - remaining_to_skip, "right")
if text_width > max_width - taken then
item.text, text_width = truncate(item.text, max_width - taken)
end
table.insert(result, item)
taken = taken + text_width
skipped = skipped + remaining_to_skip
end
elseif taken <= max_width then
item.text, text_width = truncate(item.text, max_width - taken)
table.insert(result, item)
taken = taken + text_width
end
end
return result
end
---Takes a list of rendered components and truncates them to fit the container width
---@param layer table The list of rendered components.
---@param skip_count number The number of characters to skip from the end/right.
---@param max_width number The maximum number of characters to return.
local truncate_layer_keep_right = function(layer, skip_count, max_width)
local result = {}
local taken = 0
local skipped = 0
for i = #layer, 1, -1 do
local item = layer[i]
local text_width = strwidth(item.text)
local remaining_to_skip = skip_count - skipped
if remaining_to_skip > 0 then
if text_width <= remaining_to_skip then
skipped = skipped + text_width
item.text = ""
else
item.text, text_width = truncate(item.text, text_width - remaining_to_skip)
if text_width > max_width - taken then
item.text, text_width = truncate(item.text, max_width - taken, "right")
end
table.insert(result, item)
taken = taken + text_width
skipped = skipped + remaining_to_skip
end
elseif taken <= max_width then
if text_width > max_width - taken then
item.text, text_width = truncate(item.text, max_width - taken, "right")
end
table.insert(result, item)
taken = taken + text_width
end
end
return result
end
local fade_content = function(layer, fade_char_count)
local text = layer[#layer].text
if not text or #text == 0 then
return
end
local hl = layer[#layer].highlight or "Normal"
local fade = {
highlights.get_faded_highlight_group(hl, 0.68),
highlights.get_faded_highlight_group(hl, 0.6),
highlights.get_faded_highlight_group(hl, 0.35),
}
for i = 3, 1, -1 do
if #text >= i and fade_char_count >= i then
layer[#layer].text = text:sub(1, -i - 1)
for j = i, 1, -1 do
-- force no padding for each faded character
local entry = { text = text:sub(-j, -j), highlight = fade[i - j + 1], no_padding = true }
table.insert(layer, entry)
end
break
end
end
end
local try_fade_content = function(layer, fade_char_count)
local success, err = pcall(fade_content, layer, fade_char_count)
if not success then
log.debug("Error while trying to fade content: ", err)
end
end
local merge_content = function(context)
-- Heres the idea:
-- * Starting backwards from the layer with the highest zindex
-- set the left and right tables to the content of the layer
-- * If a layer has more content than will fit, the left side will be truncated.
-- * If the available space is not used up, move on to the next layer
-- * With each subsequent layer, if the length of that layer is greater then the existing
-- length for that side (left or right), then clip that layer and append whatver portion is
-- not covered up to the appropriate side.
-- * Check again to see if we have used up the available width, short circuit if we have.
-- * Repeat until all layers have been merged.
-- * Join the left and right tables together and return.
--
local remaining_width = context.container_width
local left, right = {}, {}
local left_width, right_width = 0, 0
local wanted_width = 0
if context.left_padding and context.left_padding > 0 then
table.insert(left, { text = string.rep(" ", context.left_padding) })
remaining_width = remaining_width - context.left_padding
left_width = left_width + context.left_padding
wanted_width = wanted_width + context.left_padding
end
if context.right_padding and context.right_padding > 0 then
remaining_width = remaining_width - context.right_padding
wanted_width = wanted_width + context.right_padding
end
local keys = utils.get_keys(context.grouped_by_zindex, true)
if type(keys) ~= "table" then
return {}
end
local i = #keys
while i > 0 do
local key = keys[i]
local layer = context.grouped_by_zindex[key]
i = i - 1
if utils.truthy(layer.right) then
local width = calc_rendered_width(layer.right)
wanted_width = wanted_width + width
if remaining_width > 0 then
context.has_right_content = true
if width > remaining_width then
local truncated = truncate_layer_keep_right(layer.right, right_width, remaining_width)
vim.list_extend(right, truncated)
remaining_width = 0
else
remaining_width = remaining_width - width
vim.list_extend(right, layer.right)
right_width = right_width + width
end
end
end
if utils.truthy(layer.left) then
local width = calc_rendered_width(layer.left)
wanted_width = wanted_width + width
if remaining_width > 0 then
if width > remaining_width then
local truncated = truncate_layer_keep_left(layer.left, left_width, remaining_width)
if context.enable_character_fade then
try_fade_content(truncated, 3)
end
vim.list_extend(left, truncated)
remaining_width = 0
else
remaining_width = remaining_width - width
if context.enable_character_fade and not context.auto_expand_width then
local fade_chars = 3 - remaining_width
if fade_chars > 0 then
try_fade_content(layer.left, fade_chars)
end
end
vim.list_extend(left, layer.left)
left_width = left_width + width
end
end
end
if remaining_width == 0 and not context.auto_expand_width then
i = 0
break
end
end
if remaining_width > 0 and #right > 0 then
table.insert(left, { text = string.rep(" ", remaining_width) })
end
local result = {}
vim.list_extend(result, left)
-- we do not pad between left and right side
if #right >= 1 then
right[1].no_padding = true
end
vim.list_extend(result, right)
context.merged_content = result
log.trace("wanted width: ", wanted_width, " actual width: ", context.container_width)
context.wanted_width = math.max(wanted_width, context.wanted_width)
end
---@param config neotree.Component.Common.Container
M.render = function(config, node, state, available_width)
local context = {
wanted_width = 0,
max_width = 0,
grouped_by_zindex = {},
available_width = available_width,
left_padding = config.left_padding,
right_padding = config.right_padding,
enable_character_fade = config.enable_character_fade,
auto_expand_width = state.window.auto_expand_width and state.window.position ~= "float",
}
render_content(config, node, state, context)
calc_container_width(config, node, state, context)
merge_content(context)
if context.has_right_content then
state.has_right_content = true
end
-- we still want padding between this container and the previous component
if #context.merged_content > 0 then
context.merged_content[1].no_padding = false
end
return context.merged_content, context.wanted_width
end
return M

View file

@ -0,0 +1,341 @@
local file_nesting = require("neo-tree.sources.common.file-nesting")
local utils = require("neo-tree.utils")
local log = require("neo-tree.log")
local uv = vim.uv or vim.loop
---@type neotree.Config.SortFunction
local function sort_items(a, b)
if a.type == b.type then
return a.path < b.path
else
return a.type < b.type
end
end
---@type neotree.Config.SortFunction
local function sort_items_case_insensitive(a, b)
if a.type == b.type then
return a.path:lower() < b.path:lower()
else
return a.type < b.type
end
end
---Creates a sort function the will sort by the values returned by the field provider.
---@param field_provider neotree.Internal.SortFieldProvider a function that takes an item and returns a value to sort by.
---@param fallback_sort_function neotree.Config.SortFunction a sort function to use if the field provider returns the same value for both items.
---@return neotree.Config.SortFunction
local function make_sort_function(field_provider, fallback_sort_function, direction)
return function(a, b)
if a.type == b.type then
local a_field = field_provider(a)
local b_field = field_provider(b)
if a_field == b_field then
return fallback_sort_function(a, b)
else
if direction < 0 then
return a_field > b_field
else
return a_field < b_field
end
end
else
return a.type < b.type
end
end
end
---@param func neotree.Config.SortFunction?
---@return boolean
local function sort_function_is_valid(func)
if func == nil then
return false
end
local a = { type = "dir", path = "foo" }
local b = { type = "dir", path = "baz" }
local success, result = pcall(func, a, b)
if success and type(result) == "boolean" then
return true
end
log.error("sort function isn't valid ", result)
return false
end
---@param tbl table
---@param sort_func neotree.Config.SortFunction?
---@param field_provider neotree.Internal.SortFieldProvider?
---@param direction? 1|0
local function deep_sort(tbl, sort_func, field_provider, direction)
if sort_func == nil then
local config = require("neo-tree").config
if sort_function_is_valid(config.sort_function) then
sort_func = config.sort_function
elseif config.sort_case_insensitive then
sort_func = sort_items_case_insensitive
else
sort_func = sort_items
end
---@cast sort_func -nil
if field_provider ~= nil then
sort_func = make_sort_function(field_provider, sort_func, direction)
end
end
table.sort(tbl, sort_func)
for _, item in pairs(tbl) do
if item.type == "directory" or item.children ~= nil then
deep_sort(item.children, sort_func)
end
end
end
---@param state neotree.State
local advanced_sort = function(tbl, state)
local sort_func = state.sort_function_override
local field_provider = state.sort_field_provider
local direction = state.sort and state.sort.direction or 1
deep_sort(tbl, sort_func, field_provider, direction)
end
local create_item, set_parents
---@alias neotree.Filetype
---|"file"
---|"link"
---|"directory"
---|"unknown"
---@class neotree.FileItemFilters
---@field never_show boolean?
---@field always_show boolean?
---@field name boolean?
---@field pattern boolean?
---@field dotfiles boolean?
---@field hidden boolean?
---@field gitignored boolean?
---@field parent neotree.FileItemFilters?
---@field show_gitignored boolean?
---@class (exact) neotree.FileItemExtra
---@field status string? Git status
---@class (exact) neotree.FileItem
---@field id string
---@field name string
---@field parent_path string?
---@field path string
---@field type neotree.Filetype|string
---@field is_reveal_target boolean
---@field contains_reveal_target boolean
---@field filtered_by neotree.FileItemFilters?
---@field extra neotree.FileItemExtra?
---@field status string? Git status
---@field is_nested boolean?
---@class (exact) neotree.FileItem.File : neotree.FileItem
---@field children table<string, neotree.FileItem?>?
---@field nesting_callback neotree.filenesting.Callback
---@field base string
---@field ext string
---@field exts string
---@field name_lcase string
---@class (exact) neotree.FileItem.Link : neotree.FileItem
---@field is_link boolean
---@field link_to string?
---@class (exact) neotree.FileItem.Directory : neotree.FileItem
---@field children table<string, neotree.FileItem?>
---@field loaded boolean
---@field search_pattern string?
---@param context neotree.FileItemContext
---@param path string
---@param _type neotree.Filetype?
---@param bufnr integer?
---@return neotree.FileItem
function create_item(context, path, _type, bufnr)
local parent_path, name = utils.split_path(utils.normalize_path(path))
name = name or ""
local id = path
if path == "[No Name]" and bufnr then
parent_path = context.state.path
name = "[No Name]"
id = tostring(bufnr)
else
-- avoid creating duplicate items
if context.folders[path] or context.nesting[path] or context.item_exists[path] then
return context.folders[path] or context.nesting[path] or context.item_exists[path]
end
end
if _type == nil then
local stat = uv.fs_stat(path)
_type = stat and stat.type or "unknown"
end
local is_reveal_target = (path == context.path_to_reveal)
---@type neotree.FileItem
local item = {
id = id,
name = name,
parent_path = parent_path,
path = path,
type = _type,
is_reveal_target = is_reveal_target,
contains_reveal_target = is_reveal_target and utils.is_subpath(path, context.path_to_reveal),
}
if utils.is_windows then
if vim.fn.getftype(path) == "link" then
item.type = "link"
end
end
if item.type == "link" then
---@cast item neotree.FileItem.Link
item.is_link = true
item.link_to = uv.fs_realpath(path)
if item.link_to ~= nil then
item.type = uv.fs_stat(item.link_to).type
end
end
if item.type == "directory" then
---@cast item neotree.FileItem.Directory
item.children = {}
item.loaded = false
context.folders[path] = item
if context.state.search_pattern then
table.insert(context.state.default_expanded_nodes, item.id)
end
else
---@cast item neotree.FileItem.File
item.base = item.name:match("^([-_,()%s%w%i]+)%.")
item.ext = item.name:match("%.([-_,()%s%w%i]+)$")
item.exts = item.name:match("^[-_,()%s%w%i]+%.(.*)")
item.name_lcase = item.name:lower()
local nesting_callback = file_nesting.get_nesting_callback(item)
if nesting_callback ~= nil then
item.children = {}
item.nesting_callback = nesting_callback
context.nesting[path] = item
end
end
local state = assert(context.state)
local f = state.filtered_items
local is_not_root = not utils.is_subpath(path, context.state.path)
if f and is_not_root then
if f.never_show[name] then
item.filtered_by = item.filtered_by or {}
item.filtered_by.never_show = true
else
if utils.is_filtered_by_pattern(f.never_show_by_pattern, path, name) then
item.filtered_by = item.filtered_by or {}
item.filtered_by.never_show = true
end
end
if f.always_show[name] then
item.filtered_by = item.filtered_by or {}
item.filtered_by.always_show = true
else
if utils.is_filtered_by_pattern(f.always_show_by_pattern, path, name) then
item.filtered_by = item.filtered_by or {}
item.filtered_by.always_show = true
end
end
if f.hide_by_name[name] then
item.filtered_by = item.filtered_by or {}
item.filtered_by.name = true
end
if utils.is_filtered_by_pattern(f.hide_by_pattern, path, name) then
item.filtered_by = item.filtered_by or {}
item.filtered_by.pattern = true
end
if f.hide_dotfiles and string.sub(name, 1, 1) == "." then
item.filtered_by = item.filtered_by or {}
item.filtered_by.dotfiles = true
end
if f.hide_hidden and utils.is_hidden(path) then
item.filtered_by = item.filtered_by or {}
item.filtered_by.hidden = true
end
-- NOTE: git_ignored logic moved to job_complete
end
set_parents(context, item)
if context.all_items == nil then
context.all_items = {}
end
if is_not_root then
table.insert(context.all_items, item)
end
return item
end
-- function to set (or create) parent folder
---@param context neotree.FileItemContext
---@param item neotree.FileItem
function set_parents(context, item)
-- we can get duplicate items if we navigate up with open folders
-- this is probably hacky, but it works
if context.item_exists[item.id] then
return
end
if not item.parent_path then
return
end
local parent = context.folders[item.parent_path]
if not utils.truthy(item.parent_path) then
return
end
if parent == nil then
local success
success, parent = pcall(create_item, context, item.parent_path, "directory")
if not success then
log.error("error creating item for ", item.parent_path)
end
---@cast parent neotree.FileItem.Directory
context.folders[parent.id] = parent
set_parents(context, parent)
end
table.insert(parent.children, item)
context.item_exists[item.id] = true
if not item.filtered_by and parent.filtered_by then
item.filtered_by = {
parent = parent.filtered_by,
}
end
end
---@class (exact) neotree.FileItemContext
---@field state neotree.State?
---@field folders table<string, neotree.FileItem.Directory|neotree.FileItem.Link?>
---@field nesting neotree.FileItem[]
---@field item_exists table<string, boolean?>
---@field all_items table<string, neotree.FileItem?>
---@field path_to_reveal string?
---Create context to be used in other file-items functions.
---@param state neotree.State? The state of the file-items.
---@return neotree.FileItemContext
local create_context = function(state)
local context = {}
-- Make the context a weak table so that it can be garbage collected
--setmetatable(context, { __mode = 'v' })
context.state = state
context.folders = {}
context.nesting = {}
context.item_exists = {}
context.all_items = {}
return context
end
return {
create_context = create_context,
create_item = create_item,
deep_sort = deep_sort,
advanced_sort = advanced_sort,
}

View file

@ -0,0 +1,324 @@
local utils = require("neo-tree.utils")
local globtopattern = require("neo-tree.sources.filesystem.lib.globtopattern")
local log = require("neo-tree.log")
-- File nesting a la JetBrains (#117).
local M = {}
---@alias neotree.filenesting.Callback fun(item: table, siblings: table[], rule: neotree.filenesting.Rule): neotree.filenesting.Matches
---@class neotree.filenesting.Matcher
---@field rules table<string, neotree.filenesting.Rule>|neotree.filenesting.Rule[]
---@field get_children neotree.filenesting.Callback
---@field get_nesting_callback fun(item: table): neotree.filenesting.Callback|nil A callback that returns all the files
local DEFAULT_PATTERN_PRIORITY = 100
---@class neotree.filenesting.Rule
---@field priority number? Default is 100. Higher is prioritized.
---@field _priority number The internal priority, lower is prioritized. Determined through priority and the key for the rule at setup.
---@class neotree.filenesting.Rule.Pattern : neotree.filenesting.Rule
---@field files string[]
---@field files_exact string[]?
---@field files_glob string[]?
---@field ignore_case boolean? Default is false
---@field pattern string
---@class neotree.filenesting.Matcher.Pattern : neotree.filenesting.Matcher
---@field rules neotree.filenesting.Rule.Pattern[]
local pattern_matcher = {
rules = {},
}
---@class neotree.filenesting.Rule.Extension : neotree.filenesting.Rule
---@field [integer] string
---@class neotree.filenesting.Matcher.Extension : neotree.filenesting.Matcher
---@field rules table<string, neotree.filenesting.Rule.Extension>
local extension_matcher = {
rules = {},
}
local matchers = {
pattern = pattern_matcher,
exts = extension_matcher,
}
---@class neotree.filenesting.Matches
---@field priority number
---@field parent table
---@field children table[]
extension_matcher.get_nesting_callback = function(item)
local rule = extension_matcher.rules[item.exts]
if utils.truthy(rule) then
return function(inner_item, siblings)
return {
parent = inner_item,
children = extension_matcher.get_children(inner_item, siblings, rule),
priority = rule._priority,
}
end
end
return nil
end
---@type neotree.filenesting.Callback
extension_matcher.get_children = function(item, siblings, rule)
local matching_files = {}
if siblings == nil then
return matching_files
end
for _, ext in pairs(rule) do
for _, sibling in pairs(siblings) do
if
sibling.id ~= item.id
and sibling.exts == ext
and item.base .. "." .. ext == sibling.name
then
table.insert(matching_files, sibling)
end
end
end
---@type neotree.filenesting.Matches
return matching_files
end
pattern_matcher.get_nesting_callback = function(item)
---@type neotree.filenesting.Rule.Pattern[]
local matching_rules = {}
for _, rule in ipairs(pattern_matcher.rules) do
if item.name:match(rule.pattern) then
table.insert(matching_rules, rule)
end
end
if #matching_rules > 0 then
return function(inner_item, siblings)
local match_set = {}
---@type neotree.filenesting.Matches[]
local all_item_matches = {}
for _, rule in ipairs(matching_rules) do
---@type neotree.filenesting.Matches
local item_matches = {
priority = rule._priority,
parent = inner_item,
children = {},
}
local matched_siblings = pattern_matcher.get_children(inner_item, siblings, rule)
for _, match in ipairs(matched_siblings) do
-- Use file path as key to prevent duplicates
if not match_set[match.id] then
match_set[match.id] = true
table.insert(item_matches.children, match)
end
end
table.insert(all_item_matches, item_matches)
end
return all_item_matches
end
end
return nil
end
local pattern_matcher_types = {
files_glob = {
get_pattern = function(pattern)
return globtopattern.globtopattern(pattern)
end,
match = function(filename, pattern)
return filename:match(pattern)
end,
},
files_exact = {
get_pattern = function(pattern)
return pattern
end,
match = function(filename, pattern)
return filename == pattern
end,
},
}
---@type neotree.filenesting.Callback
pattern_matcher.get_children = function(item, siblings, rule)
local matching_files = {}
if siblings == nil then
return matching_files
end
for type, type_functions in pairs(pattern_matcher_types) do
for _, pattern in pairs(rule[type] or {}) do
repeat
---@cast rule neotree.filenesting.Rule.Pattern
local item_name = rule.ignore_case and item.name:lower() or item.name
local success, replaced_pattern = pcall(string.gsub, item_name, rule.pattern, pattern)
if not success then
log.error("Error using file glob '" .. pattern .. "'; Error: " .. replaced_pattern)
break
end
for _, sibling in pairs(siblings) do
if sibling.id ~= item.id then
local sibling_name = rule.ignore_case and sibling.name:lower() or sibling.name
local glob_or_file = type_functions.get_pattern(replaced_pattern)
if type_functions.match(sibling_name, glob_or_file) then
table.insert(matching_files, sibling)
end
end
end
until true
end
end
return matching_files
end
---@type neotree.filenesting.Matcher[]
local enabled_matchers = {}
function M.is_enabled()
return not vim.tbl_isempty(enabled_matchers)
end
function M.nest_items(context)
if not M.is_enabled() or vim.tbl_isempty(context.nesting or {}) then
return
end
-- First collect all nesting relationships
---@type neotree.filenesting.Matches[]
local nesting_relationships = {}
for _, parent in pairs(context.nesting) do
local siblings = context.folders[parent.parent_path].children
vim.list_extend(nesting_relationships, parent.nesting_callback(parent, siblings))
end
table.sort(nesting_relationships, function(a, b)
if a.priority == b.priority then
return a.parent.id < b.parent.id
end
return a.priority < b.priority
end)
-- Then apply them in order
for _, relationship in ipairs(nesting_relationships) do
local folder = context.folders[relationship.parent.parent_path]
for _, sibling in ipairs(relationship.children) do
if not sibling.is_nested then
table.insert(relationship.parent.children, sibling)
sibling.is_nested = true
sibling.nesting_parent = relationship.parent
if folder ~= nil then
for index, file_to_check in ipairs(folder.children) do
if file_to_check.id == sibling.id then
table.remove(folder.children, index)
break
end
end
end
end
end
end
end
function M.get_nesting_callback(item)
local cbs = {}
for _, matcher in ipairs(enabled_matchers) do
local callback = matcher.get_nesting_callback(item)
if callback ~= nil then
table.insert(cbs, callback)
end
end
if #cbs <= 1 then
return cbs[1]
else
return function(...)
local res = {}
for _, cb in ipairs(cbs) do
vim.list_extend(res, cb(...))
end
return res
end
end
end
local function is_glob(str)
local test = str:gsub("\\[%*%?%[%]]", "")
local pos, _ = test:find("*")
return pos ~= nil
end
local function case_insensitive_pattern(pattern)
-- find an optional '%' (group 1) followed by any character (group 2)
local p = pattern:gsub("(%%?)(.)", function(percent, letter)
if percent ~= "" or not letter:match("%a") then
-- if the '%' matched, or `letter` is not a letter, return "as is"
return percent .. letter
else
-- else, return a case-insensitive character class of the matched letter
return string.format("[%s%s]", letter:lower(), letter:upper())
end
end)
return p
end
---Setup the module with the given config
---@param config table<string, neotree.filenesting.Rule>
function M.setup(config)
config = config or {}
enabled_matchers = {}
local real_priority = 0
for _, m in pairs(matchers) do
m.rules = {}
end
for key, rule in
utils.spairs(config, function(a, b)
-- Organize by priority (descending) or by key (ascending)
local a_prio = config[a].priority or DEFAULT_PATTERN_PRIORITY
local b_prio = config[b].priority or DEFAULT_PATTERN_PRIORITY
if a_prio == b_prio then
return a < b
end
return a_prio > b_prio
end)
do
rule.priority = rule.priority or DEFAULT_PATTERN_PRIORITY
rule._priority = real_priority
real_priority = real_priority + 1
if rule.pattern then
---@cast rule neotree.filenesting.Rule.Pattern
rule.ignore_case = rule.ignore_case or false
if rule.ignore_case then
rule.pattern = case_insensitive_pattern(rule.pattern)
end
rule.files_glob = {}
rule.files_exact = {}
for _, glob in pairs(rule.files) do
if rule.ignore_case then
glob = glob:lower()
end
local replaced = glob:gsub("%%%d+", "")
if is_glob(replaced) then
table.insert(rule.files_glob, glob)
else
table.insert(rule.files_exact, glob)
end
end
-- priority does matter for pattern.rules
table.insert(matchers.pattern.rules, rule)
else
---@cast rule neotree.filenesting.Rule.Extension
matchers.exts.rules[key] = rule
end
end
enabled_matchers = vim.tbl_filter(function(m)
return not vim.tbl_isempty(m.rules)
end, matchers)
end
return M

View file

@ -0,0 +1,249 @@
-- The lua implementation of the fzy string matching algorithm
-- credits to: https://github.com/swarn/fzy-lua
--[[
The MIT License (MIT)
Copyright (c) 2020 Seth Warn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--]]
-- modified by: @pysan3 (2023)
local SCORE_GAP_LEADING = -0.005
local SCORE_GAP_TRAILING = -0.005
local SCORE_GAP_INNER = -0.01
local SCORE_MATCH_CONSECUTIVE = 1.0
local SCORE_MATCH_SLASH = 0.9
local SCORE_MATCH_WORD = 0.8
local SCORE_MATCH_CAPITAL = 0.7
local SCORE_MATCH_DOT = 0.6
local SCORE_MAX = math.huge
local SCORE_MIN = -math.huge
local MATCH_MAX_LENGTH = 1024
local M = {}
-- Return `true` if `needle` is a subsequence of `haystack`.
function M.has_match(needle, haystack, case_sensitive)
if not case_sensitive then
needle = string.lower(needle)
haystack = string.lower(haystack)
end
---@type integer?
local j = 1
for i = 1, string.len(needle) do
j = string.find(haystack, needle:sub(i, i), j, true)
if not j then
return false
else
j = j + 1
end
end
return true
end
local function is_lower(c)
return c:match("%l")
end
local function is_upper(c)
return c:match("%u")
end
local function precompute_bonus(haystack)
local match_bonus = {}
local last_char = "/"
for i = 1, string.len(haystack) do
local this_char = haystack:sub(i, i)
if last_char == "/" or last_char == "\\" then
match_bonus[i] = SCORE_MATCH_SLASH
elseif last_char == "-" or last_char == "_" or last_char == " " then
match_bonus[i] = SCORE_MATCH_WORD
elseif last_char == "." then
match_bonus[i] = SCORE_MATCH_DOT
elseif is_lower(last_char) and is_upper(this_char) then
match_bonus[i] = SCORE_MATCH_CAPITAL
else
match_bonus[i] = 0
end
last_char = this_char
end
return match_bonus
end
local function compute(needle, haystack, D, T, case_sensitive)
-- Note that the match bonuses must be computed before the arguments are
-- converted to lowercase, since there are bonuses for camelCase.
local match_bonus = precompute_bonus(haystack)
local n = string.len(needle)
local m = string.len(haystack)
if not case_sensitive then
needle = string.lower(needle)
haystack = string.lower(haystack)
end
-- Because lua only grants access to chars through substring extraction,
-- get all the characters from the haystack once now, to reuse below.
local haystack_chars = {}
for i = 1, m do
haystack_chars[i] = haystack:sub(i, i)
end
for i = 1, n do
D[i] = {}
T[i] = {}
local prev_score = SCORE_MIN
local gap_score = i == n and SCORE_GAP_TRAILING or SCORE_GAP_INNER
local needle_char = needle:sub(i, i)
for j = 1, m do
if needle_char == haystack_chars[j] then
local score = SCORE_MIN
if i == 1 then
score = ((j - 1) * SCORE_GAP_LEADING) + match_bonus[j]
elseif j > 1 then
local a = T[i - 1][j - 1] + match_bonus[j]
local b = D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE
score = math.max(a, b)
end
D[i][j] = score
prev_score = math.max(score, prev_score + gap_score)
T[i][j] = prev_score
else
D[i][j] = SCORE_MIN
prev_score = prev_score + gap_score
T[i][j] = prev_score
end
end
end
end
-- Compute a matching score for two strings.
--
-- Where `needle` is a subsequence of `haystack`, this returns a score
-- measuring the quality of their match. Better matches get higher scores.
--
-- `needle` must be a subsequence of `haystack`, the result is undefined
-- otherwise. Call `has_match()` before calling `score`.
--
-- returns `get_score_min()` where a or b are longer than `get_max_length()`
--
-- returns `get_score_min()` when a or b are empty strings.
--
-- returns `get_score_max()` when a and b are the same string.
--
-- When the return value is not covered by the above rules, it is a number
-- in the range (`get_score_floor()`, `get_score_ceiling()`)
function M.score(needle, haystack, case_sensitive)
local n = string.len(needle)
local m = string.len(haystack)
if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > MATCH_MAX_LENGTH then
return SCORE_MIN
elseif n == m then
return SCORE_MAX
else
local D = {}
local T = {}
compute(needle, haystack, D, T, case_sensitive)
return T[n][m]
end
end
-- Find the locations where fzy matched a string.
--
-- Returns {score, indices}, where indices is an array showing where each
-- character of the needle matches the haystack in the best match.
function M.score_and_positions(needle, haystack, case_sensitive)
local n = string.len(needle)
local m = string.len(haystack)
if n == 0 or m == 0 or m > MATCH_MAX_LENGTH or n > MATCH_MAX_LENGTH then
return SCORE_MIN, {}
elseif n == m then
local consecutive = {}
for i = 1, n do
consecutive[i] = i
end
return SCORE_MAX, consecutive
end
local D = {}
local T = {}
compute(needle, haystack, D, T, case_sensitive)
local positions = {}
local match_required = false
local j = m
for i = n, 1, -1 do
while j >= 1 do
if D[i][j] ~= SCORE_MIN and (match_required or D[i][j] == T[i][j]) then
match_required = (i ~= 1)
and (j ~= 1)
and (T[i][j] == D[i - 1][j - 1] + SCORE_MATCH_CONSECUTIVE)
positions[i] = j
j = j - 1
break
else
j = j - 1
end
end
end
return T[n][m], positions
end
-- Return only the positions of a match.
function M.positions(needle, haystack, case_sensitive)
local _, positions = M.score_and_positions(needle, haystack, case_sensitive)
return positions
end
function M.get_score_min()
return SCORE_MIN
end
function M.get_score_max()
return SCORE_MAX
end
function M.get_max_length()
return MATCH_MAX_LENGTH
end
function M.get_score_floor()
return MATCH_MAX_LENGTH * SCORE_GAP_INNER
end
function M.get_score_ceiling()
return MATCH_MAX_LENGTH * SCORE_MATCH_CONSECUTIVE
end
function M.get_implementation_name()
return "lua"
end
return M

View file

@ -0,0 +1,355 @@
---A generalization of the filter functionality to directly filter the
---source tree instead of relying on pre-filtered data, which is specific
---to the filesystem source.
local Input = require("nui.input")
local event = require("nui.utils.autocmd").event
local popups = require("neo-tree.ui.popups")
local renderer = require("neo-tree.ui.renderer")
local utils = require("neo-tree.utils")
local compat = require("neo-tree.utils._compat")
local log = require("neo-tree.log")
local manager = require("neo-tree.sources.manager")
local fzy = require("neo-tree.sources.common.filters.filter_fzy")
local M = {}
---Reset the current filter to the empty string.
---@param state neotree.State
---@param refresh boolean? whether to refresh the source tree
---@param open_current_node boolean? whether to open the current node
local reset_filter = function(state, refresh, open_current_node)
log.trace("reset_search")
if refresh == nil then
refresh = true
end
-- Cancel any pending search
require("neo-tree.sources.filesystem.lib.filter_external").cancel()
-- reset search state
if state.open_folders_before_search then
state.force_open_folders = vim.deepcopy(state.open_folders_before_search, compat.noref())
else
state.force_open_folders = nil
end
state.open_folders_before_search = nil
state.search_pattern = nil
if open_current_node then
local success, node = pcall(state.tree.get_node, state.tree)
if success and node then
local id = node:get_id()
renderer.position.set(state, id)
id = utils.remove_trailing_slash(id)
manager.navigate(state, nil, id, utils.wrap(pcall, renderer.focus_node, state, id, false))
end
elseif refresh then
manager.navigate(state)
else
state.tree = vim.deepcopy(state.orig_tree)
end
state.orig_tree = nil
end
---Show the filtered tree
---@param state any
---@param do_not_focus_window boolean? whether to focus the window
local show_filtered_tree = function(state, do_not_focus_window)
state.tree = vim.deepcopy(state.orig_tree)
state.tree:get_nodes()[1].search_pattern = state.search_pattern
local max_score, max_id = fzy.get_score_min(), nil
local function filter_tree(node_id)
local node = state.tree:get_node(node_id)
local path = node.extra.search_path or node.path
local should_keep = fzy.has_match(state.search_pattern, path)
if should_keep then
local score = fzy.score(state.search_pattern, path)
node.extra.fzy_score = score
if score > max_score then
max_score = score
max_id = node_id
end
end
if node:has_children() then
for _, child_id in ipairs(node:get_child_ids()) do
should_keep = filter_tree(child_id) or should_keep
end
end
if not should_keep then
state.tree:remove_node(node_id) -- TODO: this might not be efficient
end
return should_keep
end
if #state.search_pattern > 0 then
for _, root in ipairs(state.tree:get_nodes()) do
filter_tree(root:get_id())
end
end
manager.redraw(state.name)
if max_id then
renderer.focus_node(state, max_id, do_not_focus_window)
end
end
---Main entry point for the filter functionality.
---This will display a filter input popup and filter the source tree on change and on submit
---@param state neotree.State the source state
---@param search_as_you_type boolean? whether to filter as you type or only on submit
---@param keep_filter_on_submit boolean? whether to keep the filter on <CR> or reset it
M.show_filter = function(state, search_as_you_type, keep_filter_on_submit)
local winid = vim.api.nvim_get_current_win()
local height = vim.api.nvim_win_get_height(winid)
local scroll_padding = 3
-- setup the input popup options
local popup_msg = "Search:"
if search_as_you_type then
popup_msg = "Filter:"
end
if state.config.title then
popup_msg = state.config.title
end
local width = vim.fn.winwidth(0) - 2
local row = height - 3
if state.current_position == "float" then
scroll_padding = 0
width = vim.fn.winwidth(winid)
row = height - 2
vim.api.nvim_win_set_height(winid, row)
end
state.orig_tree = vim.deepcopy(state.tree)
local popup_options = popups.popup_options(popup_msg, width, {
relative = "win",
winid = winid,
position = {
row = row,
col = 0,
},
size = width,
})
local has_pre_search_folders = utils.truthy(state.open_folders_before_search)
if not has_pre_search_folders then
log.trace("No search or pre-search folders, recording pre-search folders now")
state.open_folders_before_search = renderer.get_expanded_nodes(state.tree)
end
local waiting_for_default_value = utils.truthy(state.search_pattern)
local input = Input(popup_options, {
prompt = " ",
default_value = state.search_pattern,
on_submit = function(value)
if value == "" then
reset_filter(state)
return
end
if search_as_you_type and not keep_filter_on_submit then
reset_filter(state, true, true)
return
end
-- do the search
state.search_pattern = value
show_filtered_tree(state, false)
end,
--this can be bad in a deep folder structure
on_change = function(value)
if not search_as_you_type then
return
end
-- apparently when a default value is set, on_change fires for every character
if waiting_for_default_value then
if #value < #state.search_pattern then
return
end
waiting_for_default_value = false
end
if value == state.search_pattern or value == nil then
return
end
-- finally do the search
log.trace("Setting search in on_change to: " .. value)
state.search_pattern = value
local len_to_delay = { [0] = 500, 500, 400, 200 }
local delay = len_to_delay[#value] or 100
utils.debounce(state.name .. "_filter", function()
show_filtered_tree(state, true)
end, delay, utils.debounce_strategy.CALL_LAST_ONLY)
end,
})
input:mount()
local restore_height = vim.schedule_wrap(function()
if vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_set_height(winid, height)
end
end)
---@alias neotree.FuzzyFinder.BuiltinCommandNames
---|"move_cursor_down"
---|"move_cursor_up"
---|"close"
---|"close_clear_filter"
---|"close_keep_filter"
---|neotree.FuzzyFinder.FalsyMappingNames
---@alias neotree.FuzzyFinder.CommandFunction fun(state: neotree.State, scroll_padding: integer):string?
---@class neotree.FuzzyFinder.BuiltinCommands
---@field [string] neotree.FuzzyFinder.CommandFunction?
local cmds
cmds = {
move_cursor_down = function(state_, scroll_padding_)
renderer.focus_node(state_, nil, true, 1, scroll_padding_)
end,
move_cursor_up = function(state_, scroll_padding_)
renderer.focus_node(state_, nil, true, -1, scroll_padding_)
vim.cmd("redraw!")
end,
close = function(_state)
vim.cmd("stopinsert")
input:unmount()
if utils.truthy(_state.search_pattern) then
reset_filter(_state, true)
end
restore_height()
end,
close_keep_filter = function(_state, _scroll_padding)
log.info("Persisting the search filter")
keep_filter_on_submit = true
cmds.close(_state, _scroll_padding)
end,
close_clear_filter = function(_state, _scroll_padding)
log.info("Clearing the search filter")
keep_filter_on_submit = false
cmds.close(_state, _scroll_padding)
end,
}
M.setup_hooks(input, cmds, state, scroll_padding)
M.setup_mappings(input, cmds, state, scroll_padding)
end
---@param input NuiInput
---@param cmds neotree.FuzzyFinder.BuiltinCommands
---@param state neotree.State
---@param scroll_padding integer
function M.setup_hooks(input, cmds, state, scroll_padding)
input:on(
{ event.BufLeave, event.BufDelete },
utils.wrap(cmds.close, state, scroll_padding),
{ once = true }
)
-- hacky bugfix for quitting from the filter window
input:on("QuitPre", function()
if vim.api.nvim_get_current_win() ~= input.winid then
return
end
---'confirm' can cause blocking user input on exit, so this hack disables it.
local old_confirm = vim.o.confirm
vim.o.confirm = false
vim.schedule(function()
vim.o.confirm = old_confirm
end)
end)
end
---@enum neotree.FuzzyFinder.FalsyMappingNames
M._falsy_mapping_names = { "noop", "none" }
---@alias neotree.FuzzyFinder.CommandOrName neotree.FuzzyFinder.CommandFunction|neotree.FuzzyFinder.BuiltinCommandNames
---@class neotree.FuzzyFinder.VerboseCommand
---@field [1] neotree.FuzzyFinder.Command
---@field [2] vim.keymap.set.Opts?
---@field raw boolean?
---@alias neotree.FuzzyFinder.Command neotree.FuzzyFinder.CommandOrName|neotree.FuzzyFinder.VerboseCommand|string
---@class neotree.FuzzyFinder.SimpleMappings : neotree.SimpleMappings
---@field [string] neotree.FuzzyFinder.Command?
---@class neotree.Config.FuzzyFinder.Mappings : neotree.FuzzyFinder.SimpleMappings, neotree.Mappings
---@field [integer] table<string, neotree.FuzzyFinder.SimpleMappings>
---@param input NuiInput
---@param cmds neotree.FuzzyFinder.BuiltinCommands
---@param state neotree.State
---@param scroll_padding integer
---@param mappings neotree.FuzzyFinder.SimpleMappings
---@param mode string
local function apply_simple_mappings(input, cmds, state, scroll_padding, mode, mappings)
---@param command neotree.FuzzyFinder.CommandFunction
---@return function
local function setup_command(command)
return utils.wrap(command, state, scroll_padding)
end
for lhs, rhs in pairs(mappings) do
if type(lhs) == "string" then
---@cast rhs neotree.FuzzyFinder.Command
local cmd, raw, opts
if type(rhs) == "table" then
---type doesn't narrow properly
---@cast rhs -neotree.FuzzyFinder.FalsyMappingNames
raw = rhs.raw
opts = vim.deepcopy(rhs)
opts[1] = nil
opts.raw = nil
cmd = rhs[1]
else
---type also doesn't narrow properly
---@cast rhs -neotree.FuzzyFinder.VerboseCommand
cmd = rhs
end
local cmdtype = type(cmd)
if cmdtype == "string" then
if raw then
input:map(mode, lhs, cmd, opts)
else
local command = cmds[cmd]
if command then
input:map(mode, lhs, setup_command(command), opts)
elseif not vim.tbl_contains(M._falsy_mapping_names, cmd) then
log.warn(
string.format("Invalid command in fuzzy_finder_mappings: ['%s'] = '%s'", lhs, cmd)
)
end
end
elseif cmdtype == "function" then
---@cast cmd -neotree.FuzzyFinder.VerboseCommand
input:map(mode, lhs, setup_command(cmd), opts)
end
end
end
end
---@param input NuiInput
---@param cmds neotree.FuzzyFinder.BuiltinCommands
---@param state neotree.State
---@param scroll_padding integer
function M.setup_mappings(input, cmds, state, scroll_padding)
local config = require("neo-tree").config
local ff_mappings = config.filesystem.window.fuzzy_finder_mappings or {}
apply_simple_mappings(input, cmds, state, scroll_padding, "i", ff_mappings)
for _, mappings_by_mode in ipairs(ff_mappings) do
for mode, mappings in pairs(mappings_by_mode) do
apply_simple_mappings(input, cmds, state, scroll_padding, mode, mappings)
end
end
end
return M

View file

@ -0,0 +1,172 @@
local Popup = require("nui.popup")
local NuiLine = require("nui.line")
local utils = require("neo-tree.utils")
local popups = require("neo-tree.ui.popups")
local highlights = require("neo-tree.ui.highlights")
local M = {}
---@param text string
---@param highlight string?
local add_text = function(text, highlight)
local line = NuiLine()
line:append(text, highlight)
return line
end
---@param state neotree.State
---@param prefix_key string?
local get_sub_keys = function(state, prefix_key)
local keys = utils.get_keys(state.resolved_mappings, true)
if prefix_key then
local len = prefix_key:len()
local sub_keys = {}
for _, key in ipairs(keys) do
if #key > len and key:sub(1, len) == prefix_key then
table.insert(sub_keys, key)
end
end
return sub_keys
else
return keys
end
end
---@param key string
---@param prefix string?
local function key_minus_prefix(key, prefix)
if prefix then
return key:sub(prefix:len() + 1)
else
return key
end
end
---Shows a help screen for the mapped commands when will execute those commands
---when the corresponding key is pressed.
---@param state neotree.State state of the source.
---@param title string? if this is a sub-menu for a multi-key mapping, the title for the window.
---@param prefix_key string? if this is a sub-menu, the start of tehe multi-key mapping
M.show = function(state, title, prefix_key)
local tree_width = vim.api.nvim_win_get_width(state.winid)
local keys = get_sub_keys(state, prefix_key)
local lines = { add_text("") }
lines[1] = add_text(" Press the corresponding key to execute the command.", "Comment")
lines[2] = add_text(" Press <Esc> to cancel.", "Comment")
lines[3] = add_text("")
local header = NuiLine()
header:append(string.format(" %14s", "KEY(S)"), highlights.ROOT_NAME)
header:append(" ", highlights.DIM_TEXT)
header:append("COMMAND", highlights.ROOT_NAME)
lines[4] = header
local max_width = #lines[1]:content()
for _, key in ipairs(keys) do
---@type neotree.State.ResolvedMapping
local value = state.resolved_mappings[key]
or { text = "<error mapping for key " .. key .. ">", handler = function() end }
local nline = NuiLine()
nline:append(string.format(" %14s", key_minus_prefix(key, prefix_key)), highlights.FILTER_TERM)
nline:append(" -> ", highlights.DIM_TEXT)
nline:append(value.text, highlights.NORMAL)
local line = nline:content()
if #line > max_width then
max_width = #line
end
table.insert(lines, nline)
end
local width = math.min(60, max_width + 1)
local col
if state.current_position == "right" then
col = vim.o.columns - tree_width - width - 1
else
col = tree_width - 1
end
---@type nui_popup_options
local options = {
position = {
row = 2,
col = col,
},
size = {
width = width,
height = #keys + 5,
},
enter = true,
focusable = true,
zindex = 50,
relative = "editor",
win_options = {
foldenable = false, -- Prevent folds from hiding lines
},
}
---@return integer lines The number of screen lines that the popup should occupy at most
local popup_max_height = function()
-- statusline
local statusline_lines = 0
local laststatus = vim.o.laststatus
if laststatus ~= 0 then
local windows = vim.api.nvim_tabpage_list_wins(0)
if (laststatus == 1 and #windows > 1) or laststatus > 1 then
statusline_lines = 1
end
end
-- tabs
local tab_lines = 0
local showtabline = vim.o.showtabline
if showtabline ~= 0 then
local tabs = vim.api.nvim_list_tabpages()
if (showtabline == 1 and #tabs > 1) or showtabline == 2 then
tab_lines = 1
end
end
return vim.o.lines - vim.o.cmdheight - statusline_lines - tab_lines - 2
end
local max_height = popup_max_height()
if options.size.height > max_height then
options.size.height = max_height
end
title = title or "Neotree Help"
options = popups.popup_options(title, width, options)
local popup = Popup(options)
popup:mount()
local event = require("nui.utils.autocmd").event
popup:on({ event.VimResized }, function()
popup:update_layout({
size = {
height = math.min(options.size.height --[[@as integer]], popup_max_height()),
width = math.min(options.size.width --[[@as integer]], vim.o.columns - 2),
},
})
end)
popup:on({ event.BufLeave, event.BufDelete }, function()
popup:unmount()
end, { once = true })
popup:map("n", "<esc>", function()
popup:unmount()
end, { noremap = true })
for _, key in ipairs(keys) do
-- map everything except for <escape>
if string.match(key:lower(), "^<esc") == nil then
local value = state.resolved_mappings[key]
or { text = "<error mapping for key " .. key .. ">", handler = function() end }
popup:map("n", key_minus_prefix(key, prefix_key), function()
popup:unmount()
vim.api.nvim_set_current_win(state.winid)
value.handler()
end)
end
end
for i, line in ipairs(lines) do
line:render(popup.bufnr, -1, i)
end
end
return M

View file

@ -0,0 +1,49 @@
local events = require("neo-tree.events")
local log = require("neo-tree.log")
local manager = require("neo-tree.sources.manager")
local M = {}
local hijack_cursor_handler = function()
if vim.o.filetype ~= "neo-tree" then
return
end
local success, source = pcall(vim.api.nvim_buf_get_var, 0, "neo_tree_source")
if not success then
log.debug("Cursor hijack failure: " .. vim.inspect(source))
return
end
local winid = nil
local _, position = pcall(vim.api.nvim_buf_get_var, 0, "neo_tree_position")
if position == "current" then
winid = vim.api.nvim_get_current_win()
end
local state = manager.get_state(source, nil, winid)
if not state or not state.tree then
return
end
local node = state.tree:get_node()
if not node then
return
end
log.debug("Cursor moved in tree window, hijacking cursor position")
local cursor = vim.api.nvim_win_get_cursor(0)
local row = cursor[1]
local current_line = vim.api.nvim_get_current_line()
local startIndex, _ = string.find(current_line, node.name, nil, true)
if startIndex then
vim.api.nvim_win_set_cursor(0, { row, startIndex - 1 })
end
end
--Enables cursor hijack behavior for all sources
M.setup = function()
events.subscribe({
event = events.VIM_CURSOR_MOVED,
handler = hijack_cursor_handler,
id = "neo-tree-hijack-cursor",
})
end
return M

View file

@ -0,0 +1,85 @@
local log = require("neo-tree.log")
local utils = require("neo-tree.utils")
local M = {}
--- Recursively expand all loaded nodes under the given node
--- returns table with all discovered nodes that need to be loaded
---@param node table a node to expand
---@param state neotree.State current state of the source
---@return table discovered nodes that need to be loaded
local function expand_loaded(node, state, prefetcher)
local function rec(current_node, to_load)
if prefetcher.should_prefetch(current_node) then
log.trace("Node " .. current_node:get_id() .. "not loaded, saving for later")
table.insert(to_load, current_node)
else
if not current_node:is_expanded() then
current_node:expand()
state.explicitly_opened_nodes[current_node:get_id()] = true
end
local children = state.tree:get_nodes(current_node:get_id())
log.debug("Expanding childrens of " .. current_node:get_id())
for _, child in ipairs(children) do
if utils.is_expandable(child) then
rec(child, to_load)
else
log.trace("Child: " .. (child.name or "") .. " is not expandable, skipping")
end
end
end
end
local to_load = {}
rec(node, to_load)
return to_load
end
--- Recursively expands all nodes under the given node collecting all unloaded nodes
--- Then run prefetcher on all unloaded nodes. Finally, expand loded nodes.
--- async method
---@param node table a node to expand
---@param state neotree.State current state of the source
local function expand_and_load(node, state, prefetcher)
local to_load = expand_loaded(node, state, prefetcher)
for _, _node in ipairs(to_load) do
prefetcher.prefetch(state, _node)
-- no need to handle results as prefetch is recursive
expand_loaded(_node, state, prefetcher)
end
end
--- Expands given node recursively loading all descendant nodes if needed
--- Nodes will be loaded using given prefetcher
--- async method
---@param state neotree.State current state of the source
---@param node table a node to expand
---@param prefetcher table? an object with two methods `prefetch(state, node)` and `should_prefetch(node) => boolean`
M.expand_directory_recursively = function(state, node, prefetcher)
log.debug("Expanding directory " .. node:get_id())
prefetcher = prefetcher or M.default_prefetcher
if not utils.is_expandable(node) then
return
end
state.explicitly_opened_nodes = state.explicitly_opened_nodes or {}
if prefetcher.should_prefetch(node) then
local id = node:get_id()
state.explicitly_opened_nodes[id] = true
prefetcher.prefetch(state, node)
expand_loaded(node, state, prefetcher)
else
expand_and_load(node, state, prefetcher)
end
end
M.default_prefetcher = {
prefetch = function(state, node)
log.debug("Default expander prefetch does nothing")
end,
should_prefetch = function(node)
return false
end,
}
return M

View file

@ -0,0 +1,572 @@
local utils = require("neo-tree.utils")
local highlights = require("neo-tree.ui.highlights")
local events = require("neo-tree.events")
local manager = require("neo-tree.sources.manager")
local log = require("neo-tree.log")
local renderer = require("neo-tree.ui.renderer")
local NuiPopup = require("nui.popup")
---@class neotree.Preview.Config
---@field use_float boolean?
---@field use_image_nvim boolean?
---@field use_snacks_image boolean?
---@class neotree.Preview.Event
---@field source string?
---@field event neotree.event.Handler
---@class neotree.Preview
---@field config neotree.Preview.Config?
---@field active boolean Whether the preview is active.
---@field winid integer The id of the window being used to preview.
---@field is_neo_tree_window boolean Whether the preview window belongs to neo-tree.
---@field bufnr number The buffer that is currently in the preview window.
---@field start_pos integer[]? An array-like table specifying the (0-indexed) starting position of the previewed text.
---@field end_pos integer[]? An array-like table specifying the (0-indexed) ending position of the preview text.
---@field truth table A table containing information to be restored when the preview ends.
---@field events neotree.Preview.Event[] A list of events the preview is subscribed to.
local Preview = {}
---@type neotree.Preview?
local instance = nil
local neo_tree_preview_namespace = vim.api.nvim_create_namespace("neo_tree_preview")
---@param state neotree.State
local function create_floating_preview_window(state)
local default_position = utils.resolve_config_option(state, "window.position", "left")
state.current_position = state.current_position or default_position
local title = state.config.title or "Neo-tree Preview"
local winwidth = vim.api.nvim_win_get_width(state.winid)
local winheight = vim.api.nvim_win_get_height(state.winid)
local height = vim.o.lines - 4
local width = 120
local row, col = 0, 0
if state.current_position == "left" then
col = winwidth + 1
width = math.min(vim.o.columns - col, 120)
elseif state.current_position == "top" or state.current_position == "bottom" then
height = height - winheight
width = winwidth - 2
if state.current_position == "top" then
row = vim.api.nvim_win_get_height(state.winid) + 1
end
elseif state.current_position == "right" then
width = math.min(vim.o.columns - winwidth - 4, 120)
col = vim.o.columns - winwidth - width - 3
elseif state.current_position == "float" then
local pos = vim.api.nvim_win_get_position(state.winid)
-- preview will be same height and top as tree
row = pos[1]
height = winheight
-- tree and preview window will be side by side and centered in the editor
width = math.min(vim.o.columns - winwidth - 4, 120)
local total_width = winwidth + width + 4
local margin = math.floor((vim.o.columns - total_width) / 2)
col = margin + winwidth + 2
-- move the tree window to make the combined layout centered
local popup = renderer.get_nui_popup(state.winid)
popup:update_layout({
relative = "editor",
position = {
row = row,
col = margin,
},
})
else
local cur_pos = state.current_position or "unknown"
log.error('Preview cannot be used when position = "' .. cur_pos .. '"')
return
end
if height < 5 or width < 5 then
log.error(
"Preview cannot be used without any space, please resize the neo-tree split to allow for at least 5 cells of free space."
)
return
end
local popups = require("neo-tree.ui.popups")
local options = popups.popup_options(title, width, {
ns_id = highlights.ns_id,
size = { height = height, width = width },
relative = "editor",
position = {
row = row,
col = col,
},
win_options = {
number = true,
winhighlight = "Normal:"
.. highlights.FLOAT_NORMAL
.. ",FloatBorder:"
.. highlights.FLOAT_BORDER,
},
})
options.zindex = 40
options.buf_options.filetype = "neo-tree-preview"
local win = NuiPopup(options)
win:mount()
return win
end
---Creates a new preview.
---@param state neotree.State The state of the source.
---@return neotree.Preview preview A new preview. A preview is a table consisting of the following keys:
--These keys should not be altered directly. Note that the keys `start_pos`, `end_pos` and `truth`
--may be inaccurate if `active` is false.
function Preview:new(state)
local preview = {}
preview.active = false
preview.config = vim.deepcopy(state.config)
setmetatable(preview, { __index = self })
preview:findWindow(state)
return preview
end
---Preview a buffer in the preview window and optionally reveal and highlight the previewed text.
---@param bufnr integer? The number of the buffer to be previewed.
---@param start_pos integer[]? The (0-indexed) starting position of the previewed text. May be absent.
---@param end_pos integer[]? The (0-indexed) ending position of the previewed text. May be absent
function Preview:preview(bufnr, start_pos, end_pos)
if self.is_neo_tree_window then
log.warn("Could not find appropriate window for preview")
return
end
bufnr = bufnr or self.bufnr
if not self.active then
self:activate()
end
if not self.active then
return
end
self:setBuffer(bufnr)
self.start_pos = start_pos
self.end_pos = end_pos
self:reveal()
self:highlight_preview_range()
end
---Reverts the preview and inactivates it, restoring the preview window to its previous state.
function Preview:revert()
self.active = false
self:unsubscribe()
if not renderer.is_window_valid(self.winid) then
self.winid = nil
return
end
if self.config.use_float then
vim.api.nvim_win_close(self.winid, true)
self.winid = nil
return
else
local foldenable = utils.get_value(self.truth, "options.foldenable", nil, false)
if foldenable ~= nil then
vim.wo[self.winid].foldenable = self.truth.options.foldenable
end
vim.api.nvim_win_set_var(self.winid, "neo_tree_preview", 0)
end
local bufnr = self.truth.bufnr
if type(bufnr) ~= "number" then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
self:setBuffer(bufnr)
if vim.api.nvim_win_is_valid(self.winid) then
vim.api.nvim_win_call(self.winid, function()
vim.fn.winrestview(self.truth.view)
end)
end
vim.bo[self.bufnr].bufhidden = self.truth.options.bufhidden
end
---Subscribe to event and add it to the preview event list.
---@param source string? Name of the source to add the event to. Will use `events.subscribe` if nil.
---@param event neotree.event.Handler Event to subscribe to.
function Preview:subscribe(source, event)
if source == nil then
events.subscribe(event)
else
manager.subscribe(source, event)
end
self.events = self.events or {}
table.insert(self.events, { source = source, event = event })
end
---Unsubscribe to all events in the preview event list.
function Preview:unsubscribe()
if self.events == nil then
return
end
for _, event in ipairs(self.events) do
if event.source == nil then
events.unsubscribe(event.event)
else
manager.unsubscribe(event.source, event.event)
end
end
self.events = {}
end
---Finds the appropriate window and updates the preview accordingly.
---@param state neotree.State The state of the source.
function Preview:findWindow(state)
local winid, is_neo_tree_window
if self.config.use_float then
if
type(self.winid) == "number"
and vim.api.nvim_win_is_valid(self.winid)
and utils.is_floating(self.winid)
then
return
end
local win = create_floating_preview_window(state)
if not win then
self.active = false
return
end
winid = win.winid
is_neo_tree_window = false
else
winid, is_neo_tree_window = utils.get_appropriate_window(state)
self.bufnr = vim.api.nvim_win_get_buf(winid)
end
if winid == self.winid then
return
end
self.winid, self.is_neo_tree_window = winid, is_neo_tree_window
if self.active then
self:revert()
self:preview()
end
end
---Activates the preview, but does not populate the preview window,
function Preview:activate()
if self.active then
return
end
if not renderer.is_window_valid(self.winid) then
return
end
if self.config.use_float then
self.bufnr = vim.api.nvim_create_buf(false, true)
self.truth = {}
else
self.truth = {
bufnr = self.bufnr,
view = vim.api.nvim_win_call(self.winid, vim.fn.winsaveview),
options = {
bufhidden = vim.bo[self.bufnr].bufhidden,
foldenable = vim.wo[self.winid].foldenable,
},
}
vim.bo[self.bufnr].bufhidden = "hide"
vim.wo[self.winid].foldenable = false
end
self.active = true
vim.api.nvim_win_set_var(self.winid, "neo_tree_preview", 1)
end
---@param winid number
---@param bufnr number
---@return boolean hijacked Whether the buffer was successfully hijacked.
local function try_load_image_nvim_buf(winid, bufnr)
-- notify only image.nvim to let it try and hijack
local image_augroup = vim.api.nvim_create_augroup("image.nvim", { clear = false })
if #vim.api.nvim_get_autocmds({ group = image_augroup }) == 0 then
local image_available, image = pcall(require, "image")
if not image_available then
local image_nvim_url = "https://github.com/3rd/image.nvim"
log.debug(
"use_image_nvim was set but image.nvim was not found. Install from: " .. image_nvim_url
)
return false
end
log.warn("image.nvim was not setup. Calling require('image').setup().")
image.setup()
end
vim.opt.eventignore:remove("BufWinEnter")
local ok = pcall(vim.api.nvim_win_call, winid, function()
vim.api.nvim_exec_autocmds("BufWinEnter", { group = image_augroup, buffer = bufnr })
end)
vim.opt.eventignore:append("BufWinEnter")
if not ok then
log.debug("image.nvim doesn't have any file patterns to hijack.")
return false
end
if vim.bo[bufnr].filetype ~= "image_nvim" then
return false
end
return true
end
---@param bufnr number The buffer number of the buffer to set.
---@return number bytecount The number of bytes in the buffer
local get_bufsize = function(bufnr)
return vim.api.nvim_buf_call(bufnr, function()
return vim.fn.line2byte(vim.fn.line("$") + 1)
end)
end
events.subscribe({
event = events.NEO_TREE_PREVIEW_BEFORE_RENDER,
---@param args neotree.event.args.PREVIEW_BEFORE_RENDER
handler = function(args)
local preview = args.preview
local bufnr = args.bufnr
if not preview.config.use_snacks_image then
return
end
-- check if snacks.image is available
local snacks_image_ok, image = pcall(require, "snacks.image")
if not snacks_image_ok then
local snacks_nvim_url = "https://github.com/folke/snacks.nvim"
log.debug(
"use_snacks_image was set but snacks.nvim was not found. Install from: " .. snacks_nvim_url
)
return
end
local bufname = vim.api.nvim_buf_get_name(bufnr)
-- try attaching it
if image.supports(bufname) then
image.placement.new(preview.bufnr, bufname)
vim.bo[preview.bufnr].modifiable = true
return { handled = true } -- let snacks.image handle the rest
end
end,
})
events.subscribe({
event = events.NEO_TREE_PREVIEW_BEFORE_RENDER,
---@param args neotree.event.args.PREVIEW_BEFORE_RENDER
handler = function(args)
local preview = args.preview
local bufnr = args.bufnr
if preview.config.use_image_nvim and try_load_image_nvim_buf(preview.winid, bufnr) then
-- calling the try method twice should be okay here, image.nvim should cache the image and displaying the image takes
-- really long anyways
vim.api.nvim_win_set_buf(preview.winid, bufnr)
return { handled = try_load_image_nvim_buf(preview.winid, bufnr) }
end
end,
})
---Set the buffer in the preview window without executing BufEnter or BufWinEnter autocommands.
---@param bufnr number The buffer number of the buffer to set.
function Preview:setBuffer(bufnr)
self:clearHighlight()
if bufnr == self.bufnr then
return
end
local eventignore = vim.opt.eventignore
vim.opt.eventignore:append("BufEnter,BufWinEnter")
repeat
---@class neotree.event.args.PREVIEW_BEFORE_RENDER
local args = {
preview = self,
bufnr = bufnr,
}
events.fire_event(events.NEO_TREE_PREVIEW_BEFORE_RENDER, args)
if self.config.use_float then
-- Workaround until https://github.com/neovim/neovim/issues/24973 is resolved or maybe 'previewpopup' comes in?
vim.fn.bufload(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
vim.api.nvim_buf_set_lines(self.bufnr, 0, -1, false, lines)
vim.api.nvim_win_set_buf(self.winid, self.bufnr)
-- I'm not sure why float windows won't show numbers without this
vim.wo[self.winid].number = true
-- code below is from mini.pick
-- only starts treesitter parser if the filetype is matching
local ft = vim.bo[bufnr].filetype
local bufsize = get_bufsize(bufnr)
if bufsize > 1024 * 1024 or bufsize > 1000 * #lines then
break -- goto end
end
local has_lang, lang = pcall(vim.treesitter.language.get_lang, ft)
lang = has_lang and lang or ft
local has_parser, parser =
pcall(vim.treesitter.get_parser, self.bufnr, lang, { error = false })
has_parser = has_parser and parser ~= nil
if has_parser then
has_parser = pcall(vim.treesitter.start, self.bufnr, lang)
end
if not has_parser then
vim.bo[self.bufnr].syntax = ft
end
else
vim.api.nvim_win_set_buf(self.winid, bufnr)
self.bufnr = bufnr
end
until true
vim.opt.eventignore = eventignore
end
---Move the cursor to the previewed position and center the screen.
function Preview:reveal()
local pos = self.start_pos or self.end_pos
if not self.active or not self.winid or not pos then
return
end
vim.api.nvim_win_set_cursor(self.winid, { (pos[1] or 0) + 1, pos[2] or 0 })
vim.api.nvim_win_call(self.winid, function()
vim.cmd("normal! zz")
end)
end
---Highlight the previewed range
function Preview:highlight_preview_range()
if not self.active or not self.bufnr then
return
end
local start_pos, end_pos = self.start_pos, self.end_pos
if not start_pos and not end_pos then
return
end
if not start_pos then
---@cast end_pos table
start_pos = end_pos
elseif not end_pos then
---@cast start_pos table
end_pos = start_pos
end
local start_line, end_line = start_pos[1], end_pos[1]
local start_col, end_col = start_pos[2], end_pos[2]
vim.api.nvim_buf_set_extmark(self.bufnr, neo_tree_preview_namespace, start_line, start_col, {
hl_group = highlights.PREVIEW,
end_row = end_line,
end_col = end_col,
-- priority = priority,
strict = false,
})
end
---Clear the preview highlight in the buffer currently in the preview window.
function Preview:clearHighlight()
if type(self.bufnr) == "number" and vim.api.nvim_buf_is_valid(self.bufnr) then
vim.api.nvim_buf_clear_namespace(self.bufnr, neo_tree_preview_namespace, 0, -1)
end
end
local toggle_state = false
Preview.hide = function()
toggle_state = false
if instance then
instance:revert()
end
instance = nil
end
Preview.is_active = function()
return instance and instance.active
end
---@param state neotree.State
Preview.show = function(state)
local node = assert(state.tree:get_node())
if instance then
instance:findWindow(state)
else
instance = Preview:new(state)
end
local extra = node.extra or {}
local position = extra.position
local end_position = extra.end_position
local path = node.path or node:get_id()
local bufnr = extra.bufnr or vim.fn.bufadd(path)
if bufnr and bufnr > 0 and instance then
instance:preview(bufnr, position, end_position)
end
end
---@param state neotree.State
Preview.toggle = function(state)
if toggle_state then
Preview.hide()
else
Preview.show(state)
if instance and instance.active then
toggle_state = true
else
Preview.hide()
return
end
local winid = state.winid
local source_name = state.name
local preview_event = {
event = events.VIM_CURSOR_MOVED,
handler = function()
local did_enter_preview = vim.api.nvim_get_current_win() == instance.winid
if not toggle_state or (did_enter_preview and instance.config.use_float) then
return
end
if vim.api.nvim_get_current_win() == winid then
log.debug("Cursor moved in tree window, updating preview")
Preview.show(state)
else
log.debug("Neo-tree window lost focus, disposing preview")
Preview.hide()
end
end,
id = "preview-event",
}
instance:subscribe(source_name, preview_event)
end
end
Preview.focus = function()
if Preview.is_active() then
---@cast instance table
vim.fn.win_gotoid(instance.winid)
end
end
local CTRL_E = utils.keycode("<c-e>")
local CTRL_Y = utils.keycode("<c-y>")
---@param state neotree.State
Preview.scroll = function(state)
local direction = state.config.direction
local input = direction < 0 and CTRL_E or CTRL_Y
local count = math.abs(direction)
if Preview:is_active() then
---@cast instance table
vim.api.nvim_win_call(instance.winid, function()
vim.cmd(("normal! %s%s"):format(count, input))
end)
else
vim.api.nvim_win_call(state.winid, function()
vim.api.nvim_feedkeys(state.fallback, "n", false)
end)
end
end
return Preview

View file

@ -0,0 +1,71 @@
--This file should contain all commands meant to be used by mappings.
local cc = require("neo-tree.sources.common.commands")
local utils = require("neo-tree.utils")
local manager = require("neo-tree.sources.manager")
local inputs = require("neo-tree.ui.inputs")
local filters = require("neo-tree.sources.common.filters")
---@class neotree.sources.DocumentSymbols.Commands : neotree.sources.Common.Commands
---@field [string] neotree.TreeCommand
local M = {}
local SOURCE_NAME = "document_symbols"
M.refresh = utils.wrap(manager.refresh, SOURCE_NAME)
M.redraw = utils.wrap(manager.redraw, SOURCE_NAME)
M.show_debug_info = function(state)
print(vim.inspect(state))
end
---@param node NuiTree.Node
M.jump_to_symbol = function(state, node)
node = node or state.tree:get_node()
if node:get_depth() == 1 then
return
end
vim.api.nvim_set_current_win(state.lsp_winid)
vim.api.nvim_set_current_buf(state.lsp_bufnr)
local symbol_loc = node.extra.selection_range.start
vim.api.nvim_win_set_cursor(state.lsp_winid, { symbol_loc[1] + 1, symbol_loc[2] })
end
M.rename = function(state)
local node = assert(state.tree:get_node())
if node:get_depth() == 1 then
return
end
local old_name = node.name
---@param new_name string?
local callback = function(new_name)
if not new_name or new_name == "" or new_name == old_name then
return
end
M.jump_to_symbol(state, node)
vim.lsp.buf.rename(new_name)
M.refresh(state)
end
local msg = string.format('Enter new name for "%s":', old_name)
inputs.input(msg, old_name, callback)
end
M.open = M.jump_to_symbol
M.filter_on_submit = function(state)
filters.show_filter(state, true, true)
end
M.filter = function(state)
filters.show_filter(state, true)
end
cc._add_common_commands(M, "node") -- common tree commands
cc._add_common_commands(M, "^open") -- open commands
cc._add_common_commands(M, "^close_window$")
cc._add_common_commands(M, "source$") -- source navigation
cc._add_common_commands(M, "preview") -- preview
cc._add_common_commands(M, "^cancel$") -- cancel
cc._add_common_commands(M, "help") -- help commands
cc._add_common_commands(M, "with_window_picker$") -- open using window picker
cc._add_common_commands(M, "^toggle_auto_expand_width$")
return M

View file

@ -0,0 +1,66 @@
-- This file contains the built-in components. Each componment is a function
-- that takes the following arguments:
-- config: A table containing the configuration provided by the user
-- when declaring this component in their renderer config.
-- node: A NuiNode object for the currently focused node.
-- state: The current state of the source providing the items.
--
-- The function should return either a table, or a list of tables, each of which
-- contains the following keys:
-- text: The text to display for this item.
-- highlight: The highlight group to apply to this text.
local highlights = require("neo-tree.ui.highlights")
local common = require("neo-tree.sources.common.components")
---@alias neotree.Component.DocumentSymbols._Key
---|"kind_icon"
---|"kind_name"
---|"name"
---@class neotree.Component.DocumentSymbols Use the neotree.Component.DocumentSymbols.* types to get more specific types.
---@field [1] neotree.Component.DocumentSymbols._Key|neotree.Component.Common._Key
---@type table<neotree.Component.DocumentSymbols._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.DocumentSymbols.KindIcon : neotree.Component
---@field [1] "kind_icon"?
---@field provider neotree.IconProvider?
---@param config neotree.Component.DocumentSymbols.KindIcon
M.kind_icon = function(config, node, state)
local icon = {
text = node:get_depth() == 1 and "" or node.extra.kind.icon,
highlight = node.extra.kind.hl,
}
if config.provider then
icon = config.provider(icon, node, state) or icon
end
return icon
end
---@class (exact) neotree.Component.DocumentSymbols.KindName : neotree.Component
---@field [1] "kind_name"?
---@param config neotree.Component.DocumentSymbols.KindName
M.kind_name = function(config, node, state)
return {
text = node:get_depth() == 1 and "" or node.extra.kind.name,
highlight = node.extra and node.extra.kind.hl or highlights.FILE_NAME,
}
end
---@class (exact) neotree.Component.DocumentSymbols.Name : neotree.Component.Common.Name
---@param config neotree.Component.DocumentSymbols.Name
M.name = function(config, node, state)
return {
text = node.name,
highlight = node.extra and node.extra.kind.hl or highlights.FILE_NAME,
}
end
return vim.tbl_deep_extend("force", common, M)

View file

@ -0,0 +1,129 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
local manager = require("neo-tree.sources.manager")
local events = require("neo-tree.events")
local utils = require("neo-tree.utils")
local symbols = require("neo-tree.sources.document_symbols.lib.symbols_utils")
local renderer = require("neo-tree.ui.renderer")
---@class neotree.sources.DocumentSymbols : neotree.Source
local M = {
name = "document_symbols",
display_name = "  Symbols ",
}
local get_state = function()
return manager.get_state(M.name)
end
---Refresh the source with debouncing
---@param args { afile: string }
local refresh_debounced = function(args)
if utils.is_real_file(args.afile) == false then
return
end
utils.debounce(
"document_symbols_refresh",
utils.wrap(manager.refresh, M.name),
100,
utils.debounce_strategy.CALL_LAST_ONLY
)
end
---Internal function to follow the cursor
local follow_symbol = function()
local state = get_state()
if state.lsp_bufnr ~= vim.api.nvim_get_current_buf() then
return
end
local cursor = vim.api.nvim_win_get_cursor(state.lsp_winid)
local node_id = symbols.get_symbol_by_loc(state.tree, { cursor[1] - 1, cursor[2] })
if #node_id > 0 then
renderer.focus_node(state, node_id, true)
end
end
---@class neotree.sources.documentsymbols.DebounceArgs
---Follow the cursor with debouncing
---@param args { afile: string }
local follow_debounced = function(args)
if utils.is_real_file(args.afile) == false then
return
end
utils.debounce(
"document_symbols_follow",
utils.wrap(follow_symbol, args.afile),
100,
utils.debounce_strategy.CALL_LAST_ONLY
)
end
---Navigate to the given path.
M.navigate = function(state, path, path_to_reveal, callback, async)
state.lsp_winid, _ = utils.get_appropriate_window(state)
state.lsp_bufnr = vim.api.nvim_win_get_buf(state.lsp_winid)
state.path = vim.api.nvim_buf_get_name(state.lsp_bufnr)
symbols.render_symbols(state)
if type(callback) == "function" then
vim.schedule(callback)
end
end
---@class neotree.Config.LspKindDisplay
---@field icon string
---@field hl string
---@class neotree.Config.DocumentSymbols.Renderers : neotree.Config.Renderers
---@field root neotree.Component.DocumentSymbols[]?
---@field symbol neotree.Component.DocumentSymbols[]?
---@class (exact) neotree.Config.DocumentSymbols : neotree.Config.Source
---@field follow_cursor boolean?
---@field client_filters neotree.lsp.ClientFilter?
---@field custom_kinds table<integer, string>?
---@field kinds table<string, neotree.Config.LspKindDisplay>?
---@field renderers neotree.Config.DocumentSymbols.Renderers?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.DocumentSymbols
---@param global_config neotree.Config.Base
M.setup = function(config, global_config)
symbols.setup(config)
if config.before_render then
manager.subscribe(M.name, {
event = events.BEFORE_RENDER,
handler = function(state)
local this_state = get_state()
if state == this_state then
config.before_render(this_state)
end
end,
})
end
local refresh_events = {
events.VIM_BUFFER_ENTER,
events.VIM_INSERT_LEAVE,
events.VIM_TEXT_CHANGED_NORMAL,
}
for _, event in ipairs(refresh_events) do
manager.subscribe(M.name, {
event = event,
handler = refresh_debounced,
})
end
if config.follow_cursor then
manager.subscribe(M.name, {
event = events.VIM_CURSOR_MOVED,
handler = follow_debounced,
})
end
end
return M

View file

@ -0,0 +1,97 @@
---Utilities function to filter the LSP servers
local utils = require("neo-tree.utils")
---@class neotree.lsp.RespRaw
---@field err lsp.ResponseError?
---@field error lsp.ResponseError?
---@field result any
local M = {}
---@alias neotree.lsp.Filter fun(client_name: string): boolean
---Filter clients
---@param filter_type "first" | "all"
---@param filter_fn neotree.lsp.Filter?
---@param resp table<integer, neotree.lsp.RespRaw>
---@return table<string, any>
local filter_clients = function(filter_type, filter_fn, resp)
if resp == nil or type(resp) ~= "table" then
return {}
end
filter_fn = filter_fn or function(client_name)
return true
end
local result = {}
for client_id, client_resp in pairs(resp) do
local client_name = vim.lsp.get_client_by_id(client_id).name
if filter_fn(client_name) and client_resp.result ~= nil then
result[client_name] = client_resp.result
if filter_type ~= "all" then
break
end
end
end
return result
end
---Filter only allowed clients
---@param allow_only string[] the list of clients to keep
---@return neotree.lsp.Filter
local allow_only = function(allow_only)
return function(client_name)
return vim.tbl_contains(allow_only, client_name)
end
end
---Ignore clients
---@param ignore string[] the list of clients to remove
---@return neotree.lsp.Filter
local ignore = function(ignore)
return function(client_name)
return not vim.tbl_contains(ignore, client_name)
end
end
---Main entry point for the filter
---@param resp table<integer, neotree.lsp.RespRaw>
---@return table<string, any>
M.filter_resp = function(resp)
return {}
end
---@alias neotree.lsp.Filter.Type
---|"first" # Allow the first that matches
---|"all" # Allow all that match
---@alias neotree.lsp.ClientFilter neotree.lsp.Filter.Type | { type: neotree.lsp.Filter.Type, fn: neotree.lsp.Filter, allow_only: string[], ignore: string[] }
---Setup the filter accordingly to the config
---@see neo-tree-document-symbols-source for more details on options that the filter accepts
---@param cfg_flt neotree.lsp.ClientFilter
M.setup = function(cfg_flt)
local filter_type = "first"
local filter_fn = nil
if type(cfg_flt) == "table" then
if cfg_flt.type == "all" then
filter_type = "all"
end
if cfg_flt.fn ~= nil then
filter_fn = cfg_flt.fn
elseif cfg_flt.allow_only then
filter_fn = allow_only(cfg_flt.allow_only)
elseif cfg_flt.ignore then
filter_fn = ignore(cfg_flt.ignore)
end
elseif cfg_flt == "all" then
filter_type = "all"
end
M.filter_resp = function(resp)
return filter_clients(filter_type, filter_fn, resp)
end
end
return M

View file

@ -0,0 +1,67 @@
---Helper module to render symbols' kinds
---Need to be initialized by calling M.setup()
local M = {}
local kinds_id_to_name = {
[0] = "Root",
[1] = "File",
[2] = "Module",
[3] = "Namespace",
[4] = "Package",
[5] = "Class",
[6] = "Method",
[7] = "Property",
[8] = "Field",
[9] = "Constructor",
[10] = "Enum",
[11] = "Interface",
[12] = "Function",
[13] = "Variable",
[14] = "Constant",
[15] = "String",
[16] = "Number",
[17] = "Boolean",
[18] = "Array",
[19] = "Object",
[20] = "Key",
[21] = "Null",
[22] = "EnumMember",
[23] = "Struct",
[24] = "Event",
[25] = "Operator",
[26] = "TypeParameter",
}
local kinds_map = {}
---@class neotree.LspKindDisplay
---@field name string Display name
---@field icon string Icon to render
---@field hl string Highlight for the node
---Get how the kind with kind_id should be rendered
---@param kind_id integer the kind_id to be render
---@return neotree.LspKindDisplay res
M.get_kind = function(kind_id)
local kind_name = kinds_id_to_name[kind_id]
return vim.tbl_extend(
"force",
{ name = kind_name or ("Unknown: " .. kind_id), icon = "?", hl = "" },
kind_name and (kinds_map[kind_name] or {}) or kinds_map["Unknown"]
)
end
---Setup the module with custom kinds
---@param custom_kinds table additional kinds, should be of the form { [kind_id] = kind_name }
---@param kinds_display table mapping of kind_name to corresponding display name, icon and hl group
--- { [kind_name] = {
--- name = kind_display_name,
--- icon = kind_icon,
--- hl = kind_hl
--- }, }
M.setup = function(custom_kinds, kinds_display)
kinds_id_to_name = vim.tbl_deep_extend("force", kinds_id_to_name, custom_kinds or {})
kinds_map = kinds_display
end
return M

View file

@ -0,0 +1,211 @@
---Utilities functions for the document_symbols source
local renderer = require("neo-tree.ui.renderer")
local filters = require("neo-tree.sources.document_symbols.lib.client_filters")
local kinds = require("neo-tree.sources.document_symbols.lib.kinds")
local M = {}
---@alias Loc integer[] a location in a buffer {row, col}, 0-indexed
---@alias LocRange { start: Loc, ["end"]: Loc } a range consisting of two loc
---@class neotree.SymbolExtra
---@field bufnr integer the buffer containing the symbols,
---@field kind neotree.LspKindDisplay the kind of each symbol
---@field selection_range LocRange the symbol's location
---@field position Loc start of symbol's definition
---@field end_position Loc start of symbol's definition
---@class neotree.SymbolNode see
---@field id string
---@field name string name of symbol
---@field path string buffer path - should all be the same
---@field type "root"|"symbol"
---@field children neotree.SymbolNode[]
---@field extra neotree.SymbolExtra additional info
---Parse the lsp.Range
---@param range lsp.Range the lsp.Range object to parse
---@return LocRange range the parsed range
local parse_range = function(range)
return {
start = { range.start.line, range.start.character },
["end"] = { range["end"].line, range["end"].character },
}
end
---Compare two tuples of length 2 by first - second elements
---@param a Loc
---@param b Loc
---@return boolean
local loc_less_than = function(a, b)
if a[1] < b[1] then
return true
elseif a[1] == b[1] then
return a[2] <= b[2]
end
return false
end
---Check whether loc is contained in range, i.e range[1] <= loc <= range[2]
---@param loc Loc
---@param range LocRange
---@return boolean
M.is_loc_in_range = function(loc, range)
return loc_less_than(range[1], loc) and loc_less_than(loc, range[2])
end
---Get the the current symbol under the cursor
---@param tree any the Nui symbol tree
---@param loc Loc the cursor location {row, col} (0-index)
---@return string node_id
M.get_symbol_by_loc = function(tree, loc)
local function dfs(node)
local node_id = node:get_id()
if node:has_children() then
for _, child in ipairs(tree:get_nodes(node_id)) do
if M.is_loc_in_range(loc, { child.extra.position, child.extra.end_position }) then
return dfs(child)
end
end
end
return node_id
end
for _, root in ipairs(tree:get_nodes()) do
local node_id = dfs(root)
if node_id ~= root:get_id() then
return node_id
end
end
return ""
end
---Parse the LSP response into a tree. Each node on the tree follows
---the same structure as a NuiTree node, with the extra field
---containing additional information.
---@param resp_node lsp.DocumentSymbol|lsp.SymbolInformation the LSP response node
---@param id string the id of the current node
---@return neotree.SymbolNode symb_node the parsed tree
local function parse_resp(resp_node, id, state, parent_search_path)
-- parse all children
local children = {}
local search_path = parent_search_path .. "/" .. resp_node.name
for i, child in ipairs(resp_node.children or {}) do
local child_node = parse_resp(child, id .. "." .. i, state, search_path)
table.insert(children, child_node)
end
---@type neotree.SymbolNode
local symbol_node = {
id = id,
name = resp_node.name,
type = "symbol",
path = state.path,
children = children,
---@diagnostic disable-next-line: missing-fields
extra = {
bufnr = state.lsp_bufnr,
kind = kinds.get_kind(resp_node.kind),
search_path = search_path,
-- detail = resp_node.detail,
},
}
local preview_range = resp_node.range
if preview_range then
---@cast resp_node lsp.DocumentSymbol
symbol_node.extra.selection_range = parse_range(resp_node.selectionRange)
else
---@cast resp_node lsp.SymbolInformation
preview_range = resp_node.location.range
symbol_node.extra.selection_range = parse_range(preview_range)
end
preview_range = parse_range(preview_range)
symbol_node.extra.position = preview_range.start
symbol_node.extra.end_position = preview_range["end"]
return symbol_node
end
---Callback function for lsp request
---@param lsp_resp table<integer, neotree.lsp.RespRaw> the response of the lsp clients
---@param state neotree.State the state of the source
local on_lsp_resp = function(lsp_resp, state)
if lsp_resp == nil or type(lsp_resp) ~= "table" then
return
end
-- filter the response to get only the desired LSP
local resp = filters.filter_resp(lsp_resp)
local bufname = assert(state.path)
local items = {}
-- parse each client's response
for client_name, client_result in pairs(resp) do
local symbol_list = {}
for i, resp_node in ipairs(client_result) do
table.insert(symbol_list, parse_resp(resp_node, #items .. "." .. i, state, "/"))
end
-- add the parsed response to the tree
local splits = vim.split(bufname, "/")
local filename = splits[#splits]
table.insert(items, {
id = "" .. #items,
name = string.format("SYMBOLS (%s) in %s", client_name, filename),
path = bufname,
type = "root",
children = symbol_list,
extra = { kind = kinds.get_kind(0), search_path = "/" },
})
end
renderer.show_nodes(items, state)
end
---latter is deprecated in neovim v0.11
---@diagnostic disable-next-line: deprecated
local get_clients = vim.lsp.get_clients or vim.lsp.get_active_clients
M.render_symbols = function(state)
local bufnr = state.lsp_bufnr
local bufname = state.path
-- if no client found, terminate
local client_found = false
for _, client in pairs(get_clients({ bufnr = bufnr })) do
if client.server_capabilities.documentSymbolProvider then
client_found = true
break
end
end
if not client_found then
local splits = vim.split(bufname, "/")
renderer.show_nodes({
{
id = "0",
name = "No client found for " .. splits[#splits],
path = bufname,
type = "root",
children = {},
extra = { kind = kinds.get_kind(0), search_path = "/" },
},
}, state)
return
end
-- client found
vim.lsp.buf_request_all(
bufnr,
"textDocument/documentSymbol",
{ textDocument = vim.lsp.util.make_text_document_params(bufnr) },
function(resp)
on_lsp_resp(resp, state)
end
)
end
M.setup = function(config)
filters.setup(config.client_filters)
kinds.setup(config.custom_kinds, config.kinds)
end
return M

View file

@ -0,0 +1,284 @@
--This file should contain all commands meant to be used by mappings.
local cc = require("neo-tree.sources.common.commands")
local fs = require("neo-tree.sources.filesystem")
local utils = require("neo-tree.utils")
local filter = require("neo-tree.sources.filesystem.lib.filter")
local renderer = require("neo-tree.ui.renderer")
local log = require("neo-tree.log")
local uv = vim.uv or vim.loop
---@class neotree.sources.Filesystem.Commands : neotree.sources.Common.Commands
local M = {}
local refresh = function(state)
fs._navigate_internal(state, nil, nil, nil, false)
end
local redraw = function(state)
renderer.redraw(state)
end
M.add = function(state)
cc.add(state, utils.wrap(fs.show_new_children, state))
end
M.add_directory = function(state)
cc.add_directory(state, utils.wrap(fs.show_new_children, state))
end
M.clear_filter = function(state)
fs.reset_search(state, true)
end
M.copy = function(state)
cc.copy(state, utils.wrap(fs.focus_destination_children, state))
end
---Marks node as copied, so that it can be pasted somewhere else.
M.copy_to_clipboard = function(state)
cc.copy_to_clipboard(state, utils.wrap(redraw, state))
end
---@type neotree.TreeCommandVisual
M.copy_to_clipboard_visual = function(state, selected_nodes)
cc.copy_to_clipboard_visual(state, selected_nodes, utils.wrap(redraw, state))
end
---Marks node as cut, so that it can be pasted (moved) somewhere else.
M.cut_to_clipboard = function(state)
cc.cut_to_clipboard(state, utils.wrap(redraw, state))
end
---@type neotree.TreeCommandVisual
M.cut_to_clipboard_visual = function(state, selected_nodes)
cc.cut_to_clipboard_visual(state, selected_nodes, utils.wrap(redraw, state))
end
M.move = function(state)
cc.move(state, utils.wrap(fs.focus_destination_children, state))
end
---Pastes all items from the clipboard to the current directory.
M.paste_from_clipboard = function(state)
cc.paste_from_clipboard(state, utils.wrap(fs.show_new_children, state))
end
M.delete = function(state)
cc.delete(state, utils.wrap(refresh, state))
end
---@type neotree.TreeCommandVisual
M.delete_visual = function(state, selected_nodes)
cc.delete_visual(state, selected_nodes, utils.wrap(refresh, state))
end
M.expand_all_nodes = function(state, node)
cc.expand_all_nodes(state, node, fs.prefetcher)
end
M.expand_all_subnodes = function(state, node)
cc.expand_all_subnodes(state, node, fs.prefetcher)
end
---Shows the filter input, which will filter the tree.
---@param state neotree.sources.filesystem.State
M.filter_as_you_type = function(state)
local config = state.config or {}
filter.show_filter(state, true, false, false, config.keep_filter_on_submit or false)
end
---Shows the filter input, which will filter the tree.
---@param state neotree.sources.filesystem.State
M.filter_on_submit = function(state)
filter.show_filter(state, false, false, false, true)
end
---Shows the filter input in fuzzy finder mode.
---@param state neotree.sources.filesystem.State
M.fuzzy_finder = function(state)
local config = state.config or {}
filter.show_filter(state, true, true, false, config.keep_filter_on_submit or false)
end
---Shows the filter input in fuzzy finder mode.
---@param state neotree.sources.filesystem.State
M.fuzzy_finder_directory = function(state)
local config = state.config or {}
filter.show_filter(state, true, "directory", false, config.keep_filter_on_submit or false)
end
---Shows the filter input in fuzzy sorter
---@param state neotree.sources.filesystem.State
M.fuzzy_sorter = function(state)
local config = state.config or {}
filter.show_filter(state, true, true, true, config.keep_filter_on_submit or false)
end
---Shows the filter input in fuzzy sorter with only directories
---@param state neotree.sources.filesystem.State
M.fuzzy_sorter_directory = function(state)
local config = state.config or {}
filter.show_filter(state, true, "directory", true, config.keep_filter_on_submit or false)
end
---Navigate up one level.
---@param state neotree.sources.filesystem.State
M.navigate_up = function(state)
local parent_path, _ = utils.split_path(state.path)
if not utils.truthy(parent_path) then
return
end
local path_to_reveal = nil
local node = state.tree:get_node()
if node then
path_to_reveal = node:get_id()
end
if state.search_pattern then
fs.reset_search(state, false)
end
log.debug("Changing directory to:", parent_path)
fs._navigate_internal(state, parent_path, path_to_reveal, nil, false)
end
local focus_next_git_modified = function(state, reverse)
local node = state.tree:get_node()
local current_path = node:get_id()
local g = state.git_status_lookup
if not utils.truthy(g) then
return
end
local paths = { current_path }
for path, status in pairs(g) do
if path ~= current_path and status and status ~= "!!" then
--don't include files not in the current working directory
if utils.is_subpath(state.path, path) then
table.insert(paths, path)
end
end
end
local sorted_paths = utils.sort_by_tree_display(paths)
if reverse then
sorted_paths = utils.reverse_list(sorted_paths)
end
local is_file = function(path)
local success, stats = pcall(uv.fs_stat, path)
return (success and stats and stats.type ~= "directory")
end
local passed = false
local target = nil
for _, path in ipairs(sorted_paths) do
if target == nil and is_file(path) then
target = path
end
if passed then
if is_file(path) then
target = path
break
end
elseif path == current_path then
passed = true
end
end
local existing = state.tree:get_node(target)
if existing then
renderer.focus_node(state, target)
else
fs.navigate(state, state.path, target, nil, false)
end
end
---@param state neotree.sources.filesystem.State
M.next_git_modified = function(state)
focus_next_git_modified(state, false)
end
---@param state neotree.sources.filesystem.State
M.prev_git_modified = function(state)
focus_next_git_modified(state, true)
end
M.open = function(state)
cc.open(state, utils.wrap(fs.toggle_directory, state))
end
M.open_split = function(state)
cc.open_split(state, utils.wrap(fs.toggle_directory, state))
end
M.open_rightbelow_vs = function(state)
cc.open_rightbelow_vs(state, utils.wrap(fs.toggle_directory, state))
end
M.open_leftabove_vs = function(state)
cc.open_leftabove_vs(state, utils.wrap(fs.toggle_directory, state))
end
M.open_vsplit = function(state)
cc.open_vsplit(state, utils.wrap(fs.toggle_directory, state))
end
M.open_tabnew = function(state)
cc.open_tabnew(state, utils.wrap(fs.toggle_directory, state))
end
M.open_drop = function(state)
cc.open_drop(state, utils.wrap(fs.toggle_directory, state))
end
M.open_tab_drop = function(state)
cc.open_tab_drop(state, utils.wrap(fs.toggle_directory, state))
end
M.open_with_window_picker = function(state)
cc.open_with_window_picker(state, utils.wrap(fs.toggle_directory, state))
end
M.split_with_window_picker = function(state)
cc.split_with_window_picker(state, utils.wrap(fs.toggle_directory, state))
end
M.vsplit_with_window_picker = function(state)
cc.vsplit_with_window_picker(state, utils.wrap(fs.toggle_directory, state))
end
M.refresh = refresh
M.rename = function(state)
cc.rename(state, utils.wrap(refresh, state))
end
---@param state neotree.sources.filesystem.State
M.set_root = function(state)
if state.search_pattern then
fs.reset_search(state, false)
end
local node = state.tree:get_node()
while node and node.type ~= "directory" do
local parent_id = node:get_parent_id()
node = parent_id and state.tree:get_node(parent_id) or nil
end
if not node then
return
end
fs._navigate_internal(state, node:get_id(), nil, nil, false)
end
---Toggles whether hidden files are shown or not.
---@param state neotree.sources.filesystem.State
M.toggle_hidden = function(state)
state.filtered_items.visible = not state.filtered_items.visible
log.info("Toggling hidden files: " .. tostring(state.filtered_items.visible))
refresh(state)
end
---Toggles whether the tree is filtered by gitignore or not.
---@param state neotree.sources.filesystem.State
M.toggle_gitignore = function(state)
log.warn("`toggle_gitignore` has been removed, running toggle_hidden instead.")
M.toggle_hidden(state)
end
M.toggle_node = function(state)
cc.toggle_node(state, utils.wrap(fs.toggle_directory, state))
end
cc._add_common_commands(M)
return M

View file

@ -0,0 +1,49 @@
-- This file contains the built-in components. Each componment is a function
-- that takes the following arguments:
-- config: A table containing the configuration provided by the user
-- when declaring this component in their renderer config.
-- node: A NuiNode object for the currently focused node.
-- state: The current state of the source providing the items.
--
-- The function should return either a table, or a list of tables, each of which
-- contains the following keys:
-- text: The text to display for this item.
-- highlight: The highlight group to apply to this text.
local highlights = require("neo-tree.ui.highlights")
local common = require("neo-tree.sources.common.components")
---@alias neotree.Component.Filesystem._Key
---|"current_filter"
---@class neotree.Component.Filesystem
---@field [1] neotree.Component.Filesystem._Key|neotree.Component.Common._Key
---@type table<neotree.Component.Filesystem._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.Filesystem.CurrentFilter : neotree.Component.Common.CurrentFilter
---@param config neotree.Component.Filesystem.CurrentFilter
M.current_filter = function(config, node, state)
local filter = node.search_pattern or ""
if filter == "" then
return {}
end
return {
{
text = "Find",
highlight = highlights.DIM_TEXT,
},
{
text = string.format('"%s"', filter),
highlight = config.highlight or highlights.FILTER_TERM,
},
{
text = "in",
highlight = highlights.DIM_TEXT,
},
}
end
return vim.tbl_deep_extend("force", common, M)

View file

@ -0,0 +1,536 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
local utils = require("neo-tree.utils")
local _compat = require("neo-tree.utils._compat")
local fs_scan = require("neo-tree.sources.filesystem.lib.fs_scan")
local renderer = require("neo-tree.ui.renderer")
local events = require("neo-tree.events")
local log = require("neo-tree.log")
local manager = require("neo-tree.sources.manager")
local git = require("neo-tree.git")
local glob = require("neo-tree.sources.filesystem.lib.globtopattern")
---@class neotree.sources.filesystem : neotree.Source
local M = {
name = "filesystem",
display_name = " 󰉓 Files ",
}
local wrap = function(func)
return utils.wrap(func, M.name)
end
---@return neotree.sources.filesystem.State
local get_state = function(tabid)
return manager.get_state(M.name, tabid) --[[@as neotree.sources.filesystem.State]]
end
local follow_internal = function(callback, force_show, async)
log.trace("follow called")
local state = get_state()
if vim.bo.filetype == "neo-tree" or vim.bo.filetype == "neo-tree-popup" then
return false
end
local path_to_reveal = utils.normalize_path(manager.get_path_to_reveal() or "")
if not utils.truthy(path_to_reveal) then
return false
end
---@cast path_to_reveal string
if state.current_position == "float" then
return false
end
if not state.path then
return false
end
local window_exists = renderer.window_exists(state)
if window_exists then
local node = state.tree and state.tree:get_node()
if node then
if node:get_id() == path_to_reveal then
-- already focused
return false
end
end
else
if not force_show then
return false
end
end
local is_in_path = path_to_reveal:sub(1, #state.path) == state.path
if not is_in_path then
return false
end
log.debug("follow file: ", path_to_reveal)
local show_only_explicitly_opened = function()
state.explicitly_opened_nodes = state.explicitly_opened_nodes or {}
local expanded_nodes = renderer.get_expanded_nodes(state.tree)
local state_changed = false
for _, id in ipairs(expanded_nodes) do
if not state.explicitly_opened_nodes[id] then
if path_to_reveal:sub(1, #id) == id then
state.explicitly_opened_nodes[id] = state.follow_current_file.leave_dirs_open
else
local node = state.tree:get_node(id)
if node then
node:collapse()
state_changed = true
end
end
end
if state_changed then
renderer.redraw(state)
end
end
end
fs_scan.get_items(state, nil, path_to_reveal, function()
show_only_explicitly_opened()
renderer.focus_node(state, path_to_reveal, true)
if type(callback) == "function" then
callback()
end
end, async)
return true
end
M.follow = function(callback, force_show)
if vim.fn.bufname(0) == "COMMIT_EDITMSG" then
return false
end
if utils.is_floating() then
return false
end
utils.debounce("neo-tree-follow", function()
return follow_internal(callback, force_show)
end, 100, utils.debounce_strategy.CALL_LAST_ONLY)
end
local fs_stat = (vim.uv or vim.loop).fs_stat
---@param state neotree.sources.filesystem.State
---@param path string?
---@param path_to_reveal string?
---@param callback function?
M._navigate_internal = function(state, path, path_to_reveal, callback, async)
log.trace("navigate_internal", state.current_position, path, path_to_reveal)
state.dirty = false
local is_search = utils.truthy(state.search_pattern)
local path_changed = false
if not path and not state.bind_to_cwd then
path = state.path
end
if path == nil then
log.debug("navigate_internal: path is nil, using cwd")
path = manager.get_cwd(state)
end
path = utils.normalize_path(path)
-- if path doesn't exist, navigate upwards until it does
local orig_path = path
local backed_out = false
while not fs_stat(path) do
log.debug(("navigate_internal: path %s didn't exist, going up a directory"):format(path))
backed_out = true
local parent, _ = utils.split_path(path)
if not parent then
break
end
path = parent
end
if backed_out then
log.warn(("Root path %s doesn't exist, backing out to %s"):format(orig_path, path))
end
if path ~= state.path then
log.debug("navigate_internal: path changed from ", state.path, " to ", path)
state.path = path
path_changed = true
end
if path_to_reveal then
renderer.position.set(state, path_to_reveal)
log.debug("navigate_internal: in path_to_reveal, state.position=", state.position.node_id)
fs_scan.get_items(state, nil, path_to_reveal, callback)
else
local is_current = state.current_position == "current"
local follow_file = state.follow_current_file.enabled
and not is_search
and not is_current
and manager.get_path_to_reveal()
local handled = false
if utils.truthy(follow_file) then
handled = follow_internal(callback, true, async)
end
if not handled then
local success, msg = pcall(renderer.position.save, state)
if success then
log.trace("navigate_internal: position saved")
else
log.trace("navigate_internal: FAILED to save position: ", msg)
end
fs_scan.get_items(state, nil, nil, callback, async)
end
end
if path_changed and state.bind_to_cwd then
manager.set_cwd(state)
end
local config = require("neo-tree").config
if config.enable_git_status and not is_search and config.git_status_async then
git.status_async(state.path, state.git_base, config.git_status_async_options)
end
end
---Navigate to the given path.
---@param path string? Path to navigate to. If empty, will navigate to the cwd.
---@param path_to_reveal string? Node to focus after the items are loaded.
---@param callback function? Callback to call after the items are loaded.
M.navigate = function(state, path, path_to_reveal, callback, async)
state._ready = false
log.trace("navigate", path, path_to_reveal, async)
utils.debounce("filesystem_navigate", function()
M._navigate_internal(state, path, path_to_reveal, callback, async)
end, 100, utils.debounce_strategy.CALL_FIRST_AND_LAST)
end
---@param state neotree.State
M.reset_search = function(state, refresh, open_current_node)
log.trace("reset_search")
-- Cancel any pending search
require("neo-tree.sources.filesystem.lib.filter_external").cancel()
-- reset search state
state.fuzzy_finder_mode = nil
state.use_fzy = nil
state.fzy_sort_result_scores = nil
state.sort_function_override = nil
if refresh == nil then
refresh = true
end
if state.open_folders_before_search then
state.force_open_folders = vim.deepcopy(state.open_folders_before_search, _compat.noref())
else
state.force_open_folders = nil
end
state.search_pattern = nil
state.open_folders_before_search = nil
if open_current_node then
local success, node = pcall(state.tree.get_node, state.tree)
if success and node then
local path = node:get_id()
renderer.position.set(state, path)
if node.type == "directory" then
path = utils.remove_trailing_slash(path)
log.trace("opening directory from search: ", path)
M.navigate(state, nil, path, function()
pcall(renderer.focus_node, state, path, false)
end)
else
utils.open_file(state, path)
if
refresh
and state.current_position ~= "current"
and state.current_position ~= "float"
then
M.navigate(state, nil, path)
end
end
end
else
if refresh then
M.navigate(state)
end
end
end
M.show_new_children = function(state, node_or_path)
local node = node_or_path
if node_or_path == nil then
node = state.tree:get_node()
node_or_path = node:get_id()
elseif type(node_or_path) == "string" then
node = state.tree:get_node(node_or_path)
if node == nil then
local parent_path, _ = utils.split_path(node_or_path)
node = state.tree:get_node(parent_path)
if node == nil then
M.navigate(state, nil, node_or_path)
return
end
end
else
node = node_or_path
node_or_path = node:get_id()
end
if node.type ~= "directory" then
return
end
M.navigate(state, nil, node_or_path)
end
M.focus_destination_children = function(state, move_from, destination)
return M.show_new_children(state, destination)
end
---@alias neotree.Config.Cwd "tab"|"window"|"global"
---@class neotree.Config.Filesystem.CwdTarget
---@field sidebar neotree.Config.Cwd?
---@field current neotree.Config.Cwd?
---@class neotree.Config.Filesystem.FilteredItems
---@field visible boolean?
---@field force_visible_in_empty_folder boolean?
---@field children_inherit_highlights boolean?
---@field show_hidden_count boolean?
---@field hide_dotfiles boolean?
---@field hide_gitignored boolean?
---@field hide_hidden boolean?
---@field hide_by_name string[]?
---@field hide_by_pattern string[]?
---@field always_show string[]?
---@field always_show_by_pattern string[]?
---@field never_show string[]?
---@field never_show_by_pattern string[]?
---@alias neotree.Config.Filesystem.FindArgsHandler fun(cmd:string, path:string, search_term:string, args:string[]):string[]
---@class neotree.Config.Filesystem.FollowCurrentFile
---@field enabled boolean?
---@field leave_dirs_open boolean?
---@alias neotree.Config.HijackNetrwBehavior
---|"open_default" # opening a directory opens neo-tree with the default window.position.
---|"open_current" # opening a directory opens neo-tree within the current window.
---|"disabled" # opening a directory opens neo-tree within the current window.
---@class neotree.Config.Filesystem.Renderers : neotree.Config.Renderers
---@class neotree.Config.Filesystem.Window : neotree.Config.Window
---@field fuzzy_finder_mappings neotree.Config.FuzzyFinder.Mappings?
---@alias neotree.Config.Filesystem.AsyncDirectoryScan
---|"auto"
---|"always"
---|"never"
---@alias neotree.Config.Filesystem.ScanMode
---|"shallow"
---|"deep"
---@class (exact) neotree.Config.Filesystem : neotree.Config.Source
---@field async_directory_scan neotree.Config.Filesystem.AsyncDirectoryScan?
---@field scan_mode neotree.Config.Filesystem.ScanMode?
---@field bind_to_cwd boolean?
---@field cwd_target neotree.Config.Filesystem.CwdTarget?
---@field check_gitignore_in_search boolean?
---@field filtered_items neotree.Config.Filesystem.FilteredItems?
---@field find_by_full_path_words boolean?
---@field find_command string?
---@field find_args table<string, string[]>|neotree.Config.Filesystem.FindArgsHandler|nil
---@field group_empty_dirs boolean?
---@field search_limit integer?
---@field follow_current_file neotree.Config.Filesystem.FollowCurrentFile?
---@field hijack_netrw_behavior neotree.Config.HijackNetrwBehavior?
---@field use_libuv_file_watcher boolean?
---@field renderers neotree.Config.Filesystem.Renderers?
---@field window neotree.Config.Filesystem.Window?
---@field enable_git_status boolean?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.Filesystem Configuration table containing any keys that the user wants to change from the defaults. May be empty to accept default values.
---@param global_config neotree.Config.Base
M.setup = function(config, global_config)
config.filtered_items = config.filtered_items or {}
config.enable_git_status = config.enable_git_status or global_config.enable_git_status
for _, key in ipairs({ "hide_by_pattern", "always_show_by_pattern", "never_show_by_pattern" }) do
local list = config.filtered_items[key]
if type(list) == "table" then
for i, pattern in ipairs(list) do
list[i] = glob.globtopattern(pattern)
end
end
end
for _, key in ipairs({ "hide_by_name", "always_show", "never_show" }) do
local list = config.filtered_items[key]
if type(list) == "table" then
config.filtered_items[key] = utils.list_to_dict(list)
end
end
--Configure events for before_render
if config.before_render then
--convert to new event system
manager.subscribe(M.name, {
event = events.BEFORE_RENDER,
handler = function(state)
local this_state = get_state()
if state == this_state then
config.before_render(this_state)
end
end,
})
elseif global_config.enable_git_status and global_config.git_status_async then
manager.subscribe(M.name, {
event = events.GIT_STATUS_CHANGED,
handler = wrap(manager.git_status_changed),
})
elseif global_config.enable_git_status then
manager.subscribe(M.name, {
event = events.BEFORE_RENDER,
handler = function(state)
local this_state = get_state()
if state == this_state then
state.git_status_lookup = git.status(state.git_base)
end
end,
})
end
-- Respond to git events from git_status source or Fugitive
if global_config.enable_git_status then
manager.subscribe(M.name, {
event = events.GIT_EVENT,
handler = function()
manager.refresh(M.name)
end,
})
end
--Configure event handlers for file changes
if config.use_libuv_file_watcher then
manager.subscribe(M.name, {
event = events.FS_EVENT,
handler = wrap(manager.refresh),
})
else
require("neo-tree.sources.filesystem.lib.fs_watch").unwatch_all()
if global_config.enable_refresh_on_write then
manager.subscribe(M.name, {
event = events.VIM_BUFFER_CHANGED,
handler = function(arg)
local afile = arg.afile or ""
if utils.is_real_file(afile) then
log.trace("refreshing due to vim_buffer_changed event: ", afile)
manager.refresh("filesystem")
else
log.trace("Ignoring vim_buffer_changed event for non-file: ", afile)
end
end,
})
end
end
--Configure event handlers for cwd changes
if config.bind_to_cwd then
manager.subscribe(M.name, {
event = events.VIM_DIR_CHANGED,
handler = wrap(manager.dir_changed),
})
end
--Configure event handlers for lsp diagnostic updates
if global_config.enable_diagnostics then
manager.subscribe(M.name, {
event = events.VIM_DIAGNOSTIC_CHANGED,
handler = wrap(manager.diagnostics_changed),
})
end
--Configure event handlers for modified files
if global_config.enable_modified_markers then
manager.subscribe(M.name, {
event = events.VIM_BUFFER_MODIFIED_SET,
handler = wrap(manager.opened_buffers_changed),
})
end
if global_config.enable_opened_markers then
for _, event in ipairs({ events.VIM_BUFFER_ADDED, events.VIM_BUFFER_DELETED }) do
manager.subscribe(M.name, {
event = event,
handler = wrap(manager.opened_buffers_changed),
})
end
end
-- Configure event handler for follow_current_file option
if config.follow_current_file.enabled then
manager.subscribe(M.name, {
event = events.VIM_BUFFER_ENTER,
handler = function(args)
if utils.is_real_file(args.afile) then
M.follow()
end
end,
})
end
end
---Expands or collapses the current node.
---@param state neotree.sources.filesystem.State
---@param node NuiTree.Node
---@param path_to_reveal string
---@param skip_redraw boolean?
---@param recursive boolean?
---@param callback function?
M.toggle_directory = function(state, node, path_to_reveal, skip_redraw, recursive, callback)
local tree = state.tree
if not node then
node = assert(tree:get_node())
end
if node.type ~= "directory" then
return
end
state.explicitly_opened_nodes = state.explicitly_opened_nodes or {}
if node.loaded == false then
local id = node:get_id()
state.explicitly_opened_nodes[id] = true
renderer.position.set(state, nil)
fs_scan.get_items(state, id, path_to_reveal, callback, false, recursive)
elseif node:has_children() then
local updated = false
if node:is_expanded() then
updated = node:collapse()
state.explicitly_opened_nodes[node:get_id()] = false
else
updated = node:expand()
state.explicitly_opened_nodes[node:get_id()] = true
end
if updated and not skip_redraw then
renderer.redraw(state)
end
if path_to_reveal then
renderer.focus_node(state, path_to_reveal)
end
elseif require("neo-tree").config.filesystem.scan_mode == "deep" then
node.empty_expanded = not node.empty_expanded
renderer.redraw(state)
end
end
M.prefetcher = {
---@param state neotree.sources.filesystem.State
---@param node NuiTree.Node
prefetch = function(state, node)
if node.type ~= "directory" then
return
end
log.debug("Running fs prefetch for: " .. node:get_id())
fs_scan.get_dir_items_async(state, node:get_id(), true)
end,
should_prefetch = function(node)
return not node.loaded
end,
}
return M

View file

@ -0,0 +1,247 @@
-- This file holds all code for the search function.
local Input = require("nui.input")
local fs = require("neo-tree.sources.filesystem")
local popups = require("neo-tree.ui.popups")
local renderer = require("neo-tree.ui.renderer")
local utils = require("neo-tree.utils")
local log = require("neo-tree.log")
local manager = require("neo-tree.sources.manager")
local compat = require("neo-tree.utils._compat")
local common_filter = require("neo-tree.sources.common.filters")
local M = {}
---@param state neotree.sources.filesystem.State
---@param search_as_you_type boolean?
---@param fuzzy_finder_mode "directory"|boolean?
---@param use_fzy boolean?
---@param keep_filter_on_submit boolean?
M.show_filter = function(
state,
search_as_you_type,
fuzzy_finder_mode,
use_fzy,
keep_filter_on_submit
)
local popup_options
local winid = vim.api.nvim_get_current_win()
local height = vim.api.nvim_win_get_height(winid)
local scroll_padding = 3
local popup_msg = "Search:"
if search_as_you_type then
if fuzzy_finder_mode == "directory" then
popup_msg = "Filter Directories:"
else
popup_msg = "Filter:"
end
end
if state.config.title then
popup_msg = state.config.title
end
if state.current_position == "float" then
scroll_padding = 0
local width = vim.fn.winwidth(winid)
local row = height - 2
vim.api.nvim_win_set_height(winid, row)
popup_options = popups.popup_options(popup_msg, width, {
relative = "win",
winid = winid,
position = {
row = row,
col = 0,
},
size = width,
})
else
local width = vim.fn.winwidth(0) - 2
local row = height - 3
popup_options = popups.popup_options(popup_msg, width, {
relative = "win",
winid = winid,
position = {
row = row,
col = 0,
},
size = width,
})
end
---@type neotree.Config.SortFunction
local sort_by_score = function(a, b)
-- `state.fzy_sort_result_scores` should be defined in
-- `sources.filesystem.lib.filter_external.fzy_sort_files`
local result_scores = state.fzy_sort_result_scores or { foo = 0, baz = 0 }
local a_score = result_scores[a.path]
local b_score = result_scores[b.path]
if a_score == nil or b_score == nil then
log.debug(
string.format([[Fzy: failed to compare %s: %s, %s: %s]], a.path, a_score, b.path, b_score)
)
local config = require("neo-tree").config
if config.sort_function ~= nil then
return config.sort_function(a, b)
end
return nil
end
return a_score > b_score
end
local select_first_file = function()
local is_file = function(node)
return node.type == "file"
end
local files = renderer.select_nodes(state.tree, is_file, 1)
if #files > 0 then
renderer.focus_node(state, files[1]:get_id(), true)
end
end
local has_pre_search_folders = utils.truthy(state.open_folders_before_search)
if not has_pre_search_folders then
log.trace("No search or pre-search folders, recording pre-search folders now")
---@type table|nil
state.open_folders_before_search = renderer.get_expanded_nodes(state.tree)
end
local waiting_for_default_value = utils.truthy(state.search_pattern)
local input = Input(popup_options, {
prompt = " ",
default_value = state.search_pattern,
on_submit = function(value)
if value == "" then
fs.reset_search(state)
else
if search_as_you_type and fuzzy_finder_mode and not keep_filter_on_submit then
fs.reset_search(state, true, true)
return
end
state.search_pattern = value
manager.refresh("filesystem", function()
-- focus first file
local nodes = renderer.get_all_visible_nodes(state.tree)
for _, node in ipairs(nodes) do
if node.type == "file" then
renderer.focus_node(state, node:get_id(), false)
break
end
end
end)
end
end,
--this can be bad in a deep folder structure
on_change = function(value)
if not search_as_you_type then
return
end
-- apparently when a default value is set, on_change fires for every character
if waiting_for_default_value then
if #value < #state.search_pattern then
return
else
waiting_for_default_value = false
end
end
if value == state.search_pattern then
return
elseif value == nil then
return
elseif value == "" then
if state.search_pattern == nil then
return
end
log.trace("Resetting search in on_change")
local original_open_folders = nil
if type(state.open_folders_before_search) == "table" then
original_open_folders = vim.deepcopy(state.open_folders_before_search, compat.noref())
end
fs.reset_search(state)
state.open_folders_before_search = original_open_folders
else
log.trace("Setting search in on_change to: " .. value)
state.search_pattern = value
state.fuzzy_finder_mode = fuzzy_finder_mode
if use_fzy then
state.sort_function_override = sort_by_score
state.use_fzy = true
end
---@type function|nil
local callback = select_first_file
if fuzzy_finder_mode == "directory" then
callback = nil
end
local len = #value
local delay = 500
if len > 3 then
delay = 100
elseif len > 2 then
delay = 200
elseif len > 1 then
delay = 400
end
utils.debounce("filesystem_filter", function()
fs._navigate_internal(state, nil, nil, callback)
end, delay, utils.debounce_strategy.CALL_LAST_ONLY)
end
end,
})
input:mount()
local restore_height = vim.schedule_wrap(function()
if vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_set_height(winid, height)
end
end)
---@class neotree.sources.filesystem.FuzzyFinder.BuiltinCommands : neotree.FuzzyFinder.BuiltinCommands
local cmds
cmds = {
move_cursor_down = function(_state, _scroll_padding)
renderer.focus_node(_state, nil, true, 1, _scroll_padding)
end,
move_cursor_up = function(_state, _scroll_padding)
renderer.focus_node(_state, nil, true, -1, _scroll_padding)
vim.cmd("redraw!")
end,
close = function(_state, _scroll_padding)
vim.cmd("stopinsert")
input:unmount()
-- If this was closed due to submit, that function will handle the reset_search
vim.defer_fn(function()
if
fuzzy_finder_mode
and utils.truthy(state.search_pattern)
and not keep_filter_on_submit
then
fs.reset_search(state, true)
end
end, 100)
restore_height()
end,
close_keep_filter = function(_state, _scroll_padding)
log.info("Persisting the search filter")
keep_filter_on_submit = true
cmds.close(_state, _scroll_padding)
end,
close_clear_filter = function(_state, _scroll_padding)
log.info("Clearing the search filter")
keep_filter_on_submit = false
cmds.close(_state, _scroll_padding)
end,
}
common_filter.setup_hooks(input, cmds, state, scroll_padding)
if not fuzzy_finder_mode then
return
end
common_filter.setup_mappings(input, cmds, state, scroll_padding)
end
return M

View file

@ -0,0 +1,393 @@
local log = require("neo-tree.log")
local Job = require("plenary.job")
local utils = require("neo-tree.utils")
local Queue = require("neo-tree.collections").Queue
local M = {}
local fd_supports_max_results = nil
local test_for_max_results = function(cmd)
if fd_supports_max_results == nil then
if cmd == "fd" or cmd == "fdfind" then
--test if it supports the max-results option
local test = vim.fn.system(cmd .. " this_is_only_a_test --max-depth=1 --max-results=1")
if test:match("^error:") then
fd_supports_max_results = false
log.debug(cmd, "does NOT support max-results")
else
fd_supports_max_results = true
log.debug(cmd, "supports max-results")
end
end
end
end
local get_find_command = function(state)
if state.find_command then
test_for_max_results(state.find_command)
return state.find_command
end
if 1 == vim.fn.executable("fdfind") then
state.find_command = "fdfind"
elseif 1 == vim.fn.executable("fd") then
state.find_command = "fd"
elseif 1 == vim.fn.executable("find") and vim.fn.has("win32") == 0 then
state.find_command = "find"
elseif 1 == vim.fn.executable("where") then
state.find_command = "where"
end
test_for_max_results(state.find_command)
return state.find_command
end
local running_jobs = Queue:new()
local kill_job = function(job)
local pid = job.pid
job:shutdown()
if pid ~= nil and pid > 0 then
if utils.is_windows then
vim.fn.system("taskkill /F /T /PID " .. pid)
else
vim.fn.system("kill -9 " .. pid)
end
end
return true
end
M.cancel = function()
if running_jobs:is_empty() then
return
end
running_jobs:for_each(kill_job)
end
---@class neotree.FileKind
---@field file boolean?
---@field directory boolean?
---@field symlink boolean?
---@field socket boolean?
---@field pipe boolean?
---@field executable boolean?
---@field empty boolean?
---@field block boolean? Only for `find`
---@field character boolean? Only for `find`
---filter_files_external
-- Spawns a filter command based on `cmd`
---@param cmd string Command to execute. Use `get_find_command` most times.
---@param path string Base directory to start the search.
---@param glob string | nil If not nil, do glob search. Take precedence on `regex`
---@param regex string | nil If not nil, do regex search if command supports. if glob ~= nil, ignored
---@param full_path boolean If true, search agaist the absolute path
---@param kind neotree.FileKind | nil Return only true filetypes. If nil, all are returned.
---@param ignore { dotfiles: boolean?, gitignore: boolean? } If true, ignored from result. Default: false
---@param limit? integer | nil Maximim number of results. nil will return everything.
---@param find_args? string[] | table<string, string[]> Any additional options passed to command if any.
---@param on_insert? fun(err: string, line: string): any Executed for each line of stdout and stderr.
---@param on_exit? fun(return_val: number): any Executed at the end.
M.filter_files_external = function(
cmd,
path,
glob,
regex,
full_path,
kind,
ignore,
limit,
find_args,
on_insert,
on_exit
)
if glob ~= nil and regex ~= nil then
local log_msg = string.format([[glob: %s, regex: %s]], glob, regex)
log.warn("both glob and regex are set. glob will take precedence. " .. log_msg)
end
ignore = ignore or {}
kind = kind or {}
limit = limit or math.huge -- math.huge == no limit
local file_kind_map = {
file = "f",
directory = "d",
symlink = "l",
socket = "s",
pipe = "p",
executable = "x", -- only for `fd`
empty = "e", -- only for `fd`
block = "b", -- only for `find`
character = "c", -- only for `find`
}
local args = {}
local function append(...)
for _, v in pairs({ ... }) do
if v ~= nil then
args[#args + 1] = v
end
end
end
local function append_find_args()
if find_args then
if type(find_args) == "string" then
append(find_args)
elseif type(find_args) == "table" then
if find_args[1] then
append(unpack(find_args))
elseif find_args[cmd] then
append(unpack(find_args[cmd])) ---@diagnostic disable-line
end
elseif type(find_args) == "function" then
args = find_args(cmd, path, glob, args)
end
end
end
if cmd == "fd" or cmd == "fdfind" then
if not ignore.dotfiles then
append("--hidden")
end
if not ignore.gitignore then
append("--no-ignore")
end
append("--color", "never")
if fd_supports_max_results and 0 < limit and limit < math.huge then
append("--max-results", limit)
end
for k, v in pairs(kind) do
if v and file_kind_map[k] ~= nil then
append("--type", k)
end
end
if full_path then
append("--full-path")
if glob ~= nil then
local words = utils.split(glob, " ")
regex = ".*" .. table.concat(words, ".*") .. ".*"
glob = nil
end
end
if glob ~= nil then
append("--glob")
end
append_find_args()
append("--", glob or regex or "")
append(path)
elseif cmd == "find" then
append(path)
local file_kinds = {}
for k, v in pairs(kind) do
if v and file_kind_map[k] ~= nil then
file_kinds[#file_kinds + 1] = file_kind_map[k]
end
end
if ignore.dotfiles then
append("-name", ".*", "-prune", "-o")
end
if #file_kinds > 0 then
append("-type", table.concat(file_kinds, ","))
end
if kind.empty then
append("-empty")
end
if kind.executable then
append("-executable")
end
if glob ~= nil and not full_path then
append("-iname", glob)
elseif glob ~= nil and full_path then
local words = utils.split(glob, " ")
regex = ".*" .. table.concat(words, ".*") .. ".*"
append("-regextype", "sed", "-regex", regex)
elseif regex ~= nil then
append("-regextype", "sed", "-regex", regex)
end
append("-print")
append_find_args()
elseif cmd == "fzf" then
-- This does not work yet, there's some kind of issue with how fzf uses stdout
error("fzf is not a supported find_command")
append_find_args()
append("--no-sort", "--no-expect", "--filter", glob or regex) -- using the raw term without glob patterns
elseif cmd == "where" then
append_find_args()
append("/r", path, glob or regex)
else
return { "No search command found!" }
end
if fd_supports_max_results then
limit = math.huge -- `fd` manages limit on its own
end
local item_count = 0
---@diagnostic disable-next-line: missing-fields
local job = Job:new({
command = cmd,
cwd = path,
args = args,
enable_recording = false,
on_stdout = function(err, line)
if item_count < limit and on_insert then
on_insert(err, line)
item_count = item_count + 1
end
end,
on_stderr = function(err, line)
if item_count < limit and on_insert then
on_insert(err or line, line)
-- item_count = item_count + 1
end
end,
on_exit = function(_, return_val)
if on_exit then
on_exit(return_val)
end
end,
})
-- This ensures that only one job is running at a time
running_jobs:for_each(kill_job)
running_jobs:add(job)
job:start()
end
local function fzy_sort_get_total_score(terms, path)
local fzy = require("neo-tree.sources.common.filters.filter_fzy")
local total_score = 0
for _, term in ipairs(terms) do -- spaces in `opts.term` are treated as `and`
local score = fzy.score(term, path)
if score == fzy.get_score_min() then -- if any not found, end searching
return 0
end
total_score = total_score + score
end
return total_score
end
local function modify_parent_scores(result_scores, path, score)
local parent, _ = utils.split_path(path)
while parent ~= nil do -- back propagate the score to its ancesters
if score > (result_scores[parent] or 0) then
result_scores[parent] = score
parent, _ = utils.split_path(parent)
else
break
end
end
end
---@param state neotree.State
M.fzy_sort_files = function(opts, state)
state = state or {}
local filters = opts.filtered_items
local limit = opts.limit or 100
local full_path_words = opts.find_by_full_path_words
local fuzzy_finder_mode = opts.fuzzy_finder_mode
local pwd = opts.path
if pwd:sub(-1) ~= "/" then
pwd = pwd .. "/"
end
local pwd_length = #pwd
local terms = {}
for term in string.gmatch(opts.term, "[^%s]+") do -- space split opts.term
terms[#terms + 1] = term
end
-- The base search is anything that contains the characters in the term
-- The fzy score is then used to sort the results
local chars = {}
local regex = ".*"
local chars_to_escape =
{ "%", "+", "-", "?", "[", "^", "$", "(", ")", "{", "}", "=", "!", "<", ">", "|", ":", "#" }
for _, term in ipairs(terms) do
for c in term:gmatch(".") do
if not chars[c] then
chars[c] = true
if chars_to_escape[c] then
c = [[\]] .. c
end
regex = regex .. c .. ".*"
end
end
end
local result_counter = 0
local index = 1
state.fzy_sort_result_scores = {}
local function on_insert(err, path)
if not err then
local relative_path = path
if not full_path_words and #path > pwd_length and path:sub(1, pwd_length) == pwd then
relative_path = "./" .. path:sub(pwd_length + 1)
end
index = index + 1
if state.fzy_sort_result_scores == nil then
state.fzy_sort_result_scores = {}
end
state.fzy_sort_result_scores[path] = 0
local score = fzy_sort_get_total_score(terms, relative_path)
if score > 0 then
state.fzy_sort_result_scores[path] = score
result_counter = result_counter + 1
modify_parent_scores(state.fzy_sort_result_scores, path, score)
opts.on_insert(nil, path)
if result_counter >= limit then
vim.schedule(M.cancel)
end
end
end
end
M.filter_files_external(
get_find_command(state),
pwd,
nil,
regex,
true,
{ directory = fuzzy_finder_mode == "directory", file = fuzzy_finder_mode ~= "directory" },
{
dotfiles = not filters.visible and filters.hide_dotfiles,
gitignore = not filters.visible and filters.hide_gitignored,
},
nil,
opts.find_args,
on_insert,
opts.on_exit
)
end
M.find_files = function(opts)
local filters = opts.filtered_items
local full_path_words = opts.find_by_full_path_words
local regex, glob = nil, nil
local fuzzy_finder_mode = opts.fuzzy_finder_mode
glob = opts.term
if glob:sub(1) ~= "*" then
glob = "*" .. glob
end
if glob:sub(-1) ~= "*" then
glob = glob .. "*"
end
M.filter_files_external(
get_find_command(opts),
opts.path,
glob,
regex,
full_path_words,
{ directory = fuzzy_finder_mode == "directory" },
{
dotfiles = not filters.visible and filters.hide_dotfiles,
gitignore = not filters.visible and filters.hide_gitignored,
},
opts.limit or 200,
opts.find_args,
opts.on_insert,
opts.on_exit
)
end
return M

View file

@ -0,0 +1,719 @@
-- This file is for functions that mutate the filesystem.
-- This code started out as a copy from:
-- https://github.com/mhartington/dotfiles
-- and modified to fit neo-tree's api.
-- Permalink: https://github.com/mhartington/dotfiles/blob/7560986378753e0c047d940452cb03a3b6439b11/config/nvim/lua/mh/filetree/init.lua
local api = vim.api
local uv = vim.uv or vim.loop
local scan = require("plenary.scandir")
local utils = require("neo-tree.utils")
local inputs = require("neo-tree.ui.inputs")
local events = require("neo-tree.events")
local log = require("neo-tree.log")
local Path = require("plenary").path
local M = {}
---@param a uv.fs_stat.result?
---@param b uv.fs_stat.result?
---@return boolean equal Whether a and b are stats of the same file
local same_file = function(a, b)
return a and b and a.dev == b.dev and a.ino == b.ino or false
end
---Checks to see if a file can safely be renamed to its destination without data loss.
---Also prevents renames from going through if the rename will not do anything.
---Has an additional check for case-insensitive filesystems (e.g. for windows)
---@param source string
---@param destination string
---@return boolean rename_is_safe
local function rename_is_safe(source, destination)
local destination_file = uv.fs_stat(destination)
if not destination_file then
return true
end
local src = utils.normalize_path(source)
local dest = utils.normalize_path(destination)
local changing_casing = src ~= dest and src:lower() == dest:lower()
if changing_casing then
local src_file = uv.fs_stat(src)
-- We check that the two paths resolve to the same canonical filename and file.
return same_file(src_file, destination_file)
and uv.fs_realpath(src) == uv.fs_realpath(destination)
end
return false
end
local function find_replacement_buffer(for_buf)
local bufs = vim.api.nvim_list_bufs()
-- make sure the alternate buffer is at the top of the list
local alt = vim.fn.bufnr("#")
if alt ~= -1 and alt ~= for_buf then
table.insert(bufs, 1, alt)
end
-- find the first valid real file buffer
for _, buf in ipairs(bufs) do
if buf ~= for_buf then
local is_valid = vim.api.nvim_buf_is_valid(buf)
if is_valid then
local buftype = vim.bo[buf].buftype
if buftype == "" then
return buf
end
end
end
end
return -1
end
local function clear_buffer(path)
local buf = utils.find_buffer_by_name(path)
if buf < 1 then
return
end
local alt = find_replacement_buffer(buf)
-- Check all windows to see if they are using the buffer
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == buf then
-- if there is no alternate buffer yet, create a blank one now
if alt < 1 or alt == buf then
alt = vim.api.nvim_create_buf(true, false)
end
-- replace the buffer displayed in this window with the alternate buffer
vim.api.nvim_win_set_buf(win, alt)
end
end
local success, msg = pcall(vim.api.nvim_buf_delete, buf, { force = true })
if not success then
log.error("Could not clear buffer: ", msg)
end
end
---Opens new_buf in each window that has old_buf currently open.
---Useful during file rename.
---@param old_buf number
---@param new_buf number
local function replace_buffer_in_windows(old_buf, new_buf)
for _, win in ipairs(vim.api.nvim_list_wins()) do
if vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == old_buf then
vim.api.nvim_win_set_buf(win, new_buf)
end
end
end
local function rename_buffer(old_path, new_path)
local force_save = function()
vim.cmd("silent! write!")
end
for _, buf in pairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) then
local buf_name = vim.api.nvim_buf_get_name(buf)
local new_buf_name = nil
if old_path == buf_name then
new_buf_name = new_path
elseif utils.is_subpath(old_path, buf_name) then
new_buf_name = new_path .. buf_name:sub(#old_path + 1)
end
if utils.truthy(new_buf_name) then
local new_buf = vim.fn.bufadd(new_buf_name)
vim.fn.bufload(new_buf)
vim.bo[new_buf].buflisted = true
replace_buffer_in_windows(buf, new_buf)
if vim.bo[buf].buftype == "" then
local modified = vim.bo[buf].modified
if modified then
local old_buffer_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
vim.api.nvim_buf_set_lines(new_buf, 0, -1, false, old_buffer_lines)
local msg = buf_name .. " has been modified. Save under new name? (y/n) "
inputs.confirm(msg, function(confirmed)
if confirmed then
vim.api.nvim_buf_call(new_buf, force_save)
log.trace("Force saving renamed buffer with changes")
else
vim.cmd("echohl WarningMsg")
vim.cmd(
[[echo "Skipping force save. You'll need to save it with `:w!` when you are ready to force writing with the new name."]]
)
vim.cmd("echohl NONE")
end
end)
end
end
vim.api.nvim_buf_delete(buf, { force = true })
end
end
end
end
local function create_all_parents(path)
local function create_all_as_folders(in_path)
if not uv.fs_stat(in_path) then
local parent, _ = utils.split_path(in_path)
if parent then
create_all_as_folders(parent)
end
uv.fs_mkdir(in_path, 493)
end
end
local parent_path, _ = utils.split_path(path)
create_all_as_folders(parent_path)
end
-- Gets a non-existing filename from the user and executes the callback with it.
---@param source string
---@param destination string
---@param using_root_directory boolean
---@param name_chosen_callback fun(string)
---@param first_message string?
local function get_unused_name(
source,
destination,
using_root_directory,
name_chosen_callback,
first_message
)
if not rename_is_safe(source, destination) then
local parent_path, name
if not using_root_directory then
parent_path, name = utils.split_path(destination)
elseif #using_root_directory > 0 then
parent_path = destination:sub(1, #using_root_directory)
name = destination:sub(#using_root_directory + 2)
else
parent_path = nil
name = destination
end
local message = first_message or name .. " already exists. Please enter a new name: "
inputs.input(message, name, function(new_name)
if new_name and string.len(new_name) > 0 then
local new_path = parent_path and parent_path .. utils.path_separator .. new_name or new_name
get_unused_name(source, new_path, using_root_directory, name_chosen_callback)
end
end)
else
name_chosen_callback(destination)
end
end
-- Move Node
M.move_node = function(source, destination, callback, using_root_directory)
log.trace(
"Moving node: ",
source,
" to ",
destination,
", using root directory: ",
using_root_directory
)
local _, name = utils.split_path(source)
get_unused_name(source, destination or source, using_root_directory, function(dest)
-- Resolve user-inputted relative paths out of the absolute paths
dest = vim.fs.normalize(dest)
if utils.is_windows then
dest = utils.windowize_path(dest)
end
local function move_file()
create_all_parents(dest)
uv.fs_rename(source, dest, function(err)
if err then
log.error("Could not move the files from", source, "to", dest, ":", err)
return
end
vim.schedule(function()
rename_buffer(source, dest)
end)
vim.schedule(function()
events.fire_event(events.FILE_MOVED, {
source = source,
destination = dest,
})
if callback then
callback(source, dest)
end
end)
end)
end
local event_result = events.fire_event(events.BEFORE_FILE_MOVE, {
source = source,
destination = dest,
callback = move_file,
}) or {}
if event_result.handled then
return
end
move_file()
end, 'Move "' .. name .. '" to:')
end
---Plenary path.copy() when used to copy a recursive structure, can return a nested
-- table with for each file a Path instance and the success result.
---@param copy_result table The output of Path.copy()
---@param flat_result table Return value containing the flattened results
local function flatten_path_copy_result(flat_result, copy_result)
if not copy_result then
return
end
for k, v in pairs(copy_result) do
if type(v) == "table" then
flatten_path_copy_result(flat_result, v)
else
table.insert(flat_result, { destination = k.filename, success = v })
end
end
end
-- Check if all files were copied successfully, using the flattened copy result
local function check_path_copy_result(flat_result)
if not flat_result then
return
end
for _, file_result in ipairs(flat_result) do
if not file_result.success then
return false
end
end
return true
end
-- Copy Node
M.copy_node = function(source, _destination, callback, using_root_directory)
local _, name = utils.split_path(source)
get_unused_name(source, _destination or source, using_root_directory, function(destination)
local parent_path, _ = utils.split_path(destination)
if source == parent_path then
log.warn("Cannot copy a file/folder to itself")
return
end
local event_result = events.fire_event(events.BEFORE_FILE_ADD, destination) or {}
if event_result.handled then
return
end
local source_path = Path:new(source)
if source_path:is_file() then
-- When the source is a file, then Path.copy() currently doesn't create
-- the potential non-existing parent directories of the destination.
create_all_parents(destination)
end
local success, result = pcall(source_path.copy, source_path, {
destination = destination,
recursive = true,
parents = true,
})
if not success then
log.error("Could not copy the file(s) from", source, "to", destination, ":", result)
return
end
-- It can happen that the Path.copy() function returns successfully but
-- the copy action still failed. In this case the copy() result contains
-- a nested table of Path instances for each file copied, and the success
-- result.
local flat_result = {}
flatten_path_copy_result(flat_result, result)
if not check_path_copy_result(flat_result) then
log.error("Could not copy the file(s) from", source, "to", destination, ":", flat_result)
return
end
vim.schedule(function()
events.fire_event(events.FILE_ADDED, destination)
if callback then
callback(source, destination)
end
end)
end, 'Copy "' .. name .. '" to:')
end
--- Create a new directory
M.create_directory = function(in_directory, callback, using_root_directory)
local base
if type(using_root_directory) == "string" then
if in_directory == using_root_directory then
base = ""
elseif #using_root_directory > 0 then
base = in_directory:sub(#using_root_directory + 2) .. utils.path_separator
else
base = in_directory .. utils.path_separator
end
else
base = vim.fn.fnamemodify(in_directory .. utils.path_separator, ":~")
using_root_directory = false
end
inputs.input("Enter name for new directory:", base, function(destinations)
if not destinations then
return
end
for _, destination in ipairs(utils.brace_expand(destinations)) do
if not destination or destination == base then
return
end
if using_root_directory then
destination = utils.path_join(using_root_directory, destination)
else
destination = vim.fn.fnamemodify(destination, ":p")
end
local event_result = events.fire_event(events.BEFORE_FILE_ADD, destination) or {}
if event_result.handled then
return
end
if uv.fs_stat(destination) then
log.warn("Directory already exists")
return
end
create_all_parents(destination)
uv.fs_mkdir(destination, 493)
vim.schedule(function()
events.fire_event(events.FILE_ADDED, destination)
if callback then
callback(destination)
end
end)
end
end)
end
--- Create Node
M.create_node = function(in_directory, callback, using_root_directory)
local base
if type(using_root_directory) == "string" then
if in_directory == using_root_directory then
base = ""
elseif #using_root_directory > 0 then
base = in_directory:sub(#using_root_directory + 2) .. utils.path_separator
else
base = in_directory .. utils.path_separator
end
else
base = vim.fn.fnamemodify(in_directory .. utils.path_separator, ":~")
using_root_directory = false
end
local dir_ending = '"/"'
if utils.path_separator ~= "/" then
dir_ending = dir_ending .. string.format(' or "%s"', utils.path_separator)
end
local msg = "Enter name for new file or directory (dirs end with a " .. dir_ending .. "):"
inputs.input(msg, base, function(destinations)
if not destinations then
return
end
for _, destination in ipairs(utils.brace_expand(destinations)) do
if not destination or destination == base then
return
end
local is_dir = vim.endswith(destination, "/")
or vim.endswith(destination, utils.path_separator)
if using_root_directory then
destination = utils.path_join(using_root_directory, destination)
else
destination = vim.fn.fnamemodify(destination, ":p")
end
destination = utils.normalize_path(destination)
if uv.fs_stat(destination) then
log.warn("File already exists")
return
end
local complete = vim.schedule_wrap(function()
events.fire_event(events.FILE_ADDED, destination)
if callback then
callback(destination)
end
end)
local event_result = events.fire_event(events.BEFORE_FILE_ADD, destination) or {}
if event_result.handled then
complete()
return
end
create_all_parents(destination)
if is_dir then
uv.fs_mkdir(destination, 493)
else
local open_mode = uv.constants.O_CREAT + uv.constants.O_WRONLY + uv.constants.O_TRUNC
local fd = uv.fs_open(destination, open_mode, 420)
if not fd then
if not uv.fs_stat(destination) then
log.error("Could not create file " .. destination)
return
else
log.warn("Failed to complete file creation of " .. destination)
end
else
uv.fs_close(fd)
end
end
complete()
end
end)
end
---Recursively delete a directory and its children.
---@param dir_path string Directory to delete.
---@return boolean success Whether the directory was deleted.
local function delete_dir(dir_path)
local handle = uv.fs_scandir(dir_path)
if type(handle) == "string" then
log.error(handle)
return false
end
if not handle then
log.error("could not scan dir " .. dir_path)
return false
end
while true do
local child_name, t = uv.fs_scandir_next(handle)
if not child_name then
break
end
local child_path = dir_path .. "/" .. child_name
if t == "directory" then
local success = delete_dir(child_path)
if not success then
log.error("failed to delete ", child_path)
return false
end
else
local success = uv.fs_unlink(child_path)
if not success then
return false
end
clear_buffer(child_path)
end
end
return uv.fs_rmdir(dir_path) or false
end
-- Delete Node
M.delete_node = function(path, callback, noconfirm)
local _, name = utils.split_path(path)
local msg = string.format("Are you sure you want to delete '%s'?", name)
log.trace("Deleting node: ", path)
local _type = "unknown"
local stat = uv.fs_stat(path)
if stat then
_type = stat.type
if _type == "link" then
local link_to = uv.fs_readlink(path)
if not link_to then
log.error("Could not read link")
return
end
local target_file = uv.fs_stat(link_to)
if target_file then
_type = target_file.type
end
_type = uv.fs_stat(link_to).type
end
if _type == "directory" then
local children = scan.scan_dir(path, {
hidden = true,
respect_gitignore = false,
add_dirs = true,
depth = 1,
})
if #children > 0 then
msg = "WARNING: Dir not empty! " .. msg
end
end
else
log.warn("Could not read file/dir:", path, stat, ", attempting to delete anyway...")
-- Guess the type by whether it appears to have an extension
if path:match("%.(.+)$") then
_type = "file"
else
_type = "directory"
end
return
end
local do_delete = function()
local complete = vim.schedule_wrap(function()
events.fire_event(events.FILE_DELETED, path)
if callback then
callback(path)
end
end)
local event_result = events.fire_event(events.BEFORE_FILE_DELETE, path) or {}
if event_result.handled then
complete()
return
end
if _type == "directory" then
-- first try using native system commands, which are recursive
local success = false
if utils.is_windows then
local result =
vim.fn.system({ "cmd.exe", "/c", "rmdir", "/s", "/q", vim.fn.shellescape(path) })
local error = vim.v.shell_error
if error ~= 0 then
log.debug("Could not delete directory '", path, "' with rmdir: ", result)
else
log.info("Deleted directory ", path)
success = true
end
else
local result = vim.fn.system({ "rm", "-Rf", path })
local error = vim.v.shell_error
if error ~= 0 then
log.debug("Could not delete directory '", path, "' with rm: ", result)
else
log.info("Deleted directory ", path)
success = true
end
end
-- Fallback to using libuv if native commands fail
if not success then
success = delete_dir(path)
if not success then
return log.error("Could not remove directory: " .. path)
end
end
else
local success = uv.fs_unlink(path)
if not success then
return log.error("Could not remove file: " .. path)
end
clear_buffer(path)
end
complete()
end
if noconfirm then
do_delete()
else
inputs.confirm(msg, function(confirmed)
if confirmed then
do_delete()
end
end)
end
end
M.delete_nodes = function(paths_to_delete, callback)
local msg = "Are you sure you want to delete " .. #paths_to_delete .. " items?"
inputs.confirm(msg, function(confirmed)
if not confirmed then
return
end
for _, path in ipairs(paths_to_delete) do
M.delete_node(path, nil, true)
end
if callback then
vim.schedule(function()
callback(paths_to_delete[#paths_to_delete])
end)
end
end)
end
local rename_node = function(msg, name, get_destination, path, callback)
inputs.input(msg, name, function(new_name)
-- If cancelled
if not new_name or new_name == "" then
log.info("Operation canceled")
return
end
local destination = get_destination(new_name)
if not rename_is_safe(path, destination) then
log.warn(destination, " already exists, canceling")
return
end
local complete = vim.schedule_wrap(function()
rename_buffer(path, destination)
events.fire_event(events.FILE_RENAMED, {
source = path,
destination = destination,
})
if callback then
callback(path, destination)
end
log.info("Renamed " .. new_name .. " successfully")
end)
local function fs_rename()
uv.fs_rename(path, destination, function(err)
if err then
log.warn("Could not rename the files")
return
end
complete()
end)
end
local event_result = events.fire_event(events.BEFORE_FILE_RENAME, {
source = path,
destination = destination,
callback = fs_rename,
}) or {}
if event_result.handled then
complete()
return
end
fs_rename()
end)
end
-- Rename Node
M.rename_node = function(path, callback)
local parent_path, name = utils.split_path(path)
local msg = string.format('Enter new name for "%s":', name)
local get_destination = function(new_name)
return parent_path .. utils.path_separator .. new_name
end
rename_node(msg, name, get_destination, path, callback)
end
-- Rename Node Base Name
M.rename_node_basename = function(path, callback)
local parent_path, name = utils.split_path(path)
local base_name = vim.fn.fnamemodify(path, ":t:r")
local extension = vim.fn.fnamemodify(path, ":e")
local msg = string.format('Enter new base name for "%s":', name)
local get_destination = function(new_base_name)
return parent_path
.. utils.path_separator
.. new_base_name
.. (extension:len() == 0 and "" or "." .. extension)
end
rename_node(msg, base_name, get_destination, path, callback)
end
return M

View file

@ -0,0 +1,738 @@
-- This files holds code for scanning the filesystem to build the tree.
local uv = vim.uv or vim.loop
local renderer = require("neo-tree.ui.renderer")
local utils = require("neo-tree.utils")
local filter_external = require("neo-tree.sources.filesystem.lib.filter_external")
local file_items = require("neo-tree.sources.common.file-items")
local file_nesting = require("neo-tree.sources.common.file-nesting")
local log = require("neo-tree.log")
local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch")
local git = require("neo-tree.git")
local events = require("neo-tree.events")
local async = require("plenary.async")
local M = {}
--- how many entries to load per readdir
local ENTRIES_BATCH_SIZE = 1000
---@param context neotree.sources.filesystem.Context
---@param dir_path string
local on_directory_loaded = function(context, dir_path)
local state = context.state
local scanned_folder = context.folders[dir_path]
if scanned_folder then
scanned_folder.loaded = true
end
if state.use_libuv_file_watcher then
local root = context.folders[dir_path]
if root then
local target_path = root.is_link and root.link_to or root.path
local fs_watch_callback = vim.schedule_wrap(function(err, fname)
if err then
log.error("file_event_callback: ", err)
return
end
if context.is_a_never_show_file(fname) then
-- don't fire events for nodes that are designated as "never show"
return
else
events.fire_event(events.FS_EVENT, { afile = target_path })
end
end)
log.trace("Adding fs watcher for ", target_path)
fs_watch.watch_folder(target_path, fs_watch_callback)
end
end
end
---@param context neotree.sources.filesystem.Context
---@param dir_path string
local dir_complete = function(context, dir_path)
local paths_to_load = context.paths_to_load
local folders = context.folders
on_directory_loaded(context, dir_path)
-- check to see if there are more folders to load
local next_path = nil
while #paths_to_load > 0 and not next_path do
next_path = table.remove(paths_to_load)
-- ensure that the path is still valid
local success, result = pcall(uv.fs_stat, next_path)
-- ensure that the result is a directory
if success and result and result.type == "directory" then
-- ensure that it is not already loaded
local existing = folders[next_path]
if existing and existing.loaded then
next_path = nil
end
else
-- if the path doesn't exist, skip it
next_path = nil
end
end
return next_path
end
---@param context neotree.sources.filesystem.Context
local render_context = function(context)
local state = context.state
local root = context.root
local parent_id = context.parent_id
if not parent_id and state.use_libuv_file_watcher and state.enable_git_status then
log.trace("Starting .git folder watcher")
local path = root.path
if root.is_link then
path = root.link_to
end
fs_watch.watch_git_index(path, require("neo-tree").config.git_status_async)
end
fs_watch.updated_watched()
if root and root.children then
file_items.advanced_sort(root.children, state)
end
if parent_id then
-- lazy loading a child folder
renderer.show_nodes(root.children, state, parent_id, context.callback)
else
-- full render of the tree
renderer.show_nodes({ root }, state, nil, context.callback)
end
context.state = nil
context.callback = nil
context.all_items = nil
context.root = nil
context.parent_id = nil
---@diagnostic disable-next-line: cast-local-type
context = nil
end
---@param context neotree.sources.filesystem.Context
local should_check_gitignore = function(context)
local state = context.state
if #context.all_items == 0 then
log.info("No items, skipping git ignored/status lookups")
return false
end
if state.search_pattern and state.check_gitignore_in_search == false then
return false
end
if state.filtered_items.hide_gitignored then
return true
end
if state.enable_git_status == false then
return false
end
return true
end
local job_complete_async = function(context)
local state = context.state
local parent_id = context.parent_id
file_nesting.nest_items(context)
-- if state.search_pattern and #context.all_items > 50 then
-- -- don't do git ignored/status lookups when searching unless we are down to a reasonable number of items
-- return context
-- end
if should_check_gitignore(context) then
local mark_ignored_async = async.wrap(function(_state, _all_items, _callback)
git.mark_ignored(_state, _all_items, _callback)
end, 3)
local all_items = mark_ignored_async(state, context.all_items)
if parent_id then
vim.list_extend(state.git_ignored, all_items)
else
state.git_ignored = all_items
end
end
return context
end
local job_complete = function(context)
local state = context.state
local parent_id = context.parent_id
file_nesting.nest_items(context)
if should_check_gitignore(context) then
if require("neo-tree").config.git_status_async then
git.mark_ignored(state, context.all_items, function(all_items)
if parent_id then
vim.list_extend(state.git_ignored, all_items)
else
state.git_ignored = all_items
end
vim.schedule(function()
render_context(context)
end)
end)
return
else
local all_items = git.mark_ignored(state, context.all_items)
if parent_id then
vim.list_extend(state.git_ignored, all_items)
else
state.git_ignored = all_items
end
end
render_context(context)
else
render_context(context)
end
end
local function create_node(context, node)
pcall(file_items.create_item, context, node.path, node.type)
end
local function process_node(context, path)
on_directory_loaded(context, path)
end
---@param err string libuv error
---@return boolean is_permission_error
local function is_permission_error(err)
-- Permission errors may be common when scanning over lots of folders;
-- this is used to check for them and log to `debug` instead of `error`.
return vim.startswith(err, "EPERM") or vim.startswith(err, "EACCES")
end
local function get_children_sync(path)
local children = {}
local dir, err = uv.fs_opendir(path, nil, ENTRIES_BATCH_SIZE)
if not dir then
---@cast err -nil
if is_permission_error(err) then
log.debug(err)
else
log.error(err)
end
return children
end
repeat
local stats = uv.fs_readdir(dir)
if not stats then
break
end
local more = false
for i, stat in ipairs(stats) do
more = i == ENTRIES_BATCH_SIZE
local child_path = utils.path_join(path, stat.name)
table.insert(children, { path = child_path, type = stat.type })
end
until not more
uv.fs_closedir(dir)
return children
end
local function get_children_async(path, callback)
local children = {}
uv.fs_opendir(path, function(err, dir)
if err then
if is_permission_error(err) then
log.debug(err)
else
log.error(err)
end
callback(children)
return
end
local readdir_batch
---@param _ string?
---@param stats uv.fs_readdir.entry[]
readdir_batch = function(_, stats)
if stats then
local more = false
for i, stat in ipairs(stats) do
more = i == ENTRIES_BATCH_SIZE
local child_path = utils.path_join(path, stat.name)
table.insert(children, { path = child_path, type = stat.type })
end
if more then
return uv.fs_readdir(dir, readdir_batch)
end
end
uv.fs_closedir(dir)
callback(children)
end
uv.fs_readdir(dir, readdir_batch)
end, ENTRIES_BATCH_SIZE)
end
local function scan_dir_sync(context, path)
process_node(context, path)
local children = get_children_sync(path)
for _, child in ipairs(children) do
create_node(context, child)
if child.type == "directory" then
local grandchild_nodes = get_children_sync(child.path)
if
grandchild_nodes == nil
or #grandchild_nodes == 0
or (#grandchild_nodes == 1 and grandchild_nodes[1].type == "directory")
or context.recursive
then
scan_dir_sync(context, child.path)
end
end
end
end
--- async method
local function scan_dir_async(context, path)
log.debug("scan_dir_async - start " .. path)
local get_children = async.wrap(function(_path, callback)
return get_children_async(_path, callback)
end, 2)
local children = get_children(path)
for _, child in ipairs(children) do
create_node(context, child)
if child.type == "directory" then
local grandchild_nodes = get_children(child.path)
if
grandchild_nodes == nil
or #grandchild_nodes == 0
or (#grandchild_nodes == 1 and grandchild_nodes[1].type == "directory")
or context.recursive
then
scan_dir_async(context, child.path)
end
end
end
process_node(context, path)
log.debug("scan_dir_async - finish " .. path)
return path
end
-- async_scan scans all the directories in context.paths_to_load
-- and adds them as items to render in the UI.
---@param context neotree.sources.filesystem.Context
local function async_scan(context, path)
log.trace("async_scan: ", path)
local scan_mode = require("neo-tree").config.filesystem.scan_mode
if scan_mode == "deep" then
local scan_tasks = {}
for _, p in ipairs(context.paths_to_load) do
local scan_task = function()
scan_dir_async(context, p)
end
table.insert(scan_tasks, scan_task)
end
async.util.run_all(
scan_tasks,
vim.schedule_wrap(function()
job_complete(context)
end)
)
return
end
-- scan_mode == "shallow"
context.directories_scanned = 0
context.directories_to_scan = #context.paths_to_load
context.on_exit = vim.schedule_wrap(function()
job_complete(context)
end)
-- from https://github.com/nvim-lua/plenary.nvim/blob/master/lua/plenary/scandir.lua
local function read_dir(current_dir, ctx)
uv.fs_opendir(current_dir, function(err, dir)
if err then
log.error(current_dir, ": ", err)
return
end
local function on_fs_readdir(err, entries)
if err then
log.error(current_dir, ": ", err)
return
end
if entries then
for _, entry in ipairs(entries) do
local success, item = pcall(
file_items.create_item,
ctx,
utils.path_join(current_dir, entry.name),
entry.type
)
if success then
if ctx.recursive and item.type == "directory" then
ctx.directories_to_scan = ctx.directories_to_scan + 1
table.insert(ctx.paths_to_load, item.path)
end
else
log.error("error creating item for ", path)
end
end
uv.fs_readdir(dir, on_fs_readdir)
return
end
uv.fs_closedir(dir)
on_directory_loaded(ctx, current_dir)
ctx.directories_scanned = ctx.directories_scanned + 1
if ctx.directories_scanned == #ctx.paths_to_load then
ctx.on_exit()
end
--local next_path = dir_complete(ctx, current_dir)
--if next_path then
-- local success, error = pcall(read_dir, next_path)
-- if not success then
-- log.error(next_path, ": ", error)
-- end
--else
-- on_exit()
--end
end
uv.fs_readdir(dir, on_fs_readdir)
end)
end
--local first = table.remove(context.paths_to_load)
--local success, err = pcall(read_dir, first)
--if not success then
-- log.error(first, ": ", err)
--end
for i = 1, context.directories_to_scan do
read_dir(context.paths_to_load[i], context)
end
end
---@param context neotree.sources.filesystem.Context
---@param path_to_scan string
local function sync_scan(context, path_to_scan)
log.trace("sync_scan: ", path_to_scan)
local scan_mode = require("neo-tree").config.filesystem.scan_mode
if scan_mode == "deep" then
for _, path in ipairs(context.paths_to_load) do
scan_dir_sync(context, path)
-- scan_dir(context, path)
end
job_complete(context)
else -- scan_mode == "shallow"
local dir, err = uv.fs_opendir(path_to_scan, nil, ENTRIES_BATCH_SIZE)
if dir then
repeat
local stats = uv.fs_readdir(dir)
if not stats then
break
end
local more = false
for i, stat in ipairs(stats) do
more = i == ENTRIES_BATCH_SIZE
local path = utils.path_join(path_to_scan, stat.name)
local success, _ = pcall(file_items.create_item, context, path, stat.type)
if success then
if context.recursive and stat.type == "directory" then
table.insert(context.paths_to_load, path)
end
else
log.error("error creating item for ", path)
end
end
until not more
uv.fs_closedir(dir)
else
log.error("Error opening dir:", err)
end
local next_path = dir_complete(context, path_to_scan)
if next_path then
sync_scan(context, next_path)
else
job_complete(context)
end
end
end
---@param state neotree.sources.filesystem.State
---@param parent_id string?
---@param path_to_reveal string?
---@param callback function
M.get_items_sync = function(state, parent_id, path_to_reveal, callback)
M.get_items(state, parent_id, path_to_reveal, callback, false)
end
---@param state neotree.sources.filesystem.State
---@param parent_id string?
---@param path_to_reveal string?
---@param callback function
M.get_items_async = function(state, parent_id, path_to_reveal, callback)
M.get_items(state, parent_id, path_to_reveal, callback, true)
end
---@param context neotree.sources.filesystem.Context
local handle_search_pattern = function(context)
local state = context.state
local root = context.root
local search_opts = {
filtered_items = state.filtered_items,
find_command = state.find_command,
limit = state.search_limit or 50,
path = root.path,
term = state.search_pattern,
find_args = state.find_args,
find_by_full_path_words = state.find_by_full_path_words,
fuzzy_finder_mode = state.fuzzy_finder_mode,
on_insert = function(err, path)
if err then
log.debug(err)
else
file_items.create_item(context, path)
end
end,
on_exit = vim.schedule_wrap(function()
job_complete(context)
end),
}
if state.use_fzy then
filter_external.fzy_sort_files(search_opts, state)
else
-- Use the external command because the plenary search is slow
filter_external.find_files(search_opts)
end
end
---@param context neotree.sources.filesystem.Context
---@param async_dir_scan boolean
local handle_refresh_or_up = function(context, async_dir_scan)
local parent_id = context.parent_id
local path_to_reveal = context.path_to_reveal
local state = context.state
local path = parent_id or state.path
context.paths_to_load = {}
if parent_id == nil then
if utils.truthy(state.force_open_folders) then
for _, f in ipairs(state.force_open_folders) do
table.insert(context.paths_to_load, f)
end
elseif state.tree then
context.paths_to_load = renderer.get_expanded_nodes(state.tree, state.path)
end
-- Ensure parents of all expanded nodes are also scanned
if #context.paths_to_load > 0 and state.tree then
---@type table<string, boolean?>
local seen = {}
for _, p in ipairs(context.paths_to_load) do
---@type string?
local current = p
while current do
if seen[current] then
break
end
seen[current] = true
local current_node = state.tree:get_node(current)
current = current_node and current_node:get_parent_id()
end
end
context.paths_to_load = vim.tbl_keys(seen)
end
-- Ensure that there are no nested files in the list of folders to load
context.paths_to_load = vim.tbl_filter(function(p)
local stats = uv.fs_stat(p)
return stats and stats.type == "directory" or false
end, context.paths_to_load)
if path_to_reveal then
-- be sure to load all of the folders leading up to the path to reveal
local path_to_reveal_parts = utils.split(path_to_reveal, utils.path_separator)
table.remove(path_to_reveal_parts) -- remove the file name
-- add all parent folders to the list of paths to load
utils.reduce(path_to_reveal_parts, "", function(acc, part)
local current_path = utils.path_join(acc, part)
if #current_path > #path then -- within current root
table.insert(context.paths_to_load, current_path)
table.insert(state.default_expanded_nodes, current_path)
end
return current_path
end)
context.paths_to_load = utils.unique(context.paths_to_load)
end
end
local filtered_items = state.filtered_items or {}
context.is_a_never_show_file = function(fname)
if fname then
local _, name = utils.split_path(fname)
if name then
if filtered_items.never_show and filtered_items.never_show[name] then
return true
end
if utils.is_filtered_by_pattern(filtered_items.never_show_by_pattern, fname, name) then
return true
end
end
end
return false
end
table.insert(context.paths_to_load, path)
if async_dir_scan then
async_scan(context, path)
else
sync_scan(context, path)
end
end
---@class neotree.sources.filesystem.Context : neotree.FileItemContext
---@field state neotree.sources.filesystem.State
---@field recursive boolean?
---@field parent_id string?
---@field callback function?
---@field async boolean?
---@field root neotree.FileItem.Directory|neotree.FileItem.Link
---@field directories_scanned integer?
---@field directories_to_scan integer?
---@field on_exit function?
---async
---@field paths_to_load string[]
---@field is_a_never_show_file fun(filename: string?):boolean
---@class neotree.sources.filesystem.State : neotree.StateWithTree, neotree.Config.Filesystem
---@field path string
---@param state neotree.sources.filesystem.State
---@param parent_id string?
---@param callback function?
---@param async_dir_scan boolean?
---@param recursive boolean?
M.get_items = function(state, parent_id, path_to_reveal, callback, async_dir_scan, recursive)
renderer.acquire_window(state)
if state.async_directory_scan == "always" then
async_dir_scan = true
elseif state.async_directory_scan == "never" then
async_dir_scan = false
elseif type(async_dir_scan) == "nil" then
async_dir_scan = (state.async_directory_scan == "auto") or state.async_directory_scan ~= nil
end
if not parent_id then
M.stop_watchers(state)
end
---@type neotree.sources.filesystem.Context
local context = file_items.create_context() --[[@as neotree.sources.filesystem.Context]]
context.state = state
context.parent_id = parent_id
context.path_to_reveal = path_to_reveal
context.recursive = recursive
context.callback = callback
-- Create root folder
local root = file_items.create_item(context, parent_id or state.path, "directory") --[[@as neotree.FileItem.Directory]]
root.name = vim.fn.fnamemodify(root.path, ":~")
root.loaded = true
root.search_pattern = state.search_pattern
context.root = root
context.folders[root.path] = root
state.default_expanded_nodes = state.force_open_folders or { state.path }
if state.search_pattern then
handle_search_pattern(context)
else
-- In the case of a refresh or navigating up, we need to make sure that all
-- open folders are loaded.
handle_refresh_or_up(context, async_dir_scan)
end
end
---@param state neotree.sources.filesystem.State
---@param parent_id string
---@param recursive boolean?
M.get_dir_items_async = function(state, parent_id, recursive)
local context = file_items.create_context() --[[@as neotree.sources.filesystem.Context]]
context.state = state
context.parent_id = parent_id
context.path_to_reveal = nil
context.recursive = recursive
context.callback = nil
context.paths_to_load = {}
-- Create root folder
local root = file_items.create_item(context, parent_id or state.path, "directory") --[[@as neotree.FileItem.Directory]]
root.name = vim.fn.fnamemodify(root.path, ":~")
root.loaded = true
root.search_pattern = state.search_pattern
context.root = root
context.folders[root.path] = root
state.default_expanded_nodes = state.force_open_folders or { state.path }
local filtered_items = state.filtered_items or {}
context.is_a_never_show_file = function(fname)
if fname then
local _, name = utils.split_path(fname)
if name then
if filtered_items.never_show and filtered_items.never_show[name] then
return true
end
if utils.is_filtered_by_pattern(filtered_items.never_show_by_pattern, fname, name) then
return true
end
end
end
return false
end
table.insert(context.paths_to_load, parent_id)
local scan_tasks = {}
for _, p in ipairs(context.paths_to_load) do
local scan_task = function()
scan_dir_async(context, p)
end
table.insert(scan_tasks, scan_task)
end
async.util.join(scan_tasks)
job_complete_async(context)
local finalize = async.wrap(function(_context, _callback)
vim.schedule(function()
render_context(_context)
_callback()
end)
end, 2)
finalize(context)
end
---@param state neotree.sources.filesystem.State
M.stop_watchers = function(state)
if state.use_libuv_file_watcher and state.tree then
-- We are loaded a new root or refreshing, unwatch any folders that were
-- previously being watched.
local loaded_folders = renderer.select_nodes(state.tree, function(node)
return node.type == "directory" and node.loaded
end)
fs_watch.unwatch_git_index(state.path, require("neo-tree").config.git_status_async)
for _, folder in ipairs(loaded_folders) do
log.trace("Unwatching folder ", folder.path)
if folder.is_link then
fs_watch.unwatch_folder(folder.link_to)
else
fs_watch.unwatch_folder(folder:get_id())
end
end
else
log.debug(
"Not unwatching folders... use_libuv_file_watcher is ",
state.use_libuv_file_watcher,
" and state.tree is ",
utils.truthy(state.tree)
)
end
end
return M

View file

@ -0,0 +1,177 @@
local events = require("neo-tree.events")
local log = require("neo-tree.log")
local git = require("neo-tree.git")
local utils = require("neo-tree.utils")
local uv = vim.uv or vim.loop
local M = {}
local flags = {
watch_entry = false,
stat = false,
recursive = false,
}
local watched = {}
local get_dot_git_folder = function(path, callback)
if type(callback) == "function" then
git.get_repository_root(path, function(git_root)
if git_root then
local git_folder = utils.path_join(git_root, ".git")
local stat = uv.fs_stat(git_folder)
if stat and stat.type == "directory" then
callback(git_folder, git_root)
end
else
callback(nil, nil)
end
end)
else
local git_root = git.get_repository_root(path)
if git_root then
local git_folder = utils.path_join(git_root, ".git")
local stat = uv.fs_stat(git_folder)
if stat and stat.type == "directory" then
return git_folder, git_root
end
end
return nil, nil
end
end
M.show_watched = function()
local items = {}
for _, handle in pairs(watched) do
items[handle.path] = handle.references
end
log.info("Watched Folders: ", vim.inspect(items))
end
---Watch a directory for changes to it's children. Not recursive.
---@param path string The directory to watch.
---@param custom_callback? function The callback to call when a change is detected.
---@param allow_git_watch? boolean Allow watching of git folders.
M.watch_folder = function(path, custom_callback, allow_git_watch)
if not allow_git_watch then
if path:find("/%.git$") or path:find("/%.git/") then
-- git folders seem to throw off fs events constantly.
log.debug("watch_folder(path): Skipping git folder: ", path)
return
end
end
local h = watched[path]
if h == nil then
log.trace("Starting new fs watch on: ", path)
local callback = custom_callback
or vim.schedule_wrap(function(err, fname)
if fname and fname:match("^%.null[-]ls_.+") then
-- null-ls temp file: https://github.com/jose-elias-alvarez/null-ls.nvim/pull/1075
return
end
if err then
log.error("file_event_callback: ", err)
return
end
events.fire_event(events.FS_EVENT, { afile = path })
end)
h = {
handle = uv.new_fs_event(),
path = path,
references = 0,
active = false,
callback = callback,
}
watched[path] = h
--w:start(path, flags, callback)
else
log.trace("Incrementing references for fs watch on: ", path)
end
h.references = h.references + 1
end
M.watch_git_index = function(path, async)
local function watch_git_folder(git_folder, git_root)
if git_folder then
local git_event_callback = vim.schedule_wrap(function(err, fname)
if fname and fname:match("^.+%.lock$") then
return
end
if fname and fname:match("^%._null-ls_.+") then
-- null-ls temp file: https://github.com/jose-elias-alvarez/null-ls.nvim/pull/1075
return
end
if err then
log.error("git_event_callback: ", err)
return
end
events.fire_event(events.GIT_EVENT, { path = fname, repository = git_root })
end)
M.watch_folder(git_folder, git_event_callback, true)
end
end
if async then
get_dot_git_folder(path, watch_git_folder)
else
watch_git_folder(get_dot_git_folder(path))
end
end
M.updated_watched = function()
for path, w in pairs(watched) do
if w.references > 0 then
if not w.active then
log.trace("References added for fs watch on: ", path, ", starting.")
w.handle:start(path, flags, w.callback)
w.active = true
end
else
if w.active then
log.trace("No more references for fs watch on: ", path, ", stopping.")
w.handle:stop()
w.active = false
end
end
end
end
---Stop watching a directory. If there are no more references to the handle,
---it will be destroyed. Otherwise, the reference count will be decremented.
---@param path string The directory to stop watching.
M.unwatch_folder = function(path, callback_id)
local h = watched[path]
if h then
log.trace("Decrementing references for fs watch on: ", path, callback_id)
h.references = h.references - 1
else
log.trace("(unwatch_folder) No fs watch found for: ", path)
end
end
M.unwatch_git_index = function(path, async)
local function unwatch_git_folder(git_folder, _)
if git_folder then
M.unwatch_folder(git_folder)
end
end
if async then
get_dot_git_folder(path, unwatch_git_folder)
else
unwatch_git_folder(get_dot_git_folder(path))
end
end
---Stop watching all directories. This is the nuclear option and it affects all
---sources.
M.unwatch_all = function()
for _, h in pairs(watched) do
h.handle:stop()
h.handle = nil
end
watched = {}
end
return M

View file

@ -0,0 +1,157 @@
--(c) 2008-2011 David Manura. Licensed under the same terms as Lua (MIT).
--Permission is hereby granted, free of charge, to any person obtaining a copy
--of this software and associated documentation files (the "Software"), to deal
--in the Software without restriction, including without limitation the rights
--to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
--copies of the Software, and to permit persons to whom the Software is
--furnished to do so, subject to the following conditions:
--The above copyright notice and this permission notice shall be included in
--all copies or substantial portions of the Software.
--THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
--IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
--FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
--AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
--LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
--OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
--THE SOFTWARE.
--(end license)
local M = { _TYPE = "module", _NAME = "globtopattern", _VERSION = "0.2.1.20120406" }
function M.globtopattern(g)
-- Some useful references:
-- - apr_fnmatch in Apache APR. For example,
-- http://apr.apache.org/docs/apr/1.3/group__apr__fnmatch.html
-- which cites POSIX 1003.2-1992, section B.6.
local p = "^" -- pattern being built
local i = 0 -- index in g
local c -- char at index i in g.
-- unescape glob char
local function unescape()
if c == "\\" then
i = i + 1
c = g:sub(i, i)
if c == "" then
p = "[^]"
return false
end
end
return true
end
-- escape pattern char
local function escape(c)
return c:match("^%w$") and c or "%" .. c
end
-- Convert tokens at end of charset.
local function charset_end()
while 1 do
if c == "" then
p = "[^]"
return false
elseif c == "]" then
p = p .. "]"
break
else
if not unescape() then
break
end
local c1 = c
i = i + 1
c = g:sub(i, i)
if c == "" then
p = "[^]"
return false
elseif c == "-" then
i = i + 1
c = g:sub(i, i)
if c == "" then
p = "[^]"
return false
elseif c == "]" then
p = p .. escape(c1) .. "%-]"
break
else
if not unescape() then
break
end
p = p .. escape(c1) .. "-" .. escape(c)
end
elseif c == "]" then
p = p .. escape(c1) .. "]"
break
else
p = p .. escape(c1)
i = i - 1 -- put back
end
end
i = i + 1
c = g:sub(i, i)
end
return true
end
-- Convert tokens in charset.
local function charset()
i = i + 1
c = g:sub(i, i)
if c == "" or c == "]" then
p = "[^]"
return false
elseif c == "^" or c == "!" then
i = i + 1
c = g:sub(i, i)
if c == "]" then
-- ignored
else
p = p .. "[^"
if not charset_end() then
return false
end
end
else
p = p .. "["
if not charset_end() then
return false
end
end
return true
end
-- Convert tokens.
while 1 do
i = i + 1
c = g:sub(i, i)
if c == "" then
p = p .. "$"
break
elseif c == "?" then
p = p .. "."
elseif c == "*" then
p = p .. ".*"
elseif c == "[" then
if not charset() then
break
end
elseif c == "\\" then
i = i + 1
c = g:sub(i, i)
if c == "" then
p = p .. "\\$"
break
end
p = p .. escape(c)
else
p = p .. escape(c)
end
end
return p
end
return M

View file

@ -0,0 +1,74 @@
--This file should contain all commands meant to be used by mappings.
local cc = require("neo-tree.sources.common.commands")
local utils = require("neo-tree.utils")
local manager = require("neo-tree.sources.manager")
---@class neotree.sources.GitStatus.Commands : neotree.sources.Common.Commands
local M = {}
local refresh = utils.wrap(manager.refresh, "git_status")
local redraw = utils.wrap(manager.redraw, "git_status")
-- ----------------------------------------------------------------------------
-- Common commands
-- ----------------------------------------------------------------------------
M.add = function(state)
cc.add(state, refresh)
end
M.add_directory = function(state)
cc.add_directory(state, refresh)
end
---Marks node as copied, so that it can be pasted somewhere else.
M.copy_to_clipboard = function(state)
cc.copy_to_clipboard(state, redraw)
end
---@type neotree.TreeCommandVisual
M.copy_to_clipboard_visual = function(state, selected_nodes)
cc.copy_to_clipboard_visual(state, selected_nodes, redraw)
end
---Marks node as cut, so that it can be pasted (moved) somewhere else.
M.cut_to_clipboard = function(state)
cc.cut_to_clipboard(state, redraw)
end
---@type neotree.TreeCommandVisual
M.cut_to_clipboard_visual = function(state, selected_nodes)
cc.cut_to_clipboard_visual(state, selected_nodes, redraw)
end
M.copy = function(state)
cc.copy(state, redraw)
end
M.move = function(state)
cc.move(state, redraw)
end
---Pastes all items from the clipboard to the current directory.
M.paste_from_clipboard = function(state)
cc.paste_from_clipboard(state, refresh)
end
M.delete = function(state)
cc.delete(state, refresh)
end
---@type neotree.TreeCommandVisual
M.delete_visual = function(state, selected_nodes)
cc.delete_visual(state, selected_nodes, refresh)
end
M.refresh = refresh
M.rename = function(state)
cc.rename(state, refresh)
end
cc._add_common_commands(M)
return M

View file

@ -0,0 +1,56 @@
-- This file contains the built-in components. Each componment is a function
-- that takes the following arguments:
-- config: A table containing the configuration provided by the user
-- when declaring this component in their renderer config.
-- node: A NuiNode object for the currently focused node.
-- state: The current state of the source providing the items.
--
-- The function should return either a table, or a list of tables, each of which
-- contains the following keys:
-- text: The text to display for this item.
-- highlight: The highlight group to apply to this text.
local highlights = require("neo-tree.ui.highlights")
local common = require("neo-tree.sources.common.components")
---@alias neotree.Component.GitStatus._Key
---|"name"
---@class neotree.Component.GitStatus
---@field [1] neotree.Component.GitStatus._Key|neotree.Component.Common._Key
---@type table<neotree.Component.GitStatus._Key, neotree.Renderer>
local M = {}
---@class (exact) neotree.Component.GitStatus.Name : neotree.Component.Common.Name
---@field [1] "current_filter"?
---@field use_git_status_colors boolean?
---@param config neotree.Component.GitStatus.Name
M.name = function(config, node, state)
local highlight = config.highlight or highlights.FILE_NAME_OPENED
local name = node.name
if node.type == "directory" then
if node:get_depth() == 1 then
highlight = highlights.ROOT_NAME
if node:has_children() then
name = "GIT STATUS for " .. name
else
name = "GIT STATUS (working tree clean) for " .. name
end
else
highlight = highlights.DIRECTORY_NAME
end
elseif config.use_git_status_colors then
local git_status = state.components.git_status({}, node, state)
if git_status and git_status.highlight then
highlight = git_status.highlight
end
end
return {
text = name,
highlight = highlight,
}
end
return vim.tbl_deep_extend("force", common, M)

View file

@ -0,0 +1,111 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
local utils = require("neo-tree.utils")
local renderer = require("neo-tree.ui.renderer")
local items = require("neo-tree.sources.git_status.lib.items")
local events = require("neo-tree.events")
local manager = require("neo-tree.sources.manager")
---@class neotree.sources.GitStatus : neotree.Source
local M = {
name = "git_status",
display_name = " 󰊢 Git ",
}
local wrap = function(func)
return utils.wrap(func, M.name)
end
local get_state = function()
return manager.get_state(M.name)
end
---Navigate to the given path.
---@param path string Path to navigate to. If empty, will navigate to the cwd.
M.navigate = function(state, path, path_to_reveal, callback, async)
state.path = path or state.path
state.dirty = false
if path_to_reveal then
renderer.position.set(state, path_to_reveal)
end
items.get_git_status(state)
if type(callback) == "function" then
vim.schedule(callback)
end
end
M.refresh = function()
manager.refresh(M.name)
end
---@class neotree.Config.GitStatus.Renderers : neotree.Config.Renderers
---@class (exact) neotree.Config.GitStatus : neotree.Config.Source
---@field bind_to_cwd boolean?
---@field renderers neotree.Config.GitStatus.Renderers?
---Configures the plugin, should be called before the plugin is used.
---@param config neotree.Config.GitStatus Configuration table containing any keys that the user
--wants to change from the defaults. May be empty to accept default values.
M.setup = function(config, global_config)
if config.before_render then
--convert to new event system
manager.subscribe(M.name, {
event = events.BEFORE_RENDER,
handler = function(state)
local this_state = get_state()
if state == this_state then
config.before_render(this_state)
end
end,
})
end
if global_config.enable_refresh_on_write then
manager.subscribe(M.name, {
event = events.VIM_BUFFER_CHANGED,
handler = function(args)
if utils.is_real_file(args.afile) then
M.refresh()
end
end,
})
end
if config.bind_to_cwd then
manager.subscribe(M.name, {
event = events.VIM_DIR_CHANGED,
handler = M.refresh,
})
end
if global_config.enable_diagnostics then
manager.subscribe(M.name, {
event = events.STATE_CREATED,
handler = function(state)
state.diagnostics_lookup = utils.get_diagnostic_counts()
end,
})
manager.subscribe(M.name, {
event = events.VIM_DIAGNOSTIC_CHANGED,
handler = wrap(manager.diagnostics_changed),
})
end
--Configure event handlers for modified files
if global_config.enable_modified_markers then
manager.subscribe(M.name, {
event = events.VIM_BUFFER_MODIFIED_SET,
handler = wrap(manager.opened_buffers_changed),
})
end
manager.subscribe(M.name, {
event = events.GIT_EVENT,
handler = M.refresh,
})
end
return M

View file

@ -0,0 +1,49 @@
local renderer = require("neo-tree.ui.renderer")
local file_items = require("neo-tree.sources.common.file-items")
local log = require("neo-tree.log")
local git = require("neo-tree.git")
local M = {}
---Get a table of all open buffers, along with all parent paths of those buffers.
---The paths are the keys of the table, and all the values are 'true'.
---@param state neotree.State
M.get_git_status = function(state)
if state.loading then
return
end
state.loading = true
local status_lookup, project_root = git.status(state.git_base, true, state.path)
state.path = project_root or state.path or vim.fn.getcwd()
local context = file_items.create_context()
context.state = state
-- Create root folder
local root = file_items.create_item(context, state.path, "directory") --[[@as neotree.FileItem.Directory]]
root.name = vim.fn.fnamemodify(root.path, ":~")
root.loaded = true
root.search_pattern = state.search_pattern
context.folders[root.path] = root
for path, status in pairs(status_lookup) do
local success, item = pcall(file_items.create_item, context, path, "file") --[[@as neotree.FileItem.File]]
item.status = status
if success then
item.extra = {
git_status = status,
}
else
log.error("Error creating item for " .. path .. ": " .. item)
end
end
state.git_status_lookup = status_lookup
state.default_expanded_nodes = {}
for id, _ in pairs(context.folders) do
table.insert(state.default_expanded_nodes, id)
end
file_items.advanced_sort(root.children, state)
renderer.show_nodes({ root }, state)
state.loading = false
end
return M

View file

@ -0,0 +1,772 @@
--This file should have all functions that are in the public api and either set
--or read the state of this source.
local nt = require("neo-tree")
local utils = require("neo-tree.utils")
local compat = require("neo-tree.utils._compat")
local fs_scan = require("neo-tree.sources.filesystem.lib.fs_scan")
local renderer = require("neo-tree.ui.renderer")
local inputs = require("neo-tree.ui.inputs")
local events = require("neo-tree.events")
local log = require("neo-tree.log")
local fs_watch = require("neo-tree.sources.filesystem.lib.fs_watch")
local M = {}
---@type table<string, neotree.SourceData>
local source_data = {}
---@type neotree.State[]
local all_states = {}
---@type table<string, neotree.Config.Source?>
local default_configs = {}
---@class neotree.SourceData
---@field name string
---@field state_by_tab table<integer, neotree.State>
---@field state_by_win table<integer, neotree.State>
---@field subscriptions table
---@field module neotree.Source?
---@param source_name string
---@return neotree.SourceData
local get_source_data = function(source_name)
assert(source_name, "get_source_data: source_name cannot be nil")
local sd = source_data[source_name]
if sd then
return sd
end
sd = {
name = source_name,
state_by_tab = {},
state_by_win = {},
subscriptions = {},
}
source_data[source_name] = sd
return sd
end
---@class neotree.State.Window : neotree.Config.Window
---@field win_width integer
---@field last_user_width integer
---@alias neotree.State.Position "top"|"bottom"|"left"|"right"|"current"|"float"
---@alias neotree.Internal.SortFieldProvider fun(node: NuiTree.Node):any
---@class neotree.State : neotree.Config.Source
---@field name string
---@field tabid integer
---@field id integer
---@field bufnr integer?
---@field dirty boolean
---@field position table
---@field git_base string
---@field sort table
---@field clipboard table
---@field current_position neotree.State.Position?
---@field disposed boolean?
---@field winid integer?
---@field path string?
---@field tree NuiTree?
---@field components table<string, neotree.Component>
---private-ish
---@field orig_tree NuiTree?
---@field _ready boolean?
---@field loading boolean?
---window
---@field window neotree.State.Window?
---@field win_width integer?
---@field longest_width_exact integer?
---@field longest_node integer?
---extras
---@field bind_to_cwd boolean?
---@field opened_buffers neotree.utils.OpenedBuffers?
---@field diagnostics_lookup neotree.utils.DiagnosticLookup?
---@field cwd_target neotree.Config.Filesystem.CwdTarget?
---@field sort_field_provider fun(node: NuiTree.Node):any
---@field explicitly_opened_nodes table<string, boolean?>?
---@field filtered_items neotree.Config.Filesystem.FilteredItems?
---@field skip_marker_at_level table<integer, boolean?>?
---@field group_empty_dirs boolean?
---git
---@field git_status_lookup neotree.git.Status?
---optional mapping args
---@field fallback string?
---@field config table?
---internal
---@field default_expanded_nodes NuiTree.Node[]?
---@field force_open_folders string[]?
---@field enable_source_selector boolean?
---@field follow_current_file neotree.Config.Filesystem.FollowCurrentFile?
---lsp
---@field lsp_winid number?
---@field lsp_bufnr number?
---search
---@field search_pattern string?
---@field use_fzy boolean?
---@field fzy_sort_result_scores table<string, integer?>?
---@field fuzzy_finder_mode "directory"|boolean?
---@field open_folders_before_search table?
---sort
---@field sort_function_override neotree.Config.SortFunction?
---keymaps
---@field resolved_mappings table<string, neotree.State.ResolvedMapping?>?
---@field commands table<string, neotree.TreeCommand?>?
---@class (exact) neotree.StateWithTree : neotree.State
---@field tree NuiTree
local a = {}
---@param tabid integer
---@param sd table
---@param winid integer?
---@return neotree.State
local function create_state(tabid, sd, winid)
nt.ensure_config()
local default_config = assert(default_configs[sd.name])
local state = vim.deepcopy(default_config, compat.noref())
---@cast state neotree.State
state.tabid = tabid
state.id = winid or tabid
state.dirty = true
state.position = {}
state.git_base = "HEAD"
state.sort = { label = "Name", direction = 1 }
events.fire_event(events.STATE_CREATED, state)
table.insert(all_states, state)
return state
end
M._get_all_states = function()
return all_states
end
---@param source_name string?
---@param action fun(state: neotree.State)
M._for_each_state = function(source_name, action)
M.dispose_invalid_tabs()
for _, state in ipairs(all_states) do
if source_name == nil or state.name == source_name then
action(state)
end
end
end
---For use in tests only, completely resets the state of all sources.
---This closes all windows as well since they would be broken by this action.
M._clear_state = function()
fs_watch.unwatch_all()
renderer.close_all_floating_windows()
for _, data in pairs(source_data) do
for _, state in pairs(data.state_by_tab) do
renderer.close(state)
end
for _, state in pairs(data.state_by_win) do
renderer.close(state)
end
end
source_data = {}
end
---@param source_name string
---@param config neotree.Config.Source
M.set_default_config = function(source_name, config)
if source_name == nil then
error("set_default_config: source_name cannot be nil")
end
default_configs[source_name] = config
local sd = get_source_data(source_name)
for tabid, tab_config in pairs(sd.state_by_tab) do
sd.state_by_tab[tabid] = vim.tbl_deep_extend("force", tab_config, config)
end
end
--TODO: we need to track state per window when working with netwrw style "current"
--position. How do we know which one to return when this is called?
---@param source_name string
---@param tabid integer?
---@param winid integer?
---@return neotree.State
M.get_state = function(source_name, tabid, winid)
assert(source_name, "get_state: source_name cannot be nil")
tabid = tabid or vim.api.nvim_get_current_tabpage()
local sd = get_source_data(source_name)
if type(winid) == "number" then
local win_state = sd.state_by_win[winid]
if not win_state then
win_state = create_state(tabid, sd, winid)
sd.state_by_win[winid] = win_state
end
return win_state
end
local tab_state = sd.state_by_tab[tabid]
if tab_state and tab_state.winid then
-- just in case tab and window get tangled up, tab state replaces window
sd.state_by_win[tab_state.winid] = nil
end
if not tab_state then
tab_state = create_state(tabid, sd)
sd.state_by_tab[tabid] = tab_state
end
return tab_state
end
---Returns the state for the current buffer, assuming it is a neo-tree buffer.
---@param winid number? The window id to use, if nil, the current window is used.
---@return neotree.State? state The state for the current buffer, if it's a neo-tree buffer.
M.get_state_for_window = function(winid)
winid = winid or vim.api.nvim_get_current_win()
local bufnr = vim.api.nvim_win_get_buf(winid)
local source_status, source_name = pcall(vim.api.nvim_buf_get_var, bufnr, "neo_tree_source")
local position_status, position = pcall(vim.api.nvim_buf_get_var, bufnr, "neo_tree_position")
if not source_status or not position_status then
return nil
end
local tabid = vim.api.nvim_get_current_tabpage()
if position == "current" then
return M.get_state(source_name, tabid, winid)
else
return M.get_state(source_name, tabid, nil)
end
end
M.get_path_to_reveal = function(include_terminals)
local win_id = vim.api.nvim_get_current_win()
local cfg = vim.api.nvim_win_get_config(win_id)
if cfg.relative > "" or cfg.external then
-- floating window, ignore
return nil
end
if vim.bo.filetype == "neo-tree" then
return nil
end
local path = vim.fn.expand("%:p")
if not utils.truthy(path) then
return nil
end
if not include_terminals and path:match("term://") then
return nil
end
return path
end
---@param source_name string
M.subscribe = function(source_name, event)
assert(source_name, "subscribe: source_name cannot be nil")
local sd = get_source_data(source_name)
if not sd.subscriptions then
sd.subscriptions = {}
end
if not utils.truthy(event.id) then
event.id = sd.name .. "." .. event.event
end
log.trace("subscribing to event: " .. event.id)
sd.subscriptions[event] = true
events.subscribe(event)
end
---@param source_name string
M.unsubscribe = function(source_name, event)
assert(source_name, "unsubscribe: source_name cannot be nil")
local sd = get_source_data(source_name)
log.trace("unsubscribing to event: " .. event.id or event.event)
if sd.subscriptions then
for sub, _ in pairs(sd.subscriptions) do
if sub.event == event.event and sub.id == event.id then
sd.subscriptions[sub] = false
events.unsubscribe(sub)
end
end
end
events.unsubscribe(event)
end
---@param source_name string
M.unsubscribe_all = function(source_name)
assert(source_name, "unsubscribe_all: source_name cannot be nil")
local sd = get_source_data(source_name)
if sd.subscriptions then
for event, subscribed in pairs(sd.subscriptions) do
if subscribed then
events.unsubscribe(event)
end
end
end
sd.subscriptions = {}
end
---@param source_name string
M.close = function(source_name, at_position)
local state = M.get_state(source_name)
if at_position then
if state.current_position == at_position then
return renderer.close(state)
else
return false
end
else
return renderer.close(state)
end
end
M.close_all = function(at_position)
local tabid = vim.api.nvim_get_current_tabpage()
for source_name, _ in pairs(source_data) do
M._for_each_state(source_name, function(state)
if state.tabid == tabid then
if at_position then
if state.current_position == at_position then
log.trace("Closing " .. source_name .. " at position " .. at_position)
pcall(renderer.close, state)
end
else
log.trace("Closing " .. source_name)
pcall(renderer.close, state)
end
end
end)
end
end
M.close_all_except = function(except_source_name)
local tabid = vim.api.nvim_get_current_tabpage()
for source_name, _ in pairs(source_data) do
M._for_each_state(source_name, function(state)
if state.tabid == tabid and source_name ~= except_source_name then
log.trace("Closing " .. source_name)
pcall(renderer.close, state)
end
end)
end
end
---Redraws the tree with updated diagnostics without scanning the filesystem again.
---@param source_name string
---@param args table<string, neotree.utils.DiagnosticCounts?>
M.diagnostics_changed = function(source_name, args)
if not type(args) == "table" then
error("diagnostics_changed: args must be a table")
end
M._for_each_state(source_name, function(state)
state.diagnostics_lookup = args.diagnostics_lookup
renderer.redraw(state)
end)
end
---Called by autocmds when the cwd dir is changed. This will change the root.
---@param source_name string
M.dir_changed = function(source_name)
M._for_each_state(source_name, function(state)
local cwd = M.get_cwd(state)
if state.path and cwd == state.path then
return
end
if renderer.window_exists(state) then
M.navigate(state, cwd)
else
state.path = nil
state.dirty = true
end
end)
end
--
---Redraws the tree with updated git_status without scanning the filesystem again.
---@param source_name string
M.git_status_changed = function(source_name, args)
if not type(args) == "table" then
error("git_status_changed: args must be a table")
end
M._for_each_state(source_name, function(state)
if utils.is_subpath(args.git_root, state.path) then
state.git_status_lookup = args.git_status
renderer.redraw(state)
end
end)
end
-- Vimscript functions like vim.fn.getcwd take tabpage number (tab position counting from left)
-- but API functions operate on tabpage id (as returned by nvim_tabpage_get_number). These values
-- get out of sync when tabs are being moved and we want to track state according to tabpage id.
local to_tabnr = function(tabid)
return tabid > 0 and vim.api.nvim_tabpage_get_number(tabid) or tabid
end
---@param state neotree.State
local get_params_for_cwd = function(state)
local tabid = state.tabid
-- the id is either the tabid for sidebars or the winid for splits
local winid = state.id == tabid and -1 or state.id
if state.cwd_target then
local target = state.cwd_target.sidebar
if state.current_position == "current" then
target = state.cwd_target.current
end
if target == "window" then
return winid, to_tabnr(tabid)
elseif target == "global" then
return -1, -1
elseif target == "none" then
return nil, nil
else -- default to tab
return -1, to_tabnr(tabid)
end
else
return winid, to_tabnr(tabid)
end
end
---@param state neotree.State
---@return string
M.get_cwd = function(state)
local winid, tabnr = get_params_for_cwd(state)
if winid or tabnr then
local success, cwd = pcall(vim.fn.getcwd, winid, tabnr)
if success then
return cwd
end
end
local success, cwd = pcall(vim.fn.getcwd)
if success then
return cwd
end
local err = cwd
log.debug(err)
return state.path or ""
end
---@param state neotree.State
M.set_cwd = function(state)
if not state.path then
return
end
local winid, tabnr = get_params_for_cwd(state)
if winid == nil and tabnr == nil then
return
end
local _, cwd = pcall(vim.fn.getcwd, winid, tabnr)
if state.path ~= cwd then
local path = utils.escape_path_for_cmd(state.path)
if winid > 0 then
vim.cmd("lcd " .. path)
elseif tabnr > 0 then
vim.cmd("tcd " .. path)
else
vim.cmd("cd " .. path)
end
end
end
---@param state neotree.State
local dispose_state = function(state)
pcall(fs_scan.stop_watchers, state)
pcall(renderer.close, state)
source_data[state.name].state_by_tab[state.id] = nil
source_data[state.name].state_by_win[state.id] = nil
state.disposed = true
end
---@param source_name string
---@param tabid integer
M.dispose = function(source_name, tabid)
-- Iterate in reverse because we are removing items during loop
for i = #all_states, 1, -1 do
local state = all_states[i]
if source_name == nil or state.name == source_name then
if not tabid or tabid == state.tabid then
log.trace(state.name, " disposing of tab: ", tabid)
dispose_state(state)
table.remove(all_states, i)
end
end
end
end
---@param tabid integer
M.dispose_tab = function(tabid)
if not tabid then
error("dispose_tab: tabid cannot be nil")
end
-- Iterate in reverse because we are removing items during loop
for i = #all_states, 1, -1 do
local state = all_states[i]
if tabid == state.tabid then
log.trace(state.name, " disposing of tab: ", tabid, state.name)
dispose_state(state)
table.remove(all_states, i)
end
end
end
M.dispose_invalid_tabs = function()
-- Iterate in reverse because we are removing items during loop
for i = #all_states, 1, -1 do
local state = all_states[i]
-- if not valid_tabs[state.tabid] then
if not vim.api.nvim_tabpage_is_valid(state.tabid) then
log.trace(state.name, " disposing of tab: ", state.tabid, state.name)
dispose_state(state)
table.remove(all_states, i)
end
end
end
---@param winid number
M.dispose_window = function(winid)
assert(winid, "dispose_window: winid cannot be nil")
-- Iterate in reverse because we are removing items during loop
for i = #all_states, 1, -1 do
local state = all_states[i]
if state.id == winid then
log.trace(state.name, " disposing of window: ", winid, state.name)
dispose_state(state)
table.remove(all_states, i)
end
end
end
---@param source_name string
M.float = function(source_name)
local state = M.get_state(source_name)
state.current_position = "float"
local path_to_reveal = M.get_path_to_reveal()
M.navigate(source_name, state.path, path_to_reveal)
end
---Focus the window, opening it if it is not already open.
---@param source_name string Source name.
---@param path_to_reveal string|nil Node to focus after the items are loaded.
---@param callback function|nil Callback to call after the items are loaded.
M.focus = function(source_name, path_to_reveal, callback)
local state = M.get_state(source_name)
state.current_position = nil
if path_to_reveal then
M.navigate(source_name, state.path, path_to_reveal, callback)
else
if not state.dirty and renderer.window_exists(state) then
vim.api.nvim_set_current_win(state.winid)
else
M.navigate(source_name, state.path, nil, callback)
end
end
end
---Redraws the tree with updated modified markers without scanning the filesystem again.
M.opened_buffers_changed = function(source_name, args)
if not type(args) == "table" then
error("opened_buffers_changed: args must be a table")
end
if type(args.opened_buffers) == "table" then
M._for_each_state(source_name, function(state)
if utils.tbl_equals(args.opened_buffers, state.opened_buffers) then
-- no changes, no need to redraw
return
end
state.opened_buffers = args.opened_buffers
renderer.redraw(state)
end)
end
end
---Navigate to the given path.
---@param state_or_source_name neotree.State|string The state or source name to navigate.
---@param path string? Path to navigate to. If empty, will navigate to the cwd.
---@param path_to_reveal string? Node to focus after the items are loaded.
---@param callback function? Callback to call after the items are loaded.
---@param async boolean? Whether to load the items asynchronously, may not be respected by all sources.
M.navigate = function(state_or_source_name, path, path_to_reveal, callback, async)
require("neo-tree").ensure_config()
local state, source_name
if type(state_or_source_name) == "string" then
state = M.get_state(state_or_source_name)
source_name = state_or_source_name
elseif type(state_or_source_name) == "table" then
state = state_or_source_name
source_name = state.name
else
log.error("navigate: state_or_source_name must be a string or a table")
return
end
log.trace("navigate", source_name, path, path_to_reveal)
local mod = get_source_data(source_name).module
if not mod then
mod = require("neo-tree.sources." .. source_name)
end
mod.navigate(state, path, path_to_reveal, callback, async)
end
---Redraws the tree without scanning the filesystem again. Use this after
-- making changes to the nodes that would affect how their components are
-- rendered.
M.redraw = function(source_name)
M._for_each_state(source_name, function(state)
renderer.redraw(state)
end)
end
---Refreshes the tree by scanning the filesystem again.
M.refresh = function(source_name, callback)
if type(callback) ~= "function" then
callback = nil
end
local current_tabid = vim.api.nvim_get_current_tabpage()
log.trace(source_name, "refresh")
for i = 1, #all_states, 1 do
local state = all_states[i]
if state.tabid == current_tabid and state.path and renderer.window_exists(state) then
local success, err = pcall(M.navigate, state, state.path, nil, callback)
if not success then
log.error(err)
end
else
state.dirty = true
end
end
end
--- @deprecated
--- To be removed in 4.0. Use:
--- ```lua
--- require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true })` instead
--- ```
M.reveal_current_file = function(source_name, callback, force_cwd)
log.warn(
[[DEPRECATED: use `require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true })` instead]]
)
log.trace("Revealing current file")
local state = M.get_state(source_name)
state.current_position = nil
local path = M.get_path_to_reveal()
if not path then
M.focus(source_name)
return
end
local cwd = state.path
if cwd == nil then
cwd = M.get_cwd(state)
end
if force_cwd then
if not utils.is_subpath(cwd, path) then
state.path, _ = utils.split_path(path)
end
elseif not utils.is_subpath(cwd, path) then
cwd, _ = utils.split_path(path)
inputs.confirm("File not in cwd. Change cwd to " .. cwd .. "?", function(response)
if response == true then
state.path = cwd
M.focus(source_name, path, callback)
else
M.focus(source_name, nil, callback)
end
end)
return
end
if path then
if not renderer.focus_node(state, path) then
M.focus(source_name, path, callback)
end
end
end
---@deprecated
--- To be removed in 4.0. Use:
--- ```lua
--- require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true, position = "current" }
--- ```
--- instead.
M.reveal_in_split = function(source_name, callback)
log.warn(
[[DEPRECATED: use `require("neo-tree.command").execute({ source_name = source_name, action = "focus", reveal = true, position = "current" })` instead]]
)
local state = M.get_state(source_name, nil, vim.api.nvim_get_current_win())
state.current_position = "current"
local path_to_reveal = M.get_path_to_reveal()
if not path_to_reveal then
M.navigate(state, nil, nil, callback)
return
end
local cwd = state.path
if cwd == nil then
cwd = M.get_cwd(state)
end
if cwd and not utils.is_subpath(cwd, path_to_reveal) then
state.path, _ = utils.split_path(path_to_reveal)
end
M.navigate(state, state.path, path_to_reveal, callback)
end
---Opens the tree and displays the current path or cwd, without focusing it.
M.show = function(source_name)
local state = M.get_state(source_name)
state.current_position = nil
if not renderer.window_exists(state) then
local current_win = vim.api.nvim_get_current_win()
M.navigate(source_name, state.path, nil, function()
vim.api.nvim_set_current_win(current_win)
end)
end
end
M.show_in_split = function(source_name, callback)
local state = M.get_state(source_name, nil, vim.api.nvim_get_current_win())
state.current_position = "current"
M.navigate(state, state.path, nil, callback)
end
local validate = require("neo-tree.health.typecheck").validate
---@param source_name string
---@param module neotree.Source
M.validate_source = function(source_name, module)
if source_name == nil then
error("register_source: source_name cannot be nil")
end
if module == nil then
error("register_source: module cannot be nil")
end
if type(module) ~= "table" then
error("register_source: module must be a table")
end
validate(source_name, module, function(mod)
validate("navigate", mod.navigate, "function")
validate("setup", mod.setup, "function")
end)
end
---@class neotree.Source
---@field setup fun(config: neotree.Config.Source, global_config: neotree.Config.Base)
---@field navigate fun(state: neotree.State, path: string?, path_to_reveal: string?, callback: function?, async: boolean?)
---Configures the plugin, should be called before the plugin is used.
---@param source_name string Name of the source.
---@param config neotree.Config.Source Configuration table containing merged configuration for the source.
---@param global_config neotree.Config.Base Global configuration table, shared between all sources.
---@param module neotree.Source Module containing the source's code.
M.setup = function(source_name, config, global_config, module)
log.debug(source_name, " setup ", config)
M.unsubscribe_all(source_name)
M.set_default_config(source_name, config)
if module == nil then
module = require("neo-tree.sources." .. source_name)
end
local success, err = pcall(M.validate_source, source_name, module)
if success then
success, err = pcall(module.setup, config, global_config)
if success then
get_source_data(source_name).module = module
else
log.error("Source " .. source_name .. " setup failed: " .. err)
end
else
log.error("Source " .. source_name .. " is invalid: " .. err)
end
end
return M