Merge pull request #413 from omacom/fix-remote-neovim-yanks
Fix remote Neovim yanks without blocking paste
This commit is contained in:
6 files changed
+365
-18
No files matched your search
@@ -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,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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
Executable
+7
@@ -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"
|
||||
Reference in new issue
Block a user