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:
@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
|
|||||||
metadata: FileSystemListingMetadata
|
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 const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||||
|
|
||||||
export interface WorkspaceFileResponse {
|
export interface WorkspaceFileResponse {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "fs"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import {
|
import {
|
||||||
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
FileSystemListingMetadata,
|
FileSystemListingMetadata,
|
||||||
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
|
|||||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
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 {
|
readFile(relativePath: string): string {
|
||||||
if (this.unrestricted) {
|
if (this.unrestricted) {
|
||||||
throw new Error("readFile is not available in unrestricted mode")
|
throw new Error("readFile is not available in unrestricted mode")
|
||||||
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
|
|||||||
return { entries, metadata }
|
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[] {
|
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||||
const results: FileSystemEntry[] = []
|
const results: FileSystemEntry[] = []
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
|
|||||||
includeFiles: z.coerce.boolean().optional(),
|
includeFiles: z.coerce.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FilesystemCreateFolderSchema = z.object({
|
||||||
|
parentPath: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/filesystem", async (request, reply) => {
|
app.get("/api/filesystem", async (request, reply) => {
|
||||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||||
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
|
|||||||
return { error: (error as Error).message }
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
|
|||||||
|
|
||||||
const AlertDialog: Component = () => {
|
const AlertDialog: Component = () => {
|
||||||
let primaryButtonRef: HTMLButtonElement | undefined
|
let primaryButtonRef: HTMLButtonElement | undefined
|
||||||
|
let promptInputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (alertDialogState()) {
|
const state = alertDialogState()
|
||||||
queueMicrotask(() => {
|
if (!state) return
|
||||||
primaryButtonRef?.focus()
|
|
||||||
})
|
queueMicrotask(() => {
|
||||||
}
|
if (state.type === "prompt") {
|
||||||
|
promptInputRef?.focus()
|
||||||
|
promptInputRef?.select()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
primaryButtonRef?.focus()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,25 +125,29 @@ const AlertDialog: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isPrompt}>
|
<Show when={isPrompt}>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="text-xs font-medium text-muted uppercase tracking-wide">
|
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
|
||||||
{payload.inputLabel || "Arguments"}
|
<input
|
||||||
</label>
|
ref={(el) => {
|
||||||
<input
|
promptInputRef = el
|
||||||
class="modal-search-input mt-2"
|
}}
|
||||||
value={inputValue()}
|
class="form-input mt-2"
|
||||||
placeholder={payload.inputPlaceholder || ""}
|
value={inputValue()}
|
||||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
placeholder={payload.inputPlaceholder || ""}
|
||||||
onKeyDown={(e) => {
|
autocapitalize="off"
|
||||||
if (e.key === "Enter") {
|
autocorrect="off"
|
||||||
e.preventDefault()
|
spellcheck={false}
|
||||||
dismiss(true, payload, inputValue())
|
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||||
}
|
onKeyDown={(e) => {
|
||||||
}}
|
if (e.key === "Enter") {
|
||||||
/>
|
e.preventDefault()
|
||||||
</div>
|
dismiss(true, payload, inputValue())
|
||||||
</Show>
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
{(isConfirm || isPrompt) && (
|
{(isConfirm || isPrompt) && (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
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 type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||||
|
|
||||||
function normalizePathKey(input?: string | null) {
|
function normalizePathKey(input?: string | null) {
|
||||||
if (!input || input === "." || input === "./") {
|
if (!input || input === "." || input === "./") {
|
||||||
@@ -64,6 +65,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||||
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
||||||
@@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
props.onSelect(absolutePath)
|
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) {
|
function isPathLoading(path: string) {
|
||||||
return loadingPaths().has(normalizePathKey(path))
|
return loadingPaths().has(normalizePathKey(path))
|
||||||
}
|
}
|
||||||
@@ -290,19 +338,32 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
<span class="directory-browser-current-label">Current folder</span>
|
<span class="directory-browser-current-label">Current folder</span>
|
||||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="directory-browser-current-actions">
|
||||||
type="button"
|
<button
|
||||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
type="button"
|
||||||
disabled={!canSelectCurrent()}
|
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||||
onClick={() => {
|
disabled={!canSelectCurrent() || creatingFolder()}
|
||||||
const absolute = currentAbsolutePath()
|
onClick={() => {
|
||||||
if (absolute) {
|
const absolute = currentAbsolutePath()
|
||||||
props.onSelect(absolute)
|
if (absolute) {
|
||||||
}
|
props.onSelect(absolute)
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
Select Current
|
>
|
||||||
</button>
|
Select Current
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary directory-browser-select"
|
||||||
|
disabled={!canSelectCurrent() || creatingFolder()}
|
||||||
|
onClick={() => void handleCreateFolder()}
|
||||||
|
>
|
||||||
|
<span class="inline-flex items-center gap-2">
|
||||||
|
<FolderPlus class="w-4 h-4" />
|
||||||
|
{creatingFolder() ? "Creating…" : "New Folder"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
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 normalizedKey = e.key.toLowerCase()
|
||||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||||
const blockedKeys = [
|
const blockedKeys = [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
BinaryUpdateRequest,
|
BinaryUpdateRequest,
|
||||||
BinaryValidationResult,
|
BinaryValidationResult,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
@@ -224,6 +225,13 @@ export const serverApi = {
|
|||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
|
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createFileSystemFolder(parentPath: string | undefined, name: string): Promise<FileSystemCreateFolderResponse> {
|
||||||
|
return request<FileSystemCreateFolderResponse>("/api/filesystem/folders", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ parentPath, name }),
|
||||||
|
})
|
||||||
|
},
|
||||||
readInstanceData(id: string): Promise<InstanceData> {
|
readInstanceData(id: string): Promise<InstanceData> {
|
||||||
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,6 +81,14 @@
|
|||||||
width: auto;
|
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 {
|
.directory-browser-close {
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -103,6 +103,25 @@
|
|||||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
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 */
|
/* Shared animations */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user