diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index c00ba8c7f..4989a4f90 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -245,6 +245,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool { | "clear_caches" | "clipboard_file_list" | "clipboard_has_files" + | "clipboard_image" | "clipboard_set_file_list" | "jdk_download_vendors" | "jdk_download_releases" diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index accad74b8..595402627 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -46,6 +46,7 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project( "clear_caches", "clipboard_file_list", "clipboard_has_files", + "clipboard_image", "clipboard_set_file_list", "jdk_download_vendors", "jdk_download_releases", diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs index a12cfb306..b3c5c75e7 100644 --- a/src-tauri/src/commands/clipboard.rs +++ b/src-tauri/src/commands/clipboard.rs @@ -31,6 +31,39 @@ pub async fn clipboard_set_file_list(app: AppHandle, paths: Vec) -> Ide with_clipboard(app, move || Ok(ide_clipboard::set_files(&paths))).await } +/// The clipboard's image as a PNG data URL, for a paste whose event carried no +/// files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot). +/// None when the clipboard holds no image, and always on other platforms. +#[tauri::command] +#[specta::specta] +pub async fn clipboard_image(app: AppHandle) -> IdeResult> { + #[cfg(target_os = "linux")] + { + use base64::Engine; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let sender = std::cell::Cell::new(Some(tx)); + gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).request_image(move |_, pixbuf| { + let png = pixbuf.and_then(|image| image.save_to_bufferv("png", &[]).ok()); + if let Some(sender) = sender.take() { + let _ = sender.send(png); + } + }); + }) + .map_err(|error| { + ide_core::IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}")) + })?; + let png = rx.await.ok().flatten(); + Ok(png + .map(|bytes| format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes)))) + } + #[cfg(not(target_os = "linux"))] + { + let _ = app; + Ok(None) + } +} + /// AppKit and WebKit share the general pasteboard, so a worker-side mutex cannot /// protect its type cache. Await the main thread without blocking a command thread. async fn with_clipboard( diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6e7863d19..edd9d994e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -166,6 +166,7 @@ pub fn builder() -> tauri_specta::Builder { fs::fs_read_text, clipboard::clipboard_file_list, clipboard::clipboard_has_files, + clipboard::clipboard_image, clipboard::clipboard_set_file_list, fonts::system_font_families, doc::doc_open, diff --git a/src/bindings.ts b/src/bindings.ts index e18f26c5d..e6e6cd16a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -227,6 +227,12 @@ export const commands = { clipboardFileList: () => typedError(__TAURI_INVOKE("clipboard_file_list")), /** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */ clipboardHasFiles: () => __TAURI_INVOKE("clipboard_has_files"), + /** + * The clipboard's image as a PNG data URL, for a paste whose event carried no + * files: WebKitGTK leaves `clipboardData` empty for a raw image (a screenshot). + * None when the clipboard holds no image, and always on other platforms. + */ + clipboardImage: () => typedError(__TAURI_INVOKE("clipboard_image")), /** * Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort: * false when the clipboard stayed busy; the caller keeps working either way. diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index 41d1c8872..782bc8c9b 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -300,13 +300,15 @@ export function Composer(props: ComposerProps) { [id], ); + const fitsLimit = (name: string, size: number) => { + if (size <= MAX_IMAGE_BYTES) return true; + notifications().notify({ level: "warning", title: "Image too large", body: tx`${name} is over 5 MB.` }); + return false; + }; + const readImageFiles = (picked: Iterable) => { for (const file of picked) { - if (!file.type.startsWith("image/")) continue; - if (file.size > MAX_IMAGE_BYTES) { - notifications().notify({ level: "warning", title: "Image too large", body: tx`${file.name} is over 5 MB.` }); - continue; - } + if (!file.type.startsWith("image/") || !fitsLimit(file.name, file.size)) continue; const reader = new FileReader(); reader.onload = () => { const image = typeof reader.result === "string" ? imageFromDataUrl(reader.result, file.name || "image") : null; @@ -316,6 +318,12 @@ export function Composer(props: ComposerProps) { } }; + const pasteClipboardImage = async () => { + const url = await call(commands.clipboardImage()); + const image = url ? imageFromDataUrl(url, "image") : null; + if (image && fitsLimit("image", (image.data.length * 3) / 4)) addImages([image]); + }; + // Files dropped from the OS onto the box: images are attached, other files referenced. useEffect(() => { if (!isTauri()) return; @@ -571,6 +579,10 @@ export function Composer(props: ComposerProps) { if (pasted.length) { e.preventDefault(); readImageFiles(pasted); + } else if (isTauri() && !e.clipboardData.types.length) { + pasteClipboardImage().catch((error: unknown) => + notifications().notify({ level: "error", title: tx("Cannot insert image"), body: String(error) }), + ); } }} /> diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index 9e3113835..483fd1a79 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -7,6 +7,7 @@ const agentCommands = vi.fn(); const agentCreate = vi.fn(); const vcsBranches = vi.fn(); const vcsBranchInfo = vi.fn(); +const clipboardImage = vi.fn(); vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); @@ -17,6 +18,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { ...real.commands, agentCommands: (provider: string, task: string | null) => agentCommands(provider, task), agentCreate: (request: unknown) => agentCreate(request), + clipboardImage: () => clipboardImage(), vcsBranchInfo: (root: string | null) => vcsBranchInfo(root), vcsBranches: (root: string | null) => vcsBranches(root), }, @@ -33,7 +35,7 @@ import { defaultTaskProject } from "./project"; import { HomeEditor } from "./HomeEditor"; import composerStyles from "./Composer.module.css"; import tagStyles from "@/ui/InlineTag.module.css"; -import { HOME, updateComposer, useAgents } from "./store"; +import { composerDraft, HOME, updateComposer, useAgents } from "./store"; import { emptyDraft } from "./composerModel"; const command = (name: string, description: string, source: "command" | "skill" = "command"): AgentCommand => ({ @@ -72,6 +74,7 @@ beforeEach(() => { Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }), ); agentCommands.mockReset(); + clipboardImage.mockReset().mockResolvedValue({ status: "ok", data: "data:image/png;base64,iVBORw0KGgo=" }); agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } }); useAgents.setState({ providers: [claude], @@ -227,6 +230,22 @@ describe("New Task composer commands", () => { }); }); +describe("New Task composer paste", () => { + it("attaches the clipboard's image when WebKitGTK pastes an empty clipboardData", async () => { + render(); + fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: [], files: [] } }); + await waitFor(() => + expect(composerDraft(HOME).images).toEqual([{ mimeType: "image/png", data: "iVBORw0KGgo=", name: "image" }]), + ); + }); + + it("leaves a text paste to the text box", () => { + render(); + fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: ["text/plain"], files: [] } }); + expect(clipboardImage).not.toHaveBeenCalled(); + }); +}); + describe("workspace task project", () => { const project = { root: "/workspace",