mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-13 21:59:37 +00:00
Merge pull request #227 from siteboon/feature/new-project-creation
feat(projects): add project creation wizard with enhanced UX
This commit is contained in:
@@ -69,6 +69,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
|
|||||||
import commandsRoutes from './routes/commands.js';
|
import commandsRoutes from './routes/commands.js';
|
||||||
import settingsRoutes from './routes/settings.js';
|
import settingsRoutes from './routes/settings.js';
|
||||||
import agentRoutes from './routes/agent.js';
|
import agentRoutes from './routes/agent.js';
|
||||||
|
import projectsRoutes from './routes/projects.js';
|
||||||
import { initializeDatabase } from './database/db.js';
|
import { initializeDatabase } from './database/db.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
|
|
||||||
@@ -201,6 +202,9 @@ app.use('/api', validateApiKey);
|
|||||||
// Authentication routes (public)
|
// Authentication routes (public)
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
|
// Projects API Routes (protected)
|
||||||
|
app.use('/api/projects', authenticateToken, projectsRoutes);
|
||||||
|
|
||||||
// Git API Routes (protected)
|
// Git API Routes (protected)
|
||||||
app.use('/api/git', authenticateToken, gitRoutes);
|
app.use('/api/git', authenticateToken, gitRoutes);
|
||||||
|
|
||||||
|
|||||||
378
server/routes/projects.js
Normal file
378
server/routes/projects.js
Normal file
@@ -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;
|
||||||
@@ -33,11 +33,11 @@ function ApiKeysSettings() {
|
|||||||
setApiKeys(apiKeysData.apiKeys || []);
|
setApiKeys(apiKeysData.apiKeys || []);
|
||||||
|
|
||||||
// Fetch GitHub tokens
|
// 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}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
const githubData = await githubRes.json();
|
const githubData = await githubRes.json();
|
||||||
setGithubTokens(githubData.tokens || []);
|
setGithubTokens(githubData.credentials || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching settings:', error);
|
console.error('Error fetching settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,15 +108,16 @@ function ApiKeysSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
const res = await fetch('/api/settings/github-tokens', {
|
const res = await fetch('/api/settings/credentials', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tokenName: newTokenName,
|
credentialName: newTokenName,
|
||||||
githubToken: newGithubToken
|
credentialType: 'github_token',
|
||||||
|
credentialValue: newGithubToken
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ function ApiKeysSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
await fetch(`/api/settings/github-tokens/${tokenId}`, {
|
await fetch(`/api/settings/credentials/${tokenId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
@@ -150,7 +151,7 @@ function ApiKeysSettings() {
|
|||||||
const toggleGithubToken = async (tokenId, isActive) => {
|
const toggleGithubToken = async (tokenId, isActive) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
|
await fetch(`/api/settings/credentials/${tokenId}/toggle`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -349,7 +350,7 @@ function ApiKeysSettings() {
|
|||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{token.token_name}</div>
|
<div className="font-medium">{token.credential_name}</div>
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Added: {new Date(token.created_at).toLocaleDateString()}
|
Added: {new Date(token.created_at).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4710,13 +4710,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Hint text inside input box at bottom */}
|
{/* 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
|
{sendByCtrlEnter
|
||||||
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
|
? "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"}
|
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
|
||||||
</div>
|
</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 ${
|
<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
|
{sendByCtrlEnter
|
||||||
? "Ctrl+Enter to send • Tab for modes • / for commands"
|
? "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 [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 (
|
||||||
|
<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={() => setTokenMode('stored')}
|
||||||
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
tokenMode === 'stored'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Stored Token
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTokenMode('new')}
|
||||||
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
tokenMode === 'new'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
New Token
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTokenMode('none');
|
||||||
|
setSelectedGithubToken('');
|
||||||
|
setNewGithubToken('');
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
tokenMode === 'none'
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
None (Public)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tokenMode === 'stored' ? (
|
||||||
|
<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.credential_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : tokenMode === 'new' ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
{tokenMode === 'stored' && selectedGithubToken
|
||||||
|
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||||
|
: tokenMode === 'new' && 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 React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
@@ -9,6 +10,7 @@ import { cn } from '../lib/utils';
|
|||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
import CursorLogo from './CursorLogo.jsx';
|
import CursorLogo from './CursorLogo.jsx';
|
||||||
import TaskIndicator from './TaskIndicator';
|
import TaskIndicator from './TaskIndicator';
|
||||||
|
import ProjectCreationWizard from './ProjectCreationWizard';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||||
@@ -64,8 +66,6 @@ function Sidebar({
|
|||||||
const [editingProject, setEditingProject] = useState(null);
|
const [editingProject, setEditingProject] = useState(null);
|
||||||
const [showNewProject, setShowNewProject] = useState(false);
|
const [showNewProject, setShowNewProject] = useState(false);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
const [newProjectPath, setNewProjectPath] = useState('');
|
|
||||||
const [creatingProject, setCreatingProject] = useState(false);
|
|
||||||
const [loadingSessions, setLoadingSessions] = useState({});
|
const [loadingSessions, setLoadingSessions] = useState({});
|
||||||
const [additionalSessions, setAdditionalSessions] = useState({});
|
const [additionalSessions, setAdditionalSessions] = useState({});
|
||||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
||||||
@@ -76,10 +76,6 @@ function Sidebar({
|
|||||||
const [editingSessionName, setEditingSessionName] = useState('');
|
const [editingSessionName, setEditingSessionName] = useState('');
|
||||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||||
const [searchFilter, setSearchFilter] = 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
|
// TaskMaster context
|
||||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
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 toggleProject = (projectName) => {
|
||||||
const newExpanded = new Set();
|
const newExpanded = new Set();
|
||||||
@@ -569,10 +448,27 @@ function Sidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
className="h-full flex flex-col bg-card md:select-none"
|
{/* Project Creation Wizard Modal - Rendered via Portal at document root for full-screen on mobile */}
|
||||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
{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 */}
|
{/* Header */}
|
||||||
<div className="md:p-4 md:border-b md:border-border">
|
<div className="md:p-4 md:border-b md:border-border">
|
||||||
{/* Desktop Header */}
|
{/* Desktop Header */}
|
||||||
@@ -646,263 +542,7 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 */}
|
{/* Search Filter and Actions */}
|
||||||
{projects.length > 0 && !isLoading && (
|
{projects.length > 0 && !isLoading && (
|
||||||
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
|
<div className="px-3 md:px-4 py-2 border-b border-border space-y-2">
|
||||||
@@ -1691,6 +1331,7 @@ function Sidebar({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,14 @@ export const AuthProvider = ({ children }) => {
|
|||||||
|
|
||||||
const checkAuthStatus = async () => {
|
const checkAuthStatus = async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log('[AuthContext] Checking auth status...');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Check if system needs setup
|
// Check if system needs setup
|
||||||
const statusResponse = await api.auth.status();
|
const statusResponse = await api.auth.status();
|
||||||
const statusData = await statusResponse.json();
|
const statusData = await statusResponse.json();
|
||||||
|
console.log('[AuthContext] Status response:', statusData);
|
||||||
|
|
||||||
if (statusData.needsSetup) {
|
if (statusData.needsSetup) {
|
||||||
setNeedsSetup(true);
|
setNeedsSetup(true);
|
||||||
@@ -70,9 +72,10 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth status check failed:', error);
|
console.error('[AuthContext] Auth status check failed:', error);
|
||||||
setError('Failed to check authentication status');
|
setError('Failed to check authentication status');
|
||||||
} finally {
|
} finally {
|
||||||
|
console.log('[AuthContext] Auth check complete, isLoading=false');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--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%;
|
--ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
}),
|
}),
|
||||||
|
createWorkspace: (workspaceData) =>
|
||||||
|
authenticatedFetch('/api/projects/create-workspace', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(workspaceData),
|
||||||
|
}),
|
||||||
readFile: (projectName, filePath) =>
|
readFile: (projectName, filePath) =>
|
||||||
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
|
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
|
||||||
saveFile: (projectName, filePath, content) =>
|
saveFile: (projectName, filePath, content) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user