Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/electron-app/electron/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface DialogOpenRequest {
title?: string
defaultPath?: string
filters?: Array<{ name?: string; extensions: string[] }>
multiple?: boolean
}

interface DialogOpenResult {
Expand Down Expand Up @@ -47,6 +48,9 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
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",
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,13 +214,20 @@ 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 {
workspaceId: string
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[]
Expand Down
24 changes: 24 additions & 0 deletions packages/server/src/filesystem/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "path"
import {
FileSystemCreateFolderResponse,
FileSystemEntry,
FileSystemFileContentResponse,
FileSystemListResponse,
FileSystemListingMetadata,
WINDOWS_DRIVES_ROOT,
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions packages/server/src/server/routes/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {})
Expand Down Expand Up @@ -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)
}
})
}
3 changes: 2 additions & 1 deletion packages/server/src/server/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions packages/server/src/workspaces/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
84 changes: 53 additions & 31 deletions packages/ui/src/components/directory-browser-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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<DirectoryBrowserDialogProps> = (props) => {
const { t } = useI18n()
Expand Down Expand Up @@ -171,15 +172,20 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (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
})

Expand Down Expand Up @@ -251,7 +257,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
}
const children = directoryChildren().get(key) ?? []
for (const entry of children) {
rows.push({ type: "folder", entry })
rows.push({ type: "entry", entry })
}
return rows
})
Expand Down Expand Up @@ -296,15 +302,15 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (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) {
return
}
setPathInputDirty(false)
const absolute = getAbsolutePathFromMetadata(metadata)
if (absolute) {
if (absolute && props.mode !== "files") {
setPathInput(absolute)
props.onSelect(absolute)
}
Expand All @@ -316,7 +322,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
: isAbsolutePathLike(entry.path)
? entry.path
: resolveAbsolutePath(rootPath(), entry.path)
props.onSelect(absolutePath)
props.onSelect(absolutePath, entry)
}

async function handleCreateFolder() {
Expand Down Expand Up @@ -417,22 +423,24 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
aria-label={t("directoryBrowser.currentFolder.inputAriaLabel")}
class="selector-input directory-browser-current-path"
/>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-new-folder"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => void handleCreateFolder()}
>
<span class="inline-flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
</span>
</button>
<Show when={props.mode !== "files"}>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-new-folder"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => void handleCreateFolder()}
>
<span class="inline-flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
{creatingFolder() ? t("directoryBrowser.creating") : t("directoryBrowser.newFolder")}
</span>
</button>
</Show>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-open-path"
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
onClick={() => void handleSelectCurrent()}
onClick={() => void handleOpenCurrent()}
title={t("directoryBrowser.openCurrent")}
aria-label={t("directoryBrowser.openCurrent")}
>
Expand Down Expand Up @@ -461,32 +469,46 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto directory-browser-list" role="listbox">
<For each={folderRows()}>
{(item) => {
const isFolder = item.type === "folder"
const label = isFolder ? item.entry.name || item.entry.path : t("directoryBrowser.upOneLevel")
const navigate = () => (isFolder ? handleNavigateTo(item.entry.path) : handleNavigateUp())
const isEntry = item.type === "entry"
const isFolder = isEntry && item.entry.type === "directory"
const isSelectable = isEntry && (props.mode === "files" ? item.entry.type === "file" : item.entry.type === "directory")
const label = isEntry ? item.entry.name || item.entry.path : t("directoryBrowser.upOneLevel")
const navigate = () => {
if (!isEntry) {
handleNavigateUp()
return
}
if (item.entry.type === "directory") {
handleNavigateTo(item.entry.path)
return
}
handleEntrySelect(item.entry)
}
return (
<div class="panel-list-item" role="option">
<div class="panel-list-item-content directory-browser-row">
<button type="button" class="directory-browser-row-main" onClick={navigate}>
<div class="directory-browser-row-icon">
<Show when={!isFolder} fallback={<FolderIcon class="w-4 h-4" />}>
<ArrowUpLeft class="w-4 h-4" />
<Show when={isEntry} fallback={<ArrowUpLeft class="w-4 h-4" />}>
<FileIcon class="w-4 h-4" />
</Show>
</Show>
</div>
<div class="directory-browser-row-text">
<span class="directory-browser-row-name">{label}</span>
</div>
<Show when={isFolder && isPathLoading(item.entry.path)}>
<Show when={isFolder && isEntry && isPathLoading(item.entry.path)}>
<Loader2 class="directory-browser-row-spinner animate-spin" />
</Show>
</button>
{isFolder ? (
{isSelectable ? (
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select"
onClick={(event) => {
event.stopPropagation()
handleEntrySelect(item.entry)
if (isEntry) handleEntrySelect(item.entry)
}}
>
{t("directoryBrowser.select")}
Expand Down
Loading
Loading