Skip to content

Commit 0384d7f

Browse files
committed
Add stable workspace IDs
Workspaces now use stable, unique IDs (10 hex chars) instead of deriving IDs from paths. This simplifies workspace renames from 150 lines of complex migration logic to ~60 lines of metadata updates. ## Core Changes **Stable ID Generation:** - Generate at workspace creation: `crypto.randomBytes(5).toString('hex')` - Separate `id` (stable, immutable) from `name` (mutable, user-facing) - Add symlinks for UX: `~/.cmux/src/<project>/<name>` → `<id>` **Type System:** - `WorkspaceMetadata`: Backend type with stable ID, no path field - `WorkspaceMetadataWithPaths`: Frontend type with computed paths - IPC layer enriches metadata with `stableWorkspacePath` and `namedWorkspacePath` **Workspace Operations:** - Create: Generate stable ID before creating worktree - Rename: Update metadata + symlink only (ID unchanged, ~60 lines) - Remove: Clean up worktree, session data, and symlinks **Frontend Integration:** - Build `pathToMetadata` map for lookups (handles both stable and legacy) - Use map lookups instead of parsing workspace IDs from paths - Support both new stable-ID workspaces and legacy name-based workspaces ## File Structure ``` # New workspace ~/.cmux/src/cmux/a1b2c3d4e5/ # Worktree (stable ID) ~/.cmux/src/cmux/feature-branch → a1b2c3d4e5 # Symlink ~/.cmux/sessions/a1b2c3d4e5/ # Session data # Legacy workspace (unchanged) ~/.cmux/src/cmux/stable-ids/ # Worktree ~/.cmux/sessions/cmux-stable-ids/ # Session data ``` ## Benefits - **Instant renames**: No file moves, just metadata update - **Simpler code**: Removed 90 lines of complex migration/rollback logic - **Better UX**: Symlinks let users navigate by readable names - **Stable references**: Chat history, config stay valid across renames - **Future-proof**: Enables workspace aliases, templates, cross-project refs ## Testing - ✅ 511 unit tests pass - ✅ 8 rename integration tests pass - ✅ 5 remove integration tests pass - ✅ 13 E2E tests pass - ✅ 9 new config unit tests
1 parent 8de6d12 commit 0384d7f

29 files changed

+909
-392
lines changed

src/App.tsx

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { useState, useEffect, useCallback, useRef } from "react";
1+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
22
import styled from "@emotion/styled";
33
import { Global, css } from "@emotion/react";
44
import { GlobalColors } from "./styles/colors";
55
import { GlobalFonts } from "./styles/fonts";
66
import { GlobalScrollbars } from "./styles/scrollbars";
77
import type { ProjectConfig } from "./config";
88
import type { WorkspaceSelection } from "./components/ProjectSidebar";
9+
import type { WorkspaceMetadataWithPaths } from "./types/workspace";
910
import { LeftSidebar } from "./components/LeftSidebar";
1011
import NewWorkspaceModal from "./components/NewWorkspaceModal";
1112
import { AIView } from "./components/AIView";
@@ -172,6 +173,17 @@ function AppInner() {
172173
onSelectedWorkspaceUpdate: setSelectedWorkspace,
173174
});
174175

176+
// Build path-to-metadata lookup map (handles both stable and legacy paths)
177+
const pathToMetadata = useMemo(() => {
178+
const map = new Map<string, WorkspaceMetadataWithPaths>();
179+
for (const metadata of workspaceMetadata.values()) {
180+
// Map both stable path (with ID) and named path (with name/legacy)
181+
map.set(metadata.stableWorkspacePath, metadata);
182+
map.set(metadata.namedWorkspacePath, metadata);
183+
}
184+
return map;
185+
}, [workspaceMetadata]);
186+
175187
// NEW: Sync workspace metadata with the stores
176188
const workspaceStore = useWorkspaceStoreRaw();
177189
const gitStatusStore = useGitStatusStoreRaw();
@@ -230,21 +242,13 @@ function AppInner() {
230242
const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId);
231243

232244
if (metadata) {
233-
// Find project for this workspace
234-
for (const [projectPath, projectConfig] of projects.entries()) {
235-
const workspace = projectConfig.workspaces.find(
236-
(ws) => ws.path === metadata.workspacePath
237-
);
238-
if (workspace) {
239-
setSelectedWorkspace({
240-
workspaceId: metadata.id,
241-
projectPath,
242-
projectName: metadata.projectName,
243-
workspacePath: metadata.workspacePath,
244-
});
245-
break;
246-
}
247-
}
245+
// Find project for this workspace (metadata now includes projectPath)
246+
setSelectedWorkspace({
247+
workspaceId: metadata.id,
248+
projectPath: metadata.projectPath,
249+
projectName: metadata.projectName,
250+
workspacePath: metadata.stableWorkspacePath,
251+
});
248252
}
249253
}
250254
// Only run on mount
@@ -329,8 +333,9 @@ function AppInner() {
329333
result.set(
330334
projectPath,
331335
config.workspaces.slice().sort((a, b) => {
332-
const aMeta = workspaceMetadata.get(a.path);
333-
const bMeta = workspaceMetadata.get(b.path);
336+
// Look up metadata by workspace path (handles both stable and legacy paths)
337+
const aMeta = pathToMetadata.get(a.path);
338+
const bMeta = pathToMetadata.get(b.path);
334339
if (!aMeta || !bMeta) return 0;
335340

336341
// Get timestamp of most recent user message (0 if never used)
@@ -354,7 +359,7 @@ function AppInner() {
354359
}
355360
return true;
356361
},
357-
[projects, workspaceMetadata, workspaceRecency]
362+
[projects, workspaceMetadata, workspaceRecency, pathToMetadata]
358363
);
359364

360365
const handleNavigateWorkspace = useCallback(
@@ -382,7 +387,8 @@ function AppInner() {
382387
const targetWorkspace = sortedWorkspaces[targetIndex];
383388
if (!targetWorkspace) return;
384389

385-
const metadata = workspaceMetadata.get(targetWorkspace.path);
390+
// Look up metadata by workspace path (handles both stable and legacy paths)
391+
const metadata = pathToMetadata.get(targetWorkspace.path);
386392
if (!metadata) return;
387393

388394
setSelectedWorkspace({
@@ -392,7 +398,7 @@ function AppInner() {
392398
workspaceId: metadata.id,
393399
});
394400
},
395-
[selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace]
401+
[selectedWorkspace, sortedWorkspacesByProject, pathToMetadata, setSelectedWorkspace]
396402
);
397403

398404
// Register command sources with registry

src/components/LeftSidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react";
22
import styled from "@emotion/styled";
33
import type { ProjectConfig } from "@/config";
4-
import type { WorkspaceMetadata } from "@/types/workspace";
4+
import type { WorkspaceMetadataWithPaths } from "@/types/workspace";
55
import type { WorkspaceSelection } from "./ProjectSidebar";
66
import type { Secret } from "@/types/secrets";
77
import ProjectSidebar from "./ProjectSidebar";
@@ -21,7 +21,7 @@ const LeftSidebarContainer = styled.div<{ collapsed?: boolean }>`
2121

2222
interface LeftSidebarProps {
2323
projects: Map<string, ProjectConfig>;
24-
workspaceMetadata: Map<string, WorkspaceMetadata>;
24+
workspaceMetadata: Map<string, WorkspaceMetadataWithPaths>;
2525
selectedWorkspace: WorkspaceSelection | null;
2626
onSelectWorkspace: (selection: WorkspaceSelection) => void;
2727
onAddProject: () => void;

src/components/ProjectSidebar.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
33
import styled from "@emotion/styled";
44
import { css } from "@emotion/react";
55
import type { ProjectConfig, Workspace } from "@/config";
6-
import type { WorkspaceMetadata } from "@/types/workspace";
6+
import type { WorkspaceMetadataWithPaths } from "@/types/workspace";
77
import { usePersistedState } from "@/hooks/usePersistedState";
88
import { DndProvider } from "react-dnd";
99
import { HTML5Backend, getEmptyImage } from "react-dnd-html5-backend";
@@ -471,7 +471,7 @@ const ProjectDragLayer: React.FC = () => {
471471

472472
interface ProjectSidebarProps {
473473
projects: Map<string, ProjectConfig>;
474-
workspaceMetadata: Map<string, WorkspaceMetadata>;
474+
workspaceMetadata: Map<string, WorkspaceMetadataWithPaths>;
475475
selectedWorkspace: WorkspaceSelection | null;
476476
onSelectWorkspace: (selection: WorkspaceSelection) => void;
477477
onAddProject: () => void;
@@ -512,6 +512,17 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
512512
onUpdateSecrets,
513513
sortedWorkspacesByProject,
514514
}) => {
515+
// Build path-to-metadata lookup map (handles both stable and legacy paths)
516+
const pathToMetadata = React.useMemo(() => {
517+
const map = new Map<string, WorkspaceMetadataWithPaths>();
518+
for (const metadata of workspaceMetadata.values()) {
519+
// Map both stable path (with ID) and named path (with name/legacy)
520+
map.set(metadata.stableWorkspacePath, metadata);
521+
map.set(metadata.namedWorkspacePath, metadata);
522+
}
523+
return map;
524+
}, [workspaceMetadata]);
525+
515526
// Workspace-specific subscriptions moved to WorkspaceListItem component
516527

517528
// Store as array in localStorage, convert to Set for usage
@@ -822,7 +833,8 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
822833
</WorkspaceHeader>
823834
{(sortedWorkspacesByProject.get(projectPath) ?? config.workspaces).map(
824835
(workspace) => {
825-
const metadata = workspaceMetadata.get(workspace.path);
836+
// Look up metadata by workspace path (handles both stable ID paths and legacy name paths)
837+
const metadata = pathToMetadata.get(workspace.path);
826838
if (!metadata) return null;
827839

828840
const workspaceId = metadata.id;
@@ -833,6 +845,7 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
833845
<WorkspaceListItem
834846
key={workspace.path}
835847
workspaceId={workspaceId}
848+
workspaceName={metadata.name}
836849
workspacePath={workspace.path}
837850
projectPath={projectPath}
838851
projectName={projectName}

src/components/WorkspaceListItem.tsx

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,6 @@ import { ModelDisplay } from "./Messages/ModelDisplay";
1010
import { StatusIndicator } from "./StatusIndicator";
1111
import { useRename } from "@/contexts/WorkspaceRenameContext";
1212

13-
// Helper function to extract workspace display name from path
14-
function getWorkspaceDisplayName(workspacePath: string): string {
15-
const pathParts = workspacePath.split("/");
16-
return pathParts[pathParts.length - 1] || "Unknown";
17-
}
18-
1913
// Styled Components
2014
const WorkspaceStatusIndicator = styled(StatusIndicator)`
2115
margin-left: 8px;
@@ -134,6 +128,7 @@ export interface WorkspaceSelection {
134128
export interface WorkspaceListItemProps {
135129
// Minimal data - component accesses stores directly for the rest
136130
workspaceId: string;
131+
workspaceName: string; // User-facing workspace name (from metadata)
137132
workspacePath: string;
138133
projectPath: string;
139134
projectName: string;
@@ -147,6 +142,7 @@ export interface WorkspaceListItemProps {
147142

148143
const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
149144
workspaceId,
145+
workspaceName,
150146
workspacePath,
151147
projectPath,
152148
projectName,
@@ -167,7 +163,8 @@ const WorkspaceListItemInner: React.FC<WorkspaceListItemProps> = ({
167163
const [editingName, setEditingName] = useState<string>("");
168164
const [renameError, setRenameError] = useState<string | null>(null);
169165

170-
const displayName = getWorkspaceDisplayName(workspacePath);
166+
// Use workspace name from metadata instead of deriving from path
167+
const displayName = workspaceName;
171168
const isStreaming = sidebarState.canInterrupt;
172169
const streamingModel = sidebarState.currentModel;
173170
const isEditing = editingWorkspaceId === workspaceId;

0 commit comments

Comments
 (0)