Merge pull request #1466 from Runfusion/gsxdsm/issue-1454-manage-projet-bug
fix: empty folder selection reverts in DirectoryPicker + add create-folder button
This commit is contained in:
17
.changeset/directory-picker-create-folder.md
Normal file
17
.changeset/directory-picker-create-folder.md
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Add "New folder" button to DirectoryPicker for project setup
|
||||
|
||||
The directory picker in the project setup flow now includes a "New folder"
|
||||
button that lets users create folders directly when selecting a project path.
|
||||
This includes:
|
||||
|
||||
- New `POST /api/create-directory` endpoint for creating directories
|
||||
- Create folder UI in DirectoryPicker with inline error handling
|
||||
- Keyboard support (Enter to create, Escape to cancel)
|
||||
- Client-side validation for folder names (no path separators or traversal)
|
||||
|
||||
Also fixes a bug where navigating into an empty folder would revert to the
|
||||
previous directory.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
title: "DirectoryPicker empty folder reverts on navigation"
|
||||
date: 2026-06-06
|
||||
category: ui-bugs
|
||||
module: packages/dashboard/app/components/DirectoryPicker
|
||||
problem_type: ui_bug
|
||||
component: frontend_stimulus
|
||||
symptoms:
|
||||
- "Selecting an empty folder in the directory picker automatically reverts to the previous folder"
|
||||
- "Navigation into folders with subdirectories works correctly; empty folders do not"
|
||||
- "The 'Select' button shows the wrong path after clicking into an empty directory"
|
||||
root_cause: logic_error
|
||||
resolution_type: code_fix
|
||||
severity: medium
|
||||
related_components:
|
||||
- testing_framework
|
||||
tags:
|
||||
- react-useeffect
|
||||
- state-management
|
||||
- directory-picker
|
||||
- setup-wizard
|
||||
- project-management
|
||||
- stale-prop
|
||||
---
|
||||
|
||||
# DirectoryPicker empty folder reverts on navigation
|
||||
|
||||
## Problem
|
||||
|
||||
In the Fusion dashboard's project setup flow (Setup Wizard and Project Overview "Add Project"), the `DirectoryPicker` component allowed users to browse the filesystem and select a project directory. However, when a user navigated into a folder that contained **no subdirectories**, the picker would automatically jump back to the previously browsed directory. This made it impossible to select an empty folder as a project path — a common scenario when creating a new project in a freshly created directory.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Open the directory picker, navigate to a folder with no subdirectories.
|
||||
- The picker briefly shows "No subdirectories" then reverts to the parent directory.
|
||||
- If the target folder contains even a single empty subdirectory, navigation works correctly.
|
||||
- The `currentPath` display in the action footer shows the wrong path after the revert.
|
||||
|
||||
## What Didn't Work
|
||||
|
||||
- Adding defensive checks for `entries.length === 0` in the render path — the render was correct; the problem was a re-fetch triggered by a `useEffect` that overwrote the navigated state.
|
||||
- Suspecting a race condition between `onChange` and browser close — the revert happened even without clicking "Select", purely on navigation.
|
||||
- Suspecting the API response for empty folders — the API correctly returned `entries: []`; the bug was in how the frontend handled that response.
|
||||
|
||||
## Solution
|
||||
|
||||
In `packages/dashboard/app/components/DirectoryPicker.tsx`, the `useEffect` that auto-fetches entries when the browser panel opens was using the stale `value` prop instead of `browser.currentPath`:
|
||||
|
||||
**Before:**
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (browser.isOpen && !browser.loading && browser.entries.length === 0 && !browser.error) {
|
||||
fetchEntries(value || undefined, browser.showHidden);
|
||||
}
|
||||
}, [browser.isOpen, browser.loading, browser.entries.length, browser.error, value, browser.showHidden, fetchEntries, nodeId, localNodeId]);
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
if (browser.isOpen && !browser.loading && browser.entries.length === 0 && !browser.error) {
|
||||
// Use browser.currentPath if available (user has navigated), otherwise fall back to value prop
|
||||
fetchEntries(browser.currentPath || value || undefined, browser.showHidden);
|
||||
}
|
||||
}, [browser.isOpen, browser.loading, browser.entries.length, browser.error, value, browser.showHidden, fetchEntries, nodeId, localNodeId]);
|
||||
```
|
||||
|
||||
The fix prioritizes `browser.currentPath` (which reflects the user's current navigation) over the `value` prop (which only updates when the user explicitly clicks "Select").
|
||||
|
||||
## Why This Works
|
||||
|
||||
The `useEffect` fires when `browser.entries.length === 0` — which is true both on initial open (no entries loaded yet) and after navigating into an empty folder (API returned `entries: []`).
|
||||
|
||||
- **On initial open**: `browser.currentPath` is `""`, so `value || undefined` fetches the initial directory. Correct.
|
||||
- **After navigating into an empty folder**: `browser.currentPath` is the navigated path, so `browser.currentPath || value` fetches the current directory. Correct.
|
||||
- **Without the fix**: After navigating into an empty folder, the effect used `value` (still `""` or the previous path), causing a refetch of the wrong directory and overwriting `browser.currentPath`.
|
||||
|
||||
The bug was a **stale closure / stale prop** issue: the effect captured `value` at render time, but `value` is only updated by the parent when `handleSelect` calls `onChange(browser.currentPath)`. Until then, `value` lags behind the user's navigation.
|
||||
|
||||
## Prevention
|
||||
|
||||
- **When a `useEffect` re-fetches based on empty-state conditions, prefer derived/local state over props.** Props that update via callbacks are inherently stale until the callback fires. Local state (`browser.currentPath`) reflects the immediate user interaction.
|
||||
- **Audit empty-state refetch effects** in components with browse/navigate patterns. Any effect keyed on `entries.length === 0` or `data.length === 0` that uses a prop for the fetch path is vulnerable to this pattern.
|
||||
- **Regression test**: Mock `browseDirectory` to return `entries: []` for a subdirectory, navigate into it, and assert the component does not re-fetch the parent path. The test in `DirectoryPicker.test.tsx` ("does not revert to previous folder when navigating into an empty directory") captures this invariant.
|
||||
- **Grep heuristic** for similar bugs:
|
||||
```bash
|
||||
git grep -nE "fetchEntries\(.*value.*\)" -- '*.tsx'
|
||||
git grep -nE "useEffect.*entries\.length === 0" -- '*.tsx'
|
||||
```
|
||||
Look for effects that fetch based on empty data and use a prop as the fetch key.
|
||||
|
||||
## Related
|
||||
|
||||
- PR #1466 — fix and "New folder" button feature.
|
||||
- `docs/solutions/ui-bugs/skill-autocomplete-highlight-reset-on-swr-revalidation.md` — a related class of `useEffect` bugs keyed on stale/array identity, with broader prevention guidance.
|
||||
- `packages/dashboard/app/components/DirectoryPicker.tsx` — the fixed component.
|
||||
- `packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx` — regression tests.
|
||||
@@ -7000,6 +7000,14 @@ export function browseDirectory(
|
||||
return api<BrowseDirectoryResult>(fullPath);
|
||||
}
|
||||
|
||||
/** Create a new directory */
|
||||
export function createDirectory(path: string): Promise<{ success: true; path: string }> {
|
||||
return api<{ success: true; path: string }>("/create-directory", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Register a new project */
|
||||
export function registerProject(input: ProjectCreateInput): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>("/projects", {
|
||||
|
||||
@@ -188,3 +188,47 @@
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
}
|
||||
|
||||
/* Create folder */
|
||||
.directory-picker-create-folder-toggle {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.directory-picker-create-folder-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-error);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
import { browseDirectory, type BrowseDirectoryResult } from "../api";
|
||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle, Plus } from "lucide-react";
|
||||
import { browseDirectory, createDirectory, type BrowseDirectoryResult } from "../api";
|
||||
import { getPathBreadcrumbs } from "../utils/pathDisplay";
|
||||
import "./DirectoryPicker.css";
|
||||
|
||||
@@ -25,6 +25,8 @@ interface BrowserState {
|
||||
parentPath: string | null;
|
||||
entries: BrowseDirectoryResult["entries"];
|
||||
showHidden: boolean;
|
||||
createFolderOpen: boolean;
|
||||
createFolderError: string | null;
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, nodeId, localNodeId }: DirectoryPickerProps) {
|
||||
@@ -37,7 +39,10 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
parentPath: null,
|
||||
entries: [],
|
||||
showHidden: false,
|
||||
createFolderOpen: false,
|
||||
createFolderError: null,
|
||||
});
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
|
||||
const fetchEntries = useCallback(async (path?: string, showHidden = false) => {
|
||||
setBrowser((prev) => ({ ...prev, loading: true, error: null }));
|
||||
@@ -69,12 +74,12 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch when browser opens
|
||||
// Fetch when browser opens (only for the initial open before any path has been fetched)
|
||||
useEffect(() => {
|
||||
if (browser.isOpen && !browser.loading && browser.entries.length === 0 && !browser.error) {
|
||||
if (browser.isOpen && !browser.loading && !browser.currentPath && !browser.error) {
|
||||
fetchEntries(value || undefined, browser.showHidden);
|
||||
}
|
||||
}, [browser.isOpen, browser.loading, browser.entries.length, browser.error, value, browser.showHidden, fetchEntries, nodeId, localNodeId]);
|
||||
}, [browser.isOpen, browser.loading, browser.currentPath, browser.error, value, browser.showHidden, fetchEntries, nodeId, localNodeId]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
@@ -100,7 +105,62 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
if (browser.isOpen && browser.currentPath) {
|
||||
fetchEntries(browser.currentPath, browser.showHidden);
|
||||
}
|
||||
}, [browser.showHidden]);
|
||||
}, [browser.showHidden, fetchEntries]);
|
||||
|
||||
const handleToggleCreateFolder = useCallback(() => {
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
createFolderOpen: !prev.createFolderOpen,
|
||||
createFolderError: null,
|
||||
}));
|
||||
setNewFolderName("");
|
||||
}, []);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
if (!newFolderName.trim() || !browser.currentPath) return;
|
||||
|
||||
// Validate folder name doesn't contain path separators or traversal
|
||||
const trimmedName = newFolderName.trim();
|
||||
if (trimmedName.includes("/") || trimmedName.includes("\\") || trimmedName.includes("..")) {
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
createFolderError: t("dirPicker.createFolderError", "Folder name cannot contain path separators or '..'"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path separator for the current platform by using the same
|
||||
// separator already present in currentPath
|
||||
const sep = browser.currentPath.includes("\\") ? "\\" : "/";
|
||||
const folderPath = browser.currentPath.endsWith(sep)
|
||||
? browser.currentPath + trimmedName
|
||||
: browser.currentPath + sep + trimmedName;
|
||||
|
||||
setBrowser((prev) => ({ ...prev, loading: true, createFolderError: null }));
|
||||
try {
|
||||
await createDirectory(folderPath);
|
||||
setNewFolderName("");
|
||||
setBrowser((prev) => ({ ...prev, createFolderOpen: false }));
|
||||
// Refresh entries to show the new folder
|
||||
await fetchEntries(browser.currentPath, browser.showHidden);
|
||||
} catch (err) {
|
||||
setBrowser((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
createFolderError: err instanceof Error ? err.message : "Failed to create folder",
|
||||
}));
|
||||
}
|
||||
}, [newFolderName, browser.currentPath, browser.showHidden, fetchEntries]);
|
||||
|
||||
const handleCreateFolderKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleCreateFolder();
|
||||
} else if (e.key === "Escape") {
|
||||
setBrowser((prev) => ({ ...prev, createFolderOpen: false, createFolderError: null }));
|
||||
setNewFolderName("");
|
||||
}
|
||||
}, [handleCreateFolder]);
|
||||
|
||||
const breadcrumbs = browser.currentPath ? getPathBreadcrumbs(browser.currentPath) : [];
|
||||
|
||||
@@ -171,6 +231,16 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
{browser.showHidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
<span>{browser.showHidden ? t("dirPicker.hideHidden", "Hide hidden") : t("dirPicker.showHidden", "Show hidden")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary directory-picker-create-folder-toggle"
|
||||
onClick={handleToggleCreateFolder}
|
||||
aria-label={t("dirPicker.createFolderAria", "Create new folder")}
|
||||
title={t("dirPicker.createFolderTitle", "Create folder")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>{t("dirPicker.createFolder", "New folder")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -209,6 +279,44 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create folder input */}
|
||||
{browser.createFolderOpen && (
|
||||
<div className="directory-picker-create-folder">
|
||||
<input
|
||||
type="text"
|
||||
className="input directory-picker-create-folder-input"
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={handleCreateFolderKeyDown}
|
||||
placeholder={t("dirPicker.newFolderPlaceholder", "Folder name")}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="directory-picker-create-folder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!newFolderName.trim() || browser.loading || !browser.currentPath}
|
||||
>
|
||||
{t("dirPicker.createFolderConfirm", "Create")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={handleToggleCreateFolder}
|
||||
>
|
||||
{t("dirPicker.cancel", "Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
{browser.createFolderError && (
|
||||
<div className="directory-picker-create-folder-error">
|
||||
<AlertCircle size={14} />
|
||||
<span>{browser.createFolderError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="directory-picker-actions">
|
||||
<span className="directory-picker-selected-path" title={browser.currentPath}>
|
||||
|
||||
@@ -15,17 +15,20 @@ vi.mock("lucide-react", async () => {
|
||||
Eye: ({ size, ...props }: any) => <span {...props}>👁</span>,
|
||||
EyeOff: ({ size, ...props }: any) => <span {...props}>🙈</span>,
|
||||
AlertCircle: ({ size, ...props }: any) => <span {...props}>⚠</span>,
|
||||
Plus: ({ size, ...props }: any) => <span {...props}>+</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API
|
||||
vi.mock("../../api", () => ({
|
||||
browseDirectory: vi.fn(),
|
||||
createDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
import { browseDirectory } from "../../api";
|
||||
import { browseDirectory, createDirectory } from "../../api";
|
||||
|
||||
const mockBrowseDirectory = vi.mocked(browseDirectory);
|
||||
const mockCreateDirectory = vi.mocked(createDirectory);
|
||||
|
||||
describe("DirectoryPicker", () => {
|
||||
beforeEach(() => {
|
||||
@@ -243,4 +246,117 @@ describe("DirectoryPicker", () => {
|
||||
expect(mockBrowseDirectory).toHaveBeenCalledWith("/home/user/projects", false, "remote-1", "local-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not revert to previous folder when navigating into an empty directory (FN-XXXX)", async () => {
|
||||
// First load: home directory with a folder
|
||||
mockBrowseDirectory.mockResolvedValueOnce({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [
|
||||
{ name: "empty-folder", path: "/home/user/empty-folder", hasChildren: false },
|
||||
],
|
||||
});
|
||||
|
||||
// Second load: the empty folder itself (no subdirectories)
|
||||
mockBrowseDirectory.mockResolvedValueOnce({
|
||||
currentPath: "/home/user/empty-folder",
|
||||
parentPath: "/home/user",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("empty-folder")).toBeDefined();
|
||||
});
|
||||
|
||||
// Navigate into the empty folder
|
||||
fireEvent.click(screen.getByText("empty-folder"));
|
||||
|
||||
// Should show "No subdirectories" in the empty folder, NOT re-fetch the parent
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No subdirectories")).toBeDefined();
|
||||
});
|
||||
|
||||
// The current path should remain the empty folder, not revert to /home/user
|
||||
expect(mockBrowseDirectory).toHaveBeenLastCalledWith(
|
||||
"/home/user/empty-folder",
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows creating a new folder", async () => {
|
||||
mockBrowseDirectory.mockResolvedValue({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [
|
||||
{ name: "projects", path: "/home/user/projects", hasChildren: true },
|
||||
],
|
||||
});
|
||||
|
||||
mockCreateDirectory.mockResolvedValue({ success: true, path: "/home/user/my-new-folder" });
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("projects")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click "New folder" button
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create new folder" }));
|
||||
|
||||
// Type folder name
|
||||
const input = screen.getByPlaceholderText("Folder name");
|
||||
fireEvent.change(input, { target: { value: "my-new-folder" } });
|
||||
|
||||
// Click Create
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateDirectory).toHaveBeenCalledWith("/home/user/my-new-folder");
|
||||
});
|
||||
|
||||
// Should refresh the directory listing
|
||||
await waitFor(() => {
|
||||
expect(mockBrowseDirectory).toHaveBeenLastCalledWith("/home/user", false, undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error when creating folder fails", async () => {
|
||||
mockBrowseDirectory.mockResolvedValue({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
mockCreateDirectory.mockRejectedValue(new Error("Permission denied"));
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No subdirectories")).toBeDefined();
|
||||
});
|
||||
|
||||
// Click "New folder" button
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create new folder" }));
|
||||
|
||||
// Type folder name
|
||||
const input = screen.getByPlaceholderText("Folder name");
|
||||
fireEvent.change(input, { target: { value: "test-folder" } });
|
||||
|
||||
// Click Create
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Permission denied")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4455,6 +4455,88 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/create-directory
|
||||
* Create a new directory at the specified path.
|
||||
* Body: { path: string }
|
||||
* Returns: { success: true, path: string }
|
||||
*/
|
||||
router.post("/create-directory", async (req, res) => {
|
||||
try {
|
||||
const { resolve, isAbsolute } = await import("node:path");
|
||||
const { mkdir, stat } = await import("node:fs/promises");
|
||||
|
||||
const rawPath = typeof req.body?.path === "string" ? req.body.path.trim() : "";
|
||||
if (!rawPath) {
|
||||
throw badRequest("Path is required");
|
||||
}
|
||||
|
||||
// Validate: must be absolute, no .. traversal
|
||||
if (!isAbsolute(rawPath)) {
|
||||
throw badRequest("Path must be absolute");
|
||||
}
|
||||
if (rawPath.includes("..")) {
|
||||
throw badRequest("Path must not contain '..' traversal");
|
||||
}
|
||||
const resolvedPath = resolve(rawPath);
|
||||
|
||||
// Check if path already exists
|
||||
try {
|
||||
const existingStat = await stat(resolvedPath);
|
||||
if (existingStat.isDirectory()) {
|
||||
throw badRequest("Directory already exists");
|
||||
}
|
||||
throw badRequest("A file already exists at this path");
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code !== "ENOENT") {
|
||||
throw err;
|
||||
}
|
||||
// ENOENT means it doesn't exist — proceed
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const { dirname } = await import("node:path");
|
||||
const parentPath = dirname(resolvedPath);
|
||||
try {
|
||||
const parentStat = await stat(parentPath);
|
||||
if (!parentStat.isDirectory()) {
|
||||
throw badRequest("Parent path is not a directory");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === "ENOENT") {
|
||||
throw badRequest("Parent directory does not exist");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Create the directory
|
||||
try {
|
||||
await mkdir(resolvedPath);
|
||||
} catch (err: unknown) {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === "EEXIST") {
|
||||
throw badRequest("Directory already exists");
|
||||
}
|
||||
if (e.code === "ENOENT") {
|
||||
throw badRequest("Parent directory does not exist");
|
||||
}
|
||||
if (e.code === "ENOTDIR") {
|
||||
throw badRequest("Parent path is not a directory");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
res.json({ success: true, path: resolvedPath });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Registrar order is API-contract sensitive. Keep this domain sequence stable:
|
||||
// 1) project routes (`/projects/across-nodes|detect` before `/projects/:id`)
|
||||
// 2) node CRUD/operational routes
|
||||
|
||||
@@ -1630,12 +1630,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "Directory browser",
|
||||
"browse": "Browse",
|
||||
"cancel": "Cancel",
|
||||
"closeBrowser": "Close directory browser",
|
||||
"createFolder": "New folder",
|
||||
"createFolderAria": "Create new folder",
|
||||
"createFolderConfirm": "Create",
|
||||
"createFolderError": "Folder name cannot contain path separators or '..'",
|
||||
"createFolderTitle": "Create folder",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "Hide hidden",
|
||||
"hideHiddenAria": "Hide hidden directories",
|
||||
"hideHiddenTitle": "Hide hidden",
|
||||
"loading": "Loading…",
|
||||
"newFolderPlaceholder": "Folder name",
|
||||
"noSubdirs": "No subdirectories",
|
||||
"openBrowser": "Browse directories",
|
||||
"parentDir": "Go to parent directory",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "Explorador de directorios",
|
||||
"browse": "Examinar",
|
||||
"cancel": "Cancelar",
|
||||
"closeBrowser": "Cerrar explorador de directorios",
|
||||
"createFolder": "Nueva carpeta",
|
||||
"createFolderAria": "Crear nueva carpeta",
|
||||
"createFolderConfirm": "Crear",
|
||||
"createFolderError": "El nombre de la carpeta no puede contener separadores de ruta o '..'",
|
||||
"createFolderTitle": "Crear carpeta",
|
||||
"defaultPlaceholder": "/ruta/a/tu/proyecto",
|
||||
"hideHidden": "Ocultar ocultos",
|
||||
"hideHiddenAria": "Ocultar directorios ocultos",
|
||||
"hideHiddenTitle": "Ocultar ocultos",
|
||||
"loading": "Cargando…",
|
||||
"newFolderPlaceholder": "Nombre de carpeta",
|
||||
"noSubdirs": "Sin subdirectorios",
|
||||
"openBrowser": "Examinar directorios",
|
||||
"parentDir": "Ir al directorio padre",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "Navigateur de répertoires",
|
||||
"browse": "Parcourir",
|
||||
"cancel": "Annuler",
|
||||
"closeBrowser": "Fermer le navigateur de répertoires",
|
||||
"createFolder": "Nouveau dossier",
|
||||
"createFolderAria": "Créer un nouveau dossier",
|
||||
"createFolderConfirm": "Créer",
|
||||
"createFolderError": "Le nom du dossier ne peut pas contenir de séparateurs de chemin ou '..'",
|
||||
"createFolderTitle": "Créer un dossier",
|
||||
"defaultPlaceholder": "/chemin/vers/votre/projet",
|
||||
"hideHidden": "Masquer les fichiers cachés",
|
||||
"hideHiddenAria": "Masquer les répertoires cachés",
|
||||
"hideHiddenTitle": "Masquer les fichiers cachés",
|
||||
"loading": "Chargement…",
|
||||
"newFolderPlaceholder": "Nom du dossier",
|
||||
"noSubdirs": "Aucun sous-répertoire",
|
||||
"openBrowser": "Parcourir les répertoires",
|
||||
"parentDir": "Aller au répertoire parent",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "디렉터리 브라우저",
|
||||
"browse": "찾아보기",
|
||||
"cancel": "취소",
|
||||
"closeBrowser": "디렉터리 브라우저 닫기",
|
||||
"createFolder": "새 폴더",
|
||||
"createFolderAria": "새 폴더 만들기",
|
||||
"createFolderConfirm": "만들기",
|
||||
"createFolderError": "폴더 이름에 경로 구분자나 '..'를 포함할 수 없습니다",
|
||||
"createFolderTitle": "폴더 만들기",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "숨김 파일 숨기기",
|
||||
"hideHiddenAria": "숨겨진 디렉터리 숨기기",
|
||||
"hideHiddenTitle": "숨김 파일 숨기기",
|
||||
"loading": "로드 중…",
|
||||
"newFolderPlaceholder": "폴더 이름",
|
||||
"noSubdirs": "하위 디렉터리 없음",
|
||||
"openBrowser": "디렉터리 찾아보기",
|
||||
"parentDir": "상위 디렉터리로 이동",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "目录浏览器",
|
||||
"browse": "浏览",
|
||||
"cancel": "取消",
|
||||
"closeBrowser": "关闭目录浏览器",
|
||||
"createFolder": "新建文件夹",
|
||||
"createFolderAria": "创建新文件夹",
|
||||
"createFolderConfirm": "创建",
|
||||
"createFolderError": "文件夹名称不能包含路径分隔符或 '..'",
|
||||
"createFolderTitle": "创建文件夹",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "隐藏隐藏",
|
||||
"hideHiddenAria": "隐藏隐藏目录",
|
||||
"hideHiddenTitle": "隐藏隐藏",
|
||||
"loading": "加载中…",
|
||||
"newFolderPlaceholder": "文件夹名称",
|
||||
"noSubdirs": "无子目录",
|
||||
"openBrowser": "浏览目录",
|
||||
"parentDir": "进入父目录",
|
||||
|
||||
@@ -1629,12 +1629,19 @@
|
||||
"dirPicker": {
|
||||
"ariaLabel": "目錄瀏覽器",
|
||||
"browse": "瀏覽",
|
||||
"cancel": "取消",
|
||||
"closeBrowser": "關閉目錄瀏覽器",
|
||||
"createFolder": "新增資料夾",
|
||||
"createFolderAria": "建立新資料夾",
|
||||
"createFolderConfirm": "建立",
|
||||
"createFolderError": "資料夾名稱不能包含路徑分隔符或 '..'",
|
||||
"createFolderTitle": "建立資料夾",
|
||||
"defaultPlaceholder": "/path/to/your/project",
|
||||
"hideHidden": "隱藏隱藏",
|
||||
"hideHiddenAria": "隱藏隱藏目錄",
|
||||
"hideHiddenTitle": "隱藏隱藏",
|
||||
"loading": "載入中…",
|
||||
"newFolderPlaceholder": "資料夾名稱",
|
||||
"noSubdirs": "無子目錄",
|
||||
"openBrowser": "瀏覽目錄",
|
||||
"parentDir": "進入上層目錄",
|
||||
|
||||
Reference in New Issue
Block a user