From 7270ee5021ac54f1b64293d20e6873e107a1c695 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 12 Sep 2026 16:48:59 +0200 Subject: [PATCH 1/4] Fix automatic Neovim yanks in remote sessions --- migrations/1788996284.sh | 9 ++++ test/shell.d/neovim-remote-yanks-test.sh | 52 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 migrations/1788996284.sh create mode 100755 test/shell.d/neovim-remote-yanks-test.sh diff --git a/migrations/1788996284.sh b/migrations/1788996284.sh new file mode 100644 index 00000000..e47894f8 --- /dev/null +++ b/migrations/1788996284.sh @@ -0,0 +1,9 @@ +echo "Enable automatic Neovim clipboard yanks in remote sessions" + +nvim_provider="$HOME/.config/nvim/lua/config/remote_clipboard.lua" + +if [[ -f $nvim_provider ]] && + grep -qF 'name = "OmarchyRemoteClipboard"' "$nvim_provider" && + ! grep -qF 'vim.opt.clipboard = "unnamedplus"' "$nvim_provider"; then + sed -i '/^ vim.g.clipboard = {/i\ -- LazyVim disables clipboard syncing over SSH; our provider supports it.\n vim.opt.clipboard = "unnamedplus"\n' "$nvim_provider" +fi diff --git a/test/shell.d/neovim-remote-yanks-test.sh b/test/shell.d/neovim-remote-yanks-test.sh new file mode 100755 index 00000000..94b2e7a9 --- /dev/null +++ b/test/shell.d/neovim-remote-yanks-test.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_home=$(mktemp -d) +trap 'rm -rf "$test_home"' EXIT +provider="$test_home/.config/nvim/lua/config/remote_clipboard.lua" +migration="$ROOT/migrations/1788996284.sh" + +env HOME="$test_home" bash -euo pipefail "$migration" +[[ ! -e $provider ]] || fail "missing Neovim config is left alone" +pass "missing Neovim config is left alone" + +mkdir -p "$(dirname "$provider")" +printf '%s\n' '-- Custom provider' >"$provider" +cp "$provider" "$test_home/before.lua" +env HOME="$test_home" bash -euo pipefail "$migration" +cmp "$provider" "$test_home/before.lua" || fail "custom provider is preserved" +pass "custom provider is preserved" + +cat >"$provider" <<'LUA' +local M = {} +function M.setup() + if not vim.env.SSH_CONNECTION then + return + end + vim.g.clipboard = { + name = "OmarchyRemoteClipboard", + } +end +return M +LUA +env HOME="$test_home" bash -euo pipefail "$migration" +require_command nvim +cat >"$test_home/check.lua" <<'LUA' +vim.env.SSH_CONNECTION = nil +vim.opt.clipboard = "" +dofile(vim.env.TEST_PROVIDER).setup() +assert(vim.o.clipboard == "") +vim.env.SSH_CONNECTION = "test" +dofile(vim.env.TEST_PROVIDER).setup() +assert(vim.o.clipboard == "unnamedplus") +LUA +env NVIM_LOG_FILE="$test_home/nvim.log" TEST_PROVIDER="$provider" nvim --clean -n --headless -i NONE -l "$test_home/check.lua" +pass "clipboard syncing is enabled inside remote provider setup" + +cp "$provider" "$test_home/once.lua" +env HOME="$test_home" bash -euo pipefail "$migration" +cmp "$provider" "$test_home/once.lua" || fail "migration is idempotent" +pass "migration is idempotent" From 90f00546582969a8d9359baf7271916e6aa374e1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 17:46:13 +0200 Subject: [PATCH 2/4] Upgrade known clipboard providers with backups and package version guard --- migrations/1788996284.sh | 35 ++- .../fixtures/neovim-clipboard/initial-fix.lua | 102 +++++++ .../fixtures/neovim-clipboard/july.lua | 99 +++++++ .../fixtures/neovim-clipboard/june.lua | 275 ++++++++++++++++++ .../fixtures/neovim-clipboard/parity-pr.lua | 179 ++++++++++++ test/shell.d/neovim-remote-yanks-test.sh | 90 +++--- 6 files changed, 734 insertions(+), 46 deletions(-) create mode 100644 test/shell.d/fixtures/neovim-clipboard/initial-fix.lua create mode 100644 test/shell.d/fixtures/neovim-clipboard/july.lua create mode 100644 test/shell.d/fixtures/neovim-clipboard/june.lua create mode 100644 test/shell.d/fixtures/neovim-clipboard/parity-pr.lua diff --git a/migrations/1788996284.sh b/migrations/1788996284.sh index e47894f8..5a943728 100644 --- a/migrations/1788996284.sh +++ b/migrations/1788996284.sh @@ -1,9 +1,34 @@ -echo "Enable automatic Neovim clipboard yanks in remote sessions" +echo "Repair remote Neovim clipboard yanks and paste" nvim_provider="$HOME/.config/nvim/lua/config/remote_clipboard.lua" +provider_source="/usr/share/omarchy-nvim/config/lua/config/remote_clipboard.lua" -if [[ -f $nvim_provider ]] && - grep -qF 'name = "OmarchyRemoteClipboard"' "$nvim_provider" && - ! grep -qF 'vim.opt.clipboard = "unnamedplus"' "$nvim_provider"; then - sed -i '/^ vim.g.clipboard = {/i\ -- LazyVim disables clipboard syncing over SSH; our provider supports it.\n vim.opt.clipboard = "unnamedplus"\n' "$nvim_provider" +[[ -f $nvim_provider ]] || exit 0 + +# Replace only known Omarchy versions, including the June file-backed provider +# and the two earlier proposed fixes. Preserve all user-authored changes. +provider_hash=$(sha256sum "$nvim_provider") +case ${provider_hash%% *} in + c0c15941ed7cf97a1a3d3c60f0b13875e85f641bdb7431c3283e6f86fbc95f4e|bac8268c3dde4e747772467d6671e626aa4982478551d8fc8ebe1259a4854f2c|7f012a235c05e3c559b48211c1534af2f4c919750b76db88ec3d5b7814259d30|8b371d0f271e522b27696f1b71083d0a6ba7896cd3ff7b0a938b7327fcb06a46) ;; + *) + if [[ -f $provider_source ]] && cmp -s "$nvim_provider" "$provider_source"; then + exit 0 + fi + echo "Preserving customized Neovim provider: $nvim_provider" + echo "Review remote clipboard settings manually, or use omarchy-nvim-refresh to reset them." + exit 0 + ;; +esac + +# Updates install packages before migrations. Refuse an older package so this +# migration remains pending instead of installing the provider it must repair. +nvim_package=$(pacman -Q omarchy-nvim) +if [[ $(vercmp "${nvim_package#* }" "2026.8.13-2") == -* ]] || [[ ! -f $provider_source ]]; then + echo "Update omarchy-nvim to 2026.8.13-2 or newer before rerunning this migration." >&2 + exit 1 fi + +provider_backup=$(mktemp "$nvim_provider.bak.XXXXXX") +cp -p "$nvim_provider" "$provider_backup" +install -m 0644 "$provider_source" "$nvim_provider" +echo "Previous Neovim provider saved to $provider_backup" diff --git a/test/shell.d/fixtures/neovim-clipboard/initial-fix.lua b/test/shell.d/fixtures/neovim-clipboard/initial-fix.lua new file mode 100644 index 00000000..5f3248a6 --- /dev/null +++ b/test/shell.d/fixtures/neovim-clipboard/initial-fix.lua @@ -0,0 +1,102 @@ +-- Clipboard for sessions whose yanks may need to reach another machine: +-- every copy is emitted as OSC 52 (inside tmux this becomes a tmux buffer, +-- rebroadcast to every attached client, local or SSH). Paste prefers the +-- local Wayland clipboard when one is available, so content copied in other +-- apps remains pasteable; without a display, paste is an OSC 52 query that +-- tmux (or the terminal) answers. +local M = {} + +local function proc_lines(pid, file) + local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file) + return ok and lines or {} +end + +local function proc_ppid(pid) + for _, line in ipairs(proc_lines(pid, "status")) do + local ppid = line:match("^PPid:%s+(%d+)") + if ppid then + return tonumber(ppid) + end + end +end + +local function ancestor_process_named(name) + local pid = vim.fn.getpid() + + for _ = 1, 16 do + local ppid = proc_ppid(pid) + if not ppid or ppid <= 1 then + return false + end + + local comm = proc_lines(ppid, "comm")[1] or "" + if comm:find(name, 1, true) then + return true + end + + pid = ppid + end + + return false +end + +function M.setup() + local in_tmux = vim.env.TMUX ~= nil + local in_ssh = vim.env.SSH_TTY ~= nil or vim.env.SSH_CONNECTION ~= nil + local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr") + + if not (in_tmux or in_ssh or in_herdr) then + return + end + + local osc52 = require("vim.ui.clipboard.osc52") + local has_wayland = vim.env.WAYLAND_DISPLAY ~= nil + and vim.fn.executable("wl-copy") == 1 + and vim.fn.executable("wl-paste") == 1 + + local function copy(register) + local emit = osc52.copy(register) + + return function(lines) + if has_wayland then + local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" } + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + vim.fn.system(cmd, lines) + end + + if vim.g.omarchy_remote_clipboard_osc52 ~= false then + emit(lines) + end + end + end + + local function paste(register) + if not has_wayland then + return osc52.paste(register) + end + + return function() + local cmd = { "wl-paste", "--no-newline" } + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + + local lines = vim.fn.systemlist(cmd, "", 1) + return vim.v.shell_error == 0 and lines or {} + end + end + + -- LazyVim disables clipboard syncing over SSH; our provider supports it. + vim.opt.clipboard = "unnamedplus" + + vim.g.clipboard = { + name = "OmarchyRemoteClipboard", + copy = { ["+"] = copy("+"), ["*"] = copy("*") }, + paste = { ["+"] = paste("+"), ["*"] = paste("*") }, + cache_enabled = 0, + } +end + +return M diff --git a/test/shell.d/fixtures/neovim-clipboard/july.lua b/test/shell.d/fixtures/neovim-clipboard/july.lua new file mode 100644 index 00000000..b54c54e6 --- /dev/null +++ b/test/shell.d/fixtures/neovim-clipboard/july.lua @@ -0,0 +1,99 @@ +-- Clipboard for sessions whose yanks may need to reach another machine: +-- every copy is emitted as OSC 52 (inside tmux this becomes a tmux buffer, +-- rebroadcast to every attached client, local or SSH). Paste prefers the +-- local Wayland clipboard when one is available, so content copied in other +-- apps remains pasteable; without a display, paste is an OSC 52 query that +-- tmux (or the terminal) answers. +local M = {} + +local function proc_lines(pid, file) + local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file) + return ok and lines or {} +end + +local function proc_ppid(pid) + for _, line in ipairs(proc_lines(pid, "status")) do + local ppid = line:match("^PPid:%s+(%d+)") + if ppid then + return tonumber(ppid) + end + end +end + +local function ancestor_process_named(name) + local pid = vim.fn.getpid() + + for _ = 1, 16 do + local ppid = proc_ppid(pid) + if not ppid or ppid <= 1 then + return false + end + + local comm = proc_lines(ppid, "comm")[1] or "" + if comm:find(name, 1, true) then + return true + end + + pid = ppid + end + + return false +end + +function M.setup() + local in_tmux = vim.env.TMUX ~= nil + local in_ssh = vim.env.SSH_TTY ~= nil or vim.env.SSH_CONNECTION ~= nil + local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr") + + if not (in_tmux or in_ssh or in_herdr) then + return + end + + local osc52 = require("vim.ui.clipboard.osc52") + local has_wayland = vim.env.WAYLAND_DISPLAY ~= nil + and vim.fn.executable("wl-copy") == 1 + and vim.fn.executable("wl-paste") == 1 + + local function copy(register) + local emit = osc52.copy(register) + + return function(lines) + if has_wayland then + local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" } + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + vim.fn.system(cmd, lines) + end + + if vim.g.omarchy_remote_clipboard_osc52 ~= false then + emit(lines) + end + end + end + + local function paste(register) + if not has_wayland then + return osc52.paste(register) + end + + return function() + local cmd = { "wl-paste", "--no-newline" } + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + + local lines = vim.fn.systemlist(cmd, "", 1) + return vim.v.shell_error == 0 and lines or {} + end + end + + vim.g.clipboard = { + name = "OmarchyRemoteClipboard", + copy = { ["+"] = copy("+"), ["*"] = copy("*") }, + paste = { ["+"] = paste("+"), ["*"] = paste("*") }, + cache_enabled = 0, + } +end + +return M diff --git a/test/shell.d/fixtures/neovim-clipboard/june.lua b/test/shell.d/fixtures/neovim-clipboard/june.lua new file mode 100644 index 00000000..c43aa234 --- /dev/null +++ b/test/shell.d/fixtures/neovim-clipboard/june.lua @@ -0,0 +1,275 @@ +local M = {} +local uv = vim.uv or vim.loop + +local function secure_runtime_dir() + local uid = uv.getuid() + local candidates = {} + + local function add(path) + if path and path ~= "" then + candidates[#candidates + 1] = path + end + end + + add(vim.env.XDG_RUNTIME_DIR) + if uid then + add("/run/user/" .. uid) + add("/dev/shm/nvim-remote-clipboard-" .. uid) + end + + for _, dir in ipairs(candidates) do + if dir:sub(1, 9) == "/dev/shm/" then + pcall(vim.fn.mkdir, dir, "p", 448) + pcall(vim.fn.setfperm, dir, "rwx------") + end + + local stat = uv.fs_stat(dir) + if stat and stat.type == "directory" and stat.uid == uid and stat.mode % 512 == 448 then + return dir + end + end +end + +local function wayland_connection(runtime_dir) + if vim.fn.executable("wl-copy") ~= 1 or vim.fn.executable("wl-paste") ~= 1 then + return nil + end + + local function valid_socket(path) + local stat = uv.fs_stat(path) + return stat and stat.type == "socket" + end + + if vim.env.WAYLAND_DISPLAY and vim.env.WAYLAND_DISPLAY ~= "" then + local display = vim.env.WAYLAND_DISPLAY + local socket_path = display:sub(1, 1) == "/" and display or (runtime_dir .. "/" .. display) + if valid_socket(socket_path) then + return display + end + end + + for _, path in ipairs(vim.fn.glob(runtime_dir .. "/wayland-*", false, true)) do + local display = vim.fn.fnamemodify(path, ":t") + if not display:match("%.lock$") and valid_socket(path) then + return display + end + end +end + +local function write_private_file(path, payload) + local tmp_path = path .. "." .. vim.fn.getpid() .. ".tmp" + + vim.fn.writefile({ payload }, tmp_path, "b") + vim.fn.setfperm(tmp_path, "rw-------") + vim.fn.rename(tmp_path, path) + vim.fn.setfperm(path, "rw-------") +end + +local function proc_lines(pid, file) + local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file) + return ok and lines or {} +end + +local function proc_ppid(pid) + for _, line in ipairs(proc_lines(pid, "status")) do + local ppid = line:match("^PPid:%s+(%d+)") + if ppid then + return tonumber(ppid) + end + end +end + +local function ancestor_process_named(name) + local pid = vim.fn.getpid() + + for _ = 1, 16 do + local ppid = proc_ppid(pid) + if not ppid or ppid <= 1 then + return false + end + + local comm = proc_lines(ppid, "comm")[1] or "" + if comm:find(name, 1, true) then + return true + end + + pid = ppid + end + + return false +end + +local function copy_to_client_clipboard(register, lines) + if vim.g.omarchy_remote_clipboard_osc52 == false then + return + end + + local ok, osc52 = pcall(require, "vim.ui.clipboard.osc52") + if ok then + pcall(osc52.copy(register), lines) + end +end + +function M.setup() + local in_tmux = vim.env.TMUX ~= nil + local has_display = vim.env.WAYLAND_DISPLAY or vim.env.DISPLAY + local in_remote_session = vim.env.SSH_TTY or vim.env.SSH_CONNECTION + local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr") + local needs_remote_clipboard = not in_tmux and (in_herdr or (in_remote_session and not has_display)) + + if not needs_remote_clipboard then + return + end + + local runtime_dir = secure_runtime_dir() + if not runtime_dir then + return + end + + local wayland_display = wayland_connection(runtime_dir) + local ttl_seconds = 10 * 60 + + local function empty_clipboard() + return { {}, "v" } + end + + local function read_regtype(path) + local stat = uv.fs_stat(path) + if not stat or stat.uid ~= uv.getuid() or stat.mode % 512 ~= 384 then + return "v" + end + + local ok, file_lines = pcall(vim.fn.readfile, path) + if not ok or #file_lines == 0 then + return "v" + end + + local ok_decode, data = pcall(vim.fn.json_decode, table.concat(file_lines, "\n")) + if not ok_decode or type(data) ~= "table" then + return "v" + end + + if type(data.created_at) == "number" and os.time() - data.created_at > ttl_seconds then + vim.fn.delete(path) + return "v" + end + + return type(data.regtype) == "string" and data.regtype or "v" + end + + if wayland_display then + local regtype_path = runtime_dir .. "/nvim-remote-clipboard-regtype.json" + local env = { + "env", + "XDG_RUNTIME_DIR=" .. runtime_dir, + "WAYLAND_DISPLAY=" .. wayland_display, + } + + local function write_regtype(regtype) + write_private_file(regtype_path, vim.fn.json_encode({ + regtype = regtype, + created_at = os.time(), + })) + end + + local function copy(register) + return function(lines, regtype) + write_regtype(regtype) + + local cmd = vim.list_extend(vim.deepcopy(env), { + "wl-copy", + "--sensitive", + "--type", + "text/plain", + }) + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + + vim.fn.system(cmd, lines) + copy_to_client_clipboard(register, lines) + end + end + + local function paste(register) + return function() + local cmd = vim.list_extend(vim.deepcopy(env), { "wl-paste", "--no-newline" }) + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + + local lines = vim.fn.systemlist(cmd, "", 1) + if vim.v.shell_error ~= 0 then + return empty_clipboard() + end + + return { lines, read_regtype(regtype_path) } + end + end + + vim.g.clipboard = { + name = "OmarchyWaylandClipboard", + copy = { + ["+"] = copy("+"), + ["*"] = copy("*"), + }, + paste = { + ["+"] = paste("+"), + ["*"] = paste("*"), + }, + cache_enabled = 0, + } + else + local clipboard_path = runtime_dir .. "/nvim-remote-clipboard.json" + + local function copy(register) + return function(lines, regtype) + write_private_file(clipboard_path, vim.fn.json_encode({ + lines = lines, + regtype = regtype, + created_at = os.time(), + })) + copy_to_client_clipboard(register, lines) + end + end + + local function read_clipboard() + local stat = uv.fs_stat(clipboard_path) + if not stat or stat.uid ~= uv.getuid() or stat.mode % 512 ~= 384 then + return empty_clipboard() + end + + local ok, file_lines = pcall(vim.fn.readfile, clipboard_path) + if not ok or #file_lines == 0 then + return empty_clipboard() + end + + local ok_decode, data = pcall(vim.fn.json_decode, table.concat(file_lines, "\n")) + if not ok_decode or type(data) ~= "table" or type(data.lines) ~= "table" then + return empty_clipboard() + end + + if type(data.created_at) == "number" and os.time() - data.created_at > ttl_seconds then + vim.fn.delete(clipboard_path) + return empty_clipboard() + end + + return { data.lines, data.regtype or "v" } + end + + vim.g.clipboard = { + name = "RemoteRuntimeClipboard", + copy = { + ["+"] = copy("+"), + ["*"] = copy("*"), + }, + paste = { + ["+"] = read_clipboard, + ["*"] = read_clipboard, + }, + cache_enabled = 0, + } + end +end + +return M diff --git a/test/shell.d/fixtures/neovim-clipboard/parity-pr.lua b/test/shell.d/fixtures/neovim-clipboard/parity-pr.lua new file mode 100644 index 00000000..9ee98227 --- /dev/null +++ b/test/shell.d/fixtures/neovim-clipboard/parity-pr.lua @@ -0,0 +1,179 @@ +-- Clipboard for sessions whose yanks may need to reach another machine, so that +-- yanking, deleting and putting behave the same whether Neovim is local, in +-- tmux, over SSH, or any combination of those. LazyVim empties 'clipboard' +-- whenever SSH_CONNECTION is set, which leaves a plain `y` in the unnamed +-- register; this restores 'unnamedplus' and backs it with a provider that has a +-- fast path in every one of those environments. +-- +-- Writes go to whichever transports the session has: the local Wayland +-- clipboard when there is a display, and the terminal on the other end of the +-- connection via OSC 52 -- emitted by tmux itself where possible, since `tmux +-- load-buffer -w` keeps the payload out of tmux's own input parser and its size +-- limit. Reads prefer whichever clipboard the person is actually sitting in +-- front of, and fall back to the last value this Neovim copied rather than +-- blocking on an OSC 52 query that most terminals refuse to answer. +local M = {} + +local function proc_lines(pid, file) + local ok, lines = pcall(vim.fn.readfile, "/proc/" .. pid .. "/" .. file) + return ok and lines or {} +end + +local function proc_ppid(pid) + for _, line in ipairs(proc_lines(pid, "status")) do + local ppid = line:match("^PPid:%s+(%d+)") + if ppid then + return tonumber(ppid) + end + end +end + +local function ancestor_process_named(name) + local pid = vim.fn.getpid() + + for _ = 1, 16 do + local ppid = proc_ppid(pid) + if not ppid or ppid <= 1 then + return false + end + + local comm = proc_lines(ppid, "comm")[1] or "" + if comm:find(name, 1, true) then + return true + end + + pid = ppid + end + + return false +end + +local function empty(lines) + return lines == nil or #lines == 0 or (#lines == 1 and lines[1] == "") +end + +function M.setup() + local in_tmux = vim.env.TMUX ~= nil + local in_ssh = vim.env.SSH_TTY ~= nil or vim.env.SSH_CONNECTION ~= nil + local in_herdr = vim.env.HERDR_PANE_ID ~= nil or ancestor_process_named("herdr") + + if not (in_tmux or in_ssh or in_herdr) then + return + end + + local osc52 = require("vim.ui.clipboard.osc52") + local has_wayland = vim.env.WAYLAND_DISPLAY ~= nil + and vim.fn.executable("wl-copy") == 1 + and vim.fn.executable("wl-paste") == 1 + local has_tmux = in_tmux and vim.fn.executable("tmux") == 1 + + -- The last value this Neovim put on the clipboard, per register, as + -- { lines, regtype }. A session with no readable clipboard still has to + -- answer a put, and answering it with our own last copy keeps `"+y` `"+p` + -- working instead of failing with E353. + local last_copy = {} + + local function copy(register) + local emit = osc52.copy(register) + + return function(lines, regtype) + last_copy[register] = { lines, regtype or "" } + + if has_wayland then + local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" } + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + vim.fn.system(cmd, lines) + end + + if vim.g.omarchy_remote_clipboard_osc52 == false then + return + end + + -- tmux has no notion of a primary selection, so `*` keeps using the + -- escape sequence directly. + if has_tmux and register == "+" then + vim.fn.system({ "tmux", "load-buffer", "-w", "-" }, lines) + else + emit(lines) + end + end + end + + local function read_wayland(register) + local cmd = { "wl-paste", "--no-newline" } + if register == "*" then + cmd[#cmd + 1] = "--primary" + end + + local lines = vim.fn.systemlist(cmd, "", 1) + if vim.v.shell_error == 0 then + return lines + end + end + + -- `refresh-client -l` asks the attached client for its clipboard; the short + -- sleep gives the reply time to land before the buffer is read. A terminal + -- that refuses simply leaves tmux's newest buffer in place, so this returns + -- something useful either way and never blocks the way an OSC 52 query does. + local function read_tmux() + local lines = vim.fn.systemlist( + { "sh", "-c", "tmux refresh-client -l 2>/dev/null; sleep 0.05; tmux save-buffer -" }, + "", + 1 + ) + if vim.v.shell_error == 0 then + return lines + end + end + + local function read_osc52(register) + if not (vim.g.termfeatures or {}).osc52 then + return + end + + local result = osc52.paste(register)() + if type(result) == "table" then + return result + end + end + + local function paste(register) + return function() + local lines + + -- Over SSH the clipboard worth reading is the one on the machine the + -- person is sitting at, which is the far end of the connection, not the + -- display this Neovim happens to have. + if in_ssh and has_tmux then + lines = read_tmux() + elseif has_wayland then + lines = read_wayland(register) + elseif has_tmux then + lines = read_tmux() + else + lines = read_osc52(register) + end + + if not empty(lines) then + return lines + end + + return last_copy[register] or { vim.fn.getreg('"', 1, true), vim.fn.getregtype('"') } + end + end + + vim.g.clipboard = { + name = "OmarchyRemoteClipboard", + copy = { ["+"] = copy("+"), ["*"] = copy("*") }, + paste = { ["+"] = paste("+"), ["*"] = paste("*") }, + cache_enabled = 0, + } + + -- LazyVim empties this whenever SSH_CONNECTION is set. Restore it so yanking, + -- deleting and putting reach the clipboard exactly as they do locally. + vim.opt.clipboard = "unnamedplus" +end + +return M diff --git a/test/shell.d/neovim-remote-yanks-test.sh b/test/shell.d/neovim-remote-yanks-test.sh index 94b2e7a9..d96e4621 100755 --- a/test/shell.d/neovim-remote-yanks-test.sh +++ b/test/shell.d/neovim-remote-yanks-test.sh @@ -1,52 +1,60 @@ #!/bin/bash - set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" test_home=$(mktemp -d) trap 'rm -rf "$test_home"' EXIT provider="$test_home/.config/nvim/lua/config/remote_clipboard.lua" -migration="$ROOT/migrations/1788996284.sh" +mkdir -p "$(dirname "$provider")" "$test_home/bin" +# Redirect only the packaged source path; run the actual migration logic. +sed "s@/usr/share/omarchy-nvim/config/lua/config/remote_clipboard.lua@$test_home/package.lua@" \ + "$ROOT/migrations/1788996284.sh" >"$test_home/migration.sh" +printf '%s\n' '-- corrected packaged provider' >"$test_home/package.lua" +cat >"$test_home/bin/pacman" <<'STUB' +#!/bin/bash +[[ $* == '-Q omarchy-nvim' ]] || exit 1 +printf 'omarchy-nvim %s\n' "${TEST_NVIM_VERSION:-2026.8.13-2}" +STUB +chmod +x "$test_home/bin/pacman" +run_migration() { + env HOME="$test_home" PATH="$test_home/bin:$PATH" bash -euo pipefail "$test_home/migration.sh" +} -env HOME="$test_home" bash -euo pipefail "$migration" -[[ ! -e $provider ]] || fail "missing Neovim config is left alone" -pass "missing Neovim config is left alone" +run_migration +[[ ! -e $provider ]] || fail "missing provider is left alone" +pass "missing provider is left alone" -mkdir -p "$(dirname "$provider")" -printf '%s\n' '-- Custom provider' >"$provider" -cp "$provider" "$test_home/before.lua" -env HOME="$test_home" bash -euo pipefail "$migration" -cmp "$provider" "$test_home/before.lua" || fail "custom provider is preserved" -pass "custom provider is preserved" +for fixture in "$SHELL_TEST_DIR/fixtures/neovim-clipboard/"*.lua; do + cp "$fixture" "$provider" + run_migration + cmp "$provider" "$test_home/package.lua" || fail "known provider is upgraded: $fixture" + backup=$(ls -t "$provider".bak.* | head -n1) + cmp "$backup" "$fixture" || fail "known provider is backed up: $fixture" + [[ $(stat -c %a "$provider") == "644" ]] || fail "provider is mode 0644" + before=$(ls "$provider".bak.*) + run_migration + [[ $(ls "$provider".bak.*) == "$before" ]] || fail "repeat migration does not create backups" + pass "known provider is backed up and upgraded idempotently: ${fixture##*/}" +done -cat >"$provider" <<'LUA' -local M = {} -function M.setup() - if not vim.env.SSH_CONNECTION then - return - end - vim.g.clipboard = { - name = "OmarchyRemoteClipboard", - } -end -return M -LUA -env HOME="$test_home" bash -euo pipefail "$migration" -require_command nvim -cat >"$test_home/check.lua" <<'LUA' -vim.env.SSH_CONNECTION = nil -vim.opt.clipboard = "" -dofile(vim.env.TEST_PROVIDER).setup() -assert(vim.o.clipboard == "") -vim.env.SSH_CONNECTION = "test" -dofile(vim.env.TEST_PROVIDER).setup() -assert(vim.o.clipboard == "unnamedplus") -LUA -env NVIM_LOG_FILE="$test_home/nvim.log" TEST_PROVIDER="$provider" nvim --clean -n --headless -i NONE -l "$test_home/check.lua" -pass "clipboard syncing is enabled inside remote provider setup" +cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$provider" +printf '%s\n' '-- user customization' >>"$provider" +cp "$provider" "$test_home/custom.lua" +run_migration >"$test_home/output" +cmp "$provider" "$test_home/custom.lua" || fail "customized provider is preserved" +grep -q 'Preserving customized' "$test_home/output" || fail "customized provider receives guidance" +printf '%s\n' '-- unrelated provider' >"$provider" +cp "$provider" "$test_home/custom.lua" +run_migration +cmp "$provider" "$test_home/custom.lua" || fail "unrelated provider is preserved" +pass "customized and unrelated providers are preserved" -cp "$provider" "$test_home/once.lua" -env HOME="$test_home" bash -euo pipefail "$migration" -cmp "$provider" "$test_home/once.lua" || fail "migration is idempotent" -pass "migration is idempotent" +cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" "$provider" +export TEST_NVIM_VERSION=2026.8.13-1 +if run_migration; then fail "old package leaves migration pending"; fi +cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "old package leaves provider unchanged" +unset TEST_NVIM_VERSION +mv "$test_home/package.lua" "$test_home/package.saved" +if run_migration; then fail "missing package source leaves migration pending"; fi +cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "missing source leaves provider unchanged" +pass "old or missing package cannot mark an unrepaired provider complete" From 905ff1f79228e11a47bcf3b8731335d032ad355e Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 13 Sep 2026 19:48:41 -0400 Subject: [PATCH 3/4] Replace Neovim providers atomically and preserve dotfile links --- migrations/1788996284.sh | 19 ++++++++- test/shell.d/neovim-remote-yanks-test.sh | 51 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/migrations/1788996284.sh b/migrations/1788996284.sh index 5a943728..f2326812 100644 --- a/migrations/1788996284.sh +++ b/migrations/1788996284.sh @@ -3,6 +3,18 @@ echo "Repair remote Neovim clipboard yanks and paste" nvim_provider="$HOME/.config/nvim/lua/config/remote_clipboard.lua" provider_source="/usr/share/omarchy-nvim/config/lua/config/remote_clipboard.lua" +# Dotfile managers may own either the file or a parent directory through a +# symlink. Preserve that layout rather than detaching or editing its target. +provider_path="$nvim_provider" +while [[ $provider_path != "$HOME" && $provider_path != / ]]; do + if [[ -L $provider_path ]]; then + echo "Preserving symlink-managed Neovim provider: $nvim_provider" + echo "Review remote clipboard settings in your dotfile configuration manually." + exit 0 + fi + provider_path=$(dirname "$provider_path") +done + [[ -f $nvim_provider ]] || exit 0 # Replace only known Omarchy versions, including the June file-backed provider @@ -30,5 +42,10 @@ fi provider_backup=$(mktemp "$nvim_provider.bak.XXXXXX") cp -p "$nvim_provider" "$provider_backup" -install -m 0644 "$provider_source" "$nvim_provider" +provider_staged=$(mktemp "$nvim_provider.new.XXXXXX") +trap 'rm -f -- "$provider_staged"' EXIT +install -m 0644 "$provider_source" "$provider_staged" +# A failed write leaves the recognized original live, so retries can repair it. +# The temporary file is on the same filesystem for an atomic replacement. +mv -fT -- "$provider_staged" "$nvim_provider" echo "Previous Neovim provider saved to $provider_backup" diff --git a/test/shell.d/neovim-remote-yanks-test.sh b/test/shell.d/neovim-remote-yanks-test.sh index d96e4621..84a0c721 100755 --- a/test/shell.d/neovim-remote-yanks-test.sh +++ b/test/shell.d/neovim-remote-yanks-test.sh @@ -58,3 +58,54 @@ mv "$test_home/package.lua" "$test_home/package.saved" if run_migration; then fail "missing package source leaves migration pending"; fi cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "missing source leaves provider unchanged" pass "old or missing package cannot mark an unrepaired provider complete" + +mv "$test_home/package.saved" "$test_home/package.lua" +# A write that fails after producing partial output must never damage the live +# provider. Exercise the real install destination with a failing replacement. +cat >"$test_home/bin/install" <<'STUB' +#!/bin/bash +printf '%s' '-- truncated replacement' >"${@: -1}" +exit 1 +STUB +chmod +x "$test_home/bin/install" +if run_migration; then fail "failed staging write leaves migration pending"; fi +cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "failed write preserves live provider" +if compgen -G "$provider.new.*" >/dev/null; then fail "failed staging file is cleaned up"; fi +rm "$test_home/bin/install" +run_migration +cmp "$provider" "$test_home/package.lua" || fail "retry repairs the recognized original" +pass "failed write preserves original and retry succeeds" + +# Also fail the final rename, after successful staging. +cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$provider" +printf '#!/bin/bash\nexit 1\n' >"$test_home/bin/mv" +chmod +x "$test_home/bin/mv" +if run_migration; then fail "failed rename leaves migration pending"; fi +cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" || fail "failed rename preserves original" +if compgen -G "$provider.new.*" >/dev/null; then fail "failed rename staging file is cleaned up"; fi +rm "$test_home/bin/mv" +run_migration +cmp "$provider" "$test_home/package.lua" || fail "retry after rename failure succeeds" +pass "failed rename preserves original and retry succeeds" + +cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$test_home/dotfile.lua" +rm "$provider" +ln -s "$test_home/dotfile.lua" "$provider" +run_migration >"$test_home/output" +[[ -L $provider ]] || fail "provider symlink is preserved" +cmp "$test_home/dotfile.lua" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" || fail "symlink target is preserved" +grep -q 'symlink-managed' "$test_home/output" || fail "linked provider receives guidance" +rm "$provider" +ln -s "$test_home/absent.lua" "$provider" +run_migration +[[ -L $provider && ! -e $provider ]] || fail "dangling symlink is preserved" +rm "$provider" + +mkdir "$test_home/dotfiles" +mv "$test_home/.config/nvim/lua/config" "$test_home/dotfiles/config" +ln -s "$test_home/dotfiles/config" "$test_home/.config/nvim/lua/config" +cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" "$provider" +run_migration +[[ -L $test_home/.config/nvim/lua/config ]] || fail "linked config directory is preserved" +cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/july.lua" || fail "provider in linked directory is preserved" +pass "linked files, dangling links and linked directories are preserved" From 289f36ab97426d4ef59be9b16e273c1fb2de6feb Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Fri, 25 Sep 2026 15:35:10 -0400 Subject: [PATCH 4/4] Require the corrected Neovim package before clipboard migration --- migrations/1788996284.sh | 4 ++-- test/shell.d/neovim-remote-yanks-test.sh | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/migrations/1788996284.sh b/migrations/1788996284.sh index f2326812..c72093bc 100644 --- a/migrations/1788996284.sh +++ b/migrations/1788996284.sh @@ -35,8 +35,8 @@ esac # Updates install packages before migrations. Refuse an older package so this # migration remains pending instead of installing the provider it must repair. nvim_package=$(pacman -Q omarchy-nvim) -if [[ $(vercmp "${nvim_package#* }" "2026.8.13-2") == -* ]] || [[ ! -f $provider_source ]]; then - echo "Update omarchy-nvim to 2026.8.13-2 or newer before rerunning this migration." >&2 +if [[ $(vercmp "${nvim_package#* }" "2026.9.21-2") == -* ]] || [[ ! -f $provider_source ]]; then + echo "Update omarchy-nvim to 2026.9.21-2 or newer before rerunning this migration." >&2 exit 1 fi diff --git a/test/shell.d/neovim-remote-yanks-test.sh b/test/shell.d/neovim-remote-yanks-test.sh index 84a0c721..24fb536a 100755 --- a/test/shell.d/neovim-remote-yanks-test.sh +++ b/test/shell.d/neovim-remote-yanks-test.sh @@ -13,7 +13,7 @@ printf '%s\n' '-- corrected packaged provider' >"$test_home/package.lua" cat >"$test_home/bin/pacman" <<'STUB' #!/bin/bash [[ $* == '-Q omarchy-nvim' ]] || exit 1 -printf 'omarchy-nvim %s\n' "${TEST_NVIM_VERSION:-2026.8.13-2}" +printf 'omarchy-nvim %s\n' "${TEST_NVIM_VERSION:-2026.9.21-2}" STUB chmod +x "$test_home/bin/pacman" run_migration() { @@ -50,9 +50,11 @@ cmp "$provider" "$test_home/custom.lua" || fail "unrelated provider is preserved pass "customized and unrelated providers are preserved" cp "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" "$provider" -export TEST_NVIM_VERSION=2026.8.13-1 -if run_migration; then fail "old package leaves migration pending"; fi -cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "old package leaves provider unchanged" +for TEST_NVIM_VERSION in 2026.8.13-1 2026.8.13-2 2026.9.21-1; do + export TEST_NVIM_VERSION + if run_migration; then fail "old package leaves migration pending: $TEST_NVIM_VERSION"; fi + cmp "$provider" "$SHELL_TEST_DIR/fixtures/neovim-clipboard/june.lua" || fail "old package leaves provider unchanged" +done unset TEST_NVIM_VERSION mv "$test_home/package.lua" "$test_home/package.saved" if run_migration; then fail "missing package source leaves migration pending"; fi