Skip to content

Commit 0efc399

Browse files
authored
🤖 feat: prompt to create folder when path doesn't exist (#1175)
When users type a non-existent path in the Add Project modal or the remote DirectoryPickerModal, they now see a 'Create Folder' button instead of just an error. Clicking it creates the folder (recursively like `mkdir -p`) and then either adds the project or navigates to the newly created directory. ## Changes - Add `general.createDirectory` API endpoint (recursive mkdir) - Detect 'path does not exist' errors in ProjectCreateModal - Detect ENOENT errors in DirectoryPickerModal - Show inline 'Create Folder' button when path doesn't exist ## Testing 1. Open Add Project modal 2. Type a non-existent path (e.g., `~/new-project-folder`) 3. Click 'Add Project' - should see 'This folder doesn't exist.' with 'Create Folder' button 4. Click 'Create Folder' - folder is created and project is added For remote directory picker: 1. Connect to mux server in browser 2. Open Add Project → Browse 3. Type non-existent path in path input, press Enter 4. Should see 'Folder doesn't exist.' with 'Create Folder' button 5. Click it - folder created and navigated to --- _Generated with `mux` • Model: `claude-sonnet-4-20250514` • Thinking: `none`_
1 parent a6099d3 commit 0efc399

File tree

5 files changed

+191
-10
lines changed

5 files changed

+191
-10
lines changed

src/browser/components/DirectoryPickerModal.tsx

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
3333
const [root, setRoot] = useState<FileTreeNode | null>(null);
3434
const [isLoading, setIsLoading] = useState(false);
3535
const [error, setError] = useState<string | null>(null);
36+
// Track if we can offer to create the folder (path doesn't exist)
37+
const [canCreateFolder, setCanCreateFolder] = useState(false);
3638
const [pathInput, setPathInput] = useState(initialPath || "");
3739
const [selectedIndex, setSelectedIndex] = useState(0);
3840
const treeRef = useRef<HTMLDivElement>(null);
@@ -45,13 +47,22 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
4547
}
4648
setIsLoading(true);
4749
setError(null);
50+
setCanCreateFolder(false);
4851

4952
try {
5053
const result = await api.general.listDirectory({ path });
5154

5255
if (!result.success) {
5356
const errorMessage = typeof result.error === "string" ? result.error : "Unknown error";
54-
setError(`Failed to load directory: ${errorMessage}`);
57+
// Detect "no such file or directory" to offer folder creation
58+
const isNotFound =
59+
errorMessage.includes("ENOENT") || errorMessage.includes("no such file or directory");
60+
if (isNotFound) {
61+
setCanCreateFolder(true);
62+
setError("Folder doesn't exist.");
63+
} else {
64+
setError(`Failed to load directory: ${errorMessage}`);
65+
}
5566
setRoot(null);
5667
return;
5768
}
@@ -70,8 +81,10 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
7081
[api]
7182
);
7283

84+
// Sync pathInput with initialPath when modal opens (component stays mounted)
7385
useEffect(() => {
7486
if (!isOpen) return;
87+
setPathInput(initialPath || "");
7588
void loadDirectory(initialPath || ".");
7689
}, [isOpen, initialPath, loadDirectory]);
7790

@@ -87,14 +100,78 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
87100
void loadDirectory(`${root.path}/..`);
88101
}, [loadDirectory, root]);
89102

90-
const handleConfirm = useCallback(() => {
103+
const handleConfirm = useCallback(async () => {
104+
const trimmedInput = pathInput.trim();
105+
106+
// If user has typed a different path, try to load it first
107+
if (trimmedInput && trimmedInput !== root?.path) {
108+
if (!api) return;
109+
setIsLoading(true);
110+
setError(null);
111+
setCanCreateFolder(false);
112+
113+
try {
114+
const result = await api.general.listDirectory({ path: trimmedInput });
115+
if (!result.success) {
116+
const errorMessage = typeof result.error === "string" ? result.error : "Unknown error";
117+
const isNotFound =
118+
errorMessage.includes("ENOENT") || errorMessage.includes("no such file or directory");
119+
if (isNotFound) {
120+
setCanCreateFolder(true);
121+
setError("Folder doesn't exist.");
122+
} else {
123+
setError(`Failed to load directory: ${errorMessage}`);
124+
}
125+
setRoot(null);
126+
return;
127+
}
128+
// Success - select this path
129+
onSelectPath(result.data.path);
130+
onClose();
131+
} catch (err) {
132+
const message = err instanceof Error ? err.message : String(err);
133+
setError(`Failed to load directory: ${message}`);
134+
setRoot(null);
135+
} finally {
136+
setIsLoading(false);
137+
}
138+
return;
139+
}
140+
141+
// Otherwise use the current root
91142
if (!root) {
92143
return;
93144
}
94145

95146
onSelectPath(root.path);
96147
onClose();
97-
}, [onClose, onSelectPath, root]);
148+
}, [onClose, onSelectPath, root, pathInput, api]);
149+
150+
const handleCreateFolder = useCallback(async () => {
151+
const trimmedPath = pathInput.trim();
152+
if (!trimmedPath || !api) return;
153+
154+
setIsLoading(true);
155+
setError(null);
156+
157+
try {
158+
const createResult = await api.general.createDirectory({ path: trimmedPath });
159+
if (!createResult.success) {
160+
setError(createResult.error ?? "Failed to create folder");
161+
setCanCreateFolder(false);
162+
return;
163+
}
164+
// Folder created - now navigate to it
165+
setCanCreateFolder(false);
166+
void loadDirectory(createResult.data.normalizedPath);
167+
} catch (err) {
168+
const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred";
169+
setError(`Failed to create folder: ${errorMessage}`);
170+
setCanCreateFolder(false);
171+
} finally {
172+
setIsLoading(false);
173+
}
174+
}, [pathInput, api, loadDirectory]);
98175

99176
const handleOpenChange = useCallback(
100177
(open: boolean) => {
@@ -120,7 +197,7 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
120197
} else if ((e.ctrlKey || e.metaKey) && e.key === "o") {
121198
e.preventDefault();
122199
if (!isLoading && root) {
123-
handleConfirm();
200+
void handleConfirm();
124201
}
125202
}
126203
},
@@ -144,13 +221,30 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
144221
<div className="mb-3">
145222
<Input
146223
value={pathInput}
147-
onChange={(e) => setPathInput(e.target.value)}
224+
onChange={(e) => {
225+
setPathInput(e.target.value);
226+
setCanCreateFolder(false);
227+
}}
148228
onKeyDown={handlePathInputKeyDown}
149229
placeholder="Enter path..."
150230
className="bg-modal-bg border-border-medium h-9 font-mono text-sm"
151231
/>
152232
</div>
153-
{error && <div className="text-error mb-3 text-xs">{error}</div>}
233+
{error && (
234+
<div className="mb-3 flex items-center gap-2 text-xs">
235+
<span className={canCreateFolder ? "text-muted" : "text-error"}>{error}</span>
236+
{canCreateFolder && (
237+
<Button
238+
size="sm"
239+
onClick={() => void handleCreateFolder()}
240+
disabled={isLoading}
241+
className="h-6 px-2 py-0 text-xs"
242+
>
243+
Create Folder
244+
</Button>
245+
)}
246+
</div>
247+
)}
154248
<div
155249
ref={treeRef}
156250
className="bg-modal-bg border-border-medium mb-4 h-80 overflow-hidden rounded border"
@@ -161,7 +255,7 @@ export const DirectoryPickerModal: React.FC<DirectoryPickerModalProps> = ({
161255
isLoading={isLoading}
162256
onNavigateTo={handleNavigateTo}
163257
onNavigateParent={handleNavigateParent}
164-
onConfirm={handleConfirm}
258+
onConfirm={() => void handleConfirm()}
165259
selectedIndex={selectedIndex}
166260
onSelectedIndexChange={setSelectedIndex}
167261
/>

src/browser/components/ProjectCreateModal.tsx

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
3232
const { api } = useAPI();
3333
const [path, setPath] = useState("");
3434
const [error, setError] = useState("");
35+
// Track if the error is specifically "path does not exist" so we can offer to create it
36+
const [canCreateFolder, setCanCreateFolder] = useState(false);
3537
// In Electron mode, window.api exists (set by preload) and has native directory picker via ORPC
3638
// In browser mode, window.api doesn't exist and we use web-based DirectoryPickerModal
3739
const isDesktop = !!window.api;
@@ -42,12 +44,14 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
4244
const handleCancel = useCallback(() => {
4345
setPath("");
4446
setError("");
47+
setCanCreateFolder(false);
4548
onClose();
4649
}, [onClose]);
4750

4851
const handleWebPickerPathSelected = useCallback((selected: string) => {
4952
setPath(selected);
5053
setError("");
54+
setCanCreateFolder(false);
5155
}, []);
5256

5357
const handleBrowse = useCallback(async () => {
@@ -56,6 +60,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
5660
if (selectedPath) {
5761
setPath(selectedPath);
5862
setError("");
63+
setCanCreateFolder(false);
5964
}
6065
} catch (err) {
6166
console.error("Failed to pick directory:", err);
@@ -70,6 +75,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
7075
}
7176

7277
setError("");
78+
setCanCreateFolder(false);
7379
if (!api) {
7480
setError("Not connected to server");
7581
return;
@@ -101,7 +107,13 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
101107
// Backend validation error - show inline, keep modal open
102108
const errorMessage =
103109
typeof result.error === "string" ? result.error : "Failed to add project";
104-
setError(errorMessage);
110+
// Detect "Path does not exist" error to offer folder creation
111+
if (errorMessage.includes("Path does not exist")) {
112+
setCanCreateFolder(true);
113+
setError("This folder doesn't exist.");
114+
} else {
115+
setError(errorMessage);
116+
}
105117
}
106118
} catch (err) {
107119
// Unexpected error
@@ -112,6 +124,32 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
112124
}
113125
}, [path, onSuccess, onClose, api]);
114126

127+
const handleCreateFolder = useCallback(async () => {
128+
const trimmedPath = path.trim();
129+
if (!trimmedPath || !api) return;
130+
131+
setIsCreating(true);
132+
setError("");
133+
134+
try {
135+
const createResult = await api.general.createDirectory({ path: trimmedPath });
136+
if (!createResult.success) {
137+
setError(createResult.error ?? "Failed to create folder");
138+
setCanCreateFolder(false);
139+
setIsCreating(false);
140+
return;
141+
}
142+
// Folder created - now retry adding the project (handleSelect manages isCreating)
143+
setCanCreateFolder(false);
144+
await handleSelect();
145+
} catch (err) {
146+
const errorMessage = err instanceof Error ? err.message : "An unexpected error occurred";
147+
setError(`Failed to create folder: ${errorMessage}`);
148+
setCanCreateFolder(false);
149+
setIsCreating(false);
150+
}
151+
}, [path, api, handleSelect]);
152+
115153
const handleBrowseClick = useCallback(() => {
116154
if (isDesktop) {
117155
void handleBrowse();
@@ -154,6 +192,7 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
154192
onChange={(e) => {
155193
setPath(e.target.value);
156194
setError("");
195+
setCanCreateFolder(false);
157196
}}
158197
onKeyDown={handleKeyDown}
159198
placeholder="/home/user/projects/my-project"
@@ -172,12 +211,26 @@ export const ProjectCreateModal: React.FC<ProjectCreateModalProps> = ({
172211
</Button>
173212
)}
174213
</div>
175-
{error && <div className="text-error text-xs">{error}</div>}
214+
{error && (
215+
<div className="flex items-center gap-2 text-xs">
216+
<span className={canCreateFolder ? "text-muted" : "text-error"}>{error}</span>
217+
{canCreateFolder && (
218+
<Button
219+
size="sm"
220+
onClick={() => void handleCreateFolder()}
221+
disabled={isCreating}
222+
className="h-6 px-2 py-0 text-xs"
223+
>
224+
Create Folder
225+
</Button>
226+
)}
227+
</div>
228+
)}
176229
<DialogFooter>
177230
<Button variant="secondary" onClick={handleCancel} disabled={isCreating}>
178231
Cancel
179232
</Button>
180-
<Button onClick={() => void handleSelect()} disabled={isCreating}>
233+
<Button onClick={() => void handleSelect()} disabled={isCreating || canCreateFolder}>
181234
{isCreating ? "Adding..." : "Add Project"}
182235
</Button>
183236
</DialogFooter>

src/common/orpc/schemas/api.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,14 @@ export const general = {
606606
input: z.object({ path: z.string() }),
607607
output: ResultSchema(FileTreeNodeSchema),
608608
},
609+
/**
610+
* Create a directory at the specified path.
611+
* Creates parent directories recursively if they don't exist (like mkdir -p).
612+
*/
613+
createDirectory: {
614+
input: z.object({ path: z.string() }),
615+
output: ResultSchema(z.object({ normalizedPath: z.string() }), z.string()),
616+
},
609617
ping: {
610618
input: z.string(),
611619
output: z.string(),

src/node/orpc/router.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ export const router = (authToken?: string) => {
165165
.handler(async ({ context, input }) => {
166166
return context.projectService.listDirectory(input.path);
167167
}),
168+
createDirectory: t
169+
.input(schemas.general.createDirectory.input)
170+
.output(schemas.general.createDirectory.output)
171+
.handler(async ({ context, input }) => {
172+
return context.projectService.createDirectory(input.path);
173+
}),
168174
ping: t
169175
.input(schemas.general.ping.input)
170176
.output(schemas.general.ping.output)

src/node/services/projectService.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,26 @@ export class ProjectService {
173173
};
174174
}
175175
}
176+
177+
async createDirectory(
178+
requestedPath: string
179+
): Promise<Result<{ normalizedPath: string }, string>> {
180+
try {
181+
// Expand ~ to home directory
182+
const expanded =
183+
requestedPath === "~" || requestedPath.startsWith("~/")
184+
? requestedPath.replace("~", os.homedir())
185+
: requestedPath;
186+
const normalizedPath = path.resolve(expanded);
187+
188+
await fsPromises.mkdir(normalizedPath, { recursive: true });
189+
return Ok({ normalizedPath });
190+
} catch (error) {
191+
const message = error instanceof Error ? error.message : String(error);
192+
return Err(`Failed to create directory: ${message}`);
193+
}
194+
}
195+
176196
async updateSecrets(projectPath: string, secrets: Secret[]): Promise<Result<void>> {
177197
try {
178198
await this.config.updateProjectSecrets(projectPath, secrets);

0 commit comments

Comments
 (0)