diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index fba7a7f5b..1df62a30d 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -85,6 +85,7 @@ function setupMockAPI(options: { data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project/path" }, }), remove: () => Promise.resolve({ success: true, data: undefined }), + pickDirectory: () => Promise.resolve(null), listBranches: () => Promise.resolve({ branches: ["main", "develop", "feature/new-feature"], diff --git a/src/browser/api.ts b/src/browser/api.ts index 4314b5c90..22ca7ca44 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -193,6 +193,9 @@ const webApi: IPCApi = { calculateStats: (messages, model) => invokeIPC(IPC_CHANNELS.TOKENIZER_CALCULATE_STATS, messages, model), }, + fs: { + listDirectory: (root) => invokeIPC(IPC_CHANNELS.FS_LIST_DIRECTORY, root), + }, providers: { setProviderConfig: (provider, keyPath, value) => invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), @@ -200,6 +203,7 @@ const webApi: IPCApi = { }, projects: { create: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_CREATE, projectPath), + pickDirectory: () => Promise.resolve(null), remove: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_REMOVE, projectPath), list: () => invokeIPC(IPC_CHANNELS.PROJECT_LIST), listBranches: (projectPath) => invokeIPC(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath), diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx new file mode 100644 index 000000000..b05356993 --- /dev/null +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; +import { DirectoryTree } from "./DirectoryTree"; +import type { IPCApi } from "@/common/types/ipc"; + +interface DirectoryPickerModalProps { + isOpen: boolean; + initialPath: string; + onClose: () => void; + onSelectPath: (path: string) => void; +} + +export const DirectoryPickerModal: React.FC = ({ + isOpen, + initialPath, + onClose, + onSelectPath, +}) => { + type FsListDirectoryResponse = FileTreeNode & { success?: boolean; error?: unknown }; + const [root, setRoot] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const loadDirectory = useCallback(async (path: string) => { + const api = window.api as unknown as IPCApi; + if (!api.fs?.listDirectory) { + setError("Directory picker is not available in this environment."); + return; + } + + setIsLoading(true); + setError(null); + + try { + const tree = (await api.fs.listDirectory(path)) as FsListDirectoryResponse; + + // In browser/server mode, HttpIpcMainAdapter wraps handler errors as + // { success: false, error }, and invokeIPC returns that object instead + // of throwing. Detect that shape and surface a friendly error instead + // of crashing when accessing tree.children. + if (tree.success === false) { + const errorMessage = typeof tree.error === "string" ? tree.error : "Unknown error"; + setError(`Failed to load directory: ${errorMessage}`); + setRoot(null); + return; + } + + setRoot(tree); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(`Failed to load directory: ${message}`); + setRoot(null); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!isOpen) return; + void loadDirectory(initialPath || "."); + }, [isOpen, initialPath, loadDirectory]); + + const handleNavigateTo = useCallback( + (path: string) => { + void loadDirectory(path); + }, + [loadDirectory] + ); + + const handleNavigateParent = useCallback(() => { + if (!root) return; + void loadDirectory(`${root.path}/..`); + }, [loadDirectory, root]); + + const handleConfirm = useCallback(() => { + if (!root) { + return; + } + + onSelectPath(root.path); + onClose(); + }, [onClose, onSelectPath, root]); + + if (!isOpen) return null; + const entries = + root?.children + .filter((child) => child.isDirectory) + .map((child) => ({ name: child.name, path: child.path })) ?? []; + + return ( + + {error &&
{error}
} +
+ +
+ + + Cancel + + void handleConfirm()} disabled={isLoading || !root}> + Select + + +
+ ); +}; diff --git a/src/browser/components/DirectoryTree.tsx b/src/browser/components/DirectoryTree.tsx new file mode 100644 index 000000000..a9658074a --- /dev/null +++ b/src/browser/components/DirectoryTree.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +interface DirectoryTreeEntry { + name: string; + path: string; +} + +interface DirectoryTreeProps { + currentPath: string | null; + entries: DirectoryTreeEntry[]; + isLoading?: boolean; + onNavigateTo: (path: string) => void; + onNavigateParent: () => void; +} + +export const DirectoryTree: React.FC = (props) => { + const { currentPath, entries, isLoading = false, onNavigateTo, onNavigateParent } = props; + + const hasEntries = entries.length > 0; + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (containerRef.current) { + containerRef.current.scrollTop = 0; + } + }, [currentPath]); + + return ( +
+ {isLoading && !currentPath ? ( +
Loading directories...
+ ) : ( +
    + {currentPath && ( +
  • + ... +
  • + )} + + {!isLoading && !hasEntries ? ( +
  • No subdirectories found
  • + ) : null} + + {entries.map((entry) => ( +
  • onNavigateTo(entry.path)} + > + {entry.name} +
  • + ))} + + {isLoading && currentPath && !hasEntries ? ( +
  • Loading directories...
  • + ) : null} +
+ )} +
+ ); +}; diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index 97756becd..6a7f51bec 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -1,5 +1,7 @@ import React, { useState, useCallback } from "react"; import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import type { IPCApi } from "@/common/types/ipc"; +import { DirectoryPickerModal } from "./DirectoryPickerModal"; import type { ProjectConfig } from "@/node/config"; interface ProjectCreateModalProps { @@ -21,7 +23,13 @@ export const ProjectCreateModal: React.FC = ({ }) => { const [path, setPath] = useState(""); const [error, setError] = useState(""); + // Detect desktop environment where native directory picker is available + const isDesktop = + window.api.platform !== "browser" && typeof window.api.projects.pickDirectory === "function"; + const api = window.api as unknown as IPCApi; + const hasWebFsPicker = window.api.platform === "browser" && !!api.fs?.listDirectory; const [isCreating, setIsCreating] = useState(false); + const [isDirPickerOpen, setIsDirPickerOpen] = useState(false); const handleCancel = useCallback(() => { setPath(""); @@ -29,6 +37,23 @@ export const ProjectCreateModal: React.FC = ({ onClose(); }, [onClose]); + const handleWebPickerPathSelected = useCallback((selected: string) => { + setPath(selected); + setError(""); + }, []); + + const handleBrowse = useCallback(async () => { + try { + const selectedPath = await window.api.projects.pickDirectory(); + if (selectedPath) { + setPath(selectedPath); + setError(""); + } + } catch (err) { + console.error("Failed to pick directory:", err); + } + }, []); + const handleSelect = useCallback(async () => { const trimmedPath = path.trim(); if (!trimmedPath) { @@ -78,6 +103,14 @@ export const ProjectCreateModal: React.FC = ({ } }, [path, onSuccess, onClose]); + const handleBrowseClick = useCallback(() => { + if (isDesktop) { + void handleBrowse(); + } else if (hasWebFsPicker) { + setIsDirPickerOpen(true); + } + }, [handleBrowse, hasWebFsPicker, isDesktop]); + const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { @@ -89,35 +122,55 @@ export const ProjectCreateModal: React.FC = ({ ); return ( - - { - setPath(e.target.value); - setError(""); - }} - onKeyDown={handleKeyDown} - placeholder="/home/user/projects/my-project" - autoFocus - disabled={isCreating} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted mb-5 w-full rounded border px-3 py-2 font-mono text-sm text-white focus:outline-none disabled:opacity-50" + <> + +
+ { + setPath(e.target.value); + setError(""); + }} + onKeyDown={handleKeyDown} + placeholder="/home/user/projects/my-project" + autoFocus + disabled={isCreating} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted w-full flex-1 rounded border px-3 py-2 font-mono text-sm text-white focus:outline-none disabled:opacity-50" + /> + {(isDesktop || hasWebFsPicker) && ( + + )} +
+ {error &&
{error}
} + + + Cancel + + void handleSelect()} disabled={isCreating}> + {isCreating ? "Adding..." : "Add Project"} + + +
+ setIsDirPickerOpen(false)} + onSelectPath={handleWebPickerPathSelected} /> - {error &&
{error}
} - - - Cancel - - void handleSelect()} disabled={isCreating}> - {isCreating ? "Adding..." : "Add Project"} - - -
+ ); }; diff --git a/src/browser/contexts/ProjectContext.test.tsx b/src/browser/contexts/ProjectContext.test.tsx index e30daabf9..b031ad1b7 100644 --- a/src/browser/contexts/ProjectContext.test.tsx +++ b/src/browser/contexts/ProjectContext.test.tsx @@ -359,6 +359,7 @@ function createMockAPI(overrides: Partial) { data: undefined, })) ), + pickDirectory: mock(overrides.pickDirectory ?? (() => Promise.resolve(null))), secrets: { get: mock( overrides.secrets?.get diff --git a/src/common/constants/ipc-constants.ts b/src/common/constants/ipc-constants.ts index b02a06b47..4d7a2ac7d 100644 --- a/src/common/constants/ipc-constants.ts +++ b/src/common/constants/ipc-constants.ts @@ -9,11 +9,13 @@ export const IPC_CHANNELS = { PROVIDERS_LIST: "providers:list", // Project channels + PROJECT_PICK_DIRECTORY: "project:pickDirectory", PROJECT_CREATE: "project:create", PROJECT_REMOVE: "project:remove", PROJECT_LIST: "project:list", PROJECT_LIST_BRANCHES: "project:listBranches", PROJECT_SECRETS_GET: "project:secrets:get", + FS_LIST_DIRECTORY: "fs:listDirectory", PROJECT_SECRETS_UPDATE: "project:secrets:update", // Workspace channels diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts index 339da510d..79878aa03 100644 --- a/src/common/types/ipc.ts +++ b/src/common/types/ipc.ts @@ -10,6 +10,7 @@ import type { BashToolResult } from "./tools"; import type { Secret } from "./secrets"; import type { MuxProviderOptions } from "./providerOptions"; import type { RuntimeConfig } from "./runtime"; +import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import type { TerminalSession, TerminalCreateParams, TerminalResizeParams } from "./terminal"; import type { StreamStartEvent, @@ -244,10 +245,14 @@ export interface IPCApi { ): Promise>; list(): Promise; }; + fs?: { + listDirectory(root: string): Promise; + }; projects: { create( projectPath: string ): Promise>; + pickDirectory(): Promise; remove(projectPath: string): Promise>; list(): Promise>; listBranches(projectPath: string): Promise; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 63d6dfa03..1880421ba 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -2,7 +2,7 @@ import "source-map-support/register"; import "disposablestack/auto"; -import type { MenuItemConstructorOptions } from "electron"; +import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from "electron"; import { app, BrowserWindow, @@ -322,6 +322,19 @@ async function loadServices(): Promise { // Set TerminalWindowManager for desktop mode (pop-out terminal windows) const terminalWindowManager = new TerminalWindowManagerClass(config); + ipcMain.setProjectDirectoryPicker(async (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; + + const res = await dialog.showOpenDialog(win, { + properties: ["openDirectory", "createDirectory", "showHiddenFiles"], + title: "Select Project Directory", + buttonLabel: "Select Project", + }); + + return res.canceled || res.filePaths.length === 0 ? null : res.filePaths[0]; + }); + ipcMain.setTerminalWindowManager(terminalWindowManager); loadTokenizerModules().catch((error) => { diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index b8a910bd5..5b0e5a371 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -39,8 +39,12 @@ const api: IPCApi = { ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), list: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_LIST), }, + fs: { + listDirectory: (root: string) => ipcRenderer.invoke(IPC_CHANNELS.FS_LIST_DIRECTORY, root), + }, projects: { create: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, projectPath), + pickDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_PICK_DIRECTORY), remove: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectPath), list: (): Promise> => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST), diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index d76819023..2efeb397e 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1,5 +1,5 @@ import assert from "@/common/utils/assert"; -import type { IpcMain as ElectronIpcMain, BrowserWindow } from "electron"; +import type { BrowserWindow, IpcMain as ElectronIpcMain, IpcMainInvokeEvent } from "electron"; import { spawn, spawnSync } from "child_process"; import * as fsPromises from "fs/promises"; import * as path from "path"; @@ -58,6 +58,8 @@ export class IpcMain { private readonly ptyService: PTYService; private terminalWindowManager?: TerminalWindowManager; private readonly sessions = new Map(); + private projectDirectoryPicker?: (event: IpcMainInvokeEvent) => Promise; + private readonly sessionSubscriptions = new Map< string, { chat: () => void; metadata: () => void } @@ -95,6 +97,14 @@ export class IpcMain { await this.extensionMetadata.initialize(); } + /** + * Configure a picker used to select project directories (desktop mode only). + * Server mode does not provide a native directory picker. + */ + setProjectDirectoryPicker(picker: (event: IpcMainInvokeEvent) => Promise): void { + this.projectDirectoryPicker = picker; + } + /** * Set the terminal window manager (desktop mode only). * Server mode doesn't use pop-out terminal windows. @@ -358,6 +368,37 @@ export class IpcMain { * @param ipcMain - Electron's ipcMain module * @param mainWindow - The main BrowserWindow for sending events */ + private registerFsHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle(IPC_CHANNELS.FS_LIST_DIRECTORY, async (_event, root: string) => { + try { + const normalizedRoot = path.resolve(root || "."); + const entries = await fsPromises.readdir(normalizedRoot, { withFileTypes: true }); + + const children = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const entryPath = path.join(normalizedRoot, entry.name); + return { + name: entry.name, + path: entryPath, + isDirectory: true, + children: [], + }; + }); + + return { + name: normalizedRoot, + path: normalizedRoot, + isDirectory: true, + children, + }; + } catch (error) { + log.error("FS_LIST_DIRECTORY failed:", error); + throw error instanceof Error ? error : new Error(String(error)); + } + }); + } + register(ipcMain: ElectronIpcMain, mainWindow: BrowserWindow): void { // Always update the window reference (windows can be recreated on macOS) this.mainWindow = mainWindow; @@ -373,6 +414,7 @@ export class IpcMain { this.registerTokenizerHandlers(ipcMain); this.registerWorkspaceHandlers(ipcMain); this.registerProviderHandlers(ipcMain); + this.registerFsHandlers(ipcMain); this.registerProjectHandlers(ipcMain); this.registerTerminalHandlers(ipcMain, mainWindow); this.registerSubscriptionHandlers(ipcMain); @@ -1367,6 +1409,24 @@ export class IpcMain { } private registerProjectHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle( + IPC_CHANNELS.PROJECT_PICK_DIRECTORY, + async (event: IpcMainInvokeEvent | null) => { + if (!event?.sender || !this.projectDirectoryPicker) { + // In server mode (HttpIpcMainAdapter), there is no BrowserWindow / sender. + // The browser uses the web-based directory picker instead. + return null; + } + + try { + return await this.projectDirectoryPicker(event); + } catch (error) { + log.error("Failed to pick directory:", error); + return null; + } + } + ); + ipcMain.handle(IPC_CHANNELS.PROJECT_CREATE, async (_event, projectPath: string) => { try { // Validate and expand path (handles tilde, checks existence and directory status)