Skip to content

Commit 387ed0e

Browse files
committed
🤖 Invert architecture: Use workspace names as directories
Terminals always resolve symlinks, showing stable IDs instead of names. Instead of managing symlinks, use workspace names as real directory names and track stable IDs in config. Changes: - Workspaces created with name as directory (not stable ID) - Rename uses `git worktree move` to rename directory + update config - Removed all symlink creation/management code - Both stableWorkspacePath and namedWorkspacePath now return same path - Config stores ID mapping: workspace.id tracks stable identity Benefits: - Simpler code (no symlink management) - Better UX (terminals naturally show friendly names) - Still stable (IDs tracked in config, renames don't break identity) - No existing users (can make breaking changes) _Generated with `cmux`_ 🤖 Fix lint errors from workspace rename changes 🤖 Block workspace rename during active streaming Prevents race conditions when renaming while AI stream is active: - Bash tool processes would have stale cwd references - System message would contain incorrect workspace path - Git worktree move could conflict with active file operations Changes: - Check isStreaming() before allowing rename - Return clear error message to user - Add integration test verifying blocking behavior Rename succeeds immediately after stream completes. _Generated with `cmux`_ Fix App.tsx to use correct NewWorkspaceModal props after rebase Fix lint errors: add void to async call and workspaceMetadata dependency Fix executeBash test to check for workspace name instead of ID Fix gitService tests to detect default branch instead of hardcoding 'main' 🤖 Fix: path checks use workspace name for directory lookup (stable-ids arch) - AgentSession.ensureMetadata compared against getWorkspacePath(projectPath, id)\n but directories are name-based. Use name instead.\n- Clarify config comment about getWorkspacePath usage.\n\nGenerated with
1 parent e5972fb commit 387ed0e

File tree

12 files changed

+307
-293
lines changed

12 files changed

+307
-293
lines changed

src/App.tsx

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ function AppInner() {
149149
);
150150
const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false);
151151
const [workspaceModalProject, setWorkspaceModalProject] = useState<string | null>(null);
152+
const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState<string>("");
153+
const [workspaceModalBranches, setWorkspaceModalBranches] = useState<string[]>([]);
154+
const [workspaceModalDefaultTrunk, setWorkspaceModalDefaultTrunk] = useState<string | undefined>(
155+
undefined
156+
);
157+
const [workspaceModalLoadError, setWorkspaceModalLoadError] = useState<string | null>(null);
158+
const workspaceModalProjectRef = useRef<string | null>(null);
152159
const [sidebarCollapsed, setSidebarCollapsed] = usePersistedState("sidebarCollapsed", false);
153160

154161
const handleToggleSidebar = useCallback(() => {
@@ -166,12 +173,17 @@ function AppInner() {
166173
[setProjects]
167174
);
168175

169-
const { workspaceMetadata, loading: metadataLoading, createWorkspace, removeWorkspace, renameWorkspace } =
170-
useWorkspaceManagement({
171-
selectedWorkspace,
172-
onProjectsUpdate: handleProjectsUpdate,
173-
onSelectedWorkspaceUpdate: setSelectedWorkspace,
174-
});
176+
const {
177+
workspaceMetadata,
178+
loading: metadataLoading,
179+
createWorkspace,
180+
removeWorkspace,
181+
renameWorkspace,
182+
} = useWorkspaceManagement({
183+
selectedWorkspace,
184+
onProjectsUpdate: handleProjectsUpdate,
185+
onSelectedWorkspaceUpdate: setSelectedWorkspace,
186+
});
175187

176188
// NEW: Sync workspace metadata with the stores
177189
const workspaceStore = useWorkspaceStoreRaw();
@@ -221,16 +233,16 @@ function AppInner() {
221233
}
222234
void window.api.window.setTitle("cmux");
223235
}
224-
}, [selectedWorkspace]);
236+
}, [selectedWorkspace, workspaceMetadata]);
225237

226238
// Restore workspace from URL on mount (if valid)
227239
// This effect runs once on mount to restore from hash, which takes priority over localStorage
228240
const [hasRestoredFromHash, setHasRestoredFromHash] = useState(false);
229-
241+
230242
useEffect(() => {
231243
// Only run once
232244
if (hasRestoredFromHash) return;
233-
245+
234246
// Wait for metadata to finish loading
235247
if (metadataLoading) return;
236248

@@ -251,18 +263,18 @@ function AppInner() {
251263
});
252264
}
253265
}
254-
266+
255267
setHasRestoredFromHash(true);
256268
}, [metadataLoading, workspaceMetadata, hasRestoredFromHash, setSelectedWorkspace]);
257269

258270
// Validate selected workspace exists and has all required fields
259271
useEffect(() => {
260272
// Don't validate until metadata is loaded
261273
if (metadataLoading) return;
262-
274+
263275
if (selectedWorkspace) {
264276
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
265-
277+
266278
if (!metadata) {
267279
// Workspace was deleted
268280
console.warn(
@@ -274,9 +286,7 @@ function AppInner() {
274286
}
275287
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
276288
// Old localStorage entry missing namedWorkspacePath - update it once
277-
console.log(
278-
`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`
279-
);
289+
console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`);
280290
setSelectedWorkspace({
281291
workspaceId: metadata.id,
282292
projectPath: metadata.projectPath,
@@ -287,13 +297,16 @@ function AppInner() {
287297
}
288298
}, [metadataLoading, selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
289299

290-
const openWorkspaceInTerminal = useCallback((workspaceId: string) => {
291-
// Look up workspace metadata to get the named path (user-friendly symlink)
292-
const metadata = workspaceMetadata.get(workspaceId);
293-
if (metadata) {
294-
void window.api.workspace.openTerminal(metadata.namedWorkspacePath);
295-
}
296-
}, [workspaceMetadata]);
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+
);
297310

298311
const handleRemoveProject = useCallback(
299312
async (path: string) => {
@@ -305,9 +318,45 @@ function AppInner() {
305318
[removeProject, selectedWorkspace, setSelectedWorkspace]
306319
);
307320

308-
const handleAddWorkspace = useCallback((projectPath: string) => {
321+
const handleAddWorkspace = useCallback(async (projectPath: string) => {
322+
const projectName = projectPath.split("/").pop() ?? projectPath.split("\\").pop() ?? "project";
323+
324+
workspaceModalProjectRef.current = projectPath;
309325
setWorkspaceModalProject(projectPath);
326+
setWorkspaceModalProjectName(projectName);
327+
setWorkspaceModalBranches([]);
328+
setWorkspaceModalDefaultTrunk(undefined);
329+
setWorkspaceModalLoadError(null);
310330
setWorkspaceModalOpen(true);
331+
332+
try {
333+
const branchResult = await window.api.projects.listBranches(projectPath);
334+
335+
// Guard against race condition: only update state if this is still the active project
336+
if (workspaceModalProjectRef.current !== projectPath) {
337+
return;
338+
}
339+
340+
const sanitizedBranches = Array.isArray(branchResult?.branches)
341+
? branchResult.branches.filter((branch): branch is string => typeof branch === "string")
342+
: [];
343+
344+
const recommended =
345+
typeof branchResult?.recommendedTrunk === "string" &&
346+
sanitizedBranches.includes(branchResult.recommendedTrunk)
347+
? branchResult.recommendedTrunk
348+
: sanitizedBranches[0];
349+
350+
setWorkspaceModalBranches(sanitizedBranches);
351+
setWorkspaceModalDefaultTrunk(recommended);
352+
setWorkspaceModalLoadError(null);
353+
} catch (err) {
354+
console.error("Failed to load branches for modal:", err);
355+
const message = err instanceof Error ? err.message : "Unknown error";
356+
setWorkspaceModalLoadError(
357+
`Unable to load branches automatically: ${message}. You can still enter the trunk branch manually.`
358+
);
359+
}
311360
}, []);
312361

313362
// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders
@@ -495,7 +544,7 @@ function AppInner() {
495544

496545
const openNewWorkspaceFromPalette = useCallback(
497546
(projectPath: string) => {
498-
handleAddWorkspace(projectPath);
547+
void handleAddWorkspace(projectPath);
499548
},
500549
[handleAddWorkspace]
501550
);
@@ -682,7 +731,10 @@ function AppInner() {
682731
key={selectedWorkspace.workspaceId}
683732
workspaceId={selectedWorkspace.workspaceId}
684733
projectName={selectedWorkspace.projectName}
685-
branch={selectedWorkspace.namedWorkspacePath?.split("/").pop() ?? selectedWorkspace.workspaceId}
734+
branch={
735+
selectedWorkspace.namedWorkspacePath?.split("/").pop() ??
736+
selectedWorkspace.workspaceId
737+
}
686738
namedWorkspacePath={selectedWorkspace.namedWorkspacePath ?? ""}
687739
/>
688740
</ErrorBoundary>
@@ -703,10 +755,18 @@ function AppInner() {
703755
{workspaceModalOpen && workspaceModalProject && (
704756
<NewWorkspaceModal
705757
isOpen={workspaceModalOpen}
706-
projectPath={workspaceModalProject}
758+
projectName={workspaceModalProjectName}
759+
branches={workspaceModalBranches}
760+
defaultTrunkBranch={workspaceModalDefaultTrunk}
761+
loadErrorMessage={workspaceModalLoadError}
707762
onClose={() => {
763+
workspaceModalProjectRef.current = null;
708764
setWorkspaceModalOpen(false);
709765
setWorkspaceModalProject(null);
766+
setWorkspaceModalProjectName("");
767+
setWorkspaceModalBranches([]);
768+
setWorkspaceModalDefaultTrunk(undefined);
769+
setWorkspaceModalLoadError(null);
710770
}}
711771
onAdd={handleCreateWorkspace}
712772
/>

src/config.test.ts

Lines changed: 0 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -35,105 +35,6 @@ describe("Config", () => {
3535
});
3636
});
3737

38-
describe("symlink management", () => {
39-
let projectPath: string;
40-
let projectDir: string;
41-
42-
beforeEach(() => {
43-
projectPath = "/fake/project";
44-
const projectName = "project";
45-
projectDir = path.join(config.srcDir, projectName);
46-
fs.mkdirSync(projectDir, { recursive: true });
47-
});
48-
49-
it("should create a symlink from name to ID", () => {
50-
const id = "abc123def4";
51-
const name = "feature-branch";
52-
const idPath = path.join(projectDir, id);
53-
const symlinkPath = path.join(projectDir, name);
54-
55-
// Create the actual ID directory
56-
fs.mkdirSync(idPath);
57-
58-
// Create symlink
59-
config.createWorkspaceSymlink(projectPath, id, name);
60-
61-
// Verify symlink exists and points to ID
62-
expect(fs.existsSync(symlinkPath)).toBe(true);
63-
const stats = fs.lstatSync(symlinkPath);
64-
expect(stats.isSymbolicLink()).toBe(true);
65-
expect(fs.readlinkSync(symlinkPath)).toBe(id);
66-
});
67-
68-
it("should update a symlink when renaming", () => {
69-
const id = "abc123def4";
70-
const oldName = "old-name";
71-
const newName = "new-name";
72-
const idPath = path.join(projectDir, id);
73-
const oldSymlinkPath = path.join(projectDir, oldName);
74-
const newSymlinkPath = path.join(projectDir, newName);
75-
76-
// Create the actual ID directory and initial symlink
77-
fs.mkdirSync(idPath);
78-
fs.symlinkSync(id, oldSymlinkPath, "dir");
79-
80-
// Update symlink
81-
config.updateWorkspaceSymlink(projectPath, oldName, newName, id);
82-
83-
// Verify old symlink removed and new one created
84-
expect(fs.existsSync(oldSymlinkPath)).toBe(false);
85-
expect(fs.existsSync(newSymlinkPath)).toBe(true);
86-
const stats = fs.lstatSync(newSymlinkPath);
87-
expect(stats.isSymbolicLink()).toBe(true);
88-
expect(fs.readlinkSync(newSymlinkPath)).toBe(id);
89-
});
90-
91-
it("should remove a symlink", () => {
92-
const id = "abc123def4";
93-
const name = "feature-branch";
94-
const idPath = path.join(projectDir, id);
95-
const symlinkPath = path.join(projectDir, name);
96-
97-
// Create the actual ID directory and symlink
98-
fs.mkdirSync(idPath);
99-
fs.symlinkSync(id, symlinkPath, "dir");
100-
101-
// Remove symlink
102-
config.removeWorkspaceSymlink(projectPath, name);
103-
104-
// Verify symlink removed but ID directory still exists
105-
expect(fs.existsSync(symlinkPath)).toBe(false);
106-
expect(fs.existsSync(idPath)).toBe(true);
107-
});
108-
109-
it("should handle removing non-existent symlink gracefully", () => {
110-
const name = "nonexistent";
111-
expect(() => {
112-
config.removeWorkspaceSymlink(projectPath, name);
113-
}).not.toThrow();
114-
});
115-
116-
it("should replace existing symlink when creating", () => {
117-
const id = "abc123def4";
118-
const name = "feature-branch";
119-
const idPath = path.join(projectDir, id);
120-
const symlinkPath = path.join(projectDir, name);
121-
122-
// Create the actual ID directory
123-
fs.mkdirSync(idPath);
124-
125-
// Create initial symlink to different target
126-
fs.symlinkSync("different-id", symlinkPath, "dir");
127-
128-
// Create new symlink (should replace old one)
129-
config.createWorkspaceSymlink(projectPath, id, name);
130-
131-
// Verify symlink now points to new ID
132-
expect(fs.existsSync(symlinkPath)).toBe(true);
133-
expect(fs.readlinkSync(symlinkPath)).toBe(id);
134-
});
135-
});
136-
13738
describe("getAllWorkspaceMetadata with migration", () => {
13839
it("should migrate legacy workspace without metadata file", () => {
13940
const projectPath = "/fake/project";

0 commit comments

Comments
 (0)