Compare commits

..

17 Commits

Author SHA1 Message Date
simosmik
2d06cae0ca Release 1.15.0 2026-01-28 10:06:50 +00:00
Haileyesus
14fb81586c Merge pull request #320 from EricBlanquer/fix/new-project-folder-selection
feat: enhance project creation wizard - folder browser fixes and git clone improvements
2026-01-27 22:58:56 +03:00
viper151
4d2b592ec6 Update Router to use dynamic basename 2026-01-26 15:38:00 +01:00
viper151
4957220a05 Remove base path configuration from Vite config 2026-01-26 13:39:24 +01:00
viper151
3debc3a249 Reorder return statements for claude commands 2026-01-26 13:34:38 +01:00
viper151
5512e2e15b Merge pull request #343 from siteboon/viper151-patch-1-1
Set base path for Vite configuration
2026-01-26 11:58:22 +01:00
viper151
1b42dba902 Set base path for Vite configuration 2026-01-26 11:56:05 +01:00
Eric Blanquer​
ede56ad81b fix: simplify project wizard labels for clarity 2026-01-26 03:25:43 +01:00
Eric Blanquer
36094fb73f fix: encode Windows paths correctly in addProjectManually
The regex only replaced forward slashes, causing Windows paths like
C:\Users\Eric\my_project to remain unchanged instead of being encoded
to C--Users-Eric-my-project. This caused API routes to fail.
2026-01-26 03:09:22 +01:00
Eric Blanquer​
57828653bf fix: handle EEXIST race and prevent data loss on clone 2026-01-26 03:09:22 +01:00
Eric Blanquer​
8ef0951901 fix: update i18n translations for clone progress and SSH detection 2026-01-26 03:09:22 +01:00
Eric Blanquer​
ab50c5c1a8 fix: address CodeRabbit review comments
- Add path validation to /api/create-folder endpoint (forbidden system dirs)
- Fix repo name extraction to handle trailing slashes in URLs
- Add cleanup of partial clone directory on SSE clone failure
- Remove dead code branch (unreachable message)
- Fix Windows path separator detection in createNewFolder
- Replace alert() with setError() for consistent error handling
- Detect ssh:// URLs in addition to git@ for SSH key display
- Show create folder button for both workspace types
2026-01-26 03:09:22 +01:00
Eric Blanquer​
6726e8f44e feat: enhance project creation wizard with folder creation and git clone progress
- Add "+" button to create new folders directly from folder browser
- Add SSE endpoint for git clone with real-time progress display
- Show clone progress (receiving objects, resolving deltas) in UI
- Detect SSH URLs and display "SSH Key" instead of "No authentication"
- Hide token section for SSH URLs (tokens only work with HTTPS)
- Fix auto-advance behavior: only auto-advance for "Existing Workspace"
- Fix various misleading UI messages
- Support auth token via query param for SSE endpoints
2026-01-26 03:09:22 +01:00
Eric Blanquer​
07f89e5240 fix: folder browser navigation issues
- Show parent directory (..) button even when folder has no subfolders
- Handle Windows backslash paths when calculating parent directory
- Prevent navigation to Windows drive root (C:\) from breaking
2026-01-26 03:09:22 +01:00
Eric Blanquer​
8a675a713b fix: use resolved path from API in folder browser
When selecting home folder in New Project wizard, ~ was being used
instead of /home/<user>, causing "Workspace path does not exist" error.
Now uses the resolved absolute path returned by the browse-filesystem API.
2026-01-26 03:09:22 +01:00
simosmik
5724c11253 fix:disabling zoom on focus on mobile iframe 2026-01-26 00:29:56 +00:00
simosmik
c7b9976986 fix: text selection on login for claude 2026-01-26 00:20:19 +00:00
15 changed files with 496 additions and 115 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.14.0", "version": "1.15.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.14.0", "version": "1.15.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.29", "@anthropic-ai/claude-agent-sdk": "^0.1.29",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@siteboon/claude-code-ui", "name": "@siteboon/claude-code-ui",
"version": "1.14.0", "version": "1.15.0",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"type": "module", "type": "module",
"main": "server/index.js", "main": "server/index.js",

View File

@@ -70,7 +70,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 projectsRoutes, { FORBIDDEN_PATHS } from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js'; import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js'; import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js'; import codexRoutes from './routes/codex.js';
@@ -550,6 +550,55 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
} }
}); });
app.post('/api/create-folder', authenticateToken, async (req, res) => {
try {
const { path: folderPath } = req.body;
if (!folderPath) {
return res.status(400).json({ error: 'Path is required' });
}
const homeDir = os.homedir();
const targetPath = path.resolve(folderPath.replace('~', homeDir));
const normalizedPath = path.normalize(targetPath);
const comparePath = normalizedPath.toLowerCase();
const forbiddenLower = FORBIDDEN_PATHS.map(p => p.toLowerCase());
if (forbiddenLower.includes(comparePath) || comparePath === '/') {
return res.status(403).json({ error: 'Cannot create folders in system directories' });
}
for (const forbidden of forbiddenLower) {
if (comparePath.startsWith(forbidden + path.sep)) {
if (forbidden === '/var' && (comparePath.startsWith('/var/tmp') || comparePath.startsWith('/var/folders'))) {
continue;
}
return res.status(403).json({ error: `Cannot create folders in system directory: ${forbidden}` });
}
}
const parentDir = path.dirname(targetPath);
try {
await fs.promises.access(parentDir);
} catch (err) {
return res.status(404).json({ error: 'Parent directory does not exist' });
}
try {
await fs.promises.access(targetPath);
return res.status(409).json({ error: 'Folder already exists' });
} catch (err) {
// Folder doesn't exist, which is what we want
}
try {
await fs.promises.mkdir(targetPath, { recursive: false });
res.json({ success: true, path: targetPath });
} catch (mkdirError) {
if (mkdirError.code === 'EEXIST') {
return res.status(409).json({ error: 'Folder already exists' });
}
throw mkdirError;
}
} catch (error) {
console.error('Error creating folder:', error);
res.status(500).json({ error: 'Failed to create folder' });
}
});
// Read file content endpoint // Read file content endpoint
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => { app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try { try {

View File

@@ -37,7 +37,12 @@ const authenticateToken = async (req, res, next) => {
// Normal OSS JWT validation // Normal OSS JWT validation
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
// Also check query param for SSE endpoints (EventSource can't set headers)
if (!token && req.query.token) {
token = req.query.token;
}
if (!token) { if (!token) {
return res.status(401).json({ error: 'Access denied. No token provided.' }); return res.status(401).json({ error: 'Access denied. No token provided.' });

View File

@@ -1095,7 +1095,7 @@ async function addProjectManually(projectPath, displayName = null) {
} }
// Generate project name (encode path for use as directory name) // Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-'); const projectName = absolutePath.replace(/[\\/:\s~_]/g, '-');
// Check if project already exists in config // Check if project already exists in config
const config = await loadProjectConfig(); const config = await loadProjectConfig();

View File

@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
const router = express.Router(); const router = express.Router();
function sanitizeGitError(message, token) {
if (!message || !token) return message;
return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '***');
}
// Configure allowed workspace root (defaults to user's home directory) // Configure allowed workspace root (defaults to user's home directory)
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir(); const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
// System-critical paths that should never be used as workspace directories // System-critical paths that should never be used as workspace directories
const FORBIDDEN_PATHS = [ export const FORBIDDEN_PATHS = [
// Unix
'/', '/',
'/etc', '/etc',
'/bin', '/bin',
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
'/lib64', '/lib64',
'/opt', '/opt',
'/tmp', '/tmp',
'/run' '/run',
// Windows
'C:\\Windows',
'C:\\Program Files',
'C:\\Program Files (x86)',
'C:\\ProgramData',
'C:\\System Volume Information',
'C:\\$Recycle.Bin'
]; ];
/** /**
@@ -212,20 +225,7 @@ router.post('/create-workspace', async (req, res) => {
// Handle new workspace creation // Handle new workspace creation
if (workspaceType === 'new') { if (workspaceType === 'new') {
// Check if path already exists // Create the directory if it doesn't exist
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 }); await fs.mkdir(absolutePath, { recursive: true });
// If GitHub URL is provided, clone the repository // If GitHub URL is provided, clone the repository
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
githubToken = newGithubToken; githubToken = newGithubToken;
} }
// Clone the repository // Extract repo name from URL for the clone destination
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
// Check if clone destination already exists to prevent data loss
try { try {
await cloneGitHubRepository(githubUrl, absolutePath, githubToken); await fs.access(clonePath);
return res.status(409).json({
error: 'Directory already exists',
details: `The destination path "${clonePath}" already exists. Please choose a different location or remove the existing directory.`
});
} catch (err) {
// Directory doesn't exist, which is what we want
}
// Clone the repository into a subfolder
try {
await cloneGitHubRepository(githubUrl, clonePath, githubToken);
} catch (error) { } catch (error) {
// Clean up created directory on failure // Only clean up if clone created partial data (check if dir exists and is empty or partial)
try { try {
await fs.rm(absolutePath, { recursive: true, force: true }); const stats = await fs.stat(clonePath);
if (stats.isDirectory()) {
await fs.rm(clonePath, { recursive: true, force: true });
}
} catch (cleanupError) { } catch (cleanupError) {
console.error('Failed to clean up directory after clone failure:', cleanupError); // Directory doesn't exist or cleanup failed - ignore
// Continue to throw original error
} }
throw new Error(`Failed to clone repository: ${error.message}`); throw new Error(`Failed to clone repository: ${error.message}`);
} }
// Add the cloned repo path to the project list
const project = await addProjectManually(clonePath);
return res.json({
success: true,
project,
message: 'New workspace created and repository cloned successfully'
});
} }
// Add the new workspace to the project list // Add the new workspace to the project list (no clone)
const project = await addProjectManually(absolutePath); const project = await addProjectManually(absolutePath);
return res.json({ return res.json({
success: true, success: true,
project, project,
message: githubUrl message: 'New workspace created successfully'
? 'New workspace created and repository cloned successfully'
: 'New workspace created successfully'
}); });
} }
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
return null; return null;
} }
/**
* Clone repository with progress streaming (SSE)
* GET /api/projects/clone-progress
*/
router.get('/clone-progress', async (req, res) => {
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendEvent = (type, data) => {
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
try {
if (!workspacePath || !githubUrl) {
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
res.end();
return;
}
const validation = await validateWorkspacePath(workspacePath);
if (!validation.valid) {
sendEvent('error', { message: validation.error });
res.end();
return;
}
const absolutePath = validation.resolvedPath;
await fs.mkdir(absolutePath, { recursive: true });
let githubToken = null;
if (githubTokenId) {
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
if (!token) {
await fs.rm(absolutePath, { recursive: true, force: true });
sendEvent('error', { message: 'GitHub token not found' });
res.end();
return;
}
githubToken = token.github_token;
} else if (newGithubToken) {
githubToken = newGithubToken;
}
const normalizedUrl = githubUrl.replace(/\/+$/, '').replace(/\.git$/, '');
const repoName = normalizedUrl.split('/').pop() || 'repository';
const clonePath = path.join(absolutePath, repoName);
// Check if clone destination already exists to prevent data loss
try {
await fs.access(clonePath);
sendEvent('error', { message: `Directory "${repoName}" already exists. Please choose a different location or remove the existing directory.` });
res.end();
return;
} catch (err) {
// Directory doesn't exist, which is what we want
}
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
// SSH URL or invalid - use as-is
}
}
sendEvent('progress', { message: `Cloning into '${repoName}'...` });
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0'
}
});
let lastError = '';
gitProcess.stdout.on('data', (data) => {
const message = data.toString().trim();
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.stderr.on('data', (data) => {
const message = data.toString().trim();
lastError = message;
if (message) {
sendEvent('progress', { message });
}
});
gitProcess.on('close', async (code) => {
if (code === 0) {
try {
const project = await addProjectManually(clonePath);
sendEvent('complete', { project, message: 'Repository cloned successfully' });
} catch (error) {
sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });
}
} else {
const sanitizedError = sanitizeGitError(lastError, githubToken);
let errorMessage = 'Git clone failed';
if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {
errorMessage = 'Authentication failed. Please check your credentials.';
} else if (lastError.includes('Repository not found')) {
errorMessage = 'Repository not found. Please check the URL and ensure you have access.';
} else if (lastError.includes('already exists')) {
errorMessage = 'Directory already exists';
} else if (sanitizedError) {
errorMessage = sanitizedError;
}
try {
await fs.rm(clonePath, { recursive: true, force: true });
} catch (cleanupError) {
console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));
}
sendEvent('error', { message: errorMessage });
}
res.end();
});
gitProcess.on('error', (error) => {
if (error.code === 'ENOENT') {
sendEvent('error', { message: 'Git is not installed or not in PATH' });
} else {
sendEvent('error', { message: error.message });
}
res.end();
});
req.on('close', () => {
gitProcess.kill();
});
} catch (error) {
sendEvent('error', { message: error.message });
res.end();
}
});
/** /**
* Helper function to clone a GitHub repository * Helper function to clone a GitHub repository
*/ */
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) { function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Parse GitHub URL and inject token if provided
let cloneUrl = githubUrl; let cloneUrl = githubUrl;
if (githubToken) { if (githubToken) {
try { try {
const url = new URL(githubUrl); const url = new URL(githubUrl);
// Format: https://TOKEN@github.com/user/repo.git
url.username = githubToken; url.username = githubToken;
url.password = ''; url.password = '';
cloneUrl = url.toString(); cloneUrl = url.toString();
} catch (error) { } catch (error) {
return reject(new Error('Invalid GitHub URL format')); // SSH URL - use as-is
} }
} }
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], { const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts GIT_TERMINAL_PROMPT: '0'
} }
}); });
@@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
if (code === 0) { if (code === 0) {
resolve({ stdout, stderr }); resolve({ stdout, stderr });
} else { } else {
// Parse git error messages to provide helpful feedback
let errorMessage = 'Git clone failed'; let errorMessage = 'Git clone failed';
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) { if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {

View File

@@ -990,7 +990,7 @@ function App() {
<TasksSettingsProvider> <TasksSettingsProvider>
<TaskMasterProvider> <TaskMasterProvider>
<ProtectedRoute> <ProtectedRoute>
<Router> <Router basename={window.__ROUTER_BASENAME__ || ''}>
<Routes> <Routes>
<Route path="/" element={<AppContent />} /> <Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} /> <Route path="/session/:sessionId" element={<AppContent />} />

View File

@@ -5590,7 +5590,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl" className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"
> >
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-sm sm:text-base leading-[21px] sm:leading-6 whitespace-pre-wrap break-words"> <div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-base leading-6 whitespace-pre-wrap break-words">
{renderInputWithMentions(input)} {renderInputWithMentions(input)}
</div> </div>
</div> </div>
@@ -5619,7 +5619,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}} }}
placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude') })} placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude') })}
disabled={isLoading} disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base leading-[21px] sm:leading-6 transition-all duration-200" className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-base leading-6 transition-all duration-200"
style={{ height: '50px' }} style={{ height: '50px' }}
/> />
{/* Image upload button */} {/* Image upload button */}

View File

@@ -31,13 +31,13 @@ function LoginModal({
switch (provider) { switch (provider) {
case 'claude': case 'claude':
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions'; return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
case 'cursor': case 'cursor':
return 'cursor-agent login'; return 'cursor-agent login';
case 'codex': case 'codex':
return isPlatform ? 'codex login --device-auth' : 'codex login'; return isPlatform ? 'codex login --device-auth' : 'codex login';
default: default:
return isAuthenticated ? 'claude /login --dangerously-skip-permissions' : 'claude setup-token --dangerously-skip-permissions'; return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
} }
}; };

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react'; import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { api } from '../utils/api'; import { api } from '../utils/api';
@@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const [browserFolders, setBrowserFolders] = useState([]); const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false); const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false); const [showHiddenFolders, setShowHiddenFolders] = useState(false);
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [creatingFolder, setCreatingFolder] = useState(false);
const [cloneProgress, setCloneProgress] = useState('');
// Load available GitHub tokens when needed // Load available GitHub tokens when needed
useEffect(() => { useEffect(() => {
@@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const data = await response.json(); const data = await response.json();
if (data.suggestions) { if (data.suggestions) {
// Filter suggestions based on the input // Filter suggestions based on the input, excluding exact match
const filtered = data.suggestions.filter(s => const filtered = data.suggestions.filter(s =>
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
s.path.toLowerCase() !== inputPath.toLowerCase()
); );
setPathSuggestions(filtered.slice(0, 5)); setPathSuggestions(filtered.slice(0, 5));
setShowPathDropdown(filtered.length > 0); setShowPathDropdown(filtered.length > 0);
@@ -118,24 +123,62 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const handleCreate = async () => { const handleCreate = async () => {
setIsCreating(true); setIsCreating(true);
setError(null); setError(null);
setCloneProgress('');
try { try {
if (workspaceType === 'new' && githubUrl) {
const params = new URLSearchParams({
path: workspacePath.trim(),
githubUrl: githubUrl.trim(),
});
if (tokenMode === 'stored' && selectedGithubToken) {
params.append('githubTokenId', selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
params.append('newGithubToken', newGithubToken.trim());
}
const token = localStorage.getItem('auth-token');
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
await new Promise((resolve, reject) => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
setCloneProgress(data.message);
} else if (data.type === 'complete') {
eventSource.close();
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
resolve();
} else if (data.type === 'error') {
eventSource.close();
reject(new Error(data.message));
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
reject(new Error('Connection lost during clone'));
};
});
return;
}
const payload = { const payload = {
workspaceType, workspaceType,
path: workspacePath.trim(), 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 response = await api.createWorkspace(payload);
const data = await response.json(); const data = await response.json();
@@ -143,7 +186,6 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
throw new Error(data.error || t('projectWizard.errors.failedToCreate')); throw new Error(data.error || t('projectWizard.errors.failedToCreate'));
} }
// Success!
if (onProjectCreated) { if (onProjectCreated) {
onProjectCreated(data.project); onProjectCreated(data.project);
} }
@@ -170,9 +212,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const loadBrowserFolders = async (path) => { const loadBrowserFolders = async (path) => {
try { try {
setLoadingFolders(true); setLoadingFolders(true);
setBrowserCurrentPath(path);
const response = await api.browseFilesystem(path); const response = await api.browseFilesystem(path);
const data = await response.json(); const data = await response.json();
setBrowserCurrentPath(data.path || path);
setBrowserFolders(data.suggestions || []); setBrowserFolders(data.suggestions || []);
} catch (error) { } catch (error) {
console.error('Error loading folders:', error); console.error('Error loading folders:', error);
@@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
await loadBrowserFolders(folderPath); await loadBrowserFolders(folderPath);
}; };
const createNewFolder = async () => {
if (!newFolderName.trim()) return;
setCreatingFolder(true);
setError(null);
try {
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
const response = await api.createFolder(folderPath);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
}
setNewFolderName('');
setShowNewFolderInput(false);
await loadBrowserFolders(data.path || folderPath);
} catch (error) {
console.error('Error creating folder:', error);
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
} finally {
setCreatingFolder(false);
}
};
return ( 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="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"> <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">
@@ -388,8 +453,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</p> </p>
</div> </div>
{/* GitHub Token (only if GitHub URL is provided) */} {/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
{githubUrl && ( {githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700"> <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"> <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" /> <Key className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
@@ -551,6 +616,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` ? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken : tokenMode === 'new' && newGithubToken
? t('projectWizard.step3.usingProvidedToken') ? t('projectWizard.step3.usingProvidedToken')
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
? t('projectWizard.step3.sshKey', 'SSH Key')
: t('projectWizard.step3.noAuthentication')} : t('projectWizard.step3.noAuthentication')}
</span> </span>
</div> </div>
@@ -560,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div> </div>
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800"> <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"> {isCreating && cloneProgress ? (
{workspaceType === 'existing' <div className="space-y-2">
? t('projectWizard.step3.existingInfo') <p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
: githubUrl <code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
? t('projectWizard.step3.newWithClone') {cloneProgress}
: t('projectWizard.step3.newEmpty')} </code>
</p> </div>
) : (
<p className="text-sm text-blue-800 dark:text-blue-200">
{workspaceType === 'existing'
? t('projectWizard.step3.existingInfo')
: githubUrl
? t('projectWizard.step3.newWithClone')
: t('projectWizard.step3.newEmpty')}
</p>
)}
</div> </div>
</div> </div>
)} )}
@@ -596,7 +672,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{isCreating ? ( {isCreating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
{t('projectWizard.buttons.creating')} {githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')}
</> </>
) : step === 3 ? ( ) : step === 3 ? (
<> <>
@@ -639,6 +715,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
> >
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />} {showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</button> </button>
<button
onClick={() => setShowNewFolderInput(!showNewFolderInput)}
className={`p-2 rounded-md transition-colors ${
showNewFolderInput
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="Create new folder"
>
<Plus className="w-5 h-5" />
</button>
<button <button
onClick={() => setShowFolderBrowser(false)} onClick={() => setShowFolderBrowser(false)}
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" 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"
@@ -648,23 +735,67 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</div> </div>
</div> </div>
{/* New Folder Input */}
{showNewFolderInput && (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-center gap-2">
<Input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="New folder name"
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') createNewFolder();
if (e.key === 'Escape') {
setShowNewFolderInput(false);
setNewFolderName('');
}
}}
autoFocus
/>
<Button
size="sm"
onClick={createNewFolder}
disabled={!newFolderName.trim() || creatingFolder}
>
{creatingFolder ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Create'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
</div>
</div>
)}
{/* Folder List */} {/* Folder List */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? ( {loadingFolders ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" /> <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</div> </div>
) : browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No folders found
</div>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{/* Parent Directory */} {/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && ( {browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
<button <button
onClick={() => { onClick={() => {
const parentPath = browserCurrentPath.substring(0, browserCurrentPath.lastIndexOf('/')) || '/'; const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\'));
let parentPath;
if (lastSlash <= 0) {
parentPath = '/';
} else if (lastSlash === 2 && /^[A-Za-z]:/.test(browserCurrentPath)) {
parentPath = browserCurrentPath.substring(0, 3);
} else {
parentPath = browserCurrentPath.substring(0, lastSlash);
}
navigateToFolder(parentPath); navigateToFolder(parentPath);
}} }}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3" className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
@@ -675,28 +806,34 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
)} )}
{/* Folders */} {/* Folders */}
{browserFolders {browserFolders.length === 0 ? (
.filter(folder => showHiddenFolders || !folder.name.startsWith('.')) <div className="text-center py-8 text-gray-500 dark:text-gray-400">
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) No subfolders found
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, true)}
className="text-xs px-3"
>
Select
</Button>
</div> </div>
))} ) : (
browserFolders
.filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((folder, index) => (
<div key={index} className="flex items-center gap-2">
<button
onClick={() => navigateToFolder(folder.path)}
className="flex-1 px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg flex items-center gap-3"
>
<FolderPlus className="w-5 h-5 text-blue-500" />
<span className="font-medium text-gray-900 dark:text-white">{folder.name}</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => selectFolder(folder.path, workspaceType === 'existing')}
className="text-xs px-3"
>
Select
</Button>
</div>
))
)}
</div> </div>
)} )}
</div> </div>
@@ -712,13 +849,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="flex items-center justify-end gap-2 p-4"> <div className="flex items-center justify-end gap-2 p-4">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowFolderBrowser(false)} onClick={() => {
setShowFolderBrowser(false);
setShowNewFolderInput(false);
setNewFolderName('');
}}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => selectFolder(browserCurrentPath, true)} onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
> >
Use this folder Use this folder
</Button> </Button>

View File

@@ -234,7 +234,7 @@ function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell
tabStopWidth: 4, tabStopWidth: 4,
windowsMode: false, windowsMode: false,
macOptionIsMeta: true, macOptionIsMeta: true,
macOptionClickForcesSelection: false, macOptionClickForcesSelection: true,
theme: { theme: {
background: '#1e1e1e', background: '#1e1e1e',
foreground: '#d4d4d4', foreground: '#d4d4d4',

View File

@@ -136,14 +136,14 @@
}, },
"step2": { "step2": {
"existingPath": "Workspace Path", "existingPath": "Workspace Path",
"newPath": "Where should the workspace be created?", "newPath": "Workspace Path",
"existingPlaceholder": "/path/to/existing/workspace", "existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace", "newPlaceholder": "/path/to/new/workspace",
"existingHelp": "Full path to your existing workspace directory", "existingHelp": "Full path to your existing workspace directory",
"newHelp": "Full path where the new workspace will be created", "newHelp": "Full path to your workspace directory",
"githubUrl": "GitHub URL (Optional)", "githubUrl": "GitHub URL (Optional)",
"githubPlaceholder": "https://github.com/username/repository", "githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Leave empty to create an empty workspace, or provide a GitHub URL to clone", "githubHelp": "Optional: provide a GitHub URL to clone a repository",
"githubAuth": "GitHub Authentication (Optional)", "githubAuth": "GitHub Authentication (Optional)",
"githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.", "githubAuthHelp": "Only required for private repositories. Public repos can be cloned without authentication.",
"loadingTokens": "Loading stored tokens...", "loadingTokens": "Loading stored tokens...",
@@ -170,21 +170,25 @@
"usingStoredToken": "Using stored token:", "usingStoredToken": "Using stored token:",
"usingProvidedToken": "Using provided token", "usingProvidedToken": "Using provided token",
"noAuthentication": "No authentication", "noAuthentication": "No authentication",
"sshKey": "SSH Key",
"existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.", "existingInfo": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"newWithClone": "A new workspace will be created and the repository will be cloned from GitHub.", "newWithClone": "The repository will be cloned from this folder.",
"newEmpty": "An empty workspace directory will be created at the specified path." "newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.",
"cloningRepository": "Cloning repository..."
}, },
"buttons": { "buttons": {
"cancel": "Cancel", "cancel": "Cancel",
"back": "Back", "back": "Back",
"next": "Next", "next": "Next",
"createProject": "Create Project", "createProject": "Create Project",
"creating": "Creating..." "creating": "Creating...",
"cloning": "Cloning..."
}, },
"errors": { "errors": {
"selectType": "Please select whether you have an existing workspace or want to create a new one", "selectType": "Please select whether you have an existing workspace or want to create a new one",
"providePath": "Please provide a workspace path", "providePath": "Please provide a workspace path",
"failedToCreate": "Failed to create workspace" "failedToCreate": "Failed to create workspace",
"failedToCreateFolder": "Failed to create folder"
} }
}, },
"versionUpdate": { "versionUpdate": {

View File

@@ -136,14 +136,14 @@
}, },
"step2": { "step2": {
"existingPath": "工作区路径", "existingPath": "工作区路径",
"newPath": "应该在哪里创建工作区", "newPath": "工作区路径",
"existingPlaceholder": "/path/to/existing/workspace", "existingPlaceholder": "/path/to/existing/workspace",
"newPlaceholder": "/path/to/new/workspace", "newPlaceholder": "/path/to/new/workspace",
"existingHelp": "您现有工作区目录的完整路径", "existingHelp": "您现有工作区目录的完整路径",
"newHelp": "将创建新工作区的完整路径", "newHelp": "工作区目录的完整路径",
"githubUrl": "GitHub URL可选", "githubUrl": "GitHub URL可选",
"githubPlaceholder": "https://github.com/username/repository", "githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "留空以创建空工作区,或提供 GitHub URL 以克隆", "githubHelp": "可选:提供 GitHub URL 以克隆仓库",
"githubAuth": "GitHub 身份验证(可选)", "githubAuth": "GitHub 身份验证(可选)",
"githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。", "githubAuthHelp": "仅私有仓库需要。公共仓库无需身份验证即可克隆。",
"loadingTokens": "正在加载已保存的令牌...", "loadingTokens": "正在加载已保存的令牌...",
@@ -170,21 +170,25 @@
"usingStoredToken": "使用已保存的令牌:", "usingStoredToken": "使用已保存的令牌:",
"usingProvidedToken": "使用提供的令牌", "usingProvidedToken": "使用提供的令牌",
"noAuthentication": "无身份验证", "noAuthentication": "无身份验证",
"sshKey": "SSH 密钥",
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。", "existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"newWithClone": "将创建新工作区,并从 GitHub 克隆仓库。", "newWithClone": "仓库将从此文件夹克隆。",
"newEmpty": "将在指定路径创建一个空的工作区目录。" "newEmpty": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"cloningRepository": "正在克隆仓库..."
}, },
"buttons": { "buttons": {
"cancel": "取消", "cancel": "取消",
"back": "返回", "back": "返回",
"next": "下一步", "next": "下一步",
"createProject": "创建项目", "createProject": "创建项目",
"creating": "创建中..." "creating": "创建中...",
"cloning": "正在克隆..."
}, },
"errors": { "errors": {
"selectType": "请选择您已有现有工作区还是想创建新工作区", "selectType": "请选择您已有现有工作区还是想创建新工作区",
"providePath": "请提供工作区路径", "providePath": "请提供工作区路径",
"failedToCreate": "创建工作区失败" "failedToCreate": "创建工作区失败",
"failedToCreateFolder": "创建文件夹失败"
} }
}, },
"versionUpdate": { "versionUpdate": {

View File

@@ -158,6 +158,12 @@ export const api = {
return authenticatedFetch(`/api/browse-filesystem?${params}`); return authenticatedFetch(`/api/browse-filesystem?${params}`);
}, },
createFolder: (folderPath) =>
authenticatedFetch('/api/create-folder', {
method: 'POST',
body: JSON.stringify({ path: folderPath }),
}),
// User endpoints // User endpoints
user: { user: {
gitConfig: () => authenticatedFetch('/api/user/git-config'), gitConfig: () => authenticatedFetch('/api/user/git-config'),

View File

@@ -45,4 +45,4 @@ export default defineConfig(({ command, mode }) => {
} }
} }
} }
}) })