diff --git a/packages/app/e2e/prompt/prompt-drop-native-event.spec.ts b/packages/app/e2e/prompt/prompt-drop-native-event.spec.ts new file mode 100644 index 00000000000..c5feb4e5380 --- /dev/null +++ b/packages/app/e2e/prompt/prompt-drop-native-event.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" + +test("desktop native drop event inserts a file pill", async ({ page, gotoSession }) => { + await gotoSession() + + const prompt = page.locator(promptSelector) + await prompt.click() + + const path = process.platform === "win32" ? "C:\\opencode-e2e-native-drop.ts" : "/tmp/opencode-e2e-native-drop.ts" + + await page.evaluate((value) => { + window.dispatchEvent(new CustomEvent("opencode:native-file-drop", { detail: { paths: [value] } })) + }, path) + + const pill = page.locator(`${promptSelector} [data-type="file"]`).first() + await expect(pill).toBeVisible() + await expect(pill).toHaveAttribute("data-path", path) +}) + +test("native and browser drop do not duplicate file pill", async ({ page, gotoSession }) => { + await gotoSession() + + const prompt = page.locator(promptSelector) + await prompt.click() + + const path = + process.platform === "win32" ? "C:\\opencode-e2e-native-drop-once.ts" : "/tmp/opencode-e2e-native-drop-once.ts" + + await page.evaluate((value) => { + window.dispatchEvent(new CustomEvent("opencode:native-file-drop", { detail: { paths: [value] } })) + }, path) + + const dt = await page.evaluateHandle((value) => { + const dt = new DataTransfer() + dt.setData("text/plain", value) + return dt + }, path) + + await page.dispatchEvent("body", "drop", { dataTransfer: dt }) + + const pills = page.locator(`${promptSelector} [data-type="file"][data-path="${path}"]`) + await expect(pills).toHaveCount(1) +}) + +test("desktop native drop event adds image attachment preview", async ({ page, gotoSession }) => { + const dataUrl = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII=" + + await page.addInitScript((url) => { + const target = window as Window & { + __OPENCODE__?: { + readAttachmentFromPath?: ( + path: string, + ) => + | Promise<{ filename: string; mime: string; dataUrl: string } | null> + | { filename: string; mime: string; dataUrl: string } + | null + } + } + target.__OPENCODE__ ??= {} + target.__OPENCODE__.readAttachmentFromPath = async (path: string) => { + if (!path.toLowerCase().endsWith(".png")) return null + return { filename: "native-drop.png", mime: "image/png", dataUrl: url } + } + }, dataUrl) + + await gotoSession() + + const prompt = page.locator(promptSelector) + await prompt.click() + + const path = process.platform === "win32" ? "C:\\opencode-e2e-native-drop.png" : "/tmp/opencode-e2e-native-drop.png" + + await page.evaluate((value) => { + window.dispatchEvent(new CustomEvent("opencode:native-file-drop", { detail: { paths: [value] } })) + }, path) + + await expect(page.locator('img[alt="native-drop.png"]').first()).toBeVisible() + await expect(page.locator(`${promptSelector} [data-type="file"][data-path="${path}"]`)).toHaveCount(0) +}) + +test("browser and native drop do not duplicate file pill", async ({ page, gotoSession }) => { + await gotoSession() + + const prompt = page.locator(promptSelector) + await prompt.click() + + const path = + process.platform === "win32" + ? "C:\\opencode-e2e-native-drop-reverse.ts" + : "/tmp/opencode-e2e-native-drop-reverse.ts" + + const dt = await page.evaluateHandle((value) => { + const dt = new DataTransfer() + dt.setData("text/plain", value) + return dt + }, path) + + await page.dispatchEvent("body", "drop", { dataTransfer: dt }) + + await page.evaluate((value) => { + window.dispatchEvent(new CustomEvent("opencode:native-file-drop", { detail: { paths: [value] } })) + }, path) + + const pills = page.locator(`${promptSelector} [data-type="file"][data-path="${path}"]`) + await expect(pills).toHaveCount(1) +}) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e370862212b..434bceb348f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -77,6 +77,12 @@ declare global { updaterEnabled?: boolean deepLinks?: string[] wsl?: boolean + readAttachmentFromPath?: ( + path: string, + ) => + | Promise<{ filename: string; mime: string; dataUrl: string } | null> + | { filename: string; mime: string; dataUrl: string } + | null } api?: { setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5c25235c65c..4b93479c042 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -980,6 +980,16 @@ export const PromptInput: Component = (props) => { return true } + const pickDesktopFiles = async () => { + const result = await platform.openFilePickerDialog?.({ + title: language.t("prompt.action.attachFile"), + multiple: true, + }) + if (!result) return + for (const path of Array.from(new Set(Array.isArray(result) ? result : [result]))) { + await addPath(path) + } + } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const currentHistory = mode === "shell" ? shellHistory : history const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory @@ -1043,7 +1053,7 @@ export const PromptInput: Component = (props) => { return true } - const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ + const { addAttachment, addPath, removeImageAttachment, handlePaste } = createPromptAttachments({ editor: () => editorRef, isDialogActive: () => !!dialog.active, setDraggingType: (type) => setStore("draggingType", type), @@ -1053,6 +1063,7 @@ export const PromptInput: Component = (props) => { }, addPart, readClipboardImage: platform.readClipboardImage, + readAttachmentFromPath: platform.readAttachmentFromPath, }) const variants = createMemo(() => ["default", ...local.model.variant.list()]) @@ -1293,7 +1304,7 @@ export const PromptInput: Component = (props) => { onOpen={(attachment) => dialog.show(() => ) } - onRemove={removeAttachment} + onRemove={removeImageAttachment} removeLabel={language.t("prompt.attachment.remove")} />
HTMLDivElement | undefined isDialogActive: () => boolean @@ -31,11 +39,16 @@ type PromptAttachmentsInput = { focusEditor: () => void addPart: (part: ContentPart) => boolean readClipboardImage?: () => Promise + readAttachmentFromPath?: (path: string) => Promise<{ filename: string; mime: string; dataUrl: string } | null> } export function createPromptAttachments(input: PromptAttachmentsInput) { const prompt = usePrompt() const language = useLanguage() + let recentDrop = { + time: 0, + paths: new Set(), + } const warn = () => { showToast({ @@ -69,14 +82,123 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { return true } + const addImageAttachment = (attachment: { filename: string; mime: string; dataUrl: string }) => { + if (!ACCEPTED_FILE_TYPES.includes(attachment.mime)) return false + const editor = input.editor() + if (!editor) return false + const next: ImageAttachmentPart = { + type: "image", + id: uuid(), + filename: attachment.filename, + mime: attachment.mime, + dataUrl: attachment.dataUrl, + } + const cursor = prompt.cursor() ?? getCursorPosition(editor) + prompt.set([...prompt.current(), next], cursor) + return true + } + + const addFileReference = (path: string) => { + if (!path) return + if (prompt.current().some((part) => part.type === "file" && part.path === path)) return + input.focusEditor() + input.addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 }) + } + + const addPath = async (path: string) => { + if (!path) return + const attachment = await input.readAttachmentFromPath?.(path).catch(() => null) + if (attachment && addImageAttachment(attachment)) return + addFileReference(path) + } + + const fileItemPath = (file: File | null) => { + if (!file) return null + const path = (file as PathFile).path + if (!path?.trim()) return null + return path + } + + const fromFileUri = (value: string) => { + if (!value.startsWith("file:")) return null + if (!URL.canParse(value)) return value.slice(5) + const uri = new URL(value) + if (uri.protocol !== "file:") return null + const pathname = decodeURIComponent(uri.pathname) + if (/^\/[A-Za-z]:\//.test(pathname)) return pathname.slice(1) + if (uri.host) return `//${uri.host}${pathname}` + return pathname + } + + const normalizePath = (value: string) => { + const path = value.trim() + if (!path) return null + const next = fromFileUri(path) + if (next) return next + if (path.startsWith("/") || WINDOWS_PATH.test(path) || UNC_PATH.test(path)) return path + return null + } + + const droppedPaths = (event: DragEvent) => { + const transfer = event.dataTransfer + const hasFiles = (transfer?.files.length ?? 0) > 0 + const types = [ + "text/uri-list", + "text/plain", + "public.file-url", + "text/x-moz-url", + ...(transfer?.types ?? []).filter((type) => type.includes("uri") || type.includes("file")), + ] + const list = Array.from(new Set(types)) + .map((type) => transfer?.getData(type) ?? "") + .flatMap((text) => (text ?? "").split("\n")) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")) + .flatMap((line) => { + const path = normalizePath(line) + if (path) return [path] + if (!hasFiles) return [] + if (line.startsWith("/") || WINDOWS_PATH.test(line) || UNC_PATH.test(line)) return [decodeURIComponent(line)] + return [] + }) + return Array.from(new Set(list)) + } + const addAttachment = (file: File) => add(file) - const removeAttachment = (id: string) => { + const removeImageAttachment = (id: string) => { const current = prompt.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) prompt.set(next, prompt.cursor()) } + const isDuplicateDrop = (paths: string[]) => { + if (Date.now() - recentDrop.time >= 1000) return false + if (paths.length === 0) return false + return paths.every((path) => recentDrop.paths.has(path)) + } + + const setRecentDrop = (paths: string[]) => { + if (paths.length === 0) return + recentDrop = { + time: Date.now(), + paths: new Set(paths), + } + } + + const addDroppedPaths = async (paths: string[]) => { + const existing = new Set(prompt.current().flatMap((part) => (part.type === "file" ? [part.path] : []))) + const next = paths.filter((path, index, list) => list.indexOf(path) === index).filter((path) => !existing.has(path)) + if (next.length === 0) { + setRecentDrop(paths) + return + } + for (const path of next) { + await addPath(path) + } + setRecentDrop(paths) + } + const handlePaste = async (event: ClipboardEvent) => { const clipboardData = event.clipboardData if (!clipboardData) return @@ -92,6 +214,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { for (const item of fileItems) { const file = item.getAsFile() if (!file) continue + const path = fileItemPath(file) + if (path) { + await addPath(path) + found = true + continue + } const ok = await add(file, false) if (ok) found = true } @@ -101,7 +229,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const plainText = clipboardData.getData("text/plain") ?? "" - // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images if (input.readClipboardImage && !plainText) { const file = await input.readClipboardImage() if (file) { @@ -138,10 +265,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const hasFiles = event.dataTransfer?.types.includes("Files") const hasText = event.dataTransfer?.types.includes("text/plain") if (hasFiles) { - input.setDraggingType("image") - } else if (hasText) { - input.setDraggingType("@mention") + const files = Array.from(event.dataTransfer?.items ?? []).filter((item) => item.kind === "file") + const hasMedia = files.some((item) => item.type.startsWith("image/") || item.type === "application/pdf") + input.setDraggingType(hasMedia ? "image" : "@mention") + return } + if (hasText) input.setDraggingType("@mention") } const handleGlobalDragLeave = (event: DragEvent) => { @@ -157,33 +286,61 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { event.preventDefault() input.setDraggingType(null) - const plainText = event.dataTransfer?.getData("text/plain") - const filePrefix = "file:" - if (plainText?.startsWith(filePrefix)) { - const filePath = plainText.slice(filePrefix.length) - input.focusEditor() - input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) + const paths = droppedPaths(event) + if (paths.length > 0) { + if (isDuplicateDrop(paths)) return + await addDroppedPaths(paths) return } const dropped = event.dataTransfer?.files if (!dropped) return + const filePaths = Array.from(dropped) + .flatMap((file) => { + const path = fileItemPath(file) + return path ? [path] : [] + }) + .filter((path, index, list) => list.indexOf(path) === index) + if (isDuplicateDrop(filePaths)) return + let found = false for (const file of Array.from(dropped)) { + const path = fileItemPath(file) + if (path) { + await addPath(path) + found = true + continue + } + const ok = await add(file, false) if (ok) found = true } if (!found && dropped.length > 0) warn() } + const handleNativeDrop = (event: Event) => { + if (input.isDialogActive()) return + const detail = (event as CustomEvent).detail + const paths = (detail?.paths ?? []).flatMap((path) => { + const value = normalizePath(path) + return value ? [value] : [] + }) + if (paths.length === 0) return + const deduped = Array.from(new Set(paths)) + if (isDuplicateDrop(deduped)) return + void addDroppedPaths(deduped) + } + onMount(() => { + window.addEventListener(NATIVE_DROP_EVENT, handleNativeDrop as EventListener) document.addEventListener("dragover", handleGlobalDragOver) document.addEventListener("dragleave", handleGlobalDragLeave) document.addEventListener("drop", handleGlobalDrop) }) onCleanup(() => { + window.removeEventListener(NATIVE_DROP_EVENT, handleNativeDrop as EventListener) document.removeEventListener("dragover", handleGlobalDragOver) document.removeEventListener("dragleave", handleGlobalDragLeave) document.removeEventListener("drop", handleGlobalDrop) @@ -191,7 +348,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { return { addAttachment, - removeAttachment, + addImageAttachment, + addPath, + removeImageAttachment, handlePaste, } } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b8ed58e343a..28defca8663 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -8,6 +8,7 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } type OpenFilePickerOptions = { title?: string; multiple?: boolean } type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } +type PathAttachment = { filename: string; mime: string; dataUrl: string } export type Platform = { /** Platform discriminator */ @@ -87,6 +88,9 @@ export type Platform = { /** Read image from clipboard (desktop only) */ readClipboardImage?(): Promise + + /** Read a media attachment from a local path (desktop only) */ + readAttachmentFromPath?(path: string): Promise } export type DisplayBackend = "auto" | "wayland" diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75d..ca8366d3042 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -123,6 +123,11 @@ const platform: Platform = { return stored ? ServerConnection.Key.make(stored) : null }, setDefaultServer: writeDefaultServerUrl, + readAttachmentFromPath: async (path) => { + const reader = window.__OPENCODE__?.readAttachmentFromPath + if (!reader) return null + return Promise.resolve(reader(path)).catch(() => null) + }, } if (root instanceof HTMLElement) { diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 55f0d5f3603..1898223189e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -3097,6 +3097,7 @@ dependencies = [ name = "opencode-desktop" version = "0.0.0" dependencies = [ + "base64 0.22.1", "chrono", "comrak", "dirs", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index b228c7b6162..347a5e8e6e0 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -53,6 +53,7 @@ tracing-appender = "0.2" chrono = "0.4" tokio-stream = { version = "0.1.18", features = ["sync"] } process-wrap = { version = "9.0.3", features = ["tokio1"] } +base64 = "0.22.1" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_Registry"] } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index a843ac8174e..e7a7f54acbf 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod server; mod window_customizer; mod windows; +use base64::{Engine, engine::general_purpose::STANDARD}; use crate::cli::CommandChild; use futures::{FutureExt, TryFutureExt}; use std::{ @@ -57,6 +58,26 @@ enum WslPathMode { Linux, } +#[derive(Clone, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +struct PathAttachment { + filename: String, + mime: String, + data_url: String, +} + +fn path_attachment_mime(path: &str) -> Option<&'static str> { + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + match ext.as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "pdf" => Some("application/pdf"), + _ => None, + } +} + struct InitState { current: watch::Receiver, } @@ -298,6 +319,29 @@ fn wsl_path(path: String, mode: Option) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +#[tauri::command] +#[specta::specta] +fn read_path_attachment(path: String) -> Result, String> { + let Some(mime) = path_attachment_mime(&path) else { + return Ok(None); + }; + + let name = std::path::Path::new(&path) + .file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| path.clone()); + + let bytes = std::fs::read(&path).map_err(|e| format!("Failed to read file: {e}"))?; + let data_url = format!("data:{mime};base64,{}", STANDARD.encode(bytes)); + + Ok(Some(PathAttachment { + filename: name, + mime: mime.to_string(), + data_url, + })) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = make_specta_builder(); @@ -386,6 +430,7 @@ fn make_specta_builder() -> tauri_specta::Builder { markdown::parse_markdown_command, check_app_exists, wsl_path, + read_path_attachment, resolve_app_path, open_path ]) diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index d434d3b35e8..bb4d938bd43 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -17,6 +17,11 @@ export const commands = { parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE("wsl_path", { path, mode }), + readPathAttachment: (path: string) => __TAURI_INVOKE<{ + filename: string, + mime: string, + dataUrl: string, +} | null>("read_path_attachment", { path }), resolveAppPath: (appName: string) => __TAURI_INVOKE("resolve_app_path", { appName }), openPath: (path: string, appName: string | null) => __TAURI_INVOKE("open_path", { path, appName }), }; @@ -34,6 +39,12 @@ export type LinuxDisplayBackend = "wayland" | "auto"; export type LoadingWindowComplete = null; +export type PathAttachment = { + filename: string, + mime: string, + dataUrl: string, + }; + export type ServerReadyData = { url: string, username: string | null, diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 65149f34bc1..33662ab4539 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -374,6 +374,10 @@ const createPlatform = (): Platform => { return commands.checkAppExists(appName) }, + async readAttachmentFromPath(path: string) { + return commands.readPathAttachment(path).catch(() => null) + }, + async readClipboardImage() { const image = await readImage().catch(() => null) if (!image) return null