diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx index f830bac778..afc9e46f8e 100644 --- a/src/browser/components/DirectoryPickerModal.tsx +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -33,6 +33,8 @@ export const DirectoryPickerModal: React.FC = ({ const [root, setRoot] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // Track if we can offer to create the folder (path doesn't exist) + const [canCreateFolder, setCanCreateFolder] = useState(false); const [pathInput, setPathInput] = useState(initialPath || ""); const [selectedIndex, setSelectedIndex] = useState(0); const treeRef = useRef(null); @@ -45,13 +47,22 @@ export const DirectoryPickerModal: React.FC = ({ } setIsLoading(true); setError(null); + setCanCreateFolder(false); try { const result = await api.general.listDirectory({ path }); if (!result.success) { const errorMessage = typeof result.error === "string" ? result.error : "Unknown error"; - setError(`Failed to load directory: ${errorMessage}`); + // Detect "no such file or directory" to offer folder creation + const isNotFound = + errorMessage.includes("ENOENT") || errorMessage.includes("no such file or directory"); + if (isNotFound) { + setCanCreateFolder(true); + setError("Folder doesn't exist."); + } else { + setError(`Failed to load directory: ${errorMessage}`); + } setRoot(null); return; } @@ -70,8 +81,10 @@ export const DirectoryPickerModal: React.FC = ({ [api] ); + // Sync pathInput with initialPath when modal opens (component stays mounted) useEffect(() => { if (!isOpen) return; + setPathInput(initialPath || ""); void loadDirectory(initialPath || "."); }, [isOpen, initialPath, loadDirectory]); @@ -87,14 +100,78 @@ export const DirectoryPickerModal: React.FC = ({ void loadDirectory(`${root.path}/..`); }, [loadDirectory, root]); - const handleConfirm = useCallback(() => { + const handleConfirm = useCallback(async () => { + const trimmedInput = pathInput.trim(); + + // If user has typed a different path, try to load it first + if (trimmedInput && trimmedInput !== root?.path) { + if (!api) return; + setIsLoading(true); + setError(null); + setCanCreateFolder(false); + + try { + const result = await api.general.listDirectory({ path: trimmedInput }); + if (!result.success) { + const errorMessage = typeof result.error === "string" ? result.error : "Unknown error"; + const isNotFound = + errorMessage.includes("ENOENT") || errorMessage.includes("no such file or directory"); + if (isNotFound) { + setCanCreateFolder(true); + setError("Folder doesn't exist."); + } else { + setError(`Failed to load directory: ${errorMessage}`); + } + setRoot(null); + return; + } + // Success - select this path + onSelectPath(result.data.path); + onClose(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(`Failed to load directory: ${message}`); + setRoot(null); + } finally { + setIsLoading(false); + } + return; + } + + // Otherwise use the current root if (!root) { return; } onSelectPath(root.path); onClose(); - }, [onClose, onSelectPath, root]); + }, [onClose, onSelectPath, root, pathInput, api]); + + const handleCreateFolder = useCallback(async () => { + const trimmedPath = pathInput.trim(); + if (!trimmedPath || !api) return; + + setIsLoading(true); + setError(null); + + try { + const createResult = await api.general.createDirectory({ path: trimmedPath }); + if (!createResult.success) { + setError(createResult.error ?? "Failed to create folder"); + setCanCreateFolder(false); + return; + } + // Folder created - now navigate to it + setCanCreateFolder(false); + void loadDirectory(createResult.data.normalizedPath); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred"; + setError(`Failed to create folder: ${errorMessage}`); + setCanCreateFolder(false); + } finally { + setIsLoading(false); + } + }, [pathInput, api, loadDirectory]); const handleOpenChange = useCallback( (open: boolean) => { @@ -120,7 +197,7 @@ export const DirectoryPickerModal: React.FC = ({ } else if ((e.ctrlKey || e.metaKey) && e.key === "o") { e.preventDefault(); if (!isLoading && root) { - handleConfirm(); + void handleConfirm(); } } }, @@ -144,13 +221,30 @@ export const DirectoryPickerModal: React.FC = ({
setPathInput(e.target.value)} + onChange={(e) => { + setPathInput(e.target.value); + setCanCreateFolder(false); + }} onKeyDown={handlePathInputKeyDown} placeholder="Enter path..." className="bg-modal-bg border-border-medium h-9 font-mono text-sm" />
- {error &&
{error}
} + {error && ( +
+ {error} + {canCreateFolder && ( + + )} +
+ )}
= ({ isLoading={isLoading} onNavigateTo={handleNavigateTo} onNavigateParent={handleNavigateParent} - onConfirm={handleConfirm} + onConfirm={() => void handleConfirm()} selectedIndex={selectedIndex} onSelectedIndexChange={setSelectedIndex} /> diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index e3a744d04f..9c16577404 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -32,6 +32,8 @@ export const ProjectCreateModal: React.FC = ({ const { api } = useAPI(); const [path, setPath] = useState(""); const [error, setError] = useState(""); + // Track if the error is specifically "path does not exist" so we can offer to create it + const [canCreateFolder, setCanCreateFolder] = useState(false); // In Electron mode, window.api exists (set by preload) and has native directory picker via ORPC // In browser mode, window.api doesn't exist and we use web-based DirectoryPickerModal const isDesktop = !!window.api; @@ -42,12 +44,14 @@ export const ProjectCreateModal: React.FC = ({ const handleCancel = useCallback(() => { setPath(""); setError(""); + setCanCreateFolder(false); onClose(); }, [onClose]); const handleWebPickerPathSelected = useCallback((selected: string) => { setPath(selected); setError(""); + setCanCreateFolder(false); }, []); const handleBrowse = useCallback(async () => { @@ -56,6 +60,7 @@ export const ProjectCreateModal: React.FC = ({ if (selectedPath) { setPath(selectedPath); setError(""); + setCanCreateFolder(false); } } catch (err) { console.error("Failed to pick directory:", err); @@ -70,6 +75,7 @@ export const ProjectCreateModal: React.FC = ({ } setError(""); + setCanCreateFolder(false); if (!api) { setError("Not connected to server"); return; @@ -101,7 +107,13 @@ export const ProjectCreateModal: React.FC = ({ // Backend validation error - show inline, keep modal open const errorMessage = typeof result.error === "string" ? result.error : "Failed to add project"; - setError(errorMessage); + // Detect "Path does not exist" error to offer folder creation + if (errorMessage.includes("Path does not exist")) { + setCanCreateFolder(true); + setError("This folder doesn't exist."); + } else { + setError(errorMessage); + } } } catch (err) { // Unexpected error @@ -112,6 +124,32 @@ export const ProjectCreateModal: React.FC = ({ } }, [path, onSuccess, onClose, api]); + const handleCreateFolder = useCallback(async () => { + const trimmedPath = path.trim(); + if (!trimmedPath || !api) return; + + setIsCreating(true); + setError(""); + + try { + const createResult = await api.general.createDirectory({ path: trimmedPath }); + if (!createResult.success) { + setError(createResult.error ?? "Failed to create folder"); + setCanCreateFolder(false); + setIsCreating(false); + return; + } + // Folder created - now retry adding the project (handleSelect manages isCreating) + setCanCreateFolder(false); + await handleSelect(); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred"; + setError(`Failed to create folder: ${errorMessage}`); + setCanCreateFolder(false); + setIsCreating(false); + } + }, [path, api, handleSelect]); + const handleBrowseClick = useCallback(() => { if (isDesktop) { void handleBrowse(); @@ -154,6 +192,7 @@ export const ProjectCreateModal: React.FC = ({ onChange={(e) => { setPath(e.target.value); setError(""); + setCanCreateFolder(false); }} onKeyDown={handleKeyDown} placeholder="/home/user/projects/my-project" @@ -172,12 +211,26 @@ export const ProjectCreateModal: React.FC = ({ )}
- {error &&
{error}
} + {error && ( +
+ {error} + {canCreateFolder && ( + + )} +
+ )} - diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 82a535c32d..cd2f0eb9fa 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -549,6 +549,14 @@ export const general = { input: z.object({ path: z.string() }), output: ResultSchema(FileTreeNodeSchema), }, + /** + * Create a directory at the specified path. + * Creates parent directories recursively if they don't exist (like mkdir -p). + */ + createDirectory: { + input: z.object({ path: z.string() }), + output: ResultSchema(z.object({ normalizedPath: z.string() }), z.string()), + }, ping: { input: z.string(), output: z.string(), diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index c2789df4f7..51facb6687 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -163,6 +163,12 @@ export const router = (authToken?: string) => { .handler(async ({ context, input }) => { return context.projectService.listDirectory(input.path); }), + createDirectory: t + .input(schemas.general.createDirectory.input) + .output(schemas.general.createDirectory.output) + .handler(async ({ context, input }) => { + return context.projectService.createDirectory(input.path); + }), ping: t .input(schemas.general.ping.input) .output(schemas.general.ping.output) diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index 8d4ca4eb09..3fa79a4e8e 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -173,6 +173,26 @@ export class ProjectService { }; } } + + async createDirectory( + requestedPath: string + ): Promise> { + try { + // Expand ~ to home directory + const expanded = + requestedPath === "~" || requestedPath.startsWith("~/") + ? requestedPath.replace("~", os.homedir()) + : requestedPath; + const normalizedPath = path.resolve(expanded); + + await fsPromises.mkdir(normalizedPath, { recursive: true }); + return Ok({ normalizedPath }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to create directory: ${message}`); + } + } + async updateSecrets(projectPath: string, secrets: Secret[]): Promise> { try { await this.config.updateProjectSecrets(projectPath, secrets);