feat: copy/paste to system clipboard (#559)
* feat: copy/paste to system clipboard on macOS * stylua * feat: copy/paste to system clipboard on linux * force mime type * fix string.gsub * vim.uv or vim.loop * fix stylua * support gnome directly * support wayland * refactor: extract clipboard actions into separate file * fix: copy/paste in KDE * refactor: simplify file loading * fix: copy/paste on x11 * fix: better error message when clipboard command not found * fix: paste on mac * fix: pasting in Gnome * feat: support pasting multiple files * feat: support copying multiple files to clipboard --------- Co-authored-by: Steve Walker <65963536+etherswangel@users.noreply.github.com> Co-authored-by: Steven Arcangeli <stevearc@stevearc.com>
This commit is contained in:
parent
8649818fb2
commit
4c9bdf0d83
6 changed files with 427 additions and 35 deletions
|
|
@ -545,6 +545,9 @@ close *actions.clos
|
|||
Parameters:
|
||||
{exit_if_last_buf} `boolean` Exit vim if oil is closed as the last buffer
|
||||
|
||||
copy_to_system_clipboard *actions.copy_to_system_clipboard*
|
||||
Copy the entry under the cursor to the system clipboard
|
||||
|
||||
open_cmdline *actions.open_cmdline*
|
||||
Open vim cmdline with current entry as an argument
|
||||
|
||||
|
|
@ -565,6 +568,9 @@ open_terminal *actions.open_termina
|
|||
parent *actions.parent*
|
||||
Navigate to the parent path
|
||||
|
||||
paste_from_system_clipboard *actions.paste_from_system_clipboard*
|
||||
Paste the system clipboard into the current oil directory
|
||||
|
||||
preview *actions.preview*
|
||||
Open the entry under the cursor in a preview window, or close the preview
|
||||
window if already open
|
||||
|
|
|
|||
|
|
@ -418,6 +418,20 @@ M.copy_entry_filename = {
|
|||
end,
|
||||
}
|
||||
|
||||
M.copy_to_system_clipboard = {
|
||||
desc = "Copy the entry under the cursor to the system clipboard",
|
||||
callback = function()
|
||||
require("oil.clipboard").copy_to_system_clipboard()
|
||||
end,
|
||||
}
|
||||
|
||||
M.paste_from_system_clipboard = {
|
||||
desc = "Paste the system clipboard into the current oil directory",
|
||||
callback = function()
|
||||
require("oil.clipboard").paste_from_system_clipboard()
|
||||
end,
|
||||
}
|
||||
|
||||
M.open_cmdline_dir = {
|
||||
desc = "Open vim cmdline with current directory as an argument",
|
||||
deprecated = true,
|
||||
|
|
|
|||
328
lua/oil/clipboard.lua
Normal file
328
lua/oil/clipboard.lua
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
local cache = require("oil.cache")
|
||||
local columns = require("oil.columns")
|
||||
local config = require("oil.config")
|
||||
local fs = require("oil.fs")
|
||||
local oil = require("oil")
|
||||
local util = require("oil.util")
|
||||
local view = require("oil.view")
|
||||
|
||||
local M = {}
|
||||
|
||||
---@return "wayland"|"x11"|nil
|
||||
local function get_linux_session_type()
|
||||
local xdg_session_type = vim.env.XDG_SESSION_TYPE:lower()
|
||||
if xdg_session_type:find("x11") then
|
||||
return "x11"
|
||||
elseif xdg_session_type:find("wayland") then
|
||||
return "wayland"
|
||||
else
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
---@return boolean
|
||||
local function is_linux_desktop_gnome()
|
||||
local cur_desktop = vim.env.XDG_CURRENT_DESKTOP
|
||||
local idx = vim.env.XDG_SESSION_DESKTOP:lower():find("gnome") or cur_desktop:lower():find("gnome")
|
||||
return idx ~= nil or cur_desktop == "X-Cinnamon" or cur_desktop == "XFCE"
|
||||
end
|
||||
|
||||
---@param winid integer
|
||||
---@param entry oil.InternalEntry
|
||||
---@param column_defs oil.ColumnSpec[]
|
||||
---@param adapter oil.Adapter
|
||||
---@param bufnr integer
|
||||
local function write_pasted(winid, entry, column_defs, adapter, bufnr)
|
||||
local col_width = {}
|
||||
for i in ipairs(column_defs) do
|
||||
col_width[i + 1] = 1
|
||||
end
|
||||
local line_table =
|
||||
{ view.format_entry_cols(entry, column_defs, col_width, adapter, false, bufnr) }
|
||||
local lines, _ = util.render_table(line_table, col_width)
|
||||
local pos = vim.api.nvim_win_get_cursor(winid)
|
||||
vim.api.nvim_buf_set_lines(bufnr, pos[1], pos[1], true, lines)
|
||||
end
|
||||
|
||||
---@param paths string[]
|
||||
local function paste_paths(paths)
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
local scheme = "oil://"
|
||||
local adapter = assert(config.get_adapter_by_scheme(scheme))
|
||||
local column_defs = columns.get_supported_columns(scheme)
|
||||
local winid = vim.api.nvim_get_current_win()
|
||||
|
||||
local parent_urls = {}
|
||||
local pending_paths = {}
|
||||
|
||||
for _, path in ipairs(paths) do
|
||||
-- Trim the trailing slash off directories
|
||||
if vim.endswith(path, "/") then
|
||||
path = path:sub(1, -2)
|
||||
end
|
||||
|
||||
local ori_entry = cache.get_entry_by_url(scheme .. path)
|
||||
if ori_entry then
|
||||
write_pasted(winid, ori_entry, column_defs, adapter, bufnr)
|
||||
else
|
||||
local parent_url = scheme .. vim.fs.dirname(path)
|
||||
parent_urls[parent_url] = true
|
||||
table.insert(pending_paths, path)
|
||||
end
|
||||
end
|
||||
if #pending_paths == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local cursor = vim.api.nvim_win_get_cursor(winid)
|
||||
local complete_loading = util.cb_collect(#vim.tbl_keys(parent_urls), function(err)
|
||||
if err then
|
||||
vim.notify(string.format("Error loading parent directory: %s", err), vim.log.levels.ERROR)
|
||||
else
|
||||
-- Something in this process moves the cursor to the top of the window, so have to restore it
|
||||
vim.api.nvim_win_set_cursor(winid, cursor)
|
||||
|
||||
for _, path in ipairs(pending_paths) do
|
||||
local ori_entry = cache.get_entry_by_url(scheme .. path)
|
||||
if ori_entry then
|
||||
write_pasted(winid, ori_entry, column_defs, adapter, bufnr)
|
||||
else
|
||||
vim.notify(
|
||||
string.format("The pasted file '%s' could not be found", path),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
for parent_url, _ in pairs(parent_urls) do
|
||||
local new_bufnr = vim.api.nvim_create_buf(false, false)
|
||||
vim.api.nvim_buf_set_name(new_bufnr, parent_url)
|
||||
oil.load_oil_buffer(new_bufnr)
|
||||
util.run_after_load(new_bufnr, complete_loading)
|
||||
end
|
||||
end
|
||||
|
||||
---@return integer start
|
||||
---@return integer end
|
||||
local function range_from_selection()
|
||||
-- [bufnum, lnum, col, off]; both row and column 1-indexed
|
||||
local start = vim.fn.getpos("v")
|
||||
local end_ = vim.fn.getpos(".")
|
||||
local start_row = start[2]
|
||||
local end_row = end_[2]
|
||||
|
||||
if start_row > end_row then
|
||||
start_row, end_row = end_row, start_row
|
||||
end
|
||||
|
||||
return start_row, end_row
|
||||
end
|
||||
|
||||
M.copy_to_system_clipboard = function()
|
||||
local dir = oil.get_current_dir()
|
||||
if not dir then
|
||||
vim.notify("System clipboard only works for local files", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
local entries = {}
|
||||
local mode = vim.api.nvim_get_mode().mode
|
||||
if mode == "v" or mode == "V" then
|
||||
if fs.is_mac then
|
||||
vim.notify(
|
||||
"Copying multiple paths to clipboard is not supported on mac",
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
return
|
||||
end
|
||||
local start_row, end_row = range_from_selection()
|
||||
for i = start_row, end_row do
|
||||
table.insert(entries, oil.get_entry_on_line(0, i))
|
||||
end
|
||||
|
||||
-- leave visual mode
|
||||
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<Esc>", true, false, true), "n", true)
|
||||
else
|
||||
table.insert(entries, oil.get_cursor_entry())
|
||||
end
|
||||
|
||||
-- This removes holes in the list-like table
|
||||
entries = vim.tbl_values(entries)
|
||||
|
||||
if #entries == 0 then
|
||||
vim.notify("Could not find local file under cursor", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local paths = {}
|
||||
for _, entry in ipairs(entries) do
|
||||
table.insert(paths, dir .. entry.name)
|
||||
end
|
||||
local cmd = {}
|
||||
local stdin
|
||||
if fs.is_mac then
|
||||
cmd = {
|
||||
"osascript",
|
||||
"-e",
|
||||
"on run args",
|
||||
"-e",
|
||||
"set the clipboard to POSIX file (first item of args)",
|
||||
"-e",
|
||||
"end run",
|
||||
paths[1],
|
||||
}
|
||||
elseif fs.is_linux then
|
||||
local xdg_session_type = get_linux_session_type()
|
||||
if xdg_session_type == "x11" then
|
||||
vim.list_extend(cmd, { "xclip", "-i", "-selection", "clipboard" })
|
||||
elseif xdg_session_type == "wayland" then
|
||||
table.insert(cmd, "wl-copy")
|
||||
else
|
||||
vim.notify("System clipboard not supported, check $XDG_SESSION_TYPE", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local urls = {}
|
||||
for _, path in ipairs(paths) do
|
||||
table.insert(urls, "file://" .. path)
|
||||
end
|
||||
if is_linux_desktop_gnome() then
|
||||
stdin = string.format("copy\n%s\0", table.concat(urls, "\n"))
|
||||
vim.list_extend(cmd, { "-t", "x-special/gnome-copied-files" })
|
||||
else
|
||||
stdin = table.concat(urls, "\n") .. "\n"
|
||||
vim.list_extend(cmd, { "-t", "text/uri-list" })
|
||||
end
|
||||
else
|
||||
vim.notify("System clipboard not supported on Windows", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
|
||||
if vim.fn.executable(cmd[1]) == 0 then
|
||||
vim.notify(string.format("Could not find executable '%s'", cmd[1]), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local stderr = ""
|
||||
local jid = vim.fn.jobstart(cmd, {
|
||||
stderr_buffered = true,
|
||||
on_stderr = function(_, data)
|
||||
stderr = table.concat(data, "\n")
|
||||
end,
|
||||
on_exit = function(j, exit_code)
|
||||
if exit_code ~= 0 then
|
||||
vim.notify(
|
||||
string.format("Error copying '%s' to system clipboard\n%s", vim.inspect(paths), stderr),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
else
|
||||
if #paths == 1 then
|
||||
vim.notify(string.format("Copied '%s' to system clipboard", paths[1]))
|
||||
else
|
||||
vim.notify(string.format("Copied %d files to system clipboard", #paths))
|
||||
end
|
||||
end
|
||||
end,
|
||||
})
|
||||
assert(jid > 0, "Failed to start job")
|
||||
if stdin then
|
||||
vim.api.nvim_chan_send(jid, stdin)
|
||||
vim.fn.chanclose(jid, "stdin")
|
||||
end
|
||||
end
|
||||
|
||||
---@param lines string[]
|
||||
---@return string[]
|
||||
local function handle_paste_output_mac(lines)
|
||||
local ret = {}
|
||||
for _, line in ipairs(lines) do
|
||||
if not line:match("^%s*$") then
|
||||
table.insert(ret, line)
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
---@param lines string[]
|
||||
---@return string[]
|
||||
local function handle_paste_output_linux(lines)
|
||||
local ret = {}
|
||||
for _, line in ipairs(lines) do
|
||||
local path = line:match("^file://(.+)$")
|
||||
if path then
|
||||
table.insert(ret, util.url_unescape(path))
|
||||
end
|
||||
end
|
||||
return ret
|
||||
end
|
||||
|
||||
M.paste_from_system_clipboard = function()
|
||||
local dir = oil.get_current_dir()
|
||||
if not dir then
|
||||
return
|
||||
end
|
||||
local cmd = {}
|
||||
local handle_paste_output
|
||||
if fs.is_mac then
|
||||
cmd = {
|
||||
"osascript",
|
||||
"-e",
|
||||
"on run",
|
||||
"-e",
|
||||
"POSIX path of (the clipboard as «class furl»)",
|
||||
"-e",
|
||||
"end run",
|
||||
}
|
||||
handle_paste_output = handle_paste_output_mac
|
||||
elseif fs.is_linux then
|
||||
local xdg_session_type = get_linux_session_type()
|
||||
if xdg_session_type == "x11" then
|
||||
vim.list_extend(cmd, { "xclip", "-o", "-selection", "clipboard" })
|
||||
elseif xdg_session_type == "wayland" then
|
||||
table.insert(cmd, "wl-paste")
|
||||
else
|
||||
vim.notify("System clipboard not supported, check $XDG_SESSION_TYPE", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
if is_linux_desktop_gnome() then
|
||||
vim.list_extend(cmd, { "-t", "x-special/gnome-copied-files" })
|
||||
else
|
||||
vim.list_extend(cmd, { "-t", "text/uri-list" })
|
||||
end
|
||||
handle_paste_output = handle_paste_output_linux
|
||||
else
|
||||
vim.notify("System clipboard not supported on Windows", vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local paths
|
||||
local stderr = ""
|
||||
if vim.fn.executable(cmd[1]) == 0 then
|
||||
vim.notify(string.format("Could not find executable '%s'", cmd[1]), vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local jid = vim.fn.jobstart(cmd, {
|
||||
stdout_buffered = true,
|
||||
stderr_buffered = true,
|
||||
on_stdout = function(j, data)
|
||||
local lines = vim.split(table.concat(data, "\n"), "\r?\n")
|
||||
paths = handle_paste_output(lines)
|
||||
end,
|
||||
on_stderr = function(_, data)
|
||||
stderr = table.concat(data, "\n")
|
||||
end,
|
||||
on_exit = function(j, exit_code)
|
||||
if exit_code ~= 0 or not paths then
|
||||
vim.notify(
|
||||
string.format("Error pasting from system clipboard: %s", stderr),
|
||||
vim.log.levels.ERROR
|
||||
)
|
||||
elseif #paths == 0 then
|
||||
vim.notify("No valid files found in system clipboard", vim.log.levels.WARN)
|
||||
else
|
||||
paste_paths(paths)
|
||||
end
|
||||
end,
|
||||
})
|
||||
assert(jid > 0, "Failed to start job")
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -30,8 +30,6 @@ local M = {}
|
|||
---@field filter_action? fun(action: oil.Action): boolean When present, filter out actions as they are created
|
||||
---@field filter_error? fun(action: oil.ParseError): boolean When present, filter out errors from parsing a buffer
|
||||
|
||||
local load_oil_buffer
|
||||
|
||||
---Get the entry on a specific line (1-indexed)
|
||||
---@param bufnr integer
|
||||
---@param lnum integer
|
||||
|
|
@ -593,7 +591,7 @@ M.open_preview = function(opts, callback)
|
|||
-- If we called open_preview during an autocmd, then the edit command may not trigger the
|
||||
-- BufReadCmd to load the buffer. So we need to do it manually.
|
||||
if util.is_oil_bufnr(filebufnr) then
|
||||
load_oil_buffer(filebufnr)
|
||||
M.load_oil_buffer(filebufnr)
|
||||
end
|
||||
|
||||
vim.api.nvim_set_option_value("previewwindow", true, { scope = "local", win = 0 })
|
||||
|
|
@ -1013,8 +1011,9 @@ local function restore_alt_buf()
|
|||
end
|
||||
end
|
||||
|
||||
---@private
|
||||
---@param bufnr integer
|
||||
load_oil_buffer = function(bufnr)
|
||||
M.load_oil_buffer = function(bufnr)
|
||||
local config = require("oil.config")
|
||||
local keymap_util = require("oil.keymap_util")
|
||||
local loading = require("oil.loading")
|
||||
|
|
@ -1218,7 +1217,7 @@ M.setup = function(opts)
|
|||
pattern = scheme_pattern,
|
||||
nested = true,
|
||||
callback = function(params)
|
||||
load_oil_buffer(params.buf)
|
||||
M.load_oil_buffer(params.buf)
|
||||
end,
|
||||
})
|
||||
vim.api.nvim_create_autocmd("BufWriteCmd", {
|
||||
|
|
@ -1388,7 +1387,7 @@ M.setup = function(opts)
|
|||
local util = require("oil.util")
|
||||
local scheme = util.parse_url(params.file)
|
||||
if config.adapters[scheme] and vim.api.nvim_buf_line_count(params.buf) == 1 then
|
||||
load_oil_buffer(params.buf)
|
||||
M.load_oil_buffer(params.buf)
|
||||
end
|
||||
end,
|
||||
})
|
||||
|
|
@ -1397,7 +1396,7 @@ M.setup = function(opts)
|
|||
if maybe_hijack_directory_buffer(bufnr) and vim.v.vim_did_enter == 1 then
|
||||
-- manually call load on a hijacked directory buffer if vim has already entered
|
||||
-- (the BufReadCmd will not trigger)
|
||||
load_oil_buffer(bufnr)
|
||||
M.load_oil_buffer(bufnr)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -25,38 +25,54 @@ M.escape_filename = function(filename)
|
|||
return ret
|
||||
end
|
||||
|
||||
local _url_escape_chars = {
|
||||
[" "] = "%20",
|
||||
["$"] = "%24",
|
||||
["&"] = "%26",
|
||||
["`"] = "%60",
|
||||
[":"] = "%3A",
|
||||
["<"] = "%3C",
|
||||
["="] = "%3D",
|
||||
[">"] = "%3E",
|
||||
["?"] = "%3F",
|
||||
["["] = "%5B",
|
||||
["\\"] = "%5C",
|
||||
["]"] = "%5D",
|
||||
["^"] = "%5E",
|
||||
["{"] = "%7B",
|
||||
["|"] = "%7C",
|
||||
["}"] = "%7D",
|
||||
["~"] = "%7E",
|
||||
["“"] = "%22",
|
||||
["‘"] = "%27",
|
||||
["+"] = "%2B",
|
||||
[","] = "%2C",
|
||||
["#"] = "%23",
|
||||
["%"] = "%25",
|
||||
["@"] = "%40",
|
||||
["/"] = "%2F",
|
||||
[";"] = "%3B",
|
||||
local _url_escape_to_char = {
|
||||
["20"] = " ",
|
||||
["22"] = "“",
|
||||
["23"] = "#",
|
||||
["24"] = "$",
|
||||
["25"] = "%",
|
||||
["26"] = "&",
|
||||
["27"] = "‘",
|
||||
["2B"] = "+",
|
||||
["2C"] = ",",
|
||||
["2F"] = "/",
|
||||
["3A"] = ":",
|
||||
["3B"] = ";",
|
||||
["3C"] = "<",
|
||||
["3D"] = "=",
|
||||
["3E"] = ">",
|
||||
["3F"] = "?",
|
||||
["40"] = "@",
|
||||
["5B"] = "[",
|
||||
["5C"] = "\\",
|
||||
["5D"] = "]",
|
||||
["5E"] = "^",
|
||||
["60"] = "`",
|
||||
["7B"] = "{",
|
||||
["7C"] = "|",
|
||||
["7D"] = "}",
|
||||
["7E"] = "~",
|
||||
}
|
||||
local _char_to_url_escape = {}
|
||||
for k, v in pairs(_url_escape_to_char) do
|
||||
_char_to_url_escape[v] = "%" .. k
|
||||
end
|
||||
-- TODO this uri escape handling is very incomplete
|
||||
|
||||
---@param string string
|
||||
---@return string
|
||||
M.url_escape = function(string)
|
||||
return (string:gsub(".", _url_escape_chars))
|
||||
return (string:gsub(".", _char_to_url_escape))
|
||||
end
|
||||
|
||||
---@param string string
|
||||
---@return string
|
||||
M.url_unescape = function(string)
|
||||
return (
|
||||
string:gsub("%%([0-9A-Fa-f][0-9A-Fa-f])", function(seq)
|
||||
return _url_escape_to_char[seq:upper()] or ("%" .. seq)
|
||||
end)
|
||||
)
|
||||
end
|
||||
|
||||
---@param bufnr integer
|
||||
|
|
|
|||
29
tests/util_spec.lua
Normal file
29
tests/util_spec.lua
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
local util = require("oil.util")
|
||||
describe("util", function()
|
||||
it("url_escape", function()
|
||||
local cases = {
|
||||
{ "foobar", "foobar" },
|
||||
{ "foo bar", "foo%20bar" },
|
||||
{ "/foo/bar", "%2Ffoo%2Fbar" },
|
||||
}
|
||||
for _, case in ipairs(cases) do
|
||||
local input, expected = unpack(case)
|
||||
local output = util.url_escape(input)
|
||||
assert.equals(expected, output)
|
||||
end
|
||||
end)
|
||||
|
||||
it("url_unescape", function()
|
||||
local cases = {
|
||||
{ "foobar", "foobar" },
|
||||
{ "foo%20bar", "foo bar" },
|
||||
{ "%2Ffoo%2Fbar", "/foo/bar" },
|
||||
{ "foo%%bar", "foo%%bar" },
|
||||
}
|
||||
for _, case in ipairs(cases) do
|
||||
local input, expected = unpack(case)
|
||||
local output = util.url_unescape(input)
|
||||
assert.equals(expected, output)
|
||||
end
|
||||
end)
|
||||
end)
|
||||
Loading…
Add table
Add a link
Reference in a new issue