Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion packages/app/src/components/dialog-edit-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/util/path"
import { IMAGE_EXTS, pathSuffix } from "@opencode-ai/util/file-extensions"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"

const MAX_ICON_SIZE = 5 * 1024 * 1024

const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const

export function DialogEditProject(props: { project: LocalProject }) {
Expand All @@ -36,12 +40,28 @@ export function DialogEditProject(props: { project: LocalProject }) {
let iconInput: HTMLInputElement | undefined

function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const suffix = pathSuffix(file.name)
const isImage = file.type.startsWith("image/") || IMAGE_EXTS.has(suffix)
if (!isImage) return
if (file.size > MAX_ICON_SIZE) {
showToast({
title: language.t("dialog.project.edit.icon.tooLarge.title"),
description: language.t("dialog.project.edit.icon.tooLarge.description"),
})
return
}
const reader = new FileReader()
reader.onload = (e) => {
setStore("iconUrl", e.target?.result as string)
setStore("iconHover", false)
}
reader.onerror = () => {
showToast({
title: language.t("dialog.project.edit.icon.readFailed.title"),
description: language.t("dialog.project.edit.icon.readFailed.description"),
})
setStore("iconHover", false)
}
reader.readAsDataURL(file)
}

Expand Down
18 changes: 13 additions & 5 deletions packages/app/src/components/prompt-input/attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ describe("createPromptAttachments", () => {
expect(toasts.map((toast) => toast.title)).toEqual(["prompt.toast.pasteUnsupported.title"])
})

test("rejects malformed FileReader data URLs", async () => {
test("accepts empty FileReader MIME when the routed MIME is known", async () => {
fileReaderDataUrl = "data:;base64,aGVsbG8="
const attachments = createPromptAttachments({
editor: () => ({}) as HTMLDivElement,
Expand All @@ -272,13 +272,21 @@ describe("createPromptAttachments", () => {

const result = await attachments.addAttachment(new File(["image"], "image.png", { type: "image/png" }))

expect(result).toBe(false)
expect(promptParts).toHaveLength(0)
expect(toasts.map((toast) => toast.title)).toEqual(["prompt.toast.pasteUnsupported.title"])
expect(result).toBe(true)
expect(promptParts).toEqual([
{
type: "image",
id: expect.any(String),
filename: "image.png",
mime: "image/png",
dataUrl: "data:image/png;base64,aGVsbG8=",
},
])
expect(toasts).toHaveLength(0)
})

test("reports direct attachment read failures in dropped file batches", async () => {
fileReaderDataUrl = "data:;base64,aGVsbG8="
fileReaderDataUrl = "data:;base64,not-base64"
const attachments = createPromptAttachments({
editor: () => ({}) as HTMLDivElement,
isDialogActive: () => false,
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { routeBrowserFile, routePickedPath, type AttachRoute, type ModelInputSup

function dataUrlMimeCompatible(actual: string, expected: string) {
if (actual === expected) return true
if (!actual || actual === "application/octet-stream") return true
if (expected !== "text/plain") return false
return textMime(actual)
Comment thread
Astro-Han marked this conversation as resolved.
}
Expand Down Expand Up @@ -40,7 +41,7 @@ function dataUrl(file: File, mime: string) {
}
const actual = header.slice("data:".length, -base64Marker.length).toLowerCase()
const payload = value.slice(comma + 1)
if (!actual || !dataUrlMimeCompatible(actual, mime.toLowerCase()) || !isBase64Payload(payload)) {
if (!dataUrlMimeCompatible(actual, mime.toLowerCase()) || !isBase64Payload(payload)) {
resolve("")
return
}
Expand Down Expand Up @@ -324,7 +325,13 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}

const dropped = event.dataTransfer?.files
if (!dropped) return
if (!dropped || dropped.length === 0) {
showToast({
title: language.t("prompt.toast.pasteUnsupported.title"),
description: language.t("prompt.toast.pasteUnsupported.description"),
})
return
}
Comment thread
Astro-Han marked this conversation as resolved.

await addAttachments(Array.from(dropped))
}
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ export const dict = {
"dialog.project.edit.worktree.startup": "Workspace startup script",
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
"dialog.project.edit.icon.tooLarge.title": "Image too large",
"dialog.project.edit.icon.tooLarge.description": "Please choose an image under 5MB.",
"dialog.project.edit.icon.readFailed.title": "Failed to read image",
"dialog.project.edit.icon.readFailed.description": "The selected file could not be loaded. Please try another image.",

"dialog.releaseNotes.action.getStarted": "Get started",
"dialog.releaseNotes.action.next": "Next",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,10 @@ export const dict = {
"dialog.project.edit.worktree.startup": "工作区启动脚本",
"dialog.project.edit.worktree.startup.description": "在创建新的工作区 (worktree) 后运行。",
"dialog.project.edit.worktree.startup.placeholder": "例如 bun install",
"dialog.project.edit.icon.tooLarge.title": "图片过大",
"dialog.project.edit.icon.tooLarge.description": "请选择 5MB 以下的图片。",
"dialog.project.edit.icon.readFailed.title": "读取图片失败",
"dialog.project.edit.icon.readFailed.description": "无法加载所选文件,请尝试其他图片。",

"context.breakdown.title": "上下文拆分",
"context.breakdown.note": "输入 token 的大致拆分。“其他”包含工具定义和开销。",
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop-electron/src/main/attachment-mime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe("attachmentPathMime", () => {
expect(attachmentPathMime("C:\\tmp\\image.PNG", extname)).toBe("image/png")
})

test("keeps image MIME entries in sync with the renderer contract", () => {
test("derives MIME_BY_EXTENSION from IMAGE_EXTS so they stay in sync", () => {
const imageEntries = [...MIME_BY_EXTENSION.entries()]
.filter(([extension]) => extension !== "pdf")
.sort(([left], [right]) => left.localeCompare(right))
Expand Down
8 changes: 2 additions & 6 deletions packages/desktop-electron/src/main/attachment-mime.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import path from "node:path"
import { IMAGE_EXTS } from "@opencode-ai/util/file-extensions"

// Keep image entries in sync with packages/util/src/file-extensions.ts::IMAGE_EXTS.
export const MIME_BY_EXTENSION = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
["jpg", "image/jpeg"],
...IMAGE_EXTS,
["pdf", "application/pdf"],
["png", "image/png"],
["webp", "image/webp"],
])

export function attachmentPathMime(filepath: string, extname = path.extname) {
Expand Down
Loading