feat: default editing to shared system clipboard
This commit is contained in:
@@ -30,7 +30,7 @@ Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty
|
|||||||
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
| Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode |
|
||||||
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
| Normal, Visual | `.` repeats the last successful native edit against the current targets; one undo group |
|
||||||
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
| Normal | `/` opens search; `n` finds the next match; `N` (Shift+n) finds the previous match |
|
||||||
| Normal | `v` starts Visual selections; `p`/`P` paste internal clipboard after/before each caret; Esc keeps only the primary caret |
|
| Normal | `v` starts Visual selections; `p`/`P` paste the configured clipboard after/before each caret (system by default); Esc keeps only the primary caret |
|
||||||
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
| Normal, Visual | Ctrl+k clones above; Ctrl+e clones below |
|
||||||
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting |
|
| Visual | Cursor/word motions and `n`/`N` extend every selection; `y` copies; `d`/`x`/Delete cuts; `c` changes selections; `v`/Esc cancels highlighting |
|
||||||
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
|
| Visual | Ctrl+l splits touched rows into Normal carets at first nonblank; Ctrl+n adds the next occurrence; Ctrl+s skips the primary occurrence; Ctrl+a selects all occurrences |
|
||||||
@@ -58,11 +58,29 @@ Ctrl+k/Ctrl+e clone each target to the adjacent row, preserving preferred termin
|
|||||||
|
|
||||||
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. `p`/`P` paste after/before each current grapheme, including multiline text. Each caret lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
Copy and cut return to Normal at each selection start; Visual cancellation keeps each active endpoint and removes highlighting. Normal Esc collapses to the primary. `p`/`P` paste after/before each current grapheme, including multiline text. Each caret lands on its last inserted grapheme, or the new row's start when its fragment ends in a newline. Every multi-target edit is planned against the original buffer and committed as one undo action, not one per target. Copy, movement, and cancellation do not add history or clear redo. Undo/redo restore text, the full caret set, and primary identity in Normal mode, not Visual highlighting or the clipboard.
|
||||||
|
|
||||||
Visual `c` removes all selected text into the internal clipboard and enters Insert at each selection start. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only empty final-row selections enters Insert without overwriting the clipboard.
|
Visual `c` removes all selected text into the configured clipboard and enters Insert at each selection start. In the default System policy, clipboard publication must succeed before any deletion; failures leave text and selections intact. Deletion and replacement typing form one undo action when Insert mode is exited, including through explicit command execution. Esc without typing commits the deletion alone. An identical replacement creates no history entry and preserves redo. Changing only empty final-row selections enters Insert without overwriting either clipboard.
|
||||||
|
|
||||||
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode.
|
Bracketed paste broadcasts the same literal text to all Insert carets in a separate undo group, distinct from typing before/after it. A rejected paste leaves the pending change intact. Normal-mode operator grammar such as `cw` is not supported; `c` types literal text in Insert mode.
|
||||||
|
|
||||||
The clipboard is internal to the editor session and independent of the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing state. Empty clipboard paste does not edit text; copying/cutting only empty final-row selections does not overwrite the clipboard. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
Clipboard policy is session-wide and defaults to the system clipboard. Copy/cut store fragments in document order: one fragment broadcasts to every caret; matching fragment/caret counts pair in document order; any other count refuses paste without changing editing state. Empty clipboard paste does not edit text; copying/cutting only empty final-row selections does not overwrite either register. Open a new search or enter Ex commands after leaving Visual mode. Plugin commands execute once, not once per target: successful text effects collapse to the primary and apply once; rejected or message-only effects preserve the selection set. There is no selection-aware plugin API yet.
|
||||||
|
|
||||||
|
### Clipboard policy
|
||||||
|
|
||||||
|
`config.clipboard_mode = "system"` is the default: ordinary Visual `y`/`d`/`x`/`c` and Normal `p`/`P` use the OS clipboard. External clipboard changes are read on each paste. Set `"separate"` to keep ordinary operations on vamp's internal fragment register instead. Both registers remain independent across buffers; changing the policy does not overwrite either.
|
||||||
|
|
||||||
|
Explicit `config.core.copy_system` (Visual) and `config.core.paste_system_before` / `paste_system_after` (Normal) always use the system clipboard in either policy. They appear in the command palette and can be bound without replacing ordinary internal operations:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
For other applications, multi-selection exports join fragments with LF. Vamp retains their original boundaries privately while the OS text exactly matches its export, so its own copies still paste pairwise. Different external text invalidates that metadata and becomes one literal fragment—even when it contains newlines—broadcasting to all targets. Identical external replacement text is inherently indistinguishable from an unchanged export. No empty-system fallback to stale internal text occurs.
|
||||||
|
|
||||||
|
System paste validates UTF-8, unsupported controls, newlines, and a 4 MiB limit before editing; CRLF/CR normalize to LF. System exports also have a 4 MiB limit. Provider failures report an error without deleting/changing buffer text, caret/selection state, history, or repeat intent. No silent internal fallback occurs in System mode; choose Separate if clipboard access is unavailable. A native provider that fails after publishing may already have changed OS clipboard formats; vamp cannot roll that external side effect back.
|
||||||
|
|
||||||
|
macOS uses fixed JXA/AppKit `NSPasteboardTypeString` access through `osascript`, preserving literal RTF/EPS text rather than treating it as rich clipboard data. Linux supports Wayland with `wl-paste`/`wl-copy`, or X11 with `xclip`, on PATH with the corresponding display environment. Other platforms report unsupported access. Commands have a two-second deadline and bounded output; failures terminate/reap their process group. Tests and executable smoke use isolated providers and never read or overwrite the user's desktop clipboard.
|
||||||
|
|
||||||
### Repeat last edit
|
### Repeat last edit
|
||||||
|
|
||||||
@@ -70,7 +88,7 @@ The clipboard is internal to the editor session and independent of the system cl
|
|||||||
|
|
||||||
In Normal, Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges replace the stored targeting. Target-building commands themselves do not recreate old cursors.
|
In Normal, Visual change/cut targeting replays its resolved motions and counts at each current caret: `vecitem` followed later by `.` changes a fresh whole word, even if its length differs. If targeting was rebuilt by cloning, occurrence gathering, search, or overlap merging, repeat uses relative whole-grapheme spans instead. Equal spans broadcast; unequal spans require matching target counts. In Visual, the current explicit ranges replace the stored targeting. Target-building commands themselves do not recreate old cursors.
|
||||||
|
|
||||||
Clipboard paste captures the fragments actually pasted, so a later yank does not change repeat's payload. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. A refusal or cardinality change during replay leaves text, caret/primary state, clipboard, and history unchanged.
|
Clipboard paste captures the fragments actually pasted, so a later yank or external clipboard change does not change repeat's payload; dot paste never rereads the OS. One fragment broadcasts; equal fragment/target counts pair; mismatches refuse atomically. Other per-target edit data, such as partially effective Insert deletion, obeys the same conservative distribution policy. Dot cut/change follows the current clipboard policy and stages publication until replay validation finishes. A refusal or provider error preserves editor text, caret/primary state, register caches, history, and recipe; already-published OS data cannot be transactionally restored.
|
||||||
|
|
||||||
Each repeat returns to Normal and creates one undo entry restoring the full caret set and primary. Original typing/paste/typing boundaries remain separate; dot replays the selected committed group as one transaction. Typed text retains scalar insertion boundaries, preserving combining/ZWJ interactions with surrounding graphemes; bracketed paste remains one bulk literal operation. Trailing Insert movement is not repeated.
|
Each repeat returns to Normal and creates one undo entry restoring the full caret set and primary. Original typing/paste/typing boundaries remain separate; dot replays the selected committed group as one transaction. Typed text retains scalar insertion boundaries, preserving combining/ZWJ interactions with surrounding graphemes; bracketed paste remains one bulk literal operation. Trailing Insert movement is not repeated.
|
||||||
|
|
||||||
@@ -182,10 +200,11 @@ Leader bindings require Normal/Visual and the target's existing mode eligibility
|
|||||||
|
|
||||||
## Module seams
|
## Module seams
|
||||||
|
|
||||||
One crate, five modules, no workspace or terminal-coordinate types in the editing backend:
|
One crate, six modules, no workspace or terminal-coordinate types in the editing backend:
|
||||||
|
|
||||||
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard fragments, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
- `editor`: owns buffer lifecycle and per-buffer text, saved state, primary/selection set, modes, search, and undo/redo; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, leader/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||||
|
- `clipboard`: owns native plain-text provider selection and bounded subprocess I/O; it never computes selections or editing offsets.
|
||||||
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure.
|
- `session`: `EditorSession` owns the editor and its Ruby hosts together. It loads configuration once, registers eligible plugins before installing shared bindings, executes selected commands against the active buffer, and retires both host and command ownership on invocation or effect-validation failure.
|
||||||
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
- `tui`: translates terminal events to session input, renders read-only active-buffer state, keeps frontend-owned viewports by stable buffer ID, and advertises `text` and `styled_text` capabilities. It has no plugin lifecycle callback, host storage, or paste acceptance policy.
|
||||||
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
- `ruby`: evaluates configuration and runs isolated plugin hosts. Protocol, deadlines, and process cleanup stay inside this module.
|
||||||
@@ -217,6 +236,7 @@ By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/con
|
|||||||
```ruby
|
```ruby
|
||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
config.leader = "space"
|
config.leader = "space"
|
||||||
|
config.clipboard_mode = "system"
|
||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
@@ -239,6 +259,7 @@ end
|
|||||||
|
|
||||||
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures shared list navigation; `config.modes.tree` overrides it in tree browsing. Omitting `mode` selects Normal. The leader defaults to `"space"` and is applied before configured bindings.
|
Plugin paths resolve relative to the config file, not the shell's working directory. `config.bind key, target, mode: config.modes.normal` overrides one key or leader-prefixed sequence; editing modes are `config.modes.normal`, `config.modes.insert`, `config.modes.command`, `config.modes.search`, and `config.modes.visual`. `config.modes.selection` configures shared list navigation; `config.modes.tree` overrides it in tree browsing. Omitting `mode` selects Normal. The leader defaults to `"space"` and is applied before configured bindings.
|
||||||
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
|
||||||
|
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
|
||||||
|
|
||||||
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
|
||||||
|
|
||||||
@@ -256,6 +277,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
|
|||||||
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
| Normal, Visual | `insert_first_nonblank`, `append_line_end`, `clone_above`, `clone_below` |
|
||||||
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
| Normal | `join_lines`, `undo`, `redo`, `command` |
|
||||||
| Normal | `search`, `paste_before`, `paste_after` |
|
| Normal | `search`, `paste_before`, `paste_after` |
|
||||||
|
| Normal | `paste_system_before`, `paste_system_after` |
|
||||||
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
| Normal, Visual | `select`, `search_next`, `search_previous` |
|
||||||
| Normal, Visual | `repeat_last_edit` |
|
| Normal, Visual | `repeat_last_edit` |
|
||||||
| Normal, Visual | `file_picker`, `file_explorer` |
|
| Normal, Visual | `file_picker`, `file_explorer` |
|
||||||
@@ -265,6 +287,7 @@ Key notation follows [Herdr's readable style](https://herdr.dev/docs/configurati
|
|||||||
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
| Normal, Insert, Visual | `word_next`, `word_previous`, `word_end`, `big_word_next`, `big_word_previous`, `big_word_end` |
|
||||||
| Normal, Insert | `delete` |
|
| Normal, Insert | `delete` |
|
||||||
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
|
| Visual | `copy`, `cut`, `change`, `select_lines`, `select_next`, `skip_occurrence`, `select_all` |
|
||||||
|
| Visual | `copy_system` |
|
||||||
| Normal, Insert, Command, Search, Visual | `normal`, `palette` |
|
| Normal, Insert, Command, Search, Visual | `normal`, `palette` |
|
||||||
| Insert, Command, Search | `backspace` |
|
| Insert, Command, Search | `backspace` |
|
||||||
| Insert | `newline` |
|
| Insert | `newline` |
|
||||||
@@ -313,7 +336,7 @@ JSON uses Ruby's standard library. Protocol Buffers is not installed or required
|
|||||||
|
|
||||||
## Current limits
|
## Current limits
|
||||||
|
|
||||||
- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No system clipboard integration, named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, LSP, or graphical frontend.
|
- One visible buffer at a time; Normal, Insert, Command, Search, and characterwise Visual modes. No named registers, line/block selection, Vim operators, splits/tabs, regex/multiline search, LSP, or graphical frontend. Native system clipboard access currently supports macOS and Linux; Separate mode retains internal editing on unsupported/headless environments.
|
||||||
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance.
|
||||||
- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing.
|
- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing.
|
||||||
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
|
- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
config.leader = "space"
|
config.leader = "space"
|
||||||
|
config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal
|
||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod clipboard;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod ruby;
|
pub mod ruby;
|
||||||
|
|||||||
@@ -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
|
u undo, U redo, : command, / search, n/N next/previous match
|
||||||
Normal/Visual: . repeats last native edit at current targets (one undo group)
|
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
|
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
|
Esc keeps only the primary caret
|
||||||
Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
Visual: motions extend selections, y copy, d/x/Delete cut, c change
|
||||||
I/A insert at each selected line's first nonblank/end
|
I/A insert at each selected line's first nonblank/end
|
||||||
|
|||||||
+8
-3
@@ -21,6 +21,7 @@ pub struct Config {
|
|||||||
pub file_explorer_side: String,
|
pub file_explorer_side: String,
|
||||||
pub file_picker_view: String,
|
pub file_picker_view: String,
|
||||||
pub file_explorer_view: String,
|
pub file_explorer_view: String,
|
||||||
|
pub clipboard_mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
@@ -32,6 +33,7 @@ impl Default for Config {
|
|||||||
file_explorer_side: "right".into(),
|
file_explorer_side: "right".into(),
|
||||||
file_picker_view: "tree".into(),
|
file_picker_view: "tree".into(),
|
||||||
file_explorer_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_explorer_side,
|
||||||
file_picker_view,
|
file_picker_view,
|
||||||
file_explorer_view,
|
file_explorer_view,
|
||||||
|
clipboard_mode,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let mut config = Config::default();
|
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_explorer_side = file_explorer_side;
|
||||||
config.file_picker_view = file_picker_view;
|
config.file_picker_view = file_picker_view;
|
||||||
config.file_explorer_view = file_explorer_view;
|
config.file_explorer_view = file_explorer_view;
|
||||||
|
config.clipboard_mode = clipboard_mode;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
}
|
}
|
||||||
Response::Error {
|
Response::Error {
|
||||||
@@ -213,6 +217,7 @@ enum Response {
|
|||||||
file_explorer_side: String,
|
file_explorer_side: String,
|
||||||
file_picker_view: String,
|
file_picker_view: String,
|
||||||
file_explorer_view: String,
|
file_explorer_view: String,
|
||||||
|
clipboard_mode: String,
|
||||||
},
|
},
|
||||||
#[serde(rename = "result")]
|
#[serde(rename = "result")]
|
||||||
Result {
|
Result {
|
||||||
@@ -608,9 +613,9 @@ end
|
|||||||
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
|
||||||
}
|
}
|
||||||
for frame in [
|
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":[],"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"}"#,
|
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"}"#,
|
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}");
|
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -29,6 +29,7 @@ module Vamp
|
|||||||
search search_next search_previous
|
search search_next search_previous
|
||||||
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
word_next word_previous word_end big_word_next big_word_previous big_word_end
|
||||||
select copy cut change paste_before paste_after
|
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
|
select_lines clone_above clone_below select_next skip_occurrence select_all
|
||||||
file_picker file_explorer
|
file_picker file_explorer
|
||||||
].each do |name|
|
].each do |name|
|
||||||
@@ -55,6 +56,7 @@ module Vamp
|
|||||||
@file_explorer_side = "right"
|
@file_explorer_side = "right"
|
||||||
@file_picker_view = "tree"
|
@file_picker_view = "tree"
|
||||||
@file_explorer_view = "tree"
|
@file_explorer_view = "tree"
|
||||||
|
@clipboard_mode = "system"
|
||||||
end
|
end
|
||||||
|
|
||||||
def plugin(path)
|
def plugin(path)
|
||||||
@@ -88,6 +90,12 @@ module Vamp
|
|||||||
@file_explorer_view = view
|
@file_explorer_view = view
|
||||||
end
|
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
|
def core
|
||||||
Core
|
Core
|
||||||
end
|
end
|
||||||
@@ -122,7 +130,8 @@ module Vamp
|
|||||||
"bindings" => @bindings, "leader" => @leader,
|
"bindings" => @bindings, "leader" => @leader,
|
||||||
"file_explorer_side" => @file_explorer_side,
|
"file_explorer_side" => @file_explorer_side,
|
||||||
"file_picker_view" => @file_picker_view,
|
"file_picker_view" => @file_picker_view,
|
||||||
"file_explorer_view" => @file_explorer_view }
|
"file_explorer_view" => @file_explorer_view,
|
||||||
|
"clipboard_mode" => @clipboard_mode }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+179
-2
@@ -54,6 +54,12 @@ impl EditorSession {
|
|||||||
config.file_explorer_view
|
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();
|
let mut plugins = BTreeMap::new();
|
||||||
for path in config.plugins {
|
for path in config.plugins {
|
||||||
match RubyPlugin::start(&path) {
|
match RubyPlugin::start(&path) {
|
||||||
@@ -151,12 +157,38 @@ mod tests {
|
|||||||
|
|
||||||
fn open(&self, config: &str) -> EditorSession {
|
fn open(&self, config: &str) -> EditorSession {
|
||||||
let config = self.file("config.rb", config);
|
let config = self.file("config.rb", config);
|
||||||
EditorSession::open(
|
let mut session = EditorSession::open(
|
||||||
Some(self.0.join("buffer.txt")),
|
Some(self.0.join("buffer.txt")),
|
||||||
vec!["text".into()],
|
vec!["text".into()],
|
||||||
Some(&config),
|
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) {
|
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]
|
#[test]
|
||||||
fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() {
|
fn selection_bindings_remap_palette_navigation_without_changing_editing_bindings() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
@@ -1011,6 +1175,7 @@ end
|
|||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
let buffer = fixture.file("buffer.txt", "old elephant zebra");
|
let buffer = fixture.file("buffer.txt", "old elephant zebra");
|
||||||
let mut session = EditorSession::open(Some(buffer), vec!["text".into()], None).unwrap();
|
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'] {
|
for key in ['v', 'e', 'c'] {
|
||||||
session.handle(Input::Char(key));
|
session.handle(Input::Char(key));
|
||||||
}
|
}
|
||||||
@@ -1604,6 +1769,18 @@ end
|
|||||||
r#"config.file_explorer_view = """#,
|
r#"config.file_explorer_view = """#,
|
||||||
"file explorer view must not be empty",
|
"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", "core.insert""#, "TypeError"),
|
||||||
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
|
(r#"config.bind "n", nil, mode: "insert""#, "TypeError"),
|
||||||
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
|
(r#"config.bind "n", config.modes.insert"#, "TypeError"),
|
||||||
|
|||||||
Reference in New Issue
Block a user