From 7af2b098cc3910d349c22180b96769abe53decda Mon Sep 17 00:00:00 2001 From: kkmihai Date: Mon, 2 Nov 2026 20:09:17 +0200 Subject: [PATCH] feat(terminal): paste clipboard media as file paths (#880) Pasting an image, audio or video from the clipboard into a local terminal saves the bytes to the per-user terminal cache (20 MB limit, files older than 24 h cleaned up on the next paste, private permissions) and inserts the shell-quoted path without running anything. Copied files paste their original paths, plain text pastes unchanged, and SSH sessions keep the existing remote image-paste route. Paths are quoted per shell (zsh/bash, fish, PowerShell, cmd.exe); fish quoting now escapes backslashes and single quotes. Fixes #838 Co-authored-by: kkMihai <59262001+kkMihai@users.noreply.github.com> --- crates/ide-host/src/ops/term/images.rs | 367 +++++++++++++++++++++-- crates/ide-host/tests/terminal_images.rs | 15 +- e2e/terminal-clipboard.spec.ts | 47 +++ src/features/terminal/paste.test.ts | 173 ++++++++++- src/features/terminal/paste.ts | 114 +++++-- src/features/terminal/session.ts | 14 +- src/platform/clipboard.ts | 13 +- src/platform/locales/text/zh.json | 6 +- 8 files changed, 670 insertions(+), 79 deletions(-) create mode 100644 e2e/terminal-clipboard.spec.ts diff --git a/crates/ide-host/src/ops/term/images.rs b/crates/ide-host/src/ops/term/images.rs index 6a45926f..e0c3261d 100644 --- a/crates/ide-host/src/ops/term/images.rs +++ b/crates/ide-host/src/ops/term/images.rs @@ -1,6 +1,61 @@ use ide_core::{IdeError, IdeResult}; use std::path::Path; +const LIFETIME: std::time::Duration = std::time::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"), + "audio/wav" | "audio/x-wav" => Ok("wav"), + "audio/mpeg" => Ok("mp3"), + "audio/mp4" => Ok("m4a"), + "audio/ogg" => Ok("ogg"), + "audio/webm" | "video/webm" => Ok("webm"), + "audio/flac" => Ok("flac"), + "audio/aac" => Ok("aac"), + "video/mp4" => Ok("mp4"), + "video/ogg" => Ok("ogv"), + "video/quicktime" => Ok("mov"), + _ => Err(IdeError::invalid("unsupported clipboard media type")), + } +} + +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" + | "wav" + | "mp3" + | "m4a" + | "ogg" + | "webm" + | "flac" + | "aac" + | "mp4" + | "ogv" + | "mov" + ) +} + #[cfg(unix)] mod unix { use super::*; @@ -8,24 +63,7 @@ mod unix { 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")), - } - } + use std::time::SystemTime; fn open_directory(data_dir: &Path) -> io::Result { if !data_dir.is_absolute() { @@ -55,14 +93,6 @@ mod unix { 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?; @@ -140,6 +170,7 @@ mod unix { use super::*; use std::fs; use std::os::unix::fs::{PermissionsExt, symlink}; + use std::time::Duration; #[test] fn stores_bytes_with_private_modes_and_unpredictable_names() { @@ -211,10 +242,292 @@ mod unix { } } +#[cfg(windows)] +mod windows { + use super::*; + use std::fs::{self, File, OpenOptions}; + use std::io::{self, Write}; + use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; + use std::time::SystemTime; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + fn open_directory(path: &Path) -> io::Result { + if !path.is_absolute() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "data directory must be absolute")); + } + let dir = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let meta = dir.metadata()?; + if !meta.is_dir() || meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "terminal media directory must not be a reparse point", + )); + } + Ok(dir) + } + + fn cleanup(path: &Path, now: SystemTime) -> io::Result<()> { + for entry in fs::read_dir(path)? { + let entry = entry?; + if !entry.file_name().to_str().is_some_and(owned_name) { + continue; + } + let file = match OpenOptions::new().read(true).custom_flags(FILE_FLAG_OPEN_REPARSE_POINT).open(entry.path()) + { + Ok(file) => file, + Err(_) => continue, + }; + let meta = file.metadata()?; + if meta.is_file() + && meta.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0 + && meta + .modified() + .ok() + .and_then(|time| now.duration_since(time).ok()) + .is_some_and(|age| age >= LIFETIME) + { + match fs::remove_file(entry.path()) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::NotFound => (), + Err(error) => return Err(error), + } + } + } + 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 _parent = open_directory(data_dir)?; + let path = data_dir.join(DIRECTORY); + match fs::create_dir(&path) { + Ok(()) => (), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => (), + Err(error) => return Err(error.into()), + } + let _dir = open_directory(&path)?; + cleanup(&path, now)?; + let name = format!("image-{}.{}", uuid::Uuid::new_v4().simple(), ext); + let target = path.join(&name); + let temporary = path.join(format!(".partial-{name}")); + let mut file = OpenOptions::new().write(true).create_new(true).open(&temporary)?; + let result = write(&mut file, data); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + if let Err(error) = fs::hard_link(&temporary, &target) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + let _ = fs::remove_file(&temporary); + Ok(target.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::time::Duration; + + fn junction(path: &Path, target: &Path) { + let output = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:OPENIDEA_TEST_JUNCTION -Target $env:OPENIDEA_TEST_TARGET -ErrorAction Stop | Out-Null", + ]) + .env("OPENIDEA_TEST_JUNCTION", path) + .env("OPENIDEA_TEST_TARGET", target) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + } + + #[test] + fn rejects_reparse_data_and_storage_directories() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let link = root.path().join("data"); + junction(&link, outside.path()); + assert!(store(&link, b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + let link = root.path().join(DIRECTORY); + junction(&link, outside.path()); + assert!(store(root.path(), b"x", "image/png").is_err()); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 0); + fs::remove_dir(&link).unwrap(); + } + + #[test] + fn expiry_only_removes_owned_regular_media_at_boundary() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + fs::create_dir(&path).unwrap(); + let _dir = open_directory(&path).unwrap(); + let now = SystemTime::now(); + let names = [ + "image-00000000000000000000000000000001.png", + "image-00000000000000000000000000000002.mp3", + ".partial-image-00000000000000000000000000000003.mp4", + "image-00000000000000000000000000000004.webm", + "image-00000000000000000000000000000005.wav", + "unrelated.png", + "image-00000000000000000000000000000006.exe", + ]; + for (name, time) in names.iter().zip([ + now - LIFETIME, + now - LIFETIME, + now - LIFETIME, + now - LIFETIME + Duration::from_secs(1), + now + Duration::from_secs(1), + now - LIFETIME, + 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-00000000000000000000000000000007.png"); + junction(&link, outside.path()); + let directory = path.join("image-00000000000000000000000000000008.png"); + fs::create_dir(&directory).unwrap(); + cleanup(&path, now).unwrap(); + for name in &names[..3] { + assert!(!path.join(name).exists()); + } + for name in &names[3..] { + assert!(path.join(name).exists()); + } + assert!(directory.is_dir()); + assert!(fs::symlink_metadata(&link).unwrap().file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0); + fs::remove_dir(link).unwrap(); + } + + #[test] + fn failed_write_removes_partial_file() { + let root = tempfile::tempdir().unwrap(); + let result = store_with(root.path(), b"partial", "audio/ogg", 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); + } + + #[test] + fn publication_preserves_existing_target() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"new", "video/mp4", SystemTime::now(), |file, bytes| { + file.write_all(bytes)?; + let temporary = fs::read_dir(&path)?.next().unwrap()?.file_name(); + let target = temporary.to_str().unwrap().strip_prefix(".partial-").unwrap(); + fs::write(path.join(target), b"existing") + }); + assert!(result.is_err()); + let files: Vec<_> = fs::read_dir(&path).unwrap().map(|entry| entry.unwrap().path()).collect(); + assert_eq!(files.len(), 1); + assert_eq!(fs::read(&files[0]).unwrap(), b"existing"); + assert!(!files[0].file_name().unwrap().to_str().unwrap().starts_with(".partial-")); + } + + #[test] + fn storage_handles_prevent_directory_replacement() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join(DIRECTORY); + let result = store_with(root.path(), b"x", "image/png", SystemTime::now(), |file, bytes| { + assert!(fs::rename(&path, root.path().join("replaced")).is_err()); + file.write_all(bytes) + }); + assert_eq!(fs::read(result.unwrap()).unwrap(), b"x"); + } + } +} + #[cfg(unix)] pub(super) use unix::store; -#[cfg(not(unix))] +#[cfg(windows)] +pub(super) use windows::store; + +#[cfg(not(any(unix, windows)))] pub(super) fn store(_data_dir: &Path, _data: &[u8], _mime: &str) -> IdeResult { Err(IdeError::internal("private terminal image storage is unsupported on this platform")) } + +#[cfg(all(test, any(unix, windows)))] +mod tests { + use super::*; + + #[test] + fn stores_supported_media_with_expected_extensions_and_unique_names() { + let root = tempfile::tempdir().unwrap(); + let types = [ + ("image/png", "png"), + ("image/jpeg", "jpg"), + ("image/gif", "gif"), + ("image/webp", "webp"), + ("image/bmp", "bmp"), + ("image/svg+xml", "svg"), + ("image/tiff", "tiff"), + ("image/x-icon", "ico"), + ("audio/wav", "wav"), + ("audio/x-wav", "wav"), + ("audio/mpeg", "mp3"), + ("audio/mp4", "m4a"), + ("audio/ogg", "ogg"), + ("audio/webm", "webm"), + ("audio/flac", "flac"), + ("audio/aac", "aac"), + ("video/mp4", "mp4"), + ("video/webm", "webm"), + ("video/ogg", "ogv"), + ("video/quicktime", "mov"), + ]; + let mut paths = std::collections::HashSet::new(); + for (mime, ext) in types { + let path = store(root.path(), mime.as_bytes(), mime).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), mime.as_bytes()); + assert_eq!(Path::new(&path).extension().unwrap(), ext); + assert!(owned_name(Path::new(&path).file_name().unwrap().to_str().unwrap())); + assert!(paths.insert(path)); + } + assert_eq!(std::fs::read_dir(root.path().join(DIRECTORY)).unwrap().count(), types.len()); + } + + #[test] + fn rejects_unsupported_mime_before_creating_storage() { + let root = tempfile::tempdir().unwrap(); + for mime in ["text/plain", "application/octet-stream", "image/unknown", "audio/unknown", "video/unknown", ""] { + let error = store(root.path(), b"x", mime).unwrap_err(); + assert_eq!(error.kind, ide_core::error::ErrorKind::InvalidInput); + assert_eq!(error.message, "unsupported clipboard media type"); + } + assert!(!root.path().join(DIRECTORY).exists()); + } + + #[test] + fn rejects_relative_data_directory() { + assert!(store(Path::new("relative-data"), b"x", "image/png").is_err()); + } +} diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs index 8bb4a3e1..014d1430 100644 --- a/crates/ide-host/tests/terminal_images.rs +++ b/crates/ide-host/tests/terminal_images.rs @@ -1,4 +1,4 @@ -#![cfg(unix)] +#![cfg(any(unix, windows))] use ide_core::{NullSink, TerminalId}; use ide_host::{Host, HostConfig, ops}; @@ -28,8 +28,8 @@ async fn storage_requires_a_registered_terminal() { .spawn( TerminalOptions { cwd: root.path().into(), - shell: Some("/bin/sh".into()), - args: vec![], + shell: Some(if cfg!(windows) { "powershell.exe" } else { "/bin/sh" }.into()), + args: if cfg!(windows) { vec!["-NoLogo".into(), "-NoProfile".into()] } else { vec![] }, env: Default::default(), cols: 80, rows: 24, @@ -40,9 +40,12 @@ async fn storage_requires_a_registered_terminal() { 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]); + for mime in ["image/png", "audio/mpeg", "video/mp4"] { + let path = ops::term::store_image(&host, terminal.id, vec![1, 2, 3], mime.into()).await.unwrap(); + assert_eq!(std::fs::read(path).unwrap(), [1, 2, 3]); + } + assert!(ops::term::store_image(&host, terminal.id, vec![1], "application/octet-stream".into()).await.is_err()); 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); + assert_eq!(std::fs::read_dir(data_dir.join("terminal-images")).unwrap().count(), 3); } diff --git a/e2e/terminal-clipboard.spec.ts b/e2e/terminal-clipboard.spec.ts new file mode 100644 index 00000000..1127762f --- /dev/null +++ b/e2e/terminal-clipboard.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from "@playwright/test"; + +test("local terminal stores clipboard media and pastes one shell-quoted argument", async ({ page }) => { + await page.goto("/"); + const observed = await page.evaluate(async () => { + const ipcPath = "/src/platform/ipc.ts"; + const sessionPath = "/src/features/terminal/session.ts"; + const { commands } = await import(ipcPath); + const { TerminalSession } = await import(sessionPath); + Object.defineProperty(window, "__TAURI_INTERNALS__", { + configurable: true, + value: { transformCallback: () => 1 }, + }); + const stored: { id: number; data: number[]; mime: string }[] = []; + const writes: string[] = []; + commands.termSpawn = async () => ({ status: "ok", data: { id: 838, title: "pwsh", pid: 1 } }); + commands.clipboardFileList = async () => ({ status: "ok", data: [] }); + commands.termStoreImage = async (id: number, data: number[], mime: string) => { + stored.push({ id, data, mime }); + return { status: "ok", data: "C:\\Temp\\a'b c\\image.png" }; + }; + commands.termWrite = async (_id: number, data: string) => writes.push(data); + commands.termResize = async () => undefined; + commands.termKill = async () => undefined; + const container = document.createElement("div"); + container.style.cssText = "position:fixed;inset:0;z-index:99999"; + document.body.append(container); + const session = new TerminalSession({ key: "clipboard", cwd: "C:/Temp", onChange() {}, onFocus() {} }); + try { + session.attach(container); + await session.start(); + const transfer = new DataTransfer(); + transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" })); + const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer }); + container.querySelector("textarea")!.dispatchEvent(event); + const deadline = Date.now() + 5000; + while (!writes.length && Date.now() < deadline) await new Promise((resolve) => requestAnimationFrame(resolve)); + return { stored, writes, claimed: event.defaultPrevented }; + } finally { + session.dispose(); + container.remove(); + } + }); + expect(observed.claimed).toBe(true); + expect(observed.stored).toEqual([{ id: 838, data: [137, 80, 78, 71], mime: "image/png" }]); + expect(observed.writes).toEqual(["'C:\\Temp\\a''b c\\image.png'"]); +}); diff --git a/src/features/terminal/paste.test.ts b/src/features/terminal/paste.test.ts index 726f1c8b..bb01772b 100644 --- a/src/features/terminal/paste.test.ts +++ b/src/features/terminal/paste.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { Terminal } from "@xterm/xterm"; import { commands } from "@/platform/ipc"; -import { installTerminalPaste, pasteTerminalClipboard, type RemotePasteTarget } from "./paste"; +import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste"; const notify = vi.hoisted(() => vi.fn()); vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify }) })); @@ -98,8 +98,8 @@ function imageEvent(blob: Blob, text = "") { return event; } -function remoteTarget(current = () => true): RemotePasteTarget { - return { id: 42 as RemotePasteTarget["id"], supported: true, current }; +function remoteTarget(current = () => true): TerminalPasteTarget { + return { id: 42 as TerminalPasteTarget["id"], supported: true, remote: true, shell: "sh", current }; } it.each(["event", "menu"])("rejects oversized %s images before reading or uploading", async (route) => { @@ -123,8 +123,8 @@ it.each(["event", "menu"])("rejects oversized %s images before reading or upload await vi.waitFor(() => expect(notify).toHaveBeenCalledWith({ level: "error", - title: "Cannot paste image", - body: "Image is too large to paste (limit 20 MB).", + title: "Cannot paste clipboard media", + body: "Clipboard media is too large to paste (limit 20 MB).", }), ); expect(read).not.toHaveBeenCalled(); @@ -362,3 +362,166 @@ it("leaves local image and remote text events to existing paste handlers", () => expect(store).not.toHaveBeenCalled(); local(); }); + +function localTarget(shell = "sh", current = () => true): TerminalPasteTarget { + return { ...remoteTarget(current), remote: false, shell }; +} + +it.each(["event", "menu"])("stores raw local clipboard media through %s paste", async (route) => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/a'b c.png" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + if (route === "event") { + host.addEventListener("paste", () => paste("xterm")); + const event = imageEvent(image()); + 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 () => image() }] }, + }); + await pasteTerminalClipboard(paste, () => localTarget()); + } + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a'\\''b c.png'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], "image/png"); + expect(notify).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["event", "menu"])( + "keeps existing copied files on their original quoted paths during %s paste", + async (route) => { + const paths = ["/a b/photo.png", "/it's here/clip.mp4"]; + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: paths }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + if (route === "event") host.dispatchEvent(imageEvent(image(), paths.join("\n"))); + else { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => paths.join("\r\n"), + read: async () => [{ types: ["image/png"], getType: async () => image() }], + }, + }); + await pasteTerminalClipboard(paste, () => localTarget()); + } + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/a b/photo.png' '/it'\\''s here/clip.mp4'")); + expect(store).not.toHaveBeenCalled(); + remove(); + }, +); + +it("reads file URI lists without relying on a native file-list backend", async () => { + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { + files: [], + types: ["text/uri-list"], + getData: (type: string) => + type === "text/uri-list" + ? "# files\r\nfile:///tmp/a%20b.png\r\nhttps://example.com\r\nfile:///tmp/%E4%B8%AD%E6%96%87.mp4" + : "", + }, + }); + host.dispatchEvent(event); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/a b.png' '/tmp/中文.mp4'")); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it.each([ + { shell: "pwsh", path: "C:\\Users\\a'b $c\\image.png", expected: "'C:\\Users\\a''b $c\\image.png'" }, + { shell: "powershell", path: "C:\\a b\\image.png", expected: "'C:\\a b\\image.png'" }, + { shell: "zsh", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" }, + { shell: "bash", path: "/tmp/a'b $中文.png", expected: "'/tmp/a'\\''b $中文.png'" }, + { shell: "fish", path: "/tmp/a'b $中文.png", expected: "'/tmp/a\\'b $中文.png'" }, + { shell: "fish", path: "/tmp/a\\\\b\\'中文.png", expected: "'/tmp/a\\\\\\\\b\\\\\\'中文.png'" }, + { shell: "cmd", path: "C:\\a&b\\image.png", expected: '"C:\\a&b\\image.png"' }, + { shell: "cmd.exe", path: "C:\\a b $中文\\image.png", expected: '"C:\\a b $中文\\image.png"' }, +])("quotes a stored path for $shell", async ({ shell, path, expected }) => { + vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: path }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget(shell)); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith(expected)); + remove(); +}); + +it.each(["audio/wav", "video/mp4"])("stores raw %s clipboard data", async (type) => { + const store = vi.spyOn(commands, "termStoreImage").mockResolvedValue({ status: "ok", data: "/tmp/media" }); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget()); + const event = imageEvent({ ...image(), type } as Blob); + host.dispatchEvent(event); + await vi.waitFor(() => expect(paste).toHaveBeenCalledExactlyOnceWith("'/tmp/media'")); + expect(store).toHaveBeenCalledWith(42, [1, 2], type); + remove(); +}); + +it("ignores local media when its terminal is replaced during file recovery", async () => { + let current = true; + let finish!: (value: { status: "ok"; data: string[] }) => void; + vi.spyOn(commands, "clipboardFileList").mockImplementation(() => new Promise((resolve) => (finish = resolve))); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget("sh", () => current)); + host.dispatchEvent(imageEvent(image())); + await vi.waitFor(() => expect(finish).toBeDefined()); + current = false; + finish({ status: "ok", data: [] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(store).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + remove(); +}); + +it.each(["event", "menu"])("rejects cmd expansion in copied file paths through %s paste", async (route) => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\clips\\%OS%!clip!.png"] }); + const store = vi.spyOn(commands, "termStoreImage"); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste, () => localTarget("cmd")); + if (route === "event") host.dispatchEvent(imageEvent(image())); + else { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + await pasteTerminalClipboard(paste, () => localTarget("cmd")); + } + await vi.waitFor(() => + expect(notify).toHaveBeenCalledWith({ + level: "error", + title: "Cannot paste clipboard media", + body: "Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.", + }), + ); + expect(paste).not.toHaveBeenCalled(); + expect(store).not.toHaveBeenCalled(); + remove(); +}); + +it("recovers quoted local file paths when clipboard text permission is denied", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["C:\\a b\\image.png"] }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + readText: async () => { + throw new Error("denied"); + }, + }, + }); + const paste = vi.fn(); + await pasteTerminalClipboard(paste, () => localTarget("pwsh")); + expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'"); + expect(notify).not.toHaveBeenCalled(); +}); diff --git a/src/features/terminal/paste.ts b/src/features/terminal/paste.ts index c0dd74e2..5029cd48 100644 --- a/src/features/terminal/paste.ts +++ b/src/features/terminal/paste.ts @@ -3,31 +3,75 @@ import { call, commands, type TerminalId } from "@/platform/ipc"; import { tx } from "@/platform/i18n"; import { notifications } from "@/workbench/contributions"; -export interface RemotePasteTarget { +export interface TerminalPasteTarget { id: TerminalId; supported: boolean; + remote: boolean; + shell: string; current: () => boolean; } -type Target = () => RemotePasteTarget | null; +type Target = () => TerminalPasteTarget | null; -const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const MAX_MEDIA_BYTES = 20 * 1024 * 1024; -async function pasteImage(image: Blob, target: RemotePasteTarget, paste: (text: string) => void) { +function pathsText(paths: readonly string[], target: TerminalPasteTarget): string { + const shell = target.shell.toLowerCase().replace(/\.exe$/, ""); + return paths + .map((path) => { + if (shell === "cmd") { + if (/[%!"\r\n]/.test(path)) + throw new Error(tx("Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.")); + return `"${path}"`; + } + if (shell === "powershell" || shell === "pwsh") return `'${path.replaceAll("'", "''")}'`; + if (shell === "fish") return `'${path.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`; + return `'${path.replaceAll("'", "'\\''")}'`; + }) + .join(" "); +} + +function isMedia(type: string): boolean { + return /^(image|audio|video)\//.test(type); +} + +function pasteError(error: unknown, target: TerminalPasteTarget | null | undefined) { + if (target?.current()) + notifications().notify({ + level: "error", + title: tx("Cannot paste clipboard media"), + body: error instanceof Error ? error.message : String(error), + }); +} + +function filePaths(transfer: DataTransfer): string[] { + const paths: string[] = []; + for (const line of transfer.getData("text/uri-list").split(/\r?\n/)) { + if (!line || line.startsWith("#")) continue; + try { + const url = new URL(line); + if (url.protocol !== "file:") continue; + let path = decodeURIComponent(url.pathname); + if (url.hostname && url.hostname !== "localhost") path = `//${url.hostname}${path}`; + else if (/^\/[A-Za-z]:\//.test(path)) path = path.slice(1); + paths.push(path); + } catch { + continue; + } + } + return paths; +} + +async function pasteMedia(media: Blob, target: TerminalPasteTarget, 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 (media.size > MAX_MEDIA_BYTES) throw new Error(tx`Clipboard media is too large to paste (limit ${20} MB).`); + const data = Array.from(new Uint8Array(await media.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("'", "'\\''")}'`); + const path = await call(commands.termStoreImage(target.id, data, media.type)); + if (target.current()) paste(pathsText([path], target)); } catch (error) { - if (target.current()) - notifications().notify({ - level: "error", - title: tx("Cannot paste image"), - body: error instanceof Error ? error.message : String(error), - }); + pasteError(error, target); } } @@ -36,12 +80,29 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) => 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) { + if (!event.defaultPrevented && target && transfer) { + const media = Array.from(transfer.files).find((file) => isMedia(file.type)); + const files = + transfer.files.length || + Array.from(transfer.types).some((type) => type === "Files" || type === "text/uri-list"); + if ((!target.remote && files) || (media && !transfer.getData("text/plain"))) { event.preventDefault(); event.stopImmediatePropagation(); - void pasteImage(image, { ...target, current: () => alive && target.current() }, paste); + const captured = { ...target, current: () => alive && target.current() }; + void (async () => { + if (!captured.remote) { + const paths = filePaths(transfer); + const text = paths.length + ? pathsText(paths, captured) + : await readClipboardText(transfer, (paths) => pathsText(paths, captured)); + if (!captured.current()) return; + if (text) { + paste(text); + return; + } + } + if (media) await pasteMedia(media, captured, paste); + })().catch((error) => pasteError(error, captured)); return; } } @@ -68,7 +129,9 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT (error: unknown) => ({ error }), ) : null; - const text = target ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") : await readClipboardText(); + const text = target?.remote + ? ((await navigator.clipboard?.readText().catch(() => "")) ?? "") + : await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined); if (target && !target.current()) return; if (text) { paste(text); @@ -80,24 +143,19 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT 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/")); + const mime = item.types.find(isMedia); if (mime) { const image = await item.getType(mime); - if (target.current()) await pasteImage(image, target, paste); + if (target.current()) await pasteMedia(image, target, paste); return; } } } - const paths = await readClipboardText(); + const paths = await readClipboardText(undefined, target.remote ? undefined : (paths) => pathsText(paths, target)); 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), - }); + pasteError(error, target); } } diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index da94bad3..dce99c6c 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -286,7 +286,7 @@ export class TerminalSession { }), ); this.disposables.push( - { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.remotePasteTarget) }, + { dispose: installTerminalPaste(this.host, (text) => this.paste(text), this.clipboardTarget) }, { dispose: installTerminalDrop(this.host, (text) => this.paste(text)) }, ); this.host.addEventListener("mousedown", this.onMiddleClick); @@ -298,14 +298,16 @@ export class TerminalSession { void this.pasteClipboard(); }; - private remotePasteTarget = () => { + private clipboardTarget = () => { const remote = useRemote.getState().session; const id = this.ptyId; - if (!remote || id === null || this.disposed) return null; + if (id === null || this.disposed) return null; const generation = this.generation; return { id, - supported: remote.commands.includes("term_store_image"), + supported: !remote || remote.commands.includes("term_store_image"), + remote: !!remote, + shell: this.snapshot.title, current: () => !this.disposed && this.ptyId === id && @@ -315,8 +317,8 @@ export class TerminalSession { }; pasteClipboard() { - if (this.remotePasteTarget()) this.focus(); - return pasteTerminalClipboard((text) => this.paste(text), this.remotePasteTarget); + if (this.clipboardTarget()) this.focus(); + return pasteTerminalClipboard((text) => this.paste(text), this.clipboardTarget); } /** Applies Settings › Tools › Terminal changes to a live session. */ applySettings(t: TerminalSettingsValid) { diff --git a/src/platform/clipboard.ts b/src/platform/clipboard.ts index bab48c0a..f3b25716 100644 --- a/src/platform/clipboard.ts +++ b/src/platform/clipboard.ts @@ -9,16 +9,19 @@ export async function readClipboardText( transfer?: DataTransfer | null, formatFiles: FormatFiles = rawPaths, ): Promise { - const text = transfer ? transfer.getData("text/plain") : ((await navigator.clipboard?.readText()) ?? ""); + const text = transfer + ? transfer.getData("text/plain") + : ((await navigator.clipboard?.readText().catch(() => "")) ?? ""); if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text; + let paths: string[]; try { - const paths = await call(commands.clipboardFileList()); - // Windows also supplies the file list as text; unrelated text still wins. - if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text; - return formatFiles(paths); + paths = await call(commands.clipboardFileList()); } catch { return text; } + // Windows also supplies the file list as text; unrelated text still wins. + if (text && text.replace(/\r\n/g, "\n").split("\n").map(ipcPath).join("\n") !== paths.join("\n")) return text; + return formatFiles(paths); } function hasClipboardFiles(transfer: DataTransfer): boolean { diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 048353eb..be3187ac 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5499,10 +5499,12 @@ ".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", - "Cannot paste image": "无法粘贴图片", + "Cannot paste clipboard media": "无法粘贴剪贴板媒体", + "Cannot paste this path safely in cmd.exe. Use PowerShell or a POSIX shell.": "无法在 cmd.exe 中安全粘贴此路径。请使用 PowerShell 或 POSIX Shell。", "Remote server does not support image paste.": "远程服务器不支持粘贴图片。", "Clipboard image reading is unavailable.": "无法读取剪贴板图片。", - "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", + "Clipboard media is too large to paste (limit {limit} MB).": "剪贴板媒体过大,无法粘贴(上限 {limit} MB)。", + "unsupported clipboard media type": "不支持的剪贴板媒体类型", "Show inline blame for the current line": "为当前行显示内联 Git Blame", "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", "now": "刚刚",