From bcccbf61546b81672ab3a5449b6d7bf23aaf846f Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 5 Jul 2026 22:55:51 +0100 Subject: [PATCH] feat(web): create folders from the add-project path picker Add a filesystem.createDirectory RPC and surface it in the browse picker so users can create a missing folder and keep browsing without immediately adding it as a project. The picker shows a Create folder action when the typed leaf name has no exact match, Shift+Enter creates and navigates into the folder, and Enter still adds or clones the project as before. --- .../src/workspace/WorkspaceEntries.test.ts | 50 ++++++ apps/server/src/workspace/WorkspaceEntries.ts | 164 +++++++++++++++++- apps/server/src/ws.ts | 43 +++++ .../components/CommandPalette.logic.test.ts | 57 ++++++ .../src/components/CommandPalette.logic.ts | 43 +++++ apps/web/src/components/CommandPalette.tsx | 110 +++++++++++- .../client-runtime/src/state/filesystem.ts | 6 +- packages/contracts/src/filesystem.test.ts | 21 ++- packages/contracts/src/filesystem.ts | 52 ++++++ packages/contracts/src/ipc.ts | 10 +- packages/contracts/src/rpc.ts | 11 ++ 11 files changed, 557 insertions(+), 10 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..e720f847655 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -391,4 +391,54 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); }); + + describe("createDirectory", () => { + it.effect("creates a missing directory under an existing parent", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-create-dir-" }); + const targetPath = path.join(cwd, "new-project"); + + const result = yield* workspaceEntries.createDirectory({ + partialPath: targetPath, + }); + + expect(result).toEqual({ directoryPath: targetPath }); + const stat = yield* fileSystem.stat(targetPath); + expect(stat.type).toBe("Directory"); + }), + ); + + it.effect("returns the existing directory path when it already exists", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const path = yield* Path.Path; + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-create-existing-" }); + yield* writeTextFile(cwd, "existing/index.ts", "export {};\n"); + const existingPath = path.join(cwd, "existing"); + + const result = yield* workspaceEntries.createDirectory({ + partialPath: existingPath, + }); + + expect(result).toEqual({ directoryPath: existingPath }); + }), + ); + + it.effect("rejects relative paths without cwd", () => + Effect.gen(function* () { + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + + const error = yield* workspaceEntries + .createDirectory({ + partialPath: "./new-dir", + }) + .pipe(Effect.flip); + + expect(error._tag).toBe("WorkspaceEntriesCurrentProjectRequiredError"); + }), + ); + }); }); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7501cbe0eab..5b0e6f00de6 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -12,6 +12,8 @@ import * as Schema from "effect/Schema"; import type { FilesystemBrowseInput, FilesystemBrowseResult, + FilesystemCreateDirectoryInput, + FilesystemCreateDirectoryResult, ProjectListEntriesInput, ProjectListEntriesResult, ProjectSearchEntriesInput, @@ -63,13 +65,86 @@ export class WorkspaceEntriesReadDirectoryError extends Schema.TaggedErrorClass< } } -export const WorkspaceEntriesBrowseError = Schema.Union([ +export class WorkspaceEntriesPathAlreadyExistsError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesPathAlreadyExistsError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + directoryPath: Schema.String, + }, +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Filesystem path '${this.directoryPath}' already exists while creating '${this.partialPath}'${cwd}.`; + } +} + +export class WorkspaceEntriesPathNotDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesPathNotDirectoryError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + directoryPath: Schema.String, + }, +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Filesystem path '${this.directoryPath}' is not a directory while creating '${this.partialPath}'${cwd}.`; + } +} + +export class WorkspaceEntriesParentNotFoundError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesParentNotFoundError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + parentPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Parent directory '${this.parentPath}' does not exist while creating '${this.partialPath}'${cwd}.`; + } +} + +export class WorkspaceEntriesCreateDirectoryError extends Schema.TaggedErrorClass()( + "WorkspaceEntriesCreateDirectoryError", + { + cwd: Schema.optional(Schema.String), + partialPath: Schema.String, + directoryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const cwd = this.cwd ? ` from '${this.cwd}'` : ""; + return `Failed to create directory '${this.directoryPath}' for '${this.partialPath}'${cwd}.`; + } +} + +export const WorkspaceEntriesResolveTargetError = Schema.Union([ WorkspaceEntriesWindowsPathUnsupportedError, WorkspaceEntriesCurrentProjectRequiredError, +]); +export type WorkspaceEntriesResolveTargetError = typeof WorkspaceEntriesResolveTargetError.Type; + +export const WorkspaceEntriesBrowseError = Schema.Union([ + WorkspaceEntriesResolveTargetError, WorkspaceEntriesReadDirectoryError, ]); export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; +export const WorkspaceEntriesCreateDirectoryErrorUnion = Schema.Union([ + WorkspaceEntriesResolveTargetError, + WorkspaceEntriesPathAlreadyExistsError, + WorkspaceEntriesPathNotDirectoryError, + WorkspaceEntriesParentNotFoundError, + WorkspaceEntriesCreateDirectoryError, +]); +export type WorkspaceEntriesCreateDirectoryErrorUnion = + typeof WorkspaceEntriesCreateDirectoryErrorUnion.Type; + export const WorkspaceEntriesError = Schema.Union([ WorkspacePaths.WorkspaceRootNotExistsError, WorkspacePaths.WorkspaceRootCreateFailedError, @@ -87,6 +162,9 @@ export class WorkspaceEntries extends Context.Service< readonly browse: ( input: FilesystemBrowseInput, ) => Effect.Effect; + readonly createDirectory: ( + input: FilesystemCreateDirectoryInput, + ) => Effect.Effect; readonly list: ( input: ProjectListEntriesInput, ) => Effect.Effect; @@ -110,7 +188,7 @@ function expandHomePath(input: string, path: Path.Path): string { const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(function* ( input: FilesystemBrowseInput, path: Path.Path, -): Effect.fn.Return { +): Effect.fn.Return { const platform = yield* HostProcessPlatform; if (platform !== "win32" && isWindowsAbsolutePath(input.partialPath)) { return yield* new WorkspaceEntriesWindowsPathUnsupportedError({ @@ -178,6 +256,86 @@ export const make = Effect.gen(function* () { }, ); + const createDirectory: WorkspaceEntries["Service"]["createDirectory"] = Effect.fn( + "WorkspaceEntries.createDirectory", + )(function* (input) { + const resolvedDirectoryPath = yield* resolveBrowseTarget(input, path); + const existingStat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(resolvedDirectoryPath), + catch: (cause) => cause, + }).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (stat) => stat, + }), + ); + + if (existingStat) { + if (existingStat.isDirectory()) { + return { directoryPath: resolvedDirectoryPath } as const; + } + return yield* new WorkspaceEntriesPathNotDirectoryError({ + cwd: input.cwd, + partialPath: input.partialPath, + directoryPath: resolvedDirectoryPath, + }); + } + + const parentPath = path.dirname(resolvedDirectoryPath); + const parentStat = yield* Effect.tryPromise({ + try: () => NodeFSP.stat(parentPath), + catch: (cause) => cause, + }).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.fail( + new WorkspaceEntriesParentNotFoundError({ + cwd: input.cwd, + partialPath: input.partialPath, + parentPath, + cause, + }), + ), + onSuccess: Effect.succeed, + }), + ); + if (!parentStat.isDirectory()) { + return yield* new WorkspaceEntriesPathNotDirectoryError({ + cwd: input.cwd, + partialPath: input.partialPath, + directoryPath: parentPath, + }); + } + + yield* Effect.tryPromise({ + try: () => NodeFSP.mkdir(resolvedDirectoryPath), + catch: (cause) => cause, + }).pipe( + Effect.catchIf( + (cause) => (cause as NodeJS.ErrnoException | undefined)?.code === "EEXIST", + () => + Effect.fail( + new WorkspaceEntriesPathAlreadyExistsError({ + cwd: input.cwd, + partialPath: input.partialPath, + directoryPath: resolvedDirectoryPath, + }), + ), + ), + Effect.mapError( + (cause) => + new WorkspaceEntriesCreateDirectoryError({ + cwd: input.cwd, + partialPath: input.partialPath, + directoryPath: resolvedDirectoryPath, + cause, + }), + ), + ); + + return { directoryPath: resolvedDirectoryPath } as const; + }); + const browse: WorkspaceEntries["Service"]["browse"] = Effect.fn("WorkspaceEntries.browse")( function* (input) { const resolvedInputPath = yield* resolveBrowseTarget(input, path); @@ -251,7 +409,7 @@ export const make = Effect.gen(function* () { }, ); - return WorkspaceEntries.of({ browse, list, refresh, search }); + return WorkspaceEntries.of({ browse, createDirectory, list, refresh, search }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..a647755a853 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -46,6 +46,8 @@ import { OrchestrationReplayEventsError, type FilesystemBrowseFailure, FilesystemBrowseError, + type FilesystemCreateDirectoryFailure, + FilesystemCreateDirectoryError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, @@ -201,6 +203,31 @@ function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntries } } +function filesystemCreateDirectoryFailureContext( + error: WorkspaceEntries.WorkspaceEntriesCreateDirectoryErrorUnion, +): { + readonly failure: FilesystemCreateDirectoryFailure; + readonly directoryPath?: string; + readonly platform?: string; +} { + switch (error._tag) { + case "WorkspaceEntriesWindowsPathUnsupportedError": + return { failure: "windows_path_unsupported", platform: error.platform }; + case "WorkspaceEntriesCurrentProjectRequiredError": + return { failure: "current_project_required" }; + case "WorkspaceEntriesPathAlreadyExistsError": + return { failure: "path_already_exists", directoryPath: error.directoryPath }; + case "WorkspaceEntriesPathNotDirectoryError": + return { failure: "path_not_directory", directoryPath: error.directoryPath }; + case "WorkspaceEntriesParentNotFoundError": + return { failure: "parent_not_found", directoryPath: error.parentPath }; + case "WorkspaceEntriesCreateDirectoryError": + return { failure: "create_directory_failed", directoryPath: error.directoryPath }; + default: + return unexpectedCompatibilityError(error); + } +} + function projectFileFailureContext( error: | WorkspaceFileSystem.WorkspaceFileSystemError @@ -305,6 +332,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], + [WS_METHODS.filesystemCreateDirectory, AuthOrchestrationOperateScope], [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], @@ -1404,6 +1432,21 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.filesystemCreateDirectory]: (input) => + observeRpcEffect( + WS_METHODS.filesystemCreateDirectory, + workspaceEntries.createDirectory(input).pipe( + Effect.mapError( + (cause) => + new FilesystemCreateDirectoryError({ + ...input, + ...filesystemCreateDirectoryFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..10114ac3518 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { + buildBrowseGroups, buildThreadActionItems, filterCommandPaletteGroups, + getBrowseCreateFolderName, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -35,6 +37,61 @@ function makeThread(overrides: Partial = {}): Thread { }; } +describe("getBrowseCreateFolderName", () => { + it("returns a folder name when the typed leaf does not match an existing entry", () => { + expect( + getBrowseCreateFolderName({ + browseFilterQuery: "new-folder", + exactEntry: null, + browseParentPath: "/Users/luke/projects", + isBrowsePending: false, + relativePathNeedsActiveProject: false, + }), + ).toBe("new-folder"); + }); + + it("returns null when an exact directory match already exists", () => { + expect( + getBrowseCreateFolderName({ + browseFilterQuery: "projects", + exactEntry: { name: "projects", fullPath: "/Users/luke/projects" }, + browseParentPath: "/Users/luke", + isBrowsePending: false, + relativePathNeedsActiveProject: false, + }), + ).toBeNull(); + }); +}); + +describe("buildBrowseGroups", () => { + it("adds a create-folder action when a new folder name is available", async () => { + const createFolder = vi.fn(async () => undefined); + const groups = buildBrowseGroups({ + browseEntries: [], + browseQuery: "~/projects/new-folder", + canBrowseUp: false, + upIcon: null, + directoryIcon: null, + createFolderIcon: null, + createFolderName: "new-folder", + browseUp: () => undefined, + browseTo: () => undefined, + createFolder, + }); + + const createItem = groups[0]?.items.find( + (item) => item.value === "browse:create-folder:new-folder", + ); + expect(createItem?.kind).toBe("action"); + if (createItem?.kind !== "action") { + throw new Error("Expected create-folder browse action"); + } + expect(createItem.title).toBe('Create folder "new-folder"'); + await createItem.run(); + expect(createFolder).toHaveBeenCalledWith("new-folder"); + }); +}); + describe("buildThreadActionItems", () => { it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..4bd932b15a2 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -54,6 +54,32 @@ export interface CommandPaletteView { export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; +export function getBrowseCreateFolderName(input: { + browseFilterQuery: string; + exactEntry: FilesystemBrowseEntry | null; + browseParentPath: string | null | undefined; + isBrowsePending: boolean; + relativePathNeedsActiveProject: boolean; +}): string | null { + const folderName = input.browseFilterQuery.trim(); + if ( + folderName.length === 0 || + input.exactEntry !== null || + input.isBrowsePending || + input.relativePathNeedsActiveProject || + input.browseParentPath === null || + input.browseParentPath === undefined || + input.browseParentPath.length === 0 + ) { + return null; + } + return folderName; +} + +export function buildBrowseCreateFolderValue(folderName: string): string { + return `browse:create-folder:${folderName}`; +} + export function filterBrowseEntries(input: { browseEntries: ReadonlyArray; browseFilterQuery: string; @@ -285,8 +311,11 @@ export function buildBrowseGroups(input: { canBrowseUp: boolean; upIcon: ReactNode; directoryIcon: ReactNode; + createFolderIcon?: ReactNode; + createFolderName?: string | null; browseUp: () => void; browseTo: (name: string) => void; + createFolder?: (folderName: string) => Promise; }): CommandPaletteGroup[] { const items: CommandPaletteActionItem[] = []; @@ -318,6 +347,20 @@ export function buildBrowseGroups(input: { }); } + if (input.createFolderName && input.createFolder) { + items.push({ + kind: "action", + value: buildBrowseCreateFolderValue(input.createFolderName), + searchTerms: [input.browseQuery, input.createFolderName, "create folder"], + title: `Create folder "${input.createFolderName}"`, + icon: input.createFolderIcon ?? input.directoryIcon, + keepOpen: true, + run: async () => { + await input.createFolder!(input.createFolderName!); + }, + }); + } + return [{ value: "directories", label: "Directories", items }]; } diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..4ba26fb2276 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -77,6 +77,7 @@ import { isExplicitRelativeProjectPath, isFilesystemBrowseQuery, isUnsupportedWindowsProjectPath, + normalizeProjectPathForDispatch, resolveProjectPathForDispatch, } from "../lib/projectPaths"; import { isTerminalFocused } from "../lib/terminalFocus"; @@ -101,6 +102,7 @@ import { type CommandPaletteView, filterBrowseEntries, filterCommandPaletteGroups, + getBrowseCreateFolderName, getCommandPaletteInputPlaceholder, getCommandPaletteMode, ITEM_ICON_CLASS, @@ -462,6 +464,9 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); + const createDirectory = useAtomCommand(filesystemEnvironment.createDirectory, { + reportFailure: false, + }); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, }); @@ -483,6 +488,7 @@ function OpenCommandPaletteDialog(props: { null, ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); + const [isCreatingBrowseFolder, setIsCreatingBrowseFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); @@ -627,6 +633,74 @@ function OpenCommandPaletteDialog(props: { () => filterBrowseEntries({ browseEntries, browseFilterQuery, highlightedItemValue }), [browseEntries, browseFilterQuery, highlightedItemValue], ); + const browseCreateFolderName = useMemo( + () => + getBrowseCreateFolderName({ + browseFilterQuery, + exactEntry: exactBrowseEntry, + browseParentPath: browseResult?.parentPath, + isBrowsePending, + relativePathNeedsActiveProject, + }), + [ + browseFilterQuery, + browseResult?.parentPath, + exactBrowseEntry, + isBrowsePending, + relativePathNeedsActiveProject, + ], + ); + + const handleCreateBrowseFolder = useCallback( + async (folderName: string) => { + if (!browseEnvironmentId || isCreatingBrowseFolder) { + return; + } + + const partialPath = normalizeProjectPathForDispatch( + appendBrowsePathSegment(getBrowseDirectoryPath(query), folderName), + ); + if (partialPath.length === 0) { + return; + } + + setIsCreatingBrowseFolder(true); + const createResult = await createDirectory({ + environmentId: browseEnvironmentId, + input: { + partialPath, + ...(currentProjectCwdForBrowse ? { cwd: currentProjectCwdForBrowse } : {}), + }, + }); + setIsCreatingBrowseFolder(false); + + if (createResult._tag === "Failure") { + if (!isAtomCommandInterrupted(createResult)) { + const error = squashAtomCommandFailure(createResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to create folder", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } + + const nextQuery = appendBrowsePathSegment(query, folderName); + setHighlightedItemValue(null); + setQuery(nextQuery); + setBrowseGeneration((generation) => generation + 1); + }, + [ + browseEnvironmentId, + createDirectory, + currentProjectCwdForBrowse, + isCreatingBrowseFolder, + query, + ], + ); const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { @@ -1384,8 +1458,11 @@ function OpenCommandPaletteDialog(props: { canBrowseUp, upIcon: , directoryIcon: , + createFolderIcon: , + createFolderName: browseCreateFolderName, browseUp, browseTo, + createFolder: handleCreateBrowseFolder, }); const cloneDestinationBrowseGroups = useMemo( () => @@ -1497,9 +1574,22 @@ function OpenCommandPaletteDialog(props: { return; } + if ( + canSubmitBrowsePath && + event.key === "Enter" && + event.shiftKey && + browseCreateFolderName && + !isPrimaryModifierPressed(event) + ) { + event.preventDefault(); + void handleCreateBrowseFolder(browseCreateFolderName); + return; + } + const shouldSubmitBrowsePath = canSubmitBrowsePath && event.key === "Enter" && + !event.shiftKey && (!hasHighlightedBrowseItem || isPrimaryModifierPressed(event)); if (shouldSubmitBrowsePath) { @@ -1758,6 +1848,7 @@ function OpenCommandPaletteDialog(props: { aria-label={`${submitActionLabel} (${addShortcutLabel})`} disabled={ relativePathNeedsActiveProject || + isCreatingBrowseFolder || (isCloneDestinationStep && isRemoteProjectPending) } onMouseDown={(event) => { @@ -1825,12 +1916,16 @@ function OpenCommandPaletteDialog(props: { ? { emptyStateMessage: "Choose a destination path and press Enter to clone." } : relativePathNeedsActiveProject ? { emptyStateMessage: "Relative paths require an active project." } - : willCreateProjectPath + : browseCreateFolderName ? { - emptyStateMessage: - "Press Enter to create this folder and add it as a project.", + emptyStateMessage: `Press Shift+Enter to create "${browseCreateFolderName}", or Enter to ${isCloneDestinationStep ? "clone here" : "add it as a project"}.`, } - : {})} + : willCreateProjectPath + ? { + emptyStateMessage: + "Press Enter to create this folder and add it as a project.", + } + : {})} /> @@ -1857,6 +1952,13 @@ function OpenCommandPaletteDialog(props: { Select ) : null} + {isBrowsing && browseCreateFolderName ? ( + + Shift + Enter + Create folder + + ) : null} {isSubmenu ? ( Backspace diff --git a/packages/client-runtime/src/state/filesystem.ts b/packages/client-runtime/src/state/filesystem.ts index c78b66cf316..446c2c0023f 100644 --- a/packages/client-runtime/src/state/filesystem.ts +++ b/packages/client-runtime/src/state/filesystem.ts @@ -1,7 +1,7 @@ import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; export function createFilesystemEnvironmentAtoms( @@ -12,5 +12,9 @@ export function createFilesystemEnvironmentAtoms( label: "environment-data:filesystem:browse", tag: WS_METHODS.filesystemBrowse, }), + createDirectory: createEnvironmentRpcCommand(runtime, { + label: "environment-command:filesystem:create-directory", + tag: WS_METHODS.filesystemCreateDirectory, + }), }; } diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts index 45355b73edc..6891df9eb12 100644 --- a/packages/contracts/src/filesystem.test.ts +++ b/packages/contracts/src/filesystem.test.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { FilesystemBrowseError } from "./filesystem.ts"; +import { FilesystemBrowseError, FilesystemCreateDirectoryError } from "./filesystem.ts"; describe("FilesystemBrowseError", () => { it("derives a stable message from browse context while retaining the cause", () => { @@ -31,3 +31,22 @@ describe("FilesystemBrowseError", () => { expect(error.failure).toBeUndefined(); }); }); + +describe("FilesystemCreateDirectoryError", () => { + it("derives a stable message from create-directory context while retaining the cause", () => { + const cause = new Error("sensitive filesystem detail"); + const error = new FilesystemCreateDirectoryError({ + cwd: "/workspace", + partialPath: "./src/new-dir", + failure: "create_directory_failed", + directoryPath: "/workspace/src/new-dir", + cause, + }); + + expect(error.message).toBe( + "Failed to create filesystem directory './src/new-dir' from '/workspace'.", + ); + expect(error.message).not.toContain(cause.message); + expect(error.cause).toBe(cause); + }); +}); diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index ca4519b4c8b..aa874c34862 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -28,6 +28,58 @@ export const FilesystemBrowseFailure = Schema.Literals([ ]); export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type; +export const FilesystemCreateDirectoryInput = Schema.Struct({ + partialPath: TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH)), + cwd: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(FILESYSTEM_PATH_MAX_LENGTH))), +}); +export type FilesystemCreateDirectoryInput = typeof FilesystemCreateDirectoryInput.Type; + +export const FilesystemCreateDirectoryResult = Schema.Struct({ + directoryPath: TrimmedNonEmptyString, +}); +export type FilesystemCreateDirectoryResult = typeof FilesystemCreateDirectoryResult.Type; + +export const FilesystemCreateDirectoryFailure = Schema.Literals([ + "windows_path_unsupported", + "current_project_required", + "path_already_exists", + "path_not_directory", + "parent_not_found", + "create_directory_failed", +]); +export type FilesystemCreateDirectoryFailure = typeof FilesystemCreateDirectoryFailure.Type; + +export class FilesystemCreateDirectoryError extends Schema.TaggedErrorClass()( + "FilesystemCreateDirectoryError", + { + partialPath: Schema.optional(TrimmedNonEmptyString), + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(FilesystemCreateDirectoryFailure), + directoryPath: Schema.optional(TrimmedNonEmptyString), + platform: Schema.optional(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly partialPath: string; + readonly cwd?: string | undefined; + readonly failure: FilesystemCreateDirectoryFailure; + readonly directoryPath?: string; + readonly platform?: string; + readonly cause?: unknown; + }) { + const cwd = props.cwd === undefined ? "" : ` from '${props.cwd}'`; + super({ + ...props, + message: + decodedFilesystemBrowseErrorMessage(props) ?? + `Failed to create filesystem directory '${props.partialPath}'${cwd}.`, + } as any); + } +} + function decodedFilesystemBrowseErrorMessage(props: object): string | undefined { if (!("message" in props)) return undefined; return typeof props.message === "string" ? props.message : undefined; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bbb4d934ca2..fa7ff26228f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,7 +19,12 @@ import type { VcsStatusResult, } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; -import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; +import type { + FilesystemBrowseInput, + FilesystemBrowseResult, + FilesystemCreateDirectoryInput, + FilesystemCreateDirectoryResult, +} from "./filesystem.ts"; import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { ProjectListEntriesInput, @@ -1162,6 +1167,9 @@ export interface EnvironmentApi { }; filesystem: { browse: (input: FilesystemBrowseInput) => Promise; + createDirectory: ( + input: FilesystemCreateDirectoryInput, + ) => Promise; }; assets: { createUrl: (input: AssetCreateUrlInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..eec106be35e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -12,6 +12,9 @@ import { FilesystemBrowseInput, FilesystemBrowseResult, FilesystemBrowseError, + FilesystemCreateDirectoryInput, + FilesystemCreateDirectoryResult, + FilesystemCreateDirectoryError, } from "./filesystem.ts"; import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import { @@ -159,6 +162,7 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + filesystemCreateDirectory: "filesystem.createDirectory", assetsCreateUrl: "assets.createUrl", // VCS methods @@ -389,6 +393,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsFilesystemCreateDirectoryRpc = Rpc.make(WS_METHODS.filesystemCreateDirectory, { + payload: FilesystemCreateDirectoryInput, + success: FilesystemCreateDirectoryResult, + error: Schema.Union([FilesystemCreateDirectoryError, EnvironmentAuthorizationError]), +}); + export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, @@ -705,6 +715,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsFilesystemCreateDirectoryRpc, WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc,