Files
fusion/packages/dashboard/app/components/DirectoryPicker.tsx

338 lines
13 KiB
TypeScript

import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
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";
export interface DirectoryPickerProps {
value: string;
onChange: (path: string) => void;
placeholder?: string;
/** Optional keydown handler forwarded to the text input (e.g. Enter-to-submit). */
onInputKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
/** Node ID of the target node for browsing. */
nodeId?: string;
/** Node ID of the local node (used to determine when to route through proxy). */
localNodeId?: string;
}
interface BrowserState {
isOpen: boolean;
loading: boolean;
error: string | null;
currentPath: string;
parentPath: string | null;
entries: BrowseDirectoryResult["entries"];
showHidden: boolean;
createFolderOpen: boolean;
createFolderError: string | null;
}
export function DirectoryPicker({ value, onChange, placeholder, onInputKeyDown, nodeId, localNodeId }: DirectoryPickerProps) {
const { t } = useTranslation("app");
const [browser, setBrowser] = useState<BrowserState>({
isOpen: false,
loading: false,
error: null,
currentPath: "",
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 }));
try {
const result = await browseDirectory(path, showHidden, nodeId, localNodeId);
setBrowser((prev) => ({
...prev,
loading: false,
currentPath: result.currentPath,
parentPath: result.parentPath,
entries: result.entries,
}));
} catch (err) {
setBrowser((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : "Failed to browse directory",
}));
}
}, [nodeId, localNodeId]);
const handleToggleBrowser = useCallback(() => {
setBrowser((prev) => {
if (!prev.isOpen) {
// Opening — fetch entries
return { ...prev, isOpen: true };
}
return { ...prev, isOpen: false };
});
}, []);
// Fetch when browser opens (only for the initial open before any path has been fetched)
useEffect(() => {
if (browser.isOpen && !browser.loading && !browser.currentPath && !browser.error) {
fetchEntries(value || undefined, browser.showHidden);
}
}, [browser.isOpen, browser.loading, browser.currentPath, browser.error, value, browser.showHidden, fetchEntries, nodeId, localNodeId]);
const handleNavigate = useCallback(
(path: string) => {
fetchEntries(path, browser.showHidden);
},
[fetchEntries, browser.showHidden]
);
const handleSelect = useCallback(() => {
onChange(browser.currentPath);
setBrowser((prev) => ({ ...prev, isOpen: false }));
}, [browser.currentPath, onChange]);
const handleToggleHidden = useCallback(() => {
setBrowser((prev) => {
const next = !prev.showHidden;
return { ...prev, showHidden: next };
});
}, []);
// Refetch when showHidden changes while browser is open
useEffect(() => {
if (browser.isOpen && browser.currentPath) {
fetchEntries(browser.currentPath, 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: "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) : [];
return (
<div className="directory-picker">
<div className="directory-picker-input-row">
<input
type="text"
className="input directory-picker-input"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onInputKeyDown}
placeholder={placeholder || t("dirPicker.defaultPlaceholder", "/path/to/your/project")}
/>
<button
type="button"
className="btn btn-secondary btn-sm directory-picker-browse-btn"
onClick={handleToggleBrowser}
aria-label={browser.isOpen ? t("dirPicker.closeBrowser", "Close directory browser") : t("dirPicker.openBrowser", "Browse directories")}
>
{browser.isOpen ? <FolderOpen size={16} /> : <Folder size={16} />}
<span>{t("dirPicker.browse", "Browse")}</span>
</button>
</div>
{browser.isOpen && (
<div className="directory-picker-browser" role="tree" aria-label={t("dirPicker.ariaLabel", "Directory browser")}>
{/* Breadcrumbs */}
<div className="directory-picker-breadcrumbs">
{breadcrumbs.map((breadcrumb, index) => {
return (
<span key={breadcrumb.path} className="directory-picker-breadcrumb-item">
{index > 0 && <ChevronRight size={12} className="directory-picker-breadcrumb-sep" />}
<button
type="button"
className="directory-picker-breadcrumb"
onClick={() => handleNavigate(breadcrumb.path)}
title={breadcrumb.path}
>
{breadcrumb.label}
</button>
</span>
);
})}
</div>
{/* Toolbar */}
<div className="directory-picker-toolbar">
{browser.parentPath && (
<button
type="button"
className="btn btn-sm btn-secondary directory-picker-up-btn"
onClick={() => handleNavigate(browser.parentPath!)}
aria-label={t("dirPicker.parentDir", "Go to parent directory")}
title={t("dirPicker.parentDirTitle", "Parent directory")}
>
<ChevronUp size={14} />
<span>{t("dirPicker.up", "Up")}</span>
</button>
)}
<button
type="button"
className="btn btn-sm btn-secondary directory-picker-hidden-toggle"
onClick={handleToggleHidden}
aria-label={browser.showHidden ? t("dirPicker.hideHiddenAria", "Hide hidden directories") : t("dirPicker.showHiddenAria", "Show hidden directories")}
title={browser.showHidden ? t("dirPicker.hideHiddenTitle", "Hide hidden") : t("dirPicker.showHiddenTitle", "Show hidden")}
>
{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 */}
{browser.loading ? (
<div className="directory-picker-loading">
<Loader2 size={20} className="animate-spin" />
<span>{t("dirPicker.loading", "Loading…")}</span>
</div>
) : browser.error ? (
<div className="directory-picker-error">
<AlertCircle size={16} />
<span>{browser.error}</span>
</div>
) : (
<div className="directory-picker-entries">
{browser.entries.length === 0 ? (
<div className="directory-picker-empty">{t("dirPicker.noSubdirs", "No subdirectories")}</div>
) : (
browser.entries.map((entry) => (
<button
type="button"
key={entry.path}
className="directory-picker-entry"
onClick={() => handleNavigate(entry.path)}
role="treeitem"
title={entry.path}
>
<Folder size={16} className="directory-picker-entry-icon" />
<span className="directory-picker-entry-name">{entry.name}</span>
{entry.hasChildren && (
<ChevronRight size={14} className="directory-picker-entry-arrow" />
)}
</button>
))
)}
</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}
>
{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}>
{browser.currentPath}
</span>
<button
type="button"
className="btn btn-primary directory-picker-select-btn"
onClick={handleSelect}
>
{t("dirPicker.select", "Select")}
</button>
</div>
</div>
)}
</div>
);
}