Skip to content

Commit 678ffc6

Browse files
committed
feat(#2994): add visual selection operations
1 parent ae16aab commit 678ffc6

12 files changed

Lines changed: 436 additions & 204 deletions

File tree

doc/nvim-tree-lua.txt

Lines changed: 131 additions & 123 deletions
Large diffs are not rendered by default.

lua/nvim-tree/_meta/api/fs.lua

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function nvim_tree.api.fs.copy.filename(node) end
2828

2929
---
3030
---Copy to the nvim-tree clipboard.
31+
---In visual mode, copies all nodes in the visual selection.
3132
---
3233
---@param node? nvim_tree.api.Node
3334
function nvim_tree.api.fs.copy.node(node) end
@@ -52,6 +53,7 @@ function nvim_tree.api.fs.create(node) end
5253

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

7274
---
7375
---Delete from the file system.
76+
---In visual mode, deletes all nodes in the visual selection with a single prompt.
7477
---
7578
---@param node? nvim_tree.api.Node
7679
function nvim_tree.api.fs.remove(node) end
@@ -106,7 +109,8 @@ function nvim_tree.api.fs.rename_node(node) end
106109
function nvim_tree.api.fs.rename_sub(node) end
107110

108111
---
109-
---Trash as per |nvim_tree.config.trash|
112+
---Trash as per |nvim_tree.config.trash|.
113+
---In visual mode, trashes all nodes in the visual selection with a single prompt.
110114
---
111115
---@param node? nvim_tree.api.Node
112116
function nvim_tree.api.fs.trash(node) end

lua/nvim-tree/_meta/api/marks.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ function nvim_tree.api.marks.get() end
1414
function nvim_tree.api.marks.list() end
1515

1616
---
17-
---Toggle mark.
17+
---Toggle mark. In visual mode, toggles all nodes in the visual selection.
1818
---
1919
---@param node? nvim_tree.api.Node file or directory
2020
function nvim_tree.api.marks.toggle(node) end

lua/nvim-tree/actions/fs/remove-file.lua

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,50 @@ function M.fn(node)
158158
end
159159
end
160160

161+
---Remove multiple nodes with a single confirmation prompt; used for visual selection operations.
162+
---@param nodes Node[]
163+
function M.visual_fn(nodes)
164+
if #nodes == 0 then
165+
return
166+
end
167+
168+
local function execute()
169+
for i = #nodes, 1, -1 do
170+
if nodes[i].name ~= ".." then
171+
M.remove(nodes[i])
172+
end
173+
end
174+
local explorer = core.get_explorer()
175+
if not M.config.filesystem_watchers.enable and explorer then
176+
explorer:reload_explorer()
177+
end
178+
end
179+
180+
if M.config.ui.confirm.remove then
181+
local prompt_select = string.format("Remove %d selected?", #nodes)
182+
local prompt_input, items_short, items_long
183+
184+
if M.config.ui.confirm.default_yes then
185+
prompt_input = prompt_select .. " Y/n: "
186+
items_short = { "", "n" }
187+
items_long = { "Yes", "No" }
188+
else
189+
prompt_input = prompt_select .. " y/N: "
190+
items_short = { "", "y" }
191+
items_long = { "No", "Yes" }
192+
end
193+
194+
lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_delete", function(item_short)
195+
utils.clear_prompt()
196+
if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then
197+
execute()
198+
end
199+
end)
200+
else
201+
execute()
202+
end
203+
end
204+
161205
function M.setup(opts)
162206
M.config.ui = opts.ui
163207
M.config.actions = opts.actions

lua/nvim-tree/actions/fs/trash.lua

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,46 @@ function M.fn(node)
119119
end
120120
end
121121

122+
---Trash multiple nodes with a single confirmation prompt; used for visual selection operations.
123+
---@param nodes Node[]
124+
function M.visual_fn(nodes)
125+
if #nodes == 0 then
126+
return
127+
end
128+
129+
local function execute()
130+
for i = #nodes, 1, -1 do
131+
if nodes[i].name ~= ".." then
132+
M.remove(nodes[i])
133+
end
134+
end
135+
end
136+
137+
if M.config.ui.confirm.trash then
138+
local prompt_select = string.format("Trash %d selected?", #nodes)
139+
local prompt_input, items_short, items_long
140+
141+
if M.config.ui.confirm.default_yes then
142+
prompt_input = prompt_select .. " Y/n: "
143+
items_short = { "", "n" }
144+
items_long = { "Yes", "No" }
145+
else
146+
prompt_input = prompt_select .. " y/N: "
147+
items_short = { "", "y" }
148+
items_long = { "No", "Yes" }
149+
end
150+
151+
lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_trash", function(item_short)
152+
utils.clear_prompt()
153+
if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then
154+
execute()
155+
end
156+
end)
157+
else
158+
execute()
159+
end
160+
end
161+
122162
function M.setup(opts)
123163
M.config.ui = opts.ui
124164
M.config.trash = opts.trash

lua/nvim-tree/api.lua

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
---local api = require("nvim-tree.api")
2525
---api.tree.reload()
2626
---```
27-
---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:
27+
---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.
28+
---
29+
---e.g. the following are functionally identical:
2830
---```lua
2931
---
3032
---api.node.open.edit(nil, { focus = true })

lua/nvim-tree/api/impl/post.lua

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,76 @@ local function wrap_explorer_member(explorer_member, member_method)
8686
end
8787
end
8888

89+
---Check if the current mode is visual (v, V, or CTRL-V).
90+
---@return boolean
91+
local function is_visual_mode()
92+
local mode = vim.api.nvim_get_mode().mode
93+
return mode == "v" or mode == "V" or mode == "\22" -- \22 is CTRL-V
94+
end
95+
96+
---Exit visual mode synchronously.
97+
local function exit_visual_mode()
98+
local esc = vim.api.nvim_replace_termcodes("<Esc>", true, false, true)
99+
vim.api.nvim_feedkeys(esc, "nx", false)
100+
end
101+
102+
---Get the visual selection range nodes, exiting visual mode.
103+
---@return Node[]?
104+
local function get_visual_nodes()
105+
local explorer = require("nvim-tree.core").get_explorer()
106+
if not explorer then
107+
return nil
108+
end
109+
local start_line = vim.fn.line("v")
110+
local end_line = vim.fn.line(".")
111+
if start_line > end_line then
112+
start_line, end_line = end_line, start_line
113+
end
114+
local nodes = explorer:get_nodes_in_range(start_line, end_line)
115+
exit_visual_mode()
116+
return nodes
117+
end
118+
119+
---@class WrapNodeOrVisualOpts
120+
---@field visual_fn? fun(nodes: Node[]) bulk visual handler; when nil, fn is called per-node
121+
---@field filter_descendants? boolean filter out descendant nodes in visual mode (default true)
122+
123+
---Wrap a single-node function to be mode-dependent: in visual mode, operate
124+
---on all nodes in the visual range; in normal mode, operate on a single node.
125+
---
126+
---When opts.visual_fn is provided, it receives all nodes at once (for bulk
127+
---operations like remove/trash that need a single confirmation prompt).
128+
---When opts.visual_fn is nil, fn is called on each node individually.
129+
---
130+
---@param fn fun(node: Node, ...): any
131+
---@param opts? WrapNodeOrVisualOpts
132+
---@return fun(node: Node?, ...): any
133+
local function wrap_node_or_visual(fn, opts)
134+
opts = opts or {}
135+
return function(node, ...)
136+
if is_visual_mode() then
137+
local nodes = get_visual_nodes()
138+
if nodes then
139+
if opts.filter_descendants ~= false then
140+
nodes = utils.filter_descendant_nodes(nodes)
141+
end
142+
if opts.visual_fn then
143+
opts.visual_fn(nodes)
144+
else
145+
for _, n in ipairs(nodes) do
146+
fn(n, ...)
147+
end
148+
end
149+
end
150+
else
151+
node = node or wrap_explorer("get_node_at_cursor")()
152+
if node then
153+
return fn(node, ...)
154+
end
155+
end
156+
end
157+
end
158+
89159
---@class NodeEditOpts
90160
---@field quit_on_open boolean|nil default false
91161
---@field focus boolean|nil default true
@@ -173,18 +243,18 @@ function M.hydrate(api)
173243
api.tree.winid = view.winid
174244

175245
api.fs.create = wrap_node_or_nil(actions.fs.create_file.fn)
176-
api.fs.remove = wrap_node(actions.fs.remove_file.fn)
177-
api.fs.trash = wrap_node(actions.fs.trash.fn)
246+
api.fs.remove = wrap_node_or_visual(actions.fs.remove_file.fn, { visual_fn = actions.fs.remove_file.visual_fn })
247+
api.fs.trash = wrap_node_or_visual(actions.fs.trash.fn, { visual_fn = actions.fs.trash.visual_fn })
178248
api.fs.rename_node = wrap_node(actions.fs.rename_file.fn(":t"))
179249
api.fs.rename = wrap_node(actions.fs.rename_file.fn(":t"))
180250
api.fs.rename_sub = wrap_node(actions.fs.rename_file.fn(":p:h"))
181251
api.fs.rename_basename = wrap_node(actions.fs.rename_file.fn(":t:r"))
182252
api.fs.rename_full = wrap_node(actions.fs.rename_file.fn(":p"))
183-
api.fs.cut = wrap_node(wrap_explorer_member("clipboard", "cut"))
253+
api.fs.cut = wrap_node_or_visual(wrap_explorer_member("clipboard", "cut"))
184254
api.fs.paste = wrap_node(wrap_explorer_member("clipboard", "paste"))
185255
api.fs.clear_clipboard = wrap_explorer_member("clipboard", "clear_clipboard")
186256
api.fs.print_clipboard = wrap_explorer_member("clipboard", "print_clipboard")
187-
api.fs.copy.node = wrap_node(wrap_explorer_member("clipboard", "copy"))
257+
api.fs.copy.node = wrap_node_or_visual(wrap_explorer_member("clipboard", "copy"))
188258
api.fs.copy.absolute_path = wrap_node(wrap_explorer_member("clipboard", "copy_absolute_path"))
189259
api.fs.copy.filename = wrap_node(wrap_explorer_member("clipboard", "copy_filename"))
190260
api.fs.copy.basename = wrap_node(wrap_explorer_member("clipboard", "copy_basename"))
@@ -247,7 +317,7 @@ function M.hydrate(api)
247317

248318
api.marks.get = wrap_node(wrap_explorer_member("marks", "get"))
249319
api.marks.list = wrap_explorer_member("marks", "list")
250-
api.marks.toggle = wrap_node(wrap_explorer_member("marks", "toggle"))
320+
api.marks.toggle = wrap_node_or_visual(wrap_explorer_member("marks", "toggle"), { filter_descendants = false })
251321
api.marks.clear = wrap_explorer_member("marks", "clear")
252322
api.marks.bulk.delete = wrap_explorer_member("marks", "bulk_delete")
253323
api.marks.bulk.trash = wrap_explorer_member("marks", "bulk_trash")

lua/nvim-tree/explorer/init.lua

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,22 @@ function Explorer:find_node(fn)
642642
return node, i
643643
end
644644

645+
---Get all nodes in a line range (inclusive), for visual selection operations.
646+
---@param start_line integer
647+
---@param end_line integer
648+
---@return Node[]
649+
function Explorer:get_nodes_in_range(start_line, end_line)
650+
local nodes_by_line = self:get_nodes_by_line(core.get_nodes_starting_line())
651+
local nodes = {}
652+
for line = start_line, end_line do
653+
local node = nodes_by_line[line]
654+
if node and node.absolute_path then
655+
table.insert(nodes, node)
656+
end
657+
end
658+
return nodes
659+
end
660+
645661
--- Return visible nodes indexed by line
646662
---@param line_start number
647663
---@return table

lua/nvim-tree/help.lua

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,21 @@ local function compute(map)
9292
local head_rhs1 = "exit: q"
9393
local head_rhs2 = string.format("sort by %s: s", M.config.sort_by == "key" and "description" or "keymap")
9494

95-
-- formatted lhs and desc from active keymap
96-
local mappings = vim.tbl_map(function(m)
97-
return { lhs = tidy_lhs(m.lhs), desc = tidy_desc(m.desc) }
98-
end, map)
95+
-- merge modes for duplicate lhs+desc entries e.g. "n" + "x" -> "nx"
96+
local merged = {}
97+
local mappings = {}
98+
for _, m in ipairs(map) do
99+
local lhs = tidy_lhs(m.lhs)
100+
local desc = tidy_desc(m.desc)
101+
local key = lhs .. "\0" .. desc
102+
if merged[key] then
103+
merged[key].mode = merged[key].mode .. m.mode
104+
else
105+
local entry = { lhs = lhs, desc = desc, mode = m.mode or "n" }
106+
merged[key] = entry
107+
table.insert(mappings, entry)
108+
end
109+
end
99110

100111
-- sorter function for mappings
101112
local sort_fn
@@ -113,21 +124,23 @@ local function compute(map)
113124

114125
table.sort(mappings, sort_fn)
115126

116-
-- longest lhs and description
127+
-- longest lhs, mode and description
117128
local max_lhs = 0
129+
local max_mode = 0
118130
local max_desc = 0
119-
for _, l in pairs(mappings) do
131+
for _, l in ipairs(mappings) do
120132
max_lhs = math.max(#l.lhs, max_lhs)
133+
max_mode = math.max(#l.mode, max_mode)
121134
max_desc = math.max(#l.desc, max_desc)
122135
end
123136

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

127140
-- header text, not padded
128141
local lines = {
129-
head_lhs .. string.rep(" ", max_desc + max_lhs - #head_lhs - #head_rhs1 + 2) .. head_rhs1,
130-
string.rep(" ", max_desc + max_lhs - #head_rhs2 + 2) .. head_rhs2,
142+
head_lhs .. string.rep(" ", max_lhs + max_mode + max_desc - #head_lhs - #head_rhs1 + 3) .. head_rhs1,
143+
string.rep(" ", max_lhs + max_mode + max_desc - #head_rhs2 + 3) .. head_rhs2,
131144
}
132145
local width = #lines[1]
133146

@@ -139,10 +152,10 @@ local function compute(map)
139152
}
140153

141154
-- mappings, left padded 1
142-
local fmt = string.format(" %%-%ds %%-%ds", max_lhs, max_desc)
155+
local fmt = string.format(" %%-%ds %%-%ds %%-%ds", max_lhs, max_mode, max_desc)
143156
for i, l in ipairs(mappings) do
144157
-- format in left aligned columns
145-
local line = string.format(fmt, l.lhs, l.desc)
158+
local line = string.format(fmt, l.lhs, l.mode, l.desc)
146159
table.insert(lines, line)
147160
width = math.max(#line, width)
148161

0 commit comments

Comments
 (0)