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>
This commit is contained in:
kkmihai
2026-11-03 02:09:17 +08:00
committed by GitHub
co-authored by kkMihai
parent db5251efe6
commit 7af2b098cc
8 changed files with 670 additions and 79 deletions
+340 -27
View File
@@ -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<File> {
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<File> {
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<String> {
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<String> {
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<String> {
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());
}
}
+9 -6
View File
@@ -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);
}
+47
View File
@@ -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'"]);
});
+168 -5
View File
@@ -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();
});
+86 -28
View File
@@ -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);
}
}
+8 -6
View File
@@ -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) {
+8 -5
View File
@@ -9,16 +9,19 @@ export async function readClipboardText(
transfer?: DataTransfer | null,
formatFiles: FormatFiles = rawPaths,
): Promise<string> {
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 {
+4 -2
View File
@@ -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": "刚刚",