feat(desktop): open attachments in active project (#31192)
This commit is contained in:
parent
a29deb1ee9
commit
218147271c
7
packages/app/src/components/directory-picker-policy.ts
Normal file
7
packages/app/src/components/directory-picker-policy.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
||||
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
|
||||
return "server" as const
|
||||
}
|
||||
21
packages/app/src/components/directory-picker.test.ts
Normal file
21
packages/app/src/components/directory-picker.test.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
const local = {
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
const remote = {
|
||||
type: "ssh",
|
||||
host: "example.test",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
|
||||
describe("directoryPickerKind", () => {
|
||||
test("uses the native picker only for local desktop projects", () => {
|
||||
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||
expect(directoryPickerKind("web", local)).toBe("server")
|
||||
})
|
||||
})
|
||||
31
packages/app/src/components/directory-picker.tsx
Normal file
31
packages/app/src/components/directory-picker.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
}
|
||||
|
||||
export function useDirectoryPicker() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
||||
return (input: DirectoryPickerInput) => {
|
||||
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||
void platform
|
||||
.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple })
|
||||
.then(input.onSelect)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory {...input} />,
|
||||
() => input.onSelect(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -57,7 +57,7 @@ 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 } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
@ -73,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { useDirectoryPicker } from "./directory-picker"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
@ -140,6 +142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
@ -468,7 +471,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const pick = () => {
|
||||
if (server.isLocal()) {
|
||||
fileInputRef?.click()
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk.directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-file").then((module) =>
|
||||
@ -1094,7 +1108,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@ -1385,7 +1399,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = async () => {
|
||||
const addProject = () => {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
const select = (result: string | string[] | null) => {
|
||||
@ -1393,15 +1407,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!directory) return
|
||||
selectProject(directory)
|
||||
}
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
|
||||
() => select(null),
|
||||
)
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
onSelect: select,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { attachmentMime } from "./files"
|
||||
import { attachmentMime, pickAttachmentFiles } from "./files"
|
||||
import { pasteMode } from "./paste"
|
||||
|
||||
describe("attachmentMime", () => {
|
||||
@ -24,6 +24,70 @@ describe("attachmentMime", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickAttachmentFiles", () => {
|
||||
test("reads the current project directory for every native picker invocation", async () => {
|
||||
const paths: string[] = []
|
||||
const files: File[] = []
|
||||
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||
let directory = "C:\\Projects\\LoremIpsum"
|
||||
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
|
||||
paths.push(options?.defaultPath ?? "")
|
||||
await onFile?.(file)
|
||||
}
|
||||
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
directory = "C:\\Projects\\DolorSit"
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(files).toEqual([file, file])
|
||||
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
|
||||
})
|
||||
|
||||
test("uses the browser file input when no native picker exists", async () => {
|
||||
let fallback = 0
|
||||
pickAttachmentFiles({
|
||||
directory: () => "/projects/consectetur-adipiscing",
|
||||
fallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
onFile: async () => undefined,
|
||||
onError: () => undefined,
|
||||
})
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
|
||||
test("reports native picker failures without rejecting", async () => {
|
||||
const error = new Error("picker unavailable")
|
||||
const errors: unknown[] = []
|
||||
const handled = Promise.withResolvers<void>()
|
||||
pickAttachmentFiles({
|
||||
picker: async () => Promise.reject(error),
|
||||
directory: () => "C:\\Projects\\LoremIpsum",
|
||||
fallback: () => undefined,
|
||||
onFile: async () => undefined,
|
||||
onError: (cause) => {
|
||||
errors.push(cause)
|
||||
handled.resolve()
|
||||
},
|
||||
})
|
||||
await handled.promise
|
||||
expect(errors).toEqual([error])
|
||||
})
|
||||
})
|
||||
|
||||
describe("pasteMode", () => {
|
||||
test("uses native paste for short single-line text", () => {
|
||||
expect(pasteMode("hello world")).toBe("native")
|
||||
|
||||
@ -2,6 +2,35 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
|
||||
|
||||
export { ACCEPTED_FILE_TYPES }
|
||||
|
||||
type AttachmentPicker = (options: {
|
||||
defaultPath?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
}, onFile: (file: File) => Promise<unknown>) => Promise<void>
|
||||
|
||||
export function pickAttachmentFiles(input: {
|
||||
picker?: AttachmentPicker
|
||||
directory: () => string
|
||||
fallback: () => void
|
||||
onFile: (file: File) => Promise<unknown>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
if (!input.picker) {
|
||||
input.fallback()
|
||||
return
|
||||
}
|
||||
void input
|
||||
.picker(
|
||||
{
|
||||
defaultPath: input.directory(),
|
||||
multiple: true,
|
||||
accept: ACCEPTED_FILE_TYPES,
|
||||
},
|
||||
input.onFile,
|
||||
)
|
||||
.catch(input.onError)
|
||||
}
|
||||
|
||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||
const IMAGE_EXTS = new Map([
|
||||
["gif", "image/gif"],
|
||||
|
||||
@ -8,7 +8,13 @@ import type { UpdaterPlatform } from "../updater"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||
type OpenAttachmentPickerOptions = {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
extensions?: string[]
|
||||
defaultPath?: string
|
||||
}
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
@ -21,13 +27,7 @@ export type FatalRendererErrorLog = {
|
||||
os?: DesktopOS
|
||||
}
|
||||
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: PlatformName
|
||||
|
||||
/** Desktop OS (Tauri only) */
|
||||
os?: DesktopOS
|
||||
|
||||
type PlatformBase = {
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
@ -49,13 +49,10 @@ export type Platform = {
|
||||
/** Send a system notification (optional deep link) */
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
|
||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||
openAttachmentPickerDialog?(opts: OpenAttachmentPickerOptions, onFile: (file: File) => Promise<unknown>): Promise<void>
|
||||
|
||||
/** Open native file picker dialog (Tauri only) */
|
||||
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
||||
|
||||
/** Save file picker dialog (Tauri only) */
|
||||
/** Open a native save file picker dialog (desktop only) */
|
||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
@ -110,6 +107,16 @@ export type Platform = {
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
(
|
||||
| { platform: "web"; os?: never }
|
||||
| {
|
||||
platform: "desktop"
|
||||
os?: DesktopOS
|
||||
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
}
|
||||
)
|
||||
|
||||
export type DisplayBackend = "auto" | "wayland"
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
|
||||
@ -18,7 +18,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
@ -125,6 +125,7 @@ function HomeDesign() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
@ -314,26 +315,19 @@ function HomeDesign() {
|
||||
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
}
|
||||
|
||||
async function chooseProject(conn: ServerConnection.Any) {
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
|
||||
const server = global.createServerCtx(conn)
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
@ -1101,6 +1095,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||
function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
@ -1128,7 +1123,7 @@ function LegacyHome() {
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function chooseProject() {
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
@ -1142,18 +1137,12 @@ function LegacyHome() {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
pickDirectory({
|
||||
server: s,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -64,6 +64,7 @@ import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@ -117,6 +118,7 @@ export default function Layout(props: ParentProps) {
|
||||
const layout = useLayout()
|
||||
const layoutReady = createMemo(() => layout.ready())
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
@ -1457,7 +1459,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
async function chooseProject() {
|
||||
function chooseProject() {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
function resolve(result: string | string[] | null) {
|
||||
@ -1471,22 +1473,12 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
}
|
||||
|
||||
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||
|
||||
85
packages/desktop/src/main/attachment-picker.test.ts
Normal file
85
packages/desktop/src/main/attachment-picker.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
54
packages/desktop/src/main/attachment-picker.ts
Normal file
54
packages/desktop/src/main/attachment-picker.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user