From 88619505c3bcbc84346dbc91ff4e1e3471064a33 Mon Sep 17 00:00:00 2001 From: Dimas Victor Date: Sun, 1 Nov 2026 21:42:15 +0800 Subject: [PATCH] feat(terminal): paste clipboard images into remote SSH terminals (#699) In a remote-project window, pasting a clipboard image into the terminal uploads it to the SSH host that owns the terminal (new term_store_image command over the existing remote RPC) and inserts the POSIX-quoted path after a successful upload, without a newline. Images go to a private per-user terminal-images directory (0700/0600, UUID names, no-follow descriptor-relative writes) and expire after 24 hours on the next upload. Failures notify and insert nothing; text paste and local terminals are unchanged. Images over 20 MiB are rejected in both the frontend and the host. Co-authored-by: dvictor357 <187467850+dvictor357@users.noreply.github.com> --- Cargo.lock | 1 + crates/ide-host/Cargo.toml | 3 + crates/ide-host/src/ops/term.rs | 46 ++++ crates/ide-host/src/ops/term/images.rs | 220 +++++++++++++++++ crates/ide-host/src/rpc.rs | 3 + crates/ide-host/tests/terminal_images.rs | 47 ++++ crates/ide-terminal/src/lib.rs | 4 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/term.rs | 11 + src/bindings.ts | 1 + src/features/terminal/TerminalView.tsx | 3 +- src/features/terminal/paste.test.ts | 288 ++++++++++++++++++++++- src/features/terminal/paste.ts | 91 ++++++- src/features/terminal/session.ts | 25 +- src/platform/locales/text/zh.json | 6 +- 15 files changed, 737 insertions(+), 13 deletions(-) create mode 100644 crates/ide-host/src/ops/term/images.rs create mode 100644 crates/ide-host/tests/terminal_images.rs diff --git a/Cargo.lock b/Cargo.lock index ee5c7b81..b7590a3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4241,6 +4241,7 @@ dependencies = [ "ide-vfs", "ide-workspace", "parking_lot", + "rustix", "serde", "serde_json", "specta", diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index a6e21c88..6f2c1ce5 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -54,5 +54,8 @@ tempfile.workspace = true ide-ssh = { workspace = true, features = ["testing"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] } +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", features = ["fs", "process"] } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index 50d8ac8c..c7a284c9 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -3,6 +3,21 @@ use crate::Host; use ide_core::{IdeResult, TerminalId}; use ide_terminal::{TerminalInfo, TerminalOptions}; +mod images; + +const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024; + +pub async fn store_image(host: &Host, id: TerminalId, data: Vec, mime: String) -> IdeResult { + if data.len() > MAX_IMAGE_BYTES { + return Err(ide_core::IdeError::invalid("image is too large to paste (limit 20 MB)")); + } + if !host.terminals.contains(id) { + return Err(ide_core::IdeError::invalid("terminal session not found")); + } + let data_dir = host.data_dir.clone(); + host.blocking(move || images::store(&data_dir, &data, &mime)).await +} + pub fn spawn( host: &Host, window: &str, @@ -47,3 +62,34 @@ pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> { pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult { host.terminals.is_busy(id) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::HostConfig; + use ide_core::{NullSink, error::ErrorKind}; + use std::sync::Arc; + + #[tokio::test] + async fn image_size_limit_is_checked_before_storage() { + let root = tempfile::tempdir().unwrap(); + let host = Host::new(HostConfig { + config_dir: root.path().join("config"), + data_dir: root.path().join("data"), + local_data_dir: root.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + let id = TerminalId::next(); + let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES + 1], "image/png".into()).await.unwrap_err(); + assert_eq!(error.kind, ErrorKind::InvalidInput); + assert_eq!(error.message, "image is too large to paste (limit 20 MB)"); + let error = store_image(&host, id, vec![0; MAX_IMAGE_BYTES], "image/png".into()).await.unwrap_err(); + assert_eq!(error.message, "terminal session not found"); + assert!(!host.data_dir.join("terminal-images").exists()); + } +} diff --git a/crates/ide-host/src/ops/term/images.rs b/crates/ide-host/src/ops/term/images.rs new file mode 100644 index 00000000..6a45926f --- /dev/null +++ b/crates/ide-host/src/ops/term/images.rs @@ -0,0 +1,220 @@ +use ide_core::{IdeError, IdeResult}; +use std::path::Path; + +#[cfg(unix)] +mod unix { + use super::*; + use rustix::fs::{self, AtFlags, Dir, Mode, OFlags}; + use std::fs::File; + use std::io::{self, Write}; + use std::os::unix::fs::MetadataExt; + use std::time::{Duration, SystemTime}; + + const LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); + const DIRECTORY: &str = "terminal-images"; + + fn extension(mime: &str) -> IdeResult<&str> { + match mime { + "image/png" => Ok("png"), + "image/jpeg" => Ok("jpg"), + "image/gif" => Ok("gif"), + "image/webp" => Ok("webp"), + "image/bmp" => Ok("bmp"), + "image/svg+xml" => Ok("svg"), + "image/tiff" => Ok("tiff"), + "image/x-icon" => Ok("ico"), + _ => Err(IdeError::invalid("unsupported image type")), + } + } + + fn open_directory(data_dir: &Path) -> io::Result { + if !data_dir.is_absolute() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute")); + } + // The configured data directory and its ancestors are the host's trust boundary. + let parent = + fs::open(data_dir, OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, Mode::empty())?; + match fs::mkdirat(&parent, DIRECTORY, Mode::from_bits_truncate(0o700)) { + Ok(()) | Err(rustix::io::Errno::EXIST) => (), + Err(e) => return Err(e.into()), + } + let dir: File = fs::openat( + &parent, + DIRECTORY, + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )? + .into(); + let meta = dir.metadata()?; + if meta.uid() != rustix::process::geteuid().as_raw() || meta.mode() & 0o7777 != 0o700 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "terminal image directory is not private or owned by the current user", + )); + } + Ok(dir) + } + + fn owned_name(name: &str) -> bool { + let name = name.strip_prefix(".partial-").unwrap_or(name); + let Some((id, ext)) = name.strip_prefix("image-").and_then(|s| s.split_once('.')) else { return false }; + id.len() == 32 + && id.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + && matches!(ext, "png" | "jpg" | "gif" | "webp" | "bmp" | "svg" | "tiff" | "ico") + } + + fn cleanup(dir: &File, now: SystemTime) -> io::Result<()> { + for entry in Dir::read_from(dir)? { + let entry = entry?; + let name = entry.file_name(); + if !name.to_str().is_ok_and(owned_name) { + continue; + } + // Never follow symlinks; opening nonblocking avoids hanging on an unexpected FIFO. + let fd = match fs::openat( + dir, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => fd, + Err(_) => continue, + }; + let file = File::from(fd); + let meta = file.metadata()?; + if meta.is_file() + && meta.uid() == rustix::process::geteuid().as_raw() + && meta + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= LIFETIME) + { + match fs::unlinkat(dir, name, AtFlags::empty()) { + Ok(()) | Err(rustix::io::Errno::NOENT) => (), + Err(error) => return Err(error.into()), + } + } + } + Ok(()) + } + + fn store_with( + data_dir: &Path, + data: &[u8], + mime: &str, + now: SystemTime, + write: impl FnOnce(&mut File, &[u8]) -> io::Result<()>, + ) -> IdeResult { + let ext = extension(mime)?; + let dir = open_directory(data_dir)?; + cleanup(&dir, now)?; + let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext); + let temporary = format!(".partial-{name}"); + let fd = fs::openat( + &dir, + temporary.as_str(), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_bits_truncate(0o600), + ) + .map_err(io::Error::from)?; + let mut file = File::from(fd); + if let Err(error) = write(&mut file, data) { + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + return Err(error.into()); + } + if let Err(error) = fs::linkat(&dir, temporary.as_str(), &dir, name.as_str(), AtFlags::empty()) { + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + return Err(io::Error::from(error).into()); + } + let _ = fs::unlinkat(&dir, temporary.as_str(), AtFlags::empty()); + Ok(data_dir.join(DIRECTORY).join(name).to_string_lossy().into_owned()) + } + + pub(in crate::ops::term) fn store(data_dir: &Path, data: &[u8], mime: &str) -> IdeResult { + store_with(data_dir, data, mime, SystemTime::now(), |file, bytes| file.write_all(bytes)) + } + + #[cfg(test)] + mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + + #[test] + fn stores_bytes_with_private_modes_and_unpredictable_names() { + let root = tempfile::tempdir().unwrap(); + let first = store(root.path(), b"image bytes", "image/png").unwrap(); + let second = store(root.path(), b"other", "image/jpeg").unwrap(); + assert_ne!(first, second); + assert_eq!(fs::read(&first).unwrap(), b"image bytes"); + assert_eq!(fs::metadata(&first).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(fs::metadata(root.path().join(DIRECTORY)).unwrap().permissions().mode() & 0o777, 0o700); + assert!(owned_name(Path::new(&first).file_name().unwrap().to_str().unwrap())); + } + + #[test] + fn expiry_only_removes_owned_regular_files_at_boundary() { + let root = tempfile::tempdir().unwrap(); + let dir = open_directory(root.path()).unwrap(); + let path = root.path().join(DIRECTORY); + let now = SystemTime::now(); + let names = [ + "image-00000000000000000000000000000001.png", + "image-00000000000000000000000000000002.png", + "image-00000000000000000000000000000003.png", + "unrelated.png", + ]; + for (name, time) in names.iter().zip([ + now - LIFETIME, + now - LIFETIME + Duration::from_secs(1), + now + Duration::from_secs(1), + now - LIFETIME, + ]) { + let file = File::create(path.join(name)).unwrap(); + file.set_times(fs::FileTimes::new().set_modified(time)).unwrap(); + } + let link = path.join("image-00000000000000000000000000000004.png"); + symlink(path.join(names[0]), &link).unwrap(); + cleanup(&dir, now).unwrap(); + assert!(!path.join(names[0]).exists()); + for name in &names[1..] { + assert!(path.join(name).exists()); + } + assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink()); + } + + #[test] + fn rejects_symlink_and_insecure_directory() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let target = root.path().join(DIRECTORY); + symlink(outside.path(), &target).unwrap(); + assert!(store(root.path(), b"x", "image/png").is_err()); + fs::remove_file(&target).unwrap(); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(store(root.path(), b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + } + + #[test] + fn failed_write_removes_partial_file() { + let root = tempfile::tempdir().unwrap(); + let result = store_with(root.path(), b"partial", "image/png", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + Err(io::Error::other("simulated failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), 0); + } + } +} + +#[cfg(unix)] +pub(super) use unix::store; + +#[cfg(not(unix))] +pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult { + Err(IdeError::internal("private terminal image storage is unsupported on this platform")) +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 78605b1e..8b758d6b 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -231,6 +231,9 @@ impl Dispatcher { let on_output = c.channel("onOutput")?; ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output) }) + .add("term_store_image", |c| async move { + ops::term::store_image(&c.host, c.arg("id")?, c.arg("data")?, c.arg("mime")?).await + }) .add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::("data")?) }) .add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) }) .add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) }) diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs new file mode 100644 index 00000000..13605357 --- /dev/null +++ b/crates/ide-host/tests/terminal_images.rs @@ -0,0 +1,47 @@ +#![cfg(unix)] + +use ide_core::{NullSink, TerminalId}; +use ide_host::{Host, HostConfig, ops}; +use ide_terminal::TerminalOptions; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn storage_requires_a_registered_terminal() { + let root = tempfile::tempdir().unwrap(); + let data_dir = root.path().join("data"); + std::fs::create_dir(&data_dir).unwrap(); + let host = Host::new(HostConfig { + config_dir: root.path().join("config"), + data_dir: data_dir.clone(), + local_data_dir: data_dir.clone(), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + assert!(ops::term::store_image(&host, TerminalId::next(), vec![1], "image/png".into()).await.is_err()); + assert!(!data_dir.join("terminal-images").exists()); + let terminal = host + .terminals + .spawn( + TerminalOptions { + cwd: root.path().into(), + shell: Some("/bin/sh".into()), + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + }, + Arc::new(NullSink), + Box::new(|_| {}), + ) + .unwrap(); + let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], "image/png".into()).await.unwrap(); + assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + host.terminals.kill(terminal.id).unwrap(); + assert!(ops::term::store_image(&host, terminal.id, vec![4], "image/png".into()).await.is_err()); + assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 1); +} diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 9caadf58..82df70cd 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -178,6 +178,10 @@ impl TerminalManager { let Some(pid) = session.info.pid else { return Ok(false) }; Ok(foreground_busy(&session, pid)) } + /// Whether a terminal session is still registered. + pub fn contains(&self, id: TerminalId) -> bool { + self.sessions.lock().contains_key(&id) + } pub fn list(&self) -> Vec { let mut out: Vec = self.sessions.lock().values().map(|s| s.info.clone()).collect(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ba282249..4457d138 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -298,6 +298,7 @@ pub fn builder() -> tauri_specta::Builder { vcs::vcs_changelists_save, vcs::vcs_write_patch, term::term_spawn, + term::term_store_image, term::term_write, term::term_resize, term::term_kill, diff --git a/src-tauri/src/commands/term.rs b/src-tauri/src/commands/term.rs index 6f2e10ea..cf453707 100644 --- a/src-tauri/src/commands/term.rs +++ b/src-tauri/src/commands/term.rs @@ -24,6 +24,17 @@ pub async fn term_spawn( ) } +#[tauri::command] +#[specta::specta] +pub async fn term_store_image( + state: State<'_, AppState>, + id: TerminalId, + data: Vec, + mime: String, +) -> IdeResult { + ide_host::ops::term::store_image(&state.host, id, data, mime).await +} + #[tauri::command] #[specta::specta] pub fn term_write(state: State<'_, AppState>, id: TerminalId, data: String) -> IdeResult<()> { diff --git a/src/bindings.ts b/src/bindings.ts index f4603e36..b3ea339c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -551,6 +551,7 @@ export const commands = { * to start the shell takes tens of milliseconds, a frozen UI if run there. */ termSpawn: (options: TerminalOptions, onOutput: Channel) => typedError(__TAURI_INVOKE("term_spawn", { options, onOutput })), + termStoreImage: (id: TerminalId, data: number[], mime: string) => typedError(__TAURI_INVOKE("term_store_image", { id, data, mime })), termWrite: (id: TerminalId, data: string) => typedError(__TAURI_INVOKE("term_write", { id, data })), termResize: (id: TerminalId, cols: number, rows: number) => typedError(__TAURI_INVOKE("term_resize", { id, cols, rows })), termKill: (id: TerminalId) => typedError(__TAURI_INVOKE("term_kill", { id })), diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index f9868e1a..12d5b0ae 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -31,7 +31,6 @@ import { type PaneLayout, type TerminalTab, } from "./store"; -import { pasteTerminalClipboard } from "./paste"; import { TerminalFindBar } from "./FindBar"; import styles from "./Terminal.module.css"; @@ -103,7 +102,7 @@ function paneMenu(e: MouseEvent, key: string) { { label: "Paste", shortcut: mac ? "⌘V" : "Ctrl+Shift+V", - run: () => void pasteTerminalClipboard((text) => session.paste(text)), + run: () => void session.pasteClipboard(), }, { label: "Select All", run: () => session.term.selectAll() }, { kind: "separator" }, diff --git a/src/features/terminal/paste.test.ts b/src/features/terminal/paste.test.ts index 27dad6f5..726f1c8b 100644 --- a/src/features/terminal/paste.test.ts +++ b/src/features/terminal/paste.test.ts @@ -1,9 +1,16 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Terminal } from "@xterm/xterm"; import { commands } from "@/platform/ipc"; -import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; +import { installTerminalPaste, pasteTerminalClipboard, type RemotePasteTarget } from "./paste"; -beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +const notify = vi.hoisted(() => vi.fn()); +vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) })); + +beforeEach(() => { + vi.stubGlobal("__TAURI_INTERNALS__", {}); + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: [] }); + notify.mockClear(); +}); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -78,3 +85,280 @@ it("passes recovered multiline paths through xterm bracketed paste", async () => term.dispose(); } }); + +function image(buffer: () => Promise = async () => new Uint8Array([1, 2]).buffer, size = 2): Blob { + return { type: "image/png", size, arrayBuffer: buffer } as Blob; +} + +function imageEvent(blob: Blob, text = "") { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => text, files: [blob], types: ["Files"] }, + }); + return event; +} + +function remoteTarget(current = () => true): RemotePasteTarget { + return { id: 42 as RemotePasteTarget["id"], supported: true, current }; +} + +it.each(["event", "menu"])("rejects oversized %s images before reading or uploading", async (route) => { + const read = vi.fn(async () => new ArrayBuffer(0)); + const blob = image(read, 20 * 1024 * 1024 + 1); + const store = vi.spyOn(commands, "termStoreImage"); + const paste = vi.fn(); + const host = document.createElement("div"); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + if (route === "event") { + const event = imageEvent(blob); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + } else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType: async () => blob }] }, + }); + await pasteTerminalClipboard(paste, () => remoteTarget()); + } + await vi.waitFor(() => + expect(notify).toHaveBeenCalledWith({ + level: "error", + title: "Cannot paste image", + body: "Image is too large to paste (limit 20 MB).", + }), + ); + expect(read).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + remove(); +}); + +it("allows images at the size limit", async () => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/image.png" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + host.dispatchEvent(imageEvent(image(undefined, 20 * 1024 * 1024))); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("'/remote/image.png'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(notify).not.toHaveBeenCalled(); + remove(); +}); + +it("claims remote image events before xterm and pastes a quoted path only after upload", async () => { + let finish!: (result: { status: "ok"; data: string }) => void; + const store = vi.spyOn(commands, "termStoreImage").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + host.addEventListener("paste", () => paste("xterm")); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png")); + expect(paste).not.toHaveBeenCalled(); + finish({ status: "ok", data: "/tmp/a'b c.png" }); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("'/tmp/a'\\''b c.png'")); + remove(); +}); + +it("reads an image from the explicit paste menu after text and file recovery", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/stale.png"] }); + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/image.png" }); + const getType = vi.fn(async () => image()); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType }] }, + }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => remoteTarget()); + expect(getType).toHaveBeenCalledWith("image/png"); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(paste).toHaveBeenCalledWith("'/remote/image.png'"); +}); + +it("recovers remote menu file paths when image clipboard reading is unavailable", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/file.txt"] }); + const store = vi.spyOn(commands, "termStoreImage"); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => remoteTarget()); + expect(paste).toHaveBeenCalledWith("/local/file.txt"); + expect(store).not.toHaveBeenCalled(); +}); + +it("uploads a menu image even if reading clipboard text is rejected", async () => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/image.png" }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => { + throw new Error("no text"); + }, + read: async () => [{ types: ["image/png"], getType: async () => image() }], + }, + }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => remoteTarget()); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(paste).toHaveBeenCalledWith("'/remote/image.png'"); + expect(notify).not.toHaveBeenCalled(); +}); + +it("uploads image events instead of recovering a local image path", async () => { + const list = vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/photo.png"] }); + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/remote/photo.png" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("'/remote/photo.png'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(list).not.toHaveBeenCalled(); + remove(); +}); + +it("does not upload a menu image when the target changes while reading", async () => { + let valid = true; + let finish!: (value: Blob) => void; + const getType = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { readText: async () => "", read: async () => [{ types: ["image/png"], getType }] }, + }); + const store = vi.spyOn(commands, "termStoreImage"); + const paste = vi.fn(); + const pending = pasteTerminalClipboard(paste, () => remoteTarget(() => valid)); + await vi.waitFor(() => expect(getType).toHaveBeenCalled()); + valid = false; + finish(image()); + await pending; + expect(store).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); +}); + +it.each(["read", "upload", "unsupported"])("reports image %s failure without pasting", async (failure) => { + const store = vi.spyOn(commands, "termStoreImage").mockRejectedValue(new Error("upload failed")); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => ({ ...remoteTarget(), supported: failure !== "unsupported" })); + host.dispatchEvent( + imageEvent( + image( + failure === "read" + ? async () => { + throw new Error("read failed"); + } + : undefined, + ), + ), + ); + await vi.waitFor(() => expect(notify).toHaveBeenCalled()); + expect(paste).not.toHaveBeenCalled(); + if (failure !== "upload") expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["read", "upload"])("ignores an image after its target is replaced during %s", async (stage) => { + let valid = true; + let finishRead!: (value: ArrayBuffer) => void; + let finishUpload!: (value: { status: "ok"; data: string }) => void; + const store = vi.spyOn(commands, "termStoreImage").mockImplementation( + () => + new Promise((resolve) => { + finishUpload = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget(() => valid)); + host.dispatchEvent( + imageEvent( + image( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ), + ), + ); + await vi.waitFor(() => expect(finishRead).toBeDefined()); + if (stage === "upload") { + finishRead(new Uint8Array([1]).buffer); + await vi.waitFor(() => expect(store).toHaveBeenCalled()); + } + valid = false; + if (stage === "read") finishRead(new Uint8Array([1]).buffer); + else finishUpload({ status: "ok", data: "/remote/image.png" }); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(paste).not.toHaveBeenCalled(); + if (stage === "read") expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it("does not paste after the event handler is disposed during upload", async () => { + let finish!: (value: { status: "ok"; data: string }) => void; + const store = vi.spyOn(commands, "termStoreImage").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(store).toHaveBeenCalled()); + remove(); + finish({ status: "ok", data: "/remote/image.png" }); + await Promise.resolve(); + await Promise.resolve(); + expect(paste).not.toHaveBeenCalled(); +}); + +it("keeps local image-only file recovery unchanged", async () => { + const list = vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/local/photo.png"] }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste); + const event = imageEvent(image()); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("/local/photo.png")); + expect(list).toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it("leaves local image and remote text events to existing paste handlers", () => { + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => remoteTarget()); + const event = imageEvent(image(), "plain"); + host.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(store).not.toHaveBeenCalled(); + remove(); + const local = installTerminalPaste(host, paste); + const text = imageEvent(image(), "plain"); + host.dispatchEvent(text); + expect(text.defaultPrevented).toBe(false); + expect(store).not.toHaveBeenCalled(); + local(); +}); diff --git a/src/features/terminal/paste.ts b/src/features/terminal/paste.ts index 2ce06e9a..c0dd74e2 100644 --- a/src/features/terminal/paste.ts +++ b/src/features/terminal/paste.ts @@ -1,8 +1,50 @@ import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; +import { call, commands, type TerminalId } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; +import { notifications } from "@/workbench/contributions"; -export function installTerminalPaste(host: HTMLElement, paste: (text: string) => void): () => void { +export interface RemotePasteTarget { + id: TerminalId; + supported: boolean; + current: () => boolean; +} + +type Target = () => RemotePasteTarget | null; + +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; + +async function pasteImage(image: Blob, target: RemotePasteTarget, paste: (text: string) => void) { + try { + if (image.size > MAX_IMAGE_BYTES) throw new Error(tx`Image is too large to paste (limit ${20} MB).`); + const data = Array.from(new Uint8Array(await image.arrayBuffer())); + if (!target.current()) return; + if (!target.supported) throw new Error(tx("Remote server does not support image paste.")); + const path = await call(commands.termStoreImage(target.id, data, image.type)); + if (target.current()) paste(`'${path.replaceAll("'", "'\\''")}'`); + } catch (error) { + if (target.current()) + notifications().notify({ + level: "error", + title: tx("Cannot paste image"), + body: error instanceof Error ? error.message : String(error), + }); + } +} + +export function installTerminalPaste(host: HTMLElement, paste: (text: string) => void, getTarget?: Target): () => void { let alive = true; const onPaste = (event: ClipboardEvent) => { + const transfer = event.clipboardData; + const target = getTarget?.(); + if (!event.defaultPrevented && target && transfer && !transfer.getData("text/plain")) { + const image = Array.from(transfer.files).find((file) => file.type.startsWith("image/")); + if (image) { + event.preventDefault(); + event.stopImmediatePropagation(); + void pasteImage(image, { ...target, current: () => alive && target.current() }, paste); + return; + } + } pasteClipboardFiles(event, (text) => { if (alive) paste(text); }); @@ -14,11 +56,48 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) => }; } -export async function pasteTerminalClipboard(paste: (text: string) => void) { +export async function pasteTerminalClipboard(paste: (text: string) => void, getTarget?: Target) { + const target = getTarget?.(); try { - const text = await readClipboardText(); - if (text) paste(text); - } catch { - /* Clipboard access may be denied. */ + // Native paste preserves clipboard blobs where Clipboard.read() is unavailable. + if (target && document.queryCommandSupported?.("paste") && document.execCommand("paste")) return; + const images = + target && navigator.clipboard?.read + ? navigator.clipboard.read().then( + (items) => ({ items }), + (error: unknown) => ({ error }), + ) + : null; + const text = target ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") : await readClipboardText(); + if (target && !target.current()) return; + if (text) { + paste(text); + return; + } + if (!target) return; + if (images) { + const result = await images; + if ("error" in result) throw result.error; + if (!target.current()) return; + for (const item of result.items) { + const mime = item.types.find((type) => type.startsWith("image/")); + if (mime) { + const image = await item.getType(mime); + if (target.current()) await pasteImage(image, target, paste); + return; + } + } + } + const paths = await readClipboardText(); + if (!target.current()) return; + if (paths) paste(paths); + else if (!images) throw new Error(tx("Clipboard image reading is unavailable.")); + } catch (error) { + if (target?.current()) + notifications().notify({ + level: "error", + title: tx("Cannot paste image"), + body: error instanceof Error ? error.message : String(error), + }); } } diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 7a393dd1..1f620980 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -17,6 +17,7 @@ import { ImeCommitGate } from "./imeCommit"; import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; import { useProject } from "@/platform/project"; +import { useRemote } from "@/platform/remote"; import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings"; import { editorPrefs } from "@/platform/editorSettings"; import { @@ -278,7 +279,7 @@ export class TerminalSession { }), ); this.disposables.push( - { dispose: installTerminalPaste(this.host, (text) => this.paste(text)) }, + { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.remotePasteTarget) }, { dispose: installTerminalDrop(this.host, (text) => this.paste(text)) }, ); this.host.addEventListener("mousedown", this.onMiddleClick); @@ -287,9 +288,29 @@ export class TerminalSession { private onMiddleClick = (e: MouseEvent) => { if (e.button !== 1 || !workflowSettings().terminal.pasteOnMiddleClick) return; e.preventDefault(); - void pasteTerminalClipboard((text) => this.paste(text)); + void this.pasteClipboard(); }; + private remotePasteTarget = () => { + const remote = useRemote.getState().session; + const id = this.ptyId; + if (!remote || id === null || this.disposed) return null; + const generation = this.generation; + return { + id, + supported: remote.commands.includes("term_store_image"), + current: () => + !this.disposed && + this.ptyId === id && + this.generation === generation && + useRemote.getState().session === remote, + }; + }; + + pasteClipboard() { + if (this.remotePasteTarget()) this.focus(); + return pasteTerminalClipboard((text) => this.paste(text), this.remotePasteTarget); + } /** Applies Settings › Tools › Terminal changes to a live session. */ applySettings(t: TerminalSettingsValid) { const font = fontFor(t); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 43edfc7f..daeb595f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5490,5 +5490,9 @@ "Invalid .NET project path": ".NET 项目路径无效", ".NET launch profile '{profile_name}' is missing or is not a Project profile": ".NET 启动配置“{profile_name}”不存在或不是 Project 类型", ".NET output resolution timed out": ".NET 构建产物路径查询超时", - ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build" + ".NET build output is missing; run dotnet build before debugging": ".NET 构建产物不存在,请在调试前运行 dotnet build", + "Cannot paste image": "无法粘贴图片", + "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", + "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", + "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。" }