diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index d65369459..076ec8ca3 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -10,6 +10,7 @@ interface DialogOpenRequest { title?: string defaultPath?: string filters?: Array<{ name?: string; extensions: string[] }> + multiple?: boolean } interface DialogOpenResult { @@ -47,6 +48,9 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { const properties: OpenDialogOptions["properties"] = request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] + if (request.mode === "file" && request.multiple) { + properties.push("multiSelections") + } const filters = request.filters?.map((filter) => ({ name: filter.name ?? "Files", diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index dd7ba57a1..d591dba44 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -214,6 +214,12 @@ export interface FileSystemCreateFolderResponse { absolutePath: string } +export interface FileSystemFileContentResponse { + path: string + contents: string + encoding: "utf-8" | "base64" +} + export const WINDOWS_DRIVES_ROOT = "__drives__" export interface WorkspaceFileResponse { @@ -221,6 +227,7 @@ export interface WorkspaceFileResponse { relativePath: string /** UTF-8 file contents; binary files should be base64 encoded by the caller. */ contents: string + encoding?: "utf-8" | "base64" } export type WorkspaceFileSearchResponse = FileSystemEntry[] diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts index cd6c2a9e8..933637e93 100644 --- a/packages/server/src/filesystem/browser.ts +++ b/packages/server/src/filesystem/browser.ts @@ -4,6 +4,7 @@ import path from "path" import { FileSystemCreateFolderResponse, FileSystemEntry, + FileSystemFileContentResponse, FileSystemListResponse, FileSystemListingMetadata, WINDOWS_DRIVES_ROOT, @@ -22,6 +23,7 @@ interface DirectoryReadOptions { } const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)) +const MAX_READABLE_FILE_BYTES = 5 * 1024 * 1024 export class FileSystemBrowser { private readonly root: string @@ -98,6 +100,28 @@ export class FileSystemBrowser { return fs.readFileSync(resolved, "utf-8") } + readFileBase64(relativePath: string): string { + if (this.unrestricted) { + throw new Error("readFileBase64 is not available in unrestricted mode") + } + const resolved = this.toRestrictedAbsolute(relativePath) + return fs.readFileSync(resolved).toString("base64") + } + + readFileContent(targetPath: string, options?: { encoding?: "utf-8" | "base64" }): FileSystemFileContentResponse { + const encoding = options?.encoding ?? "utf-8" + const resolved = this.unrestricted ? this.resolveUnrestrictedPath(targetPath) : this.toRestrictedAbsolute(targetPath) + const stats = fs.statSync(resolved) + if (!stats.isFile()) { + throw new Error("Selected path is not a file") + } + if (stats.size > MAX_READABLE_FILE_BYTES) { + throw new Error("Selected file is too large to attach") + } + const contents = encoding === "base64" ? fs.readFileSync(resolved).toString("base64") : fs.readFileSync(resolved, "utf-8") + return { path: targetPath, contents, encoding } + } + private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse { const normalizedPath = this.normalizeRelativePath(relativePath) const absolutePath = this.toRestrictedAbsolute(normalizedPath) diff --git a/packages/server/src/server/routes/filesystem.ts b/packages/server/src/server/routes/filesystem.ts index 4f5895f49..418157208 100644 --- a/packages/server/src/server/routes/filesystem.ts +++ b/packages/server/src/server/routes/filesystem.ts @@ -16,6 +16,11 @@ const FilesystemCreateFolderSchema = z.object({ name: z.string(), }) +const FilesystemFileContentQuerySchema = z.object({ + path: z.string(), + encoding: z.enum(["utf-8", "base64"]).optional(), +}) + export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/filesystem", async (request, reply) => { const query = FilesystemQuerySchema.parse(request.query ?? {}) @@ -51,4 +56,14 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) reply.code(400).type("text/plain").send((error as Error).message) } }) + + app.get("/api/filesystem/files/content", async (request, reply) => { + const query = FilesystemFileContentQuerySchema.parse(request.query ?? {}) + + try { + return deps.fileSystemBrowser.readFileContent(query.path, { encoding: query.encoding }) + } catch (error) { + reply.code(400).type("text/plain").send((error as Error).message) + } + }) } diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index a115bf0d5..517367f56 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -28,6 +28,7 @@ const WorkspaceFilesQuerySchema = z.object({ const WorkspaceFileContentQuerySchema = z.object({ path: z.string(), + encoding: z.enum(["utf-8", "base64"]).optional(), }) const WorkspaceFileContentBodySchema = z.object({ @@ -135,7 +136,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { }>("/api/workspaces/:id/files/content", async (request, reply) => { try { const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {}) - return deps.workspaceManager.readFile(request.params.id, query.path) + return deps.workspaceManager.readFile(request.params.id, query.path, { encoding: query.encoding }) } catch (error) { return handleWorkspaceError(error, reply) } diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 063c2cbb9..3ce25069b 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -72,14 +72,16 @@ export class WorkspaceManager { return searchWorkspaceFiles(workspace.path, query, options) } - readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse { + readFile(workspaceId: string, relativePath: string, options?: { encoding?: "utf-8" | "base64" }): WorkspaceFileResponse { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) - const contents = browser.readFile(relativePath) + const encoding = options?.encoding ?? "utf-8" + const contents = encoding === "base64" ? browser.readFileBase64(relativePath) : browser.readFile(relativePath) return { workspaceId, relativePath, contents, + encoding, } } diff --git a/packages/ui/src/components/directory-browser-dialog.tsx b/packages/ui/src/components/directory-browser-dialog.tsx index f45370ed0..cf9f9d614 100644 --- a/packages/ui/src/components/directory-browser-dialog.tsx +++ b/packages/ui/src/components/directory-browser-dialog.tsx @@ -1,5 +1,5 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" -import { ArrowRightSquare, ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid" +import { ArrowRightSquare, ArrowUpLeft, File as FileIcon, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid" import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types" import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" @@ -36,10 +36,11 @@ function isAbsolutePathLike(input: string) { interface DirectoryBrowserDialogProps { open: boolean + mode?: "directories" | "files" title: string description?: string initialPath?: string - onSelect: (absolutePath: string) => void + onSelect: (absolutePath: string, entry?: FileSystemEntry) => void onClose: () => void } @@ -71,7 +72,7 @@ function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) type FolderRow = | { type: "up"; path: string } - | { type: "folder"; entry: FileSystemEntry } + | { type: "entry"; entry: FileSystemEntry } const DirectoryBrowserDialog: Component = (props) => { const { t } = useI18n() @@ -171,15 +172,20 @@ const DirectoryBrowserDialog: Component = (props) = }) } - const response = await serverApi.listFileSystem(targetPath, { includeFiles: false }) + const response = await serverApi.listFileSystem(targetPath, { includeFiles: props.mode === "files" }) const canonicalKey = normalizePathKey(response.metadata.currentPath) - const directories = response.entries - .filter((entry) => entry.type === "directory") - .sort((a, b) => a.name.localeCompare(b.name)) + const entries = response.entries + .filter((entry) => props.mode === "files" || entry.type === "directory") + .sort((a, b) => { + const aDirectory = a.type === "directory" ? 0 : 1 + const bDirectory = b.type === "directory" ? 0 : 1 + if (aDirectory !== bDirectory) return aDirectory - bDirectory + return a.name.localeCompare(b.name) + }) setDirectoryChildren((prev) => { const next = new Map(prev) - next.set(canonicalKey, directories) + next.set(canonicalKey, entries) return next }) @@ -251,7 +257,7 @@ const DirectoryBrowserDialog: Component = (props) = } const children = directoryChildren().get(key) ?? [] for (const entry of children) { - rows.push({ type: "folder", entry }) + rows.push({ type: "entry", entry }) } return rows }) @@ -296,7 +302,7 @@ const DirectoryBrowserDialog: Component = (props) = setPathInput(getAbsolutePathFromMetadata(metadata)) } - async function handleSelectCurrent() { + async function handleOpenCurrent() { const target = pathInput().trim() const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata() if (!metadata) { @@ -304,7 +310,7 @@ const DirectoryBrowserDialog: Component = (props) = } setPathInputDirty(false) const absolute = getAbsolutePathFromMetadata(metadata) - if (absolute) { + if (absolute && props.mode !== "files") { setPathInput(absolute) props.onSelect(absolute) } @@ -316,7 +322,7 @@ const DirectoryBrowserDialog: Component = (props) = : isAbsolutePathLike(entry.path) ? entry.path : resolveAbsolutePath(rootPath(), entry.path) - props.onSelect(absolutePath) + props.onSelect(absolutePath, entry) } async function handleCreateFolder() { @@ -417,22 +423,24 @@ const DirectoryBrowserDialog: Component = (props) = aria-label={t("directoryBrowser.currentFolder.inputAriaLabel")} class="selector-input directory-browser-current-path" /> - + + + - {isFolder ? ( + {isSelectable ? ( - - -
- -
-
- -
- { - searchInputRef = el - }} - type="text" - value={searchQuery()} - onInput={(event) => setSearchQuery(event.currentTarget.value)} - placeholder={ - props.mode === "directories" - ? t("filesystemBrowser.search.placeholder.directories") - : t("filesystemBrowser.search.placeholder.files") - } - class="selector-input" - /> -
-
- - -
-
-
-

{t("filesystemBrowser.currentFolder.label")}

-

{currentAbsolutePath()}

-
- -
-
-
- -
- 0} - fallback={ -
- {error()}} - > -
- - {t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })} -
-
-
- } - > - -
- - {t("filesystemBrowser.loading.loadingWithPath", { path: describeLoadingPath() })} -
-
- 0} - fallback={ -
-

{t("filesystemBrowser.empty.noEntries")}

- -
- } - > - - {(row) => { - if (row.type === "up") { - return ( -
-
- -
-
- ) - } - - const entry = row.entry - const selectEntry = () => handleEntrySelect(entry) - const activateEntry = () => { - if (entry.type === "directory") { - handleNavigateTo(entry.path) - } else { - selectEntry() - } - } - - return ( -
-
- - -
-
- ) - }} -
-
-
-
- - - - - - - ) -} - -export default FileSystemBrowserDialog diff --git a/packages/ui/src/components/opencode-binary-selector.tsx b/packages/ui/src/components/opencode-binary-selector.tsx index ad9068355..c799f7036 100644 --- a/packages/ui/src/components/opencode-binary-selector.tsx +++ b/packages/ui/src/components/opencode-binary-selector.tsx @@ -2,7 +2,7 @@ import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid" import { useConfig } from "../stores/preferences" import { serverApi } from "../lib/api-client" -import FileSystemBrowserDialog from "./filesystem-browser-dialog" +import DirectoryBrowserDialog from "./directory-browser-dialog" import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" @@ -341,7 +341,7 @@ const OpenCodeBinarySelector: Component = (props) = - ("normal") const [expandState, setExpandState] = createSignal("normal") + const [isFileBrowserOpen, setIsFileBrowserOpen] = createSignal(false) const SELECTION_INSERT_MAX_LENGTH = 2000 + const MAX_READABLE_PICKED_FILE_BYTES = 5 * 1024 * 1024 let textareaRef: HTMLTextAreaElement | undefined + let fileInputRef: HTMLInputElement | undefined const getPlaceholder = () => { if (mode() === "shell") { @@ -98,6 +105,8 @@ export default function PromptInput(props: PromptInputProps) { handleDragOver, handleDragLeave, handleDrop, + handleFileSelection, + handleFilePathAttachment, syncAttachmentCounters, handleExpandTextAttachment, handleRemoveAttachment, @@ -108,6 +117,7 @@ export default function PromptInput(props: PromptInputProps) { prompt, setPrompt, getTextarea: () => textareaRef ?? null, + disabled: () => Boolean(props.disabled), }) createEffect(() => { @@ -390,6 +400,52 @@ export default function PromptInput(props: PromptInputProps) { textareaRef?.focus() } + async function handleAttachFiles() { + if (props.disabled) return + if (isDesktopHost() && isLocalWindow()) { + fileInputRef?.click() + return + } + setIsFileBrowserOpen(true) + } + + async function handleFileBrowserSelect(path: string, entry?: FileSystemEntry) { + if (props.disabled) return + if (typeof entry?.size === "number" && entry.size > MAX_READABLE_PICKED_FILE_BYTES) { + showAlertDialog(t("promptInput.attachFiles.tooLarge.one"), { + title: t("promptInput.attachFiles.skipped.title"), + variant: "warning", + }) + textareaRef?.focus() + return + } + try { + const filePath = entry?.path ?? path + const displayPath = entry?.absolutePath ?? path + const response = await serverApi.readFileSystemFile(filePath, { encoding: "base64" }) + handleFilePathAttachment(displayPath, response.contents, { encoding: response.encoding }) + setIsFileBrowserOpen(false) + } catch (error) { + log.error("Failed to attach selected file:", error) + showAlertDialog(error instanceof Error ? error.message : String(error), { + title: t("promptInput.attachFiles.errorTitle"), + variant: "error", + }) + } finally { + textareaRef?.focus() + } + } + + function handleFileInputChange(event: Event) { + const input = event.currentTarget as HTMLInputElement + if (props.disabled) { + input.value = "" + return + } + handleFileSelection(input.files) + input.value = "" + } + function insertBlockContent(block: string) { const textarea = textareaRef const current = prompt() @@ -707,6 +763,25 @@ export default function PromptInput(props: PromptInputProps) {