Skip to content

Commit b4741d0

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

File tree

10 files changed

+363
-97
lines changed

10 files changed

+363
-97
lines changed

doc/nvim-tree-lua.txt

Lines changed: 77 additions & 69 deletions
Large diffs are not rendered by default.

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

Lines changed: 4 additions & 0 deletions
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
@@ -107,6 +110,7 @@ function nvim_tree.api.fs.rename_sub(node) end
107110

108111
---
109112
---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: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,83 @@ 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+
---@return fun(node: Node?, ...): any
122+
local function wrap_node_or_visual(fn)
123+
return function(node, ...)
124+
if is_visual_mode() then
125+
local nodes = get_visual_nodes()
126+
if nodes then
127+
for _, n in ipairs(nodes) do
128+
fn(n)
129+
end
130+
end
131+
else
132+
node = node or wrap_explorer("get_node_at_cursor")()
133+
if node then
134+
return fn(node, ...)
135+
end
136+
end
137+
end
138+
end
139+
140+
---Wrap a destructive operation to be mode-dependent: in visual mode, collect
141+
---nodes and call a bulk method; in normal mode, call the single-node function.
142+
---@param normal_fn fun(node: Node): any
143+
---@param bulk_member string explorer member name for bulk op
144+
---@param bulk_method string method name on member for bulk op
145+
---@return fun(node: Node?): any
146+
local function wrap_node_or_visual_bulk(normal_fn, bulk_member, bulk_method)
147+
return function(node)
148+
if is_visual_mode() then
149+
local nodes = get_visual_nodes()
150+
if nodes then
151+
local explorer = require("nvim-tree.core").get_explorer()
152+
if explorer then
153+
explorer[bulk_member][bulk_method](explorer[bulk_member], nodes)
154+
end
155+
end
156+
else
157+
node = node or wrap_explorer("get_node_at_cursor")()
158+
if node then
159+
return normal_fn(node)
160+
end
161+
end
162+
end
163+
end
164+
88165
---@class NodeEditOpts
89166
---@field quit_on_open boolean|nil default false
90167
---@field focus boolean|nil default true
@@ -172,18 +249,18 @@ function M.hydrate(api)
172249
api.tree.winid = view.winid
173250

174251
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)
252+
api.fs.remove = wrap_node_or_visual_bulk(actions.fs.remove_file.fn, "marks", "bulk_delete_nodes")
253+
api.fs.trash = wrap_node_or_visual_bulk(actions.fs.trash.fn, "marks", "bulk_trash_nodes")
177254
api.fs.rename_node = wrap_node(actions.fs.rename_file.fn(":t"))
178255
api.fs.rename = wrap_node(actions.fs.rename_file.fn(":t"))
179256
api.fs.rename_sub = wrap_node(actions.fs.rename_file.fn(":p:h"))
180257
api.fs.rename_basename = wrap_node(actions.fs.rename_file.fn(":t:r"))
181258
api.fs.rename_full = wrap_node(actions.fs.rename_file.fn(":p"))
182-
api.fs.cut = wrap_node(wrap_explorer_member("clipboard", "cut"))
259+
api.fs.cut = wrap_node_or_visual(wrap_explorer_member("clipboard", "cut"))
183260
api.fs.paste = wrap_node(wrap_explorer_member("clipboard", "paste"))
184261
api.fs.clear_clipboard = wrap_explorer_member("clipboard", "clear_clipboard")
185262
api.fs.print_clipboard = wrap_explorer_member("clipboard", "print_clipboard")
186-
api.fs.copy.node = wrap_node(wrap_explorer_member("clipboard", "copy"))
263+
api.fs.copy.node = wrap_node_or_visual(wrap_explorer_member("clipboard", "copy"))
187264
api.fs.copy.absolute_path = wrap_node(wrap_explorer_member("clipboard", "copy_absolute_path"))
188265
api.fs.copy.filename = wrap_node(wrap_explorer_member("clipboard", "copy_filename"))
189266
api.fs.copy.basename = wrap_node(wrap_explorer_member("clipboard", "copy_basename"))
@@ -229,8 +306,12 @@ function M.hydrate(api)
229306
api.node.expand = wrap_node(wrap_explorer("expand_node"))
230307
api.node.collapse = wrap_node(actions.tree.collapse.node)
231308

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)
309+
api.node.buffer.delete = wrap_node(function(node, opts)
310+
actions.node.buffer.delete(node, opts)
311+
end)
312+
api.node.buffer.wipe = wrap_node(function(node, opts)
313+
actions.node.buffer.wipe(node, opts)
314+
end)
234315

235316
api.tree.reload_git = wrap_explorer("reload_git")
236317

@@ -246,7 +327,7 @@ function M.hydrate(api)
246327

247328
api.marks.get = wrap_node(wrap_explorer_member("marks", "get"))
248329
api.marks.list = wrap_explorer_member("marks", "list")
249-
api.marks.toggle = wrap_node(wrap_explorer_member("marks", "toggle"))
330+
api.marks.toggle = wrap_node_or_visual(wrap_explorer_member("marks", "toggle"))
250331
api.marks.clear = wrap_explorer_member("marks", "clear")
251332
api.marks.bulk.delete = wrap_explorer_member("marks", "bulk_delete")
252333
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

lua/nvim-tree/keymap.lua

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ function M.on_attach_default(bufnr)
6464
vim.keymap.set("n", "bt", api.marks.bulk.trash, opts("Trash Bookmarked"))
6565
vim.keymap.set("n", "bmv", api.marks.bulk.move, opts("Move Bookmarked"))
6666
vim.keymap.set("n", "B", api.filter.no_buffer.toggle, opts("Toggle Filter: No Buffer"))
67-
vim.keymap.set("n", "c", api.fs.copy.node, opts("Copy"))
67+
vim.keymap.set({ "n", "x" }, "c", api.fs.copy.node, opts("Copy"))
6868
vim.keymap.set("n", "C", api.filter.git.clean.toggle, opts("Toggle Filter: Git Clean"))
6969
vim.keymap.set("n", "[c", api.node.navigate.git.prev, opts("Prev Git"))
7070
vim.keymap.set("n", "]c", api.node.navigate.git.next, opts("Next Git"))
71-
vim.keymap.set("n", "d", api.fs.remove, opts("Delete"))
72-
vim.keymap.set("n", "D", api.fs.trash, opts("Trash"))
71+
vim.keymap.set({ "n", "x" }, "d", api.fs.remove, opts("Delete"))
72+
vim.keymap.set({ "n", "x" }, "D", api.fs.trash, opts("Trash"))
7373
vim.keymap.set("n", "E", api.tree.expand_all, opts("Expand All"))
7474
vim.keymap.set("n", "e", api.fs.rename_basename, opts("Rename: Basename"))
7575
vim.keymap.set("n", "]e", api.node.navigate.diagnostics.next, opts("Next Diagnostic"))
@@ -85,7 +85,7 @@ function M.on_attach_default(bufnr)
8585
vim.keymap.set("n", "K", api.node.navigate.sibling.first, opts("First Sibling"))
8686
vim.keymap.set("n", "L", api.node.open.toggle_group_empty, opts("Toggle Group Empty"))
8787
vim.keymap.set("n", "M", api.filter.no_bookmark.toggle, opts("Toggle Filter: No Bookmark"))
88-
vim.keymap.set("n", "m", api.marks.toggle, opts("Toggle Bookmark"))
88+
vim.keymap.set({ "n", "x" }, "m", api.marks.toggle, opts("Toggle Bookmark"))
8989
vim.keymap.set("n", "o", api.node.open.edit, opts("Open"))
9090
vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker"))
9191
vim.keymap.set("n", "p", api.fs.paste, opts("Paste"))
@@ -98,7 +98,7 @@ function M.on_attach_default(bufnr)
9898
vim.keymap.set("n", "u", api.fs.rename_full, opts("Rename: Full Path"))
9999
vim.keymap.set("n", "U", api.filter.custom.toggle, opts("Toggle Filter: Custom"))
100100
vim.keymap.set("n", "W", api.tree.collapse_all, opts("Collapse All"))
101-
vim.keymap.set("n", "x", api.fs.cut, opts("Cut"))
101+
vim.keymap.set({ "n", "x" }, "x", api.fs.cut, opts("Cut"))
102102
vim.keymap.set("n", "y", api.fs.copy.filename, opts("Copy Name"))
103103
vim.keymap.set("n", "Y", api.fs.copy.relative_path, opts("Copy Relative Path"))
104104
vim.keymap.set("n", "<2-LeftMouse>", api.node.open.edit, opts("Open"))

0 commit comments

Comments
 (0)