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>
This commit is contained in:
Dimas Victor
2026-11-01 21:42:15 +08:00
committed by GitHub
co-authored by dvictor357
parent 0d0de01831
commit 88619505c3
15 changed files with 737 additions and 13 deletions
Generated
+1
View File
@@ -4241,6 +4241,7 @@ dependencies = [
"ide-vfs",
"ide-workspace",
"parking_lot",
"rustix",
"serde",
"serde_json",
"specta",
+3
View File
@@ -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"] }
+46
View File
@@ -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<u8>, mime: String) -> IdeResult<String> {
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<bool> {
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());
}
}
+220
View File
@@ -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<File> {
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<String> {
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<String> {
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<String> {
Err(IdeError::internal("private terminal image storage is unsupported on this platform"))
}
+3
View File
@@ -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::<String>("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")?) })
+47
View File
@@ -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);
}
+4
View File
@@ -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<TerminalInfo> {
let mut out: Vec<TerminalInfo> = self.sessions.lock().values().map(|s| s.info.clone()).collect();
+1
View File
@@ -298,6 +298,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_changelists_save,
vcs::vcs_write_patch,
term::term_spawn,
term::term_store_image,
term::term_write,
term::term_resize,
term::term_kill,
+11
View File
@@ -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<u8>,
mime: String,
) -> IdeResult<String> {
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<()> {
+1
View File
@@ -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<string>) => typedError<TerminalInfo, IdeError>(__TAURI_INVOKE("term_spawn", { options, onOutput })),
termStoreImage: (id: TerminalId, data: number[], mime: string) => typedError<string, IdeError>(__TAURI_INVOKE("term_store_image", { id, data, mime })),
termWrite: (id: TerminalId, data: string) => typedError<null, IdeError>(__TAURI_INVOKE("term_write", { id, data })),
termResize: (id: TerminalId, cols: number, rows: number) => typedError<null, IdeError>(__TAURI_INVOKE("term_resize", { id, cols, rows })),
termKill: (id: TerminalId) => typedError<null, IdeError>(__TAURI_INVOKE("term_kill", { id })),
+1 -2
View File
@@ -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" },
+286 -2
View File
@@ -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<ArrayBuffer> = 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<Blob>((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();
});
+85 -6
View File
@@ -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),
});
}
}
+23 -2
View File
@@ -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);
+5 -1
View File
@@ -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)。"
}