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
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.
|
||||||
@@ -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(() => {
|
useEffect(() => {
|
||||||
if (browser.isOpen && !browser.loading && browser.entries.length === 0 && !browser.error) {
|
if (browser.isOpen && !browser.loading && !browser.currentPath && !browser.error) {
|
||||||
// Use browser.currentPath if available (user has navigated), otherwise fall back to value prop
|
fetchEntries(value || undefined, browser.showHidden);
|
||||||
fetchEntries(browser.currentPath || 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(
|
const handleNavigate = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
@@ -106,7 +105,8 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
|||||||
if (browser.isOpen && browser.currentPath) {
|
if (browser.isOpen && browser.currentPath) {
|
||||||
fetchEntries(browser.currentPath, browser.showHidden);
|
fetchEntries(browser.currentPath, browser.showHidden);
|
||||||
}
|
}
|
||||||
}, [browser.showHidden]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [browser.showHidden, fetchEntries]);
|
||||||
|
|
||||||
const handleToggleCreateFolder = useCallback(() => {
|
const handleToggleCreateFolder = useCallback(() => {
|
||||||
setBrowser((prev) => ({
|
setBrowser((prev) => ({
|
||||||
@@ -120,12 +120,22 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
|||||||
const handleCreateFolder = useCallback(async () => {
|
const handleCreateFolder = useCallback(async () => {
|
||||||
if (!newFolderName.trim() || !browser.currentPath) return;
|
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
|
// Normalize path separator for the current platform by using the same
|
||||||
// separator already present in currentPath
|
// separator already present in currentPath
|
||||||
const sep = browser.currentPath.includes("\\") ? "\\" : "/";
|
const sep = browser.currentPath.includes("\\") ? "\\" : "/";
|
||||||
const folderPath = browser.currentPath.endsWith(sep)
|
const folderPath = browser.currentPath.endsWith(sep)
|
||||||
? browser.currentPath + newFolderName.trim()
|
? browser.currentPath + trimmedName
|
||||||
: browser.currentPath + sep + newFolderName.trim();
|
: browser.currentPath + sep + trimmedName;
|
||||||
|
|
||||||
setBrowser((prev) => ({ ...prev, loading: true, createFolderError: null }));
|
setBrowser((prev) => ({ ...prev, loading: true, createFolderError: null }));
|
||||||
try {
|
try {
|
||||||
@@ -287,7 +297,7 @@ export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown,
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-sm btn-primary"
|
className="btn btn-sm btn-primary"
|
||||||
onClick={handleCreateFolder}
|
onClick={handleCreateFolder}
|
||||||
disabled={!newFolderName.trim()}
|
disabled={!newFolderName.trim() || browser.loading}
|
||||||
>
|
>
|
||||||
{t("dirPicker.createFolderConfirm", "Create")}
|
{t("dirPicker.createFolderConfirm", "Create")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4463,22 +4463,22 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
*/
|
*/
|
||||||
router.post("/create-directory", async (req, res) => {
|
router.post("/create-directory", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { resolve } = await import("node:path");
|
const { resolve, isAbsolute } = await import("node:path");
|
||||||
const { mkdir, stat } = await import("node:fs/promises");
|
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) {
|
if (!rawPath) {
|
||||||
throw badRequest("Path is required");
|
throw badRequest("Path is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate: must be absolute, no .. traversal
|
// Validate: must be absolute, no .. traversal
|
||||||
const resolvedPath = resolve(rawPath);
|
if (!isAbsolute(rawPath)) {
|
||||||
|
throw badRequest("Path must be absolute");
|
||||||
|
}
|
||||||
if (rawPath.includes("..")) {
|
if (rawPath.includes("..")) {
|
||||||
throw badRequest("Path must not contain '..' traversal");
|
throw badRequest("Path must not contain '..' traversal");
|
||||||
}
|
}
|
||||||
if (resolvedPath !== resolve(resolvedPath)) {
|
const resolvedPath = resolve(rawPath);
|
||||||
throw badRequest("Path must be absolute");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if path already exists
|
// Check if path already exists
|
||||||
try {
|
try {
|
||||||
@@ -4512,7 +4512,21 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the directory
|
// 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 });
|
res.json({ success: true, path: resolvedPath });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user