From 86ab1291ffa843e54a06fdb78fb3b45b96e4d81d Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Mon, 2 Mar 2026 16:29:48 +0300 Subject: [PATCH] refactor(wizard): rebuild project creation flow as modular TypeScript components Replace the monolithic `ProjectCreationWizard.jsx` with a feature-based TS implementation under `src/components/project-creation-wizard`, while preserving existing behavior and improving readability, maintainability, and state isolation. Why: - The previous wizard mixed API logic, flow state, folder browsing, and UI in one file. - Refactoring and testing were difficult due to tightly coupled concerns. - We needed stronger type safety and localized component state. What changed: - Deleted: - `src/components/ProjectCreationWizard.jsx` - Added new modular structure: - `src/components/project-creation-wizard/index.ts` - `src/components/project-creation-wizard/ProjectCreationWizard.tsx` - `src/components/project-creation-wizard/types.ts` - `src/components/project-creation-wizard/data/workspaceApi.ts` - `src/components/project-creation-wizard/hooks/useGithubTokens.ts` - `src/components/project-creation-wizard/utils/pathUtils.ts` - `src/components/project-creation-wizard/components/*` - `WizardProgress`, `WizardFooter`, `ErrorBanner` - `StepTypeSelection`, `StepConfiguration`, `StepReview` - `WorkspacePathField`, `GithubAuthenticationCard`, `FolderBrowserModal` - Updated import usage: - `src/components/sidebar/view/subcomponents/SidebarModals.tsx` now imports from `../../../project-creation-wizard`. Implementation details: - Migrated wizard logic to TypeScript using `type` aliases only. - Kept component prop types colocated in each component file. - Split responsibilities by feature: - container/orchestration in `ProjectCreationWizard.tsx` - API/SSE and request parsing in `data/workspaceApi.ts` - GitHub token loading/caching behavior in `useGithubTokens` - path/URL helpers in `utils/pathUtils.ts` - Localized UI-only state to child components: - folder browser modal state (current path, hidden folders, create-folder input) - path suggestion dropdown state with debounced lookup - Preserved existing UX flows: - step navigation and validation - existing/new workspace modes - optional GitHub clone + auth modes - clone progress via SSE - folder browsing + folder creation - Added focused comments for non-obvious logic (debounce, SSE auth constraint, path edge cases). --- src/components/ProjectCreationWizard.jsx | 874 ------------------ .../ProjectCreationWizard.tsx | 229 +++++ .../components/ErrorBanner.tsx | 14 + .../components/FolderBrowserModal.tsx | 251 +++++ .../components/GithubAuthenticationCard.tsx | 161 ++++ .../components/StepConfiguration.tsx | 108 +++ .../components/StepReview.tsx | 107 +++ .../components/StepTypeSelection.tsx | 71 ++ .../components/WizardFooter.tsx | 62 ++ .../components/WizardProgress.tsx | 52 ++ .../components/WorkspacePathField.tsx | 136 +++ .../data/workspaceApi.ts | 150 +++ .../hooks/useGithubTokens.ts | 73 ++ .../project-creation-wizard/index.ts | 1 + .../project-creation-wizard/types.ts | 62 ++ .../utils/pathUtils.ts | 52 ++ .../view/subcomponents/SidebarModals.tsx | 3 +- 17 files changed, 1531 insertions(+), 875 deletions(-) delete mode 100644 src/components/ProjectCreationWizard.jsx create mode 100644 src/components/project-creation-wizard/ProjectCreationWizard.tsx create mode 100644 src/components/project-creation-wizard/components/ErrorBanner.tsx create mode 100644 src/components/project-creation-wizard/components/FolderBrowserModal.tsx create mode 100644 src/components/project-creation-wizard/components/GithubAuthenticationCard.tsx create mode 100644 src/components/project-creation-wizard/components/StepConfiguration.tsx create mode 100644 src/components/project-creation-wizard/components/StepReview.tsx create mode 100644 src/components/project-creation-wizard/components/StepTypeSelection.tsx create mode 100644 src/components/project-creation-wizard/components/WizardFooter.tsx create mode 100644 src/components/project-creation-wizard/components/WizardProgress.tsx create mode 100644 src/components/project-creation-wizard/components/WorkspacePathField.tsx create mode 100644 src/components/project-creation-wizard/data/workspaceApi.ts create mode 100644 src/components/project-creation-wizard/hooks/useGithubTokens.ts create mode 100644 src/components/project-creation-wizard/index.ts create mode 100644 src/components/project-creation-wizard/types.ts create mode 100644 src/components/project-creation-wizard/utils/pathUtils.ts diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx deleted file mode 100644 index a41799ce..00000000 --- a/src/components/ProjectCreationWizard.jsx +++ /dev/null @@ -1,874 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react'; -import { Button, Input } from '../shared/view/ui'; -import { api } from '../utils/api'; -import { useTranslation } from 'react-i18next'; - -const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { - const { t } = useTranslation(); - // Wizard state - const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm - const [workspaceType, setWorkspaceType] = useState('existing'); // 'existing' or 'new' - default to 'existing' - - // Form state - const [workspacePath, setWorkspacePath] = useState(''); - const [githubUrl, setGithubUrl] = useState(''); - const [selectedGithubToken, setSelectedGithubToken] = useState(''); - const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none' - const [newGithubToken, setNewGithubToken] = useState(''); - - // UI state - const [isCreating, setIsCreating] = useState(false); - const [error, setError] = useState(null); - const [availableTokens, setAvailableTokens] = useState([]); - const [loadingTokens, setLoadingTokens] = useState(false); - const [pathSuggestions, setPathSuggestions] = useState([]); - const [showPathDropdown, setShowPathDropdown] = useState(false); - const [showFolderBrowser, setShowFolderBrowser] = useState(false); - const [browserCurrentPath, setBrowserCurrentPath] = useState('~'); - const [browserFolders, setBrowserFolders] = useState([]); - const [loadingFolders, setLoadingFolders] = useState(false); - const [showHiddenFolders, setShowHiddenFolders] = useState(false); - const [showNewFolderInput, setShowNewFolderInput] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); - const [creatingFolder, setCreatingFolder] = useState(false); - const [cloneProgress, setCloneProgress] = useState(''); - - // Load available GitHub tokens when needed - useEffect(() => { - if (step === 2 && workspaceType === 'new' && githubUrl) { - loadGithubTokens(); - } - }, [step, workspaceType, githubUrl]); - - // Load path suggestions - useEffect(() => { - if (workspacePath.length > 2) { - loadPathSuggestions(workspacePath); - } else { - setPathSuggestions([]); - setShowPathDropdown(false); - } - }, [workspacePath]); - - const loadGithubTokens = async () => { - try { - setLoadingTokens(true); - const response = await api.get('/settings/credentials?type=github_token'); - const data = await response.json(); - - const activeTokens = (data.credentials || []).filter(t => t.is_active); - setAvailableTokens(activeTokens); - - // Auto-select first token if available - if (activeTokens.length > 0 && !selectedGithubToken) { - setSelectedGithubToken(activeTokens[0].id.toString()); - } - } catch (error) { - console.error('Error loading GitHub tokens:', error); - } finally { - setLoadingTokens(false); - } - }; - - const loadPathSuggestions = async (inputPath) => { - try { - // Extract the directory to browse (parent of input) - const lastSlash = inputPath.lastIndexOf('/'); - const dirPath = lastSlash > 0 ? inputPath.substring(0, lastSlash) : '~'; - - const response = await api.browseFilesystem(dirPath); - const data = await response.json(); - - if (data.suggestions) { - // Filter suggestions based on the input, excluding exact match - const filtered = data.suggestions.filter(s => - s.path.toLowerCase().startsWith(inputPath.toLowerCase()) && - s.path.toLowerCase() !== inputPath.toLowerCase() - ); - setPathSuggestions(filtered.slice(0, 5)); - setShowPathDropdown(filtered.length > 0); - } - } catch (error) { - console.error('Error loading path suggestions:', error); - } - }; - - const handleNext = () => { - setError(null); - - if (step === 1) { - if (!workspaceType) { - setError(t('projectWizard.errors.selectType')); - return; - } - setStep(2); - } else if (step === 2) { - if (!workspacePath.trim()) { - setError(t('projectWizard.errors.providePath')); - return; - } - - // No validation for GitHub token - it's optional (only needed for private repos) - setStep(3); - } - }; - - const handleBack = () => { - setError(null); - setStep(step - 1); - }; - - const handleCreate = async () => { - setIsCreating(true); - setError(null); - setCloneProgress(''); - - try { - if (workspaceType === 'new' && githubUrl) { - const params = new URLSearchParams({ - path: workspacePath.trim(), - githubUrl: githubUrl.trim(), - }); - - if (tokenMode === 'stored' && selectedGithubToken) { - params.append('githubTokenId', selectedGithubToken); - } else if (tokenMode === 'new' && newGithubToken) { - params.append('newGithubToken', newGithubToken.trim()); - } - - const token = localStorage.getItem('auth-token'); - const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`; - - await new Promise((resolve, reject) => { - const eventSource = new EventSource(url); - - eventSource.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - if (data.type === 'progress') { - setCloneProgress(data.message); - } else if (data.type === 'complete') { - eventSource.close(); - if (onProjectCreated) { - onProjectCreated(data.project); - } - onClose(); - resolve(); - } else if (data.type === 'error') { - eventSource.close(); - reject(new Error(data.message)); - } - } catch (e) { - console.error('Error parsing SSE event:', e); - } - }; - - eventSource.onerror = () => { - eventSource.close(); - reject(new Error('Connection lost during clone')); - }; - }); - return; - } - - const payload = { - workspaceType, - path: workspacePath.trim(), - }; - - const response = await api.createWorkspace(payload); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.details || data.error || t('projectWizard.errors.failedToCreate')); - } - - if (onProjectCreated) { - onProjectCreated(data.project); - } - - onClose(); - } catch (error) { - console.error('Error creating workspace:', error); - setError(error.message || t('projectWizard.errors.failedToCreate')); - } finally { - setIsCreating(false); - } - }; - - const selectPathSuggestion = (suggestion) => { - setWorkspacePath(suggestion.path); - setShowPathDropdown(false); - }; - - const openFolderBrowser = async () => { - setShowFolderBrowser(true); - await loadBrowserFolders('~'); - }; - - const loadBrowserFolders = async (path) => { - try { - setLoadingFolders(true); - const response = await api.browseFilesystem(path); - const data = await response.json(); - setBrowserCurrentPath(data.path || path); - setBrowserFolders(data.suggestions || []); - } catch (error) { - console.error('Error loading folders:', error); - } finally { - setLoadingFolders(false); - } - }; - - const selectFolder = (folderPath, advanceToConfirm = false) => { - setWorkspacePath(folderPath); - setShowFolderBrowser(false); - if (advanceToConfirm) { - setStep(3); - } - }; - - const navigateToFolder = async (folderPath) => { - await loadBrowserFolders(folderPath); - }; - - const createNewFolder = async () => { - if (!newFolderName.trim()) return; - setCreatingFolder(true); - setError(null); - try { - const separator = browserCurrentPath.includes('\\') ? '\\' : '/'; - const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`; - const response = await api.createFolder(folderPath); - const data = await response.json(); - if (!response.ok) { - throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder')); - } - setNewFolderName(''); - setShowNewFolderInput(false); - await loadBrowserFolders(data.path || folderPath); - } catch (error) { - console.error('Error creating folder:', error); - setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder')); - } finally { - setCreatingFolder(false); - } - }; - - return ( -
-
- {/* Header */} -
-
-
- -
-

- {t('projectWizard.title')} -

-
- -
- - {/* Progress Indicator */} -
-
- {[1, 2, 3].map((s) => ( - -
-
- {s < step ? : s} -
- - {s === 1 ? t('projectWizard.steps.type') : s === 2 ? t('projectWizard.steps.configure') : t('projectWizard.steps.confirm')} - -
- {s < 3 && ( -
- )} - - ))} -
-
- - {/* Content */} -
- {/* Error Display */} - {error && ( -
- -
-

{error}

-
-
- )} - - {/* Step 1: Choose workspace type */} - {step === 1 && ( -
-
-

- {t('projectWizard.step1.question')} -

-
- {/* Existing Workspace */} - - - {/* New Workspace */} - -
-
-
- )} - - {/* Step 2: Configure workspace */} - {step === 2 && ( -
- {/* Workspace Path */} -
- -
-
- setWorkspacePath(e.target.value)} - placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'} - className="w-full" - /> - {showPathDropdown && pathSuggestions.length > 0 && ( -
- {pathSuggestions.map((suggestion, index) => ( - - ))} -
- )} -
- -
-

- {workspaceType === 'existing' - ? t('projectWizard.step2.existingHelp') - : t('projectWizard.step2.newHelp')} -

-
- - {/* GitHub URL (only for new workspace) */} - {workspaceType === 'new' && ( - <> -
- - setGithubUrl(e.target.value)} - placeholder="https://github.com/username/repository" - className="w-full" - /> -

- {t('projectWizard.step2.githubHelp')} -

-
- - {/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */} - {githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && ( -
-
- -
-
- {t('projectWizard.step2.githubAuth')} -
-

- {t('projectWizard.step2.githubAuthHelp')} -

-
-
- - {loadingTokens ? ( -
- - {t('projectWizard.step2.loadingTokens')} -
- ) : availableTokens.length > 0 ? ( - <> - {/* Token Selection Tabs */} -
- - - -
- - {tokenMode === 'stored' ? ( -
- - -
- ) : tokenMode === 'new' ? ( -
- - setNewGithubToken(e.target.value)} - placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - className="w-full" - /> -

- {t('projectWizard.step2.tokenHelp')} -

-
- ) : null} - - ) : ( -
-
-

- {t('projectWizard.step2.publicRepoInfo')} -

-
- -
- - setNewGithubToken(e.target.value)} - placeholder={t('projectWizard.step2.tokenPublicPlaceholder')} - className="w-full" - /> -

- {t('projectWizard.step2.noTokensHelp')} -

-
-
- )} -
- )} - - )} -
- )} - - {/* Step 3: Confirm */} - {step === 3 && ( -
-
-

- {t('projectWizard.step3.reviewConfig')} -

-
-
- {t('projectWizard.step3.workspaceType')} - - {workspaceType === 'existing' ? t('projectWizard.step3.existingWorkspace') : t('projectWizard.step3.newWorkspace')} - -
-
- {t('projectWizard.step3.path')} - - {workspacePath} - -
- {workspaceType === 'new' && githubUrl && ( - <> -
- {t('projectWizard.step3.cloneFrom')} - - {githubUrl} - -
-
- {t('projectWizard.step3.authentication')} - - {tokenMode === 'stored' && selectedGithubToken - ? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` - : tokenMode === 'new' && newGithubToken - ? t('projectWizard.step3.usingProvidedToken') - : (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://')) - ? t('projectWizard.step3.sshKey', 'SSH Key') - : t('projectWizard.step3.noAuthentication')} - -
- - )} -
-
- -
- {isCreating && cloneProgress ? ( -
-

{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}

- - {cloneProgress} - -
- ) : ( -

- {workspaceType === 'existing' - ? t('projectWizard.step3.existingInfo') - : githubUrl - ? t('projectWizard.step3.newWithClone') - : t('projectWizard.step3.newEmpty')} -

- )} -
-
- )} -
- - {/* Footer */} -
- - - -
-
- - {/* Folder Browser Modal */} - {showFolderBrowser && ( -
-
- {/* Browser Header */} -
-
-
- -
-

- Select Folder -

-
-
- - - -
-
- - {/* New Folder Input */} - {showNewFolderInput && ( -
-
- setNewFolderName(e.target.value)} - placeholder="New folder name" - className="flex-1" - onKeyDown={(e) => { - if (e.key === 'Enter') createNewFolder(); - if (e.key === 'Escape') { - setShowNewFolderInput(false); - setNewFolderName(''); - } - }} - autoFocus - /> - - -
-
- )} - - {/* Folder List */} -
- {loadingFolders ? ( -
- -
- ) : ( -
- {/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */} - {browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && ( - - )} - - {/* Folders */} - {browserFolders.length === 0 ? ( -
- No subfolders found -
- ) : ( - browserFolders - .filter(folder => showHiddenFolders || !folder.name.startsWith('.')) - .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) - .map((folder, index) => ( -
- - -
- )) - )} -
- )} -
- - {/* Browser Footer with Current Path */} -
-
- Path: - - {browserCurrentPath} - -
-
- - -
-
-
-
- )} -
- ); -}; - -export default ProjectCreationWizard; diff --git a/src/components/project-creation-wizard/ProjectCreationWizard.tsx b/src/components/project-creation-wizard/ProjectCreationWizard.tsx new file mode 100644 index 00000000..ce490482 --- /dev/null +++ b/src/components/project-creation-wizard/ProjectCreationWizard.tsx @@ -0,0 +1,229 @@ +import { useCallback, useMemo, useState } from 'react'; +import { FolderPlus, X } from 'lucide-react'; +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 { isCloneWorkflow, shouldShowGithubAuthentication } from './utils/pathUtils'; +import type { TokenMode, WizardFormState, WizardStep, WorkspaceType } from './types'; + +type ProjectCreationWizardProps = { + onClose: () => void; + onProjectCreated?: (project?: Record) => void; +}; + +const initialFormState: WizardFormState = { + workspaceType: 'existing', + workspacePath: '', + githubUrl: '', + tokenMode: 'stored', + selectedGithubToken: '', + newGithubToken: '', +}; + +export default function ProjectCreationWizard({ + onClose, + onProjectCreated, +}: ProjectCreationWizardProps) { + const { t } = useTranslation(); + const [step, setStep] = useState(1); + const [formState, setFormState] = useState(initialFormState); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + const [cloneProgress, setCloneProgress] = useState(''); + + const shouldLoadTokens = + step === 2 && shouldShowGithubAuthentication(formState.workspaceType, formState.githubUrl); + + const autoSelectToken = useCallback((tokenId: string) => { + setFormState((previous) => ({ ...previous, selectedGithubToken: tokenId })); + }, []); + + const { + tokens: availableTokens, + loading: loadingTokens, + loadError: tokenLoadError, + selectedTokenName, + } = useGithubTokens({ + shouldLoad: shouldLoadTokens, + selectedTokenId: formState.selectedGithubToken, + onAutoSelectToken: autoSelectToken, + }); + + // Keep cross-step values in this component; local UI state lives in child components. + const updateField = useCallback((key: K, value: WizardFormState[K]) => { + setFormState((previous) => ({ ...previous, [key]: value })); + }, []); + + const updateWorkspaceType = useCallback( + (workspaceType: WorkspaceType) => updateField('workspaceType', workspaceType), + [updateField], + ); + + const updateTokenMode = useCallback( + (tokenMode: TokenMode) => updateField('tokenMode', tokenMode), + [updateField], + ); + + const handleNext = useCallback(() => { + 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); + } + }, [formState.workspacePath, formState.workspaceType, step, t]); + + const handleBack = useCallback(() => { + setError(null); + setStep((previousStep) => (previousStep > 1 ? ((previousStep - 1) as WizardStep) : previousStep)); + }, []); + + const handleCreate = useCallback(async () => { + setIsCreating(true); + setError(null); + setCloneProgress(''); + + try { + const shouldCloneRepository = isCloneWorkflow(formState.workspaceType, formState.githubUrl); + + if (shouldCloneRepository) { + const project = await cloneWorkspaceWithProgress( + { + workspacePath: formState.workspacePath, + githubUrl: formState.githubUrl, + tokenMode: formState.tokenMode, + selectedGithubToken: formState.selectedGithubToken, + newGithubToken: formState.newGithubToken, + }, + { + onProgress: setCloneProgress, + }, + ); + + onProjectCreated?.(project); + onClose(); + return; + } + + const project = await createWorkspaceRequest({ + workspaceType: formState.workspaceType, + path: formState.workspacePath.trim(), + }); + + onProjectCreated?.(project); + onClose(); + } catch (createError) { + const errorMessage = + createError instanceof Error + ? createError.message + : t('projectWizard.errors.failedToCreate'); + setError(errorMessage); + } finally { + setIsCreating(false); + } + }, [formState, onClose, onProjectCreated, t]); + + const shouldCloneRepository = useMemo( + () => isCloneWorkflow(formState.workspaceType, formState.githubUrl), + [formState.githubUrl, formState.workspaceType], + ); + + return ( +
+
+
+
+
+ +
+

+ {t('projectWizard.title')} +

+
+ +
+ + + +
+ {error && } + + {step === 1 && ( + + )} + + {step === 2 && ( + updateField('workspacePath', workspacePath)} + onGithubUrlChange={(githubUrl) => updateField('githubUrl', githubUrl)} + onTokenModeChange={updateTokenMode} + onSelectedGithubTokenChange={(selectedGithubToken) => + updateField('selectedGithubToken', selectedGithubToken) + } + onNewGithubTokenChange={(newGithubToken) => + updateField('newGithubToken', newGithubToken) + } + onAdvanceToConfirm={() => setStep(3)} + /> + )} + + {step === 3 && ( + + )} +
+ + +
+
+ ); +} diff --git a/src/components/project-creation-wizard/components/ErrorBanner.tsx b/src/components/project-creation-wizard/components/ErrorBanner.tsx new file mode 100644 index 00000000..3843cb21 --- /dev/null +++ b/src/components/project-creation-wizard/components/ErrorBanner.tsx @@ -0,0 +1,14 @@ +import { AlertCircle } from 'lucide-react'; + +type ErrorBannerProps = { + message: string; +}; + +export default function ErrorBanner({ message }: ErrorBannerProps) { + return ( +
+ +

{message}

+
+ ); +} diff --git a/src/components/project-creation-wizard/components/FolderBrowserModal.tsx b/src/components/project-creation-wizard/components/FolderBrowserModal.tsx new file mode 100644 index 00000000..52448afa --- /dev/null +++ b/src/components/project-creation-wizard/components/FolderBrowserModal.tsx @@ -0,0 +1,251 @@ +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 { getParentPath, joinFolderPath } from '../utils/pathUtils'; +import type { FolderSuggestion } from '../types'; + +type FolderBrowserModalProps = { + isOpen: boolean; + autoAdvanceOnSelect: boolean; + onClose: () => void; + onFolderSelected: (folderPath: string, advanceToConfirm: boolean) => void; +}; + +export default function FolderBrowserModal({ + isOpen, + autoAdvanceOnSelect, + onClose, + onFolderSelected, +}: FolderBrowserModalProps) { + const [currentPath, setCurrentPath] = useState('~'); + const [folders, setFolders] = useState([]); + const [loadingFolders, setLoadingFolders] = useState(false); + const [showHiddenFolders, setShowHiddenFolders] = useState(false); + const [showNewFolderInput, setShowNewFolderInput] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [creatingFolder, setCreatingFolder] = useState(false); + const [error, setError] = useState(null); + + const loadFolders = useCallback(async (pathToLoad: string) => { + setLoadingFolders(true); + setError(null); + + try { + const result = await browseFilesystemFolders(pathToLoad); + setCurrentPath(result.path); + setFolders(result.suggestions); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Failed to load folders'); + } finally { + setLoadingFolders(false); + } + }, []); + + useEffect(() => { + if (!isOpen) { + return; + } + loadFolders('~'); + }, [isOpen, loadFolders]); + + const visibleFolders = useMemo( + () => + folders + .filter((folder) => showHiddenFolders || !folder.name.startsWith('.')) + .sort((firstFolder, secondFolder) => + firstFolder.name.toLowerCase().localeCompare(secondFolder.name.toLowerCase()), + ), + [folders, showHiddenFolders], + ); + + const resetNewFolderState = () => { + setShowNewFolderInput(false); + setNewFolderName(''); + }; + + const handleClose = () => { + setError(null); + resetNewFolderState(); + onClose(); + }; + + const handleCreateFolder = useCallback(async () => { + if (!newFolderName.trim()) { + return; + } + + setCreatingFolder(true); + setError(null); + + try { + const folderPath = joinFolderPath(currentPath, newFolderName); + const createdPath = await createFolderInFilesystem(folderPath); + resetNewFolderState(); + await loadFolders(createdPath); + } catch (createError) { + setError(createError instanceof Error ? createError.message : 'Failed to create folder'); + } finally { + setCreatingFolder(false); + } + }, [currentPath, loadFolders, newFolderName]); + + const parentPath = getParentPath(currentPath); + + if (!isOpen) { + return null; + } + + return ( +
+
+
+
+
+ +
+

Select Folder

+
+ +
+ + + +
+
+ + {showNewFolderInput && ( +
+
+ setNewFolderName(event.target.value)} + placeholder="New folder name" + className="flex-1" + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleCreateFolder(); + } + if (event.key === 'Escape') { + resetNewFolderState(); + } + }} + autoFocus + /> + + +
+
+ )} + + {error && ( +
+

{error}

+
+ )} + +
+ {loadingFolders ? ( +
+ +
+ ) : ( +
+ {parentPath && ( + + )} + + {visibleFolders.length === 0 ? ( +
+ No subfolders found +
+ ) : ( + visibleFolders.map((folder) => ( +
+ + +
+ )) + )} +
+ )} +
+ +
+
+ Path: + + {currentPath} + +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/project-creation-wizard/components/GithubAuthenticationCard.tsx b/src/components/project-creation-wizard/components/GithubAuthenticationCard.tsx new file mode 100644 index 00000000..7ce53a26 --- /dev/null +++ b/src/components/project-creation-wizard/components/GithubAuthenticationCard.tsx @@ -0,0 +1,161 @@ +import { Key, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Input } from '../../../shared/view/ui'; +import type { GithubTokenCredential, TokenMode } from '../types'; + +type GithubAuthenticationCardProps = { + tokenMode: TokenMode; + selectedGithubToken: string; + newGithubToken: string; + availableTokens: GithubTokenCredential[]; + loadingTokens: boolean; + tokenLoadError: string | null; + onTokenModeChange: (tokenMode: TokenMode) => void; + onSelectedGithubTokenChange: (tokenId: string) => void; + onNewGithubTokenChange: (tokenValue: string) => void; +}; + +const getModeClassName = (mode: TokenMode, selectedMode: TokenMode) => + `px-3 py-2 text-sm font-medium rounded-lg transition-colors ${ + mode === selectedMode + ? mode === 'none' + ? 'bg-green-500 text-white' + : 'bg-blue-500 text-white' + : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300' + }`; + +export default function GithubAuthenticationCard({ + tokenMode, + selectedGithubToken, + newGithubToken, + availableTokens, + loadingTokens, + tokenLoadError, + onTokenModeChange, + onSelectedGithubTokenChange, + onNewGithubTokenChange, +}: GithubAuthenticationCardProps) { + const { t } = useTranslation(); + + return ( +
+
+ +
+
+ {t('projectWizard.step2.githubAuth')} +
+

+ {t('projectWizard.step2.githubAuthHelp')} +

+
+
+ + {loadingTokens && ( +
+ + {t('projectWizard.step2.loadingTokens')} +
+ )} + + {!loadingTokens && tokenLoadError && ( +

{tokenLoadError}

+ )} + + {!loadingTokens && availableTokens.length > 0 && ( + <> +
+ + + +
+ + {tokenMode === 'stored' ? ( +
+ + +
+ ) : tokenMode === 'new' ? ( +
+ + onNewGithubTokenChange(event.target.value)} + placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + className="w-full" + /> +

+ {t('projectWizard.step2.tokenHelp')} +

+
+ ) : null} + + )} + + {!loadingTokens && availableTokens.length === 0 && ( +
+
+

+ {t('projectWizard.step2.publicRepoInfo')} +

+
+ +
+ + { + const tokenValue = event.target.value; + onNewGithubTokenChange(tokenValue); + onTokenModeChange(tokenValue.trim() ? 'new' : 'none'); + }} + placeholder={t('projectWizard.step2.tokenPublicPlaceholder')} + className="w-full" + /> +

+ {t('projectWizard.step2.noTokensHelp')} +

+
+
+ )} +
+ ); +} diff --git a/src/components/project-creation-wizard/components/StepConfiguration.tsx b/src/components/project-creation-wizard/components/StepConfiguration.tsx new file mode 100644 index 00000000..33234cdb --- /dev/null +++ b/src/components/project-creation-wizard/components/StepConfiguration.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from 'react-i18next'; +import { Input } from '../../../shared/view/ui'; +import GithubAuthenticationCard from './GithubAuthenticationCard'; +import WorkspacePathField from './WorkspacePathField'; +import { shouldShowGithubAuthentication } from '../utils/pathUtils'; +import type { GithubTokenCredential, TokenMode, WorkspaceType } from '../types'; + +type StepConfigurationProps = { + workspaceType: WorkspaceType; + workspacePath: string; + githubUrl: string; + tokenMode: TokenMode; + selectedGithubToken: string; + newGithubToken: string; + availableTokens: GithubTokenCredential[]; + loadingTokens: boolean; + tokenLoadError: string | null; + isCreating: boolean; + onWorkspacePathChange: (workspacePath: string) => void; + onGithubUrlChange: (githubUrl: string) => void; + onTokenModeChange: (tokenMode: TokenMode) => void; + onSelectedGithubTokenChange: (tokenId: string) => void; + onNewGithubTokenChange: (tokenValue: string) => void; + onAdvanceToConfirm: () => void; +}; + +export default function StepConfiguration({ + workspaceType, + workspacePath, + githubUrl, + tokenMode, + selectedGithubToken, + newGithubToken, + availableTokens, + loadingTokens, + tokenLoadError, + isCreating, + onWorkspacePathChange, + onGithubUrlChange, + onTokenModeChange, + onSelectedGithubTokenChange, + onNewGithubTokenChange, + onAdvanceToConfirm, +}: StepConfigurationProps) { + const { t } = useTranslation(); + const showGithubAuth = shouldShowGithubAuthentication(workspaceType, githubUrl); + + return ( +
+
+ + + + +

+ {workspaceType === 'existing' + ? t('projectWizard.step2.existingHelp') + : t('projectWizard.step2.newHelp')} +

+
+ + {workspaceType === 'new' && ( + <> +
+ + onGithubUrlChange(event.target.value)} + placeholder="https://github.com/username/repository" + className="w-full" + disabled={isCreating} + /> +

+ {t('projectWizard.step2.githubHelp')} +

+
+ + {showGithubAuth && ( + + )} + + )} +
+ ); +} diff --git a/src/components/project-creation-wizard/components/StepReview.tsx b/src/components/project-creation-wizard/components/StepReview.tsx new file mode 100644 index 00000000..17201ac1 --- /dev/null +++ b/src/components/project-creation-wizard/components/StepReview.tsx @@ -0,0 +1,107 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { isSshGitUrl } from '../utils/pathUtils'; +import type { WizardFormState } from '../types'; + +type StepReviewProps = { + formState: WizardFormState; + selectedTokenName: string | null; + isCreating: boolean; + cloneProgress: string; +}; + +export default function StepReview({ + formState, + selectedTokenName, + isCreating, + cloneProgress, +}: StepReviewProps) { + const { t } = useTranslation(); + + const authenticationLabel = useMemo(() => { + if (formState.tokenMode === 'stored' && formState.selectedGithubToken) { + return `${t('projectWizard.step3.usingStoredToken')} ${selectedTokenName || 'Unknown'}`; + } + + if (formState.tokenMode === 'new' && formState.newGithubToken.trim()) { + return t('projectWizard.step3.usingProvidedToken'); + } + + if (isSshGitUrl(formState.githubUrl)) { + return t('projectWizard.step3.sshKey', { defaultValue: 'SSH Key' }); + } + + return t('projectWizard.step3.noAuthentication'); + }, [formState, selectedTokenName, t]); + + return ( +
+
+

+ {t('projectWizard.step3.reviewConfig')} +

+ +
+
+ + {t('projectWizard.step3.workspaceType')} + + + {formState.workspaceType === 'existing' + ? t('projectWizard.step3.existingWorkspace') + : t('projectWizard.step3.newWorkspace')} + +
+ +
+ {t('projectWizard.step3.path')} + + {formState.workspacePath} + +
+ + {formState.workspaceType === 'new' && formState.githubUrl && ( + <> +
+ + {t('projectWizard.step3.cloneFrom')} + + + {formState.githubUrl} + +
+ +
+ + {t('projectWizard.step3.authentication')} + + {authenticationLabel} +
+ + )} +
+
+ +
+ {isCreating && cloneProgress ? ( +
+

+ {t('projectWizard.step3.cloningRepository', { defaultValue: 'Cloning repository...' })} +

+ + {cloneProgress} + +
+ ) : ( +

+ {formState.workspaceType === 'existing' + ? t('projectWizard.step3.existingInfo') + : formState.githubUrl + ? t('projectWizard.step3.newWithClone') + : t('projectWizard.step3.newEmpty')} +

+ )} +
+
+ ); +} diff --git a/src/components/project-creation-wizard/components/StepTypeSelection.tsx b/src/components/project-creation-wizard/components/StepTypeSelection.tsx new file mode 100644 index 00000000..830aa270 --- /dev/null +++ b/src/components/project-creation-wizard/components/StepTypeSelection.tsx @@ -0,0 +1,71 @@ +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 ( +
+

+ {t('projectWizard.step1.question')} +

+ +
+ + + +
+
+ ); +} diff --git a/src/components/project-creation-wizard/components/WizardFooter.tsx b/src/components/project-creation-wizard/components/WizardFooter.tsx new file mode 100644 index 00000000..689d1ea2 --- /dev/null +++ b/src/components/project-creation-wizard/components/WizardFooter.tsx @@ -0,0 +1,62 @@ +import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '../../../shared/view/ui'; +import type { WizardStep } from '../types'; + +type WizardFooterProps = { + step: WizardStep; + isCreating: boolean; + isCloneWorkflow: boolean; + onClose: () => void; + onBack: () => void; + onNext: () => void; + onCreate: () => void; +}; + +export default function WizardFooter({ + step, + isCreating, + isCloneWorkflow, + onClose, + onBack, + onNext, + onCreate, +}: WizardFooterProps) { + const { t } = useTranslation(); + + return ( +
+ + + +
+ ); +} diff --git a/src/components/project-creation-wizard/components/WizardProgress.tsx b/src/components/project-creation-wizard/components/WizardProgress.tsx new file mode 100644 index 00000000..2615962c --- /dev/null +++ b/src/components/project-creation-wizard/components/WizardProgress.tsx @@ -0,0 +1,52 @@ +import { Fragment } from 'react'; +import { Check } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { WizardStep } from '../types'; + +type WizardProgressProps = { + step: WizardStep; +}; + +export default function WizardProgress({ step }: WizardProgressProps) { + const { t } = useTranslation(); + const steps: WizardStep[] = [1, 2, 3]; + + return ( +
+
+ {steps.map((currentStep) => ( + +
+
+ {currentStep < step ? : currentStep} +
+ + {currentStep === 1 + ? t('projectWizard.steps.type') + : currentStep === 2 + ? t('projectWizard.steps.configure') + : t('projectWizard.steps.confirm')} + +
+ + {currentStep < 3 && ( +
+ )} + + ))} +
+
+ ); +} diff --git a/src/components/project-creation-wizard/components/WorkspacePathField.tsx b/src/components/project-creation-wizard/components/WorkspacePathField.tsx new file mode 100644 index 00000000..711bad57 --- /dev/null +++ b/src/components/project-creation-wizard/components/WorkspacePathField.tsx @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useState } from 'react'; +import { FolderOpen } from 'lucide-react'; +import { Button, Input } from '../../../shared/view/ui'; +import FolderBrowserModal from './FolderBrowserModal'; +import { browseFilesystemFolders } from '../data/workspaceApi'; +import { getSuggestionRootPath } from '../utils/pathUtils'; +import type { FolderSuggestion, WorkspaceType } from '../types'; + +type WorkspacePathFieldProps = { + workspaceType: WorkspaceType; + value: string; + disabled?: boolean; + onChange: (path: string) => void; + onAdvanceToConfirm: () => void; +}; + +export default function WorkspacePathField({ + workspaceType, + value, + disabled = false, + onChange, + onAdvanceToConfirm, +}: WorkspacePathFieldProps) { + const [pathSuggestions, setPathSuggestions] = useState([]); + const [showPathDropdown, setShowPathDropdown] = useState(false); + const [showFolderBrowser, setShowFolderBrowser] = useState(false); + + useEffect(() => { + if (value.trim().length <= 2) { + setPathSuggestions([]); + setShowPathDropdown(false); + return; + } + + // Debounce path lookup to avoid firing a request for every keystroke. + const timerId = window.setTimeout(async () => { + try { + const directoryPath = getSuggestionRootPath(value); + const result = await browseFilesystemFolders(directoryPath); + const normalizedInput = value.toLowerCase(); + + const matchingSuggestions = result.suggestions + .filter((suggestion) => { + const normalizedSuggestion = suggestion.path.toLowerCase(); + return ( + normalizedSuggestion.startsWith(normalizedInput) && + normalizedSuggestion !== normalizedInput + ); + }) + .slice(0, 5); + + setPathSuggestions(matchingSuggestions); + setShowPathDropdown(matchingSuggestions.length > 0); + } catch (error) { + console.error('Failed to load path suggestions:', error); + } + }, 200); + + return () => { + window.clearTimeout(timerId); + }; + }, [value]); + + const handleSuggestionSelect = useCallback( + (suggestion: FolderSuggestion) => { + onChange(suggestion.path); + setShowPathDropdown(false); + }, + [onChange], + ); + + const handleFolderSelected = useCallback( + (selectedPath: string, advanceToConfirm: boolean) => { + onChange(selectedPath); + setShowFolderBrowser(false); + if (advanceToConfirm) { + onAdvanceToConfirm(); + } + }, + [onAdvanceToConfirm, onChange], + ); + + return ( + <> +
+
+ onChange(event.target.value)} + placeholder={ + workspaceType === 'existing' + ? '/path/to/existing/workspace' + : '/path/to/new/workspace' + } + className="w-full" + disabled={disabled} + /> + + {showPathDropdown && pathSuggestions.length > 0 && ( +
+ {pathSuggestions.map((suggestion) => ( + + ))} +
+ )} +
+ + +
+ + setShowFolderBrowser(false)} + onFolderSelected={handleFolderSelected} + /> + + ); +} diff --git a/src/components/project-creation-wizard/data/workspaceApi.ts b/src/components/project-creation-wizard/data/workspaceApi.ts new file mode 100644 index 00000000..f4ca3baf --- /dev/null +++ b/src/components/project-creation-wizard/data/workspaceApi.ts @@ -0,0 +1,150 @@ +import { api } from '../../../utils/api'; +import type { + BrowseFilesystemResponse, + CloneProgressEvent, + CreateFolderResponse, + CreateWorkspacePayload, + CreateWorkspaceResponse, + CredentialsResponse, + FolderSuggestion, + TokenMode, +} from '../types'; + +type CloneWorkspaceParams = { + workspacePath: string; + githubUrl: string; + tokenMode: TokenMode; + selectedGithubToken: string; + newGithubToken: string; +}; + +type CloneProgressHandlers = { + onProgress: (message: string) => void; +}; + +const parseJson = async (response: Response): Promise => { + const data = (await response.json()) as T; + return data; +}; + +export const fetchGithubTokenCredentials = async () => { + const response = await api.get('/settings/credentials?type=github_token'); + const data = await parseJson(response); + + if (!response.ok) { + throw new Error(data.error || 'Failed to load GitHub tokens'); + } + + return (data.credentials || []).filter((credential) => credential.is_active); +}; + +export const browseFilesystemFolders = async (pathToBrowse: string) => { + const endpoint = `/browse-filesystem?path=${encodeURIComponent(pathToBrowse)}`; + const response = await api.get(endpoint); + const data = await parseJson(response); + + if (!response.ok) { + throw new Error(data.error || 'Failed to browse filesystem'); + } + + return { + path: data.path || pathToBrowse, + suggestions: (data.suggestions || []) as FolderSuggestion[], + }; +}; + +export const createFolderInFilesystem = async (folderPath: string) => { + const response = await api.createFolder(folderPath); + const data = await parseJson(response); + + if (!response.ok) { + throw new Error(data.error || 'Failed to create folder'); + } + + return data.path || folderPath; +}; + +export const createWorkspaceRequest = async (payload: CreateWorkspacePayload) => { + const response = await api.createWorkspace(payload); + const data = await parseJson(response); + + if (!response.ok) { + throw new Error(data.details || data.error || 'Failed to create workspace'); + } + + return data.project; +}; + +const buildCloneProgressQuery = ({ + workspacePath, + githubUrl, + tokenMode, + selectedGithubToken, + newGithubToken, +}: CloneWorkspaceParams) => { + const query = new URLSearchParams({ + path: workspacePath.trim(), + githubUrl: githubUrl.trim(), + }); + + if (tokenMode === 'stored' && selectedGithubToken) { + query.set('githubTokenId', selectedGithubToken); + } + + if (tokenMode === 'new' && newGithubToken.trim()) { + query.set('newGithubToken', newGithubToken.trim()); + } + + // EventSource cannot send custom headers, so the auth token is passed as query. + const authToken = localStorage.getItem('auth-token'); + if (authToken) { + query.set('token', authToken); + } + + return query.toString(); +}; + +export const cloneWorkspaceWithProgress = ( + params: CloneWorkspaceParams, + handlers: CloneProgressHandlers, +) => + new Promise | undefined>((resolve, reject) => { + const query = buildCloneProgressQuery(params); + const eventSource = new EventSource(`/api/projects/clone-progress?${query}`); + let settled = false; + + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + eventSource.close(); + callback(); + }; + + eventSource.onmessage = (event) => { + try { + const payload = JSON.parse(event.data) as CloneProgressEvent; + + if (payload.type === 'progress' && payload.message) { + handlers.onProgress(payload.message); + return; + } + + if (payload.type === 'complete') { + settle(() => resolve(payload.project)); + return; + } + + if (payload.type === 'error') { + settle(() => reject(new Error(payload.message || 'Failed to clone repository'))); + } + } catch (error) { + console.error('Error parsing clone progress event:', error); + } + }; + + eventSource.onerror = () => { + settle(() => reject(new Error('Connection lost during clone'))); + }; + }); diff --git a/src/components/project-creation-wizard/hooks/useGithubTokens.ts b/src/components/project-creation-wizard/hooks/useGithubTokens.ts new file mode 100644 index 00000000..c1e2b4cf --- /dev/null +++ b/src/components/project-creation-wizard/hooks/useGithubTokens.ts @@ -0,0 +1,73 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { fetchGithubTokenCredentials } from '../data/workspaceApi'; +import type { GithubTokenCredential } from '../types'; + +type UseGithubTokensParams = { + shouldLoad: boolean; + selectedTokenId: string; + onAutoSelectToken: (tokenId: string) => void; +}; + +export const useGithubTokens = ({ + shouldLoad, + selectedTokenId, + onAutoSelectToken, +}: UseGithubTokensParams) => { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const hasLoadedRef = useRef(false); + + useEffect(() => { + if (!shouldLoad || hasLoadedRef.current) { + return; + } + + let isDisposed = false; + + const loadTokens = async () => { + setLoading(true); + setLoadError(null); + + try { + const activeTokens = await fetchGithubTokenCredentials(); + if (isDisposed) { + return; + } + + setTokens(activeTokens); + hasLoadedRef.current = true; + + if (activeTokens.length > 0 && !selectedTokenId) { + onAutoSelectToken(String(activeTokens[0].id)); + } + } catch (error) { + if (!isDisposed) { + setLoadError(error instanceof Error ? error.message : 'Failed to load GitHub tokens'); + } + } finally { + if (!isDisposed) { + setLoading(false); + } + } + }; + + loadTokens(); + + return () => { + isDisposed = true; + }; + }, [onAutoSelectToken, selectedTokenId, shouldLoad]); + + const selectedTokenName = useMemo( + () => tokens.find((token) => String(token.id) === selectedTokenId)?.credential_name || null, + [selectedTokenId, tokens], + ); + + return { + tokens, + loading, + loadError, + selectedTokenName, + }; +}; diff --git a/src/components/project-creation-wizard/index.ts b/src/components/project-creation-wizard/index.ts new file mode 100644 index 00000000..58d5b82e --- /dev/null +++ b/src/components/project-creation-wizard/index.ts @@ -0,0 +1 @@ +export { default } from './ProjectCreationWizard'; diff --git a/src/components/project-creation-wizard/types.ts b/src/components/project-creation-wizard/types.ts new file mode 100644 index 00000000..4b156303 --- /dev/null +++ b/src/components/project-creation-wizard/types.ts @@ -0,0 +1,62 @@ +export type WizardStep = 1 | 2 | 3; + +export type WorkspaceType = 'existing' | 'new'; + +export type TokenMode = 'stored' | 'new' | 'none'; + +export type FolderSuggestion = { + name: string; + path: string; + type?: string; +}; + +export type GithubTokenCredential = { + id: number; + credential_name: string; + is_active: boolean; +}; + +export type CredentialsResponse = { + credentials?: GithubTokenCredential[]; + error?: string; +}; + +export type BrowseFilesystemResponse = { + path?: string; + suggestions?: FolderSuggestion[]; + error?: string; +}; + +export type CreateFolderResponse = { + success?: boolean; + path?: string; + error?: string; + details?: string; +}; + +export type CreateWorkspacePayload = { + workspaceType: WorkspaceType; + path: string; +}; + +export type CreateWorkspaceResponse = { + success?: boolean; + project?: Record; + error?: string; + details?: string; +}; + +export type CloneProgressEvent = { + type?: string; + message?: string; + project?: Record; +}; + +export type WizardFormState = { + workspaceType: WorkspaceType; + workspacePath: string; + githubUrl: string; + tokenMode: TokenMode; + selectedGithubToken: string; + newGithubToken: string; +}; diff --git a/src/components/project-creation-wizard/utils/pathUtils.ts b/src/components/project-creation-wizard/utils/pathUtils.ts new file mode 100644 index 00000000..6799c3c1 --- /dev/null +++ b/src/components/project-creation-wizard/utils/pathUtils.ts @@ -0,0 +1,52 @@ +import type { WorkspaceType } from '../types'; + +const SSH_PREFIXES = ['git@', 'ssh://']; +const WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:\\?$/; + +export const isSshGitUrl = (url: string): boolean => { + const trimmedUrl = url.trim(); + 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 isCloneWorkflow = (workspaceType: WorkspaceType, githubUrl: string): boolean => + workspaceType === 'new' && githubUrl.trim().length > 0; + +export const getSuggestionRootPath = (inputPath: string): string => { + const trimmedPath = inputPath.trim(); + const lastSeparatorIndex = Math.max(trimmedPath.lastIndexOf('/'), trimmedPath.lastIndexOf('\\')); + if (lastSeparatorIndex === 2 && /^[A-Za-z]:/.test(trimmedPath)) { + return `${trimmedPath.slice(0, 2)}\\`; + } + + return lastSeparatorIndex > 0 ? trimmedPath.slice(0, lastSeparatorIndex) : '~'; +}; + +// Handles root edge cases for Unix-like and Windows paths. +export const getParentPath = (currentPath: string): string | null => { + if (currentPath === '~' || currentPath === '/' || WINDOWS_DRIVE_PATTERN.test(currentPath)) { + return null; + } + + const lastSeparatorIndex = Math.max(currentPath.lastIndexOf('/'), currentPath.lastIndexOf('\\')); + if (lastSeparatorIndex <= 0) { + return '/'; + } + + if (lastSeparatorIndex === 2 && /^[A-Za-z]:/.test(currentPath)) { + return `${currentPath.slice(0, 2)}\\`; + } + + return currentPath.slice(0, lastSeparatorIndex); +}; + +export const joinFolderPath = (basePath: string, folderName: string): string => { + const normalizedBasePath = basePath.trim().replace(/[\\/]+$/, ''); + const separator = + normalizedBasePath.includes('\\') && !normalizedBasePath.includes('/') ? '\\' : '/'; + return `${normalizedBasePath}${separator}${folderName.trim()}`; +}; diff --git a/src/components/sidebar/view/subcomponents/SidebarModals.tsx b/src/components/sidebar/view/subcomponents/SidebarModals.tsx index e5bec6be..b12f9266 100644 --- a/src/components/sidebar/view/subcomponents/SidebarModals.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarModals.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { AlertTriangle, Trash2 } from 'lucide-react'; import type { TFunction } from 'i18next'; import { Button } from '../../../../shared/view/ui'; -import ProjectCreationWizard from '../../../ProjectCreationWizard'; + import Settings from '../../../settings/view/Settings'; import VersionUpgradeModal from '../../../version-upgrade/view'; import type { Project } from '../../../../types/app'; @@ -11,6 +11,7 @@ import type { ReleaseInfo } from '../../../../types/sharedTypes'; import type { InstallMode } from '../../../../hooks/useVersionCheck'; import { normalizeProjectForSettings } from '../../utils/utils'; import type { DeleteProjectConfirmation, SessionDeleteConfirmation, SettingsProject } from '../../types/types'; +import ProjectCreationWizard from '../../../project-creation-wizard'; type SidebarModalsProps = { projects: Project[];