feat(filesystem): add create-folder API for workspace picker

Adds a secure endpoint for creating a single subfolder in the current filesystem listing, and wires the non-native directory browser UI to create + enter the new folder.
This commit is contained in:
Shantur Rathore
2026-01-23 12:33:15 +00:00
parent b0eb9aec64
commit f0b43dbc68
9 changed files with 265 additions and 38 deletions

View File

@@ -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 {

View File

@@ -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[] = []

View File

@@ -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)
}
})
}