Compare commits

..

5 Commits

Author SHA1 Message Date
viper151
3ceb260115 Merge branch 'main' into viper151-patch-websockets 2026-01-26 13:36:26 +01:00
viper151
3debc3a249 Reorder return statements for claude commands 2026-01-26 13:34:38 +01:00
viper151
f4c0fe9e04 Simplify WebSocket URL for platform mode 2026-01-26 12:33:50 +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
11 changed files with 120 additions and 517 deletions

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, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js'; import projectsRoutes 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';
@@ -484,42 +484,22 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
} }
}); });
const expandWorkspacePath = (inputPath) => {
if (!inputPath) return inputPath;
if (inputPath === '~') {
return WORKSPACES_ROOT;
}
if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
return path.join(WORKSPACES_ROOT, inputPath.slice(2));
}
return inputPath;
};
// Browse filesystem endpoint for project suggestions - uses existing getFileTree // Browse filesystem endpoint for project suggestions - uses existing getFileTree
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => { app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
try { try {
const { path: dirPath } = req.query; const { path: dirPath } = req.query;
console.log('[API] Browse filesystem request for path:', dirPath);
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
// Default to home directory if no path provided // Default to home directory if no path provided
const defaultRoot = WORKSPACES_ROOT; const homeDir = os.homedir();
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot; let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
// Resolve and normalize the path // Resolve and normalize the path
targetPath = path.resolve(targetPath); targetPath = path.resolve(targetPath);
// Security check - ensure path is within allowed workspace root
const validation = await validateWorkspacePath(targetPath);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const resolvedPath = validation.resolvedPath || targetPath;
// Security check - ensure path is accessible // Security check - ensure path is accessible
try { try {
await fs.promises.access(resolvedPath); await fs.promises.access(targetPath);
const stats = await fs.promises.stat(resolvedPath); const stats = await fs.promises.stat(targetPath);
if (!stats.isDirectory()) { if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Path is not a directory' }); return res.status(400).json({ error: 'Path is not a directory' });
@@ -529,7 +509,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
} }
// Use existing getFileTree function with shallow depth (only direct children) // Use existing getFileTree function with shallow depth (only direct children)
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
// Filter only directories and format for suggestions // Filter only directories and format for suggestions
const directories = fileTree const directories = fileTree
@@ -549,13 +529,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
// Add common directories if browsing home directory // Add common directories if browsing home directory
const suggestions = []; const suggestions = [];
let resolvedWorkspaceRoot = defaultRoot; if (targetPath === homeDir) {
try {
resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);
} catch (error) {
// Use default root as-is if realpath fails
}
if (resolvedPath === resolvedWorkspaceRoot) {
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace']; const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name)); const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name)); const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
@@ -566,7 +540,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
} }
res.json({ res.json({
path: resolvedPath, path: targetPath,
suggestions: suggestions suggestions: suggestions
}); });
@@ -576,46 +550,6 @@ 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 expandedPath = expandWorkspacePath(folderPath);
const resolvedInput = path.resolve(expandedPath);
const validation = await validateWorkspacePath(resolvedInput);
if (!validation.valid) {
return res.status(403).json({ error: validation.error });
}
const targetPath = validation.resolvedPath || resolvedInput;
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,12 +37,7 @@ const authenticateToken = async (req, res, next) => {
// Normal OSS JWT validation // Normal OSS JWT validation
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN const 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(/[\\/:\s~_]/g, '-'); const projectName = absolutePath.replace(/\//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,17 +7,11 @@ 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)
export 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
export const FORBIDDEN_PATHS = [ const FORBIDDEN_PATHS = [
// Unix
'/', '/',
'/etc', '/etc',
'/bin', '/bin',
@@ -33,14 +27,7 @@ export 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'
]; ];
/** /**
@@ -48,7 +35,7 @@ export const FORBIDDEN_PATHS = [
* @param {string} requestedPath - The path to validate * @param {string} requestedPath - The path to validate
* @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>} * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}
*/ */
export async function validateWorkspacePath(requestedPath) { async function validateWorkspacePath(requestedPath) {
try { try {
// Resolve to absolute path // Resolve to absolute path
let absolutePath = path.resolve(requestedPath); let absolutePath = path.resolve(requestedPath);
@@ -225,7 +212,20 @@ router.post('/create-workspace', async (req, res) => {
// Handle new workspace creation // Handle new workspace creation
if (workspaceType === 'new') { if (workspaceType === 'new') {
// Create the directory if it doesn't exist // 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 }); await fs.mkdir(absolutePath, { recursive: true });
// If GitHub URL is provided, clone the repository // If GitHub URL is provided, clone the repository
@@ -246,55 +246,30 @@ router.post('/create-workspace', async (req, res) => {
githubToken = newGithubToken; githubToken = newGithubToken;
} }
// Extract repo name from URL for the clone destination // Clone the repository
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 fs.access(clonePath); await cloneGitHubRepository(githubUrl, absolutePath, githubToken);
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) {
// Only clean up if clone created partial data (check if dir exists and is empty or partial) // Clean up created directory on failure
try { try {
const stats = await fs.stat(clonePath); await fs.rm(absolutePath, { recursive: true, force: true });
if (stats.isDirectory()) {
await fs.rm(clonePath, { recursive: true, force: true });
}
} catch (cleanupError) { } catch (cleanupError) {
// Directory doesn't exist or cleanup failed - ignore 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}`); 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 (no clone) // Add the new workspace to the project list
const project = await addProjectManually(absolutePath); const project = await addProjectManually(absolutePath);
return res.json({ return res.json({
success: true, success: true,
project, project,
message: 'New workspace created successfully' message: githubUrl
? 'New workspace created and repository cloned successfully'
: 'New workspace created successfully'
}); });
} }
@@ -330,179 +305,31 @@ 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) {
// SSH URL - use as-is return reject(new Error('Invalid GitHub URL format'));
} }
} }
const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], { const gitProcess = spawn('git', ['clone', cloneUrl, destinationPath], {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
GIT_TERMINAL_PROMPT: '0' GIT_TERMINAL_PROMPT: '0' // Disable git password prompts
} }
}); });
@@ -521,6 +348,7 @@ 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

@@ -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, Plus } from 'lucide-react'; import { X, FolderPlus, GitBranch, Key, ChevronRight, ChevronLeft, Check, Loader2, AlertCircle, FolderOpen, Eye, EyeOff } 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,10 +30,6 @@ 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(() => {
@@ -82,10 +78,9 @@ 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, excluding exact match // Filter suggestions based on the input
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);
@@ -123,69 +118,32 @@ 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();
if (!response.ok) { if (!response.ok) {
throw new Error(data.details || 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);
} }
@@ -212,9 +170,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);
@@ -235,29 +193,6 @@ 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">
@@ -453,8 +388,8 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</p> </p>
</div> </div>
{/* GitHub Token (only for HTTPS URLs - SSH uses SSH keys) */} {/* GitHub Token (only if GitHub URL is provided) */}
{githubUrl && !githubUrl.startsWith('git@') && !githubUrl.startsWith('ssh://') && ( {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="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" />
@@ -616,8 +551,6 @@ 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>
@@ -627,22 +560,13 @@ 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">
{isCreating && cloneProgress ? ( <p className="text-sm text-blue-800 dark:text-blue-200">
<div className="space-y-2"> {workspaceType === 'existing'
<p className="text-sm font-medium text-blue-800 dark:text-blue-200">{t('projectWizard.step3.cloningRepository', 'Cloning repository...')}</p> ? t('projectWizard.step3.existingInfo')
<code className="block text-xs font-mono text-blue-700 dark:text-blue-300 whitespace-pre-wrap break-all"> : githubUrl
{cloneProgress} ? t('projectWizard.step3.newWithClone')
</code> : t('projectWizard.step3.newEmpty')}
</div> </p>
) : (
<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>
)} )}
@@ -672,7 +596,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" />
{githubUrl ? t('projectWizard.buttons.cloning', 'Cloning...') : t('projectWizard.buttons.creating')} {t('projectWizard.buttons.creating')}
</> </>
) : step === 3 ? ( ) : step === 3 ? (
<> <>
@@ -715,17 +639,6 @@ 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"
@@ -735,67 +648,23 @@ 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 - check for Windows root (e.g., C:\) and Unix root */} {/* Parent Directory */}
{browserCurrentPath !== '~' && browserCurrentPath !== '/' && !/^[A-Za-z]:\\?$/.test(browserCurrentPath) && ( {browserCurrentPath !== '~' && browserCurrentPath !== '/' && (
<button <button
onClick={() => { onClick={() => {
const lastSlash = Math.max(browserCurrentPath.lastIndexOf('/'), browserCurrentPath.lastIndexOf('\\')); const parentPath = browserCurrentPath.substring(0, 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"
@@ -806,34 +675,28 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
)} )}
{/* Folders */} {/* Folders */}
{browserFolders.length === 0 ? ( {browserFolders
<div className="text-center py-8 text-gray-500 dark:text-gray-400"> .filter(folder => showHiddenFolders || !folder.name.startsWith('.'))
No subfolders found .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>
</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>
@@ -849,17 +712,13 @@ 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={() => { onClick={() => setShowFolderBrowser(false)}
setShowFolderBrowser(false);
setShowNewFolderInput(false);
setNewFolderName('');
}}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => selectFolder(browserCurrentPath, workspaceType === 'existing')} onClick={() => selectFolder(browserCurrentPath, true)}
> >
Use this folder Use this folder
</Button> </Button>

View File

@@ -136,14 +136,14 @@
}, },
"step2": { "step2": {
"existingPath": "Workspace Path", "existingPath": "Workspace Path",
"newPath": "Workspace Path", "newPath": "Where should the workspace be created?",
"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 to your workspace directory", "newHelp": "Full path where the new workspace will be created",
"githubUrl": "GitHub URL (Optional)", "githubUrl": "GitHub URL (Optional)",
"githubPlaceholder": "https://github.com/username/repository", "githubPlaceholder": "https://github.com/username/repository",
"githubHelp": "Optional: provide a GitHub URL to clone a repository", "githubHelp": "Leave empty to create an empty workspace, or provide a GitHub URL to clone",
"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,25 +170,21 @@
"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": "The repository will be cloned from this folder.", "newWithClone": "A new workspace will be created and the repository will be cloned from GitHub.",
"newEmpty": "The workspace will be added to your project list and will be available for Claude/Cursor sessions.", "newEmpty": "An empty workspace directory will be created at the specified path."
"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,25 +170,21 @@
"usingStoredToken": "使用已保存的令牌:", "usingStoredToken": "使用已保存的令牌:",
"usingProvidedToken": "使用提供的令牌", "usingProvidedToken": "使用提供的令牌",
"noAuthentication": "无身份验证", "noAuthentication": "无身份验证",
"sshKey": "SSH 密钥",
"existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。", "existingInfo": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。",
"newWithClone": "仓库将从此文件夹克隆。", "newWithClone": "将创建新工作区,并从 GitHub 克隆仓库。",
"newEmpty": "工作区将被添加到您的项目列表中,并可用于 Claude/Cursor 会话。", "newEmpty": "将在指定路径创建一个空的工作区目录。"
"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,12 +158,6 @@ 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

@@ -29,7 +29,7 @@ export function useWebSocket() {
if (isPlatform) { if (isPlatform) {
// Platform mode: Use same domain as the page (goes through proxy) // Platform mode: Use same domain as the page (goes through proxy)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${protocol}//${window.location.host}/ws`; wsUrl = `/ws`;
} else { } else {
// OSS mode: Connect to same host:port that served the page // OSS mode: Connect to same host:port that served the page
const token = localStorage.getItem('auth-token'); const token = localStorage.getItem('auth-token');

View File

@@ -7,6 +7,7 @@ export default defineConfig(({ command, mode }) => {
return { return {
base: './',
plugins: [react()], plugins: [react()],
server: { server: {
port: parseInt(env.VITE_PORT) || 5173, port: parseInt(env.VITE_PORT) || 5173,