diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts
index 38cc1992..08ed8ff7 100644
--- a/packages/server/src/api-types.ts
+++ b/packages/server/src/api-types.ts
@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
metadata: FileSystemListingMetadata
}
+export interface FileSystemCreateFolderRequest {
+ /**
+ * Path identifier for the currently browsed directory.
+ * Matches the `path` parameter used for `/api/filesystem`.
+ */
+ parentPath?: string
+ /** Single folder name (no separators). */
+ name: string
+}
+
+export interface FileSystemCreateFolderResponse {
+ /**
+ * Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
+ * Relative for restricted listings, absolute for unrestricted.
+ */
+ path: string
+ /** Absolute folder path on the server host. */
+ absolutePath: string
+}
+
export const WINDOWS_DRIVES_ROOT = "__drives__"
export interface WorkspaceFileResponse {
diff --git a/packages/server/src/filesystem/browser.ts b/packages/server/src/filesystem/browser.ts
index 29ddb1c1..e5820f3d 100644
--- a/packages/server/src/filesystem/browser.ts
+++ b/packages/server/src/filesystem/browser.ts
@@ -2,6 +2,7 @@ import fs from "fs"
import os from "os"
import path from "path"
import {
+ FileSystemCreateFolderResponse,
FileSystemEntry,
FileSystemListResponse,
FileSystemListingMetadata,
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
return this.listRestrictedWithMetadata(targetPath, includeFiles)
}
+ createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
+ const name = this.normalizeFolderName(folderName)
+
+ if (this.unrestricted) {
+ const resolvedParent = this.resolveUnrestrictedPath(parentPath)
+ if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
+ throw new Error("Cannot create folders at drive root")
+ }
+ this.assertDirectoryExists(resolvedParent)
+ const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
+ fs.mkdirSync(absolutePath)
+ return { path: absolutePath, absolutePath }
+ }
+
+ const normalizedParent = this.normalizeRelativePath(parentPath)
+ const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
+ this.assertDirectoryExists(parentAbsolute)
+
+ const relativePath = this.buildRelativePath(normalizedParent, name)
+ const absolutePath = this.toRestrictedAbsolute(relativePath)
+ fs.mkdirSync(absolutePath)
+ return { path: relativePath, absolutePath }
+ }
+
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
return { entries, metadata }
}
+ private normalizeFolderName(input: string): string {
+ const name = input.trim()
+ if (!name) {
+ throw new Error("Folder name is required")
+ }
+
+ if (name === "." || name === "..") {
+ throw new Error("Invalid folder name")
+ }
+
+ if (name.startsWith("~")) {
+ throw new Error("Invalid folder name")
+ }
+
+ if (name.includes("/") || name.includes("\\")) {
+ throw new Error("Folder name must not include path separators")
+ }
+
+ if (name.includes("\u0000")) {
+ throw new Error("Invalid folder name")
+ }
+
+ return name
+ }
+
+ private assertDirectoryExists(directory: string) {
+ if (!fs.existsSync(directory)) {
+ throw new Error(`Directory does not exist: ${directory}`)
+ }
+ const stats = fs.statSync(directory)
+ if (!stats.isDirectory()) {
+ throw new Error(`Path is not a directory: ${directory}`)
+ }
+ }
+
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
const dirents = fs.readdirSync(directory, { withFileTypes: true })
const results: FileSystemEntry[] = []
diff --git a/packages/server/src/server/routes/filesystem.ts b/packages/server/src/server/routes/filesystem.ts
index d919c29e..4f5895f4 100644
--- a/packages/server/src/server/routes/filesystem.ts
+++ b/packages/server/src/server/routes/filesystem.ts
@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
includeFiles: z.coerce.boolean().optional(),
})
+const FilesystemCreateFolderSchema = z.object({
+ parentPath: z.string().optional(),
+ name: z.string(),
+})
+
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {})
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
return { error: (error as Error).message }
}
})
+
+ app.post("/api/filesystem/folders", async (request, reply) => {
+ const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
+
+ try {
+ const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
+ reply.code(201)
+ return created
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException
+ if (err?.code === "EEXIST") {
+ reply.code(409).type("text/plain").send("Folder already exists")
+ return
+ }
+ if (err?.code === "EACCES" || err?.code === "EPERM") {
+ reply.code(403).type("text/plain").send("Permission denied")
+ return
+ }
+
+ reply.code(400).type("text/plain").send((error as Error).message)
+ }
+ })
}
diff --git a/packages/ui/src/components/alert-dialog.tsx b/packages/ui/src/components/alert-dialog.tsx
index fce38bad..413e6245 100644
--- a/packages/ui/src/components/alert-dialog.tsx
+++ b/packages/ui/src/components/alert-dialog.tsx
@@ -61,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
const AlertDialog: Component = () => {
let primaryButtonRef: HTMLButtonElement | undefined
+ let promptInputRef: HTMLInputElement | undefined
createEffect(() => {
- if (alertDialogState()) {
- queueMicrotask(() => {
- primaryButtonRef?.focus()
- })
- }
+ const state = alertDialogState()
+ if (!state) return
+
+ queueMicrotask(() => {
+ if (state.type === "prompt") {
+ promptInputRef?.focus()
+ promptInputRef?.select()
+ return
+ }
+ primaryButtonRef?.focus()
+ })
})
return (
@@ -118,25 +125,29 @@ const AlertDialog: Component = () => {
-