feat(FN-694): add DirectoryPicker component and redesign SetupWizardModal
- Add browse-directory API endpoint and client for native folder selection - Create DirectoryPicker component with manual input, browse button, and validation - Redesign SetupWizardModal to use DirectoryPicker for project path selection - Add comprehensive tests for DirectoryPicker and SetupWizardModal - Remove bundled fusion skill and related extension/sync code from CLI package
This commit is contained in:
@@ -1916,6 +1916,21 @@ export function fetchProjects(): Promise<ProjectInfo[]> {
|
||||
return api<ProjectInfo[]>("/projects");
|
||||
}
|
||||
|
||||
/** Browse directory entries for the directory picker */
|
||||
export interface BrowseDirectoryResult {
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
entries: Array<{ name: string; path: string; hasChildren: boolean }>;
|
||||
}
|
||||
|
||||
export function browseDirectory(path?: string, showHidden?: boolean): Promise<BrowseDirectoryResult> {
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set("path", path);
|
||||
if (showHidden) params.set("showHidden", "true");
|
||||
const qs = params.toString();
|
||||
return api<BrowseDirectoryResult>(`/browse-directory${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/** Register a new project */
|
||||
export function registerProject(input: ProjectCreateInput): Promise<ProjectInfo> {
|
||||
return api<ProjectInfo>("/projects", {
|
||||
|
||||
224
packages/dashboard/app/components/DirectoryPicker.tsx
Normal file
224
packages/dashboard/app/components/DirectoryPicker.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { Folder, FolderOpen, ChevronRight, ChevronUp, Loader2, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
import { browseDirectory, type BrowseDirectoryResult } from "../api";
|
||||
|
||||
export interface DirectoryPickerProps {
|
||||
value: string;
|
||||
onChange: (path: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface BrowserState {
|
||||
isOpen: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
entries: BrowseDirectoryResult["entries"];
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange, placeholder }: DirectoryPickerProps) {
|
||||
const [browser, setBrowser] = useState<BrowserState>({
|
||||
isOpen: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
currentPath: "",
|
||||
parentPath: null,
|
||||
entries: [],
|
||||
showHidden: false,
|
||||
});
|
||||
|
||||
const fetchEntries = useCallback(async (path?: string, showHidden = false) => {
|
||||
setBrowser((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const result = await browseDirectory(path, showHidden);
|
||||
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",
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleBrowser = useCallback(() => {
|
||||
setBrowser((prev) => {
|
||||
if (!prev.isOpen) {
|
||||
// Opening — fetch entries
|
||||
return { ...prev, isOpen: true };
|
||||
}
|
||||
return { ...prev, isOpen: false };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fetch when browser opens
|
||||
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]);
|
||||
|
||||
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);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browser.showHidden]);
|
||||
|
||||
const breadcrumbs = browser.currentPath
|
||||
? browser.currentPath.split("/").filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="directory-picker">
|
||||
<div className="directory-picker-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="directory-picker-input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder || "/path/to/your/project"}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="directory-picker-browse-btn"
|
||||
onClick={handleToggleBrowser}
|
||||
aria-label={browser.isOpen ? "Close directory browser" : "Browse directories"}
|
||||
>
|
||||
{browser.isOpen ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{browser.isOpen && (
|
||||
<div className="directory-picker-browser" role="tree" aria-label="Directory browser">
|
||||
{/* Breadcrumbs */}
|
||||
<div className="directory-picker-breadcrumbs">
|
||||
<button
|
||||
className="directory-picker-breadcrumb"
|
||||
onClick={() => handleNavigate("/")}
|
||||
title="Root"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
{breadcrumbs.map((segment, i) => {
|
||||
const segPath = "/" + breadcrumbs.slice(0, i + 1).join("/");
|
||||
return (
|
||||
<span key={segPath} className="directory-picker-breadcrumb-item">
|
||||
<ChevronRight size={12} className="directory-picker-breadcrumb-sep" />
|
||||
<button
|
||||
className="directory-picker-breadcrumb"
|
||||
onClick={() => handleNavigate(segPath)}
|
||||
title={segPath}
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="directory-picker-toolbar">
|
||||
{browser.parentPath && (
|
||||
<button
|
||||
className="directory-picker-up-btn"
|
||||
onClick={() => handleNavigate(browser.parentPath!)}
|
||||
aria-label="Go to parent directory"
|
||||
title="Parent directory"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
<span>Up</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="directory-picker-hidden-toggle"
|
||||
onClick={handleToggleHidden}
|
||||
aria-label={browser.showHidden ? "Hide hidden directories" : "Show hidden directories"}
|
||||
title={browser.showHidden ? "Hide hidden" : "Show hidden"}
|
||||
>
|
||||
{browser.showHidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
<span>{browser.showHidden ? "Hide hidden" : "Show hidden"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{browser.loading ? (
|
||||
<div className="directory-picker-loading">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span>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">No subdirectories</div>
|
||||
) : (
|
||||
browser.entries.map((entry) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="directory-picker-actions">
|
||||
<span className="directory-picker-selected-path" title={browser.currentPath}>
|
||||
{browser.currentPath}
|
||||
</span>
|
||||
<button
|
||||
className="btn-primary directory-picker-select-btn"
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, Loader2, FolderPlus, CheckCircle } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { X, Loader2, Sparkles, CheckCircle } from "lucide-react";
|
||||
import type { ProjectInfo, ProjectCreateInput } from "../api";
|
||||
import { registerProject } from "../api";
|
||||
import { DirectoryPicker } from "./DirectoryPicker";
|
||||
import { suggestProjectName } from "../utils/projectDetection";
|
||||
|
||||
export interface SetupWizardModalProps {
|
||||
/** Called when a single project is registered */
|
||||
@@ -23,16 +25,9 @@ interface WizardState {
|
||||
|
||||
/**
|
||||
* Setup wizard for first-run project registration.
|
||||
*
|
||||
* Provides a wizard for new users to add their first project manually.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SetupWizardModal
|
||||
* onProjectRegistered={(project) => console.log(`Registered ${project.name}`)}
|
||||
* onClose={() => setShowWizard(false)}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* Provides a polished onboarding experience with a directory picker
|
||||
* for selecting the project directory and auto-name suggestion.
|
||||
*/
|
||||
export function SetupWizardModal({
|
||||
onProjectRegistered,
|
||||
@@ -53,6 +48,17 @@ export function SetupWizardModal({
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
const handlePathChange = useCallback((path: string) => {
|
||||
setState((prev) => {
|
||||
const updates: Partial<WizardState> = { manualPath: path };
|
||||
// Auto-suggest name when path changes and name is empty or was previously auto-suggested
|
||||
if (path && (!prev.manualName || prev.manualName === suggestProjectName(prev.manualPath))) {
|
||||
updates.manualName = suggestProjectName(path);
|
||||
}
|
||||
return { ...prev, ...updates };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleManualRegister = useCallback(async () => {
|
||||
if (!state.manualPath || !state.manualName) return;
|
||||
|
||||
@@ -109,26 +115,22 @@ export function SetupWizardModal({
|
||||
{/* Manual Step */}
|
||||
{state.step === "manual" && (
|
||||
<div className="setup-wizard-manual">
|
||||
<div className="welcome-icon" style={{ marginBottom: "1rem" }}>
|
||||
<FolderPlus size={48} />
|
||||
<div className="welcome-icon">
|
||||
<Sparkles size={32} />
|
||||
</div>
|
||||
<p className="welcome-text" style={{ marginBottom: "1.5rem" }}>
|
||||
Let's set up your first kb project. Enter the path to your project directory.
|
||||
<p className="welcome-text">
|
||||
Let's set up your first project. Browse to your project directory or type the path manually.
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-path">Project Path</label>
|
||||
<input
|
||||
id="project-path"
|
||||
type="text"
|
||||
<label htmlFor="project-path">Project Directory</label>
|
||||
<DirectoryPicker
|
||||
value={state.manualPath}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({ ...prev, manualPath: e.target.value }))
|
||||
}
|
||||
onChange={handlePathChange}
|
||||
placeholder="/path/to/your/project"
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Absolute path to your project directory
|
||||
Select or type the absolute path to your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -146,24 +148,48 @@ export function SetupWizardModal({
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="isolation-mode">Isolation Mode</label>
|
||||
<select
|
||||
id="isolation-mode"
|
||||
value={state.manualIsolationMode}
|
||||
onChange={(e) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
manualIsolationMode: e.target.value as "in-process" | "child-process",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="in-process">In-Process (faster, default)</option>
|
||||
<option value="child-process">Child-Process (isolated)</option>
|
||||
</select>
|
||||
<label>Isolation Mode</label>
|
||||
<div className="setup-wizard-isolation-options">
|
||||
<label
|
||||
className={`setup-wizard-isolation-option${state.manualIsolationMode === "in-process" ? " selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="isolation-mode"
|
||||
value="in-process"
|
||||
checked={state.manualIsolationMode === "in-process"}
|
||||
onChange={() =>
|
||||
setState((prev) => ({ ...prev, manualIsolationMode: "in-process" }))
|
||||
}
|
||||
/>
|
||||
<div className="setup-wizard-isolation-option-content">
|
||||
<strong>In-Process</strong>
|
||||
<span>Lower overhead, shared memory. Best for most projects.</span>
|
||||
<span className="wizard-option-recommended">Recommended</span>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
className={`setup-wizard-isolation-option${state.manualIsolationMode === "child-process" ? " selected" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="isolation-mode"
|
||||
value="child-process"
|
||||
checked={state.manualIsolationMode === "child-process"}
|
||||
onChange={() =>
|
||||
setState((prev) => ({ ...prev, manualIsolationMode: "child-process" }))
|
||||
}
|
||||
/>
|
||||
<div className="setup-wizard-isolation-option-content">
|
||||
<strong>Child-Process</strong>
|
||||
<span>Isolated execution with crash containment.</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="error-message" style={{ marginTop: "1rem" }}>
|
||||
<div className="wizard-error" role="alert">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -175,9 +201,7 @@ export function SetupWizardModal({
|
||||
<div className="setup-wizard-complete">
|
||||
<CheckCircle size={64} className="success-icon" />
|
||||
<h3>All Set!</h3>
|
||||
<p>
|
||||
Your project has been registered successfully.
|
||||
</p>
|
||||
<p>Your project has been registered successfully.</p>
|
||||
<p>You can add more projects anytime from the project overview.</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -197,9 +221,7 @@ export function SetupWizardModal({
|
||||
<span>Registering...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Register Project</span>
|
||||
</>
|
||||
<span>Register Project</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { DirectoryPicker } from "../DirectoryPicker";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
Folder: ({ size, ...props }: any) => <span data-testid="folder-icon" {...props}>📁</span>,
|
||||
FolderOpen: ({ size, ...props }: any) => <span data-testid="folder-open-icon" {...props}>📂</span>,
|
||||
ChevronRight: ({ size, ...props }: any) => <span {...props}>→</span>,
|
||||
ChevronUp: ({ size, ...props }: any) => <span {...props}>↑</span>,
|
||||
Loader2: ({ size, ...props }: any) => <span data-testid="loader" {...props}>⟳</span>,
|
||||
Eye: ({ size, ...props }: any) => <span {...props}>👁</span>,
|
||||
EyeOff: ({ size, ...props }: any) => <span {...props}>🙈</span>,
|
||||
AlertCircle: ({ size, ...props }: any) => <span {...props}>⚠</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the API
|
||||
vi.mock("../../api", () => ({
|
||||
browseDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
import { browseDirectory } from "../../api";
|
||||
|
||||
const mockBrowseDirectory = vi.mocked(browseDirectory);
|
||||
|
||||
describe("DirectoryPicker", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBrowseDirectory.mockResolvedValue({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [
|
||||
{ name: "projects", path: "/home/user/projects", hasChildren: true },
|
||||
{ name: "Documents", path: "/home/user/Documents", hasChildren: true },
|
||||
{ name: "empty-dir", path: "/home/user/empty-dir", hasChildren: false },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders with value and placeholder", () => {
|
||||
render(
|
||||
<DirectoryPicker
|
||||
value="/some/path"
|
||||
onChange={vi.fn()}
|
||||
placeholder="Select a directory"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText("Select a directory") as HTMLInputElement;
|
||||
expect(input.value).toBe("/some/path");
|
||||
expect(screen.getByText("Browse")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onChange when typing in the input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DirectoryPicker value="" onChange={onChange} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(input, { target: { value: "/new/path" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("/new/path");
|
||||
});
|
||||
|
||||
it("opens browser panel and fetches entries on Browse click", async () => {
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBrowseDirectory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("projects")).toBeDefined();
|
||||
expect(screen.getByText("Documents")).toBeDefined();
|
||||
expect(screen.getByText("empty-dir")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("navigates into a directory on click", async () => {
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("projects")).toBeDefined();
|
||||
});
|
||||
|
||||
// Now click on "projects" to navigate into it
|
||||
mockBrowseDirectory.mockResolvedValueOnce({
|
||||
currentPath: "/home/user/projects",
|
||||
parentPath: "/home/user",
|
||||
entries: [
|
||||
{ name: "my-app", path: "/home/user/projects/my-app", hasChildren: true },
|
||||
],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("projects"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBrowseDirectory).toHaveBeenCalledWith("/home/user/projects", false);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onChange when Select is clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DirectoryPicker value="" onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("projects")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Select"));
|
||||
expect(onChange).toHaveBeenCalledWith("/home/user");
|
||||
});
|
||||
|
||||
it("shows loading state while fetching", async () => {
|
||||
let resolvePromise: (val: any) => void;
|
||||
mockBrowseDirectory.mockImplementation(
|
||||
() => new Promise((resolve) => { resolvePromise = resolve; })
|
||||
);
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Loading…")).toBeDefined();
|
||||
});
|
||||
|
||||
// Resolve to clean up
|
||||
resolvePromise!({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No subdirectories")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state on fetch failure", async () => {
|
||||
mockBrowseDirectory.mockRejectedValueOnce(new Error("Permission denied"));
|
||||
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Permission denied")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes browser on second Browse click", async () => {
|
||||
render(<DirectoryPicker value="" onChange={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("projects")).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Browse"));
|
||||
expect(screen.queryByText("projects")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { SetupWizardModal } from "../SetupWizardModal";
|
||||
|
||||
// Mock lucide-react
|
||||
vi.mock("lucide-react", async () => {
|
||||
const actual = await vi.importActual("lucide-react");
|
||||
return {
|
||||
...actual,
|
||||
X: ({ size, ...props }: any) => <span data-testid="close-icon" {...props}>×</span>,
|
||||
Loader2: ({ size, ...props }: any) => <span data-testid="loader" {...props}>⟳</span>,
|
||||
Sparkles: ({ size, ...props }: any) => <span data-testid="sparkles-icon" {...props}>✨</span>,
|
||||
CheckCircle: ({ size, ...props }: any) => <span data-testid="check-icon" {...props}>✓</span>,
|
||||
Folder: ({ size, ...props }: any) => <span {...props}>📁</span>,
|
||||
FolderOpen: ({ size, ...props }: any) => <span {...props}>📂</span>,
|
||||
ChevronRight: ({ size, ...props }: any) => <span {...props}>→</span>,
|
||||
ChevronUp: ({ size, ...props }: any) => <span {...props}>↑</span>,
|
||||
Eye: ({ size, ...props }: any) => <span {...props}>👁</span>,
|
||||
EyeOff: ({ size, ...props }: any) => <span {...props}>🙈</span>,
|
||||
AlertCircle: ({ size, ...props }: any) => <span {...props}>⚠</span>,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock api module
|
||||
vi.mock("../../api", () => ({
|
||||
registerProject: vi.fn(),
|
||||
browseDirectory: vi.fn().mockResolvedValue({
|
||||
currentPath: "/home/user",
|
||||
parentPath: "/home",
|
||||
entries: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
import { registerProject } from "../../api";
|
||||
|
||||
const mockRegisterProject = vi.mocked(registerProject);
|
||||
|
||||
describe("SetupWizardModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders with welcome message", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Welcome to kb")).toBeDefined();
|
||||
expect(screen.getByText(/Let's set up your first project/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("has DirectoryPicker for path selection", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// DirectoryPicker renders an input and a Browse button
|
||||
expect(screen.getByPlaceholderText("/path/to/your/project")).toBeDefined();
|
||||
expect(screen.getByText("Browse")).toBeDefined();
|
||||
});
|
||||
|
||||
it("auto-populates project name from selected directory path", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Type a path in the DirectoryPicker input
|
||||
const pathInput = screen.getByPlaceholderText("/path/to/your/project");
|
||||
fireEvent.change(pathInput, { target: { value: "/home/user/my-awesome-project" } });
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("my-awesome-project");
|
||||
});
|
||||
|
||||
it("register button is disabled when required fields are empty", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const registerBtn = screen.getByText("Register Project").closest("button")!;
|
||||
expect(registerBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("register button is enabled when path and name are provided", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
|
||||
target: { value: "/home/user/project" },
|
||||
});
|
||||
// Name auto-populates; ensure it's not empty
|
||||
const nameInput = screen.getByPlaceholderText("my-project") as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("project");
|
||||
|
||||
const registerBtn = screen.getByText("Register Project").closest("button")!;
|
||||
expect(registerBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows error state on registration failure", async () => {
|
||||
mockRegisterProject.mockRejectedValueOnce(new Error("Path does not exist"));
|
||||
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
|
||||
target: { value: "/bad/path" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("my-project"), {
|
||||
target: { value: "test-project" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Register Project"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Path does not exist")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows completion state after successful registration", async () => {
|
||||
const mockProject = {
|
||||
id: "proj_123",
|
||||
name: "test-project",
|
||||
path: "/home/user/project",
|
||||
status: "active" as const,
|
||||
isolationMode: "in-process" as const,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
mockRegisterProject.mockResolvedValueOnce(mockProject);
|
||||
|
||||
const onProjectRegistered = vi.fn();
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={onProjectRegistered}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("/path/to/your/project"), {
|
||||
target: { value: "/home/user/project" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("my-project"), {
|
||||
target: { value: "test-project" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Register Project"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All Set!")).toBeDefined();
|
||||
expect(screen.getByText("Get Started")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
|
||||
});
|
||||
|
||||
it("close button calls onClose", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Close wizard"));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders isolation mode radio cards", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("In-Process")).toBeDefined();
|
||||
expect(screen.getByText("Child-Process")).toBeDefined();
|
||||
expect(screen.getByText("Recommended")).toBeDefined();
|
||||
});
|
||||
|
||||
it("can switch isolation mode", () => {
|
||||
render(
|
||||
<SetupWizardModal
|
||||
onProjectRegistered={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially in-process is selected
|
||||
const inProcessRadio = screen.getByDisplayValue("in-process") as HTMLInputElement;
|
||||
const childProcessRadio = screen.getByDisplayValue("child-process") as HTMLInputElement;
|
||||
|
||||
expect(inProcessRadio.checked).toBe(true);
|
||||
expect(childProcessRadio.checked).toBe(false);
|
||||
|
||||
fireEvent.click(childProcessRadio);
|
||||
expect(childProcessRadio.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13659,3 +13659,415 @@ html .column.drag-over * {
|
||||
[data-theme="light"] .executor-status-bar__state {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
/* === DirectoryPicker === */
|
||||
.directory-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.directory-picker-input-row {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.directory-picker-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.directory-picker-browse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.directory-picker-browse-btn:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.directory-picker-browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
animation: fadeIn var(--transition-fast);
|
||||
}
|
||||
|
||||
.directory-picker-breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.directory-picker-breadcrumb {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--todo);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.directory-picker-breadcrumb:hover {
|
||||
background: var(--card-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.directory-picker-breadcrumb-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.directory-picker-breadcrumb-sep {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.directory-picker-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.directory-picker-up-btn,
|
||||
.directory-picker-hidden-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.directory-picker-up-btn:hover,
|
||||
.directory-picker-hidden-toggle:hover {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.directory-picker-entries {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.directory-picker-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
padding: 6px var(--space-md);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.directory-picker-entry:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.directory-picker-entry-icon {
|
||||
color: var(--todo);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.directory-picker-entry-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.directory-picker-entry-arrow {
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.directory-picker-loading,
|
||||
.directory-picker-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.directory-picker-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
margin: var(--space-sm) var(--space-md);
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid var(--error);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--error);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.directory-picker-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.directory-picker-selected-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.directory-picker-select-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 16px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* === Setup Wizard Modal (Onboarding) === */
|
||||
.setup-wizard-modal {
|
||||
max-width: 540px;
|
||||
width: 100%;
|
||||
animation: slideUp var(--transition-normal) ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.setup-wizard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-xl) var(--space-xl) 0;
|
||||
}
|
||||
|
||||
.setup-wizard-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setup-wizard-content {
|
||||
padding: var(--space-lg) var(--space-xl);
|
||||
}
|
||||
|
||||
.setup-wizard-manual {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.setup-wizard-manual .welcome-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--todo);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.setup-wizard-manual .welcome-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 var(--space-xl);
|
||||
}
|
||||
|
||||
.setup-wizard-manual .form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.setup-wizard-manual .form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.setup-wizard-manual .form-group input,
|
||||
.setup-wizard-manual .form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.setup-wizard-manual .form-group input:focus,
|
||||
.setup-wizard-manual .form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--todo);
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.setup-wizard-manual .form-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option:hover {
|
||||
background: var(--card-hover);
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option.selected {
|
||||
background: rgba(88, 166, 255, 0.05);
|
||||
border-color: var(--todo);
|
||||
box-shadow: 0 0 0 1px var(--todo);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option input[type="radio"] {
|
||||
margin-top: 2px;
|
||||
accent-color: var(--todo);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option-content strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option-content span {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.setup-wizard-isolation-option .wizard-option-recommended {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.setup-wizard-complete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xl) 0;
|
||||
}
|
||||
|
||||
.setup-wizard-complete .success-icon {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.setup-wizard-complete h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.setup-wizard-complete p {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.setup-wizard-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 0 var(--space-xl) var(--space-xl);
|
||||
}
|
||||
|
||||
.setup-wizard-footer .btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setup-wizard-footer .btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -6078,6 +6078,76 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
// Mount mission routes at /api/missions
|
||||
router.use("/missions", createMissionRouter(store));
|
||||
|
||||
// ── Directory Browsing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/browse-directory
|
||||
* Browse filesystem directories for the directory picker.
|
||||
* Query: { path?: string, showHidden?: "true" }
|
||||
* Returns: { currentPath: string, parentPath: string | null, entries: Array<{ name: string, path: string, hasChildren: boolean }> }
|
||||
*/
|
||||
router.get("/browse-directory", async (req, res) => {
|
||||
try {
|
||||
const { resolve, dirname, join } = await import("node:path");
|
||||
const { readdir, stat } = await import("node:fs/promises");
|
||||
|
||||
const rawPath = (req.query.path as string) || process.env.HOME || process.env.USERPROFILE || "/";
|
||||
const showHidden = req.query.showHidden === "true";
|
||||
|
||||
// Validate: must be absolute, no .. traversal
|
||||
const resolvedPath = resolve(rawPath);
|
||||
if (rawPath.includes("..")) {
|
||||
res.status(400).json({ error: "Path must not contain '..' traversal" });
|
||||
return;
|
||||
}
|
||||
if (resolvedPath !== resolve(resolvedPath)) {
|
||||
res.status(400).json({ error: "Path must be absolute" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check path exists and is a directory
|
||||
let pathStat;
|
||||
try {
|
||||
pathStat = await stat(resolvedPath);
|
||||
} catch {
|
||||
res.status(404).json({ error: "Directory not found" });
|
||||
return;
|
||||
}
|
||||
if (!pathStat.isDirectory()) {
|
||||
res.status(400).json({ error: "Path is not a directory" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
const dirEntries = await readdir(resolvedPath, { withFileTypes: true });
|
||||
const entries: Array<{ name: string; path: string; hasChildren: boolean }> = [];
|
||||
|
||||
for (const entry of dirEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!showHidden && entry.name.startsWith(".")) continue;
|
||||
|
||||
const entryPath = join(resolvedPath, entry.name);
|
||||
let hasChildren = false;
|
||||
try {
|
||||
const subEntries = await readdir(entryPath, { withFileTypes: true });
|
||||
hasChildren = subEntries.some((e) => e.isDirectory());
|
||||
} catch {
|
||||
// Can't read subdirectory — treat as no children
|
||||
}
|
||||
|
||||
entries.push({ name: entry.name, path: entryPath, hasChildren });
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const parentPath = resolvedPath === "/" ? null : dirname(resolvedPath);
|
||||
|
||||
res.json({ currentPath: resolvedPath, parentPath, entries });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Project Management Routes (Multi-Project Support) ───────────────────────
|
||||
// These routes require CentralCore which is imported dynamically to avoid
|
||||
// circular dependencies and ensure the central database is initialized.
|
||||
|
||||
Reference in New Issue
Block a user