From ca37b7bafe59f71359c05f826b89fe753b4d5204 Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Mon, 23 Mar 2026 18:48:03 -0700 Subject: [PATCH 1/2] new "+" option to create an empty dir --- apps/desktop/src/main.ts | 11 + apps/desktop/src/preload.ts | 2 + apps/server/src/wsServer.test.ts | 43 ++++ apps/server/src/wsServer.ts | 41 +++ apps/web/src/appSettings.test.ts | 1 + apps/web/src/appSettings.ts | 1 + apps/web/src/components/Sidebar.tsx | 331 +++++++++++++++++++++++-- apps/web/src/routes/_chat.settings.tsx | 143 +++++++++++ apps/web/src/wsNativeApi.test.ts | 36 +++ apps/web/src/wsNativeApi.ts | 5 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/project.ts | 12 + packages/contracts/src/ws.ts | 8 +- 13 files changed, 611 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 062c79fa69..fb36859b4a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -47,6 +47,7 @@ import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runti syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const GET_DOCUMENTS_PATH_CHANNEL = "desktop:get-documents-path"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; @@ -1086,6 +1087,16 @@ function registerIpcHandlers(): void { return result.filePaths[0] ?? null; }); + ipcMain.removeHandler(GET_DOCUMENTS_PATH_CHANNEL); + ipcMain.handle(GET_DOCUMENTS_PATH_CHANNEL, async () => { + try { + const documentsPath = app.getPath("documents"); + return typeof documentsPath === "string" && documentsPath.length > 0 ? documentsPath : null; + } catch { + return null; + } + }); + ipcMain.removeHandler(CONFIRM_CHANNEL); ipcMain.handle(CONFIRM_CHANNEL, async (_event, message: unknown) => { if (typeof message !== "string") { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8e..335a816731 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron"; import type { DesktopBridge } from "@t3tools/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +const GET_DOCUMENTS_PATH_CHANNEL = "desktop:get-documents-path"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; @@ -16,6 +17,7 @@ const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), + getDocumentsPath: () => ipcRenderer.invoke(GET_DOCUMENTS_PATH_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 9c6adfeba9..a4417fa828 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1641,6 +1641,49 @@ describe("WebSocket Server", () => { expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); }); + it("supports projects.createDirectory within the workspace root", async () => { + const workspace = makeTempDir("t3code-ws-create-directory-"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsCreateDirectory, { + cwd: workspace, + relativePath: "sandboxes/fresh-room", + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + relativePath: "sandboxes/fresh-room", + absolutePath: path.join(workspace, "sandboxes", "fresh-room"), + }); + expect(fs.existsSync(path.join(workspace, "sandboxes", "fresh-room"))).toBe(true); + }); + + it("rejects projects.createDirectory when the target already exists", async () => { + const workspace = makeTempDir("t3code-ws-create-directory-existing-"); + fs.mkdirSync(path.join(workspace, "existing"), { recursive: true }); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsCreateDirectory, { + cwd: workspace, + relativePath: "existing", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Workspace directory already exists"); + }); + it("routes git core methods over websocket", async () => { const listBranches = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e22c23988b..1b1d560d1b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -776,6 +776,47 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { relativePath: target.relativePath }; } + case WS_METHODS.projectsCreateDirectory: { + const body = stripRequestTag(request.body); + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: body.cwd, + relativePath: body.relativePath, + path, + }); + + const existingEntry = yield* Effect.result(fileSystem.stat(target.absolutePath)); + if (Result.isSuccess(existingEntry)) { + return yield* new RouteRequestError({ + message: `Workspace directory already exists: ${target.relativePath}`, + }); + } + + yield* fileSystem + .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to prepare workspace directory path: ${String(cause)}`, + }), + ), + ); + + yield* fileSystem.makeDirectory(target.absolutePath, { recursive: false }).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to create workspace directory: ${String(cause)}`, + }), + ), + ); + + return { + relativePath: target.relativePath, + absolutePath: target.absolutePath, + }; + } + case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); return yield* openInEditor(body); diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 26d231537d..13619ad820 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -211,6 +211,7 @@ describe("AppSettingsSchema", () => { ).toMatchObject({ codexBinaryPath: "/usr/local/bin/codex", codexHomePath: "", + newProjectBasePath: "", defaultThreadEnvMode: "local", confirmThreadDelete: false, enableAssistantStreaming: false, diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92d..4010476f75 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -49,6 +49,7 @@ const withDefaults = export const AppSettingsSchema = Schema.Struct({ codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), codexHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + newProjectBasePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), defaultThreadEnvMode: EnvMode.pipe(withDefaults(() => "local" as const satisfies EnvMode)), confirmThreadDelete: Schema.Boolean.pipe(withDefaults(() => true)), enableAssistantStreaming: Schema.Boolean.pipe(withDefaults(() => false)), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9ff741897c..b217fdf331 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,6 +2,8 @@ import { ArrowLeftIcon, ChevronRightIcon, FolderIcon, + FolderOpenIcon, + FolderPlusIcon, GitPullRequestIcon, PlusIcon, RocketIcon, @@ -64,6 +66,17 @@ import { import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent } from "./ui/collapsible"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { SidebarContent, @@ -272,7 +285,7 @@ export default function Sidebar() { ); const navigate = useNavigate(); const isOnSettings = useLocation({ select: (loc) => loc.pathname === "/settings" }); - const { settings: appSettings } = useAppSettings(); + const { settings: appSettings, updateSettings } = useAppSettings(); const { handleNewThread } = useHandleNewThread(); const routeThreadId = useParams({ strict: false, @@ -285,11 +298,22 @@ export default function Sidebar() { const queryClient = useQueryClient(); const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); const [addingProject, setAddingProject] = useState(false); + const [isAddProjectMenuOpen, setIsAddProjectMenuOpen] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); const [addProjectError, setAddProjectError] = useState(null); const addProjectInputRef = useRef(null); + const addProjectMenuCloseTimerRef = useRef(null); + const [isCreateProjectDialogOpen, setIsCreateProjectDialogOpen] = useState(false); + const [createProjectDirectoryPath, setCreateProjectDirectoryPath] = useState(""); + const [newProjectDirectoryName, setNewProjectDirectoryName] = useState(""); + const [isCreatingProjectDirectory, setIsCreatingProjectDirectory] = useState(false); + const [isPickingCreateProjectDirectoryPath, setIsPickingCreateProjectDirectoryPath] = + useState(false); + const [createProjectDirectoryError, setCreateProjectDirectoryError] = useState( + null, + ); const [renamingThreadId, setRenamingThreadId] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< @@ -403,11 +427,11 @@ export default function Sidebar() { ); const addProjectFromPath = useCallback( - async (rawCwd: string) => { + async (rawCwd: string): Promise<{ ok: boolean; error?: string }> => { const cwd = rawCwd.trim(); - if (!cwd || isAddingProject) return; + if (!cwd || isAddingProject) return { ok: false }; const api = readNativeApi(); - if (!api) return; + if (!api) return { ok: false, error: "Native API not available." }; setIsAddingProject(true); const finishAddingProject = () => { @@ -421,7 +445,7 @@ export default function Sidebar() { if (existing) { focusMostRecentThreadForProject(existing.id); finishAddingProject(); - return; + return { ok: true }; } const projectId = newProjectId(); @@ -453,9 +477,10 @@ export default function Sidebar() { } else { setAddProjectError(description); } - return; + return { ok: false, error: description }; } finishAddingProject(); + return { ok: true }; }, [ focusMostRecentThreadForProject, @@ -472,6 +497,26 @@ export default function Sidebar() { }; const canAddProject = newCwd.trim().length > 0 && !isAddingProject; + const newProjectBasePath = appSettings.newProjectBasePath.trim(); + + const cancelAddProjectMenuClose = useCallback(() => { + if (addProjectMenuCloseTimerRef.current === null) return; + window.clearTimeout(addProjectMenuCloseTimerRef.current); + addProjectMenuCloseTimerRef.current = null; + }, []); + + const openAddProjectMenu = useCallback(() => { + cancelAddProjectMenuClose(); + setIsAddProjectMenuOpen(true); + }, [cancelAddProjectMenuClose]); + + const scheduleAddProjectMenuClose = useCallback(() => { + cancelAddProjectMenuClose(); + addProjectMenuCloseTimerRef.current = window.setTimeout(() => { + setIsAddProjectMenuOpen(false); + addProjectMenuCloseTimerRef.current = null; + }, 120); + }, [cancelAddProjectMenuClose]); const handlePickFolder = async () => { const api = readNativeApi(); @@ -492,6 +537,7 @@ export default function Sidebar() { }; const handleStartAddProject = () => { + setIsAddProjectMenuOpen(false); setAddProjectError(null); if (shouldBrowseForProjectImmediately) { void handlePickFolder(); @@ -500,6 +546,110 @@ export default function Sidebar() { setAddingProject((prev) => !prev); }; + const closeCreateProjectDialog = useCallback((open: boolean) => { + setIsCreateProjectDialogOpen(open); + if (!open) { + setCreateProjectDirectoryPath(""); + setNewProjectDirectoryName(""); + setCreateProjectDirectoryError(null); + setIsCreatingProjectDirectory(false); + setIsPickingCreateProjectDirectoryPath(false); + } + }, []); + + const handleStartCreateProjectDirectory = useCallback(() => { + setIsAddProjectMenuOpen(false); + setAddProjectError(null); + setCreateProjectDirectoryPath(newProjectBasePath); + setNewProjectDirectoryName(""); + setCreateProjectDirectoryError(null); + setIsCreateProjectDialogOpen(true); + + if (newProjectBasePath) { + return; + } + + const api = readNativeApi(); + if (!api) { + return; + } + + void api.dialogs.getDocumentsPath().then((documentsPath) => { + if (!documentsPath) return; + setCreateProjectDirectoryPath((current) => + current.trim().length > 0 ? current : documentsPath, + ); + }); + }, [newProjectBasePath]); + + const handlePickCreateProjectDirectoryPath = useCallback(async () => { + const api = readNativeApi(); + if (!api || isPickingCreateProjectDirectoryPath) return; + + setIsPickingCreateProjectDirectoryPath(true); + try { + const pickedPath = await api.dialogs.pickFolder(); + if (pickedPath) { + setCreateProjectDirectoryPath(pickedPath); + setCreateProjectDirectoryError(null); + } + } finally { + setIsPickingCreateProjectDirectoryPath(false); + } + }, [isPickingCreateProjectDirectoryPath]); + + const handleCreateProjectDirectory = useCallback(async () => { + const basePath = createProjectDirectoryPath.trim(); + const relativePath = newProjectDirectoryName.trim(); + if (!basePath || !relativePath || isCreatingProjectDirectory || isAddingProject) { + if (!basePath) { + setCreateProjectDirectoryError("Enter a directory path."); + } + if (!relativePath) { + setCreateProjectDirectoryError("Enter a directory name."); + } + return; + } + + const api = readNativeApi(); + if (!api) return; + + setIsCreatingProjectDirectory(true); + setCreateProjectDirectoryError(null); + + try { + const result = await api.projects.createDirectory({ + cwd: basePath, + relativePath, + }); + const addedProject = await addProjectFromPath(result.absolutePath); + if (!addedProject.ok) { + setCreateProjectDirectoryError(addedProject.error ?? "Unable to open the new project."); + setIsCreatingProjectDirectory(false); + return; + } + closeCreateProjectDialog(false); + } catch (error) { + setCreateProjectDirectoryError( + error instanceof Error ? error.message : "Unable to create the directory.", + ); + setIsCreatingProjectDirectory(false); + } + }, [ + addProjectFromPath, + closeCreateProjectDialog, + createProjectDirectoryPath, + isAddingProject, + isCreatingProjectDirectory, + newProjectDirectoryName, + ]); + + useEffect(() => { + return () => { + cancelAddProjectMenuClose(); + }; + }, [cancelAddProjectMenuClose]); + const cancelRename = useCallback(() => { setRenamingThreadId(null); renamingInputRef.current = null; @@ -1232,28 +1382,59 @@ export default function Sidebar() { Projects - - + + setIsAddProjectMenuOpen((open) => !open)} + /> + } + > + - } - > - - - - {shouldShowProjectPathEntry ? "Cancel add project" : "Add project"} - - + + + + + + + Open existing directory + + + Add a project from a folder you already have. + + + + + + + + Create new empty directory + + + Make a fresh folder inside your default project path. + + + + + + {shouldShowProjectPathEntry && ( @@ -1702,6 +1883,102 @@ export default function Sidebar() { + + + + Create new empty directory + + Choose where the new folder should live, then name the project and T3 Code will open + it right away. + + + +
+ Directory path +
+ { + setCreateProjectDirectoryPath(event.target.value); + if (createProjectDirectoryError) { + setCreateProjectDirectoryError(null); + } + }} + placeholder="/Users/you/Documents" + spellCheck={false} + autoFocus + /> + +
+ {!newProjectBasePath && createProjectDirectoryPath.trim() ? ( +
+ +
+ ) : null} +
+ + + + {createProjectDirectoryError ? ( +

{createProjectDirectoryError}

+ ) : null} +
+ + + + +
+
); } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index acc8763fb4..bfef3ec921 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -18,6 +18,15 @@ import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; import { Button } from "../components/ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../components/ui/dialog"; import { Input } from "../components/ui/input"; import { Select, @@ -60,6 +69,9 @@ function SettingsRouteView() { const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [isProjectBasePathDialogOpen, setIsProjectBasePathDialogOpen] = useState(false); + const [isPickingProjectBasePath, setIsPickingProjectBasePath] = useState(false); + const [projectBasePathDraft, setProjectBasePathDraft] = useState(""); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -72,6 +84,7 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const newProjectBasePath = settings.newProjectBasePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; @@ -173,6 +186,43 @@ function SettingsRouteView() { [settings, updateSettings], ); + const openProjectBasePathDialog = useCallback(() => { + setProjectBasePathDraft(settings.newProjectBasePath); + setIsProjectBasePathDialogOpen(true); + }, [settings.newProjectBasePath]); + + const closeProjectBasePathDialog = useCallback((open: boolean) => { + setIsProjectBasePathDialogOpen(open); + if (!open) { + setIsPickingProjectBasePath(false); + setProjectBasePathDraft(""); + } + }, []); + + const saveProjectBasePath = useCallback(() => { + const normalizedPath = projectBasePathDraft.trim(); + updateSettings({ + newProjectBasePath: normalizedPath, + }); + setProjectBasePathDraft(normalizedPath); + setIsProjectBasePathDialogOpen(false); + }, [projectBasePathDraft, updateSettings]); + + const pickProjectBasePath = useCallback(async () => { + if (isPickingProjectBasePath) return; + + setIsPickingProjectBasePath(true); + try { + const api = ensureNativeApi(); + const pickedPath = await api.dialogs.pickFolder(); + if (!pickedPath) return; + + setProjectBasePathDraft(pickedPath); + } finally { + setIsPickingProjectBasePath(false); + } + }, [isPickingProjectBasePath]); + return (
@@ -282,6 +332,48 @@ function SettingsRouteView() {
+
+
+

Projects

+

+ Optionally choose where newly created empty project folders should live by + default. If unset, the create-project dialog starts in your Documents folder. +

+
+ +
+
+
+

+ New project directory base path +

+

+ {newProjectBasePath || "Not set"} +

+
+ +
+ + {newProjectBasePath ? ( +
+ +
+ ) : null} +
+
+

Codex App Server

@@ -720,6 +812,57 @@ function SettingsRouteView() {
+ + + + + Default new project path + + Enter the full base path where new empty project folders should be created. + + + + + + + + + + + ); } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 2323380da0..fe54ac3b7f 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -320,6 +320,42 @@ describe("wsNativeApi", () => { }); }); + it("forwards workspace directory creation to the websocket project method", async () => { + requestMock.mockResolvedValue({ + relativePath: "sandbox", + absolutePath: "/tmp/project/sandbox", + }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.projects.createDirectory({ + cwd: "/tmp/project", + relativePath: "sandbox", + }); + + expect(requestMock).toHaveBeenCalledWith(WS_METHODS.projectsCreateDirectory, { + cwd: "/tmp/project", + relativePath: "sandbox", + }); + }); + + it("reads the desktop documents path when the bridge is available", async () => { + const getDocumentsPath = vi.fn().mockResolvedValue("/Users/davis/Documents"); + Object.defineProperty(getWindowForTest(), "desktopBridge", { + configurable: true, + writable: true, + value: { + getDocumentsPath, + }, + }); + + const { createWsNativeApi } = await import("./wsNativeApi"); + const api = createWsNativeApi(); + + await expect(api.dialogs.getDocumentsPath()).resolves.toBe("/Users/davis/Documents"); + expect(getDocumentsPath).toHaveBeenCalledTimes(1); + }); + it("forwards full-thread diff requests to the orchestration websocket method", async () => { requestMock.mockResolvedValue({ diff: "patch" }); const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ddfffbde69..f420383f92 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -94,6 +94,10 @@ export function createWsNativeApi(): NativeApi { if (!window.desktopBridge) return null; return window.desktopBridge.pickFolder(); }, + getDocumentsPath: async () => { + if (!window.desktopBridge) return null; + return window.desktopBridge.getDocumentsPath(); + }, confirm: async (message) => { if (window.desktopBridge) { return window.desktopBridge.confirm(message); @@ -114,6 +118,7 @@ export function createWsNativeApi(): NativeApi { projects: { searchEntries: (input) => transport.request(WS_METHODS.projectsSearchEntries, input), writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), + createDirectory: (input) => transport.request(WS_METHODS.projectsCreateDirectory, input), }, shell: { openInEditor: (cwd, editor) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..8483a13438 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,6 +19,8 @@ import type { GitStatusResult, } from "./git"; import type { + ProjectCreateDirectoryInput, + ProjectCreateDirectoryResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, ProjectWriteFileInput, @@ -97,6 +99,7 @@ export interface DesktopUpdateActionResult { export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; + getDocumentsPath: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; showContextMenu: ( @@ -114,6 +117,7 @@ export interface DesktopBridge { export interface NativeApi { dialogs: { pickFolder: () => Promise; + getDocumentsPath: () => Promise; confirm: (message: string) => Promise; }; terminal: { @@ -128,6 +132,7 @@ export interface NativeApi { projects: { searchEntries: (input: ProjectSearchEntriesInput) => Promise; writeFile: (input: ProjectWriteFileInput) => Promise; + createDirectory: (input: ProjectCreateDirectoryInput) => Promise; }; shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 0903253301..7fda5876b5 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -37,3 +37,15 @@ export const ProjectWriteFileResult = Schema.Struct({ relativePath: TrimmedNonEmptyString, }); export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; + +export const ProjectCreateDirectoryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + relativePath: TrimmedNonEmptyString.check(Schema.isMaxLength(PROJECT_WRITE_FILE_PATH_MAX_LENGTH)), +}); +export type ProjectCreateDirectoryInput = typeof ProjectCreateDirectoryInput.Type; + +export const ProjectCreateDirectoryResult = Schema.Struct({ + relativePath: TrimmedNonEmptyString, + absolutePath: TrimmedNonEmptyString, +}); +export type ProjectCreateDirectoryResult = typeof ProjectCreateDirectoryResult.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index ebb76138b8..e31ac8e87b 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -34,7 +34,11 @@ import { TerminalWriteInput, } from "./terminal"; import { KeybindingRule } from "./keybindings"; -import { ProjectSearchEntriesInput, ProjectWriteFileInput } from "./project"; +import { + ProjectCreateDirectoryInput, + ProjectSearchEntriesInput, + ProjectWriteFileInput, +} from "./project"; import { OpenInEditorInput } from "./editor"; import { ServerConfigUpdatedPayload } from "./server"; @@ -47,6 +51,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", + projectsCreateDirectory: "projects.createDirectory", // Shell methods shellOpenInEditor: "shell.openInEditor", @@ -111,6 +116,7 @@ const WebSocketRequestBody = Schema.Union([ // Project Search tagRequestBody(WS_METHODS.projectsSearchEntries, ProjectSearchEntriesInput), tagRequestBody(WS_METHODS.projectsWriteFile, ProjectWriteFileInput), + tagRequestBody(WS_METHODS.projectsCreateDirectory, ProjectCreateDirectoryInput), // Shell methods tagRequestBody(WS_METHODS.shellOpenInEditor, OpenInEditorInput), From db02da69baac26903b2d4880231f20d78882379f Mon Sep 17 00:00:00 2001 From: Ben Davis Date: Mon, 23 Mar 2026 19:12:50 -0700 Subject: [PATCH 2/2] Handle missing workspace directories when creating folders - Treat NotFound as the only acceptable inspect failure - Return a clearer error for unexpected filesystem inspection failures --- apps/server/src/wsServer.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 1b1d560d1b..744991cc4c 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -35,6 +35,7 @@ import { FileSystem, Layer, Path, + PlatformError, Ref, Result, Schema, @@ -790,6 +791,17 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< message: `Workspace directory already exists: ${target.relativePath}`, }); } + if ( + !( + existingEntry.failure instanceof PlatformError.PlatformError && + existingEntry.failure.reason instanceof PlatformError.SystemError && + existingEntry.failure.reason._tag === "NotFound" + ) + ) { + return yield* new RouteRequestError({ + message: `Failed to inspect workspace directory: ${String(existingEntry.failure)}`, + }); + } yield* fileSystem .makeDirectory(path.dirname(target.absolutePath), { recursive: true })