From fdd1ba124587df40165e0e104dba348a400ce5e4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:54:38 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20project=20dire?= =?UTF-8?q?ctory=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a native directory picker for adding projects on Desktop. - Adds project:pickDirectory IPC channel - Exposes picker via preload/main process - Adds 'Browse...' button to ProjectCreateModal - Stubs behavior for browser environment _Generated with mux_ --- src/browser/App.stories.tsx | 1 + src/browser/api.ts | 1 + src/browser/components/ProjectCreateModal.tsx | 53 ++++++++++++++----- src/browser/contexts/ProjectContext.test.tsx | 1 + src/common/constants/ipc-constants.ts | 1 + src/common/types/ipc.ts | 1 + src/desktop/preload.ts | 1 + src/node/services/ipcMain.ts | 24 ++++++++- 8 files changed, 69 insertions(+), 14 deletions(-) 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..5d99a5ea8 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -200,6 +200,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/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index 97756becd..31d4f2df5 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -21,6 +21,9 @@ 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 [isCreating, setIsCreating] = useState(false); const handleCancel = useCallback(() => { @@ -29,6 +32,18 @@ export const ProjectCreateModal: React.FC = ({ onClose(); }, [onClose]); + 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) { @@ -96,19 +111,31 @@ export const ProjectCreateModal: React.FC = ({ onClose={handleCancel} isLoading={isCreating} > - { - 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 && ( + + )} +
{error &&
{error}
} 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..0bb0ff589 100644 --- a/src/common/constants/ipc-constants.ts +++ b/src/common/constants/ipc-constants.ts @@ -9,6 +9,7 @@ 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", diff --git a/src/common/types/ipc.ts b/src/common/types/ipc.ts index 339da510d..e41246eba 100644 --- a/src/common/types/ipc.ts +++ b/src/common/types/ipc.ts @@ -248,6 +248,7 @@ export interface IPCApi { create( projectPath: string ): Promise>; + pickDirectory(): Promise; remove(projectPath: string): Promise>; list(): Promise>; listBranches(projectPath: string): Promise; diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index b8a910bd5..8e7873659 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -41,6 +41,7 @@ const api: IPCApi = { }, 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..29db4909a 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 { dialog, BrowserWindow, type IpcMain as ElectronIpcMain } from "electron"; import { spawn, spawnSync } from "child_process"; import * as fsPromises from "fs/promises"; import * as path from "path"; @@ -1367,6 +1367,28 @@ export class IpcMain { } private registerProjectHandlers(ipcMain: ElectronIpcMain): void { + ipcMain.handle(IPC_CHANNELS.PROJECT_PICK_DIRECTORY, async (event) => { + if (!event?.sender) { + return null; + } + + try { + 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]; + } 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) From c423339466910ade1163b1b5b232e870ae84cba0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Nov 2025 23:54:12 +0100 Subject: [PATCH 2/6] feat: add web directory picker for server projects --- src/browser/api.ts | 3 + .../components/DirectoryPickerModal.tsx | 110 +++++++++++++++++ src/browser/components/DirectoryTree.tsx | 64 ++++++++++ src/browser/components/ProjectCreateModal.tsx | 111 +++++++++++------- src/common/constants/ipc-constants.ts | 1 + src/common/types/ipc.ts | 4 + src/desktop/preload.ts | 4 + src/node/services/ipcMain.ts | 32 +++++ 8 files changed, 288 insertions(+), 41 deletions(-) create mode 100644 src/browser/components/DirectoryPickerModal.tsx create mode 100644 src/browser/components/DirectoryTree.tsx diff --git a/src/browser/api.ts b/src/browser/api.ts index 5d99a5ea8..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), diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx new file mode 100644 index 000000000..0886e0164 --- /dev/null +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -0,0 +1,110 @@ +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, +}) => { + 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); + 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 31d4f2df5..c331a7ced 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 { @@ -24,7 +26,10 @@ export const ProjectCreateModal: React.FC = ({ // 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(""); @@ -32,6 +37,14 @@ 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(); @@ -93,6 +106,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") { @@ -104,47 +125,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 w-full flex-1 rounded border px-3 py-2 font-mono text-sm text-white focus:outline-none disabled:opacity-50" - /> - {isDesktop && ( - - )} -
- {error &&
{error}
} - - - Cancel - - void handleSelect()} disabled={isCreating}> - {isCreating ? "Adding..." : "Add Project"} - - -
+ 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} + /> + ); }; diff --git a/src/common/constants/ipc-constants.ts b/src/common/constants/ipc-constants.ts index 0bb0ff589..4d7a2ac7d 100644 --- a/src/common/constants/ipc-constants.ts +++ b/src/common/constants/ipc-constants.ts @@ -15,6 +15,7 @@ export const IPC_CHANNELS = { 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 e41246eba..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,6 +245,9 @@ export interface IPCApi { ): Promise>; list(): Promise; }; + fs?: { + listDirectory(root: string): Promise; + }; projects: { create( projectPath: string diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 8e7873659..737e2b455 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -39,6 +39,10 @@ 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), diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 29db4909a..8c8d2d8e5 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -358,6 +358,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 +404,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); From 1a3348a73fa2db6712cd1620cb9136ed0435eee6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Wed, 19 Nov 2025 23:59:25 +0100 Subject: [PATCH 3/6] chore: format directory picker changes --- src/browser/components/DirectoryPickerModal.tsx | 6 +----- src/browser/components/ProjectCreateModal.tsx | 11 ++++------- src/desktop/preload.ts | 3 +-- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx index 0886e0164..b1f41099e 100644 --- a/src/browser/components/DirectoryPickerModal.tsx +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -75,7 +75,6 @@ export const DirectoryPickerModal: React.FC = ({ .filter((child) => child.isDirectory) .map((child) => ({ name: child.name, path: child.path })) ?? []; - return ( = ({ Cancel - void handleConfirm()} - disabled={isLoading || !root} - > + void handleConfirm()} disabled={isLoading || !root}> Select
diff --git a/src/browser/components/ProjectCreateModal.tsx b/src/browser/components/ProjectCreateModal.tsx index c331a7ced..6a7f51bec 100644 --- a/src/browser/components/ProjectCreateModal.tsx +++ b/src/browser/components/ProjectCreateModal.tsx @@ -37,13 +37,10 @@ export const ProjectCreateModal: React.FC = ({ onClose(); }, [onClose]); - const handleWebPickerPathSelected = useCallback( - (selected: string) => { - setPath(selected); - setError(""); - }, - [] - ); + const handleWebPickerPathSelected = useCallback((selected: string) => { + setPath(selected); + setError(""); + }, []); const handleBrowse = useCallback(async () => { try { diff --git a/src/desktop/preload.ts b/src/desktop/preload.ts index 737e2b455..5b0e5a371 100644 --- a/src/desktop/preload.ts +++ b/src/desktop/preload.ts @@ -40,8 +40,7 @@ const api: IPCApi = { list: () => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_LIST), }, fs: { - listDirectory: (root: string) => - ipcRenderer.invoke(IPC_CHANNELS.FS_LIST_DIRECTORY, root), + listDirectory: (root: string) => ipcRenderer.invoke(IPC_CHANNELS.FS_LIST_DIRECTORY, root), }, projects: { create: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, projectPath), From 47c77b3531131d4cb43ad20a6b7066d8408751bc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:16:26 +0100 Subject: [PATCH 4/6] refactor: inject project directory picker for desktop only --- src/desktop/main.ts | 15 ++++++++++- src/node/services/ipcMain.ts | 48 +++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 21 deletions(-) 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/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 8c8d2d8e5..0b9c4f036 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -1,5 +1,5 @@ import assert from "@/common/utils/assert"; -import { dialog, BrowserWindow, type IpcMain as ElectronIpcMain } 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,16 @@ 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. @@ -1399,27 +1411,23 @@ export class IpcMain { } private registerProjectHandlers(ipcMain: ElectronIpcMain): void { - ipcMain.handle(IPC_CHANNELS.PROJECT_PICK_DIRECTORY, async (event) => { - if (!event?.sender) { - return null; - } - - try { - 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", - }); + 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; + } - return res.canceled || res.filePaths.length === 0 ? null : res.filePaths[0]; - } catch (error) { - log.error("Failed to pick directory:", error); - 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 { From cb59ac30b40024498a208fc8ae9f1d3bda7a5d6e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Nov 2025 07:30:20 +0100 Subject: [PATCH 5/6] refactor: format ipcMain project directory picker --- src/node/services/ipcMain.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index 0b9c4f036..2efeb397e 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -101,9 +101,7 @@ export class IpcMain { * 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 { + setProjectDirectoryPicker(picker: (event: IpcMainInvokeEvent) => Promise): void { this.projectDirectoryPicker = picker; } From 0f1ada1c753dbaabe750cb62ca1a5ebaf5f25267 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 20 Nov 2025 07:47:50 +0100 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20handle=20directory=20?= =?UTF-8?q?picker=20errors=20in=20server=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with _ --- src/browser/components/DirectoryPickerModal.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/browser/components/DirectoryPickerModal.tsx b/src/browser/components/DirectoryPickerModal.tsx index b1f41099e..b05356993 100644 --- a/src/browser/components/DirectoryPickerModal.tsx +++ b/src/browser/components/DirectoryPickerModal.tsx @@ -17,6 +17,7 @@ export const DirectoryPickerModal: React.FC = ({ onClose, onSelectPath, }) => { + type FsListDirectoryResponse = FileTreeNode & { success?: boolean; error?: unknown }; const [root, setRoot] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -32,7 +33,19 @@ export const DirectoryPickerModal: React.FC = ({ setError(null); try { - const tree = await api.fs.listDirectory(path); + 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);