Files
fusion/packages/dashboard/app/components/SetupWizard.tsx
gsxdsm d2d32acce8 feat(KB-503): add multi-project CLI support with project subcommands
- Add project subcommands: list, add, remove, show, set-default, detect
- Add --project flag support for all task commands
- Create project-context.ts utilities for project resolution
- Add defaultProjectId to GlobalSettings type
- Implement project auto-detection from cwd
- Update CLI argument parsing for global --project flag
- Add multi-project CLI documentation to AGENTS.md
2026-04-01 07:02:15 -07:00

447 lines
15 KiB
TypeScript

import { useState, useCallback, useEffect } from "react";
import { X, ChevronRight, ChevronLeft, Folder, Check, Loader2, AlertCircle } from "lucide-react";
import type { ProjectInfo, ProjectCreateInput } from "../api";
export interface SetupWizardProps {
isOpen: boolean;
onClose: () => void;
onProjectCreated: (project: ProjectInfo) => void;
onRegisterProject?: (input: ProjectCreateInput) => Promise<ProjectInfo>;
}
type WizardStep = "directory" | "name" | "isolation" | "validation" | "summary";
interface WizardState {
step: WizardStep;
directory: string;
name: string;
isolationMode: "in-process" | "child-process";
isValidating: boolean;
validationError: string | null;
hasFusionDir: boolean | null;
isCreating: boolean;
createError: string | null;
}
const STEP_ORDER: WizardStep[] = ["directory", "name", "isolation", "validation", "summary"];
function getStepIndex(step: WizardStep): number {
return STEP_ORDER.indexOf(step);
}
function isLastStep(step: WizardStep): boolean {
return getStepIndex(step) === STEP_ORDER.length - 1;
}
function isFirstStep(step: WizardStep): boolean {
return getStepIndex(step) === 0;
}
export function SetupWizard({ isOpen, onClose, onProjectCreated, onRegisterProject }: SetupWizardProps) {
const [state, setState] = useState<WizardState>({
step: "directory",
directory: "",
name: "",
isolationMode: "in-process",
isValidating: false,
validationError: null,
hasFusionDir: null,
isCreating: false,
createError: null,
});
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setState({
step: "directory",
directory: "",
name: "",
isolationMode: "in-process",
isValidating: false,
validationError: null,
hasFusionDir: null,
isCreating: false,
createError: null,
});
}
}, [isOpen]);
// Auto-suggest name from directory
useEffect(() => {
if (state.directory && !state.name) {
const basename = state.directory.split("/").pop() || state.directory.split("\\").pop() || "";
setState((prev) => ({ ...prev, name: basename }));
}
}, [state.directory, state.name]);
const handleNext = useCallback(() => {
const currentIndex = getStepIndex(state.step);
if (currentIndex < STEP_ORDER.length - 1) {
setState((prev) => ({
...prev,
step: STEP_ORDER[currentIndex + 1],
validationError: null,
createError: null,
}));
}
}, [state.step]);
const handleBack = useCallback(() => {
const currentIndex = getStepIndex(state.step);
if (currentIndex > 0) {
setState((prev) => ({
...prev,
step: STEP_ORDER[currentIndex - 1],
validationError: null,
createError: null,
}));
}
}, [state.step]);
const handleValidate = useCallback(async () => {
setState((prev) => ({ ...prev, isValidating: true, validationError: null }));
try {
// Check if directory exists and has .fusion/ directory
// In a real implementation, this would call an API endpoint
// For now, we simulate the check
await new Promise((resolve) => setTimeout(resolve, 1000));
// Simulate validation - assume valid for now
const hasFusionDir = true; // Would be determined by API call
setState((prev) => ({
...prev,
isValidating: false,
hasFusionDir,
step: "summary",
}));
} catch (err: any) {
setState((prev) => ({
...prev,
isValidating: false,
validationError: err.message || "Validation failed",
}));
}
}, []);
const handleCreate = useCallback(async () => {
if (!onRegisterProject) {
setState((prev) => ({ ...prev, createError: "Project registration not available" }));
return;
}
setState((prev) => ({ ...prev, isCreating: true, createError: null }));
try {
const input: ProjectCreateInput = {
name: state.name,
path: state.directory,
isolationMode: state.isolationMode,
};
const project = await onRegisterProject(input);
onProjectCreated(project);
onClose();
} catch (err: any) {
setState((prev) => ({
...prev,
isCreating: false,
createError: err.message || "Failed to create project",
}));
}
}, [onRegisterProject, state.name, state.directory, state.isolationMode, onProjectCreated, onClose]);
const canProceed = () => {
switch (state.step) {
case "directory":
return state.directory.trim().length > 0;
case "name":
return state.name.trim().length > 0;
case "isolation":
return true;
case "validation":
return !state.isValidating;
case "summary":
return !state.isCreating;
default:
return false;
}
};
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={onClose}>
<div className="modal modal-lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Add New Project</h3>
<button className="modal-close" onClick={onClose} aria-label="Close">
<X size={20} />
</button>
</div>
<div className="wizard-progress">
{STEP_ORDER.map((step, index) => (
<div
key={step}
className={`wizard-progress-step ${
index <= getStepIndex(state.step) ? "active" : ""
} ${step === state.step ? "current" : ""}`}
>
<span className="wizard-progress-number">{index + 1}</span>
<span className="wizard-progress-label">
{step === "directory" && "Directory"}
{step === "name" && "Name"}
{step === "isolation" && "Mode"}
{step === "validation" && "Validate"}
{step === "summary" && "Confirm"}
</span>
</div>
))}
</div>
<div className="wizard-content">
{/* Step 1: Directory Selection */}
{state.step === "directory" && (
<div className="wizard-step">
<h4>Select Project Directory</h4>
<p className="wizard-description">
Enter the absolute path to your project directory. This should be the root folder
containing your project files.
</p>
<div className="form-group">
<label htmlFor="project-directory">
Directory Path <span className="required">*</span>
</label>
<div className="wizard-input-group">
<Folder size={16} className="wizard-input-icon" />
<input
id="project-directory"
type="text"
value={state.directory}
onChange={(e) =>
setState((prev) => ({ ...prev, directory: e.target.value }))
}
placeholder="/path/to/your/project"
autoFocus
/>
</div>
</div>
<div className="wizard-hint">
<AlertCircle size={14} />
<span>
The directory must contain a <code>.fusion/</code> folder. If it doesn't exist,
you can initialize it in the next step.
</span>
</div>
</div>
)}
{/* Step 2: Project Name */}
{state.step === "name" && (
<div className="wizard-step">
<h4>Project Name</h4>
<p className="wizard-description">
Give your project a display name. This will be shown in the dashboard.
</p>
<div className="form-group">
<label htmlFor="project-name">
Name <span className="required">*</span>
</label>
<input
id="project-name"
type="text"
value={state.name}
onChange={(e) => setState((prev) => ({ ...prev, name: e.target.value }))}
placeholder="My Project"
autoFocus
/>
</div>
<div className="wizard-hint">
<Check size={14} />
<span>Suggested from directory name. You can change it if needed.</span>
</div>
</div>
)}
{/* Step 3: Isolation Mode */}
{state.step === "isolation" && (
<div className="wizard-step">
<h4>Execution Mode</h4>
<p className="wizard-description">
Choose how tasks should be executed for this project.
</p>
<div className="wizard-options">
<label
className={`wizard-option ${
state.isolationMode === "in-process" ? "selected" : ""
}`}
>
<input
type="radio"
name="isolation-mode"
value="in-process"
checked={state.isolationMode === "in-process"}
onChange={() =>
setState((prev) => ({ ...prev, isolationMode: "in-process" }))
}
/>
<div className="wizard-option-content">
<strong>In-Process (Default)</strong>
<span>Fast, low overhead. Tasks run in the main process.</span>
<span className="wizard-option-recommended">Recommended for most projects</span>
</div>
</label>
<label
className={`wizard-option ${
state.isolationMode === "child-process" ? "selected" : ""
}`}
>
<input
type="radio"
name="isolation-mode"
value="child-process"
checked={state.isolationMode === "child-process"}
onChange={() =>
setState((prev) => ({ ...prev, isolationMode: "child-process" }))
}
/>
<div className="wizard-option-content">
<strong>Child Process (Isolated)</strong>
<span>Strong isolation. Tasks run in separate processes.</span>
<span className="wizard-option-note">Higher overhead, crash containment</span>
</div>
</label>
</div>
</div>
)}
{/* Step 4: Validation */}
{state.step === "validation" && (
<div className="wizard-step">
<h4>Validation</h4>
<p className="wizard-description">
We're checking the project directory and preparing it for use.
</p>
{state.isValidating ? (
<div className="wizard-loading">
<Loader2 size={32} className="animate-spin" />
<span>Validating project directory...</span>
</div>
) : state.validationError ? (
<div className="wizard-error">
<AlertCircle size={24} />
<span>{state.validationError}</span>
</div>
) : (
<div className="wizard-validation-success">
<Check size={32} />
<span>Project directory is valid!</span>
</div>
)}
</div>
)}
{/* Step 5: Summary */}
{state.step === "summary" && (
<div className="wizard-step">
<h4>Summary</h4>
<p className="wizard-description">
Review your project settings before creating.
</p>
<div className="wizard-summary">
<div className="wizard-summary-row">
<span className="wizard-summary-label">Name:</span>
<span className="wizard-summary-value">{state.name}</span>
</div>
<div className="wizard-summary-row">
<span className="wizard-summary-label">Directory:</span>
<span className="wizard-summary-value" title={state.directory}>
{state.directory}
</span>
</div>
<div className="wizard-summary-row">
<span className="wizard-summary-label">Execution Mode:</span>
<span className="wizard-summary-value">
{state.isolationMode === "in-process" ? "In-Process" : "Child Process (Isolated)"}
</span>
</div>
</div>
{state.createError && (
<div className="wizard-error">
<AlertCircle size={20} />
<span>{state.createError}</span>
</div>
)}
</div>
)}
</div>
<div className="modal-actions">
<div className="modal-actions-left">
{!isFirstStep(state.step) && (
<button className="btn btn-secondary" onClick={handleBack} disabled={state.isCreating}>
<ChevronLeft size={16} />
Back
</button>
)}
</div>
<div className="modal-actions-right">
<button className="btn btn-secondary" onClick={onClose} disabled={state.isCreating}>
Cancel
</button>
{state.step === "validation" ? (
<button
className="btn btn-primary"
onClick={handleValidate}
disabled={state.isValidating || state.validationError}
>
{state.isValidating ? (
<>
<Loader2 size={16} className="animate-spin" />
Validating...
</>
) : (
<>
Validate
<ChevronRight size={16} />
</>
)}
</button>
) : isLastStep(state.step) ? (
<button
className="btn btn-primary"
onClick={handleCreate}
disabled={state.isCreating || !canProceed()}
>
{state.isCreating ? (
<>
<Loader2 size={16} className="animate-spin" />
Creating...
</>
) : (
<>
Create Project
<Check size={16} />
</>
)}
</button>
) : (
<button
className="btn btn-primary"
onClick={handleNext}
disabled={!canProceed()}
>
Next
<ChevronRight size={16} />
</button>
)}
</div>
</div>
</div>
</div>
);
}