import "./SetupWizardModal.css"; import { useState, useCallback } from "react"; import { X, Loader2, CheckCircle, ChevronRight } from "lucide-react"; import type { ProjectInfo, ProjectCreateInput } from "../api"; import { registerProject } from "../api"; import { getAuthToken, setAuthToken, clearAuthToken } from "../auth"; import { DirectoryPicker } from "./DirectoryPicker"; import { suggestProjectName } from "../utils/projectDetection"; import { useNodes } from "../hooks/useNodes"; export interface SetupWizardModalProps { /** Called when a single project is registered */ onProjectRegistered: (project: ProjectInfo) => void; /** Called when wizard is closed (completed or cancelled) */ onClose?: () => void; } type WizardStep = "manual" | "complete"; type ManualSetupMode = "existing" | "clone"; interface WizardState { step: WizardStep; manualMode: ManualSetupMode; manualPath: string; manualCloneUrl: string; manualName: string; manualIsolationMode: "in-process" | "child-process"; manualNodeId: string; isRegistering: boolean; error: string | null; } /** * Setup wizard for first-run project registration. * * Provides a polished onboarding experience with a directory picker * for selecting the project directory and auto-name suggestion. */ export function SetupWizardModal({ onProjectRegistered, onClose, }: SetupWizardModalProps) { const helpUrl = "https://github.com/runfusion/fusion/discussions"; const [isOpen, setIsOpen] = useState(true); const [state, setState] = useState({ step: "manual", manualMode: "existing", manualPath: "", manualCloneUrl: "", manualName: "", manualIsolationMode: "in-process", manualNodeId: "", isRegistering: false, error: null, }); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [authTokenInput, setAuthTokenInput] = useState(""); const [storedAuthToken, setStoredAuthToken] = useState(() => getAuthToken()); const { nodes, loading: nodesLoading } = useNodes(); const localNodeId = nodes.find((n) => n.type === "local")?.id; const handleClose = useCallback(() => { setIsOpen(false); onClose?.(); }, [onClose]); const handlePathChange = useCallback((path: string) => { setState((prev) => { const updates: Partial = { 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 () => { const trimmedPath = state.manualPath.trim(); const trimmedName = state.manualName.trim(); const trimmedCloneUrl = state.manualCloneUrl.trim(); if (!trimmedPath || !trimmedName) return; if (state.manualMode === "clone" && !trimmedCloneUrl) return; setState((prev) => ({ ...prev, isRegistering: true, error: null })); try { const input: ProjectCreateInput = { name: trimmedName, path: trimmedPath, isolationMode: state.manualIsolationMode, nodeId: state.manualNodeId || undefined, cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined, }; const result = await registerProject(input); onProjectRegistered(result); setState((prev) => ({ ...prev, step: "complete", isRegistering: false, })); } catch (err) { setState((prev) => ({ ...prev, isRegistering: false, error: err instanceof Error ? err.message : "Failed to register project", })); } }, [state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, onProjectRegistered]); const handleSetAuthToken = useCallback(() => { const token = authTokenInput.trim(); if (!token) return; setAuthToken(token); window.location.reload(); }, [authTokenInput]); const handleResetAuthToken = useCallback(() => { clearAuthToken(); setStoredAuthToken(undefined); setAuthTokenInput(""); window.location.reload(); }, []); if (!isOpen) return null; const isExistingMode = state.manualMode === "existing"; const isCloneMode = state.manualMode === "clone"; const hasPath = state.manualPath.trim().length > 0; const hasName = state.manualName.trim().length > 0; const hasCloneUrl = state.manualCloneUrl.trim().length > 0; const isRegisterDisabled = state.isRegistering || !hasPath || !hasName || (isCloneMode && !hasCloneUrl); return (
{/* Header */}
Fusion

{state.step === "manual" && "Welcome to Fusion"} {state.step === "complete" && "Setup Complete!"}

{state.step !== "complete" && ( )}
{/* Content */}
{/* Manual Step */} {state.step === "manual" && (
setState((prev) => ({ ...prev, manualName: e.target.value })) } placeholder="my-project" />

{isCloneMode ? "By default this follows the destination folder name unless you edit it." : "By default this follows the selected directory name unless you edit it."}

{isCloneMode ? "Select or type an absolute destination path. Fusion will clone into this directory." : "Select or type the absolute path to your project"}

{showAdvancedSettings && (
Setup Mode
{isCloneMode && (
setState((prev) => ({ ...prev, manualCloneUrl: e.target.value }))} placeholder="https://github.com/owner/repo.git" />

Fusion will run git clone into the destination directory, then register that cloned folder.

)}
Runtime Node
setAuthTokenInput(e.target.value)} placeholder={storedAuthToken ? "Enter a new token to replace the stored one" : "Paste the auth token for this browser"} autoComplete="off" spellCheck={false} />
{storedAuthToken && ( )}

{storedAuthToken ? "A token is already stored in this browser. Updating or resetting it will reload the page." : "Store a token in this browser for authenticated dashboard requests, then reload the page."}

)}
{state.error && (
{state.error}
)}
)} {/* Complete Step */} {state.step === "complete" && (
{/* Footer */}
Need help? {state.step === "manual" && ( )} {state.step === "complete" && ( )}
); }