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 <[email protected]>
Цей коміт міститься в:
Dimas Victorтаdvictor357 authored and GitHub committed 2026-11-01 21:42:15 +08:00
1 джерело 0d0de01831
коміт 88619505c3
15 files changed
+737 -13

No files matched your search

+1 -2
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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);