From 6c956112daa43f609ca0f52adc9e9c6fcb6ae2ea Mon Sep 17 00:00:00 2001 From: Colton Padden Date: Wed, 21 Oct 2026 13:53:48 -0400 Subject: [PATCH] nvim: replace vendored image paste with external plugin --- .../nvim/lua/nvim-image-paste/README.md | 66 ----- .../nvim/lua/nvim-image-paste/init.lua | 223 --------------- vim/.config/nvim/lua/user/pack.lua | 9 + vim/.config/nvim/nvim-pack-lock.json | 4 + vim/.config/nvim/tests/nvim-image-paste.lua | 253 ------------------ 5 files changed, 13 insertions(+), 542 deletions(-) delete mode 100644 vim/.config/nvim/lua/nvim-image-paste/README.md delete mode 100644 vim/.config/nvim/lua/nvim-image-paste/init.lua delete mode 100644 vim/.config/nvim/tests/nvim-image-paste.lua diff --git a/vim/.config/nvim/lua/nvim-image-paste/README.md b/vim/.config/nvim/lua/nvim-image-paste/README.md deleted file mode 100644 index ec2f630..0000000 --- a/vim/.config/nvim/lua/nvim-image-paste/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# nvim-image-paste - -Paste clipboard images as temporary file paths. Implemented as a standalone Lua -module so it can later be extracted into a plugin. Requires Neovim 0.10+; -there is no dependency on Node or a Neovim plugin manager. - -## Usage - -The local config calls `require("nvim-image-paste").setup()` in `lua/user/keymap.lua`. - -| Mapping / command | Behavior | -| --- | --- | -| `Ctrl-V` (insert) | Paste clipboard image/file path or text at the cursor | -| `:ImagePaste` | Paste clipboard image/file path or text after the cursor | - -Ordinary `p`/`P`, normal-mode `Ctrl-V` (visual block), terminal-mode input, and -terminal bracketed paste are untouched. Insert-mode `Ctrl-V` replaces Vim's -literal-character entry shortcut; use `Ctrl-Q` for literal entry if the terminal -passes it through, or disable this mapping and choose your own. No leader mappings -are installed. - -To configure without installing mappings: - -```lua -require("nvim-image-paste").setup({ mappings = false, timeout = 5000 }) -vim.keymap.set({ "n", "x", "i" }, "", function() - require("nvim-image-paste").paste() -end) -``` - -`paste({ before = true })` uses `P` rather than `p` outside insert mode. -`read()` returns clipboard file/image path text (or `nil` for text fallback), -plus an error string if reading failed. It never changes the buffer or registers. -Commands are synchronous to keep insertion in the original buffer and selection; -clipboard reads are bounded by a one-second type-list timeout and a configurable -five-second data-read timeout. Backend errors warn and fall back to text. - -## What gets pasted - -- **macOS:** built-in `osascript` uses AppKit through JXA. Copied Finder files - insert their existing paths, one per line. Otherwise PNG clipboard data is saved; - TIFF (including screenshots) is converted to PNG. File URLs take priority over - image data, so Finder thumbnails do not become spurious image attachments. -- **Linux:** `wl-paste` (from `wl-clipboard`) on Wayland; `xclip` on X11. - Preferred image formats are PNG, JPEG, WebP, then GIF. If Wayland fails, X11 may - be tried; an empty/non-image Wayland clipboard never falls through to stale X11 - pixels. Unsupported formats are not converted. -- **Plain text:** uses Neovim's existing `+` clipboard provider, retaining normal - linewise/characterwise/blockwise put behavior. In insert mode it inserts text - at the cursor. -- **Image URL copied as text:** stays a URL. There is no network download. - Use the browser's **Copy Image** action, not **Copy Image Address**, to paste a - temporary image path. - -Image files live at `$TMPDIR/nvim-image-paste-XXXXXX/clipboard.` (the -platform's temp directory, not necessarily literal `/tmp`). Directories are 0700 -and files are 0600. They intentionally survive Neovim exit, so paths remain usable -when Neovim was an external editor for an agent. The OS may eventually purge them; -move images into your project if the reference needs to be permanent. This module -does not automatically delete successfully saved images, even when the paste is -undone. No images or runtime state are stored in the dotfiles repository. - -Windows/WSL-specific clipboard backends, remote clipboard image transport, Linux -file-manager URI lists, image previews, Markdown rendering, and URL downloads are -outside this initial version. Over SSH, clipboard access is to the host running -Neovim; OSC 52 does not transport image bytes for this module. diff --git a/vim/.config/nvim/lua/nvim-image-paste/init.lua b/vim/.config/nvim/lua/nvim-image-paste/init.lua deleted file mode 100644 index 41e0907..0000000 --- a/vim/.config/nvim/lua/nvim-image-paste/init.lua +++ /dev/null @@ -1,223 +0,0 @@ --- Clipboard files -> original paths; clipboard pixels -> persistent temporary file; --- otherwise use Neovim's normal system-clipboard paste. Requires Neovim 0.10+. -local M = {} -local uv = vim.uv or vim.loop -local timeout = 5000 - --- AppKit is available through the built-in JXA bridge; no pngpaste/Node dependency. --- Like Pi, prefer Finder file URLs over image representations (including thumbnails). -local macos_script = [[ -ObjC.import('AppKit'); -function run(argv) { - const pb = $.NSPasteboard.generalPasteboard; - const urls = pb.readObjectsForClassesOptions( - $.NSArray.arrayWithObject($.NSURL), - $.NSDictionary.dictionaryWithObjectForKey(true, $.NSPasteboardURLReadingFileURLsOnlyKey) - ); - const paths = []; - if (!urls.isNil()) { - for (let i = 0; i < urls.count; i++) paths.push(ObjC.unwrap(urls.objectAtIndex(i).path)); - } - if (paths.length) return JSON.stringify({paths: paths}); - const types = $.NSArray.arrayWithArray(['public.png', 'public.tiff']); - if (pb.availableTypeFromArray(types).isNil()) return '{}'; - let png = pb.dataForType($.NSPasteboardTypePNG); - if (png.isNil()) { - const image = $.NSImage.alloc.initWithPasteboard(pb); - if (image.isNil()) throw new Error('Could not decode clipboard image'); - const tiff = image.TIFFRepresentation; - if (tiff.isNil()) throw new Error('Could not decode clipboard TIFF'); - const bitmap = $.NSBitmapImageRep.imageRepWithData(tiff); - if (bitmap.isNil()) throw new Error('Could not decode clipboard bitmap'); - png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $({})); - } - if (png.isNil() || !png.length || !png.writeToFileAtomically(argv[0], true)) { - throw new Error('Could not write clipboard image'); - } - return JSON.stringify({image: true}); -} -]] - -local extensions = { - ["image/png"] = "png", - ["image/jpeg"] = "jpg", - ["image/webp"] = "webp", - ["image/gif"] = "gif", -} -local preferred_types = { "image/png", "image/jpeg", "image/webp", "image/gif" } - -local function run(argv, list) - local result = vim.system(argv, { text = false }):wait(list and 1000 or timeout) - if result.code ~= 0 then - error( - (result.stderr or ""):match("%S") and result.stderr - or "Clipboard command failed or timed out" - ) - end - return result.stdout or "" -end - -local function temporary_directory() - -- Do not use tempname(): Neovim deletes its temp directory on exit, invalidating - -- paths pasted into an external editor or sent to an agent after closing Neovim. - local dir, err = uv.fs_mkdtemp(uv.os_tmpdir() .. "/nvim-image-paste-XXXXXX") - assert(dir, err) - return dir -end - -local function save_image(bytes, extension) - local dir = temporary_directory() -- mkdtemp creates a private (0700) directory. - local path = dir .. "/clipboard." .. extension - local ok, err = pcall(function() - local fd, open_err = uv.fs_open(path, "wx", 384) -- 0600 - assert(fd, open_err) - local written, write_err = uv.fs_write(fd, bytes, 0) - uv.fs_close(fd) - assert(written == #bytes, write_err or "Incomplete image write") - end) - if not ok then - vim.fn.delete(dir, "rf") - error(err) - end - return path -end - -local function macos_clipboard() - local dir = temporary_directory() - local path = dir .. "/clipboard.png" - local ok, result = pcall(function() - local data = - vim.json.decode(run({ "osascript", "-l", "JavaScript", "-e", macos_script, path })) - if data.paths and #data.paths > 0 then - for _, file in ipairs(data.paths) do - assert(not file:find("%c"), "Clipboard file path contains control characters") - end - return table.concat(data.paths, "\n") - end - if data.image then - local stat = uv.fs_stat(path) - assert(stat and stat.size > 0, "Clipboard image is empty") - assert(uv.fs_chmod(path, 384)) - return path - end - end) - if not ok or result ~= path then - vim.fn.delete(dir, "rf") - end - if not ok then - error(result) - end - return result -end - -local function linux_image(list_command, read_command) - local types = vim.split(run(list_command, true), "\n", { trimempty = true }) - for _, preferred in ipairs(preferred_types) do - for _, raw_type in ipairs(types) do - raw_type = vim.trim(raw_type) - local base = vim.trim(raw_type:match("^[^;]+") or ""):lower() - if base == preferred then - local bytes = run(read_command(raw_type)) - if #bytes > 0 then - return save_image(bytes, extensions[base]) - end - return nil - end - end - end -end - ----Read a file path/image from the clipboard; nil means use the text clipboard. ----Returns text, error. Does not change registers or the current buffer. -function M.read() - local ok, result = pcall(function() - if vim.fn.has("macunix") == 1 then - return macos_clipboard() - end - if vim.fn.has("unix") ~= 1 then - return nil - end - if vim.env.WAYLAND_DISPLAY or vim.env.XDG_SESSION_TYPE == "wayland" then - if vim.fn.executable("wl-paste") == 1 then - -- Only fall back to X11 on a backend failure, never on an empty - -- Wayland clipboard (which could otherwise paste stale X11 pixels). - local success, image = pcall( - linux_image, - { "wl-paste", "--list-types" }, - function(mime) - return { "wl-paste", "--type", mime, "--no-newline" } - end - ) - if success then - return image - end - if not vim.env.DISPLAY or vim.fn.executable("xclip") ~= 1 then - error(image) - end - end - end - if vim.env.DISPLAY and vim.fn.executable("xclip") == 1 then - return linux_image( - { "xclip", "-selection", "clipboard", "-t", "TARGETS", "-o" }, - function(mime) - return { "xclip", "-selection", "clipboard", "-t", mime, "-o" } - end - ) - end - end) - if not ok then - return nil, tostring(result) - end - return result -end - ----Paste clipboard files/images, falling back to regular clipboard text. ----@param opts? {before?: boolean} -function M.paste(opts) - opts = opts or {} - if not vim.bo.modifiable then - vim.notify("nvim-image-paste: buffer is not modifiable", vim.log.levels.WARN) - return - end - local text, err = M.read() - if err then - vim.notify("nvim-image-paste: " .. err, vim.log.levels.WARN) - end - local insert = vim.api.nvim_get_mode().mode:sub(1, 1) == "i" - if insert then - local lines = text and vim.split(text, "\n", { plain = true }) - or vim.fn.getreg("+", 1, true) - vim.api.nvim_put(lines, "c", false, true) - elseif text then - -- Use a real put so normal/visual p/P retain their usual placement and - -- selection semantics. Restore scratch and unnamed registers afterwards. - local scratch, unnamed = vim.fn.getreginfo("z"), vim.fn.getreginfo('"') - vim.fn.setreg("z", text, "v") - local success, put_err = pcall(vim.cmd.normal, { - args = { '"z' .. (opts.before and "P" or "p") }, - bang = true, - }) - vim.fn.setreg("z", scratch) - vim.fn.setreg('"', unnamed) - if not success then - vim.notify("nvim-image-paste: " .. tostring(put_err), vim.log.levels.ERROR) - end - else - vim.cmd.normal({ args = { '"+' .. (opts.before and "P" or "p") }, bang = true }) - end -end - ----Register commands and optional clipboard mappings. Leaves normal p/P and Ctrl-V alone. ----@param opts? {mappings?: boolean, timeout?: integer} -function M.setup(opts) - opts = opts or {} - timeout = opts.timeout or 5000 - vim.api.nvim_create_user_command("ImagePaste", function() - M.paste() - end, { desc = "Paste clipboard image/file path or text", force = true }) - if opts.mappings ~= false then - vim.keymap.set("i", "", M.paste, { desc = "Paste clipboard image/file or text" }) - end -end - -return M diff --git a/vim/.config/nvim/lua/user/pack.lua b/vim/.config/nvim/lua/user/pack.lua index d49c6c6..a0e9ab8 100644 --- a/vim/.config/nvim/lua/user/pack.lua +++ b/vim/.config/nvim/lua/user/pack.lua @@ -43,6 +43,15 @@ vim.pack.add({ { src = github .. "tpope/vim-surround" }, }, { confirm = false }) +-- Load the development checkout directly so edits are immediately available. +-- Only install the GitHub package when the local module is absent. +local image_paste = vim.fn.expand("~/src/nvim-image-paste") +if vim.fn.filereadable(image_paste .. "/lua/nvim-image-paste/init.lua") == 1 then + vim.opt.runtimepath:prepend(image_paste) +else + vim.pack.add({ { src = github .. "cmpadden/nvim-image-paste" } }, { confirm = false }) +end + vim.api.nvim_create_user_command("PackUpdate", function() vim.pack.update() end, { desc = "Update all vim.pack plugins" }) diff --git a/vim/.config/nvim/nvim-pack-lock.json b/vim/.config/nvim/nvim-pack-lock.json index abd02e1..6b13754 100644 --- a/vim/.config/nvim/nvim-pack-lock.json +++ b/vim/.config/nvim/nvim-pack-lock.json @@ -33,6 +33,10 @@ "rev": "38af0ab56c6eb8757ee6006dd13ab79c2bc11a49", "src": "https://codeberg.org/andyg/leap.nvim" }, + "nvim-image-paste": { + "rev": "a7c7e4de201048dfee330e5c32e8669184096fbc", + "src": "https://github.com/cmpadden/nvim-image-paste" + }, "nvim-treesitter": { "rev": "5cb0114e6242625db56dd6440e945ed1ece10bc7", "src": "https://github.com/nvim-treesitter/nvim-treesitter" diff --git a/vim/.config/nvim/tests/nvim-image-paste.lua b/vim/.config/nvim/tests/nvim-image-paste.lua deleted file mode 100644 index f2c6614..0000000 --- a/vim/.config/nvim/tests/nvim-image-paste.lua +++ /dev/null @@ -1,253 +0,0 @@ --- Run from the repository root: --- nvim --headless -u NONE -i NONE -l vim/.config/nvim/tests/nvim-image-paste.lua -vim.opt.runtimepath:prepend(vim.fn.getcwd() .. "/vim/.config/nvim") -local image_copy = require("nvim-image-paste") -local uv = vim.uv -local original = { - system = vim.system, - has = vim.fn.has, - executable = vim.fn.executable, - tmpdir = uv.os_tmpdir, - read = image_copy.read, - notify = vim.notify, - wayland = vim.env.WAYLAND_DISPLAY, - session = vim.env.XDG_SESSION_TYPE, - display = vim.env.DISPLAY, -} -local root = vim.fn.tempname() -vim.fn.mkdir(root, "p") -uv.os_tmpdir = function() - return root -end -local count = 0 -local function equal(expected, actual) - assert(vim.deep_equal(expected, actual), vim.inspect({ expected = expected, actual = actual })) -end -local function test(name, callback) - vim.system = original.system - vim.fn.has = original.has - vim.fn.executable = original.executable - image_copy.read = original.read - vim.notify = original.notify - vim.env.WAYLAND_DISPLAY = nil - vim.env.XDG_SESSION_TYPE = nil - vim.env.DISPLAY = nil - vim.cmd("enew!") - vim.cmd.normal({ args = { "\027" }, bang = true }) - callback() - count = count + 1 - print("ok - " .. name) -end -local function linux(responses) - vim.fn.has = function(feature) - return feature == "unix" and 1 or 0 - end - vim.fn.executable = function() - return 1 - end - vim.env.WAYLAND_DISPLAY = "test-wayland" - vim.env.DISPLAY = ":test" - local index = 0 - vim.system = function(argv, opts) - index = index + 1 - local response = assert(responses[index], "Unexpected command: " .. vim.inspect(argv)) - equal(response.argv, argv) - equal(false, opts.text) - return { - wait = function(_, ms) - equal(response.list and 1000 or 5000, ms) - return { - code = response.code or 0, - stdout = response.stdout, - stderr = "test failure", - } - end, - } - end - return function() - equal(#responses, index) - end -end -local png = "\137PNG\r\n\026\n\0binary\255\r\n" -local function clipboard_image() - image_copy.read = function() - return "/tmp/test image.png" - end -end - -local ok, err = xpcall(function() - test("Wayland selects PNG, preserves binary bytes, and saves privately", function() - local done = linux({ - { - argv = { "wl-paste", "--list-types" }, - list = true, - stdout = "image/jpeg\nimage/png\n", - }, - { argv = { "wl-paste", "--type", "image/png", "--no-newline" }, stdout = png }, - }) - local path, read_err = image_copy.read() - assert(path, read_err) - local fd = assert(uv.fs_open(path, "r", 0)) - equal(png, uv.fs_read(fd, #png, 0)) - uv.fs_close(fd) - equal(384, uv.fs_stat(path).mode % 512) - equal(448, uv.fs_stat(vim.fs.dirname(path)).mode % 512) - done() - end) - test("empty Wayland clipboard never reads stale X11 contents", function() - local done = linux({ - { argv = { "wl-paste", "--list-types" }, list = true, stdout = "text/plain\n" }, - }) - equal(nil, image_copy.read()) - done() - end) - test("failed Wayland backend falls back to X11, preserving MIME parameters", function() - local done = linux({ - { argv = { "wl-paste", "--list-types" }, list = true, code = 1 }, - { - argv = { "xclip", "-selection", "clipboard", "-t", "TARGETS", "-o" }, - list = true, - stdout = "image/jpeg; foo=bar\n", - }, - { - argv = { "xclip", "-selection", "clipboard", "-t", "image/jpeg; foo=bar", "-o" }, - stdout = "jpeg bytes", - }, - }) - local path, read_err = image_copy.read() - assert(path, read_err) - assert(path:match("%.jpg$")) - done() - end) - test("backend failures return an error without leaving temp files", function() - local done = linux({ - { argv = { "wl-paste", "--list-types" }, list = true, code = 124 }, - }) - vim.env.DISPLAY = nil - local path, read_err = image_copy.read() - equal(nil, path) - assert(read_err:find("test failure", 1, true)) - done() - end) - test("normal image put preserves scratch and unnamed registers", function() - clipboard_image() - vim.api.nvim_buf_set_lines(0, 0, -1, false, { "ab" }) - vim.fn.setreg("z", "scratch", "V") - vim.fn.setreg('"', "unnamed", "v") - local z, unnamed = vim.fn.getreginfo("z"), vim.fn.getreginfo('"') - image_copy.paste() - equal({ "a/tmp/test image.pngb" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) - equal(z, vim.fn.getreginfo("z")) - equal(unnamed, vim.fn.getreginfo('"')) - end) - test("P inserts before the cursor", function() - clipboard_image() - vim.api.nvim_buf_set_lines(0, 0, -1, false, { "ab" }) - image_copy.paste({ before = true }) - equal({ "/tmp/test image.pngab" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) - end) - test("visual image put replaces the selection", function() - clipboard_image() - vim.api.nvim_buf_set_lines(0, 0, -1, false, { "abc" }) - vim.cmd.normal({ args = { "vl" }, bang = true }) - image_copy.paste() - equal({ "/tmp/test image.pngc" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) - end) - test("text fallback keeps linewise clipboard semantics", function() - vim.g.clipboard = { - name = "test", - copy = { ["+"] = function() end, ["*"] = function() end }, - paste = { - ["+"] = function() - return { { "clipboard text" }, "V" } - end, - ["*"] = function() - return { { "clipboard text" }, "V" } - end, - }, - } - image_copy.read = function() - return nil - end - vim.api.nvim_buf_set_lines(0, 0, -1, false, { "original" }) - image_copy.paste() - equal({ "original", "clipboard text" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) - end) - test("insert-mode Ctrl-V inserts a path at the cursor", function() - clipboard_image() - image_copy.setup() - vim.api.nvim_buf_set_lines(0, 0, -1, false, { "ab" }) - local keys = vim.api.nvim_replace_termcodes("a", true, false, true) - vim.api.nvim_feedkeys(keys, "xt", false) - equal({ "a/tmp/test image.pngb" }, vim.api.nvim_buf_get_lines(0, 0, -1, false)) - equal("", vim.fn.maparg("", "n")) - for _, mode in ipairs({ "n", "x" }) do - equal("", vim.fn.maparg("p", mode)) - equal("", vim.fn.maparg("P", mode)) - end - end) - test("unmodifiable buffer does not read the clipboard", function() - image_copy.read = function() - error("Must not read") - end - vim.notify = function() end - vim.bo.modifiable = false - image_copy.paste() - vim.bo.modifiable = true - end) - if original.has("macunix") == 1 then - -- Exercise the real AppKit/JXA bridge against a PRIVATE pasteboard, never - -- read or overwrite the user's system clipboard during these tests. - local fixture_png = - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - local fixtures = { - empty = "", - text = "pb.setStringForType('https://example.com/image.png', 'public.utf8-plain-text');", - png = "pb.setDataForType(data, 'public.png');", - tiff = "const image = $.NSImage.alloc.initWithData(data); pb.setDataForType(image.TIFFRepresentation, 'public.tiff');", - files = "pb.writeObjects($.NSArray.arrayWithObject($.NSURL.fileURLWithPath('/tmp/original image.png'))); pb.setDataForType(data, 'public.png');", - } - for name, fixture in pairs(fixtures) do - test("AppKit private pasteboard: " .. name, function() - vim.system = function(argv, opts) - local replacement = "const pb = $.NSPasteboard.pasteboardWithUniqueName;\n" - .. "const data = $.NSData.alloc.initWithBase64EncodedStringOptions('" - .. fixture_png - .. "', 0);\n" - .. fixture - argv[5] = argv[5]:gsub( - "const pb = %$%.NSPasteboard%.generalPasteboard;", - function() - return replacement - end - ) - return original.system(argv, opts) - end - local path, read_err = image_copy.read() - assert(not read_err, read_err) - if name == "empty" or name == "text" then - equal(nil, path) - elseif name == "files" then - equal("/tmp/original image.png", path) - else - assert(path and uv.fs_stat(path).size > 0) - equal(384, uv.fs_stat(path).mode % 512) - end - end) - end - end -end, debug.traceback) -vim.system = original.system -vim.fn.has = original.has -vim.fn.executable = original.executable -uv.os_tmpdir = original.tmpdir -vim.env.WAYLAND_DISPLAY = original.wayland -vim.env.XDG_SESSION_TYPE = original.session -vim.env.DISPLAY = original.display -vim.fn.delete(root, "rf") -if not ok then - io.stderr:write(err .. "\n") - vim.cmd("cquit 1") -end -print(string.format("Passed %d tests", count)) -vim.cmd("qa!")