diff --git a/server/index.js b/server/index.js index 2f19dcd..dd86757 100755 --- a/server/index.js +++ b/server/index.js @@ -550,6 +550,34 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { } }); +app.post('/api/create-folder', authenticateToken, async (req, res) => { + try { + const { path: folderPath } = req.body; + if (!folderPath) { + return res.status(400).json({ error: 'Path is required' }); + } + const homeDir = os.homedir(); + const targetPath = path.resolve(folderPath.replace('~', homeDir)); + const parentDir = path.dirname(targetPath); + try { + await fs.promises.access(parentDir); + } catch (err) { + return res.status(404).json({ error: 'Parent directory does not exist' }); + } + try { + await fs.promises.access(targetPath); + return res.status(409).json({ error: 'Folder already exists' }); + } catch (err) { + // Folder doesn't exist, which is what we want + } + await fs.promises.mkdir(targetPath, { recursive: false }); + res.json({ success: true, path: targetPath }); + } catch (error) { + console.error('Error creating folder:', error); + res.status(500).json({ error: 'Failed to create folder' }); + } +}); + // Read file content endpoint app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { try { diff --git a/server/middleware/auth.js b/server/middleware/auth.js index b9ff24f..9231e4e 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -37,7 +37,12 @@ const authenticateToken = async (req, res, next) => { // Normal OSS JWT validation const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + // Also check query param for SSE endpoints (EventSource can't set headers) + if (!token && req.query.token) { + token = req.query.token; + } if (!token) { return res.status(401).json({ error: 'Access denied. No token provided.' }); diff --git a/server/routes/projects.js b/server/routes/projects.js index 042de51..31df1a2 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -212,20 +212,7 @@ router.post('/create-workspace', async (req, res) => { // Handle new workspace creation if (workspaceType === 'new') { - // Check if path already exists - try { - await fs.access(absolutePath); - return res.status(400).json({ - error: 'Path already exists. Please choose a different path or use "existing workspace" option.' - }); - } catch (error) { - if (error.code !== 'ENOENT') { - throw error; - } - // Path doesn't exist - good, we can create it - } - - // Create the directory + // Create the directory if it doesn't exist await fs.mkdir(absolutePath, { recursive: true }); // If GitHub URL is provided, clone the repository @@ -246,22 +233,35 @@ router.post('/create-workspace', async (req, res) => { githubToken = newGithubToken; } - // Clone the repository + // Extract repo name from URL for the clone destination + const repoName = githubUrl.replace(/\.git$/, '').split('/').pop(); + const clonePath = path.join(absolutePath, repoName); + + // Clone the repository into a subfolder try { - await cloneGitHubRepository(githubUrl, absolutePath, githubToken); + await cloneGitHubRepository(githubUrl, clonePath, githubToken); } catch (error) { // Clean up created directory on failure try { - await fs.rm(absolutePath, { recursive: true, force: true }); + await fs.rm(clonePath, { recursive: true, force: true }); } catch (cleanupError) { console.error('Failed to clean up directory after clone failure:', cleanupError); // Continue to throw original error } throw new Error(`Failed to clone repository: ${error.message}`); } + + // Add the cloned repo path to the project list + const project = await addProjectManually(clonePath); + + return res.json({ + success: true, + project, + message: 'New workspace created and repository cloned successfully' + }); } - // Add the new workspace to the project list + // Add the new workspace to the project list (no clone) const project = await addProjectManually(absolutePath); return res.json({ @@ -305,31 +305,158 @@ async function getGithubTokenById(tokenId, userId) { return null; } +/** + * Clone repository with progress streaming (SSE) + * GET /api/projects/clone-progress + */ +router.get('/clone-progress', async (req, res) => { + const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query; + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + + const sendEvent = (type, data) => { + res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); + }; + + try { + if (!workspacePath || !githubUrl) { + sendEvent('error', { message: 'workspacePath and githubUrl are required' }); + res.end(); + return; + } + + const validation = await validateWorkspacePath(workspacePath); + if (!validation.valid) { + sendEvent('error', { message: validation.error }); + res.end(); + return; + } + + const absolutePath = validation.resolvedPath; + + await fs.mkdir(absolutePath, { recursive: true }); + + let githubToken = null; + if (githubTokenId) { + const token = await getGithubTokenById(parseInt(githubTokenId), req.user?.id); + if (token) { + githubToken = token.github_token; + } + } else if (newGithubToken) { + githubToken = newGithubToken; + } + + const repoName = githubUrl.replace(/\.git$/, '').split('/').pop(); + const clonePath = path.join(absolutePath, repoName); + + let cloneUrl = githubUrl; + if (githubToken) { + try { + const url = new URL(githubUrl); + url.username = githubToken; + url.password = ''; + cloneUrl = url.toString(); + } catch (error) { + // SSH URL or invalid - use as-is + } + } + + sendEvent('progress', { message: `Cloning into '${repoName}'...` }); + + const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0' + } + }); + + let lastError = ''; + + gitProcess.stdout.on('data', (data) => { + const message = data.toString().trim(); + if (message) { + sendEvent('progress', { message }); + } + }); + + gitProcess.stderr.on('data', (data) => { + const message = data.toString().trim(); + lastError = message; + if (message) { + sendEvent('progress', { message }); + } + }); + + gitProcess.on('close', async (code) => { + if (code === 0) { + try { + const project = await addProjectManually(clonePath); + sendEvent('complete', { project, message: 'Repository cloned successfully' }); + } catch (error) { + sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` }); + } + } else { + let errorMessage = 'Git clone failed'; + if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) { + errorMessage = 'Authentication failed. Please check your credentials.'; + } else if (lastError.includes('Repository not found')) { + errorMessage = 'Repository not found. Please check the URL and ensure you have access.'; + } else if (lastError.includes('already exists')) { + errorMessage = 'Directory already exists'; + } else if (lastError) { + errorMessage = lastError; + } + sendEvent('error', { message: errorMessage }); + } + res.end(); + }); + + gitProcess.on('error', (error) => { + if (error.code === 'ENOENT') { + sendEvent('error', { message: 'Git is not installed or not in PATH' }); + } else { + sendEvent('error', { message: error.message }); + } + res.end(); + }); + + req.on('close', () => { + gitProcess.kill(); + }); + + } catch (error) { + sendEvent('error', { message: error.message }); + res.end(); + } +}); + /** * Helper function to clone a GitHub repository */ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { return new Promise((resolve, reject) => { - // Parse GitHub URL and inject token if provided let cloneUrl = githubUrl; if (githubToken) { try { const url = new URL(githubUrl); - // Format: https://TOKEN@github.com/user/repo.git url.username = githubToken; url.password = ''; cloneUrl = url.toString(); } catch (error) { - return reject(new Error('Invalid GitHub URL format')); + // SSH URL - use as-is } } - const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], { + const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, - GIT_TERMINAL_PROMPT: '0' // Disable git password prompts + GIT_TERMINAL_PROMPT: '0' } }); @@ -348,7 +475,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { if (code === 0) { resolve({ stdout, stderr }); } else { - // Parse git error messages to provide helpful feedback let errorMessage = 'Git clone failed'; if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) { diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx index ed40e60..dd6ab72 100644 --- a/src/components/ProjectCreationWizard.jsx +++ b/src/components/ProjectCreationWizard.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react'; +import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { api } from '../utils/api'; @@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { 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(() => { @@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const data = await response.json(); if (data.suggestions) { - // Filter suggestions based on the input + // Filter suggestions based on the input, excluding exact match const filtered = data.suggestions.filter(s => - s.path.toLowerCase().startsWith(inputPath.toLowerCase()) + s.path.toLowerCase().startsWith(inputPath.toLowerCase()) && + s.path.toLowerCase() !== inputPath.toLowerCase() ); setPathSuggestions(filtered.slice(0, 5)); setShowPathDropdown(filtered.length > 0); @@ -118,24 +123,62 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { 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(), }; - // Add GitHub info if creating new workspace with GitHub URL - if (workspaceType === 'new' && githubUrl) { - payload.githubUrl = githubUrl.trim(); - - if (tokenMode === 'stored' && selectedGithubToken) { - payload.githubTokenId = parseInt(selectedGithubToken); - } else if (tokenMode === 'new' && newGithubToken) { - payload.newGithubToken = newGithubToken.trim(); - } - } - const response = await api.createWorkspace(payload); const data = await response.json(); @@ -143,7 +186,6 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { throw new Error(data.error || t('projectWizard.errors.failedToCreate')); } - // Success! if (onProjectCreated) { onProjectCreated(data.project); } @@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { 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 (
@@ -388,8 +453,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {

- {/* GitHub Token (only if GitHub URL is provided) */} - {githubUrl && ( + {/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */} + {githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
@@ -551,6 +616,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { ? `${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')}
@@ -560,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
-

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

+ {isCreating && cloneProgress ? ( +
+

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

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

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

+ )}
)} @@ -596,7 +672,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { {isCreating ? ( <> - {t('projectWizard.buttons.creating')} + {githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')} ) : step === 3 ? ( <> @@ -639,6 +715,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { > {showHiddenFolders ? : } + + + + + )} + {/* Folder List */}
{loadingFolders ? ( @@ -699,7 +826,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { diff --git a/src/utils/api.js b/src/utils/api.js index 7f497cd..1625546 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -158,6 +158,12 @@ export const api = { return authenticatedFetch(`/api/browse-filesystem?${params}`); }, + createFolder: (folderPath) => + authenticatedFetch('/api/create-folder', { + method: 'POST', + body: JSON.stringify({ path: folderPath }), + }), + // User endpoints user: { gitConfig: () => authenticatedFetch('/api/user/git-config'),