diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a29dfdc..a249f52 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -220,7 +220,11 @@ There is no retry loop for ordinary editor commands. Stable file reads retry up to three times, and temporary-file creation tries up to 128 random names. The main UI path is synchronous. -The only runtime background work is the terminal-disconnect monitor thread. +The only persistent runtime background work is the terminal-disconnect monitor thread. +Explicit clipboard commands spawn pbcopy or pbpaste with piped input/output and an enforced UTF-8 locale. +Nonblocking pipe I/O and child exit polling share a two-second deadline; failures kill and reap the child. +Transfers are limited to 16 MiB, and copy checks the selected Rope byte length before materializing text. +Clipboard paste uses the same application text path as terminal paste, including single-line prompt conversion. Tree-sitter work is bounded by visible ranges, read-ahead windows, per-line character limits, and a small checkpoint cache for Rust and Markdown. The retained renderer rejects terminal sizes above 1,000,000 cells, which turns an uncontrolled allocation into a recoverable render error followed by terminal cleanup. diff --git a/README.md b/README.md index 2442233..526c7a3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ It includes shared prompts for commands and buffer navigation, a directory picke | `C-k` | Cut to the end of the line | | `C-y` | Yank the newest cut | | `M-y` | Replace the last yank with an older cut | +| `M-w` | Copy the active region to the macOS clipboard | +| `C-c C-v` | Paste the macOS clipboard into the buffer or active prompt | | `C-/` or `C-_` | Undo the last edit | | `C-x u` | Undo the last edit | | `Command-z` | Undo the last edit | @@ -186,6 +188,8 @@ Escape cancels the prompt; unknown names and invalid arguments report an error w | `delete-char`, `delete-backward-char` | Delete one grapheme | | | `set-mark`, `kill-region`, `kill-line`, `yank` | Select, cut, or insert cut text | | | `yank-pop` | Replace the last yank with an older cut | | +| `copy-region` | Copy the region without deleting it | `clipboard-copy` | +| `clipboard-paste` | Insert literal clipboard text | | | `self-insert-command ` | Insert one printable character | | | `execute-extended-command` | Open the command prompt | | | `help`, `help ` | List commands or describe one | `/help`, `/commands` | @@ -216,6 +220,12 @@ Movement, edits, save, buffer changes, and undo/redo end yank-pop, so it cannot Each kill, yank, and yank-pop is one undo step. The ring is local to this process and does not read or write the system clipboard. +Clipboard commands run only when requested, using macOS `pbcopy` and `pbpaste`. +Copy keeps the region and buffer unchanged; clipboard paste is one literal undo step and clears the selection. +In editable prompts, `C-c C-v` flattens line breaks and tabs without submitting the prompt. +Clipboard commands time out after two seconds and reject transfers above 16 MiB or invalid UTF-8, leaving buffer text intact. +Clipboard errors in a prompt remain visible until the next prompt key; the entered text is preserved. + Contiguous typing and same-direction deletion undo as a group. A pause of at least 750 ms, movement, save, prompt entry, buffer switch, or deliberate edit starts a new group. Paste, indentation, kills, and yanks remain separate undo steps. diff --git a/docs/issues/167-plan.md b/docs/issues/167-plan.md new file mode 100644 index 0000000..49401a3 --- /dev/null +++ b/docs/issues/167-plan.md @@ -0,0 +1,23 @@ +# Issue 167: Explicit macOS clipboard commands + +## Task and acceptance + +Add copy-region, with clipboard-copy as an alias, bound to M-w. +Copy the active region without changing text, mark, history, or the local kill ring. +An empty region produces a clear no-op status and does not invoke a clipboard program. +Add clipboard-paste, bound to C-c C-v, inserting literal text as one undo edit with the same Unicode and mark rules as terminal paste. +The clipboard shortcut also works inside editable prompts, applying the existing single-line conversion without submitting them. +Clipboard commands cannot answer a dirty-quit confirmation. + +Use a small adapter with fixed /usr/bin/pbcopy and /usr/bin/pbpaste programs. +Access the clipboard only when an explicit copy or paste command executes. +Bound process I/O and exit waiting by a two-second deadline, avoid blocking pipe reads or writes, and reap failed or timed-out children. +Limit one clipboard transfer to 16 MiB; report oversized data or invalid UTF-8 without editing the buffer. +Do not include clipboard contents in failure messages. + +## Checks + +Use controlled executable adapters in automated tests, never the real clipboard. +Test copy/no-region behavior, named aliases and keybindings, literal paste and undo, prompt conversion, mark and kill-ring preservation, process failures, invalid UTF-8, oversized data, and timeout cleanup. +Run formatting, strict Clippy, the full suite, release build, independent review, and a terminal copy/paste/edit/undo/save/quit session with shell restoration. +If manual testing changes the system clipboard, save and restore its prior bytes without displaying them. diff --git a/docs/roadmap.md b/docs/roadmap.md index b3bd4b1..15cf0b9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -112,10 +112,10 @@ Shipped: - Find file. - Switch buffer. - Command registry and `M-x` with prefix completion. +- Kill ring and yank-pop. Planned: - Incremental search. -- Kill ring and yank-pop. Tracking issues: - [closed] [#27 Add undo and redo](https://github.com/owainlewis/cortex/issues/27) @@ -123,7 +123,7 @@ Tracking issues: - [open] [#29 Add incremental search](https://github.com/owainlewis/cortex/issues/29) - [closed] [#30 Add multiple buffers, find-file, and switch-buffer](https://github.com/owainlewis/cortex/issues/30) - [closed] [#46 Add command registry and M-x](https://github.com/owainlewis/cortex/issues/46) -- [open] [#47 Replace cut slot with a real kill ring](https://github.com/owainlewis/cortex/issues/47) +- [closed] [#47 Replace cut slot with a real kill ring](https://github.com/owainlewis/cortex/issues/47) Release notes should focus on editing safety, search, and buffer navigation. @@ -141,7 +141,7 @@ Delivery order: - [closed] [#164 Add typing and region indentation](https://github.com/owainlewis/cortex/issues/164) - [closed] [#165 Handle literal terminal paste](https://github.com/owainlewis/cortex/issues/165) - [closed] [#166 Group typing and deletion undo steps](https://github.com/owainlewis/cortex/issues/166) -- [open] [#47 Add kill ring and yank-pop](https://github.com/owainlewis/cortex/issues/47) +- [closed] [#47 Add kill ring and yank-pop](https://github.com/owainlewis/cortex/issues/47) - [open] [#167 Add explicit macOS clipboard commands](https://github.com/owainlewis/cortex/issues/167) - [open] [#168 Add word, page, and line navigation](https://github.com/owainlewis/cortex/issues/168) - [open] [#29 Add incremental search](https://github.com/owainlewis/cortex/issues/29) diff --git a/src/app.rs b/src/app.rs index b5919d3..beddc9a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,6 @@ use crate::{ buffer::Buffer, + clipboard::Clipboard, command_registry, commands, editor::{Editor, OpenResult, SwitchError}, input::key_from_event, @@ -38,6 +39,7 @@ struct AppState { last_search: Option, mark: Option, kill_ring: KillRing, + clipboard: Clipboard, last_yank: Option, last_disk_check: Option, } @@ -380,6 +382,19 @@ impl AppState { } if self.command_line.is_some() { + if self.status_kind == Some(StatusKind::Error) { + self.clear_status(); + } + // Clipboard paste is the one editor prefix available inside + // editable prompts. Its text still follows prompt paste rules. + if key == crate::input::Key::Ctrl('c') || keymap.clipboard_prefix_pending() { + self.last_yank = None; + buffer.break_undo_group(); + if keymap.resolve(key) == KeymapResult::Command(commands::Command::ClipboardPaste) { + return self.paste_clipboard(buffer, view); + } + return AppAction::Continue; + } return self.handle_command_line_key(key, buffer, view); } @@ -388,7 +403,7 @@ impl AppState { KeymapResult::PendingPrefix => { self.last_yank = None; buffer.break_undo_group(); - self.set_status("C-x", StatusKind::Prefix); + self.set_status(keymap.pending_label().unwrap_or(""), StatusKind::Prefix); AppAction::Continue } KeymapResult::Unbound => { @@ -422,6 +437,7 @@ impl AppState { } if let Some(input) = self.command_line.as_mut() { input.push_str(&crate::input::single_line_paste(text)); + self.clear_status(); return; } let point_after = buffer.insert(view.point(), text); @@ -557,6 +573,47 @@ impl AppState { AppAction::Continue } + fn copy_region(&mut self, buffer: &Buffer, view: &View) -> AppAction { + let Some(region) = self.active_region(buffer, view) else { + self.set_status("No active region", StatusKind::Error); + return AppAction::Continue; + }; + let result = self + .clipboard + .check_size(buffer.text_range_len_bytes(region.clone())) + .and_then(|()| self.clipboard.copy(&buffer.text_range(region))); + match result { + Ok(()) => self.set_status("Copied region to clipboard", StatusKind::Success), + Err(error) => { + self.set_status(format!("Clipboard copy failed: {error}"), StatusKind::Error) + } + } + AppAction::Continue + } + + fn paste_clipboard(&mut self, buffer: &mut Buffer, view: &mut View) -> AppAction { + match self.clipboard.paste() { + Ok(text) => { + self.handle_paste(&text, &mut Keymap::new(), buffer, view); + if self.command_line.is_none() { + self.set_status( + if text.is_empty() { + "Clipboard is empty" + } else { + "Pasted clipboard" + }, + StatusKind::Info, + ); + } + } + Err(error) => self.set_status( + format!("Clipboard paste failed: {error}"), + StatusKind::Error, + ), + } + AppAction::Continue + } + fn handle_command_line_key( &mut self, key: crate::input::Key, @@ -682,6 +739,8 @@ impl AppState { Command::KillLine => self.kill_line(buffer, view), Command::Yank => self.yank(buffer, view), Command::YankPop => self.yank_pop(buffer, view), + Command::CopyRegion => self.copy_region(buffer, view), + Command::ClipboardPaste => self.paste_clipboard(buffer, view), Command::RepeatSearch => self.repeat_search(buffer, view), Command::OpenFile => self.start_find_file(), Command::SwitchBuffer => self.start_switch_buffer(), @@ -832,10 +891,18 @@ impl AppState { fn prompt_text(&self) -> Option { let input = self.command_line.as_ref()?; - Some(match self.prompt_kind.unwrap_or(PromptKind::Command) { + let prompt = match self.prompt_kind.unwrap_or(PromptKind::Command) { PromptKind::Command => format!("M-x {input}"), PromptKind::FindFile => format!("Find file: {input}"), PromptKind::SwitchBuffer => format!("Switch buffer: {input}"), + }; + Some(if self.status_kind == Some(StatusKind::Error) { + format!( + "{} | {prompt}", + self.status_message.as_deref().unwrap_or("Error") + ) + } else { + prompt }) } @@ -972,6 +1039,194 @@ mod tests { static TEST_DIR_COUNTER: AtomicUsize = AtomicUsize::new(0); + fn app_with_test_clipboard(dir: &Path) -> AppState { + let copy = crate::clipboard::test_program( + dir, + "copy", + "exec /bin/cat > \"${0%/*}/clipboard-data\"", + ); + let paste = crate::clipboard::test_program( + dir, + "paste", + "exec /bin/cat \"${0%/*}/clipboard-data\"", + ); + AppState { + clipboard: crate::clipboard::Clipboard::with_test_programs(copy, paste), + ..AppState::default() + } + } + + #[test] + fn clipboard_copy_preserves_region_text_history_and_kill_ring() { + let dir = test_dir("clipboard-copy"); + let mut app = app_with_test_clipboard(&dir); + app.kill_ring.push("local cut".to_string()); + let mut buffer = buffer_with_text("copy.txt", "ae\u{301}界z"); + let mut view = View::new(); + let mut keymap = Keymap::new(); + app.handle_key(Key::Meta('w'), &mut keymap, &mut buffer, &mut view); + assert_eq!(app.status_message.as_deref(), Some("No active region")); + assert!(!dir.join("clipboard-data").exists()); + app.mark = Some(1); + view.set_point(4, &buffer); + app.handle_key(Key::Meta('w'), &mut keymap, &mut buffer, &mut view); + assert_eq!( + fs::read_to_string(dir.join("clipboard-data")).unwrap(), + "e\u{301}界" + ); + assert_eq!(buffer.text(), "ae\u{301}界z"); + assert_eq!(app.mark, Some(1)); + assert_eq!(view.point(), 4); + assert_eq!(app.kill_ring.get(0), Some("local cut")); + assert_eq!(buffer.undo(), None); + run_slash_command( + "clipboard-copy", + &mut app, + &mut keymap, + &mut buffer, + &mut view, + ); + assert_eq!(app.status_kind, Some(StatusKind::Success)); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn clipboard_paste_is_literal_one_step_and_never_synchronizes_the_kill_ring() { + for named in [false, true] { + let dir = test_dir("clipboard-paste"); + let mut app = app_with_test_clipboard(&dir); + app.kill_ring.push("local cut".to_string()); + let payload = "e\tλ\r\n/quit!\x18\x03"; + fs::write(dir.join("clipboard-data"), payload).unwrap(); + let mut buffer = buffer_with_text("paste.txt", "\u{301}tail"); + let mut view = View::new(); + let mut keymap = Keymap::new(); + app.mark = Some(1); + if named { + run_slash_command( + "clipboard-paste", + &mut app, + &mut keymap, + &mut buffer, + &mut view, + ); + } else { + app.handle_key(Key::Ctrl('c'), &mut keymap, &mut buffer, &mut view); + assert_eq!(app.status_message.as_deref(), Some("C-c")); + app.handle_key(Key::Ctrl('v'), &mut keymap, &mut buffer, &mut view); + } + assert_eq!(buffer.text(), format!("{payload}\u{301}tail")); + assert!(app.mark.is_none()); + assert_eq!(app.kill_ring.get(0), Some("local cut")); + app.execute_command(commands::Command::Undo, "", &mut buffer, &mut view); + assert_eq!(buffer.text(), "\u{301}tail"); + assert!(!buffer.is_dirty()); + assert_eq!(buffer.undo(), None); + fs::remove_dir_all(dir).unwrap(); + } + } + + #[test] + fn clipboard_shortcut_pastes_in_each_prompt_without_submission() { + for kind in [ + super::PromptKind::Command, + super::PromptKind::FindFile, + super::PromptKind::SwitchBuffer, + ] { + let dir = test_dir("clipboard-prompt"); + let mut app = app_with_test_clipboard(&dir); + fs::write(dir.join("clipboard-data"), "λ\tbar\r\n\x1b\x03/quit!").unwrap(); + app.command_line = Some("pre".to_string()); + app.prompt_kind = Some(kind); + let mut buffer = buffer_with_text("prompt.txt", "keep"); + let mut view = View::new(); + let mut keymap = Keymap::new(); + app.handle_key(Key::Ctrl('c'), &mut keymap, &mut buffer, &mut view); + app.handle_key(Key::Ctrl('v'), &mut keymap, &mut buffer, &mut view); + assert_eq!(app.command_line.as_deref(), Some("preλ bar /quit!")); + assert_eq!(app.prompt_kind, Some(kind)); + assert_eq!(buffer.text(), "keep"); + assert_eq!(buffer.undo(), None); + assert_eq!(keymap, Keymap::new()); + fs::remove_dir_all(dir).unwrap(); + } + } + + #[test] + fn clipboard_failures_preserve_text_and_are_visible_inside_a_prompt() { + let dir = test_dir("clipboard-failure"); + let fail = crate::clipboard::test_program(&dir, "fail", "exit 7"); + let mut app = AppState { + clipboard: crate::clipboard::Clipboard::with_test_programs(fail.clone(), fail), + ..AppState::default() + }; + let mut buffer = buffer_with_text("failure.txt", "keep"); + let mut view = View::new(); + let mut keymap = Keymap::new(); + app.mark = Some(0); + view.set_point(4, &buffer); + for command in [ + commands::Command::CopyRegion, + commands::Command::ClipboardPaste, + ] { + app.execute_command(command, "", &mut buffer, &mut view); + assert_eq!(buffer.text(), "keep"); + assert_eq!(app.mark, Some(0)); + assert_eq!(view.point(), 4); + assert_eq!(app.status_kind, Some(StatusKind::Error)); + assert_eq!(buffer.undo(), None); + } + app.start_command_line(); + app.command_line = Some("original".to_string()); + app.handle_key(Key::Ctrl('c'), &mut keymap, &mut buffer, &mut view); + app.handle_key(Key::Ctrl('v'), &mut keymap, &mut buffer, &mut view); + assert_eq!(app.command_line.as_deref(), Some("original")); + assert!(app + .prompt_text() + .unwrap() + .starts_with("Clipboard paste failed:")); + let empty = crate::clipboard::test_program(&dir, "empty", "exit 0"); + app.clipboard = crate::clipboard::Clipboard::with_test_programs(empty.clone(), empty); + app.handle_key(Key::Ctrl('c'), &mut keymap, &mut buffer, &mut view); + app.handle_key(Key::Ctrl('v'), &mut keymap, &mut buffer, &mut view); + assert_eq!(app.prompt_text().as_deref(), Some("M-x original")); + app.handle_key(Key::Char('x'), &mut keymap, &mut buffer, &mut view); + assert_eq!(app.prompt_text().as_deref(), Some("M-x originalx")); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn clipboard_commands_are_ignored_in_a_dirty_quit_confirmation() { + let dir = test_dir("clipboard-confirmation"); + let called = crate::clipboard::test_program( + &dir, + "called", + "printf 'y'; /usr/bin/touch \"$0.called\"", + ); + let mut app = AppState { + clipboard: crate::clipboard::Clipboard::with_test_programs( + called.clone(), + called.clone(), + ), + ..AppState::default() + }; + let mut buffer = buffer_with_text("dirty.txt", ""); + let mut view = View::new(); + let mut keymap = Keymap::new(); + buffer.insert(0, "keep"); + app.request_dirty_quit(); + for key in [Key::Meta('w'), Key::Ctrl('c'), Key::Ctrl('v')] { + assert_eq!( + app.handle_key(key, &mut keymap, &mut buffer, &mut view), + AppAction::Continue + ); + } + assert!(app.dirty_quit_prompt); + assert_eq!(buffer.text(), "keep"); + assert!(!called.with_extension("called").exists()); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn multiple_kills_yank_and_cycle_newest_first_with_independent_undo_steps() { let mut app = AppState::default(); diff --git a/src/buffer.rs b/src/buffer.rs index 5a6ea33..3543feb 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -756,6 +756,10 @@ impl Buffer { (text, context_barriers) } + pub(crate) fn text_range_len_bytes(&self, range: Range) -> usize { + self.text.slice(range).len_bytes() + } + pub fn text_range(&self, char_range: Range) -> String { let start = char_range.start.min(self.len_chars()); let end = char_range.end.min(self.len_chars()); diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..d2efbde --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,313 @@ +use std::{ + io::{self, Read, Write}, + os::fd::{AsRawFd, RawFd}, + path::PathBuf, + process::{Child, Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const TRANSFER_LIMIT: usize = 16 * 1024 * 1024; +const PROCESS_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Debug, PartialEq, Eq)] +pub struct Clipboard { + copy_program: PathBuf, + paste_program: PathBuf, + timeout: Duration, + transfer_limit: usize, +} + +impl Default for Clipboard { + fn default() -> Self { + Self { + copy_program: PathBuf::from("/usr/bin/pbcopy"), + paste_program: PathBuf::from("/usr/bin/pbpaste"), + timeout: PROCESS_TIMEOUT, + transfer_limit: TRANSFER_LIMIT, + } + } +} + +impl Clipboard { + pub fn copy(&self, text: &str) -> io::Result<()> { + self.check_size(text.len())?; + let deadline = Instant::now() + self.timeout; + let mut child = Command::new(&self.copy_program) + .env("LC_ALL", "en_US.UTF-8") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let result = (|| { + let mut input = child.stdin.take().unwrap(); + nonblocking(input.as_raw_fd())?; + let mut remaining = text.as_bytes(); + while !remaining.is_empty() { + check_deadline(deadline)?; + match input.write(remaining) { + Ok(0) => return Err(io::ErrorKind::WriteZero.into()), + Ok(written) => remaining = &remaining[written..], + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + wait_for_pipe(input.as_raw_fd(), libc::POLLOUT, deadline)?; + } + Err(error) => return Err(error), + } + } + drop(input); + wait_for_exit(&mut child, deadline) + })(); + reap_failed_child(&mut child, &result); + result + } + + pub fn paste(&self) -> io::Result { + let deadline = Instant::now() + self.timeout; + let mut child = Command::new(&self.paste_program) + .env("LC_ALL", "en_US.UTF-8") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let result = (|| { + let mut output = child.stdout.take().unwrap(); + nonblocking(output.as_raw_fd())?; + let mut bytes = Vec::new(); + let mut chunk = [0; 16 * 1024]; + loop { + check_deadline(deadline)?; + match output.read(&mut chunk) { + Ok(0) => break, + Ok(read) => { + self.check_size(bytes.len() + read)?; + bytes.extend_from_slice(&chunk[..read]); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + wait_for_pipe(output.as_raw_fd(), libc::POLLIN, deadline)?; + } + Err(error) => return Err(error), + } + } + wait_for_exit(&mut child, deadline)?; + String::from_utf8(bytes).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "clipboard text is not valid UTF-8", + ) + }) + })(); + reap_failed_child(&mut child, &result); + result + } + + pub(crate) fn check_size(&self, bytes: usize) -> io::Result<()> { + if bytes > self.transfer_limit { + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("clipboard transfer exceeds {} bytes", self.transfer_limit), + )) + } else { + Ok(()) + } + } + + #[cfg(test)] + pub(crate) fn with_test_programs(copy_program: PathBuf, paste_program: PathBuf) -> Self { + Self { + copy_program, + paste_program, + ..Self::default() + } + } +} + +fn nonblocking(fd: RawFd) -> io::Result<()> { + // SAFETY: fd is borrowed from a live child pipe. These commands query and + // update its descriptor flags without taking ownership. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn check_deadline(deadline: Instant) -> io::Result<()> { + if Instant::now() >= deadline { + Err(io::Error::new( + io::ErrorKind::TimedOut, + "clipboard command timed out", + )) + } else { + Ok(()) + } +} + +fn wait_for_pipe(fd: RawFd, events: libc::c_short, deadline: Instant) -> io::Result<()> { + loop { + check_deadline(deadline)?; + let mut descriptor = libc::pollfd { + fd, + events, + revents: 0, + }; + let timeout = deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .min(20) as i32; + // SAFETY: descriptor contains a live pipe and remains valid during poll. + let ready = unsafe { libc::poll(&mut descriptor, 1, timeout.max(1)) }; + if ready > 0 { + return Ok(()); + } + if ready < 0 { + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } + } +} + +fn wait_for_exit(child: &mut Child, deadline: Instant) -> io::Result<()> { + loop { + if let Some(status) = child.try_wait()? { + return if status.success() { + Ok(()) + } else { + Err(io::Error::other(format!( + "clipboard command failed: {status}" + ))) + }; + } + check_deadline(deadline)?; + thread::sleep( + Duration::from_millis(5).min(deadline.saturating_duration_since(Instant::now())), + ); + } +} + +fn reap_failed_child(child: &mut Child, result: &io::Result) { + if result.is_err() { + let _ = child.kill(); + let _ = child.wait(); + } +} + +#[cfg(test)] +pub(crate) fn test_program(directory: &std::path::Path, name: &str, body: &str) -> PathBuf { + use std::{fs, os::unix::fs::PermissionsExt}; + let path = directory.join(name); + fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + path +} + +#[cfg(test)] +mod tests { + use super::{test_program, Clipboard}; + use std::{ + fs, io, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, + }; + + static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + + fn fixture() -> PathBuf { + let path = std::env::temp_dir().join(format!( + "cortex-clipboard-{}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + path + } + + #[test] + fn controlled_programs_round_trip_literal_utf8_without_the_system_clipboard() { + let dir = fixture(); + let copy = test_program(&dir, "copy", "exec /bin/cat > \"${0%/*}/data\""); + let paste = test_program(&dir, "paste", "exec /bin/cat \"${0%/*}/data\""); + let clipboard = Clipboard::with_test_programs(copy, paste); + let text = " λ\tfoo\r\n\x18\x03👨‍💻 ".repeat(20_000); + clipboard.copy(&text).unwrap(); + assert_eq!(clipboard.paste().unwrap(), text); + clipboard.copy("").unwrap(); + assert_eq!(clipboard.paste().unwrap(), ""); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn failures_invalid_utf8_and_limits_are_reported_without_returning_partial_text() { + let dir = fixture(); + let fail = test_program(&dir, "fail", "printf 'private data'; exit 7"); + let mut clipboard = Clipboard::with_test_programs(fail.clone(), fail); + assert!(clipboard.copy("anything").is_err()); + let error = clipboard.paste().unwrap_err().to_string(); + assert!(error.contains("clipboard command failed")); + assert!(!error.contains("private data")); + clipboard.paste_program = test_program(&dir, "invalid", "printf '\\377'"); + assert_eq!( + clipboard.paste().unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + clipboard.transfer_limit = 4; + clipboard.paste_program = test_program(&dir, "large", "printf '12345'"); + assert_eq!( + clipboard.paste().unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + clipboard.copy_program = dir.join("missing"); + assert_eq!( + clipboard.copy("12345").unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + assert_eq!( + clipboard.copy("1234").unwrap_err().kind(), + io::ErrorKind::NotFound + ); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn failure_cleanup_kills_and_reaps_the_owned_child() { + let mut child = std::process::Command::new("/bin/sh") + .args(["-c", "while :; do :; done"]) + .spawn() + .unwrap(); + let pid = child.id() as i32; + let failure: io::Result<()> = Err(io::ErrorKind::TimedOut.into()); + super::reap_failed_child(&mut child, &failure); + assert!(child.try_wait().unwrap().is_some()); + // SAFETY: signal zero only checks whether the owned child exists. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH)); + } + + #[test] + fn timeouts_cover_blocked_readers_writers_and_closed_output() { + let dir = fixture(); + let hang = test_program(&dir, "hang", "while :; do :; done"); + let mut clipboard = Clipboard::with_test_programs(hang.clone(), hang.clone()); + clipboard.timeout = Duration::from_millis(500); + for copy in [false, true] { + let result = if copy { + clipboard.copy(&"x".repeat(1_000_000)) + } else { + clipboard.paste().map(|_| ()) + }; + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); + } + // Closing stdout does not let a still-running process bypass the deadline. + clipboard.paste_program = + test_program(&dir, "closed-output", "exec 1>&-; while :; do :; done"); + assert_eq!( + clipboard.paste().unwrap_err().kind(), + io::ErrorKind::TimedOut + ); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/command_registry.rs b/src/command_registry.rs index 653593e..f3932b7 100644 --- a/src/command_registry.rs +++ b/src/command_registry.rs @@ -221,6 +221,20 @@ pub const COMMANDS: &[CommandSpec] = &[ Command::KillLine, None, ), + command( + "copy-region", + "Copy the active region to the macOS clipboard", + &["clipboard-copy"], + Command::CopyRegion, + None, + ), + command( + "clipboard-paste", + "Insert text from the macOS clipboard", + &[], + Command::ClipboardPaste, + None, + ), command("yank", "Insert the newest kill", &[], Command::Yank, None), command( "yank-pop", diff --git a/src/commands.rs b/src/commands.rs index ea1dea4..268de4e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -34,6 +34,8 @@ pub enum Command { Undo, Yank, YankPop, + CopyRegion, + ClipboardPaste, Quit, } @@ -141,7 +143,9 @@ pub(crate) fn dispatch_at( | Command::SetMark | Command::SwitchBuffer | Command::Yank - | Command::YankPop => CommandOutcome::default(), + | Command::YankPop + | Command::CopyRegion + | Command::ClipboardPaste => CommandOutcome::default(), Command::Undo => { if let Some(point) = buffer.undo() { view.set_point(point, buffer); diff --git a/src/keymap.rs b/src/keymap.rs index 45c8661..5b49ded 100644 --- a/src/keymap.rs +++ b/src/keymap.rs @@ -8,6 +8,7 @@ pub struct Keymap { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Prefix { CtrlX, + CtrlC, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -22,6 +23,17 @@ impl Keymap { Self::default() } + pub fn pending_label(&self) -> Option<&'static str> { + self.pending_prefix.map(|prefix| match prefix { + Prefix::CtrlX => "C-x", + Prefix::CtrlC => "C-c", + }) + } + + pub fn clipboard_prefix_pending(&self) -> bool { + self.pending_prefix == Some(Prefix::CtrlC) + } + pub fn resolve(&mut self, key: Key) -> KeymapResult { if let Some(prefix) = self.pending_prefix.take() { return resolve_prefixed(prefix, key); @@ -32,6 +44,11 @@ impl Keymap { self.pending_prefix = Some(Prefix::CtrlX); KeymapResult::PendingPrefix } + Key::Ctrl('c') => { + self.pending_prefix = Some(Prefix::CtrlC); + KeymapResult::PendingPrefix + } + Key::Meta('w') => KeymapResult::Command(Command::CopyRegion), Key::Char(ch) => KeymapResult::Command(Command::Insert(ch)), Key::Enter => KeymapResult::Command(Command::InsertNewline), Key::Tab => KeymapResult::Command(Command::Indent), @@ -63,6 +80,7 @@ impl Keymap { fn resolve_prefixed(prefix: Prefix, key: Key) -> KeymapResult { match (prefix, key) { + (Prefix::CtrlC, Key::Ctrl('v')) => KeymapResult::Command(Command::ClipboardPaste), (Prefix::CtrlX, Key::Ctrl('s')) => KeymapResult::Command(Command::SaveBuffer), (Prefix::CtrlX, Key::Ctrl('c')) => KeymapResult::Command(Command::Quit), (Prefix::CtrlX, Key::Ctrl('f')) => KeymapResult::Command(Command::OpenFile), @@ -78,6 +96,27 @@ mod tests { use super::{Keymap, KeymapResult}; use crate::{commands::Command, input::Key}; + #[test] + fn clipboard_bindings_are_explicit_and_do_not_change_the_quit_prefix() { + let mut keymap = Keymap::new(); + assert_eq!( + keymap.resolve(Key::Meta('w')), + KeymapResult::Command(Command::CopyRegion) + ); + assert_eq!(keymap.resolve(Key::Ctrl('v')), KeymapResult::Unbound); + assert_eq!(keymap.resolve(Key::Ctrl('c')), KeymapResult::PendingPrefix); + assert_eq!(keymap.pending_label(), Some("C-c")); + assert_eq!( + keymap.resolve(Key::Ctrl('v')), + KeymapResult::Command(Command::ClipboardPaste) + ); + assert_eq!(keymap.resolve(Key::Ctrl('x')), KeymapResult::PendingPrefix); + assert_eq!( + keymap.resolve(Key::Ctrl('c')), + KeymapResult::Command(Command::Quit) + ); + } + #[test] fn resolves_printable_and_editing_keys_to_commands() { let mut keymap = Keymap::new(); diff --git a/src/main.rs b/src/main.rs index f86976b..61ecd5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; pub mod buffer; mod cli; +mod clipboard; mod command_registry; mod commands; mod editor;