Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 131 additions & 123 deletions doc/nvim-tree-lua.txt

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion lua/nvim-tree/_meta/api/fs.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function nvim_tree.api.fs.copy.filename(node) end

---
---Copy to the nvim-tree clipboard.
---In visual mode, copies all nodes in the visual selection.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the explicit notes is most gratefully appreciated.

---
---@param node? nvim_tree.api.Node
function nvim_tree.api.fs.copy.node(node) end
Expand All @@ -52,6 +53,7 @@ function nvim_tree.api.fs.create(node) end

---
---Cut to the nvim-tree clipboard.
---In visual mode, cuts all nodes in the visual selection.
---
---@param node? nvim_tree.api.Node
function nvim_tree.api.fs.cut(node) end
Expand All @@ -71,6 +73,7 @@ function nvim_tree.api.fs.print_clipboard() end

---
---Delete from the file system.
---In visual mode, deletes all nodes in the visual selection with a single prompt.
---
---@param node? nvim_tree.api.Node
function nvim_tree.api.fs.remove(node) end
Expand Down Expand Up @@ -106,7 +109,8 @@ function nvim_tree.api.fs.rename_node(node) end
function nvim_tree.api.fs.rename_sub(node) end

---
---Trash as per |nvim_tree.config.trash|
---Trash as per |nvim_tree.config.trash|.
---In visual mode, trashes all nodes in the visual selection with a single prompt.
---
---@param node? nvim_tree.api.Node
function nvim_tree.api.fs.trash(node) end
Expand Down
2 changes: 1 addition & 1 deletion lua/nvim-tree/_meta/api/marks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function nvim_tree.api.marks.get() end
function nvim_tree.api.marks.list() end

---
---Toggle mark.
---Toggle mark. In visual mode, toggles all nodes in the visual selection.
---
---@param node? nvim_tree.api.Node file or directory
function nvim_tree.api.marks.toggle(node) end
Expand Down
44 changes: 44 additions & 0 deletions lua/nvim-tree/actions/fs/remove-file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,50 @@ function M.fn(node)
end
end

---Remove multiple nodes with a single confirmation prompt; used for visual selection operations.
---@param nodes Node[]
function M.visual_fn(nodes)
if #nodes == 0 then
return
end

local function execute()
for i = #nodes, 1, -1 do
if nodes[i].name ~= ".." then
M.remove(nodes[i])
end
end
local explorer = core.get_explorer()
if not M.config.filesystem_watchers.enable and explorer then
explorer:reload_explorer()
end
end

if M.config.ui.confirm.remove then
local prompt_select = string.format("Remove %d selected?", #nodes)
local prompt_input, items_short, items_long

if M.config.ui.confirm.default_yes then
prompt_input = prompt_select .. " Y/n: "
items_short = { "", "n" }
items_long = { "Yes", "No" }
else
prompt_input = prompt_select .. " y/N: "
items_short = { "", "y" }
items_long = { "No", "Yes" }
end

lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_delete", function(item_short)
utils.clear_prompt()
if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then
execute()
end
end)
else
execute()
end
end

function M.setup(opts)
M.config.ui = opts.ui
M.config.actions = opts.actions
Expand Down
40 changes: 40 additions & 0 deletions lua/nvim-tree/actions/fs/trash.lua
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,46 @@ function M.fn(node)
end
end

---Trash multiple nodes with a single confirmation prompt; used for visual selection operations.
---@param nodes Node[]
function M.visual_fn(nodes)
if #nodes == 0 then
return
end

local function execute()
for i = #nodes, 1, -1 do
if nodes[i].name ~= ".." then
M.remove(nodes[i])
end
end
end

if M.config.ui.confirm.trash then
local prompt_select = string.format("Trash %d selected?", #nodes)
local prompt_input, items_short, items_long

if M.config.ui.confirm.default_yes then
prompt_input = prompt_select .. " Y/n: "
items_short = { "", "n" }
items_long = { "Yes", "No" }
else
prompt_input = prompt_select .. " y/N: "
items_short = { "", "y" }
items_long = { "No", "Yes" }
end

lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_trash", function(item_short)
utils.clear_prompt()
if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then
execute()
end
end)
else
execute()
end
end

function M.setup(opts)
M.config.ui = opts.ui
M.config.trash = opts.trash
Expand Down
4 changes: 3 additions & 1 deletion lua/nvim-tree/api.lua
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
---local api = require("nvim-tree.api")
---api.tree.reload()
---```
---Generally, functions accepting a [nvim_tree.api.Node] as their first argument will use the node under the cursor when that argument is not present or nil. e.g. the following are functionally identical:
---Generally, functions accepting a [nvim_tree.api.Node] as their first argument will use the node under the cursor when that argument is not present or nil. Some functions are mode-dependent: when invoked in visual mode they will operate on all nodes in the visual selection instead of a single node. See |nvim-tree-mappings-default| for which mappings support visual mode.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many thanks for the detail.

---
---e.g. the following are functionally identical:
---```lua
---
---api.node.open.edit(nil, { focus = true })
Expand Down
80 changes: 75 additions & 5 deletions lua/nvim-tree/api/impl/post.lua
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,76 @@ local function wrap_explorer_member(explorer_member, member_method)
end
end

---Check if the current mode is visual (v, V, or CTRL-V).
---@return boolean
local function is_visual_mode()
local mode = vim.api.nvim_get_mode().mode
return mode == "v" or mode == "V" or mode == "\22" -- \22 is CTRL-V
end

---Exit visual mode synchronously.
local function exit_visual_mode()
local esc = vim.api.nvim_replace_termcodes("<Esc>", true, false, true)
vim.api.nvim_feedkeys(esc, "nx", false)
end

---Get the visual selection range nodes, exiting visual mode.
---@return Node[]?
local function get_visual_nodes()
local explorer = require("nvim-tree.core").get_explorer()
if not explorer then
return nil
end
local start_line = vim.fn.line("v")
local end_line = vim.fn.line(".")
if start_line > end_line then
start_line, end_line = end_line, start_line
end
local nodes = explorer:get_nodes_in_range(start_line, end_line)
exit_visual_mode()
return nodes
end

---@class WrapNodeOrVisualOpts
---@field visual_fn? fun(nodes: Node[]) bulk visual handler; when nil, fn is called per-node
---@field filter_descendants? boolean filter out descendant nodes in visual mode (default true)

---Wrap a single-node function to be mode-dependent: in visual mode, operate
---on all nodes in the visual range; in normal mode, operate on a single node.
---
---When opts.visual_fn is provided, it receives all nodes at once (for bulk
---operations like remove/trash that need a single confirmation prompt).
---When opts.visual_fn is nil, fn is called on each node individually.
---
---@param fn fun(node: Node, ...): any
---@param opts? WrapNodeOrVisualOpts
---@return fun(node: Node?, ...): any
local function wrap_node_or_visual(fn, opts)
opts = opts or {}
return function(node, ...)
if is_visual_mode() then
local nodes = get_visual_nodes()
if nodes then
if opts.filter_descendants ~= false then
nodes = utils.filter_descendant_nodes(nodes)
end
if opts.visual_fn then
opts.visual_fn(nodes)
else
for _, n in ipairs(nodes) do
fn(n, ...)
end
end
end
else
node = node or wrap_explorer("get_node_at_cursor")()
if node then
return fn(node, ...)
end
end
end
end

---@class NodeEditOpts
---@field quit_on_open boolean|nil default false
---@field focus boolean|nil default true
Expand Down Expand Up @@ -173,18 +243,18 @@ function M.hydrate(api)
api.tree.winid = view.winid

api.fs.create = wrap_node_or_nil(actions.fs.create_file.fn)
api.fs.remove = wrap_node(actions.fs.remove_file.fn)
api.fs.trash = wrap_node(actions.fs.trash.fn)
api.fs.remove = wrap_node_or_visual(actions.fs.remove_file.fn, { visual_fn = actions.fs.remove_file.visual_fn })
api.fs.trash = wrap_node_or_visual(actions.fs.trash.fn, { visual_fn = actions.fs.trash.visual_fn })
api.fs.rename_node = wrap_node(actions.fs.rename_file.fn(":t"))
api.fs.rename = wrap_node(actions.fs.rename_file.fn(":t"))
api.fs.rename_sub = wrap_node(actions.fs.rename_file.fn(":p:h"))
api.fs.rename_basename = wrap_node(actions.fs.rename_file.fn(":t:r"))
api.fs.rename_full = wrap_node(actions.fs.rename_file.fn(":p"))
api.fs.cut = wrap_node(wrap_explorer_member("clipboard", "cut"))
api.fs.cut = wrap_node_or_visual(wrap_explorer_member("clipboard", "cut"))
api.fs.paste = wrap_node(wrap_explorer_member("clipboard", "paste"))
api.fs.clear_clipboard = wrap_explorer_member("clipboard", "clear_clipboard")
api.fs.print_clipboard = wrap_explorer_member("clipboard", "print_clipboard")
api.fs.copy.node = wrap_node(wrap_explorer_member("clipboard", "copy"))
api.fs.copy.node = wrap_node_or_visual(wrap_explorer_member("clipboard", "copy"))
api.fs.copy.absolute_path = wrap_node(wrap_explorer_member("clipboard", "copy_absolute_path"))
api.fs.copy.filename = wrap_node(wrap_explorer_member("clipboard", "copy_filename"))
api.fs.copy.basename = wrap_node(wrap_explorer_member("clipboard", "copy_basename"))
Expand Down Expand Up @@ -247,7 +317,7 @@ function M.hydrate(api)

api.marks.get = wrap_node(wrap_explorer_member("marks", "get"))
api.marks.list = wrap_explorer_member("marks", "list")
api.marks.toggle = wrap_node(wrap_explorer_member("marks", "toggle"))
api.marks.toggle = wrap_node_or_visual(wrap_explorer_member("marks", "toggle"), { filter_descendants = false })
api.marks.clear = wrap_explorer_member("marks", "clear")
api.marks.bulk.delete = wrap_explorer_member("marks", "bulk_delete")
api.marks.bulk.trash = wrap_explorer_member("marks", "bulk_trash")
Expand Down
16 changes: 16 additions & 0 deletions lua/nvim-tree/explorer/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,22 @@ function Explorer:find_node(fn)
return node, i
end

---Get all nodes in a line range (inclusive), for visual selection operations.
---@param start_line integer
---@param end_line integer
---@return Node[]
function Explorer:get_nodes_in_range(start_line, end_line)
local nodes_by_line = self:get_nodes_by_line(core.get_nodes_starting_line())
local nodes = {}
for line = start_line, end_line do
local node = nodes_by_line[line]
if node and node.absolute_path then
table.insert(nodes, node)
end
end
return nodes
end

--- Return visible nodes indexed by line
---@param line_start number
---@return table
Expand Down
35 changes: 24 additions & 11 deletions lua/nvim-tree/help.lua
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,21 @@ local function compute(map)
local head_rhs1 = "exit: q"
local head_rhs2 = string.format("sort by %s: s", M.config.sort_by == "key" and "description" or "keymap")

-- formatted lhs and desc from active keymap
local mappings = vim.tbl_map(function(m)
return { lhs = tidy_lhs(m.lhs), desc = tidy_desc(m.desc) }
end, map)
-- merge modes for duplicate lhs+desc entries e.g. "n" + "x" -> "nx"
local merged = {}
local mappings = {}
for _, m in ipairs(map) do
local lhs = tidy_lhs(m.lhs)
local desc = tidy_desc(m.desc)
local key = lhs .. "\0" .. desc
if merged[key] then
merged[key].mode = merged[key].mode .. m.mode
else
local entry = { lhs = lhs, desc = desc, mode = m.mode or "n" }
merged[key] = entry
table.insert(mappings, entry)
end
end

-- sorter function for mappings
local sort_fn
Expand All @@ -113,21 +124,23 @@ local function compute(map)

table.sort(mappings, sort_fn)

-- longest lhs and description
-- longest lhs, mode and description
local max_lhs = 0
local max_mode = 0
local max_desc = 0
for _, l in pairs(mappings) do
for _, l in ipairs(mappings) do
max_lhs = math.max(#l.lhs, max_lhs)
max_mode = math.max(#l.mode, max_mode)
max_desc = math.max(#l.desc, max_desc)
end

-- increase desc if lines are shorter than the header
max_desc = math.max(max_desc, #head_lhs + #head_rhs1 - max_lhs)
max_desc = math.max(max_desc, #head_lhs + #head_rhs1 - max_lhs - max_mode)

-- header text, not padded
local lines = {
head_lhs .. string.rep(" ", max_desc + max_lhs - #head_lhs - #head_rhs1 + 2) .. head_rhs1,
string.rep(" ", max_desc + max_lhs - #head_rhs2 + 2) .. head_rhs2,
head_lhs .. string.rep(" ", max_lhs + max_mode + max_desc - #head_lhs - #head_rhs1 + 3) .. head_rhs1,
string.rep(" ", max_lhs + max_mode + max_desc - #head_rhs2 + 3) .. head_rhs2,
}
local width = #lines[1]

Expand All @@ -139,10 +152,10 @@ local function compute(map)
}

-- mappings, left padded 1
local fmt = string.format(" %%-%ds %%-%ds", max_lhs, max_desc)
local fmt = string.format(" %%-%ds %%-%ds %%-%ds", max_lhs, max_mode, max_desc)
for i, l in ipairs(mappings) do
-- format in left aligned columns
local line = string.format(fmt, l.lhs, l.desc)
local line = string.format(fmt, l.lhs, l.mode, l.desc)
table.insert(lines, line)
width = math.max(#line, width)

Expand Down
Loading