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:
zhangheng
2026-06-11 20:59:03 +08:00
parent a018609a28
commit c886befac7
4 changed files with 96 additions and 9 deletions

View File

@@ -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",
] }

View File

@@ -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 可只支持**可见范围内选区 + 复制**,跨滚动选区列为后续。

View File

@@ -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);

View File

@@ -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).