Skip to content

Commit 0fcc3c2

Browse files
committed
feat(#2994): add visual selection operations
1 parent c8d8d51 commit 0fcc3c2

File tree

14 files changed

+601
-306
lines changed

14 files changed

+601
-306
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: 136 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -108,67 +108,18 @@ local function do_copy(source, destination)
108108
return true
109109
end
110110

111+
---Paste a single item with no conflict handling.
111112
---@param source string
112113
---@param dest string
113114
---@param action ClipboardAction
114115
---@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()
116+
local function do_paste_one(source, dest, action, action_fn)
117+
log.line("copy_paste", "do_paste_one '%s' -> '%s'", source, dest)
118+
local success, err = action_fn(source, dest)
119+
if not success then
120+
notify.error("Could not " .. action .. " " .. notify.render_path(source) .. " - " .. (err or "???"))
171121
end
122+
find_file(utils.path_remove_trailing(dest))
172123
end
173124

174125
---@param node Node
@@ -196,23 +147,122 @@ function Clipboard:clear_clipboard()
196147
self.explorer.renderer:draw()
197148
end
198149

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)
150+
---Bulk add/remove nodes to/from a clipboard list.
151+
---@private
152+
---@param nodes Node[] filtered nodes to operate on
153+
---@param from Node[] list to remove from (the opposite clipboard)
154+
---@param to Node[] list to add to
155+
---@param verb string notification verb ("added to" or "cut to")
156+
function Clipboard:bulk_clipboard(nodes, from, to, verb)
157+
local added = 0
158+
local removed = 0
159+
for _, node in ipairs(nodes) do
160+
if node.name ~= ".." then
161+
utils.array_remove(from, node)
162+
if utils.array_remove(to, node) then
163+
removed = removed + 1
164+
else
165+
table.insert(to, node)
166+
added = added + 1
167+
end
168+
end
169+
end
170+
if added > 0 then
171+
notify.info(string.format("%d nodes %s clipboard.", added, verb))
172+
elseif removed > 0 then
173+
notify.info(string.format("%d nodes removed from clipboard.", removed))
174+
end
204175
self.explorer.renderer:draw()
205176
end
206177

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)
178+
---Copy one or more nodes
179+
---@param node_or_nodes Node|Node[]
180+
function Clipboard:copy(node_or_nodes)
181+
if node_or_nodes.is then
182+
utils.array_remove(self.data.cut, node_or_nodes)
183+
toggle(node_or_nodes, self.data.copy)
184+
self.explorer.renderer:draw()
185+
else
186+
self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.cut, self.data.copy, "added to")
187+
end
188+
end
189+
190+
---Cut one or more nodes
191+
---@param node_or_nodes Node|Node[]
192+
function Clipboard:cut(node_or_nodes)
193+
if node_or_nodes.is then
194+
utils.array_remove(self.data.copy, node_or_nodes)
195+
toggle(node_or_nodes, self.data.cut)
196+
self.explorer.renderer:draw()
197+
else
198+
self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.copy, self.data.cut, "cut to")
199+
end
200+
end
201+
202+
---Clear clipboard for action and reload if needed.
203+
---@private
204+
---@param action ClipboardAction
205+
function Clipboard:finish_paste(action)
206+
self.data[action] = {}
207+
if not self.explorer.opts.filesystem_watchers.enable then
208+
self.explorer:reload_explorer()
209+
end
212210
self.explorer.renderer:draw()
213211
end
214212

215-
---Paste cut or cop
213+
---Resolve conflicting paste items with a single batch prompt.
214+
---@private
215+
---@param conflict {node: Node, dest: string}[]
216+
---@param destination string
217+
---@param action ClipboardAction
218+
---@param action_fn ClipboardActionFn
219+
function Clipboard:resolve_conflicts(conflict, destination, action, action_fn)
220+
local prompt_select = #conflict .. " file(s) already exist"
221+
local prompt_input = prompt_select .. ". R(ename suffix)/y/n: "
222+
223+
lib.prompt(prompt_input, prompt_select,
224+
{ "", "y", "n" },
225+
{ "Rename (suffix)", "Overwrite all", "Skip all" },
226+
"nvimtree_paste_conflict",
227+
function(item_short)
228+
utils.clear_prompt()
229+
if item_short == "y" then
230+
for _, item in ipairs(conflict) do
231+
do_paste_one(item.node.absolute_path, item.dest, action, action_fn)
232+
end
233+
self:finish_paste(action)
234+
elseif item_short == "" or item_short == "r" then
235+
vim.ui.input({ prompt = "Suffix: " }, function(suffix)
236+
utils.clear_prompt()
237+
if not suffix or suffix == "" then
238+
return
239+
end
240+
local still_conflict = {}
241+
for _, item in ipairs(conflict) do
242+
local basename = vim.fn.fnamemodify(item.node.name, ":r")
243+
local extension = vim.fn.fnamemodify(item.node.name, ":e")
244+
local new_name = extension ~= "" and (basename .. suffix .. "." .. extension) or (item.node.name .. suffix)
245+
local new_dest = utils.path_join({ destination, new_name })
246+
local stats = vim.loop.fs_stat(new_dest)
247+
if stats then
248+
table.insert(still_conflict, { node = item.node, dest = new_dest })
249+
else
250+
do_paste_one(item.node.absolute_path, new_dest, action, action_fn)
251+
end
252+
end
253+
if #still_conflict > 0 then
254+
self:resolve_conflicts(still_conflict, destination, action, action_fn)
255+
else
256+
self:finish_paste(action)
257+
end
258+
end)
259+
else
260+
self:finish_paste(action)
261+
end
262+
end)
263+
end
264+
265+
---Paste cut or copy with batch conflict resolution.
216266
---@private
217267
---@param node Node
218268
---@param action ClipboardAction
@@ -243,14 +293,29 @@ function Clipboard:do_paste(node, action, action_fn)
243293
destination = vim.fn.fnamemodify(destination, ":p:h")
244294
end
245295

296+
-- Partition into conflict / no-conflict
297+
local no_conflict = {}
298+
local conflict = {}
246299
for _, _node in ipairs(clip) do
247300
local dest = utils.path_join({ destination, _node.name })
248-
do_single_paste(_node.absolute_path, dest, action, action_fn)
301+
local dest_stats = vim.loop.fs_stat(dest)
302+
if dest_stats then
303+
table.insert(conflict, { node = _node, dest = dest })
304+
else
305+
table.insert(no_conflict, { node = _node, dest = dest })
306+
end
249307
end
250308

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

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

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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 RootNode = require("nvim-tree.node.root")
1011

1112
local M = {
1213
config = {},
@@ -126,9 +127,10 @@ function M.remove(node)
126127
notify.info(notify_node .. " was properly removed.")
127128
end
128129

130+
---Remove a single node with confirmation.
129131
---@param node Node
130-
function M.fn(node)
131-
if node.name == ".." then
132+
local function remove_one(node)
133+
if node:is(RootNode) then
132134
return
133135
end
134136

@@ -142,17 +144,7 @@ function M.fn(node)
142144

143145
if M.config.ui.confirm.remove then
144146
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
147+
local prompt_input, items_short, items_long = utils.confirm_prompt(prompt_select, M.config.ui.confirm.default_yes)
156148

157149
lib.prompt(prompt_input, prompt_select, items_short, items_long, "nvimtree_remove", function(item_short)
158150
utils.clear_prompt()
@@ -165,6 +157,51 @@ function M.fn(node)
165157
end
166158
end
167159

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

0 commit comments

Comments
 (0)