From 4c9bdf0d839932617cdb25ed46a2f7bb1e090f77 Mon Sep 17 00:00:00 2001 From: Steve Walker <65963536+stevalkr@users.noreply.github.com> Date: Thu, 20 Mar 2025 23:19:18 +0800 Subject: [PATCH] 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 --- doc/oil.txt | 6 + lua/oil/actions.lua | 14 ++ lua/oil/clipboard.lua | 328 ++++++++++++++++++++++++++++++++++++++++++ lua/oil/init.lua | 13 +- lua/oil/util.lua | 72 ++++++---- tests/util_spec.lua | 29 ++++ 6 files changed, 427 insertions(+), 35 deletions(-) create mode 100644 lua/oil/clipboard.lua create mode 100644 tests/util_spec.lua diff --git a/doc/oil.txt b/doc/oil.txt index fea9bfd..fdc7ca5 100644 --- a/doc/oil.txt +++ b/doc/oil.txt @@ -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 diff --git a/lua/oil/actions.lua b/lua/oil/actions.lua index cef37c7..6315b68 100644 --- a/lua/oil/actions.lua +++ b/lua/oil/actions.lua @@ -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, diff --git a/lua/oil/clipboard.lua b/lua/oil/clipboard.lua new file mode 100644 index 0000000..32bcfdb --- /dev/null +++ b/lua/oil/clipboard.lua @@ -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("", 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 diff --git a/lua/oil/init.lua b/lua/oil/init.lua index ca55dc3..2c10b10 100644 --- a/lua/oil/init.lua +++ b/lua/oil/init.lua @@ -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 diff --git a/lua/oil/util.lua b/lua/oil/util.lua index 8f86b64..0ffebfc 100644 --- a/lua/oil/util.lua +++ b/lua/oil/util.lua @@ -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 diff --git a/tests/util_spec.lua b/tests/util_spec.lua new file mode 100644 index 0000000..3193842 --- /dev/null +++ b/tests/util_spec.lua @@ -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)