дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02: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 <[email protected]>
Цей коміт міститься в:
1 джерело
0d0de01831
коміт
88619505c3
15 files changed
+737
-13
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача