diff --git a/server/index.js b/server/index.js index 4562f80..28ba951 100755 --- a/server/index.js +++ b/server/index.js @@ -69,6 +69,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js'; import commandsRoutes from './routes/commands.js'; import settingsRoutes from './routes/settings.js'; import agentRoutes from './routes/agent.js'; +import projectsRoutes from './routes/projects.js'; import { initializeDatabase } from './database/db.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; @@ -201,6 +202,9 @@ app.use('/api', validateApiKey); // Authentication routes (public) app.use('/api/auth', authRoutes); +// Projects API Routes (protected) +app.use('/api/projects', authenticateToken, projectsRoutes); + // Git API Routes (protected) app.use('/api/git', authenticateToken, gitRoutes); diff --git a/server/routes/projects.js b/server/routes/projects.js new file mode 100644 index 0000000..042de51 --- /dev/null +++ b/server/routes/projects.js @@ -0,0 +1,378 @@ +import express from 'express'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; +import os from 'os'; +import { addProjectManually } from '../projects.js'; + +const router = express.Router(); + +// Configure allowed workspace root (defaults to user's home directory) +const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir(); + +// System-critical paths that should never be used as workspace directories +const FORBIDDEN_PATHS = [ + '/', + '/etc', + '/bin', + '/sbin', + '/usr', + '/dev', + '/proc', + '/sys', + '/var', + '/boot', + '/root', + '/lib', + '/lib64', + '/opt', + '/tmp', + '/run' +]; + +/** + * Validates that a path is safe for workspace operations + * @param {string} requestedPath - The path to validate + * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>} + */ +async function validateWorkspacePath(requestedPath) { + try { + // Resolve to absolute path + let absolutePath = path.resolve(requestedPath); + + // Check if path is a forbidden system directory + const normalizedPath = path.normalize(absolutePath); + if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') { + return { + valid: false, + error: 'Cannot use system-critical directories as workspace locations' + }; + } + + // Additional check for paths starting with forbidden directories + for (const forbidden of FORBIDDEN_PATHS) { + if (normalizedPath === forbidden || + normalizedPath.startsWith(forbidden + path.sep)) { + // Exception: /var/tmp and similar user-accessible paths might be allowed + // but /var itself and most /var subdirectories should be blocked + if (forbidden === '/var' && + (normalizedPath.startsWith('/var/tmp') || + normalizedPath.startsWith('/var/folders'))) { + continue; // Allow these specific cases + } + + return { + valid: false, + error: `Cannot create workspace in system directory: ${forbidden}` + }; + } + } + + // Try to resolve the real path (following symlinks) + let realPath; + try { + // Check if path exists to resolve real path + await fs.access(absolutePath); + realPath = await fs.realpath(absolutePath); + } catch (error) { + if (error.code === 'ENOENT') { + // Path doesn't exist yet - check parent directory + let parentPath = path.dirname(absolutePath); + try { + const parentRealPath = await fs.realpath(parentPath); + + // Reconstruct the full path with real parent + realPath = path.join(parentRealPath, path.basename(absolutePath)); + } catch (parentError) { + if (parentError.code === 'ENOENT') { + // Parent doesn't exist either - use the absolute path as-is + // We'll validate it's within allowed root + realPath = absolutePath; + } else { + throw parentError; + } + } + } else { + throw error; + } + } + + // Resolve the workspace root to its real path + const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT); + + // Ensure the resolved path is contained within the allowed workspace root + if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) && + realPath !== resolvedWorkspaceRoot) { + return { + valid: false, + error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}` + }; + } + + // Additional symlink check for existing paths + try { + await fs.access(absolutePath); + const stats = await fs.lstat(absolutePath); + + if (stats.isSymbolicLink()) { + // Verify symlink target is also within allowed root + const linkTarget = await fs.readlink(absolutePath); + const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget); + const realTarget = await fs.realpath(resolvedTarget); + + if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) && + realTarget !== resolvedWorkspaceRoot) { + return { + valid: false, + error: 'Symlink target is outside the allowed workspace root' + }; + } + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + // Path doesn't exist - that's fine for new workspace creation + } + + return { + valid: true, + resolvedPath: realPath + }; + + } catch (error) { + return { + valid: false, + error: `Path validation failed: ${error.message}` + }; + } +} + +/** + * Create a new workspace + * POST /api/projects/create-workspace + * + * Body: + * - workspaceType: 'existing' | 'new' + * - path: string (workspace path) + * - githubUrl?: string (optional, for new workspaces) + * - githubTokenId?: number (optional, ID of stored token) + * - newGithubToken?: string (optional, one-time token) + */ +router.post('/create-workspace', async (req, res) => { + try { + const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body; + + // Validate required fields + if (!workspaceType || !workspacePath) { + return res.status(400).json({ error: 'workspaceType and path are required' }); + } + + if (!['existing', 'new'].includes(workspaceType)) { + return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' }); + } + + // Validate path safety before any operations + const validation = await validateWorkspacePath(workspacePath); + if (!validation.valid) { + return res.status(400).json({ + error: 'Invalid workspace path', + details: validation.error + }); + } + + const absolutePath = validation.resolvedPath; + + // Handle existing workspace + if (workspaceType === 'existing') { + // Check if the path exists + try { + await fs.access(absolutePath); + const stats = await fs.stat(absolutePath); + + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Path exists but is not a directory' }); + } + } catch (error) { + if (error.code === 'ENOENT') { + return res.status(404).json({ error: 'Workspace path does not exist' }); + } + throw error; + } + + // Add the existing workspace to the project list + const project = await addProjectManually(absolutePath); + + return res.json({ + success: true, + project, + message: 'Existing workspace added successfully' + }); + } + + // 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 + await fs.mkdir(absolutePath, { recursive: true }); + + // If GitHub URL is provided, clone the repository + if (githubUrl) { + let githubToken = null; + + // Get GitHub token if needed + if (githubTokenId) { + // Fetch token from database + const token = await getGithubTokenById(githubTokenId, req.user.id); + if (!token) { + // Clean up created directory + await fs.rm(absolutePath, { recursive: true, force: true }); + return res.status(404).json({ error: 'GitHub token not found' }); + } + githubToken = token.github_token; + } else if (newGithubToken) { + githubToken = newGithubToken; + } + + // Clone the repository + try { + await cloneGitHubRepository(githubUrl, absolutePath, githubToken); + } catch (error) { + // Clean up created directory on failure + try { + await fs.rm(absolutePath, { 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 new workspace to the project list + const project = await addProjectManually(absolutePath); + + return res.json({ + success: true, + project, + message: githubUrl + ? 'New workspace created and repository cloned successfully' + : 'New workspace created successfully' + }); + } + + } catch (error) { + console.error('Error creating workspace:', error); + res.status(500).json({ + error: error.message || 'Failed to create workspace', + details: process.env.NODE_ENV === 'development' ? error.stack : undefined + }); + } +}); + +/** + * Helper function to get GitHub token from database + */ +async function getGithubTokenById(tokenId, userId) { + const { getDatabase } = await import('../database/db.js'); + const db = await getDatabase(); + + const credential = await db.get( + 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1', + [tokenId, userId, 'github_token'] + ); + + // Return in the expected format (github_token field for compatibility) + if (credential) { + return { + ...credential, + github_token: credential.credential_value + }; + } + + return null; +} + +/** + * 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')); + } + } + + const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + GIT_TERMINAL_PROMPT: '0' // Disable git password prompts + } + }); + + let stdout = ''; + let stderr = ''; + + gitProcess.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + gitProcess.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + gitProcess.on('close', (code) => { + 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')) { + errorMessage = 'Authentication failed. Please check your GitHub token.'; + } else if (stderr.includes('Repository not found')) { + errorMessage = 'Repository not found. Please check the URL and ensure you have access.'; + } else if (stderr.includes('already exists')) { + errorMessage = 'Directory already exists'; + } else if (stderr) { + errorMessage = stderr; + } + + reject(new Error(errorMessage)); + } + }); + + gitProcess.on('error', (error) => { + if (error.code === 'ENOENT') { + reject(new Error('Git is not installed or not in PATH')); + } else { + reject(error); + } + }); + }); +} + +export default router; diff --git a/src/components/ApiKeysSettings.jsx b/src/components/ApiKeysSettings.jsx index 8a8c25b..c2d427c 100644 --- a/src/components/ApiKeysSettings.jsx +++ b/src/components/ApiKeysSettings.jsx @@ -33,11 +33,11 @@ function ApiKeysSettings() { setApiKeys(apiKeysData.apiKeys || []); // Fetch GitHub tokens - const githubRes = await fetch('/api/settings/github-tokens', { + const githubRes = await fetch('/api/settings/credentials?type=github_token', { headers: { 'Authorization': `Bearer ${token}` } }); const githubData = await githubRes.json(); - setGithubTokens(githubData.tokens || []); + setGithubTokens(githubData.credentials || []); } catch (error) { console.error('Error fetching settings:', error); } finally { @@ -108,15 +108,16 @@ function ApiKeysSettings() { try { const token = localStorage.getItem('auth-token'); - const res = await fetch('/api/settings/github-tokens', { + const res = await fetch('/api/settings/credentials', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - tokenName: newTokenName, - githubToken: newGithubToken + credentialName: newTokenName, + credentialType: 'github_token', + credentialValue: newGithubToken }) }); @@ -137,7 +138,7 @@ function ApiKeysSettings() { try { const token = localStorage.getItem('auth-token'); - await fetch(`/api/settings/github-tokens/${tokenId}`, { + await fetch(`/api/settings/credentials/${tokenId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); @@ -150,7 +151,7 @@ function ApiKeysSettings() { const toggleGithubToken = async (tokenId, isActive) => { try { const token = localStorage.getItem('auth-token'); - await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, { + await fetch(`/api/settings/credentials/${tokenId}/toggle`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, @@ -349,7 +350,7 @@ function ApiKeysSettings() { className="flex items-center justify-between p-3 border rounded-lg" >
-
{token.token_name}
+
{token.credential_name}
Added: {new Date(token.created_at).toLocaleDateString()}
diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 8d452d3..e8b1302 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -4744,13 +4744,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess {/* Hint text inside input box at bottom */} -
+
{sendByCtrlEnter ? "Ctrl+Enter to send • Tab for modes • / for commands" diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx new file mode 100644 index 0000000..38564dc --- /dev/null +++ b/src/components/ProjectCreationWizard.jsx @@ -0,0 +1,570 @@ +import React, { useState, useEffect } from 'react'; +import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle } from 'lucide-react'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { api } from '../utils/api'; + +const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { + // Wizard state + const [step, setStep] = useState(1); // 1: Choose type, 2: Configure, 3: Confirm + const [workspaceType, setWorkspaceType] = useState(null); // 'existing' or 'new' + + // 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); + + // 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 + const filtered = data.suggestions.filter(s => + s.path.toLowerCase().startsWith(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('Please select whether you have an existing workspace or want to create a new one'); + return; + } + setStep(2); + } else if (step === 2) { + if (!workspacePath.trim()) { + setError('Please provide a workspace path'); + 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); + + try { + 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(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to create workspace'); + } + + // Success! + if (onProjectCreated) { + onProjectCreated(data.project); + } + + onClose(); + } catch (error) { + console.error('Error creating workspace:', error); + setError(error.message || 'Failed to create workspace'); + } finally { + setIsCreating(false); + } + }; + + const selectPathSuggestion = (suggestion) => { + setWorkspacePath(suggestion.path); + setShowPathDropdown(false); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+

+ Create New Project +

+
+ +
+ + {/* Progress Indicator */} +
+
+ {[1, 2, 3].map((s) => ( + +
+
+ {s < step ? : s} +
+ + {s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'} + +
+ {s < 3 && ( +
+ )} + + ))} +
+
+ + {/* Content */} +
+ {/* Error Display */} + {error && ( +
+ +
+

{error}

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

+ Do you already have a workspace, or would you like to create a new one? +

+
+ {/* 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' + ? 'Full path to your existing workspace directory' + : 'Full path where the new workspace will be created'} +

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

+ Leave empty to create an empty workspace, or provide a GitHub URL to clone +

+
+ + {/* GitHub Token (only if GitHub URL is provided) */} + {githubUrl && ( +
+
+ +
+
+ GitHub Authentication (Optional) +
+

+ Only required for private repositories. Public repos can be cloned without authentication. +

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

+ This token will be used only for this operation +

+
+ ) : null} + + ) : ( +
+
+

+ 💡 Public repositories don't require authentication. You can skip providing a token if cloning a public repo. +

+
+ +
+ + setNewGithubToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)" + className="w-full" + /> +

+ No stored tokens available. You can add tokens in Settings → API Keys for easier reuse. +

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

+ Review Your Configuration +

+
+
+ Workspace Type: + + {workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'} + +
+
+ Path: + + {workspacePath} + +
+ {workspaceType === 'new' && githubUrl && ( + <> +
+ Clone From: + + {githubUrl} + +
+
+ Authentication: + + {tokenMode === 'stored' && selectedGithubToken + ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` + : tokenMode === 'new' && newGithubToken + ? 'Using provided token' + : 'No authentication'} + +
+ + )} +
+
+ +
+

+ {workspaceType === 'existing' + ? 'The workspace will be added to your project list and will be available for Claude/Cursor sessions.' + : githubUrl + ? 'A new workspace will be created and the repository will be cloned from GitHub.' + : 'An empty workspace directory will be created at the specified path.'} +

+
+
+ )} +
+ + {/* Footer */} +
+ + + +
+
+
+ ); +}; + +export default ProjectCreationWizard; diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index f5c3e06..f619e7f 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; import { ScrollArea } from './ui/scroll-area'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; @@ -9,6 +10,7 @@ import { cn } from '../lib/utils'; import ClaudeLogo from './ClaudeLogo'; import CursorLogo from './CursorLogo.jsx'; import TaskIndicator from './TaskIndicator'; +import ProjectCreationWizard from './ProjectCreationWizard'; import { api } from '../utils/api'; import { useTaskMaster } from '../contexts/TaskMasterContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext'; @@ -64,8 +66,6 @@ function Sidebar({ const [editingProject, setEditingProject] = useState(null); const [showNewProject, setShowNewProject] = useState(false); const [editingName, setEditingName] = useState(''); - const [newProjectPath, setNewProjectPath] = useState(''); - const [creatingProject, setCreatingProject] = useState(false); const [loadingSessions, setLoadingSessions] = useState({}); const [additionalSessions, setAdditionalSessions] = useState({}); const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set()); @@ -76,10 +76,6 @@ function Sidebar({ const [editingSessionName, setEditingSessionName] = useState(''); const [generatingSummary, setGeneratingSummary] = useState({}); const [searchFilter, setSearchFilter] = useState(''); - const [showPathDropdown, setShowPathDropdown] = useState(false); - const [pathList, setPathList] = useState([]); - const [filteredPaths, setFilteredPaths] = useState([]); - const [selectedPathIndex, setSelectedPathIndex] = useState(-1); // TaskMaster context const { setCurrentProject, mcpServerStatus } = useTaskMaster(); @@ -184,123 +180,6 @@ function Sidebar({ }; }, []); - // Load available paths for suggestions - useEffect(() => { - const loadPaths = async () => { - try { - // Get recent paths from localStorage - const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); - - // Load common/home directory paths - const response = await api.browseFilesystem(); - const data = await response.json(); - - if (data.suggestions) { - const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path })); - const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths]; - setPathList(allPaths); - } else { - setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); - } - } catch (error) { - console.error('Error loading paths:', error); - const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); - setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path }))); - } - }; - - loadPaths(); - }, []); - - // Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing) - useEffect(() => { - const inputValue = newProjectPath.trim(); - - if (inputValue.length === 0) { - setShowPathDropdown(false); - return; - } - - // Show dropdown when user starts typing - setShowPathDropdown(true); - - const updateSuggestions = async () => { - // First show filtered existing suggestions from pathList - const staticFiltered = pathList.filter(pathItem => - pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) || - pathItem.path.toLowerCase().includes(inputValue.toLowerCase()) - ); - - // Check if input looks like a directory path for dynamic browsing - const isDirPath = inputValue.includes('/') && inputValue.length > 1; - - if (isDirPath) { - try { - let dirToSearch; - - // Determine which directory to search - if (inputValue.endsWith('/')) { - // User typed "/home/simos/" - search inside /home/simos - dirToSearch = inputValue.slice(0, -1); - } else { - // User typed "/home/simos/con" - search inside /home/simos for items starting with "con" - const lastSlashIndex = inputValue.lastIndexOf('/'); - dirToSearch = inputValue.substring(0, lastSlashIndex); - } - - // Only search if we have a valid directory path (not root only) - if (dirToSearch && dirToSearch !== '') { - const response = await api.browseFilesystem(dirToSearch); - const data = await response.json(); - - if (data.suggestions) { - // Filter directories that match the current input - const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1); - const dynamicPaths = data.suggestions - .filter(suggestion => { - const dirName = suggestion.name; - return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true; - }) - .map(s => ({ name: s.name, path: s.path })) - .slice(0, 8); - - // Combine static and dynamic suggestions, prioritize dynamic - const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8); - setFilteredPaths(combined); - setSelectedPathIndex(-1); - return; - } - } - } catch (error) { - console.debug('Dynamic browsing failed:', error.message); - } - } - - // Fallback to just static filtered suggestions - setFilteredPaths(staticFiltered.slice(0, 8)); - setSelectedPathIndex(-1); - }; - - updateSuggestions(); - }, [newProjectPath, pathList]); - - // Select path from dropdown (ChatInterface pattern) - const selectPath = (pathItem) => { - setNewProjectPath(pathItem.path); - setShowPathDropdown(false); - setSelectedPathIndex(-1); - }; - - // Save path to recent paths - const saveToRecentPaths = (path) => { - try { - const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]'); - const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10); - localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths)); - } catch (error) { - console.error('Error saving recent paths:', error); - } - }; const toggleProject = (projectName) => { const newExpanded = new Set(); @@ -569,10 +448,27 @@ function Sidebar({ }; return ( -
+ <> + {/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */} + {showNewProject && ReactDOM.createPortal( + setShowNewProject(false)} + onProjectCreated={(project) => { + // Refresh projects list after creation + if (window.refreshProjects) { + window.refreshProjects(); + } else { + window.location.reload(); + } + }} + />, + document.body + )} + +
{/* Header */}
{/* Desktop Header */} @@ -646,263 +542,7 @@ function Sidebar({
- - {/* New Project Form */} - {showNewProject && ( -
- {/* Desktop Form */} -
-
- - Create New Project -
-
- setNewProjectPath(e.target.value)} - placeholder="/path/to/project or relative/path" - className="text-sm focus:ring-2 focus:ring-primary/20" - autoFocus - onKeyDown={(e) => { - // Handle path dropdown navigation (ChatInterface pattern) - if (showPathDropdown && filteredPaths.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev < filteredPaths.length - 1 ? prev + 1 : 0 - ); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev > 0 ? prev - 1 : filteredPaths.length - 1 - ); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (selectedPathIndex >= 0) { - selectPath(filteredPaths[selectedPathIndex]); - } else if (filteredPaths.length > 0) { - selectPath(filteredPaths[0]); - } else { - createNewProject(); - } - return; - } else if (e.key === 'Escape') { - e.preventDefault(); - setShowPathDropdown(false); - return; - } else if (e.key === 'Tab') { - e.preventDefault(); - if (selectedPathIndex >= 0) { - selectPath(filteredPaths[selectedPathIndex]); - } else if (filteredPaths.length > 0) { - selectPath(filteredPaths[0]); - } - return; - } - } - - // Regular input handling - if (e.key === 'Enter') { - createNewProject(); - } - if (e.key === 'Escape') { - cancelNewProject(); - } - }} - /> - - {/* Path dropdown (ChatInterface pattern) */} - {showPathDropdown && filteredPaths.length > 0 && ( -
- {filteredPaths.map((pathItem, index) => ( -
{ - e.preventDefault(); - e.stopPropagation(); - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - selectPath(pathItem); - }} - > -
- -
-
{pathItem.name}
-
- {pathItem.path} -
-
-
-
- ))} -
- )} -
-
- - -
-
- - {/* Mobile Form - Simple Overlay */} -
-
-
-
-
- -
-
-

New Project

-
-
- -
- -
-
- setNewProjectPath(e.target.value)} - placeholder="/path/to/project or relative/path" - className="text-sm h-10 rounded-md focus:border-primary transition-colors" - autoFocus - onKeyDown={(e) => { - // Handle path dropdown navigation (same as desktop) - if (showPathDropdown && filteredPaths.length > 0) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev < filteredPaths.length - 1 ? prev + 1 : 0 - ); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedPathIndex(prev => - prev > 0 ? prev - 1 : filteredPaths.length - 1 - ); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (selectedPathIndex >= 0) { - selectPath(filteredPaths[selectedPathIndex]); - } else if (filteredPaths.length > 0) { - selectPath(filteredPaths[0]); - } else { - createNewProject(); - } - return; - } else if (e.key === 'Escape') { - e.preventDefault(); - setShowPathDropdown(false); - return; - } - } - - // Regular input handling - if (e.key === 'Enter') { - createNewProject(); - } - if (e.key === 'Escape') { - cancelNewProject(); - } - }} - style={{ - fontSize: '16px', // Prevents zoom on iOS - WebkitAppearance: 'none' - }} - /> - - {/* Mobile Path dropdown */} - {showPathDropdown && filteredPaths.length > 0 && ( -
- {filteredPaths.map((pathItem, index) => ( -
{ - e.preventDefault(); - e.stopPropagation(); - }} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - selectPath(pathItem); - }} - > -
- -
-
{pathItem.name}
-
- {pathItem.path} -
-
-
-
- ))} -
- )} -
- -
- - -
-
- - {/* Safe area for mobile */} -
-
-
-
- )} - {/* Search Filter and Actions */} {projects.length > 0 && !isLoading && (
@@ -1691,6 +1331,7 @@ function Sidebar({
+ ); } diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 77acb6c..c227814 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -34,12 +34,14 @@ export const AuthProvider = ({ children }) => { const checkAuthStatus = async () => { try { + console.log('[AuthContext] Checking auth status...'); setIsLoading(true); setError(null); - + // Check if system needs setup const statusResponse = await api.auth.status(); const statusData = await statusResponse.json(); + console.log('[AuthContext] Status response:', statusData); if (statusData.needsSetup) { setNeedsSetup(true); @@ -70,9 +72,10 @@ export const AuthProvider = ({ children }) => { } } } catch (error) { - console.error('Auth status check failed:', error); + console.error('[AuthContext] Auth status check failed:', error); setError('Failed to check authentication status'); } finally { + console.log('[AuthContext] Auth check complete, isLoading=false'); setIsLoading(false); } }; diff --git a/src/index.css b/src/index.css index d47186a..90abc99 100644 --- a/src/index.css +++ b/src/index.css @@ -79,7 +79,7 @@ --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; + --input: 220 13% 46%; --ring: 217.2 91.2% 59.8%; } } diff --git a/src/utils/api.js b/src/utils/api.js index e3a93d3..9459417 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -71,6 +71,11 @@ export const api = { method: 'POST', body: JSON.stringify({ path }), }), + createWorkspace: (workspaceData) => + authenticatedFetch('/api/projects/create-workspace', { + method: 'POST', + body: JSON.stringify(workspaceData), + }), readFile: (projectName, filePath) => authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`), saveFile: (projectName, filePath, content) =>