feat(desktop): open attachments in active project (#31192)

This commit is contained in:
Luke Parker 2026-06-07 15:57:07 +10:00 committed by GitHub
commit 218147271c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 398 additions and 82 deletions

View file

@ -0,0 +1,85 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
assertAttachmentBudget,
createPickedFileAuthorizations,
MAX_ATTACHMENT_BYTES,
readAttachment,
} from "./attachment-picker"
describe("assertAttachmentBudget", () => {
test("accepts selections within the media ingest limit", () => {
expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES / 2 }, { size: MAX_ATTACHMENT_BYTES / 2 }])).not.toThrow()
})
test("rejects the selection before files are read when its total exceeds the limit", () => {
expect(() => assertAttachmentBudget([{ size: MAX_ATTACHMENT_BYTES }, { size: 1 }])).toThrow("20 MB limit")
})
test("reads an approved file through a bounded buffer", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
const file = join(directory, "example.txt")
try {
await writeFile(file, "lorem ipsum")
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("rejects an oversized file before allocating its contents", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-attachment-"))
const file = join(directory, "oversized.txt")
try {
await writeFile(file, "")
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
})
describe("picked file authorizations", () => {
const read = async (path: string) => new TextEncoder().encode(path).buffer
test("keeps concurrent picker selections isolated", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt", "b.txt"])
const second = authorizations.add(1, ["c.txt"])
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
})
test("releases unread files for one picker without affecting another", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt"])
const second = authorizations.add(1, ["b.txt"])
authorizations.release(1, first)
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
})
test("keeps picker tokens scoped to their renderer", async () => {
const authorizations = createPickedFileAuthorizations(read)
const token = authorizations.add(1, ["a.txt"])
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
})
test("charges actual reads against the selection budget", async () => {
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
if (6 > maxBytes) throw new Error("budget exceeded")
return new ArrayBuffer(6)
}, 10)
const token = authorizations.add(1, ["a.txt", "b.txt"])
await authorizations.read(1, token, "a.txt")
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
})
})

View file

@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto"
import { open } from "node:fs/promises"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
export function createPickedFileAuthorizations(
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
budget = MAX_ATTACHMENT_BYTES,
) {
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
return {
add(sender: number, paths: string[]) {
const token = randomUUID()
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
return token
},
async read(sender: number, token: string, path: string) {
const selection = selections.get(token)
if (selection?.sender !== sender || !selection.paths.delete(path)) throw new Error("File was not selected by the picker")
const bytes = await read(path, selection.remaining)
selection.remaining -= bytes.byteLength
if (selection.paths.size === 0) selections.delete(token)
return bytes
},
release(sender: number, token: string) {
if (selections.get(token)?.sender === sender) selections.delete(token)
},
}
}
export function assertAttachmentBudget(files: { size: number }[]) {
const total = files.reduce((sum, file) => sum + file.size, 0)
if (total <= MAX_ATTACHMENT_BYTES) return
throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
}
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
const file = await open(filePath, "r")
try {
const info = await file.stat()
if (info.size > maxBytes) throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`)
const bytes = Buffer.allocUnsafe(info.size)
let offset = 0
while (offset < info.size) {
const result = await file.read(bytes, offset, info.size - offset, offset)
if (result.bytesRead === 0) break
offset += result.bytesRead
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
} finally {
await file.close()
}
}

View file

@ -1,10 +1,13 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { getStore } from "./store"
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
import type { UpdaterController } from "./updater-controller"
@ -15,6 +18,8 @@ const pickerFilters = (ext?: string[]) => {
return [{ name: "Files", extensions: ext }]
}
const pickedFiles = createPickedFileAuthorizations()
type Deps = {
killSidecar: () => Promise<void> | void
relaunch: () => void
@ -115,8 +120,8 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle(
"open-file-picker",
async (
_event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
event: IpcMainInvokeEvent,
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
) => {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
@ -125,10 +130,23 @@ export function registerIpcHandlers(deps: Deps) {
filters: pickerFilters(opts?.extensions),
})
if (result.canceled) return null
return opts?.multiple ? result.filePaths : result.filePaths[0]
const files = await Promise.all(
result.filePaths.map(async (filePath) => ({ path: filePath, name: basename(filePath), size: (await stat(filePath)).size })),
)
assertAttachmentBudget(files)
const token = pickedFiles.add(event.sender.id, result.filePaths)
return { token, files }
},
)
ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => {
return pickedFiles.read(event.sender.id, token, filePath)
})
ipcMain.handle("release-picked-files", (event: IpcMainInvokeEvent, token: string) => {
pickedFiles.release(event.sender.id, token)
})
ipcMain.handle(
"save-file-picker",
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {

View file

@ -86,6 +86,8 @@ const api: ElectronAPI = {
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path),
releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token),
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),

View file

@ -74,9 +74,10 @@ export type ElectronAPI = {
multiple?: boolean
title?: string
defaultPath?: string
accept?: string[]
extensions?: string[]
}) => Promise<string | string[] | null>
}) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null>
readPickedFile: (token: string, path: string) => Promise<ArrayBuffer>
releasePickedFiles: (token: string) => Promise<void>
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>

View file

@ -2,7 +2,6 @@
import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
AppBaseProviders,
AppInterface,
handleNotificationClick,
@ -144,13 +143,21 @@ const createPlatform = (): Platform => {
})
},
async openFilePickerDialog(opts) {
return window.api.openFilePicker({
async openAttachmentPickerDialog(opts, onFile) {
const result = await window.api.openFilePicker({
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
defaultPath: opts?.defaultPath,
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
})
if (!result) return
try {
for (const file of result.files) {
await onFile(new File([await window.api.readPickedFile(result.token, file.path)], file.name))
}
} finally {
await window.api.releasePickedFiles(result.token)
}
},
async saveFilePickerDialog(opts) {