Makelore 2.0 initial clean snapshot

This commit is contained in:
inman
2026-07-29 17:22:35 +08:00
commit b8ca3f8eea
694 changed files with 139782 additions and 0 deletions

634
src/pages/Setup/index.tsx Normal file
View File

@@ -0,0 +1,634 @@
/**
* 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 { motion, AnimatePresence } from 'framer-motion';
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 { cn } from '@/lib/utils';
import { useOpencodeStore } from '@/stores/opencode';
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: 'opencode', name: t('defaultSkills.opencode.name'), description: t('defaultSkills.opencode.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] ?? '';
}
function isRuntimeReady(state: string) {
return state === 'running' || state === 'stopped';
}
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-2 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 */}
<AnimatePresence mode="wait">
<motion.div
key={step.id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="mx-auto max-w-2xl p-8"
>
<div className="text-center mb-8">
<h1 className="text-3xl font-bold 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>
)}
</motion.div>
</AnimatePresence>
</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 opencodeStatus = useOpencodeStore((state) => state.status);
const refreshOpencodeStatus = useOpencodeStore((state) => state.refreshStatus);
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);
await refreshOpencodeStatus();
const current = useOpencodeStore.getState().status;
if (isRuntimeReady(current.state)) {
markSuccess();
return;
}
if (current.state === 'error') {
markFailure();
return;
}
setRuntimeProgress(72);
} catch {
markFailure();
}
}, [markFailure, markSuccess, onStatusChange, refreshOpencodeStatus, startStuckTimeout]);
useEffect(() => {
const timer = window.setTimeout(() => {
void runChecks();
}, 0);
return () => {
window.clearTimeout(timer);
clearStuckTimeout();
};
}, [clearStuckTimeout, runChecks]);
useEffect(() => {
let timer: number | null = null;
if (opencodeStatus.state === 'running') {
timer = window.setTimeout(markSuccess, 0);
} else if (opencodeStatus.state === 'error') {
timer = window.setTimeout(markFailure, 0);
}
return () => {
if (timer !== null) window.clearTimeout(timer);
};
}, [markFailure, markSuccess, opencodeStatus.state]);
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 opencodeStatus = useOpencodeStore((state) => state.status);
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={opencodeStatus.state === 'running' ? 'text-green-400' : 'text-yellow-400'}>
{opencodeStatus.state === 'running' ? t('complete.running') : opencodeStatus.state}
</span>
</div>
</div>
<p className="text-sm text-muted-foreground">
{t('complete.footer')}
</p>
</div>
);
}
export default Setup;