mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-28 20:37:33 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d06cae0ca | ||
|
|
14fb81586c | ||
|
|
4d2b592ec6 | ||
|
|
4957220a05 | ||
|
|
3debc3a249 | ||
|
|
5512e2e15b | ||
|
|
1b42dba902 | ||
|
|
ede56ad81b | ||
|
|
36094fb73f | ||
|
|
57828653bf | ||
|
|
8ef0951901 | ||
|
|
ab50c5c1a8 | ||
|
|
6726e8f44e | ||
|
|
07f89e5240 | ||
|
|
8a675a713b | ||
|
|
5724c11253 | ||
|
|
c7b9976986 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.' });
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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')) {
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user