Cut product flows over to Coding/Pi and retain only the migration-owned v1 boundary. Promote supported native optional packages because electron-builder omitted pnpm transitive optional closure from the packaged ASAR.
600 lines
20 KiB
TypeScript
600 lines
20 KiB
TypeScript
/**
|
|
* Setup Wizard Page
|
|
* First-time setup experience for new users
|
|
*/
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Check,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Loader2,
|
|
AlertCircle,
|
|
RefreshCw,
|
|
CheckCircle2,
|
|
} from 'lucide-react';
|
|
import { TitleBar } from '@/components/layout/TitleBar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { hostApiFetch } from '@/lib/host-api';
|
|
import { cn } from '@/lib/utils';
|
|
import { useSettingsStore } from '@/stores/settings';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { TFunction } from 'i18next';
|
|
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
|
import logoSvg from '@/assets/logo.svg';
|
|
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { toast } from 'sonner';
|
|
|
|
interface SetupStep {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
}
|
|
|
|
const STEP = {
|
|
WELCOME: 0,
|
|
RUNTIME: 1,
|
|
INSTALLING: 2,
|
|
COMPLETE: 3,
|
|
} as const;
|
|
|
|
const getSteps = (t: TFunction): SetupStep[] => [
|
|
{
|
|
id: 'welcome',
|
|
title: t('steps.welcome.title'),
|
|
description: t('steps.welcome.description'),
|
|
},
|
|
{
|
|
id: 'runtime',
|
|
title: t('steps.runtime.title'),
|
|
description: t('steps.runtime.description'),
|
|
},
|
|
{
|
|
id: 'installing',
|
|
title: t('steps.installing.title'),
|
|
description: t('steps.installing.description'),
|
|
},
|
|
{
|
|
id: 'complete',
|
|
title: t('steps.complete.title'),
|
|
description: t('steps.complete.description'),
|
|
},
|
|
];
|
|
|
|
// Default skills to auto-install (no additional API keys required)
|
|
interface DefaultSkill {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
const getDefaultSkills = (t: TFunction): DefaultSkill[] => [
|
|
{ id: 'coding-runtime', name: t('defaultSkills.codingRuntime.name'), description: t('defaultSkills.codingRuntime.description') },
|
|
{ id: 'python-env', name: t('defaultSkills.python-env.name'), description: t('defaultSkills.python-env.description') },
|
|
{ id: 'code-assist', name: t('defaultSkills.code-assist.name'), description: t('defaultSkills.code-assist.description') },
|
|
{ id: 'file-tools', name: t('defaultSkills.file-tools.name'), description: t('defaultSkills.file-tools.description') },
|
|
{ id: 'terminal', name: t('defaultSkills.terminal.name'), description: t('defaultSkills.terminal.description') },
|
|
];
|
|
|
|
const SETUP_MESSAGE_INTERVAL_MS = 1600;
|
|
const RUNTIME_CHECK_STUCK_MS = 8000;
|
|
|
|
type RuntimeProgressStatus = 'checking' | 'success' | 'error';
|
|
type InstallPhase = 'installing' | 'failed';
|
|
|
|
function getTranslatedMessages(t: TFunction, key: string, fallback: string[]) {
|
|
const value = t(key, { returnObjects: true }) as unknown;
|
|
if (Array.isArray(value)) {
|
|
const messages = value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0);
|
|
if (messages.length > 0) return messages;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function useRotatingSetupMessage(messages: string[], active = true) {
|
|
const [index, setIndex] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!active || messages.length <= 1) return undefined;
|
|
const timer = window.setInterval(() => {
|
|
setIndex((current) => (current + 1) % messages.length);
|
|
}, SETUP_MESSAGE_INTERVAL_MS);
|
|
return () => window.clearInterval(timer);
|
|
}, [active, messages]);
|
|
|
|
return messages[index % Math.max(messages.length, 1)] ?? messages[0] ?? '';
|
|
}
|
|
|
|
export function Setup() {
|
|
const { t } = useTranslation('setup');
|
|
const navigate = useNavigate();
|
|
const [currentStep, setCurrentStep] = useState<number>(STEP.WELCOME);
|
|
|
|
// Setup state
|
|
// Installation state for the Installing step
|
|
const [installedSkills, setInstalledSkills] = useState<string[]>([]);
|
|
// Runtime check status
|
|
const [runtimeChecksPassed, setRuntimeChecksPassed] = useState(false);
|
|
|
|
const steps = getSteps(t);
|
|
const safeStepIndex = Number.isInteger(currentStep)
|
|
? Math.min(Math.max(currentStep, STEP.WELCOME), steps.length - 1)
|
|
: STEP.WELCOME;
|
|
const step = steps[safeStepIndex] ?? steps[STEP.WELCOME];
|
|
const isFirstStep = safeStepIndex === STEP.WELCOME;
|
|
const isLastStep = safeStepIndex === steps.length - 1;
|
|
|
|
const markSetupComplete = useSettingsStore((state) => state.markSetupComplete);
|
|
|
|
// Derive canProceed based on current step - computed directly to avoid useEffect
|
|
const canProceed = useMemo(() => {
|
|
switch (safeStepIndex) {
|
|
case STEP.WELCOME:
|
|
return true;
|
|
case STEP.RUNTIME:
|
|
return runtimeChecksPassed;
|
|
case STEP.INSTALLING:
|
|
return false; // Cannot manually proceed, auto-proceeds when done
|
|
case STEP.COMPLETE:
|
|
return true;
|
|
default:
|
|
return true;
|
|
}
|
|
}, [safeStepIndex, runtimeChecksPassed]);
|
|
|
|
const handleNext = async () => {
|
|
if (isLastStep) {
|
|
// Complete setup
|
|
markSetupComplete();
|
|
toast.success(t('complete.title'));
|
|
navigate('/');
|
|
} else {
|
|
setCurrentStep((i) => i + 1);
|
|
}
|
|
};
|
|
|
|
const handleBack = () => {
|
|
setCurrentStep((i) => Math.max(i - 1, 0));
|
|
};
|
|
|
|
const handleSkip = () => {
|
|
markSetupComplete();
|
|
navigate('/');
|
|
};
|
|
|
|
// Auto-proceed when installation is complete
|
|
const handleInstallationComplete = useCallback((skills: string[]) => {
|
|
setInstalledSkills(skills);
|
|
// Auto-proceed to next step after a short delay
|
|
setTimeout(() => {
|
|
setCurrentStep((i) => i + 1);
|
|
}, 1000);
|
|
}, []);
|
|
|
|
|
|
return (
|
|
<div data-testid="setup-page" className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
|
<TitleBar />
|
|
<div className="flex-1 overflow-auto">
|
|
{/* Progress Indicator */}
|
|
<div className="flex justify-center pt-8">
|
|
<div className="flex items-center gap-2">
|
|
{steps.map((s, i) => (
|
|
<div key={s.id} className="flex items-center">
|
|
<div
|
|
className={cn(
|
|
'flex h-8 w-8 items-center justify-center rounded-full border transition-colors',
|
|
i < safeStepIndex
|
|
? 'border-primary bg-primary text-primary-foreground'
|
|
: i === safeStepIndex
|
|
? 'border-primary text-primary'
|
|
: 'border-slate-600 text-slate-600'
|
|
)}
|
|
>
|
|
{i < safeStepIndex ? (
|
|
<Check className="h-4 w-4" />
|
|
) : (
|
|
<span className="text-sm">{i + 1}</span>
|
|
)}
|
|
</div>
|
|
{i < steps.length - 1 && (
|
|
<div
|
|
className={cn(
|
|
'h-0.5 w-8 transition-colors',
|
|
i < safeStepIndex ? 'bg-primary' : 'bg-slate-600'
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step Content */}
|
|
<div key={step.id} className="mx-auto max-w-2xl p-8">
|
|
<div className="text-center mb-8">
|
|
<h1 className="text-3xl font-medium mb-2">{t(`steps.${step.id}.title`)}</h1>
|
|
<p className="text-slate-400">{t(`steps.${step.id}.description`)}</p>
|
|
</div>
|
|
|
|
{/* Step-specific content */}
|
|
<div className="rounded-xl bg-card text-card-foreground border shadow-sm p-8 mb-8">
|
|
{safeStepIndex === STEP.WELCOME && <WelcomeContent />}
|
|
{safeStepIndex === STEP.RUNTIME && <RuntimeContent onStatusChange={setRuntimeChecksPassed} />}
|
|
{safeStepIndex === STEP.INSTALLING && (
|
|
<InstallingContent
|
|
skills={getDefaultSkills(t)}
|
|
onComplete={handleInstallationComplete}
|
|
/>
|
|
)}
|
|
{safeStepIndex === STEP.COMPLETE && (
|
|
<CompleteContent
|
|
installedSkills={installedSkills}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Navigation - hidden during installation step */}
|
|
{safeStepIndex !== STEP.INSTALLING && (
|
|
<div className="flex justify-between">
|
|
<div>
|
|
{!isFirstStep && (
|
|
<Button variant="ghost" onClick={handleBack}>
|
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
|
{t('nav.back')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{!isLastStep && safeStepIndex !== STEP.RUNTIME && (
|
|
<Button data-testid="setup-skip-button" variant="ghost" onClick={handleSkip}>
|
|
{t('nav.skipSetup')}
|
|
</Button>
|
|
)}
|
|
<Button data-testid="setup-next-button" onClick={handleNext} disabled={!canProceed}>
|
|
{isLastStep ? (
|
|
t('nav.getStarted')
|
|
) : (
|
|
<>
|
|
{t('nav.next')}
|
|
<ChevronRight className="h-4 w-4 ml-2" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ==================== Step Content Components ====================
|
|
|
|
function WelcomeContent() {
|
|
const { t } = useTranslation(['setup', 'settings']);
|
|
const { language, setLanguage } = useSettingsStore();
|
|
const authUser = useAuthStore((state) => state.user);
|
|
const userDisplayName = getAuthUserDisplayName(authUser);
|
|
|
|
return (
|
|
<div data-testid="setup-welcome-step" className="text-center space-y-4">
|
|
<div className="mb-4 flex justify-center">
|
|
<img src={logoSvg} alt="Makelore" className="h-16 w-20 object-contain" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold">
|
|
{userDisplayName ? `${userDisplayName},欢迎回来!` : t('welcome.title')}
|
|
</h2>
|
|
<p className="text-muted-foreground">
|
|
{t('welcome.description')}
|
|
</p>
|
|
|
|
{/* Language Selector */}
|
|
<div className="flex justify-center gap-2 py-2">
|
|
{SUPPORTED_LANGUAGES.map((lang) => (
|
|
<Button
|
|
key={lang.code}
|
|
variant={language === lang.code ? 'secondary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setLanguage(lang.code)}
|
|
className="h-7 text-xs"
|
|
>
|
|
{lang.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
<ul className="text-left space-y-2 text-muted-foreground pt-2">
|
|
<li className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
|
{t('welcome.features.noCommand')}
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
|
{t('welcome.features.modernUI')}
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
|
{t('welcome.features.bundles')}
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<CheckCircle2 className="h-5 w-5 text-green-400" />
|
|
{t('welcome.features.crossPlatform')}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RuntimeContentProps {
|
|
onStatusChange: (canProceed: boolean) => void;
|
|
}
|
|
|
|
function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
|
const { t } = useTranslation('setup');
|
|
const stuckTimeoutRef = useRef<number | null>(null);
|
|
|
|
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeProgressStatus>('checking');
|
|
const [runtimeProgress, setRuntimeProgress] = useState(12);
|
|
|
|
const runtimeMessages = useMemo(
|
|
() => getTranslatedMessages(t, 'runtime.progress.messages', [
|
|
'Preparing the local workspace...',
|
|
'Checking bundled tools...',
|
|
'Warming up the coding assistant...',
|
|
]),
|
|
[t],
|
|
);
|
|
const rotatingMessage = useRotatingSetupMessage(runtimeMessages, runtimeStatus === 'checking');
|
|
|
|
const clearStuckTimeout = useCallback(() => {
|
|
if (stuckTimeoutRef.current !== null) {
|
|
window.clearTimeout(stuckTimeoutRef.current);
|
|
stuckTimeoutRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const markSuccess = useCallback(() => {
|
|
clearStuckTimeout();
|
|
setRuntimeStatus('success');
|
|
setRuntimeProgress(100);
|
|
onStatusChange(true);
|
|
}, [clearStuckTimeout, onStatusChange]);
|
|
|
|
const markFailure = useCallback(() => {
|
|
clearStuckTimeout();
|
|
setRuntimeStatus('error');
|
|
setRuntimeProgress(100);
|
|
onStatusChange(false);
|
|
}, [clearStuckTimeout, onStatusChange]);
|
|
|
|
const startStuckTimeout = useCallback(() => {
|
|
clearStuckTimeout();
|
|
stuckTimeoutRef.current = window.setTimeout(() => {
|
|
setRuntimeStatus((current) => (current === 'checking' ? 'error' : current));
|
|
setRuntimeProgress(100);
|
|
onStatusChange(false);
|
|
}, RUNTIME_CHECK_STUCK_MS);
|
|
}, [clearStuckTimeout, onStatusChange]);
|
|
|
|
const runChecks = useCallback(async () => {
|
|
onStatusChange(false);
|
|
setRuntimeStatus('checking');
|
|
setRuntimeProgress(18);
|
|
startStuckTimeout();
|
|
|
|
try {
|
|
setRuntimeProgress(48);
|
|
const current = await hostApiFetch<{ runtime?: string }>('/api/app/runtime-info');
|
|
if (current.runtime === 'pi') {
|
|
markSuccess();
|
|
return;
|
|
}
|
|
markFailure();
|
|
} catch {
|
|
markFailure();
|
|
}
|
|
}, [markFailure, markSuccess, onStatusChange, startStuckTimeout]);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => {
|
|
void runChecks();
|
|
}, 0);
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
clearStuckTimeout();
|
|
};
|
|
}, [clearStuckTimeout, runChecks]);
|
|
|
|
const statusMessage = runtimeStatus === 'success'
|
|
? t('runtime.progress.done')
|
|
: runtimeStatus === 'error'
|
|
? t('runtime.progress.stuck')
|
|
: rotatingMessage;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="space-y-3 text-center">
|
|
<div
|
|
className={cn(
|
|
'mx-auto flex h-14 w-14 items-center justify-center rounded-full border',
|
|
runtimeStatus === 'success'
|
|
? 'border-green-500/40 bg-green-500/10 text-green-400'
|
|
: runtimeStatus === 'error'
|
|
? 'border-red-500/40 bg-red-500/10 text-red-400'
|
|
: 'border-primary/40 bg-primary/10 text-primary',
|
|
)}
|
|
>
|
|
{runtimeStatus === 'checking' && <Loader2 className="h-7 w-7 animate-spin" />}
|
|
{runtimeStatus === 'success' && <CheckCircle2 className="h-7 w-7" />}
|
|
{runtimeStatus === 'error' && <AlertCircle className="h-7 w-7" />}
|
|
</div>
|
|
<h2 className="text-xl font-semibold">{t('runtime.title')}</h2>
|
|
<p className="min-h-5 text-sm text-muted-foreground">{statusMessage}</p>
|
|
</div>
|
|
|
|
<div className="space-y-2" data-testid="setup-runtime-progress">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">{t('runtime.progress.label')}</span>
|
|
<span className="font-medium text-primary">{runtimeProgress}%</span>
|
|
</div>
|
|
<Progress value={runtimeProgress} className="h-2" />
|
|
</div>
|
|
|
|
{runtimeStatus === 'error' && (
|
|
<div className="flex justify-center">
|
|
<Button data-testid="setup-runtime-retry" variant="outline" onClick={() => void runChecks()}>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
{t('runtime.progress.retry')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
interface InstallingContentProps {
|
|
skills: DefaultSkill[];
|
|
onComplete: (installedSkills: string[]) => void;
|
|
}
|
|
|
|
function InstallingContent({ skills, onComplete }: InstallingContentProps) {
|
|
const { t } = useTranslation('setup');
|
|
const [installPhase, setInstallPhase] = useState<InstallPhase>('installing');
|
|
const [overallProgress, setOverallProgress] = useState(0);
|
|
const [installAttempt, setInstallAttempt] = useState(0);
|
|
const installMessages = useMemo(
|
|
() => getTranslatedMessages(t, 'installing.progressMessages', [
|
|
'Preparing your first project workspace...',
|
|
'Connecting the built-in coding tools...',
|
|
'Finishing the starter setup...',
|
|
]),
|
|
[t],
|
|
);
|
|
const rotatingMessage = useRotatingSetupMessage(installMessages, installPhase === 'installing');
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const delay = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
|
|
const runInstall = async () => {
|
|
try {
|
|
setInstallPhase('installing');
|
|
setOverallProgress(14);
|
|
await delay(350);
|
|
if (cancelled) return;
|
|
setOverallProgress(46);
|
|
await delay(450);
|
|
if (cancelled) return;
|
|
setOverallProgress(78);
|
|
await delay(450);
|
|
if (cancelled) return;
|
|
setOverallProgress(100);
|
|
await delay(450);
|
|
if (cancelled) return;
|
|
onComplete(skills.map(s => s.id));
|
|
} catch {
|
|
if (cancelled) return;
|
|
setInstallPhase('failed');
|
|
setOverallProgress(100);
|
|
toast.error(t('installing.error'));
|
|
}
|
|
};
|
|
|
|
void runInstall();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [installAttempt, onComplete, skills, t]);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="space-y-3 text-center">
|
|
<div
|
|
className={cn(
|
|
'mx-auto flex h-14 w-14 items-center justify-center rounded-full border',
|
|
installPhase === 'failed'
|
|
? 'border-red-500/40 bg-red-500/10 text-red-400'
|
|
: 'border-primary/40 bg-primary/10 text-primary',
|
|
)}
|
|
>
|
|
{installPhase === 'failed' ? (
|
|
<AlertCircle className="h-7 w-7" />
|
|
) : (
|
|
<Loader2 className="h-7 w-7 animate-spin" />
|
|
)}
|
|
</div>
|
|
<h2 className="text-xl font-semibold mb-2">{t('installing.title')}</h2>
|
|
<p className="min-h-5 text-sm text-muted-foreground">
|
|
{installPhase === 'failed' ? t('installing.stuck') : rotatingMessage}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2" data-testid="setup-installing-progress">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">{t('installing.progress')}</span>
|
|
<span className="font-medium text-primary">{overallProgress}%</span>
|
|
</div>
|
|
<Progress value={overallProgress} className="h-2" />
|
|
</div>
|
|
|
|
{installPhase === 'installing' ? (
|
|
<p className="text-sm text-muted-foreground text-center">
|
|
{t('installing.wait')}
|
|
</p>
|
|
) : (
|
|
<div className="flex justify-center">
|
|
<Button variant="outline" onClick={() => setInstallAttempt((attempt) => attempt + 1)}>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
{t('installing.retry')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
interface CompleteContentProps {
|
|
installedSkills: string[];
|
|
}
|
|
|
|
function CompleteContent({ installedSkills }: CompleteContentProps) {
|
|
const { t } = useTranslation(['setup', 'settings']);
|
|
|
|
const installedSkillNames = getDefaultSkills(t)
|
|
.filter((s: DefaultSkill) => installedSkills.includes(s.id))
|
|
.map((s: DefaultSkill) => s.name)
|
|
.join(', ');
|
|
|
|
return (
|
|
<div className="text-center space-y-6">
|
|
<div className="text-6xl mb-4">🎉</div>
|
|
<h2 className="text-xl font-semibold">{t('complete.title')}</h2>
|
|
<p className="text-muted-foreground">
|
|
{t('complete.subtitle')}
|
|
</p>
|
|
|
|
<div className="space-y-3 text-left max-w-md mx-auto">
|
|
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
|
<span>{t('complete.components')}</span>
|
|
<span className="text-green-400">
|
|
{installedSkillNames || `${installedSkills.length} ${t('installing.status.installed')}`}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/50">
|
|
<span>{t('complete.runtime')}</span>
|
|
<span className="text-green-400">{t('complete.running')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
{t('complete.footer')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Setup;
|