feat(terminal): clipboard paste with bracketed-paste support
Pasting (Ctrl-V or browser menu) now sends clipboard text to the PTY.
- on:paste reads clipboard_data().get_data("text"); newlines normalized
to CR (the line-submit byte) via the pure, unit-tested prepare_paste()
- when the app enabled bracketed paste (DECSET 2004, exposed via the new
TerminalCore::bracketed_paste() proxy), the text is wrapped in
ESC[200~ / ESC[201~ so vim et al. don't auto-indent each pasted line
- pasting returns the view to live mode + scrolls to bottom, matching the
keydown handler
- Cargo.toml: add ClipboardEvent/DataTransfer (and explicit WheelEvent)
to the web-sys feature allowlist
- right-click is left to the browser's default menu (avoids requiring
clipboard-read permission); Ctrl-V is the primary path
Adds a prepare_paste unit test (14 terminal tests total) and marks paste
done in TERMINAL_ROADMAP.md. Also fixes a clippy manual_contains lint in
an existing test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,8 @@ use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::js_sys::{Array, Function};
|
||||
use web_sys::{
|
||||
BinaryType, ErrorEvent, Event, HtmlElement, KeyboardEvent, MessageEvent,
|
||||
ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent,
|
||||
BinaryType, ClipboardEvent, ErrorEvent, Event, HtmlElement, KeyboardEvent,
|
||||
MessageEvent, ResizeObserver, ResizeObserverEntry, WebSocket, WheelEvent,
|
||||
};
|
||||
|
||||
use super::core::{TerminalCore, TerminalRow, TerminalSegment};
|
||||
@@ -386,6 +386,50 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
}
|
||||
};
|
||||
|
||||
// -- Paste handler ------------------------------------------------------
|
||||
//
|
||||
// Send clipboard text to the PTY. If the running application enabled
|
||||
// bracketed paste (vim, many shells), the text is wrapped in
|
||||
// ESC[200~ … ESC[201~ so the app can tell a paste from typed input (and
|
||||
// skip e.g. auto-indenting every pasted line).
|
||||
let handle_paste = {
|
||||
let paste_mode = view_mode;
|
||||
let paste_prog_scroll = programmatic_scroll_top;
|
||||
let paste_core = core_signal;
|
||||
|
||||
move |event: ClipboardEvent| {
|
||||
let Some(socket) = ws_signal.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let Some(raw) = event
|
||||
.clipboard_data()
|
||||
.and_then(|dt| dt.get_data("text").ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let bracketed = paste_core
|
||||
.try_with_untracked(|core| core.bracketed_paste())
|
||||
== Some(true);
|
||||
let data = prepare_paste(&raw, bracketed);
|
||||
|
||||
// Pasting is an interaction at the prompt → return to live view.
|
||||
if !paste_mode.get_untracked().is_live() {
|
||||
paste_mode.set(ViewMode::Live);
|
||||
sync_scroll_to_bottom(terminal_ref, paste_prog_scroll);
|
||||
}
|
||||
|
||||
event.prevent_default();
|
||||
send_terminal_message(
|
||||
&socket,
|
||||
&ClientTerminalMessage::Input { data },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let handle_click = move |_| {
|
||||
focus_terminal(terminal_ref);
|
||||
};
|
||||
@@ -437,6 +481,7 @@ pub fn TerminalPanel() -> impl IntoView {
|
||||
on:keydown=handle_keydown
|
||||
on:scroll=handle_scroll
|
||||
on:wheel=handle_wheel
|
||||
on:paste=handle_paste
|
||||
>
|
||||
<span node_ref=measure_ref class="terminal-measure" aria-hidden="true">
|
||||
{MEASURE_SAMPLE_TEXT}
|
||||
@@ -813,6 +858,20 @@ fn wheel_delta_to_rows(delta_y: f64, delta_mode: u32) -> usize {
|
||||
(rows.round() as usize).clamp(1, MAX_ROWS_PER_EVENT)
|
||||
}
|
||||
|
||||
/// Prepare clipboard text for sending to the PTY.
|
||||
///
|
||||
/// Normalises line endings to CR (`\r`), the byte a terminal expects for line
|
||||
/// submission (Enter sends `\r`). When the application enabled bracketed paste,
|
||||
/// wraps the text in `ESC[200~ … ESC[201~`.
|
||||
fn prepare_paste(raw: &str, bracketed: bool) -> String {
|
||||
let normalized = raw.replace("\r\n", "\r").replace('\n', "\r");
|
||||
if bracketed {
|
||||
format!("\u{1b}[200~{normalized}\u{1b}[201~")
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn map_key_to_terminal_input(event: &KeyboardEvent) -> Option<String> {
|
||||
let key = event.key();
|
||||
|
||||
@@ -872,6 +931,22 @@ mod tests {
|
||||
assert_eq!(wheel_delta_to_rows(100_000.0, 0), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_paste_normalizes_newlines_and_wraps_when_bracketed() {
|
||||
// Plain paste: CRLF and LF both become CR, no wrapping.
|
||||
assert_eq!(prepare_paste("a\r\nb\nc", false), "a\rb\rc");
|
||||
assert_eq!(prepare_paste("echo hi", false), "echo hi");
|
||||
|
||||
// Bracketed paste: same normalization, wrapped in 200~/201~.
|
||||
let esc = '\u{1b}';
|
||||
assert_eq!(
|
||||
prepare_paste("a\nb", true),
|
||||
format!("{esc}[200~a\rb{esc}[201~")
|
||||
);
|
||||
// Empty-ish content still wraps consistently when bracketed.
|
||||
assert_eq!(prepare_paste("", true), format!("{esc}[200~{esc}[201~"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_mode_window_is_pinned_to_bottom_regardless_of_scroll_top() {
|
||||
let core = core_with_lines(1000);
|
||||
|
||||
@@ -242,6 +242,13 @@ impl TerminalCore {
|
||||
pub fn alternate_screen(&self) -> bool {
|
||||
self.parser.screen().alternate_screen()
|
||||
}
|
||||
|
||||
/// Whether the running application has enabled bracketed paste mode
|
||||
/// (DECSET 2004). When true, pasted text must be wrapped in
|
||||
/// `ESC[200~ … ESC[201~` so the app can tell paste from typed input.
|
||||
pub fn bracketed_paste(&self) -> bool {
|
||||
self.parser.screen().bracketed_paste()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TerminalCore {
|
||||
@@ -754,7 +761,7 @@ mod tests {
|
||||
// must be present in history.
|
||||
let needle = format!("ln{}", lines - 100);
|
||||
assert!(
|
||||
history.iter().any(|r| *r == needle),
|
||||
history.contains(&needle),
|
||||
"late line {needle:?} must be captured into history"
|
||||
);
|
||||
// And an early line is still there too (under the cap, nothing dropped).
|
||||
|
||||
Reference in New Issue
Block a user