Skip to content

Commit 8ad31d8

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. **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 ``` ~/.cmux/src/cmux/a1b2c3d4e5/ # Worktree (stable ID) ~/.cmux/src/cmux/feature-branch → a1b2c3d4e5 # Symlink ~/.cmux/sessions/a1b2c3d4e5/ # Session data ~/.cmux/src/cmux/stable-ids/ # Worktree ~/.cmux/sessions/cmux-stable-ids/ # Session data ``` - **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 - ✅ 511 unit tests pass - ✅ 8 rename integration tests pass - ✅ 5 remove integration tests pass - ✅ 13 E2E tests pass - ✅ 9 new config unit tests Fix stuck loading state for deleted/invalid workspaces When a workspace is deleted or doesn't exist: - Previously: selectedWorkspace persisted in localStorage, causing eternal "Loading workspace..." - Now: Validate workspace exists on mount and clear invalid selection Added validation effect that: - Checks if selected workspace ID exists in workspaceMetadata - Clears selection if workspace was deleted - Also clears URL hash to prevent re-selection on reload Fixes edge cases: - Workspace deleted while app was closed - URL with invalid workspace ID (#workspace=invalid-id) - Workspace removed from another instance Remove rename blocking during streaming With stable IDs, workspace rename no longer requires moving files or changing workspace ID. Rename only updates: - metadata.name (display name) - Symlink (~/.cmux/src/project/name → workspaceId) Session directory (~/.cmux/sessions/workspaceId) remains unchanged, so active streams can continue writing safely. Changes: - Remove isStreaming check from WORKSPACE_RENAME handler - Remove "should block rename during active stream" test - Simplifies UX: no more "Press Esc first" error Benefits: - Users can organize workspaces without interrupting work - One less artificial limitation - Cleaner, simpler code (-38 lines) Fix integration test race condition with AI SDK dynamic imports Integration tests were failing in CI with: "Failed to create model: ReferenceError: You are trying to `import` a file outside of the scope of the test code." This only occurred when multiple tests ran concurrently in CI, not locally. AI SDK providers use dynamic imports for lazy loading (to optimize startup time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test files × concurrent tests within files), Jest/Bun's module resolution has a race condition where multiple simultaneous dynamic imports of the same module can fail. Preload AI SDK providers once during test setup, similar to how we preload tokenizer modules. This ensures subsequent dynamic imports hit the module cache instead of racing. - Added `preloadAISDKProviders()` function to aiService.ts - Called during `setupWorkspace()` alongside `loadTokenizerModules()` - Preserves lazy loading in production (startup optimization) - Eliminates race condition in concurrent test environment - ✅ Tests pass locally with concurrent execution - ✅ No impact on production startup time (preload only in tests) - ✅ No changes to test behavior, only timing/reliability Fixes the flaky integration test failures in PR #259. Fix formatting Refactor: eliminate pathToMetadata code smell 1. Rename type: WorkspaceMetadataWithPaths → FrontendWorkspaceMetadata 2. sortedWorkspacesByProject returns metadata arrays directly 3. Removed duplicate pathToMetadata maps from App.tsx and ProjectSidebar 4. WorkspaceListItem accepts metadata object (6 props → 1) 5. Updated keyboard navigation to work with metadata - Net: -23 lines (removed duplicate logic) - Clearer data flow: pass data, not lookup maps - Simpler component API: metadata object vs 6 props 16 files changed, 124 insertions(+), 147 deletions(-) Fix lint errors: remove unused imports and params
1 parent 03b3e18 commit 8ad31d8

29 files changed

+970
-558
lines changed

src/App.tsx

Lines changed: 60 additions & 100 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";
@@ -148,13 +149,6 @@ function AppInner() {
148149
);
149150
const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false);
150151
const [workspaceModalProject, setWorkspaceModalProject] = useState<string | null>(null);
151-
const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState<string>("");
152-
const [workspaceModalBranches, setWorkspaceModalBranches] = useState<string[]>([]);
153-
const [workspaceModalDefaultTrunk, setWorkspaceModalDefaultTrunk] = useState<string | undefined>(
154-
undefined
155-
);
156-
const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState<string | null>(null);
157-
const workspaceModalProjectRef = useRef<string | null>(null);
158152
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", false);
159153

160154
const handleToggleSidebar = useCallback(() => {
@@ -237,27 +231,36 @@ function AppInner() {
237231
const metadata = Array.from(workspaceMetadata.values()).find((ws) => ws.id === workspaceId);
238232

239233
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-
}
254-
}
234+
// Find project for this workspace (metadata now includes projectPath)
235+
setSelectedWorkspace({
236+
workspaceId: metadata.id,
237+
projectPath: metadata.projectPath,
238+
projectName: metadata.projectName,
239+
workspacePath: metadata.stableWorkspacePath,
240+
});
255241
}
256242
}
257243
// Only run on mount
258244
// eslint-disable-next-line react-hooks/exhaustive-deps
259245
}, []);
260246

247+
// Validate selected workspace exists (clear if workspace was deleted)
248+
useEffect(() => {
249+
if (selectedWorkspace && workspaceMetadata.size > 0) {
250+
const exists = workspaceMetadata.has(selectedWorkspace.workspaceId);
251+
if (!exists) {
252+
console.warn(
253+
`Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection`
254+
);
255+
setSelectedWorkspace(null);
256+
// Also clear URL hash if present
257+
if (window.location.hash) {
258+
window.history.replaceState(null, "", window.location.pathname);
259+
}
260+
}
261+
}
262+
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
263+
261264
const openWorkspaceInTerminal = useCallback((workspacePath: string) => {
262265
void window.api.workspace.openTerminal(workspacePath);
263266
}, []);
@@ -272,45 +275,9 @@ function AppInner() {
272275
[removeProject, selectedWorkspace, setSelectedWorkspace]
273276
);
274277

275-
const handleAddWorkspace = useCallback(async (projectPath: string) => {
276-
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";
277-
278-
workspaceModalProjectRef.current = projectPath;
278+
const handleAddWorkspace = useCallback((projectPath: string) => {
279279
setWorkspaceModalProject(projectPath);
280-
setWorkspaceModalProjectName(projectName);
281-
setWorkspaceModalBranches([]);
282-
setWorkspaceModalDefaultTrunk(undefined);
283-
setWorkspaceModalLoadError(null);
284280
setWorkspaceModalOpen(true);
285-
286-
try {
287-
const branchResult = await window.api.projects.listBranches(projectPath);
288-
289-
// Guard against race condition: only update state if this is still the active project
290-
if (workspaceModalProjectRef.current !== projectPath) {
291-
return;
292-
}
293-
294-
const sanitizedBranches = Array.isArray(branchResult?.branches)
295-
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
296-
: [];
297-
298-
const recommended =
299-
typeof branchResult?.recommendedTrunk === "string" &&
300-
sanitizedBranches.includes(branchResult.recommendedTrunk)
301-
? branchResult.recommendedTrunk
302-
: sanitizedBranches[0];
303-
304-
setWorkspaceModalBranches(sanitizedBranches);
305-
setWorkspaceModalDefaultTrunk(recommended);
306-
setWorkspaceModalLoadError(null);
307-
} catch (err) {
308-
console.error("Failed to load branches for modal:", err);
309-
const message = err instanceof Error ? err.message : "Unknown error";
310-
setWorkspaceModalLoadError(
311-
`Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.`
312-
);
313-
}
314281
}, []);
315282

316283
// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders
@@ -364,24 +331,32 @@ function AppInner() {
364331
const workspaceRecency = useWorkspaceRecency();
365332

366333
// Sort workspaces by recency (most recent first)
334+
// Returns Map<projectPath, FrontendWorkspaceMetadata[]> for direct component use
367335
// Use stable reference to prevent sidebar re-renders when sort order hasn't changed
368336
const sortedWorkspacesByProject = useStableReference(
369337
() => {
370-
const result = new Map<string, ProjectConfig["workspaces"]>();
338+
// Build path-to-metadata lookup map internally
339+
const pathToMetadata = new Map<string, FrontendWorkspaceMetadata>();
340+
for (const metadata of workspaceMetadata.values()) {
341+
pathToMetadata.set(metadata.stableWorkspacePath, metadata);
342+
pathToMetadata.set(metadata.namedWorkspacePath, metadata);
343+
}
344+
345+
const result = new Map<string, FrontendWorkspaceMetadata[]>();
371346
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-
);
347+
// Transform Workspace[] to FrontendWorkspaceMetadata[] and filter nulls
348+
const metadataList = config.workspaces
349+
.map((ws) => pathToMetadata.get(ws.path))
350+
.filter((meta): meta is FrontendWorkspaceMetadata => meta !== undefined);
351+
352+
// Sort by recency
353+
metadataList.sort((a, b) => {
354+
const aTimestamp = workspaceRecency[a.id] ?? 0;
355+
const bTimestamp = workspaceRecency[b.id] ?? 0;
356+
return bTimestamp - aTimestamp;
357+
});
358+
359+
result.set(projectPath, metadataList);
385360
}
386361
return result;
387362
},
@@ -390,7 +365,7 @@ function AppInner() {
390365
if (
391366
!compareMaps(prev, next, (a, b) => {
392367
if (a.length !== b.length) return false;
393-
return a.every((workspace, i) => workspace.path === b[i].path);
368+
return a.every((metadata, i) => metadata.id === b[i].id);
394369
})
395370
) {
396371
return false;
@@ -410,7 +385,7 @@ function AppInner() {
410385

411386
// Find current workspace index in sorted list
412387
const currentIndex = sortedWorkspaces.findIndex(
413-
(ws) => ws.path === selectedWorkspace.workspacePath
388+
(metadata) => metadata.id === selectedWorkspace.workspaceId
414389
);
415390
if (currentIndex === -1) return;
416391

@@ -422,20 +397,17 @@ function AppInner() {
422397
targetIndex = currentIndex === 0 ? sortedWorkspaces.length - 1 : currentIndex - 1;
423398
}
424399

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

431403
setSelectedWorkspace({
432404
projectPath: selectedWorkspace.projectPath,
433405
projectName: selectedWorkspace.projectName,
434-
workspacePath: targetWorkspace.path,
435-
workspaceId: metadata.id,
406+
workspacePath: targetMetadata.stableWorkspacePath,
407+
workspaceId: targetMetadata.id,
436408
});
437409
},
438-
[selectedWorkspace, sortedWorkspacesByProject, workspaceMetadata, setSelectedWorkspace]
410+
[selectedWorkspace, sortedWorkspacesByProject, setSelectedWorkspace]
439411
);
440412

441413
// Register command sources with registry
@@ -495,7 +467,7 @@ function AppInner() {
495467

496468
const openNewWorkspaceFromPalette = useCallback(
497469
(projectPath: string) => {
498-
void handleAddWorkspace(projectPath);
470+
handleAddWorkspace(projectPath);
499471
},
500472
[handleAddWorkspace]
501473
);
@@ -679,19 +651,15 @@ function AppInner() {
679651
/>
680652
<MainContent>
681653
<ContentArea>
682-
{selectedWorkspace?.workspacePath ? (
654+
{selectedWorkspace ? (
683655
<ErrorBoundary
684-
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId ?? "unknown"}`}
656+
workspaceInfo={`${selectedWorkspace.projectName}/${selectedWorkspace.workspacePath.split("/").pop() ?? ""}`}
685657
>
686658
<AIView
687659
key={selectedWorkspace.workspaceId}
688660
workspaceId={selectedWorkspace.workspaceId}
689661
projectName={selectedWorkspace.projectName}
690-
branch={
691-
selectedWorkspace.workspacePath?.split("/").pop() ??
692-
selectedWorkspace.workspaceId ??
693-
""
694-
}
662+
branch={selectedWorkspace.workspacePath.split("/").pop() ?? ""}
695663
workspacePath={selectedWorkspace.workspacePath}
696664
/>
697665
</ErrorBoundary>
@@ -712,18 +680,10 @@ function AppInner() {
712680
{workspaceModalOpen && workspaceModalProject && (
713681
<NewWorkspaceModal
714682
isOpen={workspaceModalOpen}
715-
projectName={workspaceModalProjectName}
716-
branches={workspaceModalBranches}
717-
defaultTrunkBranch={workspaceModalDefaultTrunk}
718-
loadErrorMessage={workspaceModalLoadError}
683+
projectPath={workspaceModalProject}
719684
onClose={() => {
720-
workspaceModalProjectRef.current = null;
721685
setWorkspaceModalOpen(false);
722686
setWorkspaceModalProject(null);
723-
setWorkspaceModalProjectName("");
724-
setWorkspaceModalBranches([]);
725-
setWorkspaceModalDefaultTrunk(undefined);
726-
setWorkspaceModalLoadError(null);
727687
}}
728688
onAdd={handleCreateWorkspace}
729689
/>

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) {

src/components/ProjectSidebar.tsx

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
22
import { createPortal } from "react-dom";
33
import styled from "@emotion/styled";
44
import { css } from "@emotion/react";
5-
import type { ProjectConfig, Workspace } from "@/config";
6-
import type { WorkspaceMetadata } from "@/types/workspace";
5+
import type { ProjectConfig } from "@/config";
6+
import type { FrontendWorkspaceMetadata } 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, FrontendWorkspaceMetadata>;
475475
selectedWorkspace: WorkspaceSelection | null;
476476
onSelectWorkspace: (selection: WorkspaceSelection) => void;
477477
onAddProject: () => void;
@@ -491,12 +491,11 @@ interface ProjectSidebarProps {
491491
onToggleCollapsed: () => void;
492492
onGetSecrets: (projectPath: string) => Promise<Secret[]>;
493493
onUpdateSecrets: (projectPath: string, secrets: Secret[]) => Promise<void>;
494-
sortedWorkspacesByProject: Map<string, Workspace[]>;
494+
sortedWorkspacesByProject: Map<string, FrontendWorkspaceMetadata[]>;
495495
}
496496

497497
const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
498498
projects,
499-
workspaceMetadata,
500499
selectedWorkspace,
501500
onSelectWorkspace,
502501
onAddProject,
@@ -820,23 +819,13 @@ const ProjectSidebarInner: React.FC<ProjectSidebarProps> = ({
820819
` (${formatKeybind(KEYBINDS.NEW_WORKSPACE)})`}
821820
</AddWorkspaceBtn>
822821
</WorkspaceHeader>
823-
{(
824-
sortedWorkspacesByProject.get(projectPath) ??
825-
config.workspaces ??
826-
[]
827-
).map((workspace) => {
828-
const metadata = workspaceMetadata.get(workspace.path);
829-
if (!metadata) return null;
830-
831-
const workspaceId = metadata.id;
832-
const isSelected =
833-
selectedWorkspace?.workspacePath === workspace.path;
822+
{sortedWorkspacesByProject.get(projectPath)?.map((metadata) => {
823+
const isSelected = selectedWorkspace?.workspaceId === metadata.id;
834824

835825
return (
836826
<WorkspaceListItem
837-
key={workspace.path}
838-
workspaceId={workspaceId}
839-
workspacePath={workspace.path}
827+
key={metadata.id}
828+
metadata={metadata}
840829
projectPath={projectPath}
841830
projectName={projectName}
842831
isSelected={isSelected}

0 commit comments

Comments
 (0)