diff options
| author | Adam <[email protected]> | 2026-03-13 06:58:24 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-13 06:58:24 -0500 |
| commit | 843f188aaafd3a19272f3867a686644d6a31c325 (patch) | |
| tree | ef2834837ff44a86f7222ed6518328d0ab46b640 /packages/app/src/components | |
| parent | 05cb3c87ca387be41aceb5ccad978c6848a56f70 (diff) | |
| download | opencode-843f188aaafd3a19272f3867a686644d6a31c325.tar.gz opencode-843f188aaafd3a19272f3867a686644d6a31c325.zip | |
fix(app): support text attachments (#17335)
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 9 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input/attachments.test.ts | 24 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input/attachments.ts | 102 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input/files.ts | 119 |
4 files changed, 211 insertions, 43 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ac5beed69..5d3f5bd99 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -38,7 +38,8 @@ import { usePlatform } from "@/context/platform" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" -import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" +import { createPromptAttachments } from "./prompt-input/attachments" +import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" import { canNavigateHistoryAtCursor, navigatePromptHistory, @@ -1007,7 +1008,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { return true } - const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({ + const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ editor: () => editorRef, isFocused, isDialogActive: () => !!dialog.active, @@ -1247,7 +1248,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { onOpen={(attachment) => dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />) } - onRemove={removeImageAttachment} + onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} /> <div @@ -1311,7 +1312,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { class="hidden" onChange={(e) => { const file = e.currentTarget.files?.[0] - if (file) addImageAttachment(file) + if (file) void addAttachment(file) e.currentTarget.value = "" }} /> diff --git a/packages/app/src/components/prompt-input/attachments.test.ts b/packages/app/src/components/prompt-input/attachments.test.ts new file mode 100644 index 000000000..d8ae43d13 --- /dev/null +++ b/packages/app/src/components/prompt-input/attachments.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import { attachmentMime } from "./files" + +describe("attachmentMime", () => { + test("keeps PDFs when the browser reports the mime", async () => { + const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" }) + expect(await attachmentMime(file)).toBe("application/pdf") + }) + + test("normalizes structured text types to text/plain", async () => { + const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" }) + expect(await attachmentMime(file)).toBe("text/plain") + }) + + test("accepts text files even with a misleading browser mime", async () => { + const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" }) + expect(await attachmentMime(file)).toBe("text/plain") + }) + + test("rejects binary files", async () => { + const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" }) + expect(await attachmentMime(file)).toBeUndefined() + }) +}) diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index a9e4e4965..b465ea5db 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -4,12 +4,27 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context import { useLanguage } from "@/context/language" import { uuid } from "@/utils/uuid" import { getCursorPosition } from "./editor-dom" - -export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] -export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] +import { attachmentMime } from "./files" const LARGE_PASTE_CHARS = 8000 const LARGE_PASTE_BREAKS = 120 +function dataUrl(file: File, mime: string) { + return new Promise<string>((resolve) => { + const reader = new FileReader() + reader.addEventListener("error", () => resolve("")) + reader.addEventListener("load", () => { + const value = typeof reader.result === "string" ? reader.result : "" + const idx = value.indexOf(",") + if (idx === -1) { + resolve(value) + return + } + resolve(`data:${mime};base64,${value.slice(idx + 1)}`) + }) + reader.readAsDataURL(file) + }) +} + function largePaste(text: string) { if (text.length >= LARGE_PASTE_CHARS) return true let breaks = 0 @@ -35,28 +50,41 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const prompt = usePrompt() const language = useLanguage() - const addImageAttachment = async (file: File) => { - if (!ACCEPTED_FILE_TYPES.includes(file.type)) return + const warn = () => { + showToast({ + title: language.t("prompt.toast.pasteUnsupported.title"), + description: language.t("prompt.toast.pasteUnsupported.description"), + }) + } - const reader = new FileReader() - reader.onload = () => { - const editor = input.editor() - if (!editor) return - const dataUrl = reader.result as string - const attachment: ImageAttachmentPart = { - type: "image", - id: uuid(), - filename: file.name, - mime: file.type, - dataUrl, - } - const cursorPosition = prompt.cursor() ?? getCursorPosition(editor) - prompt.set([...prompt.current(), attachment], cursorPosition) + const add = async (file: File, toast = true) => { + const mime = await attachmentMime(file) + if (!mime) { + if (toast) warn() + return false } - reader.readAsDataURL(file) + + const editor = input.editor() + if (!editor) return false + + const url = await dataUrl(file, mime) + if (!url) return false + + const attachment: ImageAttachmentPart = { + type: "image", + id: uuid(), + filename: file.name, + mime, + dataUrl: url, + } + const cursor = prompt.cursor() ?? getCursorPosition(editor) + prompt.set([...prompt.current(), attachment], cursor) + return true } - const removeImageAttachment = (id: string) => { + const addAttachment = (file: File) => add(file) + + const removeAttachment = (id: string) => { const current = prompt.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) prompt.set(next, prompt.cursor()) @@ -72,21 +100,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const items = Array.from(clipboardData.items) const fileItems = items.filter((item) => item.kind === "file") - const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) - if (imageItems.length > 0) { - for (const item of imageItems) { + if (fileItems.length > 0) { + let found = false + for (const item of fileItems) { const file = item.getAsFile() - if (file) await addImageAttachment(file) + if (!file) continue + const ok = await add(file, false) + if (ok) found = true } - return - } - - if (fileItems.length > 0) { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) + if (!found) warn() return } @@ -96,7 +119,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { if (input.readClipboardImage && !plainText) { const file = await input.readClipboardImage() if (file) { - await addImageAttachment(file) + await addAttachment(file) return } } @@ -153,11 +176,12 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dropped = event.dataTransfer?.files if (!dropped) return + let found = false for (const file of Array.from(dropped)) { - if (ACCEPTED_FILE_TYPES.includes(file.type)) { - await addImageAttachment(file) - } + const ok = await add(file, false) + if (ok) found = true } + if (!found && dropped.length > 0) warn() } onMount(() => { @@ -173,8 +197,8 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { }) return { - addImageAttachment, - removeImageAttachment, + addAttachment, + removeAttachment, handlePaste, } } diff --git a/packages/app/src/components/prompt-input/files.ts b/packages/app/src/components/prompt-input/files.ts new file mode 100644 index 000000000..594991d07 --- /dev/null +++ b/packages/app/src/components/prompt-input/files.ts @@ -0,0 +1,119 @@ +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] + +const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) +const IMAGE_EXTS = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"], +]) +const TEXT_MIMES = new Set([ + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", +]) + +export const ACCEPTED_FILE_TYPES = [ + ...ACCEPTED_IMAGE_TYPES, + "application/pdf", + "text/*", + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", + ".c", + ".cc", + ".cjs", + ".conf", + ".cpp", + ".css", + ".csv", + ".cts", + ".env", + ".go", + ".gql", + ".graphql", + ".h", + ".hh", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".mdx", + ".mjs", + ".mts", + ".py", + ".rb", + ".rs", + ".sass", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zsh", +] + +const SAMPLE = 4096 + +function kind(type: string) { + return type.split(";", 1)[0]?.trim().toLowerCase() ?? "" +} + +function ext(name: string) { + const idx = name.lastIndexOf(".") + if (idx === -1) return "" + return name.slice(idx + 1).toLowerCase() +} + +function textMime(type: string) { + if (!type) return false + if (type.startsWith("text/")) return true + if (TEXT_MIMES.has(type)) return true + if (type.endsWith("+json")) return true + return type.endsWith("+xml") +} + +function textBytes(bytes: Uint8Array) { + if (bytes.length === 0) return true + let count = 0 + for (const byte of bytes) { + if (byte === 0) return false + if (byte < 9 || (byte > 13 && byte < 32)) count += 1 + } + return count / bytes.length <= 0.3 +} + +export async function attachmentMime(file: File) { + const type = kind(file.type) + if (IMAGE_MIMES.has(type)) return type + if (type === "application/pdf") return type + + const suffix = ext(file.name) + const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined) + if ((!type || type === "application/octet-stream") && fallback) return fallback + + if (textMime(type)) return "text/plain" + const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer()) + if (!textBytes(bytes)) return + return "text/plain" +} |
