From d8e325b17e1e1fadd144f6898b55b55a35f52eb5 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 6 Jun 2026 01:32:06 -0700 Subject: [PATCH 1/6] fix: empty folder selection reverts in DirectoryPicker + add create-folder button Fix bug where navigating into an empty folder in the DirectoryPicker would automatically revert to the previous folder. The useEffect that auto-fetches on open was using the stale 'value' prop instead of 'browser.currentPath' after navigation. Also adds a 'New folder' button to the DirectoryPicker toolbar so users can create folders directly when setting up a project. - DirectoryPicker.tsx: fix useEffect to use browser.currentPath || value - DirectoryPicker.tsx: add create folder UI state and handlers - DirectoryPicker.css: styles for create folder input and actions - routes.ts: add POST /api/create-directory endpoint - legacy.ts: add createDirectory API client - DirectoryPicker.test.tsx: add tests for empty folder nav and create folder --- packages/dashboard/app/api/legacy.ts | 8 ++ .../app/components/DirectoryPicker.css | 44 +++++++ .../app/components/DirectoryPicker.tsx | 105 +++++++++++++++- .../__tests__/DirectoryPicker.test.tsx | 118 +++++++++++++++++- packages/dashboard/src/routes.ts | 68 ++++++++++ 5 files changed, 339 insertions(+), 4 deletions(-) diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 2380a6b428..d2f3bc107f 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -7000,6 +7000,14 @@ export function browseDirectory( return api(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 { return api("/projects", { diff --git a/packages/dashboard/app/components/DirectoryPicker.css b/packages/dashboard/app/components/DirectoryPicker.css index e12fc8c305..d33e5226ea 100644 --- a/packages/dashboard/app/components/DirectoryPicker.css +++ b/packages/dashboard/app/components/DirectoryPicker.css @@ -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); +} diff --git a/packages/dashboard/app/components/DirectoryPicker.tsx b/packages/dashboard/app/components/DirectoryPicker.tsx index af3327f842..3b4fe09e27 100644 --- a/packages/dashboard/app/components/DirectoryPicker.tsx +++ b/packages/dashboard/app/components/DirectoryPicker.tsx @@ -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 })); @@ -72,7 +77,8 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, // Fetch when browser opens useEffect(() => { if (browser.isOpen && !browser.loading && browser.entries.length === 0 && !browser.error) { - fetchEntries(value || undefined, browser.showHidden); + // 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]); @@ -102,6 +108,51 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, } }, [browser.showHidden]); + const handleToggleCreateFolder = useCallback(() => { + setBrowser((prev) => ({ + ...prev, + createFolderOpen: !prev.createFolderOpen, + createFolderError: null, + })); + setNewFolderName(""); + }, []); + + const handleCreateFolder = useCallback(async () => { + if (!newFolderName.trim() || !browser.currentPath) 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 + newFolderName.trim() + : browser.currentPath + sep + newFolderName.trim(); + + 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) => { + 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) : []; return ( @@ -171,6 +222,16 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, {browser.showHidden ? : } {browser.showHidden ? t("dirPicker.hideHidden", "Hide hidden") : t("dirPicker.showHidden", "Show hidden")} + {/* Content */} @@ -209,6 +270,44 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, )} + {/* Create folder input */} + {browser.createFolderOpen && ( +
+ setNewFolderName(e.target.value)} + onKeyDown={handleCreateFolderKeyDown} + placeholder={t("dirPicker.newFolderPlaceholder", "Folder name")} + autoFocus + /> +
+ + +
+ {browser.createFolderError && ( +
+ + {browser.createFolderError} +
+ )} +
+ )} + {/* Actions */}
diff --git a/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx b/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx index 845a857265..259fda7684 100644 --- a/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx +++ b/packages/dashboard/app/components/__tests__/DirectoryPicker.test.tsx @@ -15,17 +15,20 @@ vi.mock("lucide-react", async () => { Eye: ({ size, ...props }: any) => 👁, EyeOff: ({ size, ...props }: any) => 🙈, AlertCircle: ({ size, ...props }: any) => ⚠, + Plus: ({ size, ...props }: any) => +, }; }); // 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(); + + 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(); + + 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(); + + 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(); + }); + }); }); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 4cd992a077..cfcc6c4868 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -4455,6 +4455,74 @@ 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 } = await import("node:path"); + const { mkdir, stat } = await import("node:fs/promises"); + + const rawPath = req.body?.path as string | undefined; + if (!rawPath) { + throw badRequest("Path is required"); + } + + // Validate: must be absolute, no .. traversal + const resolvedPath = resolve(rawPath); + if (rawPath.includes("..")) { + throw badRequest("Path must not contain '..' traversal"); + } + if (resolvedPath !== resolve(resolvedPath)) { + throw badRequest("Path must be absolute"); + } + + // 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 + await mkdir(resolvedPath); + + 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 From 020a1cf7b9d50f344b3e01e73c80282000b4477c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 6 Jun 2026 01:42:57 -0700 Subject: [PATCH 2/6] docs: compound knowledge for DirectoryPicker empty-folder revert bug --- ...cker-empty-folder-reverts-on-navigation.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/solutions/ui-bugs/directorypicker-empty-folder-reverts-on-navigation.md diff --git a/docs/solutions/ui-bugs/directorypicker-empty-folder-reverts-on-navigation.md b/docs/solutions/ui-bugs/directorypicker-empty-folder-reverts-on-navigation.md new file mode 100644 index 0000000000..931b54c634 --- /dev/null +++ b/docs/solutions/ui-bugs/directorypicker-empty-folder-reverts-on-navigation.md @@ -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. \ No newline at end of file From ee5f5e84acc1ec19873e4daa617eec1373932e92 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 6 Jun 2026 06:31:44 -0700 Subject: [PATCH 3/6] Address PR review feedback (#1466) - routes.ts: use isAbsolute() before resolve() for proper path validation - routes.ts: wrap mkdir() in try/catch to map EEXIST/ENOENT/ENOTDIR to 4xx - DirectoryPicker.tsx: use !browser.currentPath guard to prevent re-fetch loop - DirectoryPicker.tsx: disable Create button during in-flight requests - DirectoryPicker.tsx: add fetchEntries to useEffect dependency array - DirectoryPicker.tsx: client-side validation for folder names - Add changeset for the new create-folder feature --- .changeset/directory-picker-create-folder.md | 17 +++++++++++ .../app/components/DirectoryPicker.tsx | 28 +++++++++++++------ packages/dashboard/src/routes.ts | 28 ++++++++++++++----- 3 files changed, 57 insertions(+), 16 deletions(-) create mode 100644 .changeset/directory-picker-create-folder.md diff --git a/.changeset/directory-picker-create-folder.md b/.changeset/directory-picker-create-folder.md new file mode 100644 index 0000000000..ba7cf88158 --- /dev/null +++ b/.changeset/directory-picker-create-folder.md @@ -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. diff --git a/packages/dashboard/app/components/DirectoryPicker.tsx b/packages/dashboard/app/components/DirectoryPicker.tsx index 3b4fe09e27..0731106cc5 100644 --- a/packages/dashboard/app/components/DirectoryPicker.tsx +++ b/packages/dashboard/app/components/DirectoryPicker.tsx @@ -74,13 +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) { - // Use browser.currentPath if available (user has navigated), otherwise fall back to value prop - fetchEntries(browser.currentPath || value || undefined, browser.showHidden); + 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) => { @@ -106,7 +105,8 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, if (browser.isOpen && browser.currentPath) { fetchEntries(browser.currentPath, browser.showHidden); } - }, [browser.showHidden]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [browser.showHidden, fetchEntries]); const handleToggleCreateFolder = useCallback(() => { setBrowser((prev) => ({ @@ -120,12 +120,22 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, 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: "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 + newFolderName.trim() - : browser.currentPath + sep + newFolderName.trim(); + ? browser.currentPath + trimmedName + : browser.currentPath + sep + trimmedName; setBrowser((prev) => ({ ...prev, loading: true, createFolderError: null })); try { @@ -287,7 +297,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, type="button" className="btn btn-sm btn-primary" onClick={handleCreateFolder} - disabled={!newFolderName.trim()} + disabled={!newFolderName.trim() || browser.loading} > {t("dirPicker.createFolderConfirm", "Create")} diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index cfcc6c4868..a73e15dcc5 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -4463,22 +4463,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout */ router.post("/create-directory", async (req, res) => { try { - const { resolve } = await import("node:path"); + const { resolve, isAbsolute } = await import("node:path"); const { mkdir, stat } = await import("node:fs/promises"); - const rawPath = req.body?.path as string | undefined; + const rawPath = typeof req.body?.path === "string" ? req.body.path.trim() : ""; if (!rawPath) { throw badRequest("Path is required"); } // Validate: must be absolute, no .. traversal - const resolvedPath = resolve(rawPath); + if (!isAbsolute(rawPath)) { + throw badRequest("Path must be absolute"); + } if (rawPath.includes("..")) { throw badRequest("Path must not contain '..' traversal"); } - if (resolvedPath !== resolve(resolvedPath)) { - throw badRequest("Path must be absolute"); - } + const resolvedPath = resolve(rawPath); // Check if path already exists try { @@ -4512,7 +4512,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout } // Create the directory - await mkdir(resolvedPath); + 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) { From a87c7d72cddbbc99f8cf95e3e2437ec520d74854 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 6 Jun 2026 06:52:43 -0700 Subject: [PATCH 4/6] fix: remove invalid eslint-disable comment for non-existent react-hooks rule --- packages/dashboard/app/components/DirectoryPicker.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/dashboard/app/components/DirectoryPicker.tsx b/packages/dashboard/app/components/DirectoryPicker.tsx index 0731106cc5..28247491b8 100644 --- a/packages/dashboard/app/components/DirectoryPicker.tsx +++ b/packages/dashboard/app/components/DirectoryPicker.tsx @@ -105,7 +105,6 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, if (browser.isOpen && browser.currentPath) { fetchEntries(browser.currentPath, browser.showHidden); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [browser.showHidden, fetchEntries]); const handleToggleCreateFolder = useCallback(() => { From 163332c6cc5662b0a64d11f3fc116e8082be1cff Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 6 Jun 2026 06:59:25 -0700 Subject: [PATCH 5/6] fix: localize create-folder validation error and add missing i18n keys - Add dirPicker.createFolderError to all locale files (en, es, fr, ko, zh-CN, zh-TW) - Use t() for the folder name validation error in DirectoryPicker - Add missing dirPicker keys: cancel, createFolder, createFolderAria, createFolderConfirm, createFolderTitle, newFolderPlaceholder --- packages/dashboard/app/components/DirectoryPicker.tsx | 2 +- packages/i18n/locales/en/app.json | 7 +++++++ packages/i18n/locales/es/app.json | 7 +++++++ packages/i18n/locales/fr/app.json | 7 +++++++ packages/i18n/locales/ko/app.json | 7 +++++++ packages/i18n/locales/zh-CN/app.json | 7 +++++++ packages/i18n/locales/zh-TW/app.json | 7 +++++++ 7 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/app/components/DirectoryPicker.tsx b/packages/dashboard/app/components/DirectoryPicker.tsx index 28247491b8..262ffe6fb7 100644 --- a/packages/dashboard/app/components/DirectoryPicker.tsx +++ b/packages/dashboard/app/components/DirectoryPicker.tsx @@ -124,7 +124,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, if (trimmedName.includes("/") || trimmedName.includes("\\") || trimmedName.includes("..")) { setBrowser((prev) => ({ ...prev, - createFolderError: "Folder name cannot contain path separators or '..'", + createFolderError: t("dirPicker.createFolderError", "Folder name cannot contain path separators or '..'"), })); return; } diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 7152123a10..b693b53bd8 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 9d3b07f82b..0e25bd2c8b 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -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", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index a9a5c21878..86c4aef2a3 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -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", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index a6eb88c43b..8dfa242996 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -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": "상위 디렉터리로 이동", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 35a35fa0f9..0b77256cc2 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -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": "进入父目录", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 706eeaa596..419267b7bd 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -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": "進入上層目錄", From d92ad82e0c4e7dd28ed5a611a1857f1721e7d3bf Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 6 Jun 2026 07:01:48 -0700 Subject: [PATCH 6/6] fix: disable Create button when currentPath is empty --- packages/dashboard/app/components/DirectoryPicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dashboard/app/components/DirectoryPicker.tsx b/packages/dashboard/app/components/DirectoryPicker.tsx index 262ffe6fb7..1f0d4d6c20 100644 --- a/packages/dashboard/app/components/DirectoryPicker.tsx +++ b/packages/dashboard/app/components/DirectoryPicker.tsx @@ -296,7 +296,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, type="button" className="btn btn-sm btn-primary" onClick={handleCreateFolder} - disabled={!newFolderName.trim() || browser.loading} + disabled={!newFolderName.trim() || browser.loading || !browser.currentPath} > {t("dirPicker.createFolderConfirm", "Create")}