Compare commits

..

24 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
simosmik
f16e3e763d Release 1.14.0 2026-01-26 00:11:12 +00:00
simosmik
477bc404b0 fix: switch login mechanism for claude code 2026-01-25 23:55:05 +00:00
viper151
ae5a21cd6e Merge pull request #337 from timbot/main
fix: prevent codex spawn error when codex CLI is not installed
2026-01-25 23:18:57 +01:00
viper151
b2c69d6ea8 Merge branch 'main' into main 2026-01-25 23:17:29 +01:00
viper151
8825baf5b4 Update codex.js 2026-01-25 23:17:08 +01:00
viper151
0d1a3df1f7 Merge pull request #318 from siteboon/fix/session-streamed-to-another-chat
Fix/session streamed to another chat
2026-01-25 23:15:31 +01:00
Tim Smith
dab089b29f fix: prevent codex spawn error when codex CLI is not installed
Return success with empty servers array from the config read endpoint
when no config file exists, so the frontend doesn't fall through to
the CLI list endpoint which attempts to spawn the codex binary.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 11:21:43 -08:00
17 changed files with 507 additions and 119 deletions

4
package-lock.json generated
View File

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

View File

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

View File

@@ -70,7 +70,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import projectsRoutes from './routes/projects.js';
import projectsRoutes, { FORBIDDEN_PATHS } from './routes/projects.js';
import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.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
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {

View File

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

View File

@@ -1095,7 +1095,7 @@ async function addProjectManually(projectPath, displayName = null) {
}
// 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
const config = await loadProjectConfig();

View File

@@ -262,8 +262,7 @@ router.get('/mcp/config/read', async (req, res) => {
}
if (!configData) {
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
}
return res.json({ success: true, configPath, servers: [] }); }
const servers = [];

View File

@@ -7,11 +7,17 @@ import { addProjectManually } from '../projects.js';
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)
const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();
// System-critical paths that should never be used as workspace directories
const FORBIDDEN_PATHS = [
export const FORBIDDEN_PATHS = [
// Unix
'/',
'/etc',
'/bin',
@@ -27,7 +33,14 @@ const FORBIDDEN_PATHS = [
'/lib64',
'/opt',
'/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
if (workspaceType === 'new') {
// Check if path already exists
try {
await fs.access(absolutePath);
return res.status(400).json({
error: 'Path already exists. Please choose a different path or use "existing workspace" option.'
});
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
// Path doesn't exist - good, we can create it
}
// Create the directory
// Create the directory if it doesn't exist
await fs.mkdir(absolutePath, { recursive: true });
// If GitHub URL is provided, clone the repository
@@ -246,30 +246,55 @@ router.post('/create-workspace', async (req, res) => {
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 {
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) {
// Clean up created directory on failure
// Only clean up if clone created partial data (check if dir exists and is empty or partial)
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) {
console.error('Failed to clean up directory after clone failure:', cleanupError);
// Continue to throw original error
// Directory doesn't exist or cleanup failed - ignore
}
throw new Error(`Failed to clone repository: ${error.message}`);
}
// Add the cloned repo path to the project list
const project = await addProjectManually(clonePath);
return res.json({
success: true,
project,
message: 'New workspace created and repository cloned successfully'
});
}
// Add the new workspace to the project list
// Add the new workspace to the project list (no clone)
const project = await addProjectManually(absolutePath);
return res.json({
success: true,
project,
message: githubUrl
? 'New workspace created and repository cloned successfully'
: 'New workspace created successfully'
message: 'New workspace created successfully'
});
}
@@ -305,31 +330,179 @@ async function getGithubTokenById(tokenId, userId) {
return null;
}
/**
* Clone repository with progress streaming (SSE)
* GET /api/projects/clone-progress
*/
router.get('/clone-progress', async (req, res) => {
const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendEvent = (type, data) => {
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
};
try {
if (!workspacePath || !githubUrl) {
sendEvent('error', { message: 'workspacePath and githubUrl are required' });
res.end();
return;
}
const validation = await validateWorkspacePath(workspacePath);
if (!validation.valid) {
sendEvent('error', { message: validation.error });
res.end();
return;
}
const absolutePath = validation.resolvedPath;
await fs.mkdir(absolutePath, { recursive: true });
let githubToken = null;
if (githubTokenId) {
const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);
if (!token) {
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
*/
function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
return new Promise((resolve, reject) => {
// Parse GitHub URL and inject token if provided
let cloneUrl = githubUrl;
if (githubToken) {
try {
const url = new URL(githubUrl);
// Format: https://TOKEN@github.com/user/repo.git
url.username = githubToken;
url.password = '';
cloneUrl = url.toString();
} catch (error) {
return reject(new Error('Invalid GitHub URL format'));
// SSH URL - use as-is
}
}
const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
GIT_TERMINAL_PROMPT: '0'
}
});
@@ -348,7 +521,6 @@ function cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {
if (code === 0) {
resolve({ stdout, stderr });
} else {
// Parse git error messages to provide helpful feedback
let errorMessage = 'Git clone failed';
if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {

View File

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

View File

@@ -4975,7 +4975,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="flex flex-col items-center justify-center h-full gap-3">
<ClaudeLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Claude</p>
<p className="font-semibold text-gray-900 dark:text-white">Claude Code</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
</div>
</div>
@@ -5590,7 +5590,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
aria-hidden="true"
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)}
</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') })}
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' }}
/>
{/* Image upload button */}

View File

@@ -11,6 +11,7 @@ import StandaloneShell from './StandaloneShell';
* @param {Object} props.project - Project object containing name and path information
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
* @param {string} props.customCommand - Optional custom command to override defaults
* @param {boolean} props.isAuthenticated - Whether user is already authenticated (for re-auth flow)
*/
function LoginModal({
isOpen,
@@ -18,7 +19,8 @@ function LoginModal({
provider = 'claude',
project,
onComplete,
customCommand
customCommand,
isAuthenticated = false
}) {
if (!isOpen) return null;
@@ -29,13 +31,13 @@ function LoginModal({
switch (provider) {
case 'claude':
return 'claude setup-token --dangerously-skip-permissions';
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
case 'cursor':
return 'cursor-agent login';
case 'codex':
return isPlatform ? 'codex login --device-auth' : 'codex login';
default:
return '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 { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } from 'lucide-react';
import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff, Plus } from 'lucide-react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { api } from '../utils/api';
@@ -30,6 +30,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const [browserFolders, setBrowserFolders] = useState([]);
const [loadingFolders, setLoadingFolders] = useState(false);
const [showHiddenFolders, setShowHiddenFolders] = useState(false);
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [creatingFolder, setCreatingFolder] = useState(false);
const [cloneProgress, setCloneProgress] = useState('');
// Load available GitHub tokens when needed
useEffect(() => {
@@ -78,9 +82,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const data = await response.json();
if (data.suggestions) {
// Filter suggestions based on the input
// Filter suggestions based on the input, excluding exact match
const filtered = data.suggestions.filter(s =>
s.path.toLowerCase().startsWith(inputPath.toLowerCase())
s.path.toLowerCase().startsWith(inputPath.toLowerCase()) &&
s.path.toLowerCase() !== inputPath.toLowerCase()
);
setPathSuggestions(filtered.slice(0, 5));
setShowPathDropdown(filtered.length > 0);
@@ -118,24 +123,62 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const handleCreate = async () => {
setIsCreating(true);
setError(null);
setCloneProgress('');
try {
if (workspaceType === 'new' && githubUrl) {
const params = new URLSearchParams({
path: workspacePath.trim(),
githubUrl: githubUrl.trim(),
});
if (tokenMode === 'stored' && selectedGithubToken) {
params.append('githubTokenId', selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
params.append('newGithubToken', newGithubToken.trim());
}
const token = localStorage.getItem('auth-token');
const url = `/api/projects/clone-progress?${params}${token ? `&token=${token}` : ''}`;
await new Promise((resolve, reject) => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
setCloneProgress(data.message);
} else if (data.type === 'complete') {
eventSource.close();
if (onProjectCreated) {
onProjectCreated(data.project);
}
onClose();
resolve();
} else if (data.type === 'error') {
eventSource.close();
reject(new Error(data.message));
}
} catch (e) {
console.error('Error parsing SSE event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
reject(new Error('Connection lost during clone'));
};
});
return;
}
const payload = {
workspaceType,
path: workspacePath.trim(),
};
// Add GitHub info if creating new workspace with GitHub URL
if (workspaceType === 'new' && githubUrl) {
payload.githubUrl = githubUrl.trim();
if (tokenMode === 'stored' && selectedGithubToken) {
payload.githubTokenId = parseInt(selectedGithubToken);
} else if (tokenMode === 'new' && newGithubToken) {
payload.newGithubToken = newGithubToken.trim();
}
}
const response = await api.createWorkspace(payload);
const data = await response.json();
@@ -143,7 +186,6 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
throw new Error(data.error || t('projectWizard.errors.failedToCreate'));
}
// Success!
if (onProjectCreated) {
onProjectCreated(data.project);
}
@@ -170,9 +212,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
const loadBrowserFolders = async (path) => {
try {
setLoadingFolders(true);
setBrowserCurrentPath(path);
const response = await api.browseFilesystem(path);
const data = await response.json();
setBrowserCurrentPath(data.path || path);
setBrowserFolders(data.suggestions || []);
} catch (error) {
console.error('Error loading folders:', error);
@@ -193,6 +235,29 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
await loadBrowserFolders(folderPath);
};
const createNewFolder = async () => {
if (!newFolderName.trim()) return;
setCreatingFolder(true);
setError(null);
try {
const separator = browserCurrentPath.includes('\\') ? '\\' : '/';
const folderPath = `${browserCurrentPath}${separator}${newFolderName.trim()}`;
const response = await api.createFolder(folderPath);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
}
setNewFolderName('');
setShowNewFolderInput(false);
await loadBrowserFolders(data.path || folderPath);
} catch (error) {
console.error('Error creating folder:', error);
setError(error.message || t('projectWizard.errors.failedToCreateFolder', 'Failed to create folder'));
} finally {
setCreatingFolder(false);
}
};
return (
<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">
@@ -388,8 +453,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</p>
</div>
{/* GitHub Token (only if GitHub URL is provided) */}
{githubUrl && (
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */}
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && (
<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" />
@@ -551,6 +616,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
? `${t('projectWizard.step3.usingStoredToken')} ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
: tokenMode === 'new' && newGithubToken
? t('projectWizard.step3.usingProvidedToken')
: (githubUrl.startsWith('git@') || githubUrl.startsWith('ssh://'))
? t('projectWizard.step3.sshKey', 'SSH Key')
: t('projectWizard.step3.noAuthentication')}
</span>
</div>
@@ -560,13 +627,22 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</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'
? t('projectWizard.step3.existingInfo')
: githubUrl
? t('projectWizard.step3.newWithClone')
: t('projectWizard.step3.newEmpty')}
</p>
{isCreating && cloneProgress ? (
<div className="space-y-2">
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p>
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all">
{cloneProgress}
</code>
</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>
)}
@@ -596,7 +672,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
{isCreating ? (
<>
<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 ? (
<>
@@ -639,6 +715,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
>
{showHiddenFolders ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />}
</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
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"
@@ -648,23 +735,67 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</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 */}
<div className="flex-1 overflow-y-auto p-4">
{loadingFolders ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
</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">
{/* Parent Directory */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && (
{/* Parent Directory - check for Windows root (e.g., C:\) and Unix root */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && (
<button
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);
}}
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 */}
{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, true)}
className="text-xs px-3"
>
Select
</Button>
{browserFolders.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No subfolders found
</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>
@@ -712,13 +849,17 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="flex items-center justify-end gap-2 p-4">
<Button
variant="outline"
onClick={() => setShowFolderBrowser(false)}
onClick={() => {
setShowFolderBrowser(false);
setShowNewFolderInput(false);
setNewFolderName('');
}}
>
Cancel
</Button>
<Button
variant="outline"
onClick={() => selectFolder(browserCurrentPath, true)}
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')}
>
Use this folder
</Button>

View File

@@ -1966,6 +1966,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }) {
provider={loginProvider}
project={selectedProject}
onComplete={handleLoginComplete}
isAuthenticated={
loginProvider === 'claude' ? claudeAuthStatus.authenticated :
loginProvider === 'cursor' ? cursorAuthStatus.authenticated :
loginProvider === 'codex' ? codexAuthStatus.authenticated :
false
}
/>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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