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
parent a29deb1ee9
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,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
}

View 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")
})
})

View 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),
)
}
}

View File

@ -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,
})
}

View File

@ -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")

View File

@ -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"],

View File

@ -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({

View File

@ -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 (

View File

@ -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) => {

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) {