Skip to content

Commit a95d027

Browse files
authored
🤖 Add stable workspace IDs (#259)
## Summary 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. ## Changes **Core Implementation:** - Generate stable IDs at workspace creation using `crypto.randomBytes(5).toString('hex')` - Separate `id` (stable, immutable) from `name` (mutable, user-facing) - Add symlinks for UX: `~/.cmux/src/<project>/<name>` → `<id>` - Automatic migration for legacy workspaces on startup **Workspace Rename Simplified:** - Before: Move session dir, migrate message IDs, move worktree, complex rollback (150 lines) - After: Update metadata name field and symlink (60 lines) - Workspace ID never changes during rename **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 ``` ## Implementation Details **Type Changes:** - Added `WorkspaceMetadata.name` field (separate from `id`) - Added `WorkspaceMetadata.createdAt` timestamp (optional for backward compat) **Config Module:** - `generateStableId()`: Create 10-char hex IDs - `createWorkspaceSymlink()`, `updateWorkspaceSymlink()`, `removeWorkspaceSymlink()`: Manage symlinks - `getAllWorkspaceMetadata()`: Eager migration for legacy workspaces **Git Operations:** - Added `workspaceId` parameter to `CreateWorktreeOptions` - Use stable ID for worktree directory name **IPC Handlers:** - Workspace creation generates stable ID before creating worktree - Rename updates metadata + symlink only (ID unchanged) - Remove cleans up symlinks **Frontend:** - Pass `metadata.name` to WorkspaceListItem for display - Removed path parsing logic ## Testing - ✅ **511 unit tests** pass - ✅ **8 rename integration tests** pass - ✅ **5 remove integration tests** pass (including new symlink cleanup test) - ✅ **9 config unit tests** (new) ## Simplifications Applied 1. **Removed unused `WorkspaceMetadataUI` type alias** (-3 lines) 2. **Simplified `updateWorkspaceSymlink`** by reusing existing methods (-16 lines) 3. **Improved `removeWorkspaceSymlink`** robustness: - Fixed TOCTOU vulnerability (atomic check + operation) - Use `lstat` to avoid following symlinks - Handle ENOENT gracefully 4. **Added symlink cleanup on workspace removal** (+10 lines, bug fix) **Net change:** +368 lines total (+536 additions, -168 deletions), +150 product code ## 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 _Generated with `cmux`_
1 parent 03b3e18 commit a95d027

35 files changed

+1205
-772
lines changed

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ export default defineConfig([
329329
"src/debug/**/*.ts",
330330
"src/git.ts",
331331
"src/main.ts",
332+
"src/config.test.ts",
332333
"src/services/gitService.ts",
333334
"src/services/log.ts",
334335
"src/services/streamManager.ts",

src/App.tsx

Lines changed: 110 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ 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 { FrontendWorkspaceMetadata } from "./types/workspace";
910
import { LeftSidebar } from "./components/LeftSidebar";
1011
import NewWorkspaceModal from "./components/NewWorkspaceModal";
1112
import { AIView } from "./components/AIView";
@@ -172,12 +173,17 @@ function AppInner() {
172173
[setProjects]
173174
);
174175

175-
const { workspaceMetadata, createWorkspace, removeWorkspace, renameWorkspace } =
176-
useWorkspaceManagement({
177-
selectedWorkspace,
178-
onProjectsUpdate: handleProjectsUpdate,
179-
onSelectedWorkspaceUpdate: setSelectedWorkspace,
180-
});
176+
const {
177+
workspaceMetadata,
178+
loading: metadataLoading,
179+
createWorkspace,
180+
removeWorkspace,
181+
renameWorkspace,
182+
} = useWorkspaceManagement({
183+
selectedWorkspace,
184+
onProjectsUpdate: handleProjectsUpdate,
185+
onSelectedWorkspaceUpdate: setSelectedWorkspace,
186+
});
181187

182188
// NEW: Sync workspace metadata with the stores
183189
const workspaceStore = useWorkspaceStoreRaw();
@@ -215,8 +221,10 @@ function AppInner() {
215221
window.history.replaceState(null, "", newHash);
216222
}
217223

218-
// Update window title
219-
const title = `${selectedWorkspace.workspaceId} - ${selectedWorkspace.projectName} - cmux`;
224+
// Update window title with workspace name
225+
const workspaceName =
226+
workspaceMetadata.get(selectedWorkspace.workspaceId)?.name ?? selectedWorkspace.workspaceId;
227+
const title = `${workspaceName} - ${selectedWorkspace.projectName} - cmux`;
220228
void window.api.window.setTitle(title);
221229
} else {
222230
// Clear hash when no workspace selected
@@ -225,42 +233,80 @@ function AppInner() {
225233
}
226234
void window.api.window.setTitle("cmux");
227235
}
228-
}, [selectedWorkspace]);
236+
}, [selectedWorkspace, workspaceMetadata]);
229237

230238
// Restore workspace from URL on mount (if valid)
239+
// This effect runs once on mount to restore from hash, which takes priority over localStorage
240+
const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false);
241+
231242
useEffect(() => {
243+
// Only run once
244+
if (hasRestoredFromHash) return;
245+
246+
// Wait for metadata to finish loading
247+
if (metadataLoading) return;
248+
232249
const hash = window.location.hash;
233250
if (hash.startsWith("#workspace=")) {
234251
const workspaceId = decodeURIComponent(hash.substring("#workspace=".length));
235252

236253
// Find workspace in metadata
237-
const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId);
254+
const metadata = workspaceMetadata.get(workspaceId);
238255

239256
if (metadata) {
240-
// Find project for this workspace
241-
for (const [projectPath, projectConfig] of projects.entries()) {
242-
const workspace = (projectConfig.workspaces ?? []).find(
243-
(ws) => ws.path === metadata.workspacePath
244-
);
245-
if (workspace) {
246-
setSelectedWorkspace({
247-
workspaceId: metadata.id,
248-
projectPath,
249-
projectName: metadata.projectName,
250-
workspacePath: metadata.workspacePath,
251-
});
252-
break;
253-
}
257+
// Restore from hash (overrides localStorage)
258+
setSelectedWorkspace({
259+
workspaceId: metadata.id,
260+
projectPath: metadata.projectPath,
261+
projectName: metadata.projectName,
262+
namedWorkspacePath: metadata.namedWorkspacePath,
263+
});
264+
}
265+
}
266+
267+
setHasRestoredFromHash(true);
268+
}, [metadataLoading, workspaceMetadata, hasRestoredFromHash, setSelectedWorkspace]);
269+
270+
// Validate selected workspace exists and has all required fields
271+
useEffect(() => {
272+
// Don't validate until metadata is loaded
273+
if (metadataLoading) return;
274+
275+
if (selectedWorkspace) {
276+
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
277+
278+
if (!metadata) {
279+
// Workspace was deleted
280+
console.warn(
281+
`Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection`
282+
);
283+
setSelectedWorkspace(null);
284+
if (window.location.hash) {
285+
window.history.replaceState(null, "", window.location.pathname);
254286
}
287+
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
288+
// Old localStorage entry missing namedWorkspacePath - update it once
289+
console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`);
290+
setSelectedWorkspace({
291+
workspaceId: metadata.id,
292+
projectPath: metadata.projectPath,
293+
projectName: metadata.projectName,
294+
namedWorkspacePath: metadata.namedWorkspacePath,
295+
});
255296
}
256297
}
257-
// Only run on mount
258-
// eslint-disable-next-line react-hooks/exhaustive-deps
259-
}, []);
298+
}, [metadataLoading, selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
260299

261-
const openWorkspaceInTerminal = useCallback((workspacePath: string) => {
262-
void window.api.workspace.openTerminal(workspacePath);
263-
}, []);
300+
const openWorkspaceInTerminal = useCallback(
301+
(workspaceId: string) => {
302+
// Look up workspace metadata to get the named path (user-friendly symlink)
303+
const metadata = workspaceMetadata.get(workspaceId);
304+
if (metadata) {
305+
void window.api.workspace.openTerminal(metadata.namedWorkspacePath);
306+
}
307+
},
308+
[workspaceMetadata]
309+
);
264310

265311
const handleRemoveProject = useCallback(
266312
async (path: string) => {
@@ -364,33 +410,39 @@ function AppInner() {
364410
const workspaceRecency = useWorkspaceRecency();
365411

366412
// Sort workspaces by recency (most recent first)
413+
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
367414
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
368415
const sortedWorkspacesByProject = useStableReference(
369416
() => {
370-
const result = new Map<string, ProjectConfig["workspaces"]>();
417+
const result = new Map<string, FrontendWorkspaceMetadata[]>();
371418
for (const [projectPath, config] of projects) {
372-
result.set(
373-
projectPath,
374-
(config.workspaces ?? []).slice().sort((a, b) => {
375-
const aMeta = workspaceMetadata.get(a.path);
376-
const bMeta = workspaceMetadata.get(b.path);
377-
if (!aMeta || !bMeta) return 0;
378-
379-
// Get timestamp of most recent user message (0 if never used)
380-
const aTimestamp = workspaceRecency[aMeta.id] ?? 0;
381-
const bTimestamp = workspaceRecency[bMeta.id] ?? 0;
382-
return bTimestamp - aTimestamp;
383-
})
384-
);
419+
// Transform Workspace[] to FrontendWorkspaceMetadata[] using workspace ID
420+
const metadataList = config.workspaces
421+
.map((ws) => (ws.id ? workspaceMetadata.get(ws.id) : undefined))
422+
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined && meta !== null);
423+
424+
// Sort by recency
425+
metadataList.sort((a, b) => {
426+
const aTimestamp = workspaceRecency[a.id] ?? 0;
427+
const bTimestamp = workspaceRecency[b.id] ?? 0;
428+
return bTimestamp - aTimestamp;
429+
});
430+
431+
result.set(projectPath, metadataList);
385432
}
386433
return result;
387434
},
388435
(prev, next) => {
389-
// Compare Maps: check if both size and workspace order are the same
436+
// Compare Maps: check if size, workspace order, and metadata content are the same
390437
if (
391438
!compareMaps(prev, next, (a, b) => {
392439
if (a.length !== b.length) return false;
393-
return a.every((workspace, i) => workspace.path === b[i].path);
440+
// Check both ID and name to detect renames
441+
return a.every((metadata, i) => {
442+
const bMeta = b[i];
443+
if (!bMeta || !metadata) return false; // Null-safe
444+
return metadata.id === bMeta.id && metadata.name === bMeta.name;
445+
});
394446
})
395447
) {
396448
return false;
@@ -410,7 +462,7 @@ function AppInner() {
410462

411463
// Find current workspace index in sorted list
412464
const currentIndex = sortedWorkspaces.findIndex(
413-
(ws) => ws.path === selectedWorkspace.workspacePath
465+
(metadata) => metadata.id === selectedWorkspace.workspaceId
414466
);
415467
if (currentIndex === -1) return;
416468

@@ -422,20 +474,17 @@ function AppInner() {
422474
targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1;
423475
}
424476

425-
const targetWorkspace = sortedWorkspaces[targetIndex];
426-
if (!targetWorkspace) return;
427-
428-
const metadata = workspaceMetadata.get(targetWorkspace.path);
429-
if (!metadata) return;
477+
const targetMetadata = sortedWorkspaces[targetIndex];
478+
if (!targetMetadata) return;
430479

431480
setSelectedWorkspace({
432481
projectPath: selectedWorkspace.projectPath,
433482
projectName: selectedWorkspace.projectName,
434-
workspacePath: targetWorkspace.path,
435-
workspaceId: metadata.id,
483+
namedWorkspacePath: targetMetadata.namedWorkspacePath,
484+
workspaceId: targetMetadata.id,
436485
});
437486
},
438-
[selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace]
487+
[selectedWorkspace, sortedWorkspacesByProject, setSelectedWorkspace]
439488
);
440489

441490
// Register command sources with registry
@@ -534,12 +583,7 @@ function AppInner() {
534583
);
535584

536585
const selectWorkspaceFromPalette = useCallback(
537-
(selection: {
538-
projectPath: string;
539-
projectName: string;
540-
workspacePath: string;
541-
workspaceId: string;
542-
}) => {
586+
(selection: WorkspaceSelection) => {
543587
setSelectedWorkspace(selection);
544588
},
545589
[setSelectedWorkspace]
@@ -679,20 +723,19 @@ function AppInner() {
679723
/>
680724
<MainContent>
681725
<ContentArea>
682-
{selectedWorkspace?.workspacePath ? (
726+
{selectedWorkspace ? (
683727
<ErrorBoundary
684-
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId ?? "unknown"}`}
728+
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.namedWorkspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId}`}
685729
>
686730
<AIView
687731
key={selectedWorkspace.workspaceId}
688732
workspaceId={selectedWorkspace.workspaceId}
689733
projectName={selectedWorkspace.projectName}
690734
branch={
691-
selectedWorkspace.workspacePath?.split("/").pop() ??
692-
selectedWorkspace.workspaceId ??
693-
""
735+
selectedWorkspace.namedWorkspacePath?.split("/").pop() ??
736+
selectedWorkspace.workspaceId
694737
}
695-
workspacePath={selectedWorkspace.workspacePath}
738+
namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""}
696739
/>
697740
</ErrorBoundary>
698741
) : (

src/components/AIView.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -193,15 +193,15 @@ interface AIViewProps {
193193
workspaceId: string;
194194
projectName: string;
195195
branch: string;
196-
workspacePath: string;
196+
namedWorkspacePath: string; // User-friendly path for display and terminal
197197
className?: string;
198198
}
199199

200200
const AIViewInner: React.FC<AIViewProps> = ({
201201
workspaceId,
202202
projectName,
203203
branch,
204-
workspacePath,
204+
namedWorkspacePath,
205205
className,
206206
}) => {
207207
const chatAreaRef = useRef<HTMLDivElement>(null);
@@ -311,8 +311,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
311311
);
312312

313313
const handleOpenTerminal = useCallback(() => {
314-
void window.api.workspace.openTerminal(workspacePath);
315-
}, [workspacePath]);
314+
void window.api.workspace.openTerminal(namedWorkspacePath);
315+
}, [namedWorkspacePath]);
316316

317317
// Auto-scroll when messages update (during streaming)
318318
useEffect(() => {
@@ -443,7 +443,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
443443
tooltipPosition="bottom"
444444
/>
445445
{projectName} / {branch}
446-
<WorkspacePath>{workspacePath}</WorkspacePath>
446+
<WorkspacePath>{namedWorkspacePath}</WorkspacePath>
447447
<TooltipWrapper inline>
448448
<TerminalIconButton onClick={handleOpenTerminal}>
449449
<svg viewBox="0 0 16 16" fill="currentColor">

src/components/LeftSidebar.tsx

Lines changed: 3 additions & 3 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 { FrontendWorkspaceMetadata } 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, FrontendWorkspaceMetadata>;
2525
selectedWorkspace: WorkspaceSelection | null;
2626
onSelectWorkspace: (selection: WorkspaceSelection) => void;
2727
onAddProject: () => void;
@@ -41,7 +41,7 @@ interface LeftSidebarProps {
4141
onToggleCollapsed: () => void;
4242
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
4343
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
44-
sortedWorkspacesByProject: Map<string, ProjectConfig["workspaces"]>;
44+
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
4545
}
4646

4747
export function LeftSidebar(props: LeftSidebarProps) {

0 commit comments

Comments
 (0)