Browse Source

app: file type filter on desktop + multiple files (#18403)

Brendan Allan 3 weeks ago
parent
commit
d0a57305ef

+ 7 - 2
packages/app/src/components/prompt-input.tsx

@@ -1383,11 +1383,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             <input
               ref={fileInputRef}
               type="file"
+              multiple
               accept={ACCEPTED_FILE_TYPES.join(",")}
               class="hidden"
               onChange={(e) => {
-                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 = ""
               }}
             />

+ 3 - 56
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) {

+ 89 - 0
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 }]
+}

+ 1 - 1
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 }
 

+ 1 - 0
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"

+ 10 - 1
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<string>
@@ -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]

+ 2 - 0
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<string | string[] | null>
   saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
   openLink: (url: string) => void

+ 4 - 0
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)
     },

+ 3 - 0
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)
     },