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

@@ -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 = () => {
</div>
</div>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-xs font-medium text-muted uppercase tracking-wide">
{payload.inputLabel || "Arguments"}
</label>
<input
class="modal-search-input mt-2"
value={inputValue()}
placeholder={payload.inputPlaceholder || ""}
onInput={(e) => setInputValue(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
dismiss(true, payload, inputValue())
}
}}
/>
</div>
</Show>
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
<input
ref={(el) => {
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())
}
}}
/>
</div>
</Show>
<div class="mt-6 flex justify-end gap-3">
{(isConfirm || isPrompt) && (

View File

@@ -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<DirectoryBrowserDialogProps> = (props) =
const [rootPath, setRootPath] = createSignal("")
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [creatingFolder, setCreatingFolder] = createSignal(false)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
@@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (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<DirectoryBrowserDialogProps> = (props) =
<span class="directory-browser-current-label">Current folder</span>
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
</div>
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
Select Current
</button>
<div class="directory-browser-current-actions">
<button
type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
disabled={!canSelectCurrent() || creatingFolder()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
>
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>
</Show>
<Show

View File

@@ -57,6 +57,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (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 = [

View File

@@ -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<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> {
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
},

View File

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

View File

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