Meh I'll figure out submodules later
This commit is contained in:
parent
4ca9d44a90
commit
8cb281f436
352 changed files with 66107 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue