Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ jobs:
-w /workspace \
archlinux:base-devel bash -lc '
set -euo pipefail
pacman -Syu --noconfirm git jq
pacman -Syu --noconfirm git jq neovim tmux python
./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
'
2 changes: 1 addition & 1 deletion pkgbuilds/omarchy-nvim/PKGBUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Maintainer: Ryan Hughes <ryan@omarchy.org>
pkgname=omarchy-nvim
pkgver=2026.8.13
pkgrel=1
pkgrel=2
pkgdesc="Pre-built LazyVim configuration with cached plugins"
arch=('any')
url="https://github.com/LazyVim/LazyVim"
Expand Down
91 changes: 75 additions & 16 deletions pkgbuilds/omarchy-nvim/lua/config/remote_clipboard.lua
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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

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

local lines = vim.fn.systemlist(cmd, "", 1)
return vim.v.shell_error == 0 and lines or {}
return vim.deepcopy(last_copy[register] or { {}, "v" })
end
end

Expand All @@ -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 changes: 148 additions & 0 deletions tests/neovim-clipboard-tmux.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading