feat(desktop): open attachments in active project (#31192)
This commit is contained in:
parent
a29deb1ee9
commit
218147271c
15 changed files with 398 additions and 82 deletions
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 { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
|
|
@ -73,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
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 { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||||
import { useQueries } from "@tanstack/solid-query"
|
import { useQueries } from "@tanstack/solid-query"
|
||||||
import { useQueryOptions } from "@/context/server-sync"
|
import { useQueryOptions } from "@/context/server-sync"
|
||||||
|
|
@ -140,6 +142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
|
|
@ -468,7 +471,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
const pick = () => {
|
const pick = () => {
|
||||||
if (server.isLocal()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
void import("@/components/dialog-select-file").then((module) =>
|
void import("@/components/dialog-select-file").then((module) =>
|
||||||
|
|
@ -1094,7 +1108,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
|
|
@ -1385,7 +1399,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
server.projects.touch(worktree)
|
server.projects.touch(worktree)
|
||||||
navigate(`/${base64Encode(worktree)}/session`)
|
navigate(`/${base64Encode(worktree)}/session`)
|
||||||
}
|
}
|
||||||
const addProject = async () => {
|
const addProject = () => {
|
||||||
const conn = server.current
|
const conn = server.current
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
const select = (result: string | string[] | null) => {
|
const select = (result: string | string[] | null) => {
|
||||||
|
|
@ -1393,15 +1407,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
selectProject(directory)
|
selectProject(directory)
|
||||||
}
|
}
|
||||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
pickDirectory({
|
||||||
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
|
server: conn,
|
||||||
return
|
title: language.t("command.project.open"),
|
||||||
}
|
onSelect: select,
|
||||||
void import("@/components/dialog-select-directory").then((x) => {
|
|
||||||
dialog.show(
|
|
||||||
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
|
|
||||||
() => select(null),
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { attachmentMime } from "./files"
|
import { attachmentMime, pickAttachmentFiles } from "./files"
|
||||||
import { pasteMode } from "./paste"
|
import { pasteMode } from "./paste"
|
||||||
|
|
||||||
describe("attachmentMime", () => {
|
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", () => {
|
describe("pasteMode", () => {
|
||||||
test("uses native paste for short single-line text", () => {
|
test("uses native paste for short single-line text", () => {
|
||||||
expect(pasteMode("hello world")).toBe("native")
|
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 }
|
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_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||||
const IMAGE_EXTS = new Map([
|
const IMAGE_EXTS = new Map([
|
||||||
["gif", "image/gif"],
|
["gif", "image/gif"],
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ import type { UpdaterPlatform } from "../updater"
|
||||||
|
|
||||||
type PickerPaths = string | string[] | null
|
type PickerPaths = string | string[] | null
|
||||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
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 SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||||
type PlatformName = "web" | "desktop"
|
type PlatformName = "web" | "desktop"
|
||||||
type DesktopOS = "macos" | "windows" | "linux"
|
type DesktopOS = "macos" | "windows" | "linux"
|
||||||
|
|
@ -21,13 +27,7 @@ export type FatalRendererErrorLog = {
|
||||||
os?: DesktopOS
|
os?: DesktopOS
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Platform = {
|
type PlatformBase = {
|
||||||
/** Platform discriminator */
|
|
||||||
platform: PlatformName
|
|
||||||
|
|
||||||
/** Desktop OS (Tauri only) */
|
|
||||||
os?: DesktopOS
|
|
||||||
|
|
||||||
/** App version */
|
/** App version */
|
||||||
version?: string
|
version?: string
|
||||||
|
|
||||||
|
|
@ -49,13 +49,10 @@ export type Platform = {
|
||||||
/** Send a system notification (optional deep link) */
|
/** Send a system notification (optional deep link) */
|
||||||
notify(title: string, description?: string, href?: string): Promise<void>
|
notify(title: string, description?: string, href?: string): Promise<void>
|
||||||
|
|
||||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||||
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
openAttachmentPickerDialog?(opts: OpenAttachmentPickerOptions, onFile: (file: File) => Promise<unknown>): Promise<void>
|
||||||
|
|
||||||
/** Open native file picker dialog (Tauri only) */
|
/** Open a native save file picker dialog (desktop only) */
|
||||||
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
|
||||||
|
|
||||||
/** Save file picker dialog (Tauri only) */
|
|
||||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||||
|
|
||||||
/** Storage mechanism, defaults to localStorage */
|
/** Storage mechanism, defaults to localStorage */
|
||||||
|
|
@ -110,6 +107,16 @@ export type Platform = {
|
||||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
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 type DisplayBackend = "auto" | "wayland"
|
||||||
|
|
||||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { DateTime } from "luxon"
|
import { DateTime } from "luxon"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
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 { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
|
|
@ -125,6 +125,7 @@ function HomeDesign() {
|
||||||
const sync = useServerSync()
|
const sync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
|
@ -314,26 +315,19 @@ function HomeDesign() {
|
||||||
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
|
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) {
|
function resolve(result: string | string[] | null) {
|
||||||
addProjects(conn, homeProjectDirectories(result))
|
addProjects(conn, homeProjectDirectories(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = global.createServerCtx(conn)
|
const server = global.createServerCtx(conn)
|
||||||
|
|
||||||
if (platform.openDirectoryPickerDialog && server.isLocal) {
|
pickDirectory({
|
||||||
const result = await platform.openDirectoryPickerDialog?.({
|
server: conn,
|
||||||
title: language.t("command.project.open"),
|
title: language.t("command.project.open"),
|
||||||
multiple: true,
|
multiple: true,
|
||||||
})
|
onSelect: resolve,
|
||||||
resolve(result)
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.show(
|
|
||||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
|
||||||
() => resolve(null),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
|
|
@ -1101,6 +1095,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||||
function LegacyHome() {
|
function LegacyHome() {
|
||||||
const sync = useServerSync()
|
const sync = useServerSync()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
|
|
@ -1128,7 +1123,7 @@ function LegacyHome() {
|
||||||
navigate(`/${base64Encode(directory)}`)
|
navigate(`/${base64Encode(directory)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseProject() {
|
function chooseProject() {
|
||||||
const s = server.current
|
const s = server.current
|
||||||
if (!s) return
|
if (!s) return
|
||||||
|
|
||||||
|
|
@ -1142,18 +1137,12 @@ function LegacyHome() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
pickDirectory({
|
||||||
const result = await platform.openDirectoryPickerDialog?.({
|
server: s,
|
||||||
title: language.t("command.project.open"),
|
title: language.t("command.project.open"),
|
||||||
multiple: true,
|
multiple: true,
|
||||||
})
|
onSelect: resolve,
|
||||||
resolve(result)
|
})
|
||||||
} else {
|
|
||||||
dialog.show(
|
|
||||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
|
|
||||||
() => resolve(null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import { useCommand, type CommandOption } from "@/context/command"
|
||||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { DebugBar } from "@/components/debug-bar"
|
import { DebugBar } from "@/components/debug-bar"
|
||||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||||
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { useLanguage, type Locale } from "@/context/language"
|
import { useLanguage, type Locale } from "@/context/language"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
|
@ -117,6 +118,7 @@ export default function Layout(props: ParentProps) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const layoutReady = createMemo(() => layout.ready())
|
const layoutReady = createMemo(() => layout.ready())
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const pickDirectory = useDirectoryPicker()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
|
|
@ -1457,7 +1459,7 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseProject() {
|
function chooseProject() {
|
||||||
const conn = server.current
|
const conn = server.current
|
||||||
if (!conn) return
|
if (!conn) return
|
||||||
function resolve(result: string | string[] | null) {
|
function resolve(result: string | string[] | null) {
|
||||||
|
|
@ -1471,22 +1473,12 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
pickDirectory({
|
||||||
const result = await platform.openDirectoryPickerDialog?.({
|
server: conn,
|
||||||
title: language.t("command.project.open"),
|
title: language.t("command.project.open"),
|
||||||
multiple: true,
|
multiple: true,
|
||||||
})
|
onSelect: resolve,
|
||||||
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),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
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 { 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 { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
|
||||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||||
|
|
||||||
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types"
|
||||||
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
import { runDesktopMenuAction } from "./desktop-menu-actions"
|
||||||
|
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
import { getPinchZoomEnabled, setPinchZoomEnabled, setTitlebar, updateTitlebar } from "./windows"
|
||||||
import type { UpdaterController } from "./updater-controller"
|
import type { UpdaterController } from "./updater-controller"
|
||||||
|
|
@ -15,6 +18,8 @@ const pickerFilters = (ext?: string[]) => {
|
||||||
return [{ name: "Files", extensions: ext }]
|
return [{ name: "Files", extensions: ext }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pickedFiles = createPickedFileAuthorizations()
|
||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
killSidecar: () => Promise<void> | void
|
killSidecar: () => Promise<void> | void
|
||||||
relaunch: () => void
|
relaunch: () => void
|
||||||
|
|
@ -115,8 +120,8 @@ export function registerIpcHandlers(deps: Deps) {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"open-file-picker",
|
"open-file-picker",
|
||||||
async (
|
async (
|
||||||
_event: IpcMainInvokeEvent,
|
event: IpcMainInvokeEvent,
|
||||||
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
|
opts?: { multiple?: boolean; title?: string; defaultPath?: string; extensions?: string[] },
|
||||||
) => {
|
) => {
|
||||||
const result = await dialog.showOpenDialog({
|
const result = await dialog.showOpenDialog({
|
||||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||||
|
|
@ -125,10 +130,23 @@ export function registerIpcHandlers(deps: Deps) {
|
||||||
filters: pickerFilters(opts?.extensions),
|
filters: pickerFilters(opts?.extensions),
|
||||||
})
|
})
|
||||||
if (result.canceled) return null
|
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(
|
ipcMain.handle(
|
||||||
"save-file-picker",
|
"save-file-picker",
|
||||||
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
|
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ const api: ElectronAPI = {
|
||||||
|
|
||||||
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
|
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
|
||||||
openFilePicker: (opts) => ipcRenderer.invoke("open-file-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),
|
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
|
||||||
openLink: (url) => ipcRenderer.send("open-link", url),
|
openLink: (url) => ipcRenderer.send("open-link", url),
|
||||||
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
|
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,10 @@ export type ElectronAPI = {
|
||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
defaultPath?: string
|
defaultPath?: string
|
||||||
accept?: string[]
|
|
||||||
extensions?: 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>
|
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
||||||
openLink: (url: string) => void
|
openLink: (url: string) => void
|
||||||
openPath: (path: string, app?: string) => Promise<void>
|
openPath: (path: string, app?: string) => Promise<void>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ACCEPTED_FILE_EXTENSIONS,
|
ACCEPTED_FILE_EXTENSIONS,
|
||||||
ACCEPTED_FILE_TYPES,
|
|
||||||
AppBaseProviders,
|
AppBaseProviders,
|
||||||
AppInterface,
|
AppInterface,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
|
|
@ -144,13 +143,21 @@ const createPlatform = (): Platform => {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
async openFilePickerDialog(opts) {
|
async openAttachmentPickerDialog(opts, onFile) {
|
||||||
return window.api.openFilePicker({
|
const result = await window.api.openFilePicker({
|
||||||
multiple: opts?.multiple ?? false,
|
multiple: opts?.multiple ?? false,
|
||||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||||
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
|
defaultPath: opts?.defaultPath,
|
||||||
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
|
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) {
|
async saveFilePickerDialog(opts) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue