mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
co-authored by
dvictor357
parent
0d0de01831
commit
88619505c3
Generated
+1
@@ -4241,6 +4241,7 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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")?) })
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user