refactor: setup project wizards with only three steps

This commit is contained in:
Haileyesus
2026-03-27 22:16:56 +03:00
parent ec70bfe7c7
commit 6cfe617711
19 changed files with 332 additions and 563 deletions

View File

@@ -4,21 +4,19 @@ import { useTranslation } from 'react-i18next';
import ErrorBanner from './components/ErrorBanner';
import StepConfiguration from './components/StepConfiguration';
import StepReview from './components/StepReview';
import StepTypeSelection from './components/StepTypeSelection';
import WizardFooter from './components/WizardFooter';
import WizardProgress from './components/WizardProgress';
import { useGithubTokens } from './hooks/useGithubTokens';
import { cloneWorkspaceWithProgress, createWorkspaceRequest } from './data/workspaceApi';
import { cloneWorkspaceWithProgress, createWorkspaceRequest } from './data/projectWizardApi';
import { isCloneWorkflow, shouldShowGithubAuthentication } from './utils/pathUtils';
import type { TokenMode, WizardFormState, WizardStep, WorkspaceType } from './types';
import type { TokenMode, WizardFormState, WizardStep } from './types';
type ProjectCreationWizardProps = {
onClose: () => void;
onProjectCreated?: (project?: Record<string, unknown>) => void;
onProjectCreated?: () => void;
};
const initialFormState: WizardFormState = {
workspaceType: 'existing',
workspacePath: '',
githubUrl: '',
tokenMode: 'stored',
@@ -37,8 +35,7 @@ export default function ProjectCreationWizard({
const [error, setError] = useState<string | null>(null);
const [cloneProgress, setCloneProgress] = useState('');
const shouldLoadTokens =
step === 2 && shouldShowGithubAuthentication(formState.workspaceType, formState.githubUrl);
const shouldLoadTokens = step === 1 && shouldShowGithubAuthentication(formState.githubUrl);
const autoSelectToken = useCallback((tokenId: string) => {
setFormState((previous) => ({ ...previous, selectedGithubToken: tokenId }));
@@ -60,11 +57,6 @@ export default function ProjectCreationWizard({
setFormState((previous) => ({ ...previous, [key]: value }));
}, []);
const updateWorkspaceType = useCallback(
(workspaceType: WorkspaceType) => updateField('workspaceType', workspaceType),
[updateField],
);
const updateTokenMode = useCallback(
(tokenMode: TokenMode) => updateField('tokenMode', tokenMode),
[updateField],
@@ -74,22 +66,13 @@ export default function ProjectCreationWizard({
setError(null);
if (step === 1) {
if (!formState.workspaceType) {
setError(t('projectWizard.errors.selectType'));
return;
}
setStep(2);
return;
}
if (step === 2) {
if (!formState.workspacePath.trim()) {
setError(t('projectWizard.errors.providePath'));
return;
}
setStep(3);
setStep(2);
}
}, [formState.workspacePath, formState.workspaceType, step, t]);
}, [formState.workspacePath, step, t]);
const handleBack = useCallback(() => {
setError(null);
@@ -102,10 +85,10 @@ export default function ProjectCreationWizard({
setCloneProgress('');
try {
const shouldCloneRepository = isCloneWorkflow(formState.workspaceType, formState.githubUrl);
const shouldCloneRepository = isCloneWorkflow(formState.githubUrl);
if (shouldCloneRepository) {
const project = await cloneWorkspaceWithProgress(
await cloneWorkspaceWithProgress(
{
workspacePath: formState.workspacePath,
githubUrl: formState.githubUrl,
@@ -118,17 +101,16 @@ export default function ProjectCreationWizard({
},
);
onProjectCreated?.(project);
onProjectCreated?.();
onClose();
return;
}
const project = await createWorkspaceRequest({
workspaceType: formState.workspaceType,
await createWorkspaceRequest({
path: formState.workspacePath.trim(),
});
onProjectCreated?.(project);
onProjectCreated?.();
onClose();
} catch (createError) {
const errorMessage =
@@ -141,10 +123,7 @@ export default function ProjectCreationWizard({
}
}, [formState, onClose, onProjectCreated, t]);
const shouldCloneRepository = useMemo(
() => isCloneWorkflow(formState.workspaceType, formState.githubUrl),
[formState.githubUrl, formState.workspaceType],
);
const shouldCloneRepository = useMemo(() => isCloneWorkflow(formState.githubUrl), [formState.githubUrl]);
return (
<div className="fixed bottom-0 left-0 right-0 top-0 z-[60] flex items-center justify-center bg-black/50 p-0 backdrop-blur-sm sm:p-4">
@@ -173,15 +152,7 @@ export default function ProjectCreationWizard({
{error && <ErrorBanner message={error} />}
{step === 1 && (
<StepTypeSelection
workspaceType={formState.workspaceType}
onWorkspaceTypeChange={updateWorkspaceType}
/>
)}
{step === 2 && (
<StepConfiguration
workspaceType={formState.workspaceType}
workspacePath={formState.workspacePath}
githubUrl={formState.githubUrl}
tokenMode={formState.tokenMode}
@@ -200,11 +171,11 @@ export default function ProjectCreationWizard({
onNewGithubTokenChange={(newGithubToken) =>
updateField('newGithubToken', newGithubToken)
}
onAdvanceToConfirm={() => setStep(3)}
onAdvanceToConfirm={() => setStep(2)}
/>
)}
{step === 3 && (
{step === 2 && (
<StepReview
formState={formState}
selectedTokenName={selectedTokenName}

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Eye, EyeOff, FolderOpen, FolderPlus, Loader2, Plus, X } from 'lucide-react';
import { Button, Input } from '../../../shared/view/ui';
import { browseFilesystemFolders, createFolderInFilesystem } from '../data/workspaceApi';
import { browseFilesystemFolders, createFolderInFilesystem } from '../data/projectWizardApi';
import { getParentPath, joinFolderPath } from '../utils/pathUtils';
import type { FolderSuggestion } from '../types';

View File

@@ -1,12 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Input } from '../../../shared/view/ui';
import { shouldShowGithubAuthentication } from '../utils/pathUtils';
import type { GithubTokenCredential, TokenMode, WorkspaceType } from '../types';
import type { GithubTokenCredential, TokenMode } from '../types';
import GithubAuthenticationCard from './GithubAuthenticationCard';
import WorkspacePathField from './WorkspacePathField';
type StepConfigurationProps = {
workspaceType: WorkspaceType;
workspacePath: string;
githubUrl: string;
tokenMode: TokenMode;
@@ -25,7 +24,6 @@ type StepConfigurationProps = {
};
export default function StepConfiguration({
workspaceType,
workspacePath,
githubUrl,
tokenMode,
@@ -43,19 +41,16 @@ export default function StepConfiguration({
onAdvanceToConfirm,
}: StepConfigurationProps) {
const { t } = useTranslation();
const showGithubAuth = shouldShowGithubAuthentication(workspaceType, githubUrl);
const showGithubAuth = shouldShowGithubAuthentication(githubUrl);
return (
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{workspaceType === 'existing'
? t('projectWizard.step2.existingPath')
: t('projectWizard.step2.newPath')}
{t('projectWizard.step2.newPath')}
</label>
<WorkspacePathField
workspaceType={workspaceType}
value={workspacePath}
disabled={isCreating}
onChange={onWorkspacePathChange}
@@ -63,45 +58,39 @@ export default function StepConfiguration({
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{workspaceType === 'existing'
? t('projectWizard.step2.existingHelp')
: t('projectWizard.step2.newHelp')}
{t('projectWizard.step2.newHelp')}
</p>
</div>
{workspaceType === 'new' && (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('projectWizard.step2.githubUrl')}
</label>
<Input
type="text"
value={githubUrl}
onChange={(event) => onGithubUrlChange(event.target.value)}
placeholder="https://github.com/username/repository"
className="w-full"
disabled={isCreating}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.githubHelp')}
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{t('projectWizard.step2.githubUrl')}
</label>
<Input
type="text"
value={githubUrl}
onChange={(event) => onGithubUrlChange(event.target.value)}
placeholder="https://github.com/username/repository"
className="w-full"
disabled={isCreating}
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{t('projectWizard.step2.githubHelp')}
</p>
</div>
{showGithubAuth && (
<GithubAuthenticationCard
tokenMode={tokenMode}
selectedGithubToken={selectedGithubToken}
newGithubToken={newGithubToken}
availableTokens={availableTokens}
loadingTokens={loadingTokens}
tokenLoadError={tokenLoadError}
onTokenModeChange={onTokenModeChange}
onSelectedGithubTokenChange={onSelectedGithubTokenChange}
onNewGithubTokenChange={onNewGithubTokenChange}
/>
)}
</>
{showGithubAuth && (
<GithubAuthenticationCard
tokenMode={tokenMode}
selectedGithubToken={selectedGithubToken}
newGithubToken={newGithubToken}
availableTokens={availableTokens}
loadingTokens={loadingTokens}
tokenLoadError={tokenLoadError}
onTokenModeChange={onTokenModeChange}
onSelectedGithubTokenChange={onSelectedGithubTokenChange}
onNewGithubTokenChange={onNewGithubTokenChange}
/>
)}
</div>
);

View File

@@ -42,17 +42,6 @@ export default function StepReview({
</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
{t('projectWizard.step3.workspaceType')}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{formState.workspaceType === 'existing'
? t('projectWizard.step3.existingWorkspace')
: t('projectWizard.step3.newWorkspace')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">{t('projectWizard.step3.path')}</span>
<span className="break-all font-mono text-xs text-gray-900 dark:text-white">
@@ -60,7 +49,7 @@ export default function StepReview({
</span>
</div>
{formState.workspaceType === 'new' && formState.githubUrl && (
{formState.githubUrl && (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
@@ -94,11 +83,7 @@ export default function StepReview({
</div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-200">
{formState.workspaceType === 'existing'
? t('projectWizard.step3.existingInfo')
: formState.githubUrl
? t('projectWizard.step3.newWithClone')
: t('projectWizard.step3.newEmpty')}
{formState.githubUrl ? t('projectWizard.step3.newWithClone') : t('projectWizard.step3.newEmpty')}
</p>
)}
</div>

View File

@@ -1,71 +0,0 @@
import { FolderPlus, GitBranch } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { WorkspaceType } from '../types';
type StepTypeSelectionProps = {
workspaceType: WorkspaceType;
onWorkspaceTypeChange: (workspaceType: WorkspaceType) => void;
};
export default function StepTypeSelection({
workspaceType,
onWorkspaceTypeChange,
}: StepTypeSelectionProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<h4 className="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
{t('projectWizard.step1.question')}
</h4>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<button
onClick={() => onWorkspaceTypeChange('existing')}
className={`rounded-lg border-2 p-4 text-left transition-all ${
workspaceType === 'existing'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/50">
<FolderPlus className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h5 className="mb-1 font-semibold text-gray-900 dark:text-white">
{t('projectWizard.step1.existing.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step1.existing.description')}
</p>
</div>
</div>
</button>
<button
onClick={() => onWorkspaceTypeChange('new')}
className={`rounded-lg border-2 p-4 text-left transition-all ${
workspaceType === 'new'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/50">
<GitBranch className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h5 className="mb-1 font-semibold text-gray-900 dark:text-white">
{t('projectWizard.step1.new.title')}
</h5>
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('projectWizard.step1.new.description')}
</p>
</div>
</div>
</button>
</div>
</div>
);
}

View File

@@ -37,7 +37,7 @@ export default function WizardFooter({
)}
</Button>
<Button onClick={step === 3 ? onCreate : onNext} disabled={isCreating}>
<Button onClick={step === 2 ? onCreate : onNext} disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -45,7 +45,7 @@ export default function WizardFooter({
? t('projectWizard.buttons.cloning', { defaultValue: 'Cloning...' })
: t('projectWizard.buttons.creating')}
</>
) : step === 3 ? (
) : step === 2 ? (
<>
<Check className="mr-1 h-4 w-4" />
{t('projectWizard.buttons.createProject')}

View File

@@ -9,7 +9,7 @@ type WizardProgressProps = {
export default function WizardProgress({ step }: WizardProgressProps) {
const { t } = useTranslation();
const steps: WizardStep[] = [1, 2, 3];
const steps: WizardStep[] = [1, 2];
return (
<div className="px-6 pb-2 pt-4">
@@ -29,15 +29,11 @@ export default function WizardProgress({ step }: WizardProgressProps) {
{currentStep < step ? <Check className="h-4 w-4" /> : currentStep}
</div>
<span className="hidden text-sm font-medium text-gray-700 dark:text-gray-300 sm:inline">
{currentStep === 1
? t('projectWizard.steps.type')
: currentStep === 2
? t('projectWizard.steps.configure')
: t('projectWizard.steps.confirm')}
{currentStep === 1 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')}
</span>
</div>
{currentStep < 3 && (
{currentStep < 2 && (
<div
className={`mx-2 h-1 flex-1 rounded ${
currentStep < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'

View File

@@ -1,13 +1,12 @@
import { useCallback, useEffect, useState } from 'react';
import { FolderOpen } from 'lucide-react';
import { Button, Input } from '../../../shared/view/ui';
import { browseFilesystemFolders } from '../data/workspaceApi';
import { browseFilesystemFolders } from '../data/projectWizardApi';
import { getSuggestionRootPath } from '../utils/pathUtils';
import type { FolderSuggestion, WorkspaceType } from '../types';
import type { FolderSuggestion } from '../types';
import FolderBrowserModal from './FolderBrowserModal';
type WorkspacePathFieldProps = {
workspaceType: WorkspaceType;
value: string;
disabled?: boolean;
onChange: (path: string) => void;
@@ -15,7 +14,6 @@ type WorkspacePathFieldProps = {
};
export default function WorkspacePathField({
workspaceType,
value,
disabled = false,
onChange,
@@ -88,11 +86,7 @@ export default function WorkspacePathField({
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={
workspaceType === 'existing'
? '/path/to/existing/workspace'
: '/path/to/new/workspace'
}
placeholder="/path/to/workspace"
className="w-full"
disabled={disabled}
/>
@@ -127,7 +121,7 @@ export default function WorkspacePathField({
<FolderBrowserModal
isOpen={showFolderBrowser}
autoAdvanceOnSelect={workspaceType === 'existing'}
autoAdvanceOnSelect={false}
onClose={() => setShowFolderBrowser(false)}
onFolderSelected={handleFolderSelected}
/>

View File

@@ -71,8 +71,6 @@ export const createWorkspaceRequest = async (payload: CreateWorkspacePayload) =>
if (!response.ok) {
throw new Error(data.details || data.error || 'Failed to create workspace');
}
return data.project;
};
const buildCloneProgressQuery = ({
@@ -108,7 +106,7 @@ export const cloneWorkspaceWithProgress = (
params: CloneWorkspaceParams,
handlers: CloneProgressHandlers,
) =>
new Promise<Record<string, unknown> | undefined>((resolve, reject) => {
new Promise<void>((resolve, reject) => {
const query = buildCloneProgressQuery(params);
const eventSource = new EventSource(`/api/projects/clone-progress?${query}`);
let settled = false;
@@ -132,7 +130,7 @@ export const cloneWorkspaceWithProgress = (
}
if (payload.type === 'complete') {
settle(() => resolve(payload.project));
settle(() => resolve());
return;
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { fetchGithubTokenCredentials } from '../data/workspaceApi';
import { fetchGithubTokenCredentials } from '../data/projectWizardApi';
import type { GithubTokenCredential } from '../types';
type UseGithubTokensParams = {

View File

@@ -1,6 +1,4 @@
export type WizardStep = 1 | 2 | 3;
export type WorkspaceType = 'existing' | 'new';
export type WizardStep = 1 | 2;
export type TokenMode = 'stored' | 'new' | 'none';
@@ -35,13 +33,12 @@ export type CreateFolderResponse = {
};
export type CreateWorkspacePayload = {
workspaceType: WorkspaceType;
path: string;
};
export type CreateWorkspaceResponse = {
success?: boolean;
project?: Record<string, unknown>;
message?: string;
error?: string;
details?: string;
};
@@ -49,11 +46,9 @@ export type CreateWorkspaceResponse = {
export type CloneProgressEvent = {
type?: string;
message?: string;
project?: Record<string, unknown>;
};
export type WizardFormState = {
workspaceType: WorkspaceType;
workspacePath: string;
githubUrl: string;
tokenMode: TokenMode;

View File

@@ -1,5 +1,3 @@
import type { WorkspaceType } from '../types';
const SSH_PREFIXES = ['git@', 'ssh://'];
const WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:\\?$/;
@@ -8,13 +6,10 @@ export const isSshGitUrl = (url: string): boolean => {
return SSH_PREFIXES.some((prefix) => trimmedUrl.startsWith(prefix));
};
export const shouldShowGithubAuthentication = (
workspaceType: WorkspaceType,
githubUrl: string,
): boolean => workspaceType === 'new' && githubUrl.trim().length > 0 && !isSshGitUrl(githubUrl);
export const shouldShowGithubAuthentication = (githubUrl: string): boolean =>
githubUrl.trim().length > 0 && !isSshGitUrl(githubUrl);
export const isCloneWorkflow = (workspaceType: WorkspaceType, githubUrl: string): boolean =>
workspaceType === 'new' && githubUrl.trim().length > 0;
export const isCloneWorkflow = (githubUrl: string): boolean => githubUrl.trim().length > 0;
export const getSuggestionRootPath = (inputPath: string): string => {
const trimmedPath = inputPath.trim();

View File

@@ -7,7 +7,7 @@ import type { Project } from '@/types/app';
*/
export const fetchWorkspaces = async (): Promise<Project[]> => {
try {
const response = await authenticatedFetch('/api/get-workspaces');
const response = await authenticatedFetch('/api/projects');
if (!response.ok) {
throw new Error(`Failed to fetch workspaces: ${response.statusText}`);
}

View File

@@ -154,8 +154,8 @@
"newPath": "Workspace Path",
"existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace",
"existingHelp": "Full path to your existing workspace directory",
"newHelp": "Full path to your workspace directory",
"existingHelp": "You can also paste your chosen workspace directory path above",
"newHelp": "You can also paste your chosen workspace directory path above",
"githubUrl": "GitHub URL (Optional)",
"githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Optional: provide a GitHub URL to clone a repository",
@@ -186,9 +186,9 @@
"usingProvidedToken": "Using provided token",
"noAuthentication": "No authentication",
"sshKey": "SSH Key",
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"existingInfo": "This workspace will be available to your chosen ai providers.",
"newWithClone": "The repository will be cloned from this folder.",
"newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"newEmpty": "This workspace will be available to your chosen ai providers.",
"cloningRepository": "Cloning repository..."
},
"buttons": {