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:
gsxdsm
2026-06-06 09:24:31 -07:00
committed by GitHub
13 changed files with 523 additions and 7 deletions

View File

@@ -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", {

View File

@@ -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);
}

View File

@@ -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}>

View File

@@ -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();
});
});
});

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "상위 디렉터리로 이동",

View File

@@ -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": "进入父目录",

View File

@@ -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": "進入上層目錄",