diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 55cfaa490..f3d3e135d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1383,11 +1383,16 @@ export const PromptInput: Component = (props) => { { - const file = e.currentTarget.files?.[0] - if (file) void addAttachment(file) + const list = e.currentTarget.files + if (list) { + for (const file of Array.from(list)) { + void addAttachment(file) + } + } e.currentTarget.value = "" }} /> diff --git a/packages/app/src/components/prompt-input/files.ts b/packages/app/src/components/prompt-input/files.ts index 594991d07..eae8af03d 100644 --- a/packages/app/src/components/prompt-input/files.ts +++ b/packages/app/src/components/prompt-input/files.ts @@ -1,4 +1,6 @@ -export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] +import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker" + +export { ACCEPTED_FILE_TYPES } const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES) const IMAGE_EXTS = new Map([ @@ -18,61 +20,6 @@ const TEXT_MIMES = new Set([ "application/yaml", ]) -export const ACCEPTED_FILE_TYPES = [ - ...ACCEPTED_IMAGE_TYPES, - "application/pdf", - "text/*", - "application/json", - "application/ld+json", - "application/toml", - "application/x-toml", - "application/x-yaml", - "application/xml", - "application/yaml", - ".c", - ".cc", - ".cjs", - ".conf", - ".cpp", - ".css", - ".csv", - ".cts", - ".env", - ".go", - ".gql", - ".graphql", - ".h", - ".hh", - ".hpp", - ".htm", - ".html", - ".ini", - ".java", - ".js", - ".json", - ".jsx", - ".log", - ".md", - ".mdx", - ".mjs", - ".mts", - ".py", - ".rb", - ".rs", - ".sass", - ".scss", - ".sh", - ".sql", - ".toml", - ".ts", - ".tsx", - ".txt", - ".xml", - ".yaml", - ".yml", - ".zsh", -] - const SAMPLE = 4096 function kind(type: string) { diff --git a/packages/app/src/constants/file-picker.ts b/packages/app/src/constants/file-picker.ts new file mode 100644 index 000000000..c661bc8f3 --- /dev/null +++ b/packages/app/src/constants/file-picker.ts @@ -0,0 +1,89 @@ +export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] + +export const ACCEPTED_FILE_TYPES = [ + ...ACCEPTED_IMAGE_TYPES, + "application/pdf", + "text/*", + "application/json", + "application/ld+json", + "application/toml", + "application/x-toml", + "application/x-yaml", + "application/xml", + "application/yaml", + ".c", + ".cc", + ".cjs", + ".conf", + ".cpp", + ".css", + ".csv", + ".cts", + ".env", + ".go", + ".gql", + ".graphql", + ".h", + ".hh", + ".hpp", + ".htm", + ".html", + ".ini", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".mdx", + ".mjs", + ".mts", + ".py", + ".rb", + ".rs", + ".sass", + ".scss", + ".sh", + ".sql", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", + ".zsh", +] + +const MIME_EXT = new Map([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/gif", "gif"], + ["image/webp", "webp"], + ["application/pdf", "pdf"], + ["application/json", "json"], + ["application/ld+json", "jsonld"], + ["application/toml", "toml"], + ["application/x-toml", "toml"], + ["application/x-yaml", "yaml"], + ["application/xml", "xml"], + ["application/yaml", "yaml"], +]) + +const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"] + +export const ACCEPTED_FILE_EXTENSIONS = Array.from( + new Set( + ACCEPTED_FILE_TYPES.flatMap((item) => { + if (item.startsWith(".")) return [item.slice(1)] + if (item === "text/*") return TEXT_EXT + const out = MIME_EXT.get(item) + return out ? [out] : [] + }), + ), +).sort() + +export function filePickerFilters(ext?: string[]) { + if (!ext || ext.length === 0) return undefined + return [{ name: "Files", extensions: ext }] +} diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index b8ed58e34..3bdc46391 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -5,7 +5,7 @@ import { ServerConnection } from "./server" type PickerPaths = string | string[] | null type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } -type OpenFilePickerOptions = { title?: string; multiple?: boolean } +type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] } type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 6c870dfa4..53063f48f 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,4 +1,5 @@ export { AppBaseProviders, AppInterface } from "./app" +export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 71b3c3395..543f857a5 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -6,6 +6,11 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, import { getStore } from "./store" import { setTitlebar } from "./windows" +const pickerFilters = (ext?: string[]) => { + if (!ext || ext.length === 0) return undefined + return [{ name: "Files", extensions: ext }] +} + type Deps = { killSidecar: () => void installCli: () => Promise @@ -94,11 +99,15 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle( "open-file-picker", - async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { + async ( + _event: IpcMainInvokeEvent, + opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] }, + ) => { const result = await dialog.showOpenDialog({ properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], title: opts?.title ?? "Choose a file", defaultPath: opts?.defaultPath, + filters: pickerFilters(opts?.extensions), }) if (result.canceled) return null return opts?.multiple ? result.filePaths : result.filePaths[0] diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 100508fcd..f8e6d52c7 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -50,6 +50,8 @@ export type ElectronAPI = { multiple?: boolean title?: string defaultPath?: string + accept?: string[] + extensions?: string[] }) => Promise saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise openLink: (url: string) => void diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 30e882e23..ec2b4d1e7 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -1,6 +1,8 @@ // @refresh reload import { + ACCEPTED_FILE_EXTENSIONS, + ACCEPTED_FILE_TYPES, AppBaseProviders, AppInterface, handleNotificationClick, @@ -111,6 +113,8 @@ const createPlatform = (): Platform => { const result = await window.api.openFilePicker({ multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFile"), + accept: opts?.accept ?? ACCEPTED_FILE_TYPES, + extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS, }) return handleWslPicker(result) }, diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 65149f34b..e67795644 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,6 +1,8 @@ // @refresh reload import { + ACCEPTED_FILE_EXTENSIONS, + filePickerFilters, AppBaseProviders, AppInterface, handleNotificationClick, @@ -98,6 +100,7 @@ const createPlatform = (): Platform => { directory: false, multiple: opts?.multiple ?? false, title: opts?.title ?? t("desktop.dialog.chooseFile"), + filters: filePickerFilters(opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS), }) return handleWslPicker(result) },