дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(agents): attach pasted clipboard images on Linux (#2336)
WebKitGTK hands the composer an empty clipboardData when the clipboard holds only an image, as after a GNOME screenshot, and refuses navigator.clipboard.read(). Ctrl+V then attached nothing. A paste that carries no data now reads the image through the GTK clipboard with the new clipboard_image command. Co-authored-by: NealimeKenna <[email protected]>
Цей коміт міститься в:
1 джерело
ff2eb95b01
коміт
e44a3af310
7 files changed
+79
-6
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -31,6 +31,39 @@ pub async fn clipboard_set_file_list(app: AppHandle, paths: Vec<PathBuf>) -> 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<Option<String>> {
|
||||
#[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<T: Send + 'static>(
|
||||
|
||||
@@ -166,6 +166,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -227,6 +227,12 @@ export const commands = {
|
||||
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("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<string | null, IdeError>(__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.
|
||||
|
||||
@@ -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<File>) => {
|
||||
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) }),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
@@ -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(<HomeEditor />);
|
||||
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(<HomeEditor />);
|
||||
fireEvent.paste(screen.getByRole("textbox"), { clipboardData: { types: ["text/plain"], files: [] } });
|
||||
expect(clipboardImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace task project", () => {
|
||||
const project = {
|
||||
root: "/workspace",
|
||||
|
||||
Reference in new issue
Заблокувати користувача