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:
@@ -39,8 +39,10 @@ wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", default-features = false, features = [
|
||||
"BinaryType",
|
||||
"ClipboardEvent",
|
||||
"CloseEvent",
|
||||
"console",
|
||||
"DataTransfer",
|
||||
"Document",
|
||||
"DomRectReadOnly",
|
||||
"Element",
|
||||
@@ -54,6 +56,7 @@ web-sys = { version = "0.3", default-features = false, features = [
|
||||
"ResizeObserver",
|
||||
"ResizeObserverEntry",
|
||||
"WebSocket",
|
||||
"WheelEvent",
|
||||
"Window",
|
||||
] }
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
| `clear`(含 `ESC[3J` 清 scrollback) | `core.rs::contains_erase_scrollback` |
|
||||
| alt-screen 滚轮 → 方向键翻页 | `component.rs::handle_wheel` |
|
||||
| 基础按键:Ctrl-C/D/L、方向键、Enter/Tab/Esc/Backspace | `component.rs::map_key_to_terminal_input` |
|
||||
| 粘贴(Ctrl-V / 浏览器菜单,含 bracketed paste) | `component.rs::handle_paste` / `prepare_paste` |
|
||||
|
||||
---
|
||||
|
||||
@@ -36,14 +37,15 @@
|
||||
|
||||
### P0 — 复制 / 粘贴 + 文本选区 ⭐ 投入产出比最高
|
||||
|
||||
**现状**:完全没有。无法选中终端文本、无法粘贴。这是「能用 vs 好用」的分界线。
|
||||
**现状**:粘贴**已完成**(见下);选区 / 复制尚未做。
|
||||
|
||||
**计划实现**:
|
||||
1. **粘贴(先做,最简单)**
|
||||
- 监听 `on:paste`(`ClipboardEvent`),读 `clipboard_data().get_data("text")`。
|
||||
- 直接作为 `ClientTerminalMessage::Input` 发送。
|
||||
- 若应用开启 bracketed paste 模式(vt100 的 `screen().bracketed_paste()` 为真),把粘贴内容包进 `ESC[200~ … ESC[201~`,避免 vim 等把粘贴当连续输入触发自动缩进。
|
||||
2. **选区(较复杂)**
|
||||
1. ~~**粘贴(先做,最简单)**~~ ✅ **已完成**
|
||||
- `on:paste`(`ClipboardEvent`)读 `clipboard_data().get_data("text")`,归一换行为 `\r` 后作为 `Input` 发送。
|
||||
- bracketed paste:应用开启时(`core.bracketed_paste()`)包进 `ESC[200~ … ESC[201~`。
|
||||
- 实现于 `component.rs::handle_paste` + 纯函数 `prepare_paste`(有单元测试)。
|
||||
- 右键不拦截,走浏览器默认菜单(避免依赖剪贴板读权限);Ctrl-V 为主路径。
|
||||
2. **选区(较复杂,下一步)**
|
||||
- 因为渲染是 DOM(每行一个 `<div>`、每段一个 `<span>`),可优先尝试**浏览器原生选区**:给可见行允许 `user-select: text`,监听 `copy` 事件,从 `window.getSelection()` 取文本。
|
||||
- 但虚拟滚动只渲染可见窗口,跨窗口选区会断 → 需要把「逻辑行号 → 文本」的映射暴露给选区逻辑,从 `TerminalCore` 的 `history_rows + screen_rows` 直接取纯文本(已有 `row` → 文本的能力,测试里 `row_text` 即是)。
|
||||
- MVP 可只支持**可见范围内选区 + 复制**,跨滚动选区列为后续。
|
||||
|
||||
@@ -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