feat: default editing to shared system clipboard

This commit is contained in:
Valdemar Lorenzen
2026-10-09 10:26:04 +02:00
parent d61f0b750b
commit 981011096e
9 changed files with 1591 additions and 68 deletions
+576
View File
@@ -0,0 +1,576 @@
use std::path::PathBuf;
use std::time::Duration;
const MAX_CLIPBOARD_BYTES: usize = 4 * 1024 * 1024;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(2);
#[cfg(target_os = "macos")]
const MAC_READ: &str = r#"
ObjC.import('AppKit');
function run() {
var text = $.NSPasteboard.generalPasteboard.stringForType($.NSPasteboardTypeString);
if (text.isNil()) return;
$.NSFileHandle.fileHandleWithStandardOutput.writeData(
text.dataUsingEncoding($.NSUTF8StringEncoding));
}
"#;
#[cfg(target_os = "macos")]
const MAC_WRITE: &str = r#"
ObjC.import('AppKit');
function run() {
var data = $.NSFileHandle.fileHandleWithStandardInput.readDataToEndOfFile;
var text = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);
if (text.isNil()) throw Error('Clipboard input is not UTF-8');
var pasteboard = $.NSPasteboard.generalPasteboard;
pasteboard.clearContents;
if (!pasteboard.setStringForType(text, $.NSPasteboardTypeString))
throw Error('Cannot write plain-text clipboard');
}
"#;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClipboardMode {
System,
Separate,
}
struct ClipboardCommand {
program: PathBuf,
args: &'static [&'static str],
}
struct Commands {
reader: ClipboardCommand,
writer: ClipboardCommand,
}
pub struct SystemClipboard {
commands: Option<Result<Commands, String>>,
timeout: Duration,
}
impl Default for SystemClipboard {
fn default() -> Self {
Self::new()
}
}
impl SystemClipboard {
pub fn new() -> Self {
Self {
commands: None,
timeout: COMMAND_TIMEOUT,
}
}
#[cfg(test)]
pub(crate) fn with_commands(reader: PathBuf, writer: PathBuf) -> Self {
Self {
commands: Some(Ok(Commands {
reader: ClipboardCommand {
program: reader,
args: &[],
},
writer: ClipboardCommand {
program: writer,
args: &[],
},
})),
timeout: COMMAND_TIMEOUT,
}
}
pub fn read(&mut self) -> Result<String, String> {
let timeout = self.timeout;
let commands = self.commands.get_or_insert_with(select_commands);
let command = &commands.as_ref().map_err(Clone::clone)?.reader;
let bytes = run(command, None, timeout)?;
String::from_utf8(bytes).map_err(|_| "System clipboard is not valid UTF-8".into())
}
pub fn write(&mut self, text: &str) -> Result<(), String> {
if text.len() > MAX_CLIPBOARD_BYTES {
return Err("System clipboard text exceeds 4 MiB".into());
}
let timeout = self.timeout;
let commands = self.commands.get_or_insert_with(select_commands);
let command = &commands.as_ref().map_err(Clone::clone)?.writer;
run(command, Some(text.as_bytes()), timeout).map(|_| ())
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn executable(name: &str) -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.map(|directory| directory.join(name))
.find(|path| {
path.metadata().is_ok_and(|metadata| {
metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
})
})
}
fn select_commands() -> Result<Commands, String> {
#[cfg(target_os = "macos")]
{
if let Some(program) = executable("osascript") {
return Ok(Commands {
reader: ClipboardCommand {
program: program.clone(),
args: &["-l", "JavaScript", "-e", MAC_READ],
},
writer: ClipboardCommand {
program,
args: &["-l", "JavaScript", "-e", MAC_WRITE],
},
});
}
Err("System clipboard requires osascript with JXA/AppKit on PATH".into())
}
#[cfg(target_os = "linux")]
{
if std::env::var_os("WAYLAND_DISPLAY").is_some_and(|value| !value.is_empty()) {
if let (Some(reader), Some(writer)) = (executable("wl-paste"), executable("wl-copy")) {
return Ok(Commands {
reader: ClipboardCommand {
program: reader,
args: &["--no-newline", "--type", "text"],
},
writer: ClipboardCommand {
program: writer,
args: &["--type", "text/plain;charset=utf-8"],
},
});
}
}
if std::env::var_os("DISPLAY").is_some_and(|value| !value.is_empty()) {
if let Some(program) = executable("xclip") {
return Ok(Commands {
reader: ClipboardCommand {
program: program.clone(),
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-out"],
},
writer: ClipboardCommand {
program,
args: &["-selection", "clipboard", "-target", "UTF8_STRING", "-in"],
},
});
}
}
Err("System clipboard requires Wayland with wl-paste/wl-copy or X11 with xclip".into())
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
Err("System clipboard is unsupported on this platform".into())
}
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn run(_: &ClipboardCommand, _: Option<&[u8]>, _: Duration) -> Result<Vec<u8>, String> {
Err("System clipboard is unsupported on this platform".into())
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn run(
command: &ClipboardCommand,
input: Option<&[u8]>,
timeout: Duration,
) -> Result<Vec<u8>, String> {
use std::io::{self, Read, Write};
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use std::time::Instant;
let mut command_process = Command::new(&command.program);
command_process
.args(command.args)
.process_group(0)
.stdin(if input.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(if input.is_none() {
Stdio::piped()
} else {
Stdio::null()
})
// Native tools may echo input in diagnostics. Never capture or surface stderr.
.stderr(Stdio::null());
let child = command_process
.spawn()
.map_err(|error| format!("Cannot start system clipboard command: {error}"))?;
let mut process = ClipboardProcess(Some(child));
let child = process.0.as_mut().expect("clipboard child exists");
let mut stdin = child.stdin.take();
let mut stdout = child.stdout.take();
if let Some(pipe) = &stdin {
nonblocking(pipe).map_err(|error| format!("Cannot open clipboard input: {error}"))?;
}
if let Some(pipe) = &stdout {
nonblocking(pipe).map_err(|error| format!("Cannot open clipboard output: {error}"))?;
}
let started = Instant::now();
let mut written = 0;
let mut output = Vec::new();
let mut status = None;
let mut chunk = [0_u8; 8192];
loop {
if started.elapsed() >= timeout {
return Err("System clipboard command timed out".into());
}
let mut progressed = false;
if let Some(pipe) = stdin.as_mut() {
let bytes = input.expect("input pipe has bytes");
if written == bytes.len() {
stdin.take();
progressed = true;
} else {
let end = bytes.len().min(written + chunk.len());
match pipe.write(&bytes[written..end]) {
Ok(0) => return Err("System clipboard command closed its input".into()),
Ok(count) => {
written += count;
progressed = true;
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) => {}
Err(error) => return Err(format!("System clipboard write failed: {error}")),
}
}
}
if let Some(pipe) = stdout.as_mut() {
let limit = chunk.len().min(MAX_CLIPBOARD_BYTES + 1 - output.len());
match pipe.read(&mut chunk[..limit]) {
Ok(0) => {
stdout.take();
progressed = true;
}
Ok(count) => {
if output.len() + count > MAX_CLIPBOARD_BYTES {
return Err("System clipboard text exceeds 4 MiB".into());
}
output.extend_from_slice(&chunk[..count]);
progressed = true;
}
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) => {}
Err(error) => return Err(format!("System clipboard read failed: {error}")),
}
}
if status.is_none() {
status = process
.0
.as_mut()
.expect("clipboard child exists")
.try_wait()
.map_err(|error| format!("Cannot wait for system clipboard command: {error}"))?;
}
if let Some(exit) = status {
if !exit.success() {
return Err(format!("System clipboard command failed ({exit})"));
}
if stdin.is_none() && stdout.is_none() {
// Clipboard owners (wl-copy/xclip) intentionally leave a serving child alive.
process.0.take();
return Ok(output);
}
}
if !progressed {
let remaining = timeout.saturating_sub(started.elapsed());
wait_for_pipes(stdin.as_ref(), stdout.as_ref(), remaining)?;
}
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
struct ClipboardProcess(Option<std::process::Child>);
#[cfg(any(target_os = "macos", target_os = "linux"))]
impl Drop for ClipboardProcess {
fn drop(&mut self) {
if let Some(mut child) = self.0.take() {
// Also close inherited pipes held by descendants, as in the Ruby host runner.
// SAFETY: spawn placed this child in a process group with its own PID.
unsafe {
kill(-(child.id() as i32), 9);
}
let _ = child.kill();
let _ = child.wait();
}
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn nonblocking(pipe: &impl std::os::fd::AsRawFd) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
const O_NONBLOCK: i32 = 0x0004;
#[cfg(target_os = "linux")]
const O_NONBLOCK: i32 = 0x0800;
// SAFETY: the descriptor is borrowed from a live child pipe; F_GETFL/F_SETFL
// inspect/change flags only and neither consume the descriptor nor access pointers.
unsafe {
let flags = fcntl(pipe.as_raw_fd(), 3);
if flags == -1 || fcntl(pipe.as_raw_fd(), 4, flags | O_NONBLOCK) == -1 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[repr(C)]
struct PollFd {
fd: i32,
events: i16,
revents: i16,
}
#[cfg(target_os = "macos")]
type PollCount = u32;
#[cfg(target_os = "linux")]
type PollCount = usize;
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn wait_for_pipes(
stdin: Option<&std::process::ChildStdin>,
stdout: Option<&std::process::ChildStdout>,
remaining: Duration,
) -> Result<(), String> {
use std::os::fd::AsRawFd;
let mut pipes = [
PollFd {
fd: stdin.map_or(-1, AsRawFd::as_raw_fd),
events: 4,
revents: 0,
},
PollFd {
fd: stdout.map_or(-1, AsRawFd::as_raw_fd),
events: 1,
revents: 0,
},
];
// Recheck process completion at least every 5 ms, waking immediately on pipe readiness.
let milliseconds = remaining.as_millis().min(5) as i32;
// SAFETY: the C-compatible array is live for the call; negative descriptors are ignored.
if unsafe { poll(pipes.as_mut_ptr(), pipes.len() as PollCount, milliseconds) } == -1 {
let error = std::io::Error::last_os_error();
if error.kind() != std::io::ErrorKind::Interrupted {
return Err(format!("Cannot poll system clipboard command: {error}"));
}
}
Ok(())
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
unsafe extern "C" {
fn fcntl(fd: i32, command: i32, ...) -> i32;
fn kill(pid: i32, signal: i32) -> i32;
fn poll(fds: *mut PollFd, count: PollCount, timeout: i32) -> i32;
}
#[cfg(all(test, any(target_os = "macos", target_os = "linux")))]
mod tests {
use super::*;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::sync::atomic::{AtomicU64, Ordering};
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"vamp-clipboard-{}-{}",
std::process::id(),
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&path).unwrap();
Self(path)
}
fn command(&self, name: &str, body: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
path
}
fn clipboard(&self, text: &[u8]) -> SystemClipboard {
let state = self.0.join("state");
fs::write(&state, text).unwrap();
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
SystemClipboard::with_commands(
self.command("read", &format!("exec /bin/cat {state}")),
self.command("write", &format!("exec /bin/cat > {state}")),
)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn construction_does_not_probe_or_spawn() {
assert!(SystemClipboard::new().commands.is_none());
}
#[test]
fn round_trips_unicode_whitespace_and_large_pipes_exactly() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(b"");
for text in ["", " héllo\t世界🙂\r\n\n", &"é\n".repeat(300_000)] {
clipboard.write(text).unwrap();
assert_eq!(fs::read(fixture.0.join("state")).unwrap(), text.as_bytes());
assert_eq!(clipboard.read().unwrap(), text);
}
}
#[test]
fn rejects_oversize_and_invalid_utf8_without_leaking_contents() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(&[0xff, b'\n']);
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard is not valid UTF-8"
);
let maximum = "é".repeat(MAX_CLIPBOARD_BYTES / 2);
clipboard.write(&maximum).unwrap();
assert_eq!(clipboard.read().unwrap(), maximum);
let too_large = format!("{maximum}x");
assert_eq!(
clipboard.write(&too_large).unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
assert_eq!(
fs::read(fixture.0.join("state")).unwrap(),
maximum.as_bytes()
);
fs::write(fixture.0.join("state"), &too_large).unwrap();
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard text exceeds 4 MiB"
);
let mut incomplete = vec![b'x'; MAX_CLIPBOARD_BYTES - 1];
incomplete.push(0xc3);
fs::write(fixture.0.join("state"), incomplete).unwrap();
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard is not valid UTF-8"
);
}
#[test]
fn command_failures_and_stderr_cannot_leak_clipboard_text() {
let fixture = Fixture::new();
let failed = fixture.command(
"failed",
"printf 'private clipboard payload' >&2\nprintf 'private clipboard payload'\nexit 23",
);
let mut clipboard = SystemClipboard::with_commands(failed.clone(), failed);
let error = clipboard.read().unwrap_err();
assert!(error.contains("failed"));
assert!(!error.contains("private clipboard payload"));
let error = clipboard.write("private clipboard payload").unwrap_err();
assert!(!error.contains("private clipboard payload"));
let missing = fixture.0.join("not-an-executable");
let mut clipboard = SystemClipboard::with_commands(missing.clone(), missing);
assert!(clipboard.read().unwrap_err().contains("Cannot start"));
assert!(
clipboard
.write("secret")
.unwrap_err()
.contains("Cannot start")
);
}
#[test]
fn ignored_stderr_does_not_block_large_writes() {
let fixture = Fixture::new();
let mut clipboard = fixture.clipboard(b"");
let state = fixture.0.join("state");
let state = format!("'{}'", state.to_str().unwrap().replace('\'', "'\\''"));
clipboard
.commands
.as_mut()
.unwrap()
.as_mut()
.unwrap()
.writer
.program = fixture.command(
"noisy",
&format!("/bin/dd if=/dev/zero bs=65536 count=8 >&2\nexec /bin/cat > {state}"),
);
let text = "λ\n".repeat(300_000);
clipboard.write(&text).unwrap();
assert_eq!(clipboard.read().unwrap(), text);
}
#[test]
fn failed_process_cleanup_kills_and_reaps_a_running_child() {
use std::io::Read;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
unsafe extern "C" {
fn waitpid(pid: i32, status: *mut i32, options: i32) -> i32;
}
let fixture = Fixture::new();
let hung = fixture.command("running", "printf R\nwhile :; do :; done");
let mut command = Command::new(hung);
command
.process_group(0)
.stdout(Stdio::piped())
.stderr(Stdio::null());
let mut child = command.spawn().unwrap();
let pid = child.id() as i32;
let mut ready = [0];
child
.stdout
.as_mut()
.unwrap()
.read_exact(&mut ready)
.unwrap();
assert_eq!(ready, [b'R']);
drop(ClipboardProcess(Some(child)));
let mut status = 0;
// SAFETY: waitpid's output pointer is valid; WNOHANG cannot block this check.
assert_eq!(unsafe { waitpid(pid, &mut status, 1) }, -1);
assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(10));
}
#[test]
fn immediate_injected_timeout_terminates_nonterminating_commands() {
let fixture = Fixture::new();
let hung = fixture.command("hung", "while :; do :; done");
let mut clipboard = SystemClipboard::with_commands(hung.clone(), hung);
// No assertion depends on wall-clock speed or a deadline remaining in the future.
clipboard.timeout = Duration::ZERO;
assert_eq!(
clipboard.read().unwrap_err(),
"System clipboard command timed out"
);
assert_eq!(
clipboard.write("text").unwrap_err(),
"System clipboard command timed out"
);
}
}
+785 -55
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
pub mod clipboard;
pub mod editor;
pub mod files;
pub mod ruby;
+1
View File
@@ -21,6 +21,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line
u undo, U redo, : command, / search, n/N next/previous match
Normal/Visual: . repeats last native edit at current targets (one undo group)
v select, p/P paste fragments, Ctrl+k/Ctrl+e clone above/below
y/d/c/p/P use the system clipboard by default; config.clipboard_mode = \"separate\" isolates it
Esc keeps only the primary caret
Visual: motions extend selections, y copy, d/x/Delete cut, c change
I/A insert at each selected line's first nonblank/end
+8 -3
View File
@@ -21,6 +21,7 @@ pub struct Config {
pub file_explorer_side: String,
pub file_picker_view: String,
pub file_explorer_view: String,
pub clipboard_mode: String,
}
impl Default for Config {
@@ -32,6 +33,7 @@ impl Default for Config {
file_explorer_side: "right".into(),
file_picker_view: "tree".into(),
file_explorer_view: "tree".into(),
clipboard_mode: "system".into(),
}
}
}
@@ -50,6 +52,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_explorer_side,
file_picker_view,
file_explorer_view,
clipboard_mode,
..
} => {
let mut config = Config::default();
@@ -65,6 +68,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.file_explorer_side = file_explorer_side;
config.file_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode;
Ok(config)
}
Response::Error {
@@ -213,6 +217,7 @@ enum Response {
file_explorer_side: String,
file_picker_view: String,
file_explorer_view: String,
clipboard_mode: String,
},
#[serde(rename = "result")]
Result {
@@ -608,9 +613,9 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
r#"{"type":"config","version":1,"plugins":[],"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system"}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
+10 -1
View File
@@ -29,6 +29,7 @@ module Vamp
search search_next search_previous
word_next word_previous word_end big_word_next big_word_previous big_word_end
select copy cut change paste_before paste_after
copy_system paste_system_before paste_system_after
select_lines clone_above clone_below select_next skip_occurrence select_all
file_picker file_explorer
].each do |name|
@@ -55,6 +56,7 @@ module Vamp
@file_explorer_side = "right"
@file_picker_view = "tree"
@file_explorer_view = "tree"
@clipboard_mode = "system"
end
def plugin(path)
@@ -88,6 +90,12 @@ module Vamp
@file_explorer_view = view
end
def clipboard_mode=(mode)
mode = Vamp.string!(mode, "clipboard mode")
raise ArgumentError, "clipboard mode must not be empty" if mode.empty?
@clipboard_mode = mode
end
def core
Core
end
@@ -122,7 +130,8 @@ module Vamp
"bindings" => @bindings, "leader" => @leader,
"file_explorer_side" => @file_explorer_side,
"file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view }
"file_explorer_view" => @file_explorer_view,
"clipboard_mode" => @clipboard_mode }
end
end
+179 -2
View File
@@ -54,6 +54,12 @@ impl EditorSession {
config.file_explorer_view
));
}
if let Err(error) = editor.set_clipboard_mode(&config.clipboard_mode) {
notices.push(format!(
"clipboard mode {:?}: {error}",
config.clipboard_mode
));
}
let mut plugins = BTreeMap::new();
for path in config.plugins {
match RubyPlugin::start(&path) {
@@ -151,12 +157,38 @@ mod tests {
fn open(&self, config: &str) -> EditorSession {
let config = self.file("config.rb", config);
EditorSession::open(
let mut session = EditorSession::open(
Some(self.0.join("buffer.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap()
.unwrap();
// Legacy editing scenarios exercise the internal register, never the user's desktop.
session.editor.set_clipboard_mode("separate").unwrap();
session
}
#[cfg(unix)]
fn system_session(&self, config: &str) -> EditorSession {
use std::os::unix::fs::PermissionsExt;
let config = self.file("config.rb", config);
let mut session = EditorSession::open(
Some(self.0.join("buffer.txt")),
vec!["text".into()],
Some(&config),
)
.unwrap();
self.file("system.txt", "");
let reader = self.file("clip-read", "#!/bin/sh\n[ -e \"${0%/*}/read.fail\" ] && exit 13\nexec /bin/cat \"${0%/*}/system.txt\"\n");
let writer = self.file("clip-write", "#!/bin/sh\n[ -e \"${0%/*}/write.fail\" ] && exit 13\nexec /bin/cat > \"${0%/*}/system.txt\"\n");
fs::set_permissions(&reader, fs::Permissions::from_mode(0o700)).unwrap();
fs::set_permissions(&writer, fs::Permissions::from_mode(0o700)).unwrap();
session
.editor
.set_system_clipboard(crate::clipboard::SystemClipboard::with_commands(
reader, writer,
));
session
}
fn healthy(&self) {
@@ -176,6 +208,138 @@ Vamp.command("healthy") { |editor, args| editor.replace "healthy #{args}" }
}
}
#[cfg(unix)]
#[test]
fn invalid_clipboard_policy_retains_system_default_and_valid_bindings() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
let mut session = fixture.system_session(
r#"
Vamp.configure do |config|
config.clipboard_mode = "combined"
config.bind "n", config.core.right
end
"#,
);
assert_eq!(
session.editor().clipboard_mode(),
crate::clipboard::ClipboardMode::System
);
session.handle(Input::Char('n'));
session.handle(Input::Char('v'));
session.handle(Input::Char('y'));
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"b"
);
assert_eq!(session.editor().lines(), &["abc"]);
}
#[cfg(unix)]
#[test]
fn default_system_clipboard_tracks_external_text_and_preserves_cut_failures() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha beta");
let mut session = fixture.system_session("");
assert_eq!(
session.editor().clipboard_mode(),
crate::clipboard::ClipboardMode::System
);
fixture.file("system.txt", "external");
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["externalalpha beta"]);
session.handle(Input::Char('u'));
for ch in "vey".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"alpha"
);
fixture.file("system.txt", "outside");
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["outsidealpha beta"]);
fixture.file("system.txt", "fresh");
session.handle(Input::Home);
session.handle(Input::Char('.'));
assert_eq!(session.editor().lines(), &["outsideoutsidealpha beta"]);
session.handle(Input::Char('u'));
session.handle(Input::Char('u'));
assert_eq!(session.editor().lines(), &["alpha beta"]);
session.handle(Input::Home);
session.handle(Input::Char('v'));
session.handle(Input::Char('e'));
let selection = session.editor().selections();
fixture.file("write.fail", "");
session.handle(Input::Char('c'));
assert_eq!(session.editor().mode(), crate::editor::Mode::Visual);
assert_eq!(session.editor().selections(), selection);
assert_eq!(session.editor().lines(), &["alpha beta"]);
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"fresh"
);
fs::remove_file(fixture.0.join("write.fail")).unwrap();
session.handle(Input::Escape);
session.handle(Input::Home);
fixture.file("system.txt", "\u{1b}");
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["alpha beta"]);
assert_eq!(session.editor().cursor(), crate::editor::Cursor::default());
assert_eq!(session.editor().mode(), crate::editor::Mode::Normal);
}
#[cfg(unix)]
#[test]
fn separate_clipboards_keep_internal_and_explicit_system_routes_independent() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "alpha beta");
let mut session = fixture.system_session(
r#"
Vamp.configure do |config|
config.clipboard_mode = "separate"
config.bind "ctrl+s", config.core.copy_system, mode: config.modes.visual
config.bind "ctrl+g", config.core.paste_system_before
end
"#,
);
fixture.file("system.txt", "outside");
for ch in "vey".chars() {
session.handle(Input::Char(ch));
}
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"outside"
);
for ch in "wve".chars() {
session.handle(Input::Char(ch));
}
session.handle(Input::Ctrl('s'));
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"beta"
);
session.handle(Input::Home);
session.handle(Input::Char('P'));
assert_eq!(session.editor().lines(), &["alphaalpha beta"]);
session.handle(Input::Char('u'));
session.handle(Input::Home);
session.handle(Input::Ctrl('g'));
assert_eq!(session.editor().lines(), &["betaalpha beta"]);
session.handle(Input::Char('u'));
session.handle(Input::Home);
session.handle(Input::Char('v'));
session.handle(Input::Char('e'));
fixture.file("write.fail", "");
session.handle(Input::Char('d'));
assert_eq!(session.editor().lines(), &[" beta"]);
assert_eq!(
fs::read_to_string(fixture.0.join("system.txt")).unwrap(),
"beta"
);
}
#[test]
fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() {
let fixture = Fixture::new();
@@ -1011,6 +1175,7 @@ end
let fixture = Fixture::new();
let buffer = fixture.file("buffer.txt", "old elephant zebra");
let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap();
session.editor.set_clipboard_mode("separate").unwrap();
for key in ['v', 'e', 'c'] {
session.handle(Input::Char(key));
}
@@ -1604,6 +1769,18 @@ end
r#"config.file_explorer_view = """#,
"file explorer view must not be empty",
),
(
"config.clipboard_mode = 7",
"clipboard mode must be a String",
),
(
"config.clipboard_mode = nil",
"clipboard mode must be a String",
),
(
r#"config.clipboard_mode = """#,
"clipboard mode must not be empty",
),
(r#"config.bind "n", "core.insert""#, "TypeError"),
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
(r#"config.bind "n", config.modes.insert"#, "TypeError"),