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
This commit is contained in:
@@ -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 }));
|
||||
@@ -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<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) : [];
|
||||
|
||||
return (
|
||||
@@ -171,6 +222,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 +270,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()}
|
||||
>
|
||||
{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,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
|
||||
|
||||
Reference in New Issue
Block a user