Skip to content

Commit f0290f6

Browse files
authored
🤖 fix: expand tilde in project paths and validate existence (#492)
When users input project paths with tilde (~), they were not being expanded correctly, causing all project operations to fail. This fix implements automatic tilde expansion and path validation. _Generated with `cmux`_
1 parent 48fb55a commit f0290f6

File tree

14 files changed

+593
-191
lines changed

14 files changed

+593
-191
lines changed

src/App.stories.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@ function setupMockAPI(options: {
1919
const mockWorkspaces = options.workspaces ?? [];
2020

2121
const mockApi: IPCApi = {
22-
dialog: {
23-
selectDirectory: () => Promise.resolve(null),
24-
},
2522
providers: {
2623
setProviderConfig: () => Promise.resolve({ success: true, data: undefined }),
2724
list: () => Promise.resolve([]),
@@ -64,7 +61,11 @@ function setupMockAPI(options: {
6461
},
6562
projects: {
6663
list: () => Promise.resolve(Array.from(mockProjects.entries())),
67-
create: () => Promise.resolve({ success: true, data: { workspaces: [] } }),
64+
create: () =>
65+
Promise.resolve({
66+
success: true,
67+
data: { projectConfig: { workspaces: [] }, normalizedPath: "/mock/project/path" },
68+
}),
6869
remove: () => Promise.resolve({ success: true, data: undefined }),
6970
listBranches: () =>
7071
Promise.resolve({

src/App.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { WorkspaceSelection } from "./components/ProjectSidebar";
55
import type { FrontendWorkspaceMetadata } from "./types/workspace";
66
import { LeftSidebar } from "./components/LeftSidebar";
77
import NewWorkspaceModal from "./components/NewWorkspaceModal";
8-
import { DirectorySelectModal } from "./components/DirectorySelectModal";
8+
import { ProjectCreateModal } from "./components/ProjectCreateModal";
99
import { AIView } from "./components/AIView";
1010
import { ErrorBoundary } from "./components/ErrorBoundary";
1111
import { usePersistedState, updatePersistedState } from "./hooks/usePersistedState";
@@ -46,6 +46,7 @@ function AppInner() {
4646
selectedWorkspace,
4747
setSelectedWorkspace,
4848
} = useApp();
49+
const [projectCreateModalOpen, setProjectCreateModalOpen] = useState(false);
4950
const [workspaceModalOpen, setWorkspaceModalOpen] = useState(false);
5051
const [workspaceModalProject, setWorkspaceModalProject] = useState<string | null>(null);
5152
const [workspaceModalProjectName, setWorkspaceModalProjectName] = useState<string>("");
@@ -218,8 +219,8 @@ function AppInner() {
218219

219220
// Memoize callbacks to prevent LeftSidebar/ProjectSidebar re-renders
220221
const handleAddProjectCallback = useCallback(() => {
221-
void addProject();
222-
}, [addProject]);
222+
setProjectCreateModalOpen(true);
223+
}, []);
223224

224225
const handleAddWorkspaceCallback = useCallback(
225226
(projectPath: string) => {
@@ -473,8 +474,8 @@ function AppInner() {
473474
);
474475

475476
const addProjectFromPalette = useCallback(() => {
476-
void addProject();
477-
}, [addProject]);
477+
setProjectCreateModalOpen(true);
478+
}, []);
478479

479480
const removeProjectFromPalette = useCallback(
480481
(path: string) => {
@@ -694,7 +695,11 @@ function AppInner() {
694695
onAdd={handleCreateWorkspace}
695696
/>
696697
)}
697-
<DirectorySelectModal />
698+
<ProjectCreateModal
699+
isOpen={projectCreateModalOpen}
700+
onClose={() => setProjectCreateModalOpen(false)}
701+
onSuccess={addProject}
702+
/>
698703
</div>
699704
</>
700705
);

src/browser/api.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -184,27 +184,8 @@ class WebSocketManager {
184184

185185
const wsManager = new WebSocketManager();
186186

187-
// Directory selection via custom event (for browser mode)
188-
interface DirectorySelectEvent extends CustomEvent {
189-
detail: {
190-
resolve: (path: string | null) => void;
191-
};
192-
}
193-
194-
function requestDirectorySelection(): Promise<string | null> {
195-
return new Promise((resolve) => {
196-
const event = new CustomEvent("directory-select-request", {
197-
detail: { resolve },
198-
}) as DirectorySelectEvent;
199-
window.dispatchEvent(event);
200-
});
201-
}
202-
203187
// Create the Web API implementation
204188
const webApi: IPCApi = {
205-
dialog: {
206-
selectDirectory: requestDirectorySelection,
207-
},
208189
providers: {
209190
setProviderConfig: (provider, keyPath, value) =>
210191
invokeIPC(IPC_CHANNELS.PROVIDERS_SET_CONFIG, provider, keyPath, value),

src/components/DirectorySelectModal.tsx

Lines changed: 0 additions & 94 deletions
This file was deleted.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import React, { useState, useCallback } from "react";
2+
import { Modal, ModalActions, CancelButton, PrimaryButton } from "./Modal";
3+
import type { ProjectConfig } from "@/config";
4+
5+
interface ProjectCreateModalProps {
6+
isOpen: boolean;
7+
onClose: () => void;
8+
onSuccess: (normalizedPath: string, projectConfig: ProjectConfig) => void;
9+
}
10+
11+
/**
12+
* Project creation modal that handles the full flow from path input to backend validation.
13+
*
14+
* Displays a modal for path input, calls the backend to create the project, and shows
15+
* validation errors inline. Modal stays open until project is successfully created or user cancels.
16+
*/
17+
export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
18+
isOpen,
19+
onClose,
20+
onSuccess,
21+
}) => {
22+
const [path, setPath] = useState("");
23+
const [error, setError] = useState("");
24+
const [isCreating, setIsCreating] = useState(false);
25+
26+
const handleCancel = useCallback(() => {
27+
setPath("");
28+
setError("");
29+
onClose();
30+
}, [onClose]);
31+
32+
const handleSelect = useCallback(async () => {
33+
const trimmedPath = path.trim();
34+
if (!trimmedPath) {
35+
setError("Please enter a directory path");
36+
return;
37+
}
38+
39+
setError("");
40+
setIsCreating(true);
41+
42+
try {
43+
// First check if project already exists
44+
const existingProjects = await window.api.projects.list();
45+
const existingPaths = new Map(existingProjects);
46+
47+
// Try to create the project
48+
const result = await window.api.projects.create(trimmedPath);
49+
50+
if (result.success) {
51+
// Check if duplicate (backend may normalize the path)
52+
const { normalizedPath, projectConfig } = result.data as {
53+
normalizedPath: string;
54+
projectConfig: ProjectConfig;
55+
};
56+
if (existingPaths.has(normalizedPath)) {
57+
setError("This project has already been added.");
58+
return;
59+
}
60+
61+
// Success - notify parent and close
62+
onSuccess(normalizedPath, projectConfig);
63+
setPath("");
64+
setError("");
65+
onClose();
66+
} else {
67+
// Backend validation error - show inline, keep modal open
68+
const errorMessage =
69+
typeof result.error === "string" ? result.error : "Failed to add project";
70+
setError(errorMessage);
71+
}
72+
} catch (err) {
73+
// Unexpected error
74+
const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred";
75+
setError(`Failed to add project: ${errorMessage}`);
76+
} finally {
77+
setIsCreating(false);
78+
}
79+
}, [path, onSuccess, onClose]);
80+
81+
const handleKeyDown = useCallback(
82+
(e: React.KeyboardEvent) => {
83+
if (e.key === "Enter") {
84+
e.preventDefault();
85+
void handleSelect();
86+
}
87+
},
88+
[handleSelect]
89+
);
90+
91+
return (
92+
<Modal
93+
isOpen={isOpen}
94+
title="Add Project"
95+
subtitle="Enter the path to your project directory"
96+
onClose={handleCancel}
97+
isLoading={isCreating}
98+
>
99+
<input
100+
type="text"
101+
value={path}
102+
onChange={(e) => {
103+
setPath(e.target.value);
104+
setError("");
105+
}}
106+
onKeyDown={handleKeyDown}
107+
placeholder="/home/user/projects/my-project"
108+
autoFocus
109+
disabled={isCreating}
110+
className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-muted mb-5 w-full rounded border px-3 py-2 font-mono text-sm text-white focus:outline-none disabled:opacity-50"
111+
/>
112+
{error && <div className="text-error -mt-3 mb-3 text-xs">{error}</div>}
113+
<ModalActions>
114+
<CancelButton onClick={handleCancel} disabled={isCreating}>
115+
Cancel
116+
</CancelButton>
117+
<PrimaryButton onClick={() => void handleSelect()} disabled={isCreating}>
118+
{isCreating ? "Adding..." : "Add Project"}
119+
</PrimaryButton>
120+
</ModalActions>
121+
</Modal>
122+
);
123+
};

src/constants/ipc-constants.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@
44
*/
55

66
export const IPC_CHANNELS = {
7-
// Dialog channels
8-
DIALOG_SELECT_DIR: "dialog:selectDirectory",
9-
107
// Provider channels
118
PROVIDERS_SET_CONFIG: "providers:setConfig",
129
PROVIDERS_LIST: "providers:list",

src/contexts/AppContext.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface AppContextType {
1313
// Projects
1414
projects: Map<string, ProjectConfig>;
1515
setProjects: Dispatch<SetStateAction<Map<string, ProjectConfig>>>;
16-
addProject: () => Promise<void>;
16+
addProject: (normalizedPath: string, projectConfig: ProjectConfig) => void;
1717
removeProject: (path: string) => Promise<void>;
1818

1919
// Workspaces

0 commit comments

Comments
 (0)