diff --git a/eslint.config.mjs b/eslint.config.mjs index 58d5b8f27..de834af5f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -329,6 +329,7 @@ export default defineConfig([ "src/debug/**/*.ts", "src/git.ts", "src/main.ts", + "src/config.test.ts", "src/services/gitService.ts", "src/services/log.ts", "src/services/streamManager.ts", diff --git a/src/App.tsx b/src/App.tsx index 8a602fc0b..2c5fe7b29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import { GlobalFonts } from "./styles/fonts"; import { GlobalScrollbars } from "./styles/scrollbars"; import type { ProjectConfig } from "./config"; import type { WorkspaceSelection } from "./components/ProjectSidebar"; +import type { FrontendWorkspaceMetadata } from "./types/workspace"; import { LeftSidebar } from "./components/LeftSidebar"; import NewWorkspaceModal from "./components/NewWorkspaceModal"; import { AIView } from "./components/AIView"; @@ -172,12 +173,17 @@ function AppInner() { [setProjects] ); - const { workspaceMetadata, createWorkspace, removeWorkspace, renameWorkspace } = - useWorkspaceManagement({ - selectedWorkspace, - onProjectsUpdate: handleProjectsUpdate, - onSelectedWorkspaceUpdate: setSelectedWorkspace, - }); + const { + workspaceMetadata, + loading: metadataLoading, + createWorkspace, + removeWorkspace, + renameWorkspace, + } = useWorkspaceManagement({ + selectedWorkspace, + onProjectsUpdate: handleProjectsUpdate, + onSelectedWorkspaceUpdate: setSelectedWorkspace, + }); // NEW: Sync workspace metadata with the stores const workspaceStore = useWorkspaceStoreRaw(); @@ -215,8 +221,10 @@ function AppInner() { window.history.replaceState(null, "", newHash); } - // Update window title - const title = `${selectedWorkspace.workspaceId} - ${selectedWorkspace.projectName} - cmux`; + // Update window title with workspace name + const workspaceName = + workspaceMetadata.get(selectedWorkspace.workspaceId)?.name ?? selectedWorkspace.workspaceId; + const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`; void window.api.window.setTitle(title); } else { // Clear hash when no workspace selected @@ -225,42 +233,80 @@ function AppInner() { } void window.api.window.setTitle("cmux"); } - }, [selectedWorkspace]); + }, [selectedWorkspace, workspaceMetadata]); // Restore workspace from URL on mount (if valid) + // This effect runs once on mount to restore from hash, which takes priority over localStorage + const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false); + useEffect(() => { + // Only run once + if (hasRestoredFromHash) return; + + // Wait for metadata to finish loading + if (metadataLoading) return; + const hash = window.location.hash; if (hash.startsWith("#workspace=")) { const workspaceId = decodeURIComponent(hash.substring("#workspace=".length)); // Find workspace in metadata - const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId); + const metadata = workspaceMetadata.get(workspaceId); if (metadata) { - // Find project for this workspace - for (const [projectPath, projectConfig] of projects.entries()) { - const workspace = (projectConfig.workspaces ?? []).find( - (ws) => ws.path === metadata.workspacePath - ); - if (workspace) { - setSelectedWorkspace({ - workspaceId: metadata.id, - projectPath, - projectName: metadata.projectName, - workspacePath: metadata.workspacePath, - }); - break; - } + // Restore from hash (overrides localStorage) + setSelectedWorkspace({ + workspaceId: metadata.id, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + namedWorkspacePath: metadata.namedWorkspacePath, + }); + } + } + + setHasRestoredFromHash(true); + }, [metadataLoading, workspaceMetadata, hasRestoredFromHash, setSelectedWorkspace]); + + // Validate selected workspace exists and has all required fields + useEffect(() => { + // Don't validate until metadata is loaded + if (metadataLoading) return; + + if (selectedWorkspace) { + const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId); + + if (!metadata) { + // Workspace was deleted + console.warn( + `Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection` + ); + setSelectedWorkspace(null); + if (window.location.hash) { + window.history.replaceState(null, "", window.location.pathname); } + } else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) { + // Old localStorage entry missing namedWorkspacePath - update it once + console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`); + setSelectedWorkspace({ + workspaceId: metadata.id, + projectPath: metadata.projectPath, + projectName: metadata.projectName, + namedWorkspacePath: metadata.namedWorkspacePath, + }); } } - // Only run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [metadataLoading, selectedWorkspace, workspaceMetadata, setSelectedWorkspace]); - const openWorkspaceInTerminal = useCallback((workspacePath: string) => { - void window.api.workspace.openTerminal(workspacePath); - }, []); + const openWorkspaceInTerminal = useCallback( + (workspaceId: string) => { + // Look up workspace metadata to get the named path (user-friendly symlink) + const metadata = workspaceMetadata.get(workspaceId); + if (metadata) { + void window.api.workspace.openTerminal(metadata.namedWorkspacePath); + } + }, + [workspaceMetadata] + ); const handleRemoveProject = useCallback( async (path: string) => { @@ -364,33 +410,39 @@ function AppInner() { const workspaceRecency = useWorkspaceRecency(); // Sort workspaces by recency (most recent first) + // Returns Map for direct component use // Use stable reference to prevent sidebar re-renders when sort order hasn't changed const sortedWorkspacesByProject = useStableReference( () => { - const result = new Map(); + const result = new Map(); for (const [projectPath, config] of projects) { - result.set( - projectPath, - (config.workspaces ?? []).slice().sort((a, b) => { - const aMeta = workspaceMetadata.get(a.path); - const bMeta = workspaceMetadata.get(b.path); - if (!aMeta || !bMeta) return 0; - - // Get timestamp of most recent user message (0 if never used) - const aTimestamp = workspaceRecency[aMeta.id] ?? 0; - const bTimestamp = workspaceRecency[bMeta.id] ?? 0; - return bTimestamp - aTimestamp; - }) - ); + // Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID + const metadataList = config.workspaces + .map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined)) + .filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null); + + // Sort by recency + metadataList.sort((a, b) => { + const aTimestamp = workspaceRecency[a.id] ?? 0; + const bTimestamp = workspaceRecency[b.id] ?? 0; + return bTimestamp - aTimestamp; + }); + + result.set(projectPath, metadataList); } return result; }, (prev, next) => { - // Compare Maps: check if both size and workspace order are the same + // Compare Maps: check if size, workspace order, and metadata content are the same if ( !compareMaps(prev, next, (a, b) => { if (a.length !== b.length) return false; - return a.every((workspace, i) => workspace.path === b[i].path); + // Check both ID and name to detect renames + return a.every((metadata, i) => { + const bMeta = b[i]; + if (!bMeta || !metadata) return false; // Null-safe + return metadata.id === bMeta.id && metadata.name === bMeta.name; + }); }) ) { return false; @@ -410,7 +462,7 @@ function AppInner() { // Find current workspace index in sorted list const currentIndex = sortedWorkspaces.findIndex( - (ws) => ws.path === selectedWorkspace.workspacePath + (metadata) => metadata.id === selectedWorkspace.workspaceId ); if (currentIndex === -1) return; @@ -422,20 +474,17 @@ function AppInner() { targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1; } - const targetWorkspace = sortedWorkspaces[targetIndex]; - if (!targetWorkspace) return; - - const metadata = workspaceMetadata.get(targetWorkspace.path); - if (!metadata) return; + const targetMetadata = sortedWorkspaces[targetIndex]; + if (!targetMetadata) return; setSelectedWorkspace({ projectPath: selectedWorkspace.projectPath, projectName: selectedWorkspace.projectName, - workspacePath: targetWorkspace.path, - workspaceId: metadata.id, + namedWorkspacePath: targetMetadata.namedWorkspacePath, + workspaceId: targetMetadata.id, }); }, - [selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace] + [selectedWorkspace, sortedWorkspacesByProject, setSelectedWorkspace] ); // Register command sources with registry @@ -534,12 +583,7 @@ function AppInner() { ); const selectWorkspaceFromPalette = useCallback( - (selection: { - projectPath: string; - projectName: string; - workspacePath: string; - workspaceId: string; - }) => { + (selection: WorkspaceSelection) => { setSelectedWorkspace(selection); }, [setSelectedWorkspace] @@ -679,20 +723,19 @@ function AppInner() { /> - {selectedWorkspace?.workspacePath ? ( + {selectedWorkspace ? ( ) : ( diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 5bfa32bfd..1fc21b1d3 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -193,7 +193,7 @@ interface AIViewProps { workspaceId: string; projectName: string; branch: string; - workspacePath: string; + namedWorkspacePath: string; // User-friendly path for display and terminal className?: string; } @@ -201,7 +201,7 @@ const AIViewInner: React.FC = ({ workspaceId, projectName, branch, - workspacePath, + namedWorkspacePath, className, }) => { const chatAreaRef = useRef(null); @@ -311,8 +311,8 @@ const AIViewInner: React.FC = ({ ); const handleOpenTerminal = useCallback(() => { - void window.api.workspace.openTerminal(workspacePath); - }, [workspacePath]); + void window.api.workspace.openTerminal(namedWorkspacePath); + }, [namedWorkspacePath]); // Auto-scroll when messages update (during streaming) useEffect(() => { @@ -443,7 +443,7 @@ const AIViewInner: React.FC = ({ tooltipPosition="bottom" /> {projectName} / {branch} - {workspacePath} + {namedWorkspacePath} diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx index 3cffe88be..06a74c093 100644 --- a/src/components/LeftSidebar.tsx +++ b/src/components/LeftSidebar.tsx @@ -1,7 +1,7 @@ import React from "react"; import styled from "@emotion/styled"; import type { ProjectConfig } from "@/config"; -import type { WorkspaceMetadata } from "@/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceSelection } from "./ProjectSidebar"; import type { Secret } from "@/types/secrets"; import ProjectSidebar from "./ProjectSidebar"; @@ -21,7 +21,7 @@ const LeftSidebarContainer = styled.div<{ collapsed?: boolean }>` interface LeftSidebarProps { projects: Map; - workspaceMetadata: Map; + workspaceMetadata: Map; selectedWorkspace: WorkspaceSelection | null; onSelectWorkspace: (selection: WorkspaceSelection) => void; onAddProject: () => void; @@ -41,7 +41,7 @@ interface LeftSidebarProps { onToggleCollapsed: () => void; onGetSecrets: (projectPath: string) => Promise; onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise; - sortedWorkspacesByProject: Map; + sortedWorkspacesByProject: Map; } export function LeftSidebar(props: LeftSidebarProps) { diff --git a/src/components/ProjectSidebar.tsx b/src/components/ProjectSidebar.tsx index 6c7adbbf0..630170ee5 100644 --- a/src/components/ProjectSidebar.tsx +++ b/src/components/ProjectSidebar.tsx @@ -2,8 +2,8 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import styled from "@emotion/styled"; import { css } from "@emotion/react"; -import type { ProjectConfig, Workspace } from "@/config"; -import type { WorkspaceMetadata } from "@/types/workspace"; +import type { ProjectConfig } from "@/config"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import { usePersistedState } from "@/hooks/usePersistedState"; import { DndProvider } from "react-dnd"; import { HTML5Backend, getEmptyImage } from "react-dnd-html5-backend"; @@ -471,7 +471,7 @@ const ProjectDragLayer: React.FC = () => { interface ProjectSidebarProps { projects: Map; - workspaceMetadata: Map; + workspaceMetadata: Map; selectedWorkspace: WorkspaceSelection | null; onSelectWorkspace: (selection: WorkspaceSelection) => void; onAddProject: () => void; @@ -491,12 +491,11 @@ interface ProjectSidebarProps { onToggleCollapsed: () => void; onGetSecrets: (projectPath: string) => Promise; onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise; - sortedWorkspacesByProject: Map; + sortedWorkspacesByProject: Map; } const ProjectSidebarInner: React.FC = ({ projects, - workspaceMetadata, selectedWorkspace, onSelectWorkspace, onAddProject, @@ -820,23 +819,13 @@ const ProjectSidebarInner: React.FC = ({ ` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`} - {( - sortedWorkspacesByProject.get(projectPath) ?? - config.workspaces ?? - [] - ).map((workspace) => { - const metadata = workspaceMetadata.get(workspace.path); - if (!metadata) return null; - - const workspaceId = metadata.id; - const isSelected = - selectedWorkspace?.workspacePath === workspace.path; + {sortedWorkspacesByProject.get(projectPath)?.map((metadata) => { + const isSelected = selectedWorkspace?.workspaceId === metadata.id; return ( = ({ - workspaceId, - workspacePath, + metadata, projectPath, projectName, isSelected, @@ -156,6 +149,8 @@ const WorkspaceListItemInner: React.FC = ({ onRemoveWorkspace, onToggleUnread, }) => { + // Destructure metadata for convenience + const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; // Subscribe to this specific workspace's sidebar state (streaming status, model, recency) const sidebarState = useWorkspaceSidebarState(workspaceId); const gitStatus = useGitStatus(workspaceId); @@ -167,7 +162,8 @@ const WorkspaceListItemInner: React.FC = ({ const [editingName, setEditingName] = useState(""); const [renameError, setRenameError] = useState(null); - const displayName = getWorkspaceDisplayName(workspacePath); + // Use workspace name from metadata instead of deriving from path + const displayName = workspaceName; const isStreaming = sidebarState.canInterrupt; const streamingModel = sidebarState.currentModel; const isEditing = editingWorkspaceId === workspaceId; @@ -250,7 +246,7 @@ const WorkspaceListItemInner: React.FC = ({ onSelectWorkspace({ projectPath, projectName, - workspacePath, + namedWorkspacePath, workspaceId, }) } @@ -260,7 +256,7 @@ const WorkspaceListItemInner: React.FC = ({ onSelectWorkspace({ projectPath, projectName, - workspacePath, + namedWorkspacePath, workspaceId, }); } @@ -268,7 +264,7 @@ const WorkspaceListItemInner: React.FC = ({ role="button" tabIndex={0} aria-current={isSelected ? "true" : undefined} - data-workspace-path={workspacePath} + data-workspace-path={namedWorkspacePath} data-workspace-id={workspaceId} > diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 000000000..23bac50ab --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,124 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Config } from "./config"; + +describe("Config", () => { + let tempDir: string; + let config: Config; + + beforeEach(() => { + // Create a temporary directory for each test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cmux-test-")); + config = new Config(tempDir); + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("generateStableId", () => { + it("should generate a 10-character hex string", () => { + const id = config.generateStableId(); + expect(id).toMatch(/^[0-9a-f]{10}$/); + }); + + it("should generate unique IDs", () => { + const id1 = config.generateStableId(); + const id2 = config.generateStableId(); + const id3 = config.generateStableId(); + + expect(id1).not.toBe(id2); + expect(id2).not.toBe(id3); + expect(id1).not.toBe(id3); + }); + }); + + describe("getAllWorkspaceMetadata with migration", () => { + it("should migrate legacy workspace without metadata file", () => { + const projectPath = "/fake/project"; + const workspacePath = path.join(config.srcDir, "project", "feature-branch"); + + // Create workspace directory + fs.mkdirSync(workspacePath, { recursive: true }); + + // Add workspace to config without metadata file + config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [{ path: workspacePath }], + }); + return cfg; + }); + + // Get all metadata (should trigger migration) + const allMetadata = config.getAllWorkspaceMetadata(); + + expect(allMetadata).toHaveLength(1); + const metadata = allMetadata[0]; + expect(metadata.id).toBe("project-feature-branch"); // Legacy ID format + expect(metadata.name).toBe("feature-branch"); + expect(metadata.projectName).toBe("project"); + expect(metadata.projectPath).toBe(projectPath); + + // Verify metadata was migrated to config + const configData = config.loadConfigOrDefault(); + const projectConfig = configData.projects.get(projectPath); + expect(projectConfig).toBeDefined(); + expect(projectConfig!.workspaces).toHaveLength(1); + const workspace = projectConfig!.workspaces[0]; + expect(workspace.id).toBe("project-feature-branch"); + expect(workspace.name).toBe("feature-branch"); + }); + + it("should use existing metadata file if present (legacy format)", () => { + const projectPath = "/fake/project"; + const workspaceName = "my-feature"; + const workspacePath = path.join(config.srcDir, "project", workspaceName); + + // Create workspace directory + fs.mkdirSync(workspacePath, { recursive: true }); + + // Create metadata file using legacy ID format (project-workspace) + const legacyId = config.generateWorkspaceId(projectPath, workspacePath); + const sessionDir = config.getSessionDir(legacyId); + fs.mkdirSync(sessionDir, { recursive: true }); + const metadataPath = path.join(sessionDir, "metadata.json"); + const existingMetadata = { + id: legacyId, + name: workspaceName, + projectName: "project", + projectPath: projectPath, + createdAt: "2025-01-01T00:00:00.000Z", + }; + fs.writeFileSync(metadataPath, JSON.stringify(existingMetadata)); + + // Add workspace to config (without id/name, simulating legacy format) + config.editConfig((cfg) => { + cfg.projects.set(projectPath, { + workspaces: [{ path: workspacePath }], + }); + return cfg; + }); + + // Get all metadata (should use existing metadata and migrate to config) + const allMetadata = config.getAllWorkspaceMetadata(); + + expect(allMetadata).toHaveLength(1); + const metadata = allMetadata[0]; + expect(metadata.id).toBe(legacyId); + expect(metadata.name).toBe(workspaceName); + expect(metadata.createdAt).toBe("2025-01-01T00:00:00.000Z"); + + // Verify metadata was migrated to config + const configData = config.loadConfigOrDefault(); + const projectConfig = configData.projects.get(projectPath); + expect(projectConfig).toBeDefined(); + expect(projectConfig!.workspaces).toHaveLength(1); + const workspace = projectConfig!.workspaces[0]; + expect(workspace.id).toBe(legacyId); + expect(workspace.name).toBe(workspaceName); + expect(workspace.createdAt).toBe("2025-01-01T00:00:00.000Z"); + }); + }); +}); diff --git a/src/config.ts b/src/config.ts index d120868f9..8b954af19 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,28 +1,15 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; +import * as crypto from "crypto"; import * as jsonc from "jsonc-parser"; import writeFileAtomic from "write-file-atomic"; -import type { WorkspaceMetadata } from "./types/workspace"; +import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "./types/workspace"; import type { Secret, SecretsConfig } from "./types/secrets"; +import type { Workspace, ProjectConfig, ProjectsConfig } from "./types/project"; -export interface Workspace { - path: string; // Absolute path to workspace worktree - id?: string; // Optional: Stable ID from newer config format (for forward compat) - name?: string; // Optional: Friendly name from newer config format (for forward compat) - createdAt?: string; // Optional: Creation timestamp from newer config format - // NOTE: If id is not present, it's generated on-demand from path - // using generateWorkspaceId(). This ensures compatibility with both old and new formats. -} - -export interface ProjectConfig { - path: string; - workspaces: Workspace[]; -} - -export interface ProjectsConfig { - projects: Map; -} +// Re-export project types from dedicated types file (for preload usage) +export type { Workspace, ProjectConfig, ProjectsConfig }; export interface ProviderConfig { apiKey?: string; @@ -113,11 +100,22 @@ export class Config { } /** - * Generate workspace ID from project and workspace paths. - * This is the CENTRAL place for workspace ID generation. - * Format: ${projectBasename}-${workspaceBasename} + * Generate a stable unique workspace ID. + * Uses 10 random hex characters for readability while maintaining uniqueness. * - * NEVER duplicate this logic elsewhere - always call this method. + * Example: "a1b2c3d4e5" + */ + generateStableId(): string { + // Generate 5 random bytes and convert to 10 hex chars + return crypto.randomBytes(5).toString("hex"); + } + + /** + * DEPRECATED: Generate workspace ID from project and workspace paths. + * This method is used only for legacy workspace migration. + * New workspaces should use generateStableId() instead. + * + * Format: ${projectBasename}-${workspaceBasename} */ generateWorkspaceId(projectPath: string, workspacePath: string): string { const projectBasename = this.getProjectName(projectPath); @@ -126,9 +124,42 @@ export class Config { return `${projectBasename}-${workspaceBasename}`; } - getWorkspacePath(projectPath: string, branch: string): string { + /** + * Get the workspace worktree path for a given workspace ID. + * New workspaces use stable IDs, legacy workspaces use the old format. + */ + getWorkspacePath(projectPath: string, workspaceId: string): string { const projectName = this.getProjectName(projectPath); - return path.join(this.srcDir, projectName, branch); + return path.join(this.srcDir, projectName, workspaceId); + } + + /** + * Compute workspace path from metadata. + * Directory uses workspace name (e.g., ~/.cmux/src/project/workspace-name). + */ + getWorkspacePaths(metadata: WorkspaceMetadata): { + /** Worktree path (uses workspace name as directory) */ + namedWorkspacePath: string; + } { + const path = this.getWorkspacePath(metadata.projectPath, metadata.name); + return { + namedWorkspacePath: path, + }; + } + + /** + * Add paths to WorkspaceMetadata to create FrontendWorkspaceMetadata. + * Helper to avoid duplicating path computation logic. + */ + private addPathsToMetadata( + metadata: WorkspaceMetadata, + workspacePath: string, + _projectPath: string + ): FrontendWorkspaceMetadata { + return { + ...metadata, + namedWorkspacePath: workspacePath, + }; } /** @@ -139,15 +170,38 @@ export class Config { const config = this.loadConfigOrDefault(); for (const [projectPath, project] of config.projects) { - for (const workspace of project.workspaces ?? []) { - // If workspace has stored ID, use it (new format) - // Otherwise, generate ID from path (old format) - const workspaceIdToMatch = - workspace.id ?? this.generateWorkspaceId(projectPath, workspace.path); - - if (workspaceIdToMatch === workspaceId) { + for (const workspace of project.workspaces) { + // NEW FORMAT: Check config first (primary source of truth after migration) + if (workspace.id === workspaceId) { return { workspacePath: workspace.path, projectPath }; } + + // LEGACY FORMAT: Fall back to metadata.json and legacy ID for unmigrated workspaces + if (!workspace.id) { + // Extract workspace basename (could be stable ID or legacy name) + const workspaceBasename = + workspace.path.split("/").pop() ?? workspace.path.split("\\").pop() ?? "unknown"; + + // Try loading metadata with basename as ID (works for old workspaces) + const metadataPath = path.join(this.getSessionDir(workspaceBasename), "metadata.json"); + if (fs.existsSync(metadataPath)) { + try { + const data = fs.readFileSync(metadataPath, "utf-8"); + const metadata = JSON.parse(data) as WorkspaceMetadata; + if (metadata.id === workspaceId) { + return { workspacePath: workspace.path, projectPath }; + } + } catch { + // Ignore parse errors, try legacy ID + } + } + + // Try legacy ID format as last resort + const legacyId = this.generateWorkspaceId(projectPath, workspace.path); + if (legacyId === workspaceId) { + return { workspacePath: workspace.path, projectPath }; + } + } } } @@ -155,17 +209,16 @@ export class Config { } /** - * WARNING: Never try to derive workspace path from workspace ID! - * This is a code smell that leads to bugs. + * Workspace Path Architecture: + * + * Workspace paths are computed on-demand from projectPath + workspaceId using + * config.getWorkspacePath(). This ensures single source of truth for path format. * - * The workspace path should always: - * 1. Be stored in WorkspaceMetadata when the workspace is created - * 2. Be retrieved from WorkspaceMetadata when needed - * 3. Be passed through the call stack explicitly + * Backend: Uses getWorkspacePath(metadata.projectPath, metadata.name) for directory paths (worktree directories use name) + * Frontend: Gets enriched metadata with paths via IPC (FrontendWorkspaceMetadata) * - * Parsing workspaceId strings to derive paths is fragile and error-prone. - * The workspace path is established when the git worktree is created, - * and that canonical path should be preserved and used throughout. + * WorkspaceMetadata.workspacePath is deprecated and will be removed. Use computed + * paths from getWorkspacePath() or getWorkspacePaths() instead. */ /** @@ -176,31 +229,114 @@ export class Config { } /** - * Get all workspace metadata by loading config and generating IDs. - * This is the CENTRAL place for workspace ID generation. + * Get all workspace metadata by loading config and metadata files. + * + * Returns FrontendWorkspaceMetadata with paths already computed. + * This eliminates the need for separate "enrichment" - paths are computed + * once during the loop when we already have all the necessary data. * - * IDs are generated using the formula: ${projectBasename}-${workspaceBasename} - * This ensures single source of truth and makes config format migration-free. + * NEW BEHAVIOR: Config is the primary source of truth + * - If workspace has id/name/createdAt in config, use those directly + * - If workspace only has path, fall back to reading metadata.json + * - Migrate old workspaces by copying metadata from files to config + * + * This centralizes workspace metadata in config.json and eliminates the need + * for scattered metadata.json files (kept for backward compat with older versions). */ - getAllWorkspaceMetadata(): WorkspaceMetadata[] { + getAllWorkspaceMetadata(): FrontendWorkspaceMetadata[] { const config = this.loadConfigOrDefault(); - const workspaceMetadata: WorkspaceMetadata[] = []; + const workspaceMetadata: FrontendWorkspaceMetadata[] = []; + let configModified = false; for (const [projectPath, projectConfig] of config.projects) { const projectName = this.getProjectName(projectPath); - for (const workspace of projectConfig.workspaces ?? []) { - // Use stored ID if available (new format), otherwise generate (old format) - const workspaceId = workspace.id ?? this.generateWorkspaceId(projectPath, workspace.path); - - workspaceMetadata.push({ - id: workspaceId, - projectName, - workspacePath: workspace.path, - }); + for (const workspace of projectConfig.workspaces) { + // Extract workspace basename from path (could be stable ID or legacy name) + const workspaceBasename = + workspace.path.split("/").pop() ?? workspace.path.split("\\").pop() ?? "unknown"; + + try { + // NEW FORMAT: If workspace has metadata in config, use it directly + if (workspace.id && workspace.name) { + const metadata: WorkspaceMetadata = { + id: workspace.id, + name: workspace.name, + projectName, + projectPath, + createdAt: workspace.createdAt, + }; + workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); + continue; // Skip metadata file lookup + } + + // LEGACY FORMAT: Fall back to reading metadata.json + // Try legacy ID format first (project-workspace) - used by E2E tests and old workspaces + const legacyId = this.generateWorkspaceId(projectPath, workspace.path); + const metadataPath = path.join(this.getSessionDir(legacyId), "metadata.json"); + let metadataFound = false; + + if (fs.existsSync(metadataPath)) { + const data = fs.readFileSync(metadataPath, "utf-8"); + let metadata = JSON.parse(data) as WorkspaceMetadata; + + // Ensure required fields are present + if (!metadata.name || !metadata.projectPath) { + metadata = { + ...metadata, + name: metadata.name ?? workspaceBasename, + projectPath: metadata.projectPath ?? projectPath, + projectName: metadata.projectName ?? projectName, + }; + } + + // Migrate to config for next load + workspace.id = metadata.id; + workspace.name = metadata.name; + workspace.createdAt = metadata.createdAt; + configModified = true; + + workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); + metadataFound = true; + } + + // No metadata found anywhere - create basic metadata + if (!metadataFound) { + const legacyId = this.generateWorkspaceId(projectPath, workspace.path); + const metadata: WorkspaceMetadata = { + id: legacyId, + name: workspaceBasename, + projectName, + projectPath, + }; + + // Save to config for next load + workspace.id = metadata.id; + workspace.name = metadata.name; + configModified = true; + + workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); + } + } catch (error) { + console.error(`Failed to load/migrate workspace metadata:`, error); + // Fallback to basic metadata if migration fails + const legacyId = this.generateWorkspaceId(projectPath, workspace.path); + const metadata: WorkspaceMetadata = { + id: legacyId, + name: workspaceBasename, + projectName, + projectPath, + }; + workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath)); + } } } + // Save config if we migrated any workspaces + if (configModified) { + this.saveConfig(config); + } + return workspaceMetadata; } diff --git a/src/git.ts b/src/git.ts index 9f04c0cf5..ec015a9f3 100644 --- a/src/git.ts +++ b/src/git.ts @@ -11,6 +11,8 @@ export interface WorktreeResult { export interface CreateWorktreeOptions { trunkBranch: string; + /** Directory name to use for the worktree (if not provided, uses branchName) */ + workspaceId?: string; } export async function listLocalBranches(projectPath: string): Promise { @@ -74,7 +76,9 @@ export async function createWorktree( options: CreateWorktreeOptions ): Promise { try { - const workspacePath = config.getWorkspacePath(projectPath, branchName); + // Use workspaceId for directory name if provided, otherwise fall back to branchName (legacy) + const directoryName = options.workspaceId ?? branchName; + const workspacePath = config.getWorkspacePath(projectPath, directoryName); const { trunkBranch } = options; const normalizedTrunkBranch = typeof trunkBranch === "string" ? trunkBranch.trim() : ""; diff --git a/src/hooks/useProjectManagement.ts b/src/hooks/useProjectManagement.ts index 696c022a5..3fd2ccffd 100644 --- a/src/hooks/useProjectManagement.ts +++ b/src/hooks/useProjectManagement.ts @@ -17,7 +17,7 @@ export function useProjectManagement() { const projectsList = await window.api.projects.list(); console.log("Received projects:", projectsList); - const projectsMap = new Map(projectsList.map((p) => [p.path, p])); + const projectsMap = new Map(projectsList); console.log("Created projects map, size:", projectsMap.size); setProjects(projectsMap); } catch (error) { diff --git a/src/hooks/useWorkspaceManagement.ts b/src/hooks/useWorkspaceManagement.ts index f485160bc..1e5de837d 100644 --- a/src/hooks/useWorkspaceManagement.ts +++ b/src/hooks/useWorkspaceManagement.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import type { WorkspaceMetadata } from "@/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceSelection } from "@/components/ProjectSidebar"; import type { ProjectConfig } from "@/config"; @@ -17,16 +17,18 @@ export function useWorkspaceManagement({ onProjectsUpdate, onSelectedWorkspaceUpdate, }: UseWorkspaceManagementProps) { - const [workspaceMetadata, setWorkspaceMetadata] = useState>( - new Map() - ); + const [workspaceMetadata, setWorkspaceMetadata] = useState< + Map + >(new Map()); + const [loading, setLoading] = useState(true); const loadWorkspaceMetadata = useCallback(async () => { try { const metadataList = await window.api.workspace.list(); const metadataMap = new Map(); for (const metadata of metadataList) { - metadataMap.set(metadata.workspacePath, metadata); + // Use stable workspace ID as key (not path, which can change) + metadataMap.set(metadata.id, metadata); } setWorkspaceMetadata(metadataMap); } catch (error) { @@ -34,9 +36,40 @@ export function useWorkspaceManagement({ } }, []); + // Load metadata once on mount + useEffect(() => { + void (async () => { + await loadWorkspaceMetadata(); + // After loading metadata (which may trigger migration), reload projects + // to ensure frontend has the updated config with workspace IDs + const projectsList = await window.api.projects.list(); + const loadedProjects = new Map(projectsList); + onProjectsUpdate(loadedProjects); + setLoading(false); + })(); + }, [loadWorkspaceMetadata, onProjectsUpdate]); + + // Subscribe to metadata updates (for create/rename/delete operations) useEffect(() => { - void loadWorkspaceMetadata(); - }, [loadWorkspaceMetadata]); + const unsubscribe = window.api.workspace.onMetadata( + (event: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null }) => { + setWorkspaceMetadata((prev) => { + const updated = new Map(prev); + if (event.metadata === null) { + // Workspace deleted - remove from map + updated.delete(event.workspaceId); + } else { + updated.set(event.workspaceId, event.metadata); + } + return updated; + }); + } + ); + + return () => { + unsubscribe(); + }; + }, []); const createWorkspace = async (projectPath: string, branchName: string, trunkBranch: string) => { console.assert( @@ -47,7 +80,7 @@ export function useWorkspaceManagement({ if (result.success) { // Backend has already updated the config - reload projects to get updated state const projectsList = await window.api.projects.list(); - const loadedProjects = new Map(projectsList.map((p) => [p.path, p])); + const loadedProjects = new Map(projectsList); onProjectsUpdate(loadedProjects); // Reload workspace metadata to get the new workspace ID @@ -57,7 +90,7 @@ export function useWorkspaceManagement({ return { projectPath, projectName: result.metadata.projectName, - workspacePath: result.metadata.workspacePath, + namedWorkspacePath: result.metadata.namedWorkspacePath, workspaceId: result.metadata.id, }; } else { @@ -74,7 +107,7 @@ export function useWorkspaceManagement({ if (result.success) { // Backend has already updated the config - reload projects to get updated state const projectsList = await window.api.projects.list(); - const loadedProjects = new Map(projectsList.map((p) => [p.path, p])); + const loadedProjects = new Map(projectsList); onProjectsUpdate(loadedProjects); // Reload workspace metadata @@ -99,7 +132,7 @@ export function useWorkspaceManagement({ if (result.success) { // Backend has already updated the config - reload projects to get updated state const projectsList = await window.api.projects.list(); - const loadedProjects = new Map(projectsList.map((p) => [p.path, p])); + const loadedProjects = new Map(projectsList); onProjectsUpdate(loadedProjects); // Reload workspace metadata @@ -115,7 +148,7 @@ export function useWorkspaceManagement({ onSelectedWorkspaceUpdate({ projectPath: selectedWorkspace.projectPath, projectName: newMetadata.projectName, - workspacePath: newMetadata.workspacePath, + namedWorkspacePath: newMetadata.namedWorkspacePath, workspaceId: newWorkspaceId, }); } @@ -131,9 +164,9 @@ export function useWorkspaceManagement({ return { workspaceMetadata, + loading, createWorkspace, removeWorkspace, renameWorkspace, - loadWorkspaceMetadata, }; } diff --git a/src/preload.ts b/src/preload.ts index 85cc99449..768b58ce4 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -20,7 +20,8 @@ import { contextBridge, ipcRenderer } from "electron"; import type { IPCApi, WorkspaceChatMessage } from "./types/ipc"; -import type { WorkspaceMetadata } from "./types/workspace"; +import type { FrontendWorkspaceMetadata } from "./types/workspace"; +import type { ProjectConfig } from "./types/project"; import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants"; // Build the API implementation using the shared interface @@ -36,7 +37,8 @@ const api: IPCApi = { projects: { create: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, projectPath), remove: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectPath), - list: () => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST), + list: (): Promise> => + ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST), listBranches: (projectPath: string) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath), secrets: { @@ -88,11 +90,11 @@ const api: IPCApi = { }; }, onMetadata: ( - callback: (data: { workspaceId: string; metadata: WorkspaceMetadata }) => void + callback: (data: { workspaceId: string; metadata: FrontendWorkspaceMetadata }) => void ) => { const handler = ( _event: unknown, - data: { workspaceId: string; metadata: WorkspaceMetadata } + data: { workspaceId: string; metadata: FrontendWorkspaceMetadata } ) => callback(data); // Subscribe to metadata events diff --git a/src/services/agentSession.ts b/src/services/agentSession.ts index 77fd0ea10..e7aba0ab7 100644 --- a/src/services/agentSession.ts +++ b/src/services/agentSession.ts @@ -152,26 +152,36 @@ export class AgentSession { assert(trimmedWorkspacePath.length > 0, "workspacePath must not be empty"); const normalizedWorkspacePath = path.resolve(trimmedWorkspacePath); - const existing = await this.aiService.getWorkspaceMetadata(this.workspaceId); + const existing = this.aiService.getWorkspaceMetadata(this.workspaceId); if (existing.success) { + // Metadata already exists, verify workspace path matches const metadata = existing.data; + // Directory name uses workspace name (not stable ID) + const expectedPath = this.config.getWorkspacePath(metadata.projectPath, metadata.name); assert( - metadata.workspacePath === normalizedWorkspacePath, - `Existing metadata workspacePath mismatch for ${this.workspaceId}` + expectedPath === normalizedWorkspacePath, + `Existing metadata workspace path mismatch for ${this.workspaceId}: expected ${expectedPath}, got ${normalizedWorkspacePath}` ); return; } + // Derive project path from workspace path (parent directory) + const derivedProjectPath = path.dirname(normalizedWorkspacePath); + const derivedProjectName = projectName && projectName.trim().length > 0 ? projectName.trim() - : path.basename(path.dirname(normalizedWorkspacePath)) || "unknown"; + : path.basename(derivedProjectPath) || "unknown"; + + // Extract name from workspace path (last component) + const workspaceName = path.basename(normalizedWorkspacePath); const metadata: WorkspaceMetadata = { id: this.workspaceId, + name: workspaceName, projectName: derivedProjectName, - workspacePath: normalizedWorkspacePath, + projectPath: derivedProjectPath, }; const saveResult = await this.aiService.saveWorkspaceMetadata(this.workspaceId, metadata); diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 44857bbde..c4b33b62a 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -7,7 +7,7 @@ import { applyToolOutputRedaction } from "@/utils/messages/applyToolOutputRedact import type { Result } from "@/types/result"; import { Ok, Err } from "@/types/result"; import type { WorkspaceMetadata } from "@/types/workspace"; -import { WorkspaceMetadataSchema } from "@/types/workspace"; + import type { CmuxMessage, CmuxTextPart } from "@/types/message"; import { createCmuxMessage } from "@/types/message"; import type { Config } from "@/config"; @@ -90,6 +90,19 @@ if (typeof globalFetchWithExtras.certificate === "function") { defaultFetchWithExtras.certificate = globalFetchWithExtras.certificate.bind(globalFetchWithExtras); } + +/** + * Preload AI SDK provider modules to avoid race conditions in concurrent test environments. + * This function loads @ai-sdk/anthropic and @ai-sdk/openai eagerly so that subsequent + * dynamic imports in createModel() hit the module cache instead of racing. + * + * In production, providers are lazy-loaded on first use to optimize startup time. + * In tests, we preload them once during setup to ensure reliable concurrent execution. + */ +export async function preloadAISDKProviders(): Promise { + await Promise.all([import("@ai-sdk/anthropic"), import("@ai-sdk/openai")]); +} + export class AIService extends EventEmitter { private readonly METADATA_FILE = "metadata.json"; private readonly streamManager: StreamManager; @@ -167,33 +180,21 @@ export class AIService extends EventEmitter { return this.mockModeEnabled; } - async getWorkspaceMetadata(workspaceId: string): Promise> { + getWorkspaceMetadata(workspaceId: string): Result { try { - const metadataPath = this.getMetadataPath(workspaceId); - const data = await fs.readFile(metadataPath, "utf-8"); + // Get all workspace metadata (which includes migration logic) + // This ensures we always get complete metadata with all required fields + const allMetadata = this.config.getAllWorkspaceMetadata(); + const metadata = allMetadata.find((m) => m.id === workspaceId); - // Parse and validate with Zod schema (handles any type safely) - const validated = WorkspaceMetadataSchema.parse(JSON.parse(data)); - - return Ok(validated); - } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { - // Fallback: Try to reconstruct metadata from config (for forward compatibility) - // This handles workspaces created on newer branches that don't have metadata.json - const allMetadata = this.config.getAllWorkspaceMetadata(); - const metadataFromConfig = allMetadata.find((m) => m.id === workspaceId); - - if (metadataFromConfig) { - // Found in config - save it to metadata.json for future use - await this.saveWorkspaceMetadata(workspaceId, metadataFromConfig); - return Ok(metadataFromConfig); - } - - // If metadata doesn't exist anywhere, workspace is not properly initialized + if (!metadata) { return Err( `Workspace metadata not found for ${workspaceId}. Workspace may not be properly initialized.` ); } + + return Ok(metadata); + } catch (error) { const message = error instanceof Error ? error.message : String(error); return Err(`Failed to read workspace metadata: ${message}`); } @@ -509,14 +510,26 @@ export class AIService extends EventEmitter { } // Get workspace metadata to retrieve workspace path - const metadataResult = await this.getWorkspaceMetadata(workspaceId); + const metadataResult = this.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) { return Err({ type: "unknown", raw: metadataResult.error }); } + const metadata = metadataResult.data; + + // Get actual workspace path from config (handles both legacy and new format) + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err({ type: "unknown", raw: `Workspace ${workspaceId} not found in config` }); + } + + // Get workspace path (directory name uses workspace name) + const workspacePath = this.config.getWorkspacePath(metadata.projectPath, metadata.name); + // Build system message from workspace metadata const systemMessage = await buildSystemMessage( - metadataResult.data, + metadata, + workspacePath, mode, additionalSystemInstructions ); @@ -525,13 +538,8 @@ export class AIService extends EventEmitter { const tokenizer = getTokenizerForModel(modelString); const systemMessageTokens = tokenizer.countTokens(systemMessage); - const workspacePath = metadataResult.data.workspacePath; - - // Find project path for this workspace to load secrets - const workspaceInfo = this.config.findWorkspace(workspaceId); - const projectSecrets = workspaceInfo - ? this.config.getProjectSecrets(workspaceInfo.projectPath) - : []; + // Load project secrets + const projectSecrets = this.config.getProjectSecrets(metadata.projectPath); // Generate stream token and create temp directory for tools const streamToken = this.streamManager.generateStreamToken(); diff --git a/src/services/gitService.test.ts b/src/services/gitService.test.ts index 676df4106..fee53d3ed 100644 --- a/src/services/gitService.test.ts +++ b/src/services/gitService.test.ts @@ -2,7 +2,8 @@ import { describe, test, expect, beforeEach, afterEach } from "@jest/globals"; import * as fs from "fs/promises"; import * as path from "path"; import { execSync } from "child_process"; -import { removeWorktreeSafe, isWorktreeClean, createWorktree, hasSubmodules } from "./gitService"; +import { removeWorktreeSafe, isWorktreeClean, hasSubmodules } from "./gitService"; +import { createWorktree, detectDefaultTrunkBranch } from "@/git"; import type { Config } from "@/config"; // Helper to create a test git repo @@ -32,10 +33,12 @@ const mockConfig = { describe("removeWorktreeSafe", () => { let tempDir: string; let repoPath: string; + let defaultBranch: string; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(__dirname, "..", "test-temp-")); repoPath = await createTestRepo(tempDir); + defaultBranch = await detectDefaultTrunkBranch(repoPath); }); afterEach(async () => { @@ -48,7 +51,9 @@ describe("removeWorktreeSafe", () => { test("should instantly remove clean worktree via rename", async () => { // Create a worktree - const result = await createWorktree(mockConfig, repoPath, "test-branch"); + const result = await createWorktree(mockConfig, repoPath, "test-branch", { + trunkBranch: defaultBranch, + }); expect(result.success).toBe(true); const worktreePath = result.path!; @@ -79,7 +84,9 @@ describe("removeWorktreeSafe", () => { test("should block removal of dirty worktree", async () => { // Create a worktree - const result = await createWorktree(mockConfig, repoPath, "dirty-branch"); + const result = await createWorktree(mockConfig, repoPath, "dirty-branch", { + trunkBranch: defaultBranch, + }); expect(result.success).toBe(true); const worktreePath = result.path!; @@ -106,7 +113,9 @@ describe("removeWorktreeSafe", () => { test("should handle already-deleted worktree gracefully", async () => { // Create a worktree - const result = await createWorktree(mockConfig, repoPath, "temp-branch"); + const result = await createWorktree(mockConfig, repoPath, "temp-branch", { + trunkBranch: defaultBranch, + }); expect(result.success).toBe(true); const worktreePath = result.path!; @@ -121,7 +130,9 @@ describe("removeWorktreeSafe", () => { test("should remove clean worktree with staged changes using git", async () => { // Create a worktree - const result = await createWorktree(mockConfig, repoPath, "staged-branch"); + const result = await createWorktree(mockConfig, repoPath, "staged-branch", { + trunkBranch: defaultBranch, + }); expect(result.success).toBe(true); const worktreePath = result.path!; @@ -141,7 +152,9 @@ describe("removeWorktreeSafe", () => { test("should call onBackgroundDelete callback on errors", async () => { // Create a worktree - const result = await createWorktree(mockConfig, repoPath, "callback-branch"); + const result = await createWorktree(mockConfig, repoPath, "callback-branch", { + trunkBranch: defaultBranch, + }); expect(result.success).toBe(true); const worktreePath = result.path!; @@ -168,10 +181,12 @@ describe("removeWorktreeSafe", () => { describe("isWorktreeClean", () => { let tempDir: string; let repoPath: string; + let defaultBranch: string; beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(__dirname, "..", "test-temp-")); repoPath = await createTestRepo(tempDir); + defaultBranch = await detectDefaultTrunkBranch(repoPath); }); afterEach(async () => { @@ -183,7 +198,9 @@ describe("isWorktreeClean", () => { }); test("should return true for clean worktree", async () => { - const result = await createWorktree(mockConfig, repoPath, "clean-check"); + const result = await createWorktree(mockConfig, repoPath, "clean-check", { + trunkBranch: defaultBranch, + }); expect(result.success).toBe(true); const isClean = await isWorktreeClean(result.path!); @@ -191,7 +208,9 @@ describe("isWorktreeClean", () => { }); test("should return false for worktree with uncommitted changes", async () => { - const result = await createWorktree(mockConfig, repoPath, "dirty-check"); + const result = await createWorktree(mockConfig, repoPath, "dirty-check", { + trunkBranch: defaultBranch, + }); expect(result.success).toBe(true); const worktreePath = result.path!; diff --git a/src/services/gitService.ts b/src/services/gitService.ts index 97e1acba5..896fe2c4e 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -1,7 +1,5 @@ -import * as fs from "fs"; import * as fsPromises from "fs/promises"; import * as path from "path"; -import type { Config } from "@/config"; import { execAsync } from "@/utils/disposableExec"; export interface WorktreeResult { @@ -10,60 +8,6 @@ export interface WorktreeResult { error?: string; } -export async function createWorktree( - config: Config, - projectPath: string, - branchName: string -): Promise { - try { - const workspacePath = config.getWorkspacePath(projectPath, branchName); - - // Create workspace directory if it doesn't exist - if (!fs.existsSync(path.dirname(workspacePath))) { - fs.mkdirSync(path.dirname(workspacePath), { recursive: true }); - } - - // Check if workspace already exists - if (fs.existsSync(workspacePath)) { - return { - success: false, - error: `Workspace already exists at ${workspacePath}`, - }; - } - - // Check if branch exists - using branchesProc = execAsync(`git -C "${projectPath}" branch -a`); - const { stdout: branches } = await branchesProc.result; - const branchExists = branches - .split("\n") - .some( - (b) => - b.trim() === branchName || - b.trim() === `* ${branchName}` || - b.trim() === `remotes/origin/${branchName}` - ); - - if (branchExists) { - // Branch exists, create worktree with existing branch - using proc = execAsync( - `git -C "${projectPath}" worktree add "${workspacePath}" "${branchName}"` - ); - await proc.result; - } else { - // Branch doesn't exist, create new branch with worktree - using proc = execAsync( - `git -C "${projectPath}" worktree add -b "${branchName}" "${workspacePath}"` - ); - await proc.result; - } - - return { success: true, path: workspacePath }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, error: message }; - } -} - /** * Check if a worktree has uncommitted changes or untracked files * Returns true if the worktree is clean (safe to delete), false otherwise @@ -242,92 +186,3 @@ export async function removeWorktreeSafe( return { success: true }; } - -export async function moveWorktree( - projectPath: string, - oldPath: string, - newPath: string -): Promise { - try { - // Check if new path already exists - if (fs.existsSync(newPath)) { - return { - success: false, - error: `Target path already exists: ${newPath}`, - }; - } - - // Create parent directory for new path if needed - const parentDir = path.dirname(newPath); - if (!fs.existsSync(parentDir)) { - fs.mkdirSync(parentDir, { recursive: true }); - } - - // Move the worktree using git (from the main repository context) - using proc = execAsync(`git -C "${projectPath}" worktree move "${oldPath}" "${newPath}"`); - await proc.result; - return { success: true, path: newPath }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { success: false, error: message }; - } -} - -export async function listWorktrees(projectPath: string): Promise { - try { - using proc = execAsync(`git -C "${projectPath}" worktree list --porcelain`); - const { stdout } = await proc.result; - const worktrees: string[] = []; - const lines = stdout.split("\n"); - - for (const line of lines) { - if (line.startsWith("worktree ")) { - const path = line.slice("worktree ".length); - if (path !== projectPath) { - // Exclude main worktree - worktrees.push(path); - } - } - } - - return worktrees; - } catch (error) { - console.error("Error listing worktrees:", error); - return []; - } -} - -export async function isGitRepository(projectPath: string): Promise { - try { - using proc = execAsync(`git -C "${projectPath}" rev-parse --git-dir`); - await proc.result; - return true; - } catch { - return false; - } -} - -/** - * Get the main repository path from a worktree path - * @param worktreePath Path to a git worktree - * @returns Path to the main repository, or null if not found - */ -export async function getMainWorktreeFromWorktree(worktreePath: string): Promise { - try { - // Get the worktree list from the worktree itself - using proc = execAsync(`git -C "${worktreePath}" worktree list --porcelain`); - const { stdout } = await proc.result; - const lines = stdout.split("\n"); - - // The first worktree in the list is always the main worktree - for (const line of lines) { - if (line.startsWith("worktree ")) { - return line.slice("worktree ".length); - } - } - - return null; - } catch { - return null; - } -} diff --git a/src/services/ipcMain.ts b/src/services/ipcMain.ts index a9d70d887..2a2b07e71 100644 --- a/src/services/ipcMain.ts +++ b/src/services/ipcMain.ts @@ -1,12 +1,10 @@ import assert from "node:assert/strict"; import type { BrowserWindow, IpcMain as ElectronIpcMain } from "electron"; import { spawn, spawnSync } from "child_process"; -import * as path from "path"; import * as fsPromises from "fs/promises"; import type { Config, ProjectConfig } from "@/config"; import { createWorktree, - moveWorktree, listLocalBranches, detectDefaultTrunkBranch, getMainWorktreeFromWorktree, @@ -190,52 +188,66 @@ export class IpcMain { const normalizedTrunkBranch = trunkBranch.trim(); - // First create the git worktree + // Generate stable workspace ID (stored in config, not used for directory name) + const workspaceId = this.config.generateStableId(); + + // Create the git worktree with the workspace name as directory name const result = await createWorktree(this.config, projectPath, branchName, { trunkBranch: normalizedTrunkBranch, + workspaceId: branchName, // Use name for directory (workspaceId param is misnamed, it's directoryName) }); if (result.success && result.path) { const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "unknown"; - // Generate workspace ID using central method - const workspaceId = this.config.generateWorkspaceId(projectPath, result.path); - - // Initialize workspace metadata + // Initialize workspace metadata with stable ID and name const metadata = { id: workspaceId, + name: branchName, // Name is separate from ID projectName, - workspacePath: result.path, + projectPath, // Full project path for computing worktree path + createdAt: new Date().toISOString(), }; - await this.aiService.saveWorkspaceMetadata(workspaceId, metadata); + // Note: metadata.json no longer written - config is the only source of truth - // Update config to include the new workspace + // Update config to include the new workspace (with full metadata) this.config.editConfig((config) => { let projectConfig = config.projects.get(projectPath); if (!projectConfig) { // Create project config if it doesn't exist projectConfig = { - path: projectPath, workspaces: [], }; config.projects.set(projectPath, projectConfig); } - // Add workspace to project config - if (!projectConfig.workspaces) projectConfig.workspaces = []; + // Add workspace to project config with full metadata projectConfig.workspaces.push({ path: result.path!, + id: workspaceId, + name: branchName, + createdAt: metadata.createdAt, }); return config; }); + // No longer creating symlinks - directory name IS the workspace name + + // Get complete metadata from config (includes paths) + const allMetadata = this.config.getAllWorkspaceMetadata(); + const completeMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!completeMetadata) { + return { success: false, error: "Failed to retrieve workspace metadata" }; + } + // Emit metadata event for new workspace const session = this.getOrCreateSession(workspaceId); - session.emitMetadata(metadata); + session.emitMetadata(completeMetadata); + // Return complete metadata with paths for frontend return { success: true, - metadata, + metadata: completeMetadata, }; } @@ -252,146 +264,102 @@ export class IpcMain { ipcMain.handle( IPC_CHANNELS.WORKSPACE_RENAME, - async (_event, workspaceId: string, newName: string) => { + (_event, workspaceId: string, newName: string) => { try { + // Block rename during active streaming to prevent race conditions + // (bash processes would have stale cwd, system message would be wrong) + if (this.aiService.isStreaming(workspaceId)) { + return Err( + "Cannot rename workspace while AI stream is active. Please wait for the stream to complete." + ); + } + // Validate workspace name const validation = validateWorkspaceName(newName); if (!validation.valid) { return Err(validation.error ?? "Invalid workspace name"); } - // Block rename if there's an active stream - if (this.aiService.isStreaming(workspaceId)) { - return Err( - "Cannot rename workspace while stream is active. Press Esc to stop the stream first." - ); - } - // Get current metadata - const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); + const metadataResult = this.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) { return Err(`Failed to get workspace metadata: ${metadataResult.error}`); } const oldMetadata = metadataResult.data; - - // Calculate new workspace ID - const newWorkspaceId = `${oldMetadata.projectName}-${newName}`; + const oldName = oldMetadata.name; // If renaming to itself, just return success (no-op) - if (newWorkspaceId === workspaceId) { - return Ok({ newWorkspaceId }); - } - - // Check if new workspace ID already exists - const existingMetadata = await this.aiService.getWorkspaceMetadata(newWorkspaceId); - if (existingMetadata.success) { - return Err(`Workspace with name "${newName}" already exists`); + if (newName === oldName) { + return Ok({ newWorkspaceId: workspaceId }); } - // Get old and new session directory paths - const oldSessionDir = this.config.getSessionDir(workspaceId); - const newSessionDir = this.config.getSessionDir(newWorkspaceId); - - // Find project path from config (needed for git operations) - const projectsConfig = this.config.loadConfigOrDefault(); - let foundProjectPath: string | null = null; - let workspaceIndex = -1; - - for (const [projectPath, projectConfig] of projectsConfig.projects.entries()) { - const idx = (projectConfig.workspaces ?? []).findIndex((w) => { - // If workspace has stored ID, use it (new format) - // Otherwise, generate ID from path (old format) - const workspaceIdToMatch = - w.id ?? this.config.generateWorkspaceId(projectPath, w.path); - return workspaceIdToMatch === workspaceId; - }); - - if (idx !== -1) { - foundProjectPath = projectPath; - workspaceIndex = idx; - break; - } - } - - if (!foundProjectPath) { - return Err("Failed to find project path for workspace"); - } - - // Rename session directory - await fsPromises.rename(oldSessionDir, newSessionDir); - - // Migrate workspace IDs in history messages - const migrateResult = await this.historyService.migrateWorkspaceId( - workspaceId, - newWorkspaceId + // Check if new name collides with existing workspace name or ID + const allWorkspaces = this.config.getAllWorkspaceMetadata(); + const collision = allWorkspaces.find( + (ws) => (ws.name === newName || ws.id === newName) && ws.id !== workspaceId ); - if (!migrateResult.success) { - // Rollback session directory rename - await fsPromises.rename(newSessionDir, oldSessionDir); - return Err(`Failed to migrate message workspace IDs: ${migrateResult.error}`); + if (collision) { + return Err(`Workspace with name "${newName}" already exists`); } - // Calculate new worktree path - const oldWorktreePath = oldMetadata.workspacePath; - const newWorktreePath = path.join( - path.dirname(oldWorktreePath), - newName // Use newName as the directory name - ); - - // Move worktree directory - const moveResult = await moveWorktree(foundProjectPath, oldWorktreePath, newWorktreePath); - if (!moveResult.success) { - // Rollback session directory rename - await fsPromises.rename(newSessionDir, oldSessionDir); - return Err(`Failed to move worktree: ${moveResult.error ?? "unknown error"}`); + // Find project path from config + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err("Failed to find workspace in config"); } + const { projectPath, workspacePath } = workspace; - // Update metadata with new ID and path - const newMetadata = { - id: newWorkspaceId, - projectName: oldMetadata.projectName, - workspacePath: newWorktreePath, - }; + // Compute new path (based on name) + const oldPath = workspacePath; + const newPath = this.config.getWorkspacePath(projectPath, newName); - const saveResult = await this.aiService.saveWorkspaceMetadata( - newWorkspaceId, - newMetadata - ); - if (!saveResult.success) { - // Rollback worktree and session directory - await moveWorktree(foundProjectPath, newWorktreePath, oldWorktreePath); - await fsPromises.rename(newSessionDir, oldSessionDir); - return Err(`Failed to save new metadata: ${saveResult.error}`); + // Use git worktree move to rename the worktree directory + // This updates git's internal worktree metadata correctly + try { + const result = spawnSync("git", ["worktree", "move", oldPath, newPath], { + cwd: projectPath, + }); + if (result.status !== 0) { + const stderr = result.stderr?.toString() || "Unknown error"; + return Err(`Failed to move worktree: ${stderr}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to move worktree: ${message}`); } - // Update config with new workspace info using atomic edit + // Update config with new name and path this.config.editConfig((config) => { - const projectConfig = config.projects.get(foundProjectPath); - if (projectConfig && workspaceIndex !== -1 && projectConfig.workspaces) { - projectConfig.workspaces[workspaceIndex] = { - path: newWorktreePath, - }; + const projectConfig = config.projects.get(projectPath); + if (projectConfig) { + const workspaceEntry = projectConfig.workspaces.find((w) => w.path === oldPath); + if (workspaceEntry) { + workspaceEntry.name = newName; + workspaceEntry.path = newPath; // Update path to reflect new directory name + } } return config; }); - // Emit metadata event for old workspace deletion - const oldSession = this.sessions.get(workspaceId); - if (oldSession) { - oldSession.emitMetadata(null); - this.disposeSession(workspaceId); + // Get updated metadata from config (includes updated name and paths) + const allMetadata = this.config.getAllWorkspaceMetadata(); + const updatedMetadata = allMetadata.find((m) => m.id === workspaceId); + if (!updatedMetadata) { + return Err("Failed to retrieve updated workspace metadata"); + } + + // Emit metadata event with updated metadata (same workspace ID) + const session = this.sessions.get(workspaceId); + if (session) { + session.emitMetadata(updatedMetadata); } else if (this.mainWindow) { this.mainWindow.webContents.send(IPC_CHANNELS.WORKSPACE_METADATA, { workspaceId, - metadata: null, + metadata: updatedMetadata, }); } - // Emit metadata event for new workspace - const newSession = this.getOrCreateSession(newWorkspaceId); - newSession.emitMetadata(newMetadata); - - return Ok({ newWorkspaceId }); + return Ok({ newWorkspaceId: workspaceId }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return Err(`Failed to rename workspace: ${message}`); @@ -401,6 +369,7 @@ export class IpcMain { ipcMain.handle(IPC_CHANNELS.WORKSPACE_LIST, () => { try { + // getAllWorkspaceMetadata now returns complete metadata with paths return this.config.getAllWorkspaceMetadata(); } catch (error) { console.error("Failed to list workspaces:", error); @@ -408,9 +377,10 @@ export class IpcMain { } }); - ipcMain.handle(IPC_CHANNELS.WORKSPACE_GET_INFO, async (_event, workspaceId: string) => { - const result = await this.aiService.getWorkspaceMetadata(workspaceId); - return result.success ? result.data : null; + ipcMain.handle(IPC_CHANNELS.WORKSPACE_GET_INFO, (_event, workspaceId: string) => { + // Get complete metadata from config (includes paths) + const allMetadata = this.config.getAllWorkspaceMetadata(); + return allMetadata.find((m) => m.id === workspaceId) ?? null; }); ipcMain.handle( @@ -605,19 +575,27 @@ export class IpcMain { } ) => { try { - // Get workspace metadata to find workspacePath - const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); + // Get workspace metadata + const metadataResult = this.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) { return Err(`Failed to get workspace metadata: ${metadataResult.error}`); } - const workspacePath = metadataResult.data.workspacePath; + const metadata = metadataResult.data; - // Find project path for this workspace to load secrets - const workspaceInfo = this.config.findWorkspace(workspaceId); - const projectSecrets = workspaceInfo - ? this.config.getProjectSecrets(workspaceInfo.projectPath) - : []; + // Get actual workspace path from config (handles both legacy and new format) + // Legacy workspaces: path stored in config doesn't match computed path + // New workspaces: path can be computed, but config is still source of truth + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + return Err(`Workspace ${workspaceId} not found in config`); + } + + // Get workspace path (directory name uses workspace name) + const namedPath = this.config.getWorkspacePath(metadata.projectPath, metadata.name); + + // Load project secrets + const projectSecrets = this.config.getProjectSecrets(metadata.projectPath); // Create scoped temp directory for this IPC call using tempDir = new DisposableTempDir("cmux-ipc-bash"); @@ -625,7 +603,7 @@ export class IpcMain { // Create bash tool with workspace's cwd and secrets // All IPC bash calls are from UI (background operations) - use truncate to avoid temp file spam const bashTool = createBashTool({ - cwd: workspacePath, + cwd: namedPath, secrets: secretsToRecord(projectSecrets), niceness: options?.niceness, tempDir: tempDir.path, @@ -658,13 +636,21 @@ export class IpcMain { // macOS - try Ghostty first, fallback to Terminal.app const terminal = await this.findAvailableCommand(["ghostty", "terminal"]); if (terminal === "ghostty") { - const child = spawn("open", ["-a", "Ghostty", workspacePath], { + // Match main: pass workspacePath to 'open -a Ghostty' to avoid regressions + const cmd = "open"; + const args = ["-a", "Ghostty", workspacePath]; + log.info(`Opening terminal: ${cmd} ${args.join(" ")}`); + const child = spawn(cmd, args, { detached: true, stdio: "ignore", }); child.unref(); } else { - const child = spawn("open", ["-a", "Terminal", workspacePath], { + // Terminal.app opens in the directory when passed as argument + const cmd = "open"; + const args = ["-a", "Terminal", workspacePath]; + log.info(`Opening terminal: ${cmd} ${args.join(" ")}`); + const child = spawn(cmd, args, { detached: true, stdio: "ignore", }); @@ -672,7 +658,10 @@ export class IpcMain { } } else if (process.platform === "win32") { // Windows - const child = spawn("cmd", ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath], { + const cmd = "cmd"; + const args = ["/c", "start", "cmd", "/K", "cd", "/D", workspacePath]; + log.info(`Opening terminal: ${cmd} ${args.join(" ")}`); + const child = spawn(cmd, args, { detached: true, shell: true, stdio: "ignore", @@ -696,13 +685,16 @@ export class IpcMain { const availableTerminal = await this.findAvailableTerminal(terminals); if (availableTerminal) { + const cwdInfo = availableTerminal.cwd ? ` (cwd: ${availableTerminal.cwd})` : ""; + log.info( + `Opening terminal: ${availableTerminal.cmd} ${availableTerminal.args.join(" ")}${cwdInfo}` + ); const child = spawn(availableTerminal.cmd, availableTerminal.args, { cwd: availableTerminal.cwd ?? workspacePath, detached: true, stdio: "ignore", }); child.unref(); - log.info(`Opened terminal ${availableTerminal.cmd} at ${workspacePath}`); } else { log.error( "No terminal emulator found. Tried: " + terminals.map((t) => t.cmd).join(", ") @@ -724,15 +716,21 @@ export class IpcMain { options: { force: boolean } ): Promise<{ success: boolean; error?: string }> { try { - // Get workspace path from metadata - const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId); + // Get workspace metadata + const metadataResult = this.aiService.getWorkspaceMetadata(workspaceId); if (!metadataResult.success) { // If metadata doesn't exist, workspace is already gone - consider it success log.info(`Workspace ${workspaceId} metadata not found, considering removal successful`); return { success: true }; } - const workspacePath = metadataResult.data.workspacePath; + // Get actual workspace path from config (handles both legacy and new format) + const workspace = this.config.findWorkspace(workspaceId); + if (!workspace) { + log.info(`Workspace ${workspaceId} metadata exists but not found in config`); + return { success: true }; // Consider it already removed + } + const workspacePath = workspace.workspacePath; // Get project path from the worktree itself const foundProjectPath = await getMainWorktreeFromWorktree(workspacePath); @@ -803,17 +801,17 @@ export class IpcMain { return { success: false, error: aiResult.error }; } + // No longer need to remove symlinks (directory IS the workspace name) + // Update config to remove the workspace from all projects // We iterate through all projects instead of relying on foundProjectPath // because the worktree might be deleted (so getMainWorktreeFromWorktree fails) const projectsConfig = this.config.loadConfigOrDefault(); let configUpdated = false; for (const [_projectPath, projectConfig] of projectsConfig.projects.entries()) { - const initialCount = (projectConfig.workspaces ?? []).length; - projectConfig.workspaces = (projectConfig.workspaces ?? []).filter( - (w) => w.path !== workspacePath - ); - if ((projectConfig.workspaces ?? []).length < initialCount) { + const initialCount = projectConfig.workspaces.length; + projectConfig.workspaces = projectConfig.workspaces.filter((w) => w.path !== workspacePath); + if (projectConfig.workspaces.length < initialCount) { configUpdated = true; } } @@ -903,7 +901,6 @@ export class IpcMain { // Create new project config const projectConfig: ProjectConfig = { - path: projectPath, workspaces: [], }; @@ -928,9 +925,9 @@ export class IpcMain { } // Check if project has any workspaces - if ((projectConfig.workspaces ?? []).length > 0) { + if (projectConfig.workspaces.length > 0) { return Err( - `Cannot remove project with active workspaces. Please remove all ${(projectConfig.workspaces ?? []).length} workspace(s) first.` + `Cannot remove project with active workspaces. Please remove all ${projectConfig.workspaces.length} workspace(s) first.` ); } @@ -956,7 +953,8 @@ export class IpcMain { ipcMain.handle(IPC_CHANNELS.PROJECT_LIST, () => { try { const config = this.config.loadConfigOrDefault(); - return Array.from(config.projects.values()); + // Return array of [projectPath, projectConfig] tuples + return Array.from(config.projects.entries()); } catch (error) { log.error("Failed to list projects:", error); return []; diff --git a/src/services/systemMessage.test.ts b/src/services/systemMessage.test.ts index ba2065219..40e50589c 100644 --- a/src/services/systemMessage.test.ts +++ b/src/services/systemMessage.test.ts @@ -47,11 +47,12 @@ Use diagrams where appropriate. const metadata: WorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: workspaceDir, + projectPath: tempDir, }; - const systemMessage = await buildSystemMessage(metadata, "plan"); + const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); // Should include the mode-specific content expect(systemMessage).toContain(""); @@ -77,11 +78,12 @@ Focus on planning and design. const metadata: WorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: workspaceDir, + projectPath: tempDir, }; - const systemMessage = await buildSystemMessage(metadata); + const systemMessage = await buildSystemMessage(metadata, workspaceDir); // Should NOT include the mode-specific tag expect(systemMessage).not.toContain(""); @@ -115,11 +117,12 @@ Workspace plan instructions (should win). const metadata: WorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: workspaceDir, + projectPath: tempDir, }; - const systemMessage = await buildSystemMessage(metadata, "plan"); + const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); // Should include workspace mode section in the tag (workspace wins) expect(systemMessage).toMatch(/\s*Workspace plan instructions \(should win\)\./s); @@ -149,11 +152,12 @@ Just general workspace stuff. const metadata: WorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: workspaceDir, + projectPath: tempDir, }; - const systemMessage = await buildSystemMessage(metadata, "plan"); + const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); // Should include global mode section as fallback expect(systemMessage).toContain("Global plan instructions"); @@ -169,11 +173,12 @@ Special mode instructions. const metadata: WorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: workspaceDir, + projectPath: tempDir, }; - const systemMessage = await buildSystemMessage(metadata, "My-Special_Mode!"); + const systemMessage = await buildSystemMessage(metadata, workspaceDir, "My-Special_Mode!"); // Tag should be sanitized to only contain valid characters expect(systemMessage).toContain(""); diff --git a/src/services/systemMessage.ts b/src/services/systemMessage.ts index 93763a26e..a1cff1c9b 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -68,24 +68,29 @@ function getSystemDirectory(): string { * If a base instruction file is found, its corresponding .local.md variant is also * checked and appended when building the instruction set (useful for personal preferences not committed to git). * - * @param metadata - Workspace metadata containing the workspace path + * @param metadata - Workspace metadata + * @param workspacePath - Absolute path to the workspace worktree directory * @param mode - Optional mode name (e.g., "plan", "exec") - looks for {MODE}.md files if provided * @param additionalSystemInstructions - Optional additional system instructions to append at the end * @returns System message string with all instruction sources combined - * @throws Error if metadata is invalid or workspace path is missing + * @throws Error if metadata is invalid */ export async function buildSystemMessage( metadata: WorkspaceMetadata, + workspacePath: string, mode?: string, additionalSystemInstructions?: string ): Promise { - // Validate metadata early - if (!metadata?.workspacePath) { - throw new Error("Invalid workspace metadata: workspacePath is required"); + // Validate inputs + if (!metadata) { + throw new Error("Invalid workspace metadata: metadata is required"); + } + if (!workspacePath) { + throw new Error("Invalid workspace path: workspacePath is required"); } const systemDir = getSystemDirectory(); - const workspaceDir = metadata.workspacePath; + const workspaceDir = workspacePath; // Gather instruction sets from both global and workspace directories // Global instructions apply first, then workspace-specific ones diff --git a/src/stores/GitStatusStore.test.ts b/src/stores/GitStatusStore.test.ts index cea640b14..92fe5c6f7 100644 --- a/src/stores/GitStatusStore.test.ts +++ b/src/stores/GitStatusStore.test.ts @@ -3,7 +3,7 @@ import type { BashToolResult } from "@/types/tools"; import { describe, it, test, expect, beforeEach, afterEach, jest } from "@jest/globals"; import { GitStatusStore } from "./GitStatusStore"; -import type { WorkspaceMetadata } from "@/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; /** * Unit tests for GitStatusStore. @@ -65,13 +65,15 @@ describe("GitStatusStore", () => { }); test("syncWorkspaces initializes metadata", () => { - const metadata = new Map([ + const metadata = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], ]); @@ -84,21 +86,25 @@ describe("GitStatusStore", () => { }); test("syncWorkspaces removes deleted workspaces", () => { - const metadata1 = new Map([ + const metadata1 = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], [ "ws2", { id: "ws2", + name: "feature", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/feature", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/feature", }, ], ]); @@ -112,13 +118,15 @@ describe("GitStatusStore", () => { expect(status2Initial).toBeNull(); // Remove ws2 - const metadata2 = new Map([ + const metadata2 = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], ]); @@ -134,13 +142,15 @@ describe("GitStatusStore", () => { const listener = jest.fn(); store.subscribe(listener); - const metadata = new Map([ + const metadata = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], ]); @@ -157,13 +167,15 @@ describe("GitStatusStore", () => { }); test("getStatus caching persists across calls", () => { - const metadata = new Map([ + const metadata = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], ]); @@ -184,13 +196,15 @@ describe("GitStatusStore", () => { const listener = jest.fn(); store.subscribe(listener); - const metadata = new Map([ + const metadata = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], ]); @@ -209,13 +223,15 @@ describe("GitStatusStore", () => { const listener = jest.fn(); store.subscribe(listener); - const metadata = new Map([ + const metadata = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], ]); @@ -235,13 +251,15 @@ describe("GitStatusStore", () => { const listener = jest.fn(); const unsub = store.subscribe(listener); - const metadata1 = new Map([ + const metadata1 = new Map([ [ "ws1", { id: "ws1", + name: "main", projectName: "test-project", - workspacePath: "/home/user/.cmux/src/test-project/main", + projectPath: "/home/user/test-project", + namedWorkspacePath: "/home/user/.cmux/src/test-project/main", }, ], ]); @@ -260,7 +278,7 @@ describe("GitStatusStore", () => { listener.mockClear(); // Sync with empty metadata to remove ws1 - const metadata2 = new Map(); + const metadata2 = new Map(); store.syncWorkspaces(metadata2); // Listener should be called (workspace removed) diff --git a/src/stores/GitStatusStore.ts b/src/stores/GitStatusStore.ts index 578fe8721..e1573d540 100644 --- a/src/stores/GitStatusStore.ts +++ b/src/stores/GitStatusStore.ts @@ -1,4 +1,4 @@ -import type { WorkspaceMetadata, GitStatus } from "@/types/workspace"; +import type { FrontendWorkspaceMetadata, GitStatus } from "@/types/workspace"; import { parseGitShowBranchForStatus } from "@/utils/git/parseGitStatus"; import { GIT_STATUS_SCRIPT, @@ -42,7 +42,7 @@ export class GitStatusStore { private statuses = new MapStore(); private fetchCache = new Map(); private pollInterval: NodeJS.Timeout | null = null; - private workspaceMetadata = new Map(); + private workspaceMetadata = new Map(); private isActive = true; constructor() { @@ -85,7 +85,7 @@ export class GitStatusStore { * Sync workspaces with metadata. * Called when workspace list changes. */ - syncWorkspaces(metadata: Map): void { + syncWorkspaces(metadata: Map): void { // Reactivate if disposed by React Strict Mode (dev only) // In dev, Strict Mode unmounts/remounts, disposing the store but reusing the ref if (!this.isActive && metadata.size > 0) { @@ -200,7 +200,7 @@ export class GitStatusStore { * Check git status for a single workspace. */ private async checkWorkspaceStatus( - metadata: WorkspaceMetadata + metadata: FrontendWorkspaceMetadata ): Promise<[string, GitStatus | null]> { // Defensive: Return null if window.api is unavailable (e.g., test environment) if (typeof window === "undefined" || !window.api) { @@ -259,9 +259,9 @@ export class GitStatusStore { * Group workspaces by project name. */ private groupWorkspacesByProject( - metadata: Map - ): Map { - const groups = new Map(); + metadata: Map + ): Map { + const groups = new Map(); for (const m of metadata.values()) { const projectName = m.projectName; @@ -278,7 +278,7 @@ export class GitStatusStore { /** * Try to fetch the project that needs it most urgently. */ - private tryFetchNextProject(projectGroups: Map): void { + private tryFetchNextProject(projectGroups: Map): void { let targetProject: string | null = null; let targetWorkspaceId: string | null = null; let oldestTime = Date.now(); diff --git a/src/stores/WorkspaceStore.test.ts b/src/stores/WorkspaceStore.test.ts index 05f601662..a10d7064b 100644 --- a/src/stores/WorkspaceStore.test.ts +++ b/src/stores/WorkspaceStore.test.ts @@ -1,5 +1,5 @@ +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import { WorkspaceStore } from "./WorkspaceStore"; -import type { WorkspaceMetadata } from "@/types/workspace"; // Mock window.api const mockExecuteBash = jest.fn(() => ({ @@ -62,10 +62,12 @@ describe("WorkspaceStore", () => { const unsubscribe = store.subscribe(listener); // Create workspace metadata - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; // Add workspace (should trigger IPC subscription) @@ -87,10 +89,12 @@ describe("WorkspaceStore", () => { const listener = jest.fn(); const unsubscribe = store.subscribe(listener); - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -107,10 +111,12 @@ describe("WorkspaceStore", () => { describe("syncWorkspaces", () => { it("should add new workspaces", () => { - const metadata1: WorkspaceMetadata = { + const metadata1: FrontendWorkspaceMetadata = { id: "workspace-1", + name: "workspace-1", projectName: "project-1", - workspacePath: "/path/1", + projectPath: "/project-1", + namedWorkspacePath: "/path/1", }; const workspaceMap = new Map([[metadata1.id, metadata1]]); @@ -123,10 +129,12 @@ describe("WorkspaceStore", () => { }); it("should remove deleted workspaces", () => { - const metadata1: WorkspaceMetadata = { + const metadata1: FrontendWorkspaceMetadata = { id: "workspace-1", + name: "workspace-1", projectName: "project-1", - workspacePath: "/path/1", + projectPath: "/project-1", + namedWorkspacePath: "/path/1", }; // Add workspace @@ -183,10 +191,12 @@ describe("WorkspaceStore", () => { describe("model tracking", () => { it("should call onModelUsed when stream starts", async () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -225,10 +235,12 @@ describe("WorkspaceStore", () => { }); it("getWorkspaceState() returns same reference when state hasn't changed", () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -241,7 +253,7 @@ describe("WorkspaceStore", () => { const listener = jest.fn(); store.subscribe(listener); - const metadata = new Map(); + const metadata = new Map(); store.syncWorkspaces(metadata); expect(listener).not.toHaveBeenCalled(); @@ -274,10 +286,12 @@ describe("WorkspaceStore", () => { describe("cache invalidation", () => { it("invalidates getWorkspaceState() cache when workspace changes", async () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -310,10 +324,12 @@ describe("WorkspaceStore", () => { }); it("invalidates getAllStates() cache when workspace changes", async () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -345,10 +361,12 @@ describe("WorkspaceStore", () => { }); it("invalidates getWorkspaceRecency() cache when workspace changes", async () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -366,10 +384,12 @@ describe("WorkspaceStore", () => { }); it("maintains cache when no changes occur", () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -392,10 +412,12 @@ describe("WorkspaceStore", () => { describe("race conditions", () => { it("handles IPC message for removed workspace gracefully", async () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); @@ -436,15 +458,19 @@ describe("WorkspaceStore", () => { }); it("handles concurrent workspace additions", () => { - const metadata1: WorkspaceMetadata = { + const metadata1: FrontendWorkspaceMetadata = { id: "workspace-1", + name: "workspace-1", projectName: "project-1", - workspacePath: "/path/1", + projectPath: "/project-1", + namedWorkspacePath: "/path/1", }; - const metadata2: WorkspaceMetadata = { + const metadata2: FrontendWorkspaceMetadata = { id: "workspace-2", + name: "workspace-2", projectName: "project-2", - workspacePath: "/path/2", + projectPath: "/project-2", + namedWorkspacePath: "/path/2", }; // Add workspaces concurrently @@ -458,10 +484,12 @@ describe("WorkspaceStore", () => { }); it("handles workspace removal during state access", () => { - const metadata: WorkspaceMetadata = { + const metadata: FrontendWorkspaceMetadata = { id: "test-workspace", + name: "test-workspace", projectName: "test-project", - workspacePath: "/test/path", + projectPath: "/test/project", + namedWorkspacePath: "/test/project/test-workspace", }; store.addWorkspace(metadata); diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index d5cb86245..881e106fb 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -1,6 +1,6 @@ import type { CmuxMessage, DisplayedMessage } from "@/types/message"; import { createCmuxMessage } from "@/types/message"; -import type { WorkspaceMetadata } from "@/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage } from "@/types/ipc"; import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; @@ -265,7 +265,7 @@ export class WorkspaceStore { /** * Add a workspace and subscribe to its IPC events. */ - addWorkspace(metadata: WorkspaceMetadata): void { + addWorkspace(metadata: FrontendWorkspaceMetadata): void { const workspaceId = metadata.id; // Skip if already subscribed @@ -322,7 +322,7 @@ export class WorkspaceStore { /** * Sync workspaces with metadata - add new, remove deleted. */ - syncWorkspaces(workspaceMetadata: Map): void { + syncWorkspaces(workspaceMetadata: Map): void { const metadataIds = new Set(Array.from(workspaceMetadata.values()).map((m) => m.id)); const currentIds = new Set(this.ipcUnsubscribers.keys()); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index ece311231..401db2cc1 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -1,5 +1,5 @@ import type { Result } from "./result"; -import type { WorkspaceMetadata } from "./workspace"; +import type { FrontendWorkspaceMetadata } from "./workspace"; import type { CmuxMessage, CmuxFrontendMetadata } from "./message"; import type { ProjectConfig } from "@/config"; import type { SendMessageError, StreamErrorType } from "./errors"; @@ -169,7 +169,7 @@ export interface IPCApi { projects: { create(projectPath: string): Promise>; remove(projectPath: string): Promise>; - list(): Promise; + list(): Promise>; listBranches(projectPath: string): Promise; secrets: { get(projectPath: string): Promise; @@ -177,12 +177,14 @@ export interface IPCApi { }; }; workspace: { - list(): Promise; + list(): Promise; create( projectPath: string, branchName: string, trunkBranch: string - ): Promise<{ success: true; metadata: WorkspaceMetadata } | { success: false; error: string }>; + ): Promise< + { success: true; metadata: FrontendWorkspaceMetadata } | { success: false; error: string } + >; remove( workspaceId: string, options?: { force?: boolean } @@ -206,7 +208,7 @@ export interface IPCApi { workspaceId: string, summaryMessage: CmuxMessage ): Promise>; - getInfo(workspaceId: string): Promise; + getInfo(workspaceId: string): Promise; executeBash( workspaceId: string, script: string, @@ -224,7 +226,7 @@ export interface IPCApi { // through continuous subscriptions rather than polling patterns. onChat(workspaceId: string, callback: (data: WorkspaceChatMessage) => void): () => void; onMetadata( - callback: (data: { workspaceId: string; metadata: WorkspaceMetadata }) => void + callback: (data: { workspaceId: string; metadata: FrontendWorkspaceMetadata }) => void ): () => void; }; window: { diff --git a/src/types/project.ts b/src/types/project.ts new file mode 100644 index 000000000..682aa4ace --- /dev/null +++ b/src/types/project.ts @@ -0,0 +1,44 @@ +/** + * Project and workspace configuration types. + * Kept lightweight for preload script usage. + */ + +/** + * Workspace configuration in config.json. + * + * NEW FORMAT (preferred, used for all new workspaces): + * { + * "path": "~/.cmux/src/project/workspace-id", // Kept for backward compat + * "id": "a1b2c3d4e5", // Stable workspace ID + * "name": "feature-branch", // User-facing name + * "createdAt": "2024-01-01T00:00:00Z" // Creation timestamp + * } + * + * LEGACY FORMAT (old workspaces, still supported): + * { + * "path": "~/.cmux/src/project/workspace-id" // Only field present + * } + * + * For legacy entries, metadata is read from ~/.cmux/sessions/{workspaceId}/metadata.json + */ +export interface Workspace { + /** Absolute path to workspace worktree - REQUIRED for backward compatibility */ + path: string; + + /** Stable workspace ID (10 hex chars for new workspaces) - optional for legacy */ + id?: string; + + /** User-facing workspace name - optional for legacy */ + name?: string; + + /** ISO 8601 creation timestamp - optional for legacy */ + createdAt?: string; +} + +export interface ProjectConfig { + workspaces: Workspace[]; +} + +export interface ProjectsConfig { + projects: Map; +} diff --git a/src/types/workspace.ts b/src/types/workspace.ts index a2e31461b..5198f2364 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -5,33 +5,51 @@ import { z } from "zod"; */ export const WorkspaceMetadataSchema = z.object({ id: z.string().min(1, "Workspace ID is required"), + name: z.string().min(1, "Workspace name is required"), projectName: z.string().min(1, "Project name is required"), - workspacePath: z.string().min(1, "Workspace path is required"), + projectPath: z.string().min(1, "Project path is required"), + createdAt: z.string().optional(), // ISO 8601 timestamp (optional for backward compatibility) + // Legacy field - ignored on load, removed on save + workspacePath: z.string().optional(), }); /** * Unified workspace metadata type used throughout the application. * This is the single source of truth for workspace information. * - * NOTE: This does NOT include branch name. Branch can be changed after workspace - * creation (user can switch branches in the worktree), and we should not depend - * on branch state in backend logic. Frontend can track branch for UI purposes. + * ID vs Name: + * - `id`: Stable unique identifier (10 hex chars for new workspaces, legacy format for old) + * Generated once at creation, never changes + * - `name`: User-facing mutable name (e.g., "feature-branch") + * Can be changed via rename operation + * + * For legacy workspaces created before stable IDs: + * - id and name are the same (e.g., "cmux-stable-ids") + * For new workspaces: + * - id is a random 10 hex char string (e.g., "a1b2c3d4e5") + * - name is the branch/workspace name (e.g., "feature-branch") + * + * Path handling: + * - Worktree paths are computed on-demand via config.getWorkspacePath(projectPath, id) + * - This avoids storing redundant derived data + * - Frontend can show symlink paths, backend uses real paths */ export interface WorkspaceMetadata { - /** Unique workspace identifier (e.g., "project-branch") */ + /** Stable unique identifier (10 hex chars for new workspaces, legacy format for old) */ id: string; - /** Project name extracted from project path */ + /** User-facing workspace name (e.g., "feature-branch") */ + name: string; + + /** Project name extracted from project path (for display) */ projectName: string; - /** Absolute path to the workspace worktree directory */ - workspacePath: string; -} + /** Absolute path to the project (needed to compute worktree path) */ + projectPath: string; -/** - * UI-facing workspace metadata. - */ -export type WorkspaceMetadataUI = WorkspaceMetadata; + /** ISO 8601 timestamp of when workspace was created (optional for backward compatibility) */ + createdAt?: string; +} /** * Git status for a workspace (ahead/behind relative to origin's primary branch) @@ -43,11 +61,26 @@ export interface GitStatus { dirty: boolean; } +/** + * Frontend workspace metadata enriched with computed paths. + * Backend computes these paths to avoid duplication of path construction logic. + * Follows naming convention: Backend types vs Frontend types. + */ +export interface FrontendWorkspaceMetadata extends WorkspaceMetadata { + /** Worktree path (uses workspace name as directory) */ + namedWorkspacePath: string; +} + +/** + * @deprecated Use FrontendWorkspaceMetadata instead + */ +export type WorkspaceMetadataWithPaths = FrontendWorkspaceMetadata; + /** * Frontend-enriched workspace metadata with additional UI-specific data. * Extends backend WorkspaceMetadata with frontend-computed information. */ -export interface DisplayedWorkspaceMetadata extends WorkspaceMetadata { +export interface DisplayedWorkspaceMetadata extends FrontendWorkspaceMetadata { /** Git status relative to origin's primary branch (null if not available) */ gitStatus: GitStatus | null; } diff --git a/src/utils/commands/sources.test.ts b/src/utils/commands/sources.test.ts index 3f7f1eaa1..b2e98e13c 100644 --- a/src/utils/commands/sources.test.ts +++ b/src/utils/commands/sources.test.ts @@ -1,23 +1,34 @@ import { buildCoreSources } from "./sources"; import type { ProjectConfig } from "@/config"; -import type { WorkspaceMetadata } from "@/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; const mk = (over: Partial[0]> = {}) => { const projects = new Map(); - projects.set("/repo/a", { path: "/repo/a", workspaces: [{ path: "/repo/a/feat-x" }] }); - const workspaceMetadata = new Map(); - workspaceMetadata.set("/repo/a/feat-x", { + projects.set("/repo/a", { + workspaces: [{ path: "/repo/a/feat-x" }, { path: "/repo/a/feat-y" }], + }); + const workspaceMetadata = new Map(); + workspaceMetadata.set("w1", { id: "w1", + name: "feat-x", projectName: "a", - workspacePath: "/repo/a/feat-x", - } as WorkspaceMetadata); + projectPath: "/repo/a", + namedWorkspacePath: "/repo/a/feat-x", + }); + workspaceMetadata.set("w2", { + id: "w2", + name: "feat-y", + projectName: "a", + projectPath: "/repo/a", + namedWorkspacePath: "/repo/a/feat-y", + }); const params: Parameters[0] = { projects, workspaceMetadata, selectedWorkspace: { projectPath: "/repo/a", projectName: "a", - workspacePath: "/repo/a/feat-x", + namedWorkspacePath: "/repo/a/feat-x", workspaceId: "w1", }, streamingModels: new Map(), diff --git a/src/utils/commands/sources.ts b/src/utils/commands/sources.ts index 50d09020c..a3474d518 100644 --- a/src/utils/commands/sources.ts +++ b/src/utils/commands/sources.ts @@ -4,16 +4,17 @@ import type { ThinkingLevel } from "@/types/thinking"; import { CUSTOM_EVENTS } from "@/constants/events"; import type { ProjectConfig } from "@/config"; -import type { WorkspaceMetadata } from "@/types/workspace"; +import type { FrontendWorkspaceMetadata } from "@/types/workspace"; import type { BranchListResult } from "@/types/ipc"; export interface BuildSourcesParams { projects: Map; - workspaceMetadata: Map; + /** Map of workspace ID to workspace metadata (keyed by metadata.id, not path) */ + workspaceMetadata: Map; selectedWorkspace: { projectPath: string; projectName: string; - workspacePath: string; + namedWorkspacePath: string; workspaceId: string; } | null; streamingModels?: Map; @@ -31,7 +32,7 @@ export interface BuildSourcesParams { onSelectWorkspace: (sel: { projectPath: string; projectName: string; - workspacePath: string; + namedWorkspacePath: string; workspaceId: string; }) => void; onRemoveWorkspace: (workspaceId: string) => Promise<{ success: boolean; error?: string }>; @@ -43,7 +44,7 @@ export interface BuildSourcesParams { onRemoveProject: (path: string) => void; onToggleSidebar: () => void; onNavigateWorkspace: (dir: "next" | "prev") => void; - onOpenWorkspaceInTerminal: (workspacePath: string) => void; + onOpenWorkspaceInTerminal: (workspaceId: string) => void; } const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high"]; @@ -132,33 +133,29 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi } // Switch to workspace - for (const [projectPath, config] of p.projects.entries()) { - const projectName = projectPath.split("/").pop() ?? projectPath; - for (const ws of config.workspaces) { - const meta = p.workspaceMetadata.get(ws.path); - if (!meta) continue; - const isCurrent = selected?.workspaceId === meta.id; - const isStreaming = p.streamingModels?.has(meta.id) ?? false; - list.push({ - id: `ws:switch:${meta.id}`, - title: `${isCurrent ? "• " : ""}Switch to ${ws.path.split("/").pop() ?? ws.path}`, - subtitle: `${projectName}${isStreaming ? " • streaming" : ""}`, - section: section.workspaces, - keywords: [projectName, ws.path], - run: () => - p.onSelectWorkspace({ - projectPath, - projectName, - workspacePath: ws.path, - workspaceId: meta.id, - }), - }); - } + // Iterate through all workspace metadata (now keyed by workspace ID) + for (const meta of p.workspaceMetadata.values()) { + const isCurrent = selected?.workspaceId === meta.id; + const isStreaming = p.streamingModels?.has(meta.id) ?? false; + list.push({ + id: `ws:switch:${meta.id}`, + title: `${isCurrent ? "• " : ""}Switch to ${meta.name}`, + subtitle: `${meta.projectName}${isStreaming ? " • streaming" : ""}`, + section: section.workspaces, + keywords: [meta.name, meta.projectName, meta.namedWorkspacePath], + run: () => + p.onSelectWorkspace({ + projectPath: meta.projectPath, + projectName: meta.projectName, + namedWorkspacePath: meta.namedWorkspacePath, + workspaceId: meta.id, + }), + }); } // Remove current workspace (rename action intentionally omitted until we add a proper modal) - if (selected) { - const workspaceDisplayName = `${selected.projectName}/${selected.workspacePath?.split("/").pop() ?? selected.workspacePath}`; + if (selected?.namedWorkspacePath) { + const workspaceDisplayName = `${selected.projectName}/${selected.namedWorkspacePath.split("/").pop() ?? selected.namedWorkspacePath}`; list.push({ id: "ws:open-terminal-current", title: "Open Current Workspace in Terminal", @@ -166,7 +163,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi section: section.workspaces, shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL), run: () => { - p.onOpenWorkspaceInTerminal(selected.workspacePath); + p.onOpenWorkspaceInTerminal(selected.workspaceId); }, }); list.push({ @@ -193,8 +190,9 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi name: "newName", label: "New name", placeholder: "Enter new workspace name", - initialValue: selected.workspacePath?.split("/").pop() ?? "", - getInitialValue: () => selected.workspacePath?.split("/").pop() ?? "", + // Use workspace metadata name (not path) for initial value + initialValue: p.workspaceMetadata.get(selected.workspaceId)?.name ?? "", + getInitialValue: () => p.workspaceMetadata.get(selected.workspaceId)?.name ?? "", validate: (v) => (!v.trim() ? "Name is required" : null), }, ], @@ -216,23 +214,23 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi fields: [ { type: "select", - name: "workspacePath", + name: "workspaceId", label: "Workspace", placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath?.split("/").pop() ?? meta.workspacePath; - const label = `${meta.projectName} / ${workspaceName}`; + // Use workspace name instead of extracting from path + const label = `${meta.projectName} / ${meta.name}`; return { - id: meta.workspacePath, + id: meta.id, label, - keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + keywords: [meta.name, meta.projectName, meta.namedWorkspacePath, meta.id], }; }), }, ], onSubmit: (vals) => { - p.onOpenWorkspaceInTerminal(vals.workspacePath); + p.onOpenWorkspaceInTerminal(vals.workspaceId); }, }, }); @@ -251,12 +249,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath?.split("/").pop() ?? meta.workspacePath; - const label = `${meta.projectName} / ${workspaceName}`; + const label = `${meta.projectName} / ${meta.name}`; return { id: meta.id, label, - keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + keywords: [meta.name, meta.projectName, meta.namedWorkspacePath, meta.id], }; }), }, @@ -269,7 +266,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const meta = Array.from(p.workspaceMetadata.values()).find( (m) => m.id === values.workspaceId ); - return meta ? (meta.workspacePath?.split("/").pop() ?? "") : ""; + return meta ? meta.name : ""; }, validate: (v) => (!v.trim() ? "Name is required" : null), }, @@ -294,12 +291,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi placeholder: "Search workspaces…", getOptions: () => Array.from(p.workspaceMetadata.values()).map((meta) => { - const workspaceName = meta.workspacePath?.split("/").pop() ?? meta.workspacePath; - const label = `${meta.projectName}/${workspaceName}`; + const label = `${meta.projectName}/${meta.name}`; return { id: meta.id, label, - keywords: [workspaceName, meta.projectName, meta.workspacePath, meta.id], + keywords: [meta.name, meta.projectName, meta.namedWorkspacePath, meta.id], }; }), }, @@ -308,9 +304,7 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const meta = Array.from(p.workspaceMetadata.values()).find( (m) => m.id === vals.workspaceId ); - const workspaceName = meta - ? `${meta.projectName}/${meta.workspacePath?.split("/").pop() ?? meta.workspacePath}` - : vals.workspaceId; + const workspaceName = meta ? `${meta.projectName}/${meta.name}` : vals.workspaceId; const ok = confirm(`Remove workspace ${workspaceName}? This cannot be undone.`); if (ok) { await p.onRemoveWorkspace(vals.workspaceId); diff --git a/tests/e2e/utils/demoProject.ts b/tests/e2e/utils/demoProject.ts index f40458206..36cd183f8 100644 --- a/tests/e2e/utils/demoProject.ts +++ b/tests/e2e/utils/demoProject.ts @@ -54,12 +54,13 @@ export function prepareDemoProject( const workspaceId = config.generateWorkspaceId(projectPath, workspacePath); const metadata = { id: workspaceId, + name: workspaceBranch, projectName, - workspacePath, + projectPath, }; const configPayload = { - projects: [[projectPath, { path: projectPath, workspaces: [{ path: workspacePath }] }]], + projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], } as const; fs.writeFileSync(configPath, JSON.stringify(configPayload, null, 2)); diff --git a/tests/ipcMain/createWorkspace.test.ts b/tests/ipcMain/createWorkspace.test.ts index d2797c081..34337e41a 100644 --- a/tests/ipcMain/createWorkspace.test.ts +++ b/tests/ipcMain/createWorkspace.test.ts @@ -77,7 +77,8 @@ describeIntegration("IpcMain create workspace integration tests", () => { } expect(createResult.success).toBe(true); expect(createResult.metadata.id).toBeDefined(); - expect(createResult.metadata.workspacePath).toBeDefined(); + expect(createResult.metadata.namedWorkspacePath).toBeDefined(); + expect(createResult.metadata.namedWorkspacePath).toBeDefined(); expect(createResult.metadata.projectName).toBeDefined(); // Clean up the workspace diff --git a/tests/ipcMain/executeBash.test.ts b/tests/ipcMain/executeBash.test.ts index a0eeedcee..47884203b 100644 --- a/tests/ipcMain/executeBash.test.ts +++ b/tests/ipcMain/executeBash.test.ts @@ -26,7 +26,8 @@ describeIntegration("IpcMain executeBash integration tests", () => { try { // Create a workspace const createResult = await createWorkspace(env.mockIpcRenderer, tempGitRepo, "test-bash"); - const workspaceId = expectWorkspaceCreationSuccess(createResult).id; + const metadata = expectWorkspaceCreationSuccess(createResult); + const workspaceId = metadata.id; // Execute a simple bash command (pwd should return workspace path) const pwdResult = await env.mockIpcRenderer.invoke( @@ -37,7 +38,8 @@ describeIntegration("IpcMain executeBash integration tests", () => { expect(pwdResult.success).toBe(true); expect(pwdResult.data.success).toBe(true); - expect(pwdResult.data.output).toContain("test-bash"); + // Verify pwd output contains the workspace name (directories are named with workspace names) + expect(pwdResult.data.output).toContain(metadata.name); expect(pwdResult.data.exitCode).toBe(0); // Clean up diff --git a/tests/ipcMain/helpers.ts b/tests/ipcMain/helpers.ts index 70481737d..bff1647e0 100644 --- a/tests/ipcMain/helpers.ts +++ b/tests/ipcMain/helpers.ts @@ -3,7 +3,7 @@ import { IPC_CHANNELS, getChatChannel } from "../../src/constants/ipc-constants" import type { SendMessageOptions, WorkspaceChatMessage } from "../../src/types/ipc"; import type { Result } from "../../src/types/result"; import type { SendMessageError } from "../../src/types/errors"; -import type { WorkspaceMetadata } from "../../src/types/workspace"; +import type { WorkspaceMetadataWithPaths } from "../../src/types/workspace"; import * as path from "path"; import * as os from "os"; import { detectDefaultTrunkBranch } from "../../src/git"; @@ -67,7 +67,9 @@ export async function createWorkspace( projectPath: string, branchName: string, trunkBranch?: string -): Promise<{ success: true; metadata: WorkspaceMetadata } | { success: false; error: string }> { +): Promise< + { success: true; metadata: WorkspaceMetadataWithPaths } | { success: false; error: string } +> { const resolvedTrunk = typeof trunkBranch === "string" && trunkBranch.trim().length > 0 ? trunkBranch.trim() @@ -78,7 +80,7 @@ export async function createWorkspace( projectPath, branchName, resolvedTrunk - )) as { success: true; metadata: WorkspaceMetadata } | { success: false; error: string }; + )) as { success: true; metadata: WorkspaceMetadataWithPaths } | { success: false; error: string }; } /** diff --git a/tests/ipcMain/removeWorkspace.test.ts b/tests/ipcMain/removeWorkspace.test.ts index 88746c95a..408094d37 100644 --- a/tests/ipcMain/removeWorkspace.test.ts +++ b/tests/ipcMain/removeWorkspace.test.ts @@ -31,7 +31,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.workspacePath; + const workspacePath = metadata.namedWorkspacePath; // Verify the worktree exists const worktreeExistsBefore = await fs @@ -40,6 +40,15 @@ describeIntegration("IpcMain remove workspace integration tests", () => { .catch(() => false); expect(worktreeExistsBefore).toBe(true); + // Get the symlink path before removing + const projectName = tempGitRepo.split("/").pop() || "unknown"; + const symlinkPath = `${env.config.srcDir}/${projectName}/${metadata.name}`; + const symlinkExistsBefore = await fs + .lstat(symlinkPath) + .then(() => true) + .catch(() => false); + expect(symlinkExistsBefore).toBe(true); + // Remove the workspace const removeResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_REMOVE, @@ -51,6 +60,13 @@ describeIntegration("IpcMain remove workspace integration tests", () => { const worktreeRemoved = await waitForFileNotExists(workspacePath, 5000); expect(worktreeRemoved).toBe(true); + // Verify symlink is removed + const symlinkExistsAfter = await fs + .lstat(symlinkPath) + .then(() => true) + .catch(() => false); + expect(symlinkExistsAfter).toBe(false); + // Verify workspace is no longer in config const config = env.config.loadConfigOrDefault(); const project = config.projects.get(tempGitRepo); @@ -104,7 +120,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.workspacePath; + const workspacePath = metadata.namedWorkspacePath; // Manually delete the worktree directory (simulating external deletion) await fs.rm(workspacePath, { recursive: true, force: true }); @@ -158,7 +174,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.workspacePath; + const workspacePath = metadata.namedWorkspacePath; // Initialize submodule in the worktree const { exec } = await import("child_process"); @@ -212,7 +228,7 @@ describeIntegration("IpcMain remove workspace integration tests", () => { } const { metadata } = createResult; - const workspacePath = metadata.workspacePath; + const workspacePath = metadata.namedWorkspacePath; // Initialize submodule in the worktree const { exec } = await import("child_process"); diff --git a/tests/ipcMain/renameWorkspace.test.ts b/tests/ipcMain/renameWorkspace.test.ts index 238e848b8..8abe6ba75 100644 --- a/tests/ipcMain/renameWorkspace.test.ts +++ b/tests/ipcMain/renameWorkspace.test.ts @@ -9,6 +9,7 @@ import { import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; import type { CmuxMessage } from "../../src/types/message"; import * as fs from "fs/promises"; +import * as fsSync from "fs"; // Skip all tests if TEST_INTEGRATION is not set const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; @@ -25,19 +26,26 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const { env, workspaceId, workspacePath, tempGitRepo, branchName, cleanup } = await setupWorkspace("anthropic"); try { - // Note: setupWorkspace already called WORKSPACE_CREATE which adds both - // the project and workspace to config, so no manual config manipulation needed + // Add project and workspace to config via IPC + await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); + // Manually add workspace to the project (normally done by WORKSPACE_CREATE) + const projectsConfig = env.config.loadConfigOrDefault(); + const projectConfig = projectsConfig.projects.get(tempGitRepo); + if (projectConfig) { + projectConfig.workspaces.push({ + path: workspacePath, + id: workspaceId, + name: branchName, + }); + env.config.saveConfig(projectsConfig); + } const oldSessionDir = env.config.getSessionDir(workspaceId); const oldMetadataResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_GET_INFO, workspaceId ); expect(oldMetadataResult).toBeTruthy(); - const oldWorkspacePath = oldMetadataResult.workspacePath; - - // Verify old session directory exists (with retry for timing) - const oldDirExists = await waitForFileExists(oldSessionDir); - expect(oldDirExists).toBe(true); + const oldWorkspacePath = oldMetadataResult.namedWorkspacePath; // Clear events before rename env.sentEvents.length = 0; @@ -59,61 +67,53 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const newWorkspaceId = renameResult.data.newWorkspaceId; const projectName = oldMetadataResult.projectName; // Still need this for assertions - // Verify new session directory exists (with retry for timing) - const newSessionDir = env.config.getSessionDir(newWorkspaceId); - const newDirExists = await waitForFileExists(newSessionDir); - expect(newDirExists).toBe(true); + // With stable IDs, workspace ID should NOT change during rename + expect(newWorkspaceId).toBe(workspaceId); - // Verify old session directory no longer exists (with retry for timing) - const oldDirGone = await waitForFileNotExists(oldSessionDir); - expect(oldDirGone).toBe(true); + // Session directory should still be the same (stable IDs don't move directories) + const sessionDir = env.config.getSessionDir(workspaceId); + expect(sessionDir).toBe(oldSessionDir); - // Verify metadata was updated + // Verify metadata was updated (name changed, path changed, but ID stays the same) const newMetadataResult = await env.mockIpcRenderer.invoke( IPC_CHANNELS.WORKSPACE_GET_INFO, - newWorkspaceId + workspaceId // Use same workspace ID ); expect(newMetadataResult).toBeTruthy(); - expect(newMetadataResult.id).toBe(newWorkspaceId); + expect(newMetadataResult.id).toBe(workspaceId); // ID unchanged + expect(newMetadataResult.name).toBe(newName); // Name updated expect(newMetadataResult.projectName).toBe(projectName); - expect(newMetadataResult.workspacePath).not.toBe(oldWorkspacePath); - // Verify old workspace no longer exists - const oldMetadataAfterRename = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_GET_INFO, - workspaceId - ); - expect(oldMetadataAfterRename).toBeNull(); + // Path DOES change (directory is renamed from old name to new name) + const newWorkspacePath = newMetadataResult.namedWorkspacePath; + expect(newWorkspacePath).not.toBe(oldWorkspacePath); + expect(newWorkspacePath).toContain(newName); // New path includes new name - // Verify config was updated - workspace path should match new metadata + // Verify config was updated with new path const config = env.config.loadConfigOrDefault(); let foundWorkspace = false; for (const [, projectConfig] of config.projects.entries()) { - const workspace = projectConfig.workspaces.find( - (w) => w.path === newMetadataResult.workspacePath - ); + const workspace = projectConfig.workspaces.find((w) => w.path === newWorkspacePath); if (workspace) { foundWorkspace = true; + expect(workspace.name).toBe(newName); // Name updated in config + expect(workspace.id).toBe(workspaceId); // ID unchanged break; } } expect(foundWorkspace).toBe(true); - // Verify metadata events were emitted (delete old, create new) + // Verify metadata event was emitted (update existing workspace) const metadataEvents = env.sentEvents.filter( (e) => e.channel === IPC_CHANNELS.WORKSPACE_METADATA ); - expect(metadataEvents.length).toBe(2); - // First event should be deletion of old workspace - expect(metadataEvents[0].data).toEqual({ + expect(metadataEvents.length).toBe(1); + // Event should be update of existing workspace + expect(metadataEvents[0].data).toMatchObject({ workspaceId, - metadata: null, - }); - // Second event should be creation of new workspace - expect(metadataEvents[1].data).toMatchObject({ - workspaceId: newWorkspaceId, metadata: expect.objectContaining({ - id: newWorkspaceId, + id: workspaceId, + name: newName, projectName, }), }); @@ -121,7 +121,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { await cleanup(); } }, - 15000 + 30000 // Increased timeout to debug hanging test ); test.concurrent( @@ -167,8 +167,19 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const { env, workspaceId, workspacePath, tempGitRepo, branchName, cleanup } = await setupWorkspace("anthropic"); try { - // Note: setupWorkspace already called WORKSPACE_CREATE which adds both - // the project and workspace to config, so no manual config manipulation needed + // Add project and workspace to config via IPC + await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); + // Manually add workspace to the project (normally done by WORKSPACE_CREATE) + const projectsConfig = env.config.loadConfigOrDefault(); + const projectConfig = projectsConfig.projects.get(tempGitRepo); + if (projectConfig) { + projectConfig.workspaces.push({ + path: workspacePath, + id: workspaceId, + name: branchName, + }); + env.config.saveConfig(projectsConfig); + } // Get current metadata const oldMetadata = await env.mockIpcRenderer.invoke( @@ -193,7 +204,7 @@ describeIntegration("IpcMain rename workspace integration tests", () => { ); expect(newMetadata).toBeTruthy(); expect(newMetadata.id).toBe(workspaceId); - expect(newMetadata.workspacePath).toBe(oldMetadata.workspacePath); + expect(newMetadata.namedWorkspacePath).toBe(oldMetadata.namedWorkspacePath); } finally { await cleanup(); } @@ -221,43 +232,6 @@ describeIntegration("IpcMain rename workspace integration tests", () => { 15000 ); - test.concurrent( - "should block rename during active stream and require Esc first", - async () => { - const { env, workspaceId, cleanup } = await setupWorkspace("anthropic"); - try { - // Clear events before starting stream - env.sentEvents.length = 0; - - // Start a long-running stream - void sendMessageWithModel( - env.mockIpcRenderer, - workspaceId, - "Run this bash command: for i in {1..60}; do sleep 0.5; done && echo done" - ); - - // Wait for stream to start - const startCollector = createEventCollector(env.sentEvents, workspaceId); - await startCollector.waitForEvent("stream-start", 10000); - - // Try to rename during active stream - should be blocked - const renameResult = await env.mockIpcRenderer.invoke( - IPC_CHANNELS.WORKSPACE_RENAME, - workspaceId, - "new-name" - ); - expect(renameResult.success).toBe(false); - expect(renameResult.error).toContain("stream is active"); - expect(renameResult.error).toContain("Press Esc"); - - // Test passed - rename was successfully blocked during active stream - } finally { - await cleanup(); - } - }, - 15000 - ); - test.concurrent( "should fail to rename with invalid workspace name", async () => { @@ -303,8 +277,19 @@ describeIntegration("IpcMain rename workspace integration tests", () => { const { env, workspaceId, workspacePath, tempGitRepo, branchName, cleanup } = await setupWorkspace("anthropic"); try { - // Note: setupWorkspace already called WORKSPACE_CREATE which adds both - // the project and workspace to config, so no manual config manipulation needed + // Add project and workspace to config via IPC + await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); + // Manually add workspace to the project (normally done by WORKSPACE_CREATE) + const projectsConfig = env.config.loadConfigOrDefault(); + const projectConfig = projectsConfig.projects.get(tempGitRepo); + if (projectConfig) { + projectConfig.workspaces.push({ + path: workspacePath, + id: workspaceId, + name: branchName, + }); + env.config.saveConfig(projectsConfig); + } // Send a message to create some history env.sentEvents.length = 0; const result = await sendMessageWithModel(env.mockIpcRenderer, workspaceId, "What is 2+2?"); @@ -352,11 +337,22 @@ describeIntegration("IpcMain rename workspace integration tests", () => { test.concurrent( "should support editing messages after rename", async () => { - const { env, workspaceId, workspacePath, tempGitRepo, cleanup } = + const { env, workspaceId, workspacePath, tempGitRepo, branchName, cleanup } = await setupWorkspace("anthropic"); try { - // Note: setupWorkspace already called WORKSPACE_CREATE which adds both - // the project and workspace to config, so no manual config manipulation needed + // Add project and workspace to config via IPC + await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); + // Manually add workspace to the project (normally done by WORKSPACE_CREATE) + const projectsConfig = env.config.loadConfigOrDefault(); + const projectConfig = projectsConfig.projects.get(tempGitRepo); + if (projectConfig) { + projectConfig.workspaces.push({ + path: workspacePath, + id: workspaceId, + name: branchName, + }); + env.config.saveConfig(projectsConfig); + } // Send a message to create history before rename env.sentEvents.length = 0; @@ -438,4 +434,56 @@ describeIntegration("IpcMain rename workspace integration tests", () => { }, 30000 ); + + test.concurrent( + "should fail to rename if workspace is currently streaming", + async () => { + const { env, workspaceId, tempGitRepo, branchName, cleanup } = + await setupWorkspace("anthropic"); + try { + // Add project and workspace to config via IPC + await env.mockIpcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, tempGitRepo); + const projectsConfig = env.config.loadConfigOrDefault(); + const projectConfig = projectsConfig.projects.get(tempGitRepo); + if (projectConfig) { + const workspacePath = env.config.getWorkspacePath(tempGitRepo, branchName); + projectConfig.workspaces.push({ + path: workspacePath, + id: workspaceId, + name: branchName, + }); + env.config.saveConfig(projectsConfig); + } + + // Start a stream (don't await - we want it running) + sendMessageWithModel( + env.mockIpcRenderer, + workspaceId, + "What is 2+2?" // Simple query that should complete quickly + ); + + // Wait for stream to actually start + const collector = createEventCollector(env.sentEvents, workspaceId); + await collector.waitForEvent("stream-start", 5000); + + // Attempt to rename while streaming - should fail + const newName = "renamed-during-stream"; + const renameResult = await env.mockIpcRenderer.invoke( + IPC_CHANNELS.WORKSPACE_RENAME, + workspaceId, + newName + ); + + // Verify rename was blocked due to active stream + expect(renameResult.success).toBe(false); + expect(renameResult.error).toContain("stream is active"); + + // Wait for stream to complete + await collector.waitForEvent("stream-end", 10000); + } finally { + await cleanup(); + } + }, + 20000 + ); }); diff --git a/tests/ipcMain/setup.ts b/tests/ipcMain/setup.ts index 2c1acae57..60a95d18f 100644 --- a/tests/ipcMain/setup.ts +++ b/tests/ipcMain/setup.ts @@ -10,6 +10,7 @@ import { IPC_CHANNELS } from "../../src/constants/ipc-constants"; import { generateBranchName, createWorkspace } from "./helpers"; import { shouldRunIntegrationTests, validateApiKeys, getApiKey } from "../testUtils"; import { loadTokenizerModules } from "../../src/utils/main/tokenizer"; +import { preloadAISDKProviders } from "../../src/services/aiService"; export interface TestEnvironment { config: Config; @@ -154,6 +155,10 @@ export async function setupWorkspace( // Without this, tests would use /4 approximation which can cause API errors await loadTokenizerModules(); + // Preload AI SDK providers to avoid race conditions with dynamic imports + // in concurrent test environments + await preloadAISDKProviders(); + // Create dedicated temp git repo for this test const tempGitRepo = await createTempGitRepo(); @@ -178,7 +183,7 @@ export async function setupWorkspace( throw new Error("Workspace ID not returned from creation"); } - if (!createResult.metadata.workspacePath) { + if (!createResult.metadata.namedWorkspacePath) { await cleanupTempGitRepo(tempGitRepo); throw new Error("Workspace path not returned from creation"); } @@ -194,7 +199,7 @@ export async function setupWorkspace( return { env, workspaceId: createResult.metadata.id, - workspacePath: createResult.metadata.workspacePath, + workspacePath: createResult.metadata.namedWorkspacePath, branchName, tempGitRepo, cleanup, @@ -232,7 +237,7 @@ export async function setupWorkspaceWithoutProvider(branchPrefix?: string): Prom throw new Error("Workspace ID not returned from creation"); } - if (!createResult.metadata.workspacePath) { + if (!createResult.metadata.namedWorkspacePath) { await cleanupTempGitRepo(tempGitRepo); throw new Error("Workspace path not returned from creation"); } @@ -247,7 +252,7 @@ export async function setupWorkspaceWithoutProvider(branchPrefix?: string): Prom return { env, workspaceId: createResult.metadata.id, - workspacePath: createResult.metadata.workspacePath, + workspacePath: createResult.metadata.namedWorkspacePath, branchName, tempGitRepo, cleanup,