Skip to content

Commit c087166

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

File tree

7 files changed

+193
-2
lines changed

7 files changed

+193
-2
lines changed

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ function nvim_tree.api.fs.copy.filename(node) end
3232
---@param node? nvim_tree.api.Node
3333
function nvim_tree.api.fs.copy.node(node) end
3434

35+
---
36+
---Copy all visually selected nodes to the nvim-tree clipboard.
37+
---
38+
function nvim_tree.api.fs.copy.visual() end
39+
3540
---
3641
---Copy the path relative to the tree root to the system clipboard.
3742
---
@@ -56,6 +61,11 @@ function nvim_tree.api.fs.create(node) end
5661
---@param node? nvim_tree.api.Node
5762
function nvim_tree.api.fs.cut(node) end
5863

64+
---
65+
---Cut all visually selected nodes to the nvim-tree clipboard.
66+
---
67+
function nvim_tree.api.fs.cut_visual() end
68+
5969
---
6070
---Paste from the nvim-tree clipboard.
6171
---
@@ -75,6 +85,11 @@ function nvim_tree.api.fs.print_clipboard() end
7585
---@param node? nvim_tree.api.Node
7686
function nvim_tree.api.fs.remove(node) end
7787

88+
---
89+
---Delete all visually selected nodes, prompting once.
90+
---
91+
function nvim_tree.api.fs.remove_visual() end
92+
7893
---
7994
---Prompt to rename by name.
8095
---
@@ -111,4 +126,9 @@ function nvim_tree.api.fs.rename_sub(node) end
111126
---@param node? nvim_tree.api.Node
112127
function nvim_tree.api.fs.trash(node) end
113128

129+
---
130+
---Trash all visually selected nodes, prompting once.
131+
---
132+
function nvim_tree.api.fs.trash_visual() end
133+
114134
return nvim_tree.api.fs

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ function nvim_tree.api.marks.list() end
1919
---@param node? nvim_tree.api.Node file or directory
2020
function nvim_tree.api.marks.toggle(node) end
2121

22+
---
23+
---Toggle mark on all visually selected nodes.
24+
---
25+
function nvim_tree.api.marks.toggle_visual() end
26+
2227
---
2328
---Clear all marks.
2429
---

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,47 @@ local function wrap_explorer_member(explorer_member, member_method)
8585
end
8686
end
8787

88+
---Exit visual mode synchronously.
89+
local function exit_visual_mode()
90+
local esc = vim.api.nvim_replace_termcodes("<Esc>", true, false, true)
91+
vim.api.nvim_feedkeys(esc, "nx", false)
92+
end
93+
94+
---Wrap a single-node function to operate on visual selection range.
95+
---@param fn fun(node: Node): any
96+
---@return fun(): any
97+
local function wrap_visual_range(fn)
98+
return function()
99+
local explorer = require("nvim-tree.core").get_explorer()
100+
if not explorer then return end
101+
local start_line = vim.fn.line("v")
102+
local end_line = vim.fn.line(".")
103+
if start_line > end_line then start_line, end_line = end_line, start_line end
104+
local nodes = explorer:get_nodes_in_range(start_line, end_line)
105+
exit_visual_mode()
106+
for _, node in ipairs(nodes) do
107+
fn(node)
108+
end
109+
end
110+
end
111+
112+
---Wrap a bulk operation that collects visual nodes and passes them all at once.
113+
---@param member string explorer member name
114+
---@param method string method name to invoke on member
115+
---@return fun(): any
116+
local function wrap_visual_bulk(member, method)
117+
return function()
118+
local explorer = require("nvim-tree.core").get_explorer()
119+
if not explorer then return end
120+
local start_line = vim.fn.line("v")
121+
local end_line = vim.fn.line(".")
122+
if start_line > end_line then start_line, end_line = end_line, start_line end
123+
local nodes = explorer:get_nodes_in_range(start_line, end_line)
124+
exit_visual_mode()
125+
explorer[member][method](explorer[member], nodes)
126+
end
127+
end
128+
88129
---@class NodeEditOpts
89130
---@field quit_on_open boolean|nil default false
90131
---@field focus boolean|nil default true
@@ -254,6 +295,12 @@ function M.hydrate(api)
254295
api.marks.navigate.next = wrap_explorer_member("marks", "navigate_next")
255296
api.marks.navigate.prev = wrap_explorer_member("marks", "navigate_prev")
256297
api.marks.navigate.select = wrap_explorer_member("marks", "navigate_select")
298+
api.marks.toggle_visual = wrap_visual_range(wrap_explorer_member("marks", "toggle"))
299+
300+
api.fs.copy.visual = wrap_visual_range(wrap_explorer_member("clipboard", "copy"))
301+
api.fs.cut_visual = wrap_visual_range(wrap_explorer_member("clipboard", "cut"))
302+
api.fs.remove_visual = wrap_visual_bulk("marks", "bulk_delete_nodes")
303+
api.fs.trash_visual = wrap_visual_bulk("marks", "bulk_trash_nodes")
257304

258305
api.map.keymap.current = keymap.get_keymap
259306

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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,13 @@ 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
95+
-- formatted lhs and desc from active keymap, prefixing visual mode keys
9696
local mappings = vim.tbl_map(function(m)
97-
return { lhs = tidy_lhs(m.lhs), desc = tidy_desc(m.desc) }
97+
local lhs = tidy_lhs(m.lhs)
98+
if m.mode == "x" then
99+
lhs = "[v] " .. lhs
100+
end
101+
return { lhs = lhs, desc = tidy_desc(m.desc) }
98102
end, map)
99103

100104
-- sorter function for mappings

lua/nvim-tree/keymap.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,12 @@ function M.on_attach_default(bufnr)
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"))
105105
vim.keymap.set("n", "<2-RightMouse>", api.tree.change_root_to_node, opts("CD"))
106+
107+
vim.keymap.set("x", "m", api.marks.toggle_visual, opts("Toggle Bookmark"))
108+
vim.keymap.set("x", "d", api.fs.remove_visual, opts("Delete"))
109+
vim.keymap.set("x", "D", api.fs.trash_visual, opts("Trash"))
110+
vim.keymap.set("x", "c", api.fs.copy.visual, opts("Copy"))
111+
vim.keymap.set("x", "x", api.fs.cut_visual, opts("Cut"))
106112
-- END_ON_ATTACH_DEFAULT
107113
end
108114

lua/nvim-tree/marks/init.lua

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,99 @@ function Marks:bulk_trash()
228228
end
229229
end
230230

231+
---Filter out nodes that are descendants of other nodes in the list.
232+
---When a directory is selected along with its children, only the directory needs to be operated on.
233+
---@private
234+
---@param nodes Node[]
235+
---@return Node[]
236+
function Marks:filter_descendant_nodes(nodes)
237+
local dominated = {}
238+
for i, a in ipairs(nodes) do
239+
for j, b in ipairs(nodes) do
240+
if i ~= j then
241+
local prefix = b.absolute_path .. "/"
242+
if a.absolute_path:sub(1, #prefix) == prefix then
243+
dominated[i] = true
244+
break
245+
end
246+
end
247+
end
248+
end
249+
local filtered = {}
250+
for i, node in ipairs(nodes) do
251+
if not dominated[i] then
252+
table.insert(filtered, node)
253+
end
254+
end
255+
return filtered
256+
end
257+
258+
---Delete a list of nodes with a single prompt; used for visual selection operations.
259+
---@public
260+
---@param nodes Node[]
261+
function Marks:bulk_delete_nodes(nodes)
262+
if #nodes == 0 then
263+
return
264+
end
265+
266+
nodes = self:filter_descendant_nodes(nodes)
267+
268+
local function execute()
269+
for i = #nodes, 1, -1 do
270+
remove_file.remove(nodes[i])
271+
end
272+
if not self.explorer.opts.filesystem_watchers.enable then
273+
self.explorer:reload_explorer()
274+
end
275+
end
276+
277+
if self.explorer.opts.ui.confirm.remove then
278+
local prompt_select = string.format("Remove %d selected ?", #nodes)
279+
local prompt_input = prompt_select .. " y/N: "
280+
lib.prompt(prompt_input, prompt_select, { "", "y" }, { "No", "Yes" }, "nvimtree_visual_delete", function(item_short)
281+
utils.clear_prompt()
282+
if item_short == "y" then
283+
execute()
284+
end
285+
end)
286+
else
287+
execute()
288+
end
289+
end
290+
291+
---Trash a list of nodes with a single prompt; used for visual selection operations.
292+
---@public
293+
---@param nodes Node[]
294+
function Marks:bulk_trash_nodes(nodes)
295+
if #nodes == 0 then
296+
return
297+
end
298+
299+
nodes = self:filter_descendant_nodes(nodes)
300+
301+
local function execute()
302+
for i = #nodes, 1, -1 do
303+
trash.remove(nodes[i])
304+
end
305+
if not self.explorer.opts.filesystem_watchers.enable then
306+
self.explorer:reload_explorer()
307+
end
308+
end
309+
310+
if self.explorer.opts.ui.confirm.trash then
311+
local prompt_select = string.format("Trash %d selected ?", #nodes)
312+
local prompt_input = prompt_select .. " y/N: "
313+
lib.prompt(prompt_input, prompt_select, { "", "y" }, { "No", "Yes" }, "nvimtree_visual_trash", function(item_short)
314+
utils.clear_prompt()
315+
if item_short == "y" then
316+
execute()
317+
end
318+
end)
319+
else
320+
execute()
321+
end
322+
end
323+
231324
---Move marked
232325
---@public
233326
function Marks:bulk_move()

0 commit comments

Comments
 (0)