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:
gsxdsm
2026-04-02 13:30:08 -07:00
parent 2acd2cfb17
commit 7760bcc9cd
7 changed files with 1178 additions and 45 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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