Skip to content

Commit ba869c9

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

File tree

10 files changed

+475
-233
lines changed

10 files changed

+475
-233
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/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: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,90 @@ 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+
---Wrap a single-node function to be mode-dependent: in visual mode, operate
119+
---on all nodes in the visual range; in normal mode, operate on a single node.
120+
---@param fn fun(node: Node, ...): any
121+
---@param filter_descendants boolean? filter out descendant nodes in visual mode
122+
---@return fun(node: Node?, ...): any
123+
local function wrap_node_or_visual(fn, filter_descendants)
124+
return function(node, ...)
125+
if is_visual_mode() then
126+
local nodes = get_visual_nodes()
127+
if nodes then
128+
if filter_descendants then
129+
local explorer = require("nvim-tree.core").get_explorer()
130+
if explorer then
131+
nodes = explorer.marks:filter_descendant_nodes(nodes)
132+
end
133+
end
134+
for _, n in ipairs(nodes) do
135+
fn(n, ...)
136+
end
137+
end
138+
else
139+
node = node or wrap_explorer("get_node_at_cursor")()
140+
if node then
141+
return fn(node, ...)
142+
end
143+
end
144+
end
145+
end
146+
147+
---Wrap a destructive operation to be mode-dependent: in visual mode, collect
148+
---nodes and call a bulk method; in normal mode, call the single-node function.
149+
---@param normal_fn fun(node: Node): any
150+
---@param bulk_member string explorer member name for bulk op
151+
---@param bulk_method string method name on member for bulk op
152+
---@return fun(node: Node?): any
153+
local function wrap_node_or_visual_bulk(normal_fn, bulk_member, bulk_method)
154+
return function(node)
155+
if is_visual_mode() then
156+
local nodes = get_visual_nodes()
157+
if nodes then
158+
local explorer = require("nvim-tree.core").get_explorer()
159+
if explorer then
160+
explorer[bulk_member][bulk_method](explorer[bulk_member], nodes)
161+
end
162+
end
163+
else
164+
node = node or wrap_explorer("get_node_at_cursor")()
165+
if node then
166+
return normal_fn(node)
167+
end
168+
end
169+
end
170+
end
171+
88172
---@class NodeEditOpts
89173
---@field quit_on_open boolean|nil default false
90174
---@field focus boolean|nil default true
@@ -172,18 +256,18 @@ function M.hydrate(api)
172256
api.tree.winid = view.winid
173257

174258
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)
259+
api.fs.remove = wrap_node_or_visual_bulk(actions.fs.remove_file.fn, "marks", "bulk_delete_nodes")
260+
api.fs.trash = wrap_node_or_visual_bulk(actions.fs.trash.fn, "marks", "bulk_trash_nodes")
177261
api.fs.rename_node = wrap_node(actions.fs.rename_file.fn(":t"))
178262
api.fs.rename = wrap_node(actions.fs.rename_file.fn(":t"))
179263
api.fs.rename_sub = wrap_node(actions.fs.rename_file.fn(":p:h"))
180264
api.fs.rename_basename = wrap_node(actions.fs.rename_file.fn(":t:r"))
181265
api.fs.rename_full = wrap_node(actions.fs.rename_file.fn(":p"))
182-
api.fs.cut = wrap_node(wrap_explorer_member("clipboard", "cut"))
266+
api.fs.cut = wrap_node_or_visual(wrap_explorer_member("clipboard", "cut"), true)
183267
api.fs.paste = wrap_node(wrap_explorer_member("clipboard", "paste"))
184268
api.fs.clear_clipboard = wrap_explorer_member("clipboard", "clear_clipboard")
185269
api.fs.print_clipboard = wrap_explorer_member("clipboard", "print_clipboard")
186-
api.fs.copy.node = wrap_node(wrap_explorer_member("clipboard", "copy"))
270+
api.fs.copy.node = wrap_node_or_visual(wrap_explorer_member("clipboard", "copy"), true)
187271
api.fs.copy.absolute_path = wrap_node(wrap_explorer_member("clipboard", "copy_absolute_path"))
188272
api.fs.copy.filename = wrap_node(wrap_explorer_member("clipboard", "copy_filename"))
189273
api.fs.copy.basename = wrap_node(wrap_explorer_member("clipboard", "copy_basename"))
@@ -229,8 +313,12 @@ function M.hydrate(api)
229313
api.node.expand = wrap_node(wrap_explorer("expand_node"))
230314
api.node.collapse = wrap_node(actions.tree.collapse.node)
231315

232-
api.node.buffer.delete = wrap_node(function(node, opts) actions.node.buffer.delete(node, opts) end)
233-
api.node.buffer.wipe = wrap_node(function(node, opts) actions.node.buffer.wipe(node, opts) end)
316+
api.node.buffer.delete = wrap_node(function(node, opts)
317+
actions.node.buffer.delete(node, opts)
318+
end)
319+
api.node.buffer.wipe = wrap_node(function(node, opts)
320+
actions.node.buffer.wipe(node, opts)
321+
end)
234322

235323
api.tree.reload_git = wrap_explorer("reload_git")
236324

@@ -246,7 +334,7 @@ function M.hydrate(api)
246334

247335
api.marks.get = wrap_node(wrap_explorer_member("marks", "get"))
248336
api.marks.list = wrap_explorer_member("marks", "list")
249-
api.marks.toggle = wrap_node(wrap_explorer_member("marks", "toggle"))
337+
api.marks.toggle = wrap_node_or_visual(wrap_explorer_member("marks", "toggle"))
250338
api.marks.clear = wrap_explorer_member("marks", "clear")
251339
api.marks.bulk.delete = wrap_explorer_member("marks", "bulk_delete")
252340
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)