Skip to content

Commit cae607a

Browse files
committed
feat(#2994): add visual selection operations
1 parent 5499299 commit cae607a

File tree

12 files changed

+432
-204
lines changed

12 files changed

+432
-204
lines changed

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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,48 @@ 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(nodes)
164+
if #nodes == 0 then
165+
return
166+
end
167+
168+
local function execute()
169+
for i = #nodes, 1, -1 do
170+
M.remove(nodes[i])
171+
end
172+
local explorer = core.get_explorer()
173+
if not M.config.filesystem_watchers.enable and explorer then
174+
explorer:reload_explorer()
175+
end
176+
end
177+
178+
if M.config.ui.confirm.remove then
179+
local prompt_select = string.format("Remove %d selected?", #nodes)
180+
local prompt_input, items_short, items_long
181+
182+
if M.config.ui.confirm.default_yes then
183+
prompt_input = prompt_select .. " Y/n: "
184+
items_short = { "", "n" }
185+
items_long = { "Yes", "No" }
186+
else
187+
prompt_input = prompt_select .. " y/N: "
188+
items_short = { "", "y" }
189+
items_long = { "No", "Yes" }
190+
end
191+
192+
lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_delete", function(item_short)
193+
utils.clear_prompt()
194+
if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then
195+
execute()
196+
end
197+
end)
198+
else
199+
execute()
200+
end
201+
end
202+
161203
function M.setup(opts)
162204
M.config.ui = opts.ui
163205
M.config.actions = opts.actions

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,44 @@ 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(nodes)
125+
if #nodes == 0 then
126+
return
127+
end
128+
129+
local function execute()
130+
for i = #nodes, 1, -1 do
131+
M.remove(nodes[i])
132+
end
133+
end
134+
135+
if M.config.ui.confirm.trash then
136+
local prompt_select = string.format("Trash %d selected?", #nodes)
137+
local prompt_input, items_short, items_long
138+
139+
if M.config.ui.confirm.default_yes then
140+
prompt_input = prompt_select .. " Y/n: "
141+
items_short = { "", "n" }
142+
items_long = { "Yes", "No" }
143+
else
144+
prompt_input = prompt_select .. " y/N: "
145+
items_short = { "", "y" }
146+
items_long = { "No", "Yes" }
147+
end
148+
149+
lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_visual_trash", function(item_short)
150+
utils.clear_prompt()
151+
if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then
152+
execute()
153+
end
154+
end)
155+
else
156+
execute()
157+
end
158+
end
159+
122160
function M.setup(opts)
123161
M.config.ui = opts.ui
124162
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
@@ -85,6 +85,76 @@ local function wrap_explorer_member(explorer_member, member_method)
8585
end
8686
end
8787

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

174244
api.fs.create = wrap_node_or_nil(actions.fs.create_file.fn)
175-
api.fs.remove = wrap_node(actions.fs.remove_file.fn)
176-
api.fs.trash = wrap_node(actions.fs.trash.fn)
245+
api.fs.remove = wrap_node_or_visual(actions.fs.remove_file.fn, { visual_fn = actions.fs.remove_file.visual })
246+
api.fs.trash = wrap_node_or_visual(actions.fs.trash.fn, { visual_fn = actions.fs.trash.visual })
177247
api.fs.rename_node = wrap_node(actions.fs.rename_file.fn(":t"))
178248
api.fs.rename = wrap_node(actions.fs.rename_file.fn(":t"))
179249
api.fs.rename_sub = wrap_node(actions.fs.rename_file.fn(":p:h"))
180250
api.fs.rename_basename = wrap_node(actions.fs.rename_file.fn(":t:r"))
181251
api.fs.rename_full = wrap_node(actions.fs.rename_file.fn(":p"))
182-
api.fs.cut = wrap_node(wrap_explorer_member("clipboard", "cut"))
252+
api.fs.cut = wrap_node_or_visual(wrap_explorer_member("clipboard", "cut"))
183253
api.fs.paste = wrap_node(wrap_explorer_member("clipboard", "paste"))
184254
api.fs.clear_clipboard = wrap_explorer_member("clipboard", "clear_clipboard")
185255
api.fs.print_clipboard = wrap_explorer_member("clipboard", "print_clipboard")
186-
api.fs.copy.node = wrap_node(wrap_explorer_member("clipboard", "copy"))
256+
api.fs.copy.node = wrap_node_or_visual(wrap_explorer_member("clipboard", "copy"))
187257
api.fs.copy.absolute_path = wrap_node(wrap_explorer_member("clipboard", "copy_absolute_path"))
188258
api.fs.copy.filename = wrap_node(wrap_explorer_member("clipboard", "copy_filename"))
189259
api.fs.copy.basename = wrap_node(wrap_explorer_member("clipboard", "copy_basename"))
@@ -246,7 +316,7 @@ function M.hydrate(api)
246316

247317
api.marks.get = wrap_node(wrap_explorer_member("marks", "get"))
248318
api.marks.list = wrap_explorer_member("marks", "list")
249-
api.marks.toggle = wrap_node(wrap_explorer_member("marks", "toggle"))
319+
api.marks.toggle = wrap_node_or_visual(wrap_explorer_member("marks", "toggle"), { filter_descendants = false })
250320
api.marks.clear = wrap_explorer_member("marks", "clear")
251321
api.marks.bulk.delete = wrap_explorer_member("marks", "bulk_delete")
252322
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)