Merge pull request #413 from omacom/fix-remote-neovim-yanks

Fix remote Neovim yanks without blocking paste
This commit is contained in:
Ryan Hughes authored and GitHub committed 2026-09-25 14:52:06 -05:00
commit b1f2d77ef8
6 files changed
+365 -18

No files matched your search

+3 -1
View File
@@ -47,13 +47,15 @@ jobs:
-w /workspace \
archlinux:base-devel bash -lc '
set -euo pipefail
pacman -Syu --noconfirm git jq python libarchive
pacman -Syu --noconfirm git jq python libarchive neovim tmux
python tests/oma-service-removal.py
python tests/upstream-watch.py
./bin/sync-upstream self-test
./bin/sync-rebuilds --self-test
./bin/omarchy-pkgs self-test
./bin/omarchy-release self-test
./tests/neovim-remote-clipboard.sh
python tests/neovim-clipboard-tmux.py
./tests/partial-release.sh
./tests/published-build-plan.sh
./tests/controller.sh
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Ryan Hughes <ryan@omarchy.org>
pkgname=omarchy-nvim
pkgver=2026.9.21
pkgrel=1
pkgrel=2
pkgdesc="Pre-built LazyVim configuration with cached plugins"
arch=('any')
url="https://github.com/LazyVim/LazyVim"
@@ -1,9 +1,8 @@
-- 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.
-- Copies reach the local Wayland clipboard and attached terminals. Reads use
-- Wayland or tmux's buffer, falling back to this Neovim's last copy. Never query
-- the terminal with OSC 52: support for writes does not imply permission to read,
-- and an unanswered query blocks ordinary paste for ten seconds. To insert new
-- text from the host when no readable clipboard exists, use terminal paste.
local M = {}
local function proc_lines(pid, file)
@@ -54,10 +53,20 @@ function M.setup()
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
local last_copy = {}
local function tmux_export_enabled()
local setting = vim.fn.systemlist({ "tmux", "show-options", "-sv", "set-clipboard" })
return vim.v.shell_error == 0 and (setting[1] == "on" or setting[1] == "external")
end
local function copy(register)
local emit = osc52.copy(register)
return function(lines)
return function(lines, regtype)
last_copy[register] = { vim.deepcopy(lines), regtype }
if has_wayland then
local cmd = { "wl-copy", "--sensitive", "--type", "text/plain" }
if register == "*" then
@@ -67,24 +76,68 @@ function M.setup()
end
if vim.g.omarchy_remote_clipboard_osc52 ~= false then
if has_tmux and register == "+" then
-- tmux ignores zero-byte loads. Record the current buffer's identity
-- instead: all Neovim instances see empty until a new buffer arrives,
-- without deleting the user's shared tmux clipboard history.
if #lines == 0 or (#lines == 1 and lines[1] == "") then
vim.fn.system({ "tmux", "set-option", "-sF", "@omarchy-nvim-cleared-buffer", "#{buffer_name}" })
return
end
-- Let tmux emit OSC 52, avoiding its input parser's payload size limit.
-- Explicit -w bypasses set-clipboard, so honor the user's policy first.
local cmd = { "tmux", "load-buffer" }
if tmux_export_enabled() then
cmd[#cmd + 1] = "-w"
end
cmd[#cmd + 1] = "-"
vim.fn.system(cmd, lines)
if vim.v.shell_error == 0 then
return
end
end
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"
local cmd
-- tmux's buffer is shared between Neovim instances on the server. Over
-- SSH, prefer it to the remote machine's unrelated graphical clipboard.
if has_tmux and register == "+" and (in_ssh or not has_wayland)
and vim.g.omarchy_remote_clipboard_osc52 ~= false then
local buffers = vim.fn.systemlist({ "tmux", "display-message", "-p",
"#{buffer_name}\n#{@omarchy-nvim-cleared-buffer}" })
if vim.v.shell_error ~= 0 then
return vim.deepcopy(last_copy[register] or { {}, "v" })
end
if not buffers[1] or buffers[1] == "" or buffers[1] == buffers[2] then
return { {}, "v" }
end
-- Pin the buffer selected above: a concurrent copy must not change
-- which payload we read after checking its identity against the marker.
cmd = { "tmux", "save-buffer", "-b", buffers[1], "-" }
elseif has_wayland then
cmd = { "wl-paste", "--no-newline" }
if register == "*" then
cmd[#cmd + 1] = "--primary"
end
end
local lines = vim.fn.systemlist(cmd, "", 1)
return vim.v.shell_error == 0 and lines or {}
if cmd then
local lines = vim.fn.systemlist(cmd, "", 1)
if vim.v.shell_error == 0 then
-- An empty clipboard is valid; only failed reads use the fallback.
if last_copy[register] and vim.deep_equal(lines, last_copy[register][1]) then
return vim.deepcopy(last_copy[register])
end
return lines
end
end
return vim.deepcopy(last_copy[register] or { {}, "v" })
end
end
@@ -94,6 +147,12 @@ function M.setup()
paste = { ["+"] = paste("+"), ["*"] = paste("*") },
cache_enabled = 0,
}
-- LazyVim clears this over SSH. Users can opt out before setup or override
-- the option afterward in config/options.lua.
if vim.g.omarchy_remote_clipboard_sync ~= false then
vim.opt.clipboard:append("unnamedplus")
end
end
return M
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Exercise the provider against real, isolated tmux buffers and fake terminals."""
import base64
import fcntl
import json
import os
from pathlib import Path
import pty
import select
import struct
import subprocess
import tempfile
import termios
import time
ROOT = Path(__file__).resolve().parents[1]
PROVIDER = ROOT / "pkgbuilds/omarchy-nvim/lua/config/remote_clipboard.lua"
def drain(fd, seconds=0.1):
data = b""
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
if select.select([fd], [], [], max(0, deadline - time.monotonic()))[0]:
try:
data += os.read(fd, 65536)
except OSError:
break
return data
with tempfile.TemporaryDirectory(prefix="nvim-tmux-test-") as directory:
root = Path(directory)
command = ["tmux", "-S", str(root / "socket"), "-f", "/dev/null"]
clients = []
def tmux(*args):
return subprocess.check_output(command + list(args), text=True)
driver = root / "driver.lua"
driver.write_text(r'''
local request = vim.json.decode(table.concat(vim.fn.readfile(vim.env.TEST_REQUEST), "\n"))
dofile(vim.env.TEST_PROVIDER).setup()
if request.operation == "copy" then
vim.fn.setreg("+", request.lines, request.regtype or "v")
else
local value = vim.g.clipboard.paste["+"]()
local lines = type(value[1]) == "table" and value[1] or value
io.write(vim.json.encode(lines))
end
''')
def nvim(operation, lines=None, regtype="v"):
request = root / "request.json"
request.write_text(json.dumps(dict(operation=operation, lines=lines, regtype=regtype)))
env = dict(os.environ)
for key in ("WAYLAND_DISPLAY", "DISPLAY", "HERDR_PANE_ID", "SSH_TTY"):
env.pop(key, None)
env.update(
TMUX=tmux("display-message", "-p", "#{socket_path},#{pid},0").strip(),
TMUX_PANE=tmux("display-message", "-p", "-t", "A:0.0", "#{pane_id}").strip(),
SSH_CONNECTION="test", TEST_PROVIDER=str(PROVIDER),
TEST_REQUEST=str(request),
)
result = subprocess.run(
["nvim", "--clean", "-n", "--headless", "-i", "NONE", "-l", str(driver)],
env=env, cwd=root, capture_output=True, text=True, timeout=15,
)
assert result.returncode == 0, result.stderr
if operation == "paste":
return json.loads(result.stdout)
try:
tmux("new-session", "-d", "-s", "A")
tmux("new-session", "-d", "-s", "B")
tmux("set-option", "-s", "terminal-features", "xterm*:clipboard")
tmux("set-option", "-g", "set-clipboard", "off")
# Every nvim() call is a separate instance: clearing must be shared.
nvim("copy", [""])
assert nvim("paste") == [], "empty server clipboard did not stay empty"
tmux("set-buffer", "older history")
nvim("copy", ["private previous value"])
assert nvim("paste") == ["private previous value"]
history = tmux("list-buffers")
for _ in range(2):
nvim("copy", [""])
assert nvim("paste") == [], "clear returned stale clipboard text"
assert tmux("list-buffers") == history, "clear destroyed tmux history"
tmux("set-buffer", "external copy")
assert nvim("paste") == ["external copy"], "new copy did not supersede clear"
nvim("copy", [""])
nvim("copy", ["new Neovim copy"])
assert nvim("paste") == ["new Neovim copy"]
print("ok - shared empty clipboard, preserved history, and subsequent copies")
for lines, regtype in [(["line"], "V"), (["a", "b"], "v"),
(["x" * (1024 * 1024 + 1)], "v"),
(["$(touch NEVER) `id`; quotes ' \"", "λ\u001b\n"], "v")]:
nvim("copy", lines, regtype)
# Neovim represents embedded NUL bytes as newline inside a list item.
expected = list(lines)
if regtype == "V":
expected.append("")
actual = nvim("paste")
assert actual == expected, ("transport changed clipboard bytes", repr(actual)[:200], repr(expected)[:200])
assert not (root / "NEVER").exists(), "clipboard content executed as shell code"
print("ok - real transport: linewise, multiline, large and literal/control payloads")
# Attach three PTYs, never the developer's terminal or clipboard.
for session in ("A", "A", "B"):
master, slave = pty.openpty()
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 80, 0, 0))
def controlling_tty():
os.setsid()
fcntl.ioctl(0, termios.TIOCSCTTY, 0)
env = dict(os.environ, TERM="xterm-256color")
env.pop("TMUX", None)
env.pop("TMUX_PANE", None)
process = subprocess.Popen(
command + ["attach-session", "-t", session], stdin=slave,
stdout=slave, stderr=slave, env=env, preexec_fn=controlling_tty,
)
os.close(slave)
clients.append((master, process))
drain(master)
for policy in ("off", "on", "external", "off"):
tmux("set-option", "-s", "set-clipboard", policy)
for fd, _ in clients:
drain(fd)
payload = "synthetic-export-" + policy
nvim("copy", [payload])
outputs = [drain(fd, 0.3) for fd, _ in clients]
exports = [b"\x1b]52;" in output for output in outputs]
assert not exports[2], "clipboard escaped into an unrelated session"
if policy == "off":
assert not any(exports), "set-clipboard off leaked OSC 52"
else:
assert sum(exports[:2]) == 1, "expected one selected tmux client"
assert any(base64.b64encode(payload.encode()) in output for output in outputs[:2])
assert nvim("paste") == [payload], "export policy broke internal paste"
print("ok - real clients: export policy, policy changes, selected-client isolation")
finally:
subprocess.run(command + ["kill-server"], capture_output=True)
for fd, process in clients:
process.wait(timeout=5)
os.close(fd)
+131
View File
@@ -0,0 +1,131 @@
local provider = assert(vim.env.TEST_PROVIDER)
local real_system, real_systemlist = vim.fn.system, vim.fn.systemlist
local real_executable, real_readfile = vim.fn.executable, vim.fn.readfile
local writes, reads, emitted, read_result, failed, write_failed
local function setup(env, options)
for _, key in ipairs({ "SSH_CONNECTION", "SSH_TTY", "TMUX", "HERDR_PANE_ID", "WAYLAND_DISPLAY" }) do
vim.env[key] = nil
end
for key, value in pairs(env) do vim.env[key] = value end
vim.g.clipboard = nil
vim.g.omarchy_remote_clipboard_sync = options and options.sync
vim.g.omarchy_remote_clipboard_osc52 = options and options.osc52
vim.opt.clipboard = options and options.clipboard or ""
writes, reads, emitted = {}, {}, {}
read_result, failed, write_failed = nil, false, false
vim.fn.readfile = function(path, ...)
if path:match("^/proc/") then return {} end
return real_readfile(path, ...)
end
vim.fn.executable = function(cmd)
if cmd == "tmux" or cmd == "wl-copy" or cmd == "wl-paste" then return 1 end
return real_executable(cmd)
end
vim.fn.system = function(cmd, lines)
writes[#writes + 1] = { cmd, vim.deepcopy(lines) }
real_system({ "sh", "-c", write_failed and "exit 1" or "exit 0" })
return ""
end
vim.fn.systemlist = function(cmd)
if cmd[2] == "show-options" then
real_system({ "sh", "-c", "exit 0" })
return { "on" }
end
if cmd[2] == "display-message" then
real_system({ "sh", "-c", failed and "exit 1" or "exit 0" })
return { "buffer-test", "" }
end
reads[#reads + 1] = cmd
real_system({ "sh", "-c", failed and "exit 1" or "exit 0" })
return vim.deepcopy(read_result or {})
end
vim.api.nvim_ui_send = function(data) emitted[#emitted + 1] = data end
-- A clipboard read must never reach Neovim's synchronous OSC 52 query.
require("vim.ui.clipboard.osc52").paste = function() error("OSC 52 read attempted") end
dofile(provider).setup()
vim.cmd("unlet! g:loaded_clipboard_provider")
vim.cmd("runtime autoload/provider/clipboard.vim")
end
local function equal(actual, expected)
assert(vim.deep_equal(actual, expected), vim.inspect(actual) .. " != " .. vim.inspect(expected))
end
local function put_yank()
vim.api.nvim_buf_set_lines(0, 0, -1, false, { "probe" })
vim.cmd("normal! gg0yy")
-- Unnamed clipboard writes are deferred until the command loop returns.
vim.cmd("redraw")
local start = vim.uv.hrtime()
vim.cmd("normal! p")
assert((vim.uv.hrtime() - start) / 1e6 < 1000, "paste blocked")
equal(vim.api.nvim_buf_get_lines(0, 0, -1, false), { "probe", "probe" })
end
setup({})
assert(vim.g.clipboard == nil and vim.o.clipboard == "", "local config changed")
for _, env in ipairs({ { SSH_CONNECTION = "test" }, { SSH_TTY = "test" }, { HERDR_PANE_ID = "test" } }) do
setup(env)
assert(vim.o.clipboard == "unnamedplus")
put_yank()
assert(#emitted > 0 and emitted[1]:find("\27]52;c;", 1, true))
assert(#reads == 0)
equal(vim.g.clipboard.paste["*"](), { {}, "v" })
for _, regtype in ipairs({ "v", "V", "\22" .. "3" }) do
vim.g.clipboard.copy["*"]({ "abc" }, regtype)
equal(vim.g.clipboard.paste["*"](), { { "abc" }, regtype })
end
end
print("ok - local, SSH, SSH_TTY and Herdr setup; real yy/p; no OSC 52 reads; register types")
setup({ SSH_CONNECTION = "test" }, { sync = false, clipboard = "unnamed" })
assert(vim.o.clipboard == "unnamed")
setup({ SSH_CONNECTION = "test" }, { clipboard = "unnamed" })
assert(vim.o.clipboard:find("unnamedplus", 1, true) and vim.o.clipboard:find("unnamed,", 1, true))
vim.opt.clipboard = ""
assert(vim.o.clipboard == "", "user override must win")
setup({ SSH_CONNECTION = "test" }, { osc52 = false })
put_yank()
assert(#emitted == 0)
print("ok - sync and OSC 52 opt-outs; existing clipboard flags and subsequent overrides")
setup({ SSH_CONNECTION = "test", TMUX = "test" })
local large = { string.rep("x", 1024 * 1024 + 1) }
vim.g.clipboard.copy["+"](large, "v")
equal(writes[1], { { "tmux", "load-buffer", "-w", "-" }, large })
assert(#emitted == 0)
read_result = { "external buffer" }
equal(vim.g.clipboard.paste["+"](), read_result)
equal(reads[1], { "tmux", "save-buffer", "-b", "buffer-test", "-" })
failed = true
equal(vim.g.clipboard.paste["+"](), { large, "v" })
write_failed = true
vim.g.clipboard.copy["+"]({ "retry" }, "v")
assert(#emitted == 1)
print("ok - tmux large yanks, shared buffer reads, failed read and write fallbacks")
setup({ TMUX = "test" }, { osc52 = false })
put_yank()
assert(#writes == 0 and #reads == 0 and #emitted == 0)
setup({ TMUX = "test" })
failed = true
put_yank()
print("ok - tmux-only yanks and paste; opt-out does not read a stale tmux buffer")
setup({ HERDR_PANE_ID = "test", WAYLAND_DISPLAY = "test" })
vim.g.clipboard.copy["*"]({ "primary" }, "v")
equal(writes[1][1], { "wl-copy", "--sensitive", "--type", "text/plain", "--primary" })
read_result = { "from another app" }
equal(vim.g.clipboard.paste["*"](), read_result)
equal(reads[1], { "wl-paste", "--no-newline", "--primary" })
read_result = {}
equal(vim.g.clipboard.paste["*"](), {})
failed = true
equal(vim.g.clipboard.paste["*"](), { { "primary" }, "v" })
setup({ SSH_CONNECTION = "test", WAYLAND_DISPLAY = "test", TMUX = "test" })
vim.g.clipboard.copy["+"]({ "both" }, "v")
assert(#writes == 2)
read_result = { "both" }
equal(vim.g.clipboard.paste["+"](), { { "both" }, "v" })
equal(reads[1], { "tmux", "save-buffer", "-b", "buffer-test", "-" })
print("ok - Wayland primary, external, empty and failed reads; SSH prefers tmux")
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
export TEST_PROVIDER="$root/pkgbuilds/omarchy-nvim/lua/config/remote_clipboard.lua"
export NVIM_LOG_FILE=$(mktemp)
trap 'rm -f "$NVIM_LOG_FILE"' EXIT
nvim --clean -n --headless -i NONE -l "$root/tests/neovim-remote-clipboard.lua"