diff --git a/src/App.stories.tsx b/src/App.stories.tsx index 9cfcdce36..799620dde 100644 --- a/src/App.stories.tsx +++ b/src/App.stories.tsx @@ -19,9 +19,6 @@ function setupMockAPI(options: { const mockWorkspaces = options.workspaces ?? []; const mockApi: IPCApi = { - dialog: { - selectDirectory: () => Promise.resolve(null), - }, providers: { setProviderConfig: () => Promise.resolve({ success: true, data: undefined }), list: () => Promise.resolve([]), @@ -64,7 +61,11 @@ function setupMockAPI(options: { }, projects: { list: () => Promise.resolve(Array.from(mockProjects.entries())), - create: () => Promise.resolve({ success: true, data: { workspaces: [] } }), + create: () => + Promise.resolve({ + success: true, + data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project/path" }, + }), remove: () => Promise.resolve({ success: true, data: undefined }), listBranches: () => Promise.resolve({ diff --git a/src/App.tsx b/src/App.tsx index dbed6f47f..d7b1ea208 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import type { WorkspaceSelection } from "./components/ProjectSidebar"; import type { FrontendWorkspaceMetadata } from "./types/workspace"; import { LeftSidebar } from "./components/LeftSidebar"; import NewWorkspaceModal from "./components/NewWorkspaceModal"; -import { DirectorySelectModal } from "./components/DirectorySelectModal"; +import { ProjectCreateModal } from "./components/ProjectCreateModal"; import { AIView } from "./components/AIView"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState"; @@ -46,6 +46,7 @@ function AppInner() { selectedWorkspace, setSelectedWorkspace, } = useApp(); + const [projectCreateModalOpen, setProjectCreateModalOpen] = useState(false); const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false); const [workspaceModalProject, setWorkspaceModalProject] = useState(null); const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState(""); @@ -218,8 +219,8 @@ function AppInner() { // Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders const handleAddProjectCallback = useCallback(() => { - void addProject(); - }, [addProject]); + setProjectCreateModalOpen(true); + }, []); const handleAddWorkspaceCallback = useCallback( (projectPath: string) => { @@ -473,8 +474,8 @@ function AppInner() { ); const addProjectFromPalette = useCallback(() => { - void addProject(); - }, [addProject]); + setProjectCreateModalOpen(true); + }, []); const removeProjectFromPalette = useCallback( (path: string) => { @@ -694,7 +695,11 @@ function AppInner() { onAdd={handleCreateWorkspace} /> )} - + setProjectCreateModalOpen(false)} + onSuccess={addProject} + /> ); diff --git a/src/browser/api.ts b/src/browser/api.ts index 75e1c08e8..6a77917ab 100644 --- a/src/browser/api.ts +++ b/src/browser/api.ts @@ -184,27 +184,8 @@ class WebSocketManager { const wsManager = new WebSocketManager(); -// Directory selection via custom event (for browser mode) -interface DirectorySelectEvent extends CustomEvent { - detail: { - resolve: (path: string | null) => void; - }; -} - -function requestDirectorySelection(): Promise { - return new Promise((resolve) => { - const event = new CustomEvent("directory-select-request", { - detail: { resolve }, - }) as DirectorySelectEvent; - window.dispatchEvent(event); - }); -} - // Create the Web API implementation const webApi: IPCApi = { - dialog: { - selectDirectory: requestDirectorySelection, - }, providers: { setProviderConfig: (provider, keyPath, value) => invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), diff --git a/src/components/DirectorySelectModal.tsx b/src/components/DirectorySelectModal.tsx deleted file mode 100644 index d3b3a4dde..000000000 --- a/src/components/DirectorySelectModal.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; - -/** - * Self-contained directory selection modal for browser mode. - * - * Listens for 'directory-select-request' custom events and displays - * a modal for the user to enter a directory path. The promise from - * the event is resolved with the selected path or null if cancelled. - */ -export const DirectorySelectModal: React.FC = () => { - const [isOpen, setIsOpen] = useState(false); - const [path, setPath] = useState(""); - const [error, setError] = useState(""); - const resolveRef = useRef<((path: string | null) => void) | null>(null); - - // Listen for directory selection requests - useEffect(() => { - const handleDirectorySelectRequest = (e: Event) => { - const customEvent = e as CustomEvent<{ - resolve: (path: string | null) => void; - }>; - - resolveRef.current = customEvent.detail.resolve; - setPath(""); - setError(""); - setIsOpen(true); - }; - - window.addEventListener("directory-select-request", handleDirectorySelectRequest); - return () => { - window.removeEventListener("directory-select-request", handleDirectorySelectRequest); - }; - }, []); - - const handleCancel = useCallback(() => { - if (resolveRef.current) { - resolveRef.current(null); - resolveRef.current = null; - } - setIsOpen(false); - }, []); - - const handleSelect = useCallback(() => { - const trimmedPath = path.trim(); - if (!trimmedPath) { - setError("Please enter a directory path"); - return; - } - - if (resolveRef.current) { - resolveRef.current(trimmedPath); - resolveRef.current = null; - } - setIsOpen(false); - }, [path]); - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSelect(); - } - }, - [handleSelect] - ); - - return ( - - { - setPath(e.target.value); - setError(""); - }} - onKeyDown={handleKeyDown} - placeholder="/home/user/projects/my-project" - autoFocus - 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" - /> - {error &&
{error}
} - - Cancel - Select - -
- ); -}; diff --git a/src/components/ProjectCreateModal.tsx b/src/components/ProjectCreateModal.tsx new file mode 100644 index 000000000..6cb042678 --- /dev/null +++ b/src/components/ProjectCreateModal.tsx @@ -0,0 +1,123 @@ +import React, { useState, useCallback } from "react"; +import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal"; +import type { ProjectConfig } from "@/config"; + +interface ProjectCreateModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (normalizedPath: string, projectConfig: ProjectConfig) => void; +} + +/** + * Project creation modal that handles the full flow from path input to backend validation. + * + * Displays a modal for path input, calls the backend to create the project, and shows + * validation errors inline. Modal stays open until project is successfully created or user cancels. + */ +export const ProjectCreateModal: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const [path, setPath] = useState(""); + const [error, setError] = useState(""); + const [isCreating, setIsCreating] = useState(false); + + const handleCancel = useCallback(() => { + setPath(""); + setError(""); + onClose(); + }, [onClose]); + + const handleSelect = useCallback(async () => { + const trimmedPath = path.trim(); + if (!trimmedPath) { + setError("Please enter a directory path"); + return; + } + + setError(""); + setIsCreating(true); + + try { + // First check if project already exists + const existingProjects = await window.api.projects.list(); + const existingPaths = new Map(existingProjects); + + // Try to create the project + const result = await window.api.projects.create(trimmedPath); + + if (result.success) { + // Check if duplicate (backend may normalize the path) + const { normalizedPath, projectConfig } = result.data as { + normalizedPath: string; + projectConfig: ProjectConfig; + }; + if (existingPaths.has(normalizedPath)) { + setError("This project has already been added."); + return; + } + + // Success - notify parent and close + onSuccess(normalizedPath, projectConfig); + setPath(""); + setError(""); + onClose(); + } else { + // Backend validation error - show inline, keep modal open + const errorMessage = + typeof result.error === "string" ? result.error : "Failed to add project"; + setError(errorMessage); + } + } catch (err) { + // Unexpected error + const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred"; + setError(`Failed to add project: ${errorMessage}`); + } finally { + setIsCreating(false); + } + }, [path, onSuccess, onClose]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleSelect(); + } + }, + [handleSelect] + ); + + 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" + /> + {error &&
{error}
} + + + Cancel + + void handleSelect()} disabled={isCreating}> + {isCreating ? "Adding..." : "Add Project"} + + +
+ ); +}; diff --git a/src/constants/ipc-constants.ts b/src/constants/ipc-constants.ts index ce3a3ffb0..b5d35489f 100644 --- a/src/constants/ipc-constants.ts +++ b/src/constants/ipc-constants.ts @@ -4,9 +4,6 @@ */ export const IPC_CHANNELS = { - // Dialog channels - DIALOG_SELECT_DIR: "dialog:selectDirectory", - // Provider channels PROVIDERS_SET_CONFIG: "providers:setConfig", PROVIDERS_LIST: "providers:list", diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx index 5663dea1f..f14600f73 100644 --- a/src/contexts/AppContext.tsx +++ b/src/contexts/AppContext.tsx @@ -13,7 +13,7 @@ interface AppContextType { // Projects projects: Map; setProjects: Dispatch>>; - addProject: () => Promise; + addProject: (normalizedPath: string, projectConfig: ProjectConfig) => void; removeProject: (path: string) => Promise; // Workspaces diff --git a/src/hooks/useProjectManagement.ts b/src/hooks/useProjectManagement.ts index 3fd2ccffd..2ad842b0a 100644 --- a/src/hooks/useProjectManagement.ts +++ b/src/hooks/useProjectManagement.ts @@ -13,12 +13,8 @@ export function useProjectManagement() { const loadProjects = async () => { try { - console.log("Loading projects..."); const projectsList = await window.api.projects.list(); - console.log("Received projects:", projectsList); - const projectsMap = new Map(projectsList); - console.log("Created projects map, size:", projectsMap.size); setProjects(projectsMap); } catch (error) { console.error("Failed to load projects:", error); @@ -26,28 +22,15 @@ export function useProjectManagement() { } }; - const addProject = useCallback(async () => { - try { - const selectedPath = await window.api.dialog.selectDirectory(); - if (!selectedPath) return; - - if (projects.has(selectedPath)) { - console.log("Project already exists:", selectedPath); - return; - } - - const result = await window.api.projects.create(selectedPath); - if (result.success) { - const newProjects = new Map(projects); - newProjects.set(selectedPath, result.data); - setProjects(newProjects); - } else { - console.error("Failed to create project:", result.error); - } - } catch (error) { - console.error("Failed to add project:", error); - } - }, [projects]); + const addProject = useCallback( + (normalizedPath: string, projectConfig: ProjectConfig) => { + // Add successfully created project to local state + const newProjects = new Map(projects); + newProjects.set(normalizedPath, projectConfig); + setProjects(newProjects); + }, + [projects] + ); const removeProject = useCallback( async (path: string) => { @@ -59,8 +42,7 @@ export function useProjectManagement() { setProjects(newProjects); } else { console.error("Failed to remove project:", result.error); - // Show error to user - they might need to remove workspaces first - alert(result.error); + // TODO: Show error to user in UI - they might need to remove workspaces first } } catch (error) { console.error("Failed to remove project:", error); diff --git a/src/preload.ts b/src/preload.ts index dfb2ad6b7..c767ced5f 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -26,9 +26,6 @@ import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants"; // Build the API implementation using the shared interface const api: IPCApi = { - dialog: { - selectDirectory: () => ipcRenderer.invoke(IPC_CHANNELS.DIALOG_SELECT_DIR), - }, providers: { setProviderConfig: (provider, keyPath, value) => ipcRenderer.invoke(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value), diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index 1a195fe96..440bb7009 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -26,6 +26,7 @@ import { BashExecutionService } from "@/services/bashExecutionService"; import { InitStateManager } from "@/services/initStateManager"; import { createRuntime } from "@/runtime/runtimeFactory"; import type { RuntimeConfig } from "@/types/runtime"; +import { validateProjectPath } from "@/utils/pathUtils"; /** * IpcMain - Manages all IPC handlers and service coordination * @@ -216,7 +217,6 @@ export class IpcMain { return; } - this.registerDialogHandlers(ipcMain); this.registerWindowHandlers(ipcMain); this.registerWorkspaceHandlers(ipcMain); this.registerProviderHandlers(ipcMain); @@ -225,26 +225,6 @@ export class IpcMain { this.registered = true; } - private registerDialogHandlers(ipcMain: ElectronIpcMain): void { - ipcMain.handle(IPC_CHANNELS.DIALOG_SELECT_DIR, async () => { - if (!this.mainWindow) return null; - - // Dynamic import to avoid issues with electron mocks in tests - // eslint-disable-next-line no-restricted-syntax - const { dialog } = await import("electron"); - - const result = await dialog.showOpenDialog(this.mainWindow, { - properties: ["openDirectory"], - }); - - if (result.canceled) { - return null; - } - - return result.filePaths[0]; - }); - } - private registerWindowHandlers(ipcMain: ElectronIpcMain): void { ipcMain.handle(IPC_CHANNELS.WINDOW_SET_TITLE, (_event, title: string) => { if (!this.mainWindow) return; @@ -1179,12 +1159,21 @@ export class IpcMain { } private registerProjectHandlers(ipcMain: ElectronIpcMain): void { - ipcMain.handle(IPC_CHANNELS.PROJECT_CREATE, (_event, projectPath: string) => { + ipcMain.handle(IPC_CHANNELS.PROJECT_CREATE, async (_event, projectPath: string) => { try { + // Validate and expand path (handles tilde, checks existence and directory status) + const validation = await validateProjectPath(projectPath); + if (!validation.valid) { + return Err(validation.error ?? "Invalid project path"); + } + + // Use the expanded/normalized path + const normalizedPath = validation.expandedPath!; + const config = this.config.loadConfigOrDefault(); - // Check if project already exists - if (config.projects.has(projectPath)) { + // Check if project already exists (using normalized path) + if (config.projects.has(normalizedPath)) { return Err("Project already exists"); } @@ -1193,11 +1182,12 @@ export class IpcMain { workspaces: [], }; - // Add to config - config.projects.set(projectPath, projectConfig); + // Add to config with normalized path + config.projects.set(normalizedPath, projectConfig); this.config.saveConfig(config); - return Ok(projectConfig); + // Return both the config and the normalized path so frontend can use it + return Ok({ projectConfig, normalizedPath }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return Err(`Failed to create project: ${message}`); @@ -1256,8 +1246,15 @@ export class IpcMain { } try { - const branches = await listLocalBranches(projectPath); - const recommendedTrunk = await detectDefaultTrunkBranch(projectPath, branches); + // Validate and expand path (handles tilde) + const validation = await validateProjectPath(projectPath); + if (!validation.valid) { + throw new Error(validation.error ?? "Invalid project path"); + } + + const normalizedPath = validation.expandedPath!; + const branches = await listLocalBranches(normalizedPath); + const recommendedTrunk = await detectDefaultTrunkBranch(normalizedPath, branches); return { branches, recommendedTrunk }; } catch (error) { log.error("Failed to list branches:", error); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 7ae90ee34..d0c8c485c 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -200,9 +200,6 @@ export interface SendMessageOptions { // Minimize the number of methods - use optional parameters for operation variants // (e.g. remove(id, force?) not remove(id) + removeForce(id)). export interface IPCApi { - dialog: { - selectDirectory(): Promise; - }; providers: { setProviderConfig( provider: string, @@ -212,7 +209,9 @@ export interface IPCApi { list(): Promise; }; projects: { - create(projectPath: string): Promise>; + create( + projectPath: string + ): Promise>; remove(projectPath: string): Promise>; list(): Promise>; listBranches(projectPath: string): Promise; diff --git a/src/utils/pathUtils.test.ts b/src/utils/pathUtils.test.ts new file mode 100644 index 000000000..2f8d86e7b --- /dev/null +++ b/src/utils/pathUtils.test.ts @@ -0,0 +1,133 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { expandTilde, validateProjectPath } from "./pathUtils"; + +describe("pathUtils", () => { + describe("expandTilde", () => { + it("should expand ~ to home directory", () => { + const result = expandTilde("~/Documents"); + const expected = path.join(os.homedir(), "Documents"); + expect(result).toBe(expected); + }); + + it("should expand ~/ to home directory with trailing path", () => { + const result = expandTilde("~/Projects/my-app"); + const expected = path.join(os.homedir(), "Projects", "my-app"); + expect(result).toBe(expected); + }); + + it("should return path unchanged if it doesn't start with ~", () => { + const testPath = "/absolute/path/to/project"; + const result = expandTilde(testPath); + expect(result).toBe(testPath); + }); + + it("should handle ~ alone (home directory)", () => { + const result = expandTilde("~"); + expect(result).toBe(os.homedir()); + }); + + it("should handle relative paths without tilde", () => { + const relativePath = "relative/path"; + const result = expandTilde(relativePath); + expect(result).toBe(relativePath); + }); + + it("should handle empty string", () => { + const result = expandTilde(""); + expect(result).toBe(""); + }); + }); + + describe("validateProjectPath", () => { + let tempDir: string; + + beforeEach(() => { + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-path-test-")); + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("should return success for existing git directory", async () => { + // Create .git directory + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup only + fs.mkdirSync(path.join(tempDir, ".git")); + const result = await validateProjectPath(tempDir); + expect(result.valid).toBe(true); + expect(result.expandedPath).toBe(tempDir); + expect(result.error).toBeUndefined(); + }); + + it("should expand tilde and validate", async () => { + // Create a test directory in home with .git + const testDir = path.join(os.homedir(), `cmux-test-git-${Date.now()}`); + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup only + fs.mkdirSync(testDir, { recursive: true }); + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup only + fs.mkdirSync(path.join(testDir, ".git")); + + const result = await validateProjectPath(`~/${path.basename(testDir)}`); + expect(result.valid).toBe(true); + expect(result.expandedPath).toBe(testDir); + expect(result.error).toBeUndefined(); + + // Cleanup + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it("should return error for non-existent path", async () => { + const nonExistentPath = "/this/path/definitely/does/not/exist/cmux-test-12345"; + const result = await validateProjectPath(nonExistentPath); + expect(result.valid).toBe(false); + expect(result.error).toContain("does not exist"); + }); + + it("should return error for file path (not directory)", async () => { + const filePath = path.join(tempDir, "test-file.txt"); + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup only + fs.writeFileSync(filePath, "test content"); + + const result = await validateProjectPath(filePath); + expect(result.valid).toBe(false); + expect(result.error).toContain("not a directory"); + }); + + it("should handle tilde path to non-existent directory", async () => { + const nonExistentTildePath = "~/this-directory-should-not-exist-cmux-test-12345"; + const result = await validateProjectPath(nonExistentTildePath); + expect(result.valid).toBe(false); + expect(result.error).toContain("does not exist"); + }); + + it("should return normalized absolute path", async () => { + const pathWithDots = path.join(tempDir, "..", path.basename(tempDir)); + // Add .git directory for validation + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup only + fs.mkdirSync(path.join(tempDir, ".git")); + const result = await validateProjectPath(pathWithDots); + expect(result.valid).toBe(true); + expect(result.expandedPath).toBe(tempDir); + }); + + it("should reject directory without .git", async () => { + const result = await validateProjectPath(tempDir); + expect(result.valid).toBe(false); + expect(result.error).toContain("Not a git repository"); + }); + + it("should accept directory with .git", async () => { + const gitDir = path.join(tempDir, ".git"); + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup only + fs.mkdirSync(gitDir); + + const result = await validateProjectPath(tempDir); + expect(result.valid).toBe(true); + expect(result.expandedPath).toBe(tempDir); + }); + }); +}); diff --git a/src/utils/pathUtils.ts b/src/utils/pathUtils.ts new file mode 100644 index 000000000..4514280d6 --- /dev/null +++ b/src/utils/pathUtils.ts @@ -0,0 +1,104 @@ +import * as fs from "fs/promises"; +import * as os from "os"; +import * as path from "path"; + +/** + * Result of path validation + */ +export interface PathValidationResult { + valid: boolean; + expandedPath?: string; + error?: string; +} + +/** + * Expand tilde (~) in paths to the user's home directory + * + * @param inputPath - Path that may contain tilde + * @returns Path with tilde expanded to home directory + * + * @example + * expandTilde("~/Documents") // => "/home/user/Documents" + * expandTilde("~") // => "/home/user" + * expandTilde("/absolute/path") // => "/absolute/path" + */ +export function expandTilde(inputPath: string): string { + if (!inputPath) { + return inputPath; + } + + if (inputPath === "~") { + return os.homedir(); + } + + if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) { + return path.join(os.homedir(), inputPath.slice(2)); + } + + return inputPath; +} + +/** + * Validate that a project path exists, is a directory, and is a git repository + * Automatically expands tilde and normalizes the path + * + * @param inputPath - Path to validate (may contain tilde) + * @returns Validation result with expanded path or error + * + * @example + * await validateProjectPath("~/my-project") + * // => { valid: true, expandedPath: "/home/user/my-project" } + * + * await validateProjectPath("~/nonexistent") + * // => { valid: false, error: "Path does not exist: /home/user/nonexistent" } + * + * await validateProjectPath("~/not-a-git-repo") + * // => { valid: false, error: "Not a git repository: /home/user/not-a-git-repo" } + */ +export async function validateProjectPath(inputPath: string): Promise { + // Expand tilde if present + const expandedPath = expandTilde(inputPath); + + // Normalize to resolve any .. or . in the path + const normalizedPath = path.normalize(expandedPath); + + // Check if path exists + try { + const stats = await fs.stat(normalizedPath); + + // Check if it's a directory + if (!stats.isDirectory()) { + return { + valid: false, + error: `Path is not a directory: ${normalizedPath}`, + }; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return { + valid: false, + error: `Path does not exist: ${normalizedPath}`, + }; + } + throw err; + } + + // Check if it's a git repository + const gitPath = path.join(normalizedPath, ".git"); + try { + await fs.stat(gitPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return { + valid: false, + error: `Not a git repository: ${normalizedPath}`, + }; + } + throw err; + } + + return { + valid: true, + expandedPath: normalizedPath, + }; +} diff --git a/tests/ipcMain/projectCreate.test.ts b/tests/ipcMain/projectCreate.test.ts new file mode 100644 index 000000000..89e58d14a --- /dev/null +++ b/tests/ipcMain/projectCreate.test.ts @@ -0,0 +1,177 @@ +/** + * Integration tests for PROJECT_CREATE IPC handler + * + * Tests: + * - Tilde expansion in project paths + * - Path validation (existence, directory check) + * - Prevention of adding non-existent projects + */ + +import * as fs from "fs/promises"; +import * as path from "path"; +import * as os from "os"; +import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "./setup"; +import type { TestEnvironment } from "./setup"; +import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("PROJECT_CREATE IPC Handler", () => { + test.concurrent("should expand tilde in project path and create project", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + // Create a test directory in home directory + const testDirName = `cmux-test-tilde-${Date.now()}`; + const homeProjectPath = path.join(os.homedir(), testDirName); + await fs.mkdir(homeProjectPath, { recursive: true }); + // Create .git directory to make it a valid git repo + await fs.mkdir(path.join(homeProjectPath, ".git")); + + try { + // Try to create project with tilde path + const tildeProjectPath = `~/${testDirName}`; + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.PROJECT_CREATE, + tildeProjectPath + ); + + // Should succeed + expect(result.success).toBe(true); + expect(result.data.normalizedPath).toBe(homeProjectPath); + + // Verify the project was added with expanded path (not tilde path) + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); + const projectPaths = projectsList.map((p: [string, unknown]) => p[0]); + + // Should contain the expanded path + expect(projectPaths).toContain(homeProjectPath); + // Should NOT contain the tilde path + expect(projectPaths).not.toContain(tildeProjectPath); + } finally { + // Clean up test directory + await fs.rm(homeProjectPath, { recursive: true, force: true }); + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + } + }); + + test.concurrent("should reject non-existent project path", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + const nonExistentPath = "/this/path/definitely/does/not/exist/cmux-test-12345"; + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, nonExistentPath); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not exist"); + + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + }); + + test.concurrent("should reject non-existent tilde path", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + const nonExistentTildePath = "~/this-directory-should-not-exist-cmux-test-12345"; + const result = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.PROJECT_CREATE, + nonExistentTildePath + ); + + expect(result.success).toBe(false); + expect(result.error).toContain("does not exist"); + + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + }); + + test.concurrent("should reject file path (not a directory)", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + const testFile = path.join(tempProjectDir, "test-file.txt"); + await fs.writeFile(testFile, "test content"); + + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, testFile); + + expect(result.success).toBe(false); + expect(result.error).toContain("not a directory"); + + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + }); + + test.concurrent("should reject directory without .git", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempProjectDir); + + expect(result.success).toBe(false); + expect(result.error).toContain("Not a git repository"); + + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + }); + + test.concurrent("should accept valid absolute path", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + // Create .git directory to make it a valid git repo + await fs.mkdir(path.join(tempProjectDir, ".git")); + + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempProjectDir); + + expect(result.success).toBe(true); + expect(result.data.normalizedPath).toBe(tempProjectDir); + + // Verify project was added + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); + const projectPaths = projectsList.map((p: [string, unknown]) => p[0]); + expect(projectPaths).toContain(tempProjectDir); + + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + }); + + test.concurrent("should normalize paths with .. in them", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + // Create .git directory to make it a valid git repo + await fs.mkdir(path.join(tempProjectDir, ".git")); + + // Create a path with .. that resolves to tempProjectDir + const pathWithDots = path.join(tempProjectDir, "..", path.basename(tempProjectDir)); + const result = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, pathWithDots); + + expect(result.success).toBe(true); + expect(result.data.normalizedPath).toBe(tempProjectDir); + + // Verify project was added with normalized path + const projectsList = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST); + const projectPaths = projectsList.map((p: [string, unknown]) => p[0]); + expect(projectPaths).toContain(tempProjectDir); + + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + }); + + test.concurrent("should reject duplicate projects (same expanded path)", async () => { + const env = await createTestEnvironment(); + const tempProjectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cmux-project-test-")); + // Create .git directory to make it a valid git repo + await fs.mkdir(path.join(tempProjectDir, ".git")); + + // Create first project + const result1 = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempProjectDir); + expect(result1.success).toBe(true); + + // Try to create the same project with a path that has .. + const pathWithDots = path.join(tempProjectDir, "..", path.basename(tempProjectDir)); + const result2 = await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, pathWithDots); + + expect(result2.success).toBe(false); + expect(result2.error).toContain("already exists"); + + await cleanupTestEnvironment(env); + await fs.rm(tempProjectDir, { recursive: true, force: true }); + }); +});