mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-09 01:39:37 +00:00
feat(projects): add project creation wizard with enhanced UX
Add new project creation wizard component with improved user experience and better input field interactions. Integrate projects API routes on the backend to support CRUD operations for project management. Changes include: - Add ProjectCreationWizard component with step-by-step project setup - Improve input hint visibility to hide when user starts typing - Refactor project creation state management in Sidebar component - Add ReactDOM import for portal-based wizard rendering The wizard provides a more intuitive onboarding experience for users creating new projects, while the enhanced input hints reduce visual clutter during active typing.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
215
server/routes/projects.js
Normal file
215
server/routes/projects.js
Normal file
@@ -0,0 +1,215 @@
|
||||
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();
|
||||
|
||||
/**
|
||||
* 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"' });
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(workspacePath);
|
||||
|
||||
// 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
|
||||
await fs.rm(absolutePath, { recursive: true, force: true });
|
||||
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 token = await db.get(
|
||||
'SELECT * FROM github_tokens WHERE id = ? AND user_id = ? AND is_active = 1',
|
||||
[tokenId, userId]
|
||||
);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -4710,13 +4710,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</button>
|
||||
|
||||
{/* Hint text inside input box at bottom */}
|
||||
<div className="absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block">
|
||||
<div className={`absolute bottom-1 left-12 right-14 sm:right-40 text-xs text-gray-400 dark:text-gray-500 pointer-events-none hidden sm:block transition-opacity duration-200 ${
|
||||
input.trim() ? 'opacity-0' : 'opacity-100'
|
||||
}`}>
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
||||
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
||||
</div>
|
||||
<div className={`absolute bottom-1 left-12 right-14 text-xs text-gray-400 dark:text-gray-500 pointer-events-none sm:hidden transition-opacity duration-200 ${
|
||||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||||
isInputFocused && !input.trim() ? 'opacity-100' : 'opacity-0'
|
||||
}`}>
|
||||
{sendByCtrlEnter
|
||||
? "Ctrl+Enter to send • Tab for modes • / for commands"
|
||||
|
||||
570
src/components/ProjectCreationWizard.jsx
Normal file
570
src/components/ProjectCreationWizard.jsx
Normal file
@@ -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 [useExistingToken, setUseExistingToken] = useState(true);
|
||||
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/github-tokens');
|
||||
const data = await response.json();
|
||||
|
||||
const activeTokens = (data.tokens || []).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 (useExistingToken && selectedGithubToken) {
|
||||
payload.githubTokenId = parseInt(selectedGithubToken);
|
||||
} else if (!useExistingToken && 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 (
|
||||
<div className="fixed top-0 left-0 right-0 bottom-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-0 sm:p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-none sm:rounded-lg shadow-xl w-full h-full sm:h-auto sm:max-w-2xl border-0 sm:border border-gray-200 dark:border-gray-700 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<FolderPlus className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Create New Project
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
disabled={isCreating}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Indicator */}
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<React.Fragment key={s}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center font-medium text-sm ${
|
||||
s < step
|
||||
? 'bg-green-500 text-white'
|
||||
: s === step
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{s < step ? <Check className="w-4 h-4" /> : s}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 hidden sm:inline">
|
||||
{s === 1 ? 'Type' : s === 2 ? 'Configure' : 'Confirm'}
|
||||
</span>
|
||||
</div>
|
||||
{s < 3 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-2 rounded ${
|
||||
s < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 min-h-[300px]">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Choose workspace type */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Do you already have a workspace, or would you like to create a new one?
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Existing Workspace */}
|
||||
<button
|
||||
onClick={() => setWorkspaceType('existing')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
workspaceType === 'existing'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FolderPlus className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Existing Workspace
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
I already have a workspace on my server and just need to add it to the project list
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* New Workspace */}
|
||||
<button
|
||||
onClick={() => setWorkspaceType('new')}
|
||||
className={`p-4 border-2 rounded-lg text-left transition-all ${
|
||||
workspaceType === 'new'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<GitBranch className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-gray-900 dark:text-white mb-1">
|
||||
New Workspace
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Create a new workspace, optionally clone from a GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Configure workspace */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* Workspace Path */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{workspaceType === 'existing' ? 'Workspace Path' : 'Where should the workspace be created?'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
value={workspacePath}
|
||||
onChange={(e) => setWorkspacePath(e.target.value)}
|
||||
placeholder={workspaceType === 'existing' ? '/path/to/existing/workspace' : '/path/to/new/workspace'}
|
||||
className="w-full"
|
||||
/>
|
||||
{showPathDropdown && pathSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{pathSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => selectPathSuggestion(suggestion)}
|
||||
className="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-sm"
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-white">{suggestion.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{suggestion.path}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{workspaceType === 'existing'
|
||||
? 'Full path to your existing workspace directory'
|
||||
: 'Full path where the new workspace will be created'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub URL (only for new workspace) */}
|
||||
{workspaceType === 'new' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
GitHub URL (Optional)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={githubUrl}
|
||||
onChange={(e) => setGithubUrl(e.target.value)}
|
||||
placeholder="https://github.com/username/repository"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Leave empty to create an empty workspace, or provide a GitHub URL to clone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* GitHub Token (only if GitHub URL is provided) */}
|
||||
{githubUrl && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-1">
|
||||
GitHub Authentication (Optional)
|
||||
</h5>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Only required for private repositories. Public repos can be cloned without authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingTokens ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading stored tokens...
|
||||
</div>
|
||||
) : availableTokens.length > 0 ? (
|
||||
<>
|
||||
{/* Token Selection Tabs */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setUseExistingToken(true)}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
useExistingToken
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Stored Token
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUseExistingToken(false)}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
!useExistingToken && (selectedGithubToken || newGithubToken)
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
New Token
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUseExistingToken(true);
|
||||
setSelectedGithubToken('');
|
||||
setNewGithubToken('');
|
||||
}}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
!selectedGithubToken && !newGithubToken
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
None (Public)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{useExistingToken ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Select Token
|
||||
</label>
|
||||
<select
|
||||
value={selectedGithubToken}
|
||||
onChange={(e) => setSelectedGithubToken(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">-- Select a token --</option>
|
||||
{availableTokens.map((token) => (
|
||||
<option key={token.id} value={token.id}>
|
||||
{token.token_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
GitHub Token
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
This token will be used only for this operation
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 <strong>Public repositories</strong> don't require authentication. You can skip providing a token if cloning a public repo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
GitHub Token (Optional for Public Repos)
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
placeholder="ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Confirm */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Review Your Configuration
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Workspace Type:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{workspaceType === 'existing' ? 'Existing Workspace' : 'New Workspace'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Path:</span>
|
||||
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||
{workspacePath}
|
||||
</span>
|
||||
</div>
|
||||
{workspaceType === 'new' && githubUrl && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Clone From:</span>
|
||||
<span className="font-mono text-xs text-gray-900 dark:text-white break-all">
|
||||
{githubUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
|
||||
<span className="text-xs text-gray-900 dark:text-white">
|
||||
{useExistingToken && selectedGithubToken
|
||||
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.token_name || 'Unknown'}`
|
||||
: newGithubToken
|
||||
? 'Using provided token'
|
||||
: 'No authentication'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={step === 1 ? onClose : handleBack}
|
||||
disabled={isCreating}
|
||||
>
|
||||
{step === 1 ? (
|
||||
'Cancel'
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
Back
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={step === 3 ? handleCreate : handleNext}
|
||||
disabled={isCreating || (step === 1 && !workspaceType)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : step === 3 ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Create Project
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectCreationWizard;
|
||||
@@ -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();
|
||||
@@ -465,50 +344,6 @@ function Sidebar({
|
||||
}
|
||||
};
|
||||
|
||||
const createNewProject = async () => {
|
||||
if (!newProjectPath.trim()) {
|
||||
alert('Please enter a project path');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingProject(true);
|
||||
|
||||
try {
|
||||
const response = await api.createProject(newProjectPath.trim());
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
// Save the path to recent paths before clearing
|
||||
saveToRecentPaths(newProjectPath.trim());
|
||||
|
||||
setShowNewProject(false);
|
||||
setNewProjectPath('');
|
||||
setShowSuggestions(false);
|
||||
|
||||
// Refresh projects to show the new one
|
||||
if (window.refreshProjects) {
|
||||
window.refreshProjects();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Failed to create project. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
alert('Error creating project. Please try again.');
|
||||
} finally {
|
||||
setCreatingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelNewProject = () => {
|
||||
setShowNewProject(false);
|
||||
setNewProjectPath('');
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const loadMoreSessions = async (project) => {
|
||||
// Check if we can load more sessions
|
||||
@@ -571,10 +406,27 @@ function Sidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex flex-col bg-card md:select-none"
|
||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||
>
|
||||
<>
|
||||
{/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
|
||||
{showNewProject && ReactDOM.createPortal(
|
||||
<ProjectCreationWizard
|
||||
onClose={() => setShowNewProject(false)}
|
||||
onProjectCreated={(project) => {
|
||||
// Refresh projects list after creation
|
||||
if (window.refreshProjects) {
|
||||
window.refreshProjects();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<div
|
||||
className="h-full flex flex-col bg-card md:select-none"
|
||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="md:p-4 md:border-b md:border-border">
|
||||
{/* Desktop Header */}
|
||||
@@ -648,263 +500,7 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* New Project Form */}
|
||||
{showNewProject && (
|
||||
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
|
||||
{/* Desktop Form */}
|
||||
<div className="hidden md:block space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Create New Project
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||
{filteredPaths.map((pathItem, index) => (
|
||||
<div
|
||||
key={pathItem.path}
|
||||
className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
|
||||
index === selectedPathIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectPath(pathItem);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{pathItem.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={createNewProject}
|
||||
disabled={!newProjectPath.trim() || creatingProject}
|
||||
className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{creatingProject ? 'Creating...' : 'Create Project'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
className="h-8 text-xs hover:bg-accent transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Form - Simple Overlay */}
|
||||
<div className="md:hidden fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-end justify-center px-4 pb-24">
|
||||
<div className="w-full max-w-sm bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
|
||||
<FolderPlus className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">New Project</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
|
||||
{filteredPaths.map((pathItem, index) => (
|
||||
<div
|
||||
key={pathItem.path}
|
||||
className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
|
||||
index === selectedPathIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectPath(pathItem);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{pathItem.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={cancelNewProject}
|
||||
disabled={creatingProject}
|
||||
variant="outline"
|
||||
className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={createNewProject}
|
||||
disabled={!newProjectPath.trim() || creatingProject}
|
||||
className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
|
||||
>
|
||||
{creatingProject ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Safe area for mobile */}
|
||||
<div className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Filter and Actions */}
|
||||
{projects.length > 0 && !isLoading && (
|
||||
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
|
||||
@@ -1693,6 +1289,7 @@ function Sidebar({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user