Skip to content

Commit 2181472

Browse files
authored
feat(desktop): open attachments in active project (anomalyco#31192)
1 parent a29deb1 commit 2181472

15 files changed

Lines changed: 398 additions & 82 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { ServerConnection } from "@/context/server"
2+
import type { Platform } from "@/context/platform"
3+
4+
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
5+
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
6+
return "server" as const
7+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { directoryPickerKind } from "./directory-picker-policy"
3+
4+
const local = {
5+
type: "sidecar",
6+
variant: "base",
7+
http: { url: "http://localhost:4096" },
8+
} as const
9+
const remote = {
10+
type: "ssh",
11+
host: "example.test",
12+
http: { url: "http://localhost:4096" },
13+
} as const
14+
15+
describe("directoryPickerKind", () => {
16+
test("uses the native picker only for local desktop projects", () => {
17+
expect(directoryPickerKind("desktop", local)).toBe("native")
18+
expect(directoryPickerKind("desktop", remote)).toBe("server")
19+
expect(directoryPickerKind("web", local)).toBe("server")
20+
})
21+
})
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useDialog } from "@opencode-ai/ui/context/dialog"
2+
import { ServerConnection } from "@/context/server"
3+
import { usePlatform } from "@/context/platform"
4+
import { DialogSelectDirectory } from "./dialog-select-directory"
5+
import { directoryPickerKind } from "./directory-picker-policy"
6+
7+
type DirectoryPickerInput = {
8+
server: ServerConnection.Any
9+
title?: string
10+
multiple?: boolean
11+
onSelect: (result: string | string[] | null) => void
12+
}
13+
14+
export function useDirectoryPicker() {
15+
const platform = usePlatform()
16+
const dialog = useDialog()
17+
18+
return (input: DirectoryPickerInput) => {
19+
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
20+
void platform
21+
.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple })
22+
.then(input.onSelect)
23+
return
24+
}
25+
26+
dialog.show(
27+
() => <DialogSelectDirectory {...input} />,
28+
() => input.onSelect(null),
29+
)
30+
}
31+
}

packages/app/src/components/prompt-input.tsx

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
5757
import { createSessionTabs } from "@/pages/session/helpers"
5858
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
5959
import { createPromptAttachments } from "./prompt-input/attachments"
60-
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
60+
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
6161
import {
6262
canNavigateHistoryAtCursor,
6363
navigatePromptHistory,
@@ -73,6 +73,8 @@ import { PromptContextItems } from "./prompt-input/context-items"
7373
import { PromptImageAttachments } from "./prompt-input/image-attachments"
7474
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
7575
import { promptPlaceholder } from "./prompt-input/placeholder"
76+
import { useDirectoryPicker } from "./directory-picker"
77+
import { showToast } from "@/utils/toast"
7678
import { ImagePreview } from "@opencode-ai/ui/image-preview"
7779
import { useQueries } from "@tanstack/solid-query"
7880
import { useQueryOptions } from "@/context/server-sync"
@@ -140,6 +142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
140142
const permission = usePermission()
141143
const language = useLanguage()
142144
const platform = usePlatform()
145+
const pickDirectory = useDirectoryPicker()
143146
const settings = useSettings()
144147
const { params, tabs, view } = useSessionLayout()
145148
let editorRef!: HTMLDivElement
@@ -468,7 +471,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
468471

469472
const pick = () => {
470473
if (server.isLocal()) {
471-
fileInputRef?.click()
474+
pickAttachmentFiles({
475+
picker: platform.openAttachmentPickerDialog,
476+
directory: () => sdk.directory,
477+
fallback: () => fileInputRef?.click(),
478+
onFile: addAttachment,
479+
onError: (error) =>
480+
showToast({
481+
variant: "error",
482+
title: language.t("common.requestFailed"),
483+
description: error instanceof Error ? error.message : String(error),
484+
}),
485+
})
472486
return
473487
}
474488
void import("@/components/dialog-select-file").then((module) =>
@@ -1094,7 +1108,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
10941108
return true
10951109
}
10961110

1097-
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
1111+
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
10981112
editor: () => editorRef,
10991113
isDialogActive: () => !!dialog.active,
11001114
setDraggingType: (type) => setStore("draggingType", type),
@@ -1385,23 +1399,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
13851399
server.projects.touch(worktree)
13861400
navigate(`/${base64Encode(worktree)}/session`)
13871401
}
1388-
const addProject = async () => {
1402+
const addProject = () => {
13891403
const conn = server.current
13901404
if (!conn) return
13911405
const select = (result: string | string[] | null) => {
13921406
const directory = Array.isArray(result) ? result[0] : result
13931407
if (!directory) return
13941408
selectProject(directory)
13951409
}
1396-
if (platform.openDirectoryPickerDialog && server.isLocal()) {
1397-
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
1398-
return
1399-
}
1400-
void import("@/components/dialog-select-directory").then((x) => {
1401-
dialog.show(
1402-
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
1403-
() => select(null),
1404-
)
1410+
pickDirectory({
1411+
server: conn,
1412+
title: language.t("command.project.open"),
1413+
onSelect: select,
14051414
})
14061415
}
14071416

packages/app/src/components/prompt-input/attachments.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test"
2-
import { attachmentMime } from "./files"
2+
import { attachmentMime, pickAttachmentFiles } from "./files"
33
import { pasteMode } from "./paste"
44

55
describe("attachmentMime", () => {
@@ -24,6 +24,70 @@ describe("attachmentMime", () => {
2424
})
2525
})
2626

27+
describe("pickAttachmentFiles", () => {
28+
test("reads the current project directory for every native picker invocation", async () => {
29+
const paths: string[] = []
30+
const files: File[] = []
31+
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
32+
let directory = "C:\\Projects\\LoremIpsum"
33+
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
34+
paths.push(options?.defaultPath ?? "")
35+
await onFile?.(file)
36+
}
37+
38+
pickAttachmentFiles({
39+
picker,
40+
directory: () => directory,
41+
fallback: () => undefined,
42+
onFile: async (selected) => files.push(selected),
43+
onError: () => undefined,
44+
})
45+
await Promise.resolve()
46+
directory = "C:\\Projects\\DolorSit"
47+
pickAttachmentFiles({
48+
picker,
49+
directory: () => directory,
50+
fallback: () => undefined,
51+
onFile: async (selected) => files.push(selected),
52+
onError: () => undefined,
53+
})
54+
await Promise.resolve()
55+
expect(files).toEqual([file, file])
56+
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
57+
})
58+
59+
test("uses the browser file input when no native picker exists", async () => {
60+
let fallback = 0
61+
pickAttachmentFiles({
62+
directory: () => "/projects/consectetur-adipiscing",
63+
fallback: () => {
64+
fallback += 1
65+
},
66+
onFile: async () => undefined,
67+
onError: () => undefined,
68+
})
69+
expect(fallback).toBe(1)
70+
})
71+
72+
test("reports native picker failures without rejecting", async () => {
73+
const error = new Error("picker unavailable")
74+
const errors: unknown[] = []
75+
const handled = Promise.withResolvers<void>()
76+
pickAttachmentFiles({
77+
picker: async () => Promise.reject(error),
78+
directory: () => "C:\\Projects\\LoremIpsum",
79+
fallback: () => undefined,
80+
onFile: async () => undefined,
81+
onError: (cause) => {
82+
errors.push(cause)
83+
handled.resolve()
84+
},
85+
})
86+
await handled.promise
87+
expect(errors).toEqual([error])
88+
})
89+
})
90+
2791
describe("pasteMode", () => {
2892
test("uses native paste for short single-line text", () => {
2993
expect(pasteMode("hello world")).toBe("native")

packages/app/src/components/prompt-input/files.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,35 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
22

33
export { ACCEPTED_FILE_TYPES }
44

5+
type AttachmentPicker = (options: {
6+
defaultPath?: string
7+
multiple?: boolean
8+
accept?: string[]
9+
}, onFile: (file: File) => Promise<unknown>) => Promise<void>
10+
11+
export function pickAttachmentFiles(input: {
12+
picker?: AttachmentPicker
13+
directory: () => string
14+
fallback: () => void
15+
onFile: (file: File) => Promise<unknown>
16+
onError: (error: unknown) => void
17+
}) {
18+
if (!input.picker) {
19+
input.fallback()
20+
return
21+
}
22+
void input
23+
.picker(
24+
{
25+
defaultPath: input.directory(),
26+
multiple: true,
27+
accept: ACCEPTED_FILE_TYPES,
28+
},
29+
input.onFile,
30+
)
31+
.catch(input.onError)
32+
}
33+
534
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
635
const IMAGE_EXTS = new Map([
736
["gif", "image/gif"],

packages/app/src/context/platform.tsx

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import type { UpdaterPlatform } from "../updater"
88

99
type PickerPaths = string | string[] | null
1010
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
11-
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
11+
type OpenAttachmentPickerOptions = {
12+
title?: string
13+
multiple?: boolean
14+
accept?: string[]
15+
extensions?: string[]
16+
defaultPath?: string
17+
}
1218
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
1319
type PlatformName = "web" | "desktop"
1420
type DesktopOS = "macos" | "windows" | "linux"
@@ -21,13 +27,7 @@ export type FatalRendererErrorLog = {
2127
os?: DesktopOS
2228
}
2329

24-
export type Platform = {
25-
/** Platform discriminator */
26-
platform: PlatformName
27-
28-
/** Desktop OS (Tauri only) */
29-
os?: DesktopOS
30-
30+
type PlatformBase = {
3131
/** App version */
3232
version?: string
3333

@@ -49,13 +49,10 @@ export type Platform = {
4949
/** Send a system notification (optional deep link) */
5050
notify(title: string, description?: string, href?: string): Promise<void>
5151

52-
/** Open directory picker dialog (native on Tauri, server-backed on web) */
53-
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
54-
55-
/** Open native file picker dialog (Tauri only) */
56-
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
52+
/** Open a native attachment picker and read selected files sequentially (desktop only) */
53+
openAttachmentPickerDialog?(opts: OpenAttachmentPickerOptions, onFile: (file: File) => Promise<unknown>): Promise<void>
5754

58-
/** Save file picker dialog (Tauri only) */
55+
/** Open a native save file picker dialog (desktop only) */
5956
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
6057

6158
/** Storage mechanism, defaults to localStorage */
@@ -110,6 +107,16 @@ export type Platform = {
110107
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
111108
}
112109

110+
export type Platform = PlatformBase &
111+
(
112+
| { platform: "web"; os?: never }
113+
| {
114+
platform: "desktop"
115+
os?: DesktopOS
116+
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
117+
}
118+
)
119+
113120
export type DisplayBackend = "auto" | "wayland"
114121

115122
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

0 commit comments

Comments
 (0)