feat(#1549): p will paste files from another nvim-tree instance, gp will cut/paste (#3336)

* feat: add copy absolute path with protocol into public api

* feat: add helper get_basename into node

* feat: add gby keymap, and allow visual mode for copy.basename|absolute_path

* feat(clipboard): add opts for adding protocol in reg operations

* feat(api): allow visual operation in absolute_path*

* feat: add paste with protocol to keymap+api

* feat: add paste with protocol to clipboard

* feat: add configurable clipboard protocol

* fix: filter descendant nodes on copy_absolute_path_with_protocol

* refactor: change clip protocol keymaps to bgy and bgp

* wip

* fix(clipboad): correctly show the number of copied nodes on bgy

* chore: remove bgy, bgp and add gp + gx

* chore: update api/fs.lua and api impl.lua to match new keymaps

* fix: correctly pass the function to new keymaps

* chore: remove use_protocol and use use_register + cut options

* chore: update docs

* chore: move node:get_basename to cliboard_

* chore(clipboard): remove vim.cmd workaround in favor of vim.fn.setreg

* chore: use newline as separator instead of comma

* refactor(clipboard): simplify copying node attribute and allow all to work in visual mode

* refactor(keymap): remove gx, use gp to move

* feat(clipboard): always copy/cut to register and make `p` pick local data or register data

* refactor(docs): update api docs and rename paste_while_cutting to move

* fix: correctly format keymaps

* chore: regenerate docs

* fix: remove unused apis

* chore: copy clipboard.data to register on copy/cut

* chore: revert copy_node_attribute

* refactor: remove visual mode from copy.basename|filename|path

* chore: update docs

* chore: add fs.move api and update docs

* chore: destroy dummy nodes and prevent empty paths nodes parsing

* fix: _meta/api/fs.lua and docs

* fix(clipboard): destroy dummy nodes from register on paste

* fix(clipboard): correctly check for local paste operation
This commit is contained in:
Uanela Como
2026-07-01 02:11:03 +02:00
committed by GitHub
parent 8e8be709c9
commit c760c92667
5 changed files with 153 additions and 37 deletions

View File

@@ -20,7 +20,8 @@ Features
- Git integration - Git integration
- Diagnostics integration: LSP and COC - Diagnostics integration: LSP and COC
- (Live) filtering - (Live) filtering
- Cut, copy, paste, rename, delete, create - Rename, delete, create
- Cut, copy, paste locally and between instances
- Highly customisable - Highly customisable
File Icons File Icons
@@ -142,7 +143,7 @@ Show the mappings: `g?`
`F` n Live Filter: Clear |nvim_tree.api.filter.live.clear()| `F` n Live Filter: Clear |nvim_tree.api.filter.live.clear()|
`f` n Live Filter: Start |nvim_tree.api.filter.live.start()| `f` n Live Filter: Start |nvim_tree.api.filter.live.start()|
`g?` n Help |nvim_tree.api.tree.toggle_help()| `g?` n Help |nvim_tree.api.tree.toggle_help()|
`gy` n Copy Absolute Path |nvim_tree.api.fs.copy.absolute_path()| `gy` nx Copy Absolute Path |nvim_tree.api.fs.copy.absolute_path()|
`ge` n Copy Basename |nvim_tree.api.fs.copy.basename()| `ge` n Copy Basename |nvim_tree.api.fs.copy.basename()|
`H` n Toggle Filter: Dotfiles |nvim_tree.api.filter.dotfiles.toggle()| `H` n Toggle Filter: Dotfiles |nvim_tree.api.filter.dotfiles.toggle()|
`I` n Toggle Filter: Git Ignored |nvim_tree.api.filter.git.ignored.toggle()| `I` n Toggle Filter: Git Ignored |nvim_tree.api.filter.git.ignored.toggle()|
@@ -154,6 +155,7 @@ Show the mappings: `g?`
`o` n Open |nvim_tree.api.node.open.edit()| `o` n Open |nvim_tree.api.node.open.edit()|
`O` n Open: No Window Picker |nvim_tree.api.node.open.no_window_picker()| `O` n Open: No Window Picker |nvim_tree.api.node.open.no_window_picker()|
`p` n Paste |nvim_tree.api.fs.paste()| `p` n Paste |nvim_tree.api.fs.paste()|
`gp` n Move |nvim_tree.api.fs.move()|
`P` n Parent Directory |nvim_tree.api.node.navigate.parent()| `P` n Parent Directory |nvim_tree.api.node.navigate.parent()|
`q` n Close |nvim_tree.api.tree.close()| `q` n Close |nvim_tree.api.tree.close()|
`r` n Rename |nvim_tree.api.fs.rename()| `r` n Rename |nvim_tree.api.fs.rename()|
@@ -435,7 +437,7 @@ You are encouraged to copy these to your {on_attach} function. >lua
vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear")) vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear"))
vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start")) vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start"))
vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help")) vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help"))
vim.keymap.set("n", "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path")) vim.keymap.set({ "n", "x" }, "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path"))
vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename")) vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename"))
vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles")) vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles"))
vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored")) vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored"))
@@ -447,6 +449,7 @@ You are encouraged to copy these to your {on_attach} function. >lua
vim.keymap.set("n", "o", api.node.open.edit, opts("Open")) vim.keymap.set("n", "o", api.node.open.edit, opts("Open"))
vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker")) vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker"))
vim.keymap.set("n", "p", api.fs.paste, opts("Paste")) vim.keymap.set("n", "p", api.fs.paste, opts("Paste"))
vim.keymap.set("n", "gp", api.fs.move, opts("Move"))
vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory")) vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory"))
vim.keymap.set("n", "q", api.tree.close, opts("Close")) vim.keymap.set("n", "q", api.tree.close, opts("Close"))
vim.keymap.set("n", "r", api.fs.rename, opts("Rename")) vim.keymap.set("n", "r", api.fs.rename, opts("Rename"))
@@ -2459,8 +2462,20 @@ cut({node}) *nvim_tree.api.fs.cut()*
Parameters: ~ Parameters: ~
• {node} (`nvim_tree.api.Node?`) • {node} (`nvim_tree.api.Node?`)
move({node}) *nvim_tree.api.fs.move()*
Paste while cutting files from:
• current nvim-tree clipboard (if present)
• another nvim-tree instance via system clipboard
If {node} is a file it will pasted in the parent directory.
Parameters: ~
• {node} (`nvim_tree.api.Node?`)
paste({node}) *nvim_tree.api.fs.paste()* paste({node}) *nvim_tree.api.fs.paste()*
Paste from the nvim-tree clipboard. Paste files from:
• current nvim-tree clipboard (if present)
• another nvim-tree instance via system clipboard
If {node} is a file it will pasted in the parent directory. If {node} is a file it will pasted in the parent directory.

View File

@@ -59,13 +59,25 @@ function nvim_tree.api.fs.create(node) end
function nvim_tree.api.fs.cut(node) end function nvim_tree.api.fs.cut(node) end
--- ---
---Paste from the nvim-tree clipboard. ---Paste files from:
---- current nvim-tree clipboard (if present)
---- another nvim-tree instance via system clipboard
--- ---
---If {node} is a file it will pasted in the parent directory. ---If {node} is a file it will pasted in the parent directory.
--- ---
---@param node? nvim_tree.api.Node ---@param node? nvim_tree.api.Node
function nvim_tree.api.fs.paste(node) end function nvim_tree.api.fs.paste(node) end
---
---Paste while cutting files from:
---- current nvim-tree clipboard (if present)
---- another nvim-tree instance via system clipboard
---
---If {node} is a file it will pasted in the parent directory.
---
---@param node? nvim_tree.api.Node
function nvim_tree.api.fs.move(node) end
--- ---
---Print the contents of the nvim-tree clipboard. ---Print the contents of the nvim-tree clipboard.
--- ---

View File

@@ -9,6 +9,7 @@ local find_file = require("nvim-tree.actions.finders.find-file").fn
local Class = require("nvim-tree.classic") local Class = require("nvim-tree.classic")
local DirectoryNode = require("nvim-tree.node.directory") local DirectoryNode = require("nvim-tree.node.directory")
local FileNode = require("nvim-tree.node.file")
local Node = require("nvim-tree.node") local Node = require("nvim-tree.node")
---@alias ClipboardAction "copy" | "cut" ---@alias ClipboardAction "copy" | "cut"
@@ -176,28 +177,40 @@ function Clipboard:bulk_clipboard(nodes, from, to, verb)
self.explorer.renderer:draw() self.explorer.renderer:draw()
end end
---@private
---@param node_or_nodes Node|Node[]
---@return boolean
function Clipboard:is_nodes_array(node_or_nodes)
if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then
return false
end
return true
end
---Copy one or more nodes ---Copy one or more nodes
---@param node_or_nodes Node|Node[] ---@param node_or_nodes Node|Node[]
function Clipboard:copy(node_or_nodes) function Clipboard:copy(node_or_nodes)
if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then if self:is_nodes_array(node_or_nodes) == false then
utils.array_remove(self.data.cut, node_or_nodes) utils.array_remove(self.data.cut, node_or_nodes)
toggle(node_or_nodes, self.data.copy) toggle(node_or_nodes, self.data.copy)
self.explorer.renderer:draw() self.explorer.renderer:draw()
else else
self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.cut, self.data.copy, "added to") self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.cut, self.data.copy, "added to")
end end
self:copy_absolute_path(self.data.copy, { notify = false })
end end
---Cut one or more nodes ---Cut one or more nodes
---@param node_or_nodes Node|Node[] ---@param node_or_nodes Node|Node[]
function Clipboard:cut(node_or_nodes) function Clipboard:cut(node_or_nodes)
if type(node_or_nodes) == "table" and node_or_nodes.is and node_or_nodes:is(Node) then if self:is_nodes_array(node_or_nodes) == false then
utils.array_remove(self.data.copy, node_or_nodes) utils.array_remove(self.data.copy, node_or_nodes)
toggle(node_or_nodes, self.data.cut) toggle(node_or_nodes, self.data.cut)
self.explorer.renderer:draw() self.explorer.renderer:draw()
else else
self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.copy, self.data.cut, "cut to") self:bulk_clipboard(utils.filter_descendant_nodes(node_or_nodes), self.data.copy, self.data.cut, "cut to")
end end
self:copy_absolute_path(self.data.cut, { notify = false })
end end
---Clear clipboard for action and reload to reflect filesystem changes from paste. ---Clear clipboard for action and reload to reflect filesystem changes from paste.
@@ -304,6 +317,40 @@ function Clipboard:resolve_conflicts(conflict, destination, action, action_fn)
end) end)
end end
--- Transforms the copied absolute paths on register to node
---@private
function Clipboard:get_nodes_from_reg()
local content = vim.fn.getreg(self.reg)
if #content == 0 then
return {}
end
local nodes = {}
local absolute_paths = vim.split(content:sub(1, #content), "\n")
for _, absolute_path in ipairs(absolute_paths) do
if absolute_path:match("^%s*(.-)%s*s") then
local node_args = { absolute_path = absolute_path, name = vim.fn.fnamemodify(absolute_path, ":t"), explorer = self.explorer }
if absolute_path:sub(-1) == "/" then
node_args.name = vim.fn.fnamemodify(absolute_path:sub(1, -2), ":t")
table.insert(nodes, DirectoryNode(node_args))
else
table.insert(nodes, FileNode(node_args))
end
end
end
return nodes
end
---@param nodes Node[]
---@private
function Clipboard:destroy_nodes(nodes)
for _, n in ipairs(nodes) do
n:destroy()
end
end
---Paste cut or copy with batch conflict resolution. ---Paste cut or copy with batch conflict resolution.
---@private ---@private
---@param node Node ---@param node Node
@@ -318,7 +365,9 @@ function Clipboard:do_paste(node, action, action_fn)
node = dir:last_group_node() node = dir:last_group_node()
end end
end end
local clip = self.data[action] local is_local = #self.data[action] > 0
local clip = is_local and self.data[action] or self:get_nodes_from_reg()
if #clip == 0 then if #clip == 0 then
return return
end end
@@ -328,6 +377,9 @@ function Clipboard:do_paste(node, action, action_fn)
if not stats and err_name ~= "ENOENT" then if not stats and err_name ~= "ENOENT" then
log.line("copy_paste", "do_paste fs_stat '%s' failed '%s'", destination, err) log.line("copy_paste", "do_paste fs_stat '%s' failed '%s'", destination, err)
notify.error("Could not " .. action .. " " .. notify.render_path(destination) .. " - " .. (err or "???")) notify.error("Could not " .. action .. " " .. notify.render_path(destination) .. " - " .. (err or "???"))
if is_local == false then
self:destroy_nodes(clip)
end
return return
end end
local is_dir = stats and stats.type == "directory" local is_dir = stats and stats.type == "directory"
@@ -359,6 +411,10 @@ function Clipboard:do_paste(node, action, action_fn)
else else
self:finish_paste(action) self:finish_paste(action)
end end
if is_local == false then
self:destroy_nodes(clip)
end
end end
---@param source string ---@param source string
@@ -386,10 +442,12 @@ end
---Paste cut (if present) or copy (if present) ---Paste cut (if present) or copy (if present)
---@param node Node ---@param node Node
function Clipboard:paste(node) ---@param opts? { cut?: boolean }
if self.data.cut[1] ~= nil then function Clipboard:paste(node, opts)
opts = opts and opts or {}
if self.data.cut[1] ~= nil or opts.cut == true then
self:do_paste(node, "cut", do_cut) self:do_paste(node, "cut", do_cut)
elseif self.data.copy[1] ~= nil then else
self:do_paste(node, "copy", do_copy) self:do_paste(node, "copy", do_copy)
end end
end end
@@ -413,17 +471,15 @@ function Clipboard:print_clipboard()
end end
---@param content string ---@param content string
function Clipboard:copy_to_reg(content) ---@param message? string
-- manually firing TextYankPost does not set vim.v.event ---@param opts? { notify?: boolean }
-- workaround: create a scratch buffer with the clipboard contents and send a yank command function Clipboard:copy_to_reg(content, message, opts)
local temp_buf = vim.api.nvim_create_buf(false, true) opts = opts and opts or {}
vim.api.nvim_buf_set_text(temp_buf, 0, 0, 0, 0, { content }) vim.fn.setreg(self.reg, type(content) == "table" and content or { content }, "v")
vim.api.nvim_buf_call(temp_buf, function()
vim.cmd(string.format('normal! "%sy$', self.reg))
end)
vim.api.nvim_buf_delete(temp_buf, {})
notify.info(string.format("Copied %s to %s clipboard!", content, self.clipboard_name)) if opts.notify ~= false then
notify.info(message or string.format("Copied %s to %s clipboard!", content, self.clipboard_name))
end
end end
---@param node Node ---@param node Node
@@ -437,18 +493,20 @@ function Clipboard:copy_filename(node)
end end
end end
---@private
---@param node Node ---@param node Node
function Clipboard:copy_basename(node) ---@return string
function Clipboard:get_absolute_path(node)
if node.name == ".." then if node.name == ".." then
-- root node = self.explorer
self:copy_to_reg(vim.fn.fnamemodify(self.explorer.absolute_path, ":t:r"))
else
-- node
self:copy_to_reg(vim.fn.fnamemodify(node.name, ":r"))
end end
local absolute_path = node.absolute_path
return node.nodes ~= nil and utils.path_add_trailing(absolute_path) or absolute_path
end end
---@param node Node ---@param node Node
---@return string|nil
function Clipboard:copy_path(node) function Clipboard:copy_path(node)
if node.name == ".." then if node.name == ".." then
-- root -- root
@@ -470,15 +528,44 @@ function Clipboard:copy_path(node)
end end
end end
---@param node Node ---@param node_or_nodes Node|Node[]
function Clipboard:copy_absolute_path(node) ---@param opts? { notify?: boolean }
if node.name == ".." then function Clipboard:copy_absolute_path(node_or_nodes, opts)
node = self.explorer opts = opts and opts or {}
local content
local is_single = self:is_nodes_array(node_or_nodes) == false
if is_single then
local node = #node_or_nodes == 1 and node_or_nodes[0] or node_or_nodes
content = self:get_absolute_path(node)
else
node_or_nodes = utils.filter_descendant_nodes(node_or_nodes)
content = {}
for _, node in ipairs(node_or_nodes) do
table.insert(content, self:get_absolute_path(node))
end
end end
local absolute_path = node.absolute_path
local content = node.nodes ~= nil and utils.path_add_trailing(absolute_path) or absolute_path if content ~= nil then
self:copy_to_reg(content) local message = nil
if not is_single then
message = string.format("%s %s copied to register", #content, #content > 1 and "absolute paths" or "absolute path")
end
self:copy_to_reg(content, message, opts)
end
end
---@param node Node
function Clipboard:copy_basename(node)
if node.name == ".." then
-- root
self:copy_to_reg(vim.fn.fnamemodify(node.explorer.absolute_path, ":t:r"))
else
-- node
self:copy_to_reg(vim.fn.fnamemodify(node.name, ":r"))
end
end end
---Node is cut. Will not be copied. ---Node is cut. Will not be copied.

View File

@@ -155,14 +155,15 @@ function M.hydrate_post_setup(api)
api.filter.toggle = e_(function(e) e.filters:toggle() end) api.filter.toggle = e_(function(e) e.filters:toggle() end)
api.fs.clear_clipboard = e_(function(e) e.clipboard:clear_clipboard() end) api.fs.clear_clipboard = e_(function(e) e.clipboard:clear_clipboard() end)
api.fs.copy.absolute_path = en(function(e, n) e.clipboard:copy_absolute_path(n) end) api.fs.copy.absolute_path = ev(function(e, n) e.clipboard:copy_absolute_path(n) end)
api.fs.copy.basename = en(function(e, n) e.clipboard:copy_basename(n) end) api.fs.copy.basename = en(function(e, n) e.clipboard:copy_basename(n) end)
api.fs.copy.filename = en(function(e, n) e.clipboard:copy_filename(n) end) api.fs.copy.filename = en(function(e, n) e.clipboard:copy_filename(n) end)
api.fs.copy.node = ev(function(e, n) e.clipboard:copy(n) end)
api.fs.copy.relative_path = en(function(e, n) e.clipboard:copy_path(n) end) api.fs.copy.relative_path = en(function(e, n) e.clipboard:copy_path(n) end)
api.fs.copy.node = ev(function(e, n) e.clipboard:copy(n) end)
api.fs.create = _n(function(n) require("nvim-tree.actions.fs.create-file").fn(n) end) api.fs.create = _n(function(n) require("nvim-tree.actions.fs.create-file").fn(n) end)
api.fs.cut = ev(function(e, n) e.clipboard:cut(n) end) api.fs.cut = ev(function(e, n) e.clipboard:cut(n) end)
api.fs.paste = en(function(e, n) e.clipboard:paste(n) end) api.fs.paste = en(function(e, n) e.clipboard:paste(n) end)
api.fs.move = en(function(e, n) e.clipboard:paste(n, { cut = true }) end)
api.fs.print_clipboard = e_(function(e) e.clipboard:print_clipboard() end) api.fs.print_clipboard = e_(function(e) e.clipboard:print_clipboard() end)
api.fs.remove = _v(function(n) require("nvim-tree.actions.fs.remove-file").fn(n) end) api.fs.remove = _v(function(n) require("nvim-tree.actions.fs.remove-file").fn(n) end)
api.fs.rename = _n(function(n) require("nvim-tree.actions.fs.rename-file").rename_node(n) end) api.fs.rename = _n(function(n) require("nvim-tree.actions.fs.rename-file").rename_node(n) end)

View File

@@ -90,7 +90,7 @@ function M.on_attach_default(bufnr)
vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear")) vim.keymap.set("n", "F", api.filter.live.clear, opts("Live Filter: Clear"))
vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start")) vim.keymap.set("n", "f", api.filter.live.start, opts("Live Filter: Start"))
vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help")) vim.keymap.set("n", "g?", api.tree.toggle_help, opts("Help"))
vim.keymap.set("n", "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path")) vim.keymap.set({ "n", "x" }, "gy", api.fs.copy.absolute_path, opts("Copy Absolute Path"))
vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename")) vim.keymap.set("n", "ge", api.fs.copy.basename, opts("Copy Basename"))
vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles")) vim.keymap.set("n", "H", api.filter.dotfiles.toggle, opts("Toggle Filter: Dotfiles"))
vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored")) vim.keymap.set("n", "I", api.filter.git.ignored.toggle, opts("Toggle Filter: Git Ignored"))
@@ -102,6 +102,7 @@ function M.on_attach_default(bufnr)
vim.keymap.set("n", "o", api.node.open.edit, opts("Open")) vim.keymap.set("n", "o", api.node.open.edit, opts("Open"))
vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker")) vim.keymap.set("n", "O", api.node.open.no_window_picker, opts("Open: No Window Picker"))
vim.keymap.set("n", "p", api.fs.paste, opts("Paste")) vim.keymap.set("n", "p", api.fs.paste, opts("Paste"))
vim.keymap.set("n", "gp", api.fs.move, opts("Move"))
vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory")) vim.keymap.set("n", "P", api.node.navigate.parent, opts("Parent Directory"))
vim.keymap.set("n", "q", api.tree.close, opts("Close")) vim.keymap.set("n", "q", api.tree.close, opts("Close"))
vim.keymap.set("n", "r", api.fs.rename, opts("Rename")) vim.keymap.set("n", "r", api.fs.rename, opts("Rename"))