Skip to content

Commit 31c5df1

Browse files
committed
feat(#2994): add visual selection operations
1 parent 4b30847 commit 31c5df1

File tree

14 files changed

+604
-307
lines changed

14 files changed

+604
-307
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/clipboard.lua

Lines changed: 135 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ local find_file = require("nvim-tree.actions.finders.find-file").fn
99

1010
local Class = require("nvim-tree.classic")
1111
local DirectoryNode = require("nvim-tree.node.directory")
12+
local Node = require("nvim-tree.node")
1213

1314
---@alias ClipboardAction "copy" | "cut"
1415
---@alias ClipboardData table<ClipboardAction, Node[]>
@@ -108,67 +109,18 @@ local function do_copy(source, destination)
108109
return true
109110
end
110111

112+
---Paste a single item with no conflict handling.
111113
---@param source string
112114
---@param dest string
113115
---@param action ClipboardAction
114116
---@param action_fn ClipboardActionFn
115-
---@return boolean|nil -- success
116-
---@return string|nil -- error message
117-
local function do_single_paste(source, dest, action, action_fn)
118-
local notify_source = notify.render_path(source)
119-
120-
log.line("copy_paste", "do_single_paste '%s' -> '%s'", source, dest)
121-
122-
local dest_stats, err, err_name = vim.loop.fs_stat(dest)
123-
if not dest_stats and err_name ~= "ENOENT" then
124-
notify.error("Could not " .. action .. " " .. notify_source .. " - " .. (err or "???"))
125-
return false, err
126-
end
127-
128-
local function on_process()
129-
local success, error = action_fn(source, dest)
130-
if not success then
131-
notify.error("Could not " .. action .. " " .. notify_source .. " - " .. (error or "???"))
132-
return false, error
133-
end
134-
135-
find_file(utils.path_remove_trailing(dest))
136-
end
137-
138-
if dest_stats then
139-
local input_opts = {
140-
prompt = "Rename to ",
141-
default = dest,
142-
completion = "dir",
143-
}
144-
145-
if source == dest then
146-
vim.ui.input(input_opts, function(new_dest)
147-
utils.clear_prompt()
148-
if new_dest then
149-
do_single_paste(source, new_dest, action, action_fn)
150-
end
151-
end)
152-
else
153-
local prompt_select = "Overwrite " .. dest .. " ?"
154-
local prompt_input = prompt_select .. " R(ename)/y/n: "
155-
lib.prompt(prompt_input, prompt_select, { "", "y", "n" }, { "Rename", "Yes", "No" }, "nvimtree_overwrite_rename", function(item_short)
156-
utils.clear_prompt()
157-
if item_short == "y" then
158-
on_process()
159-
elseif item_short == "" or item_short == "r" then
160-
vim.ui.input(input_opts, function(new_dest)
161-
utils.clear_prompt()
162-
if new_dest then
163-
do_single_paste(source, new_dest, action, action_fn)
164-
end
165-
end)
166-
end
167-
end)
168-
end
169-
else
170-
on_process()
117+
local function do_paste_one(source, dest, action, action_fn)
118+
log.line("copy_paste", "do_paste_one '%s' -> '%s'", source, dest)
119+
local success, err = action_fn(source, dest)
120+
if not success then
121+
notify.error("Could not " .. action .. " " .. notify.render_path(source) .. " - " .. (err or "???"))
171122
end
123+
find_file(utils.path_remove_trailing(dest))
172124
end
173125

174126
---@param node Node
@@ -196,23 +148,119 @@ function Clipboard:clear_clipboard()
196148
self.explorer.renderer:draw()
197149
end
198150

199-
---Copy one node
200-
---@param node Node
201-
function Clipboard:copy(node)
202-
utils.array_remove(self.data.cut, node)
203-
toggle(node, self.data.copy)
151+
---Bulk add/remove nodes to/from a clipboard list.
152+
---@private
153+
---@param nodes Node[] filtered nodes to operate on
154+
---@param from Node[] list to remove from (the opposite clipboard)
155+
---@param to Node[] list to add to
156+
---@param verb string notification verb ("added to" or "cut to")
157+
function Clipboard:bulk_clipboard(nodes, from, to, verb)
158+
local added = 0
159+
local removed = 0
160+
for _, node in ipairs(nodes) do
161+
if node.name ~= ".." then
162+
utils.array_remove(from, node)
163+
if utils.array_remove(to, node) then
164+
removed = removed + 1
165+
else
166+
table.insert(to, node)
167+
added = added + 1
168+
end
169+
end
170+
end
171+
if added > 0 then
172+
notify.info(string.format("%d nodes %s clipboard.", added, verb))
173+
elseif removed > 0 then
174+
notify.info(string.format("%d nodes removed from clipboard.", removed))
175+
end
204176
self.explorer.renderer:draw()
205177
end
206178

207-
---Cut one node
208-
---@param node Node
209-
function Clipboard:cut(node)
210-
utils.array_remove(self.data.copy, node)
211-
toggle(node, self.data.cut)
212-
self.explorer.renderer:draw()
179+
---Copy one or more nodes
180+
---@param node_or_nodes Node|Node[]
181+
function Clipboard:copy(node_or_nodes)
182+
if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then
183+
utils.array_remove(self.data.cut, node_or_nodes)
184+
toggle(node_or_nodes, self.data.copy)
185+
self.explorer.renderer:draw()
186+
else
187+
self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.cut, self.data.copy, "added to")
188+
end
213189
end
214190

215-
---Paste cut or cop
191+
---Cut one or more nodes
192+
---@param node_or_nodes Node|Node[]
193+
function Clipboard:cut(node_or_nodes)
194+
if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then
195+
utils.array_remove(self.data.copy, node_or_nodes)
196+
toggle(node_or_nodes, self.data.cut)
197+
self.explorer.renderer:draw()
198+
else
199+
self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.copy, self.data.cut, "cut to")
200+
end
201+
end
202+
203+
---Clear clipboard for action and reload to reflect filesystem changes from paste.
204+
---@private
205+
---@param action ClipboardAction
206+
function Clipboard:finish_paste(action)
207+
self.data[action] = {}
208+
self.explorer:reload_explorer()
209+
end
210+
211+
---Resolve conflicting paste items with a single batch prompt.
212+
---@private
213+
---@param conflict {node: Node, dest: string}[]
214+
---@param destination string
215+
---@param action ClipboardAction
216+
---@param action_fn ClipboardActionFn
217+
function Clipboard:resolve_conflicts(conflict, destination, action, action_fn)
218+
local prompt_select = #conflict .. " file(s) already exist"
219+
local prompt_input = prompt_select .. ". R(ename suffix)/y/n: "
220+
221+
lib.prompt(prompt_input, prompt_select,
222+
{ "", "y", "n" },
223+
{ "Rename (suffix)", "Overwrite all", "Skip all" },
224+
"nvimtree_paste_conflict",
225+
function(item_short)
226+
utils.clear_prompt()
227+
if item_short == "y" then
228+
for _, item in ipairs(conflict) do
229+
do_paste_one(item.node.absolute_path, item.dest, action, action_fn)
230+
end
231+
self:finish_paste(action)
232+
elseif item_short == "" or item_short == "r" then
233+
vim.ui.input({ prompt = "Suffix: " }, function(suffix)
234+
utils.clear_prompt()
235+
if not suffix or suffix == "" then
236+
return
237+
end
238+
local still_conflict = {}
239+
for _, item in ipairs(conflict) do
240+
local basename = vim.fn.fnamemodify(item.node.name, ":r")
241+
local extension = vim.fn.fnamemodify(item.node.name, ":e")
242+
local new_name = extension ~= "" and (basename .. suffix .. "." .. extension) or (item.node.name .. suffix)
243+
local new_dest = utils.path_join({ destination, new_name })
244+
local stats = vim.loop.fs_stat(new_dest)
245+
if stats then
246+
table.insert(still_conflict, { node = item.node, dest = new_dest })
247+
else
248+
do_paste_one(item.node.absolute_path, new_dest, action, action_fn)
249+
end
250+
end
251+
if #still_conflict > 0 then
252+
self:resolve_conflicts(still_conflict, destination, action, action_fn)
253+
else
254+
self:finish_paste(action)
255+
end
256+
end)
257+
else
258+
self:finish_paste(action)
259+
end
260+
end)
261+
end
262+
263+
---Paste cut or copy with batch conflict resolution.
216264
---@private
217265
---@param node Node
218266
---@param action ClipboardAction
@@ -243,14 +291,29 @@ function Clipboard:do_paste(node, action, action_fn)
243291
destination = vim.fn.fnamemodify(destination, ":p:h")
244292
end
245293

294+
-- Partition into conflict / no-conflict
295+
local no_conflict = {}
296+
local conflict = {}
246297
for _, _node in ipairs(clip) do
247298
local dest = utils.path_join({ destination, _node.name })
248-
do_single_paste(_node.absolute_path, dest, action, action_fn)
299+
local dest_stats = vim.loop.fs_stat(dest)
300+
if dest_stats then
301+
table.insert(conflict, { node = _node, dest = dest })
302+
else
303+
table.insert(no_conflict, { node = _node, dest = dest })
304+
end
249305
end
250306

251-
self.data[action] = {}
252-
if not self.explorer.opts.filesystem_watchers.enable then
253-
self.explorer:reload_explorer()
307+
-- Paste non-conflicting items immediately
308+
for _, item in ipairs(no_conflict) do
309+
do_paste_one(item.node.absolute_path, item.dest, action, action_fn)
310+
end
311+
312+
-- Resolve conflicts in batch
313+
if #conflict > 0 then
314+
self:resolve_conflicts(conflict, destination, action, action_fn)
315+
else
316+
self:finish_paste(action)
254317
end
255318
end
256319

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

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ local notify = require("nvim-tree.notify")
77

88
local DirectoryLinkNode = require("nvim-tree.node.directory-link")
99
local DirectoryNode = require("nvim-tree.node.directory")
10+
local Node = require("nvim-tree.node")
11+
local RootNode = require("nvim-tree.node.root")
1012

1113
local M = {
1214
config = {},
@@ -126,9 +128,10 @@ function M.remove(node)
126128
notify.info(notify_node .. " was properly removed.")
127129
end
128130

131+
---Remove a single node with confirmation.
129132
---@param node Node
130-
function M.fn(node)
131-
if node.name == ".." then
133+
local function remove_one(node)
134+
if node:is(RootNode) then
132135
return
133136
end
134137

@@ -142,17 +145,7 @@ function M.fn(node)
142145

143146
if M.config.ui.confirm.remove then
144147
local prompt_select = "Remove " .. node.name .. "?"
145-
local prompt_input, items_short, items_long
146-
147-
if M.config.ui.confirm.default_yes then
148-
prompt_input = prompt_select .. " Y/n: "
149-
items_short = { "", "n" }
150-
items_long = { "Yes", "No" }
151-
else
152-
prompt_input = prompt_select .. " y/N: "
153-
items_short = { "", "y" }
154-
items_long = { "No", "Yes" }
155-
end
148+
local prompt_input, items_short, items_long = utils.confirm_prompt(prompt_select, M.config.ui.confirm.default_yes)
156149

157150
lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_remove", function(item_short)
158151
utils.clear_prompt()
@@ -165,6 +158,51 @@ function M.fn(node)
165158
end
166159
end
167160

161+
---Remove multiple nodes with a single confirmation prompt.
162+
---@param nodes Node[]
163+
local function remove_many(nodes)
164+
if #nodes == 0 then
165+
return
166+
end
167+
168+
nodes = utils.filter_descendant_nodes(nodes)
169+
170+
local function execute()
171+
for _, node in ipairs(nodes) do
172+
if not node:is(RootNode) then
173+
M.remove(node)
174+
end
175+
end
176+
local explorer = core.get_explorer()
177+
if not M.config.filesystem_watchers.enable and explorer then
178+
explorer:reload_explorer()
179+
end
180+
end
181+
182+
if M.config.ui.confirm.remove then
183+
local prompt_select = string.format("Remove %d selected?", #nodes)
184+
local prompt_input, items_short, items_long = utils.confirm_prompt(prompt_select, M.config.ui.confirm.default_yes)
185+
186+
lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_remove", function(item_short)
187+
utils.clear_prompt()
188+
if item_short == "y" or item_short == (M.config.ui.confirm.default_yes and "") then
189+
execute()
190+
end
191+
end)
192+
else
193+
execute()
194+
end
195+
end
196+
197+
---@param node_or_nodes Node|Node[]
198+
function M.fn(node_or_nodes)
199+
if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then
200+
remove_one(node_or_nodes)
201+
else
202+
remove_many(node_or_nodes)
203+
end
204+
end
205+
168206
function M.setup(opts)
169207
M.config.ui = opts.ui
170208
M.config.actions = opts.actions

0 commit comments

Comments
 (0)