From f0b43dbc68ec065039adf951fd1668909cac7d3d Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Fri, 23 Jan 2026 12:33:15 +0000 Subject: [PATCH] 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. --- packages/server/src/api-types.ts | 20 +++++ packages/server/src/filesystem/browser.ts | 60 +++++++++++++ .../server/src/server/routes/filesystem.ts | 27 ++++++ packages/ui/src/components/alert-dialog.tsx | 59 +++++++----- .../components/directory-browser-dialog.tsx | 89 ++++++++++++++++--- .../src/components/folder-selection-view.tsx | 13 +++ packages/ui/src/lib/api-client.ts | 8 ++ .../styles/components/directory-browser.css | 8 ++ packages/ui/src/styles/utilities.css | 19 ++++ 9 files changed, 265 insertions(+), 38 deletions(-) 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 = () => { - -
- - setInputValue(e.currentTarget.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault() - dismiss(true, payload, inputValue()) - } - }} - /> -
-
+ +
+ + { + promptInputRef = el + }} + class="form-input mt-2" + value={inputValue()} + placeholder={payload.inputPlaceholder || ""} + autocapitalize="off" + autocorrect="off" + spellcheck={false} + onInput={(e) => setInputValue(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + dismiss(true, payload, inputValue()) + } + }} + /> +
+
{(isConfirm || isPrompt) && ( diff --git a/packages/ui/src/components/directory-browser-dialog.tsx b/packages/ui/src/components/directory-browser-dialog.tsx index c3622a6e..bab1ab8d 100644 --- a/packages/ui/src/components/directory-browser-dialog.tsx +++ b/packages/ui/src/components/directory-browser-dialog.tsx @@ -1,8 +1,9 @@ import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" -import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid" +import { ArrowUpLeft, 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" +import { showAlertDialog, showPromptDialog } from "../stores/alerts" function normalizePathKey(input?: string | null) { if (!input || input === "." || input === "./") { @@ -64,6 +65,7 @@ const DirectoryBrowserDialog: Component = (props) = const [rootPath, setRootPath] = createSignal("") const [loading, setLoading] = createSignal(false) const [error, setError] = createSignal(null) + const [creatingFolder, setCreatingFolder] = createSignal(false) const [directoryChildren, setDirectoryChildren] = createSignal>(new Map()) const [loadingPaths, setLoadingPaths] = createSignal>(new Set()) const [currentPathKey, setCurrentPathKey] = createSignal(null) @@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component = (props) = props.onSelect(absolutePath) } + async function handleCreateFolder() { + if (creatingFolder()) return + const metadata = currentMetadata() + if (!metadata || metadata.pathKind === "drives") { + return + } + + const name = + (await showPromptDialog("Create a new folder in the current directory.", { + title: "New Folder", + inputLabel: "Folder name", + inputPlaceholder: "e.g. my-new-project", + confirmLabel: "Create", + cancelLabel: "Cancel", + }))?.trim() ?? "" + if (!name) return + + if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) { + showAlertDialog("Please enter a single folder name.", { + variant: "warning", + detail: "Folder names cannot include slashes, '..', or '~'.", + }) + return + } + + setCreatingFolder(true) + try { + const parentKey = normalizePathKey(metadata.currentPath) + metadataCache.delete(parentKey) + inFlightRequests.delete(parentKey) + setDirectoryChildren((prev) => { + const next = new Map(prev) + next.delete(parentKey) + return next + }) + + const created = await serverApi.createFileSystemFolder(metadata.currentPath, name) + await navigateTo(created.path) + } catch (err) { + const message = err instanceof Error ? err.message : "Unable to create folder" + showAlertDialog(message, { variant: "error", title: "Unable to create folder" }) + } finally { + setCreatingFolder(false) + } + } + function isPathLoading(path: string) { return loadingPaths().has(normalizePathKey(path)) } @@ -290,19 +338,32 @@ const DirectoryBrowserDialog: Component = (props) = Current folder {currentAbsolutePath()}
- +
+ + +
= (props) => { function handleKeyDown(e: KeyboardEvent) { + let activeElement: HTMLElement | null = null + if (typeof document !== "undefined") { + activeElement = document.activeElement as HTMLElement | null + } + const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']") + const isEditingField = + activeElement && + (["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal)) + + if (isEditingField) { + return + } + const normalizedKey = e.key.toLowerCase() const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n" const blockedKeys = [ diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index e6793484..a0e02eaf 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -8,6 +8,7 @@ import type { BinaryUpdateRequest, BinaryValidationResult, FileSystemEntry, + FileSystemCreateFolderResponse, FileSystemListResponse, InstanceData, ServerMeta, @@ -224,6 +225,13 @@ export const serverApi = { const query = params.toString() return request(query ? `/api/filesystem?${query}` : "/api/filesystem") }, + + createFileSystemFolder(parentPath: string | undefined, name: string): Promise { + return request("/api/filesystem/folders", { + method: "POST", + body: JSON.stringify({ parentPath, name }), + }) + }, readInstanceData(id: string): Promise { return request(`/api/storage/instances/${encodeURIComponent(id)}`) }, diff --git a/packages/ui/src/styles/components/directory-browser.css b/packages/ui/src/styles/components/directory-browser.css index 635d4d7c..bdbccbc0 100644 --- a/packages/ui/src/styles/components/directory-browser.css +++ b/packages/ui/src/styles/components/directory-browser.css @@ -81,6 +81,14 @@ width: auto; } +.directory-browser-current-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; + justify-content: flex-end; +} + .directory-browser-close { display: inline-flex; diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 2d256fa3..b7609c9a 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -103,6 +103,25 @@ box-shadow: 0 0 0 2px var(--accent-primary); } +/* Form controls */ +.form-input { + @apply w-full px-3 py-2 text-sm; + background-color: var(--surface-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + color: var(--text-primary); +} + +.form-input::placeholder { + color: var(--text-muted); +} + +.form-input:focus { + outline: none; + border-color: transparent; + box-shadow: 0 0 0 2px var(--accent-primary); +} + /* Shared animations */ @keyframes pulse { 0%, 100% { opacity: 1; }