7 Commits

23 changed files with 3042 additions and 403 deletions

1683
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-ui",
"version": "1.1.1",
"version": "1.1.4",
"description": "A web-based UI for Claude Code CLI",
"main": "server/index.js",
"scripts": {
@@ -33,11 +33,13 @@
"@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-webgl": "^0.18.0",
"bcrypt": "^6.0.0",
"chokidar": "^4.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.515.0",
"mime-types": "^3.0.1",
"node-fetch": "^2.7.0",
@@ -46,6 +48,7 @@
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.1",
"ws": "^8.14.2",
"xterm": "^5.3.0",

100
server/database/db.js Normal file
View File

@@ -0,0 +1,100 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const DB_PATH = path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
// Create database connection
const db = new sqlite3.Database(DB_PATH, (err) => {
if (err) {
console.error('Error opening database:', err.message);
} else {
console.log('Connected to SQLite database');
}
});
// Initialize database with schema
const initializeDatabase = async () => {
return new Promise((resolve, reject) => {
try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
db.exec(initSQL, (err) => {
if (err) {
console.error('Error initializing database:', err.message);
reject(err);
} else {
console.log('Database initialized successfully');
resolve();
}
});
} catch (error) {
console.error('Error reading init SQL file:', error);
reject(error);
}
});
};
// User database operations
const userDb = {
// Check if any users exist
hasUsers: () => {
return new Promise((resolve, reject) => {
db.get('SELECT COUNT(*) as count FROM users', (err, row) => {
if (err) reject(err);
else resolve(row.count > 0);
});
});
},
// Create a new user
createUser: (username, passwordHash) => {
return new Promise((resolve, reject) => {
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
stmt.run(username, passwordHash, function(err) {
if (err) {
reject(err);
} else {
resolve({ id: this.lastID, username });
}
});
stmt.finalize();
});
},
// Get user by username
getUserByUsername: (username) => {
return new Promise((resolve, reject) => {
db.get('SELECT * FROM users WHERE username = ? AND is_active = 1', [username], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
},
// Update last login time
updateLastLogin: (userId) => {
return new Promise((resolve, reject) => {
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [userId], (err) => {
if (err) reject(err);
else resolve();
});
});
},
// Get user by ID
getUserById: (userId) => {
return new Promise((resolve, reject) => {
db.get('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1', [userId], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
};
module.exports = {
db,
initializeDatabase,
userDb
};

16
server/database/init.sql Normal file
View File

@@ -0,0 +1,16 @@
-- Initialize authentication database
PRAGMA foreign_keys = ON;
-- Users table (single user system)
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
is_active BOOLEAN DEFAULT 1
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);

View File

@@ -30,9 +30,12 @@ const os = require('os');
const pty = require('node-pty');
const fetch = require('node-fetch');
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects');
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git');
const authRoutes = require('./routes/auth');
const { initializeDatabase } = require('./database/db');
const { validateApiKey, authenticateToken, authenticateWebSocket } = require('./middleware/auth');
// File system watcher for projects folder
let projectsWatcher = null;
@@ -76,6 +79,9 @@ function setupProjectsWatcher() {
debounceTimer = setTimeout(async () => {
try {
// Clear project directory cache when files change
clearProjectDirectoryCache();
// Get updated projects list
const updatedProjects = await getProjects();
@@ -139,19 +145,43 @@ const wss = new WebSocketServer({
server,
verifyClient: (info) => {
console.log('WebSocket connection attempt to:', info.req.url);
return true; // Accept all connections for now
// Extract token from query parameters or headers
const url = new URL(info.req.url, 'http://localhost');
const token = url.searchParams.get('token') ||
info.req.headers.authorization?.split(' ')[1];
// Verify token
const user = authenticateWebSocket(token);
if (!user) {
console.log('❌ WebSocket authentication failed');
return false;
}
// Store user info in the request for later use
info.req.user = user;
console.log('✅ WebSocket authenticated for user:', user.username);
return true;
}
});
app.use(cors());
app.use(express.json());
// Optional API key validation (if configured)
app.use('/api', validateApiKey);
// Authentication routes (public)
app.use('/api/auth', authRoutes);
// Git API Routes (protected)
app.use('/api/git', authenticateToken, gitRoutes);
// Static files served after API routes
app.use(express.static(path.join(__dirname, '../dist')));
// Git API Routes
app.use('/api/git', gitRoutes);
// API Routes
app.get('/api/config', (req, res) => {
// API Routes (protected)
app.get('/api/config', authenticateToken, (req, res) => {
// Always use the server's actual IP and port for WebSocket connections
const serverIP = getServerIP();
const host = `${serverIP}:${PORT}`;
@@ -165,7 +195,7 @@ app.get('/api/config', (req, res) => {
});
});
app.get('/api/projects', async (req, res) => {
app.get('/api/projects', authenticateToken, async (req, res) => {
try {
const projects = await getProjects();
res.json(projects);
@@ -174,7 +204,7 @@ app.get('/api/projects', async (req, res) => {
}
});
app.get('/api/projects/:projectName/sessions', async (req, res) => {
app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {
try {
const { limit = 5, offset = 0 } = req.query;
const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));
@@ -185,7 +215,7 @@ app.get('/api/projects/:projectName/sessions', async (req, res) => {
});
// Get messages for a specific session
app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, res) => {
app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
const messages = await getSessionMessages(projectName, sessionId);
@@ -196,7 +226,7 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', async (req, r
});
// Rename project endpoint
app.put('/api/projects/:projectName/rename', async (req, res) => {
app.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {
try {
const { displayName } = req.body;
await renameProject(req.params.projectName, displayName);
@@ -207,7 +237,7 @@ app.put('/api/projects/:projectName/rename', async (req, res) => {
});
// Delete session endpoint
app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) => {
app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {
try {
const { projectName, sessionId } = req.params;
await deleteSession(projectName, sessionId);
@@ -218,7 +248,7 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', async (req, res) =>
});
// Delete project endpoint (only if empty)
app.delete('/api/projects/:projectName', async (req, res) => {
app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
await deleteProject(projectName);
@@ -229,7 +259,7 @@ app.delete('/api/projects/:projectName', async (req, res) => {
});
// Create project endpoint
app.post('/api/projects/create', async (req, res) => {
app.post('/api/projects/create', authenticateToken, async (req, res) => {
try {
const { path: projectPath } = req.body;
@@ -246,7 +276,7 @@ app.post('/api/projects/create', async (req, res) => {
});
// Read file content endpoint
app.get('/api/projects/:projectName/file', async (req, res) => {
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath } = req.query;
@@ -275,7 +305,7 @@ app.get('/api/projects/:projectName/file', async (req, res) => {
});
// Serve binary file content endpoint (for images, etc.)
app.get('/api/projects/:projectName/files/content', async (req, res) => {
app.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { path: filePath } = req.query;
@@ -321,7 +351,7 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
});
// Save file content endpoint
app.put('/api/projects/:projectName/file', async (req, res) => {
app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
try {
const { projectName } = req.params;
const { filePath, content } = req.body;
@@ -368,51 +398,19 @@ app.put('/api/projects/:projectName/file', async (req, res) => {
}
});
app.get('/api/projects/:projectName/files', async (req, res) => {
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
try {
const fs = require('fs').promises;
const projectPath = path.join(process.env.HOME, '.claude', 'projects', req.params.projectName);
// Try different methods to get the actual project path
let actualPath = projectPath;
// Use extractProjectDirectory to get the actual project path
let actualPath;
try {
// First try to read metadata.json
const metadataPath = path.join(projectPath, 'metadata.json');
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
actualPath = metadata.path || metadata.cwd;
} catch (e) {
// Fallback: try to find the actual path by testing different dash interpretations
let testPath = req.params.projectName;
if (testPath.startsWith('-')) {
testPath = testPath.substring(1);
}
// Try to intelligently decode the path by testing which directories exist
const pathParts = testPath.split('-');
actualPath = '/' + pathParts.join('/');
// If the simple replacement doesn't work, try to find the correct path
// by testing combinations where some dashes might be part of directory names
if (!require('fs').existsSync(actualPath)) {
// Try different combinations of dash vs slash
for (let i = pathParts.length - 1; i >= 0; i--) {
let testParts = [...pathParts];
// Try joining some parts with dashes instead of slashes
for (let j = i; j < testParts.length - 1; j++) {
testParts[j] = testParts[j] + '-' + testParts[j + 1];
testParts.splice(j + 1, 1);
let testActualPath = '/' + testParts.join('/');
if (require('fs').existsSync(testActualPath)) {
actualPath = testActualPath;
break;
}
}
if (require('fs').existsSync(actualPath)) break;
}
}
actualPath = await extractProjectDirectory(req.params.projectName);
} catch (error) {
console.error('Error extracting project directory:', error);
// Fallback to simple dash replacement
actualPath = req.params.projectName.replace(/-/g, '/');
}
// Check if path exists
@@ -438,12 +436,16 @@ wss.on('connection', (ws, request) => {
const url = request.url;
console.log('🔗 Client connected to:', url);
if (url === '/shell') {
// Parse URL to get pathname without query parameters
const urlObj = new URL(url, 'http://localhost');
const pathname = urlObj.pathname;
if (pathname === '/shell') {
handleShellConnection(ws);
} else if (url === '/ws') {
} else if (pathname === '/ws') {
handleChatConnection(ws);
} else {
console.log('❌ Unknown WebSocket path:', url);
console.log('❌ Unknown WebSocket path:', pathname);
ws.close();
}
});
@@ -658,7 +660,7 @@ function handleShellConnection(ws) {
});
}
// Audio transcription endpoint
app.post('/api/transcribe', async (req, res) => {
app.post('/api/transcribe', authenticateToken, async (req, res) => {
try {
const multer = require('multer');
const upload = multer({ storage: multer.memoryStorage() });
@@ -864,9 +866,24 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
}
const PORT = process.env.PORT || 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
setupProjectsWatcher();
});
// Initialize database and start server
async function startServer() {
try {
// Initialize authentication database
await initializeDatabase();
console.log('✅ Database initialized successfully');
server.listen(PORT, '0.0.0.0', () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
setupProjectsWatcher();
});
} catch (error) {
console.error('❌ Failed to start server:', error);
process.exit(1);
}
}
startServer();

80
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,80 @@
const jwt = require('jsonwebtoken');
const { userDb } = require('../database/db');
// Get JWT secret from environment or use default (for development)
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
// Optional API key middleware
const validateApiKey = (req, res, next) => {
// Skip API key validation if not configured
if (!process.env.API_KEY) {
return next();
}
const apiKey = req.headers['x-api-key'];
if (apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
};
// JWT authentication middleware
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access denied. No token provided.' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Verify user still exists and is active
const user = await userDb.getUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
req.user = user;
next();
} catch (error) {
console.error('Token verification error:', error);
return res.status(403).json({ error: 'Invalid token' });
}
};
// Generate JWT token (never expires)
const generateToken = (user) => {
return jwt.sign(
{
userId: user.id,
username: user.username
},
JWT_SECRET
// No expiration - token lasts forever
);
};
// WebSocket authentication function
const authenticateWebSocket = (token) => {
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
return decoded;
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
}
};
module.exports = {
validateApiKey,
authenticateToken,
generateToken,
authenticateWebSocket,
JWT_SECRET
};

View File

@@ -2,6 +2,17 @@ const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
// Cache for extracted project directories
const projectDirectoryCache = new Map();
let cacheTimestamp = Date.now();
// Clear cache when needed (called when project files change)
function clearProjectDirectoryCache() {
projectDirectoryCache.clear();
cacheTimestamp = Date.now();
console.log('🗑️ Project directory cache cleared');
}
// Load project configuration file
async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
@@ -54,12 +65,20 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
return projectPath;
}
// Extract the actual project directory from JSONL sessions
// Extract the actual project directory from JSONL sessions (with caching)
async function extractProjectDirectory(projectName) {
// Check cache first
if (projectDirectoryCache.has(projectName)) {
return projectDirectoryCache.get(projectName);
}
console.log(`🔍 Extracting project directory for: ${projectName}`);
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const cwdCounts = new Map();
let latestTimestamp = 0;
let latestCwd = null;
let extractedPath;
try {
const files = await fs.readdir(projectDir);
@@ -67,75 +86,87 @@ async function extractProjectDirectory(projectName) {
if (jsonlFiles.length === 0) {
// Fall back to decoded project name if no sessions
return projectName.replace(/-/g, '/');
}
// Process all JSONL files to collect cwd values
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = require('fs').createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
if (entry.cwd) {
// Count occurrences of each cwd
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
extractedPath = projectName.replace(/-/g, '/');
} else {
// Process all JSONL files to collect cwd values
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = require('fs').createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
// Track the most recent cwd
const timestamp = new Date(entry.timestamp || 0).getTime();
if (timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestCwd = entry.cwd;
if (entry.cwd) {
// Count occurrences of each cwd
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
// Track the most recent cwd
const timestamp = new Date(entry.timestamp || 0).getTime();
if (timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestCwd = entry.cwd;
}
}
} catch (parseError) {
// Skip malformed lines
}
} catch (parseError) {
// Skip malformed lines
}
}
}
// Determine the best cwd to use
if (cwdCounts.size === 0) {
// No cwd found, fall back to decoded project name
extractedPath = projectName.replace(/-/g, '/');
} else if (cwdCounts.size === 1) {
// Only one cwd, use it
extractedPath = Array.from(cwdCounts.keys())[0];
} else {
// Multiple cwd values - prefer the most recent one if it has reasonable usage
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
const maxCount = Math.max(...cwdCounts.values());
// Use most recent if it has at least 25% of the max count
if (mostRecentCount >= maxCount * 0.25) {
extractedPath = latestCwd;
} else {
// Otherwise use the most frequently used cwd
for (const [cwd, count] of cwdCounts.entries()) {
if (count === maxCount) {
extractedPath = cwd;
break;
}
}
}
// Fallback (shouldn't reach here)
if (!extractedPath) {
extractedPath = latestCwd || projectName.replace(/-/g, '/');
}
}
}
// Determine the best cwd to use
if (cwdCounts.size === 0) {
// No cwd found, fall back to decoded project name
return projectName.replace(/-/g, '/');
}
// Cache the result
projectDirectoryCache.set(projectName, extractedPath);
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
if (cwdCounts.size === 1) {
// Only one cwd, use it
return Array.from(cwdCounts.keys())[0];
}
// Multiple cwd values - prefer the most recent one if it has reasonable usage
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
const maxCount = Math.max(...cwdCounts.values());
// Use most recent if it has at least 25% of the max count
if (mostRecentCount >= maxCount * 0.25) {
return latestCwd;
}
// Otherwise use the most frequently used cwd
for (const [cwd, count] of cwdCounts.entries()) {
if (count === maxCount) {
return cwd;
}
}
// Fallback (shouldn't reach here)
return latestCwd || projectName.replace(/-/g, '/');
return extractedPath;
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fall back to decoded project name
return projectName.replace(/-/g, '/');
extractedPath = projectName.replace(/-/g, '/');
// Cache the fallback result too
projectDirectoryCache.set(projectName, extractedPath);
return extractedPath;
}
}
@@ -582,5 +613,6 @@ module.exports = {
addProjectManually,
loadProjectConfig,
saveProjectConfig,
extractProjectDirectory
extractProjectDirectory,
clearProjectDirectoryCache
};

125
server/routes/auth.js Normal file
View File

@@ -0,0 +1,125 @@
const express = require('express');
const bcrypt = require('bcrypt');
const { userDb } = require('../database/db');
const { generateToken, authenticateToken } = require('../middleware/auth');
const router = express.Router();
// Check auth status and setup requirements
router.get('/status', async (req, res) => {
try {
const hasUsers = await userDb.hasUsers();
res.json({
needsSetup: !hasUsers,
isAuthenticated: false // Will be overridden by frontend if token exists
});
} catch (error) {
console.error('Auth status error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// User registration (setup) - only allowed if no users exist
router.post('/register', async (req, res) => {
try {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
if (username.length < 3 || password.length < 6) {
return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });
}
// Check if users already exist (only allow one user)
const hasUsers = await userDb.hasUsers();
if (hasUsers) {
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const user = await userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
await userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Registration error:', error);
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
res.status(409).json({ error: 'Username already exists' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// User login
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
// Get user from database
const user = await userDb.getUserByUsername(username);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Generate token
const token = generateToken(user);
// Update last login
await userDb.updateLastLogin(user.id);
res.json({
success: true,
user: { id: user.id, username: user.username },
token
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get current user (protected route)
router.get('/user', authenticateToken, (req, res) => {
res.json({
user: req.user
});
});
// Logout (client-side token removal, but this endpoint can be used for logging)
router.post('/logout', authenticateToken, (req, res) => {
// In a simple JWT system, logout is mainly client-side
// This endpoint exists for consistency and potential future logging
res.json({ success: true, message: 'Logged out successfully' });
});
module.exports = router;

View File

@@ -3,15 +3,47 @@ const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs').promises;
const { extractProjectDirectory } = require('../projects');
const router = express.Router();
const execAsync = promisify(exec);
// Helper function to get the actual project path from the encoded project name
function getActualProjectPath(projectName) {
// Claude stores projects with dashes instead of slashes
// Convert "-Users-dmieloch-Dev-experiments-claudecodeui" to "/Users/dmieloch/Dev/experiments/claudecodeui"
return projectName.replace(/-/g, '/');
async function getActualProjectPath(projectName) {
try {
return await extractProjectDirectory(projectName);
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fallback to the old method
return projectName.replace(/-/g, '/');
}
}
// Helper function to validate git repository
async function validateGitRepository(projectPath) {
try {
// Check if directory exists
await fs.access(projectPath);
} catch {
throw new Error(`Project path not found: ${projectPath}`);
}
try {
// Use --show-toplevel to get the root of the git repository
const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
const normalizedGitRoot = path.resolve(gitRoot.trim());
const normalizedProjectPath = path.resolve(projectPath);
// Ensure the git root matches our project path (prevent using parent git repos)
if (normalizedGitRoot !== normalizedProjectPath) {
throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
}
} catch (error) {
if (error.message.includes('Project directory is not a git repository')) {
throw error;
}
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
}
}
// Get git status for a project
@@ -23,24 +55,11 @@ router.get('/status', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
console.log('Git status for project:', project, '-> path:', projectPath);
// Check if directory exists
try {
await fs.access(projectPath);
} catch {
console.error('Project path not found:', projectPath);
return res.json({ error: 'Project not found' });
}
// Check if it's a git repository
try {
await execAsync('git rev-parse --git-dir', { cwd: projectPath });
} catch {
console.error('Not a git repository:', projectPath);
return res.json({ error: 'Not a git repository' });
}
// Validate git repository
await validateGitRepository(projectPath);
// Get current branch
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
@@ -79,7 +98,14 @@ router.get('/status', async (req, res) => {
});
} catch (error) {
console.error('Git status error:', error);
res.json({ error: error.message });
res.json({
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
? error.message
: 'Git operation failed',
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
? error.message
: `Failed to get git status: ${error.message}`
});
}
});
@@ -92,7 +118,10 @@ router.get('/diff', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Check if file is untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
@@ -133,7 +162,10 @@ router.post('/commit', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Stage selected files
for (const file of files) {
@@ -159,9 +191,12 @@ router.get('/branches', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
console.log('Git branches for project:', project, '-> path:', projectPath);
// Validate git repository
await validateGitRepository(projectPath);
// Get all branches
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
@@ -199,7 +234,7 @@ router.post('/checkout', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Checkout the branch
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
@@ -220,7 +255,7 @@ router.post('/create-branch', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Create and checkout new branch
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
@@ -241,7 +276,7 @@ router.get('/commits', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Get commit log with stats
const { stdout } = await execAsync(
@@ -292,7 +327,7 @@ router.get('/commit-diff', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Get diff for the commit
const { stdout } = await execAsync(
@@ -316,7 +351,7 @@ router.post('/generate-commit-message', async (req, res) => {
}
try {
const projectPath = getActualProjectPath(project);
const projectPath = await getActualProjectPath(project);
// Get diff for selected files
let combinedDiff = '';

View File

@@ -28,7 +28,10 @@ import QuickSettingsPanel from './components/QuickSettingsPanel';
import { useWebSocket } from './utils/websocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import { useVersionCheck } from './hooks/useVersionCheck';
import { api } from './utils/api';
// Main App component with routing
@@ -182,7 +185,7 @@ function AppContent() {
const fetchProjects = async () => {
try {
setIsLoadingProjects(true);
const response = await fetch('/api/projects');
const response = await api.projects();
const data = await response.json();
// Optimize to preserve object references when data hasn't changed
@@ -304,7 +307,7 @@ function AppContent() {
const handleSidebarRefresh = async () => {
// Refresh only the sessions for all projects, don't change selected state
try {
const response = await fetch('/api/projects');
const response = await api.projects();
const freshProjects = await response.json();
// Optimize to preserve object references and minimize re-renders
@@ -633,12 +636,16 @@ function AppContent() {
function App() {
return (
<ThemeProvider>
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
<AuthProvider>
<ProtectedRoute>
<Router>
<Routes>
<Route path="/" element={<AppContent />} />
<Route path="/session/:sessionId" element={<AppContent />} />
</Routes>
</Router>
</ProtectedRoute>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -23,6 +23,7 @@ import ClaudeLogo from './ClaudeLogo.jsx';
import ClaudeStatus from './ClaudeStatus';
import { MicButton } from './MicButton.jsx';
import { api } from '../utils/api';
// Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
@@ -949,7 +950,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setIsLoadingSessionMessages(true);
try {
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`);
const response = await api.sessionMessages(projectName, sessionId);
if (!response.ok) {
throw new Error('Failed to load session messages');
}
@@ -1451,7 +1452,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const fetchProjectFiles = async () => {
try {
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
const response = await api.getFiles(selectedProject.name);
if (response.ok) {
const files = await response.json();
// Flatten the file tree to get all file paths

View File

@@ -10,6 +10,7 @@ import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, Decoration } from '@codemirror/view';
import { StateField, StateEffect, RangeSetBuilder } from '@codemirror/state';
import { X, Save, Download, Maximize2, Minimize2, Eye, EyeOff } from 'lucide-react';
import { api } from '../utils/api';
function CodeEditor({ file, onClose, projectPath }) {
const [content, setContent] = useState('');
@@ -19,6 +20,7 @@ function CodeEditor({ file, onClose, projectPath }) {
const [isDarkMode, setIsDarkMode] = useState(true);
const [saveSuccess, setSaveSuccess] = useState(false);
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
const [wordWrap, setWordWrap] = useState(false);
// Create diff highlighting
const diffEffect = StateEffect.define();
@@ -138,7 +140,7 @@ function CodeEditor({ file, onClose, projectPath }) {
try {
setLoading(true);
const response = await fetch(`/api/projects/${file.projectName}/file?filePath=${encodeURIComponent(file.path)}`);
const response = await api.readFile(file.projectName, file.path);
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
@@ -175,16 +177,7 @@ function CodeEditor({ file, onClose, projectPath }) {
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch(`/api/projects/${file.projectName}/file`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
filePath: file.path,
content: content
})
});
const response = await api.saveFile(file.projectName, file.path, content);
if (!response.ok) {
const errorData = await response.json();
@@ -265,28 +258,17 @@ function CodeEditor({ file, onClose, projectPath }) {
}
return (
<>
<style>
{`
.code-editor-modal {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
.code-editor-modal:hover {
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
}
`}
</style>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`code-editor-modal shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
<div className={`fixed inset-0 z-50 ${
// Mobile: native fullscreen, Desktop: modal with backdrop
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
} ${isFullscreen ? 'md:p-0' : ''}`}>
<div className={`bg-white shadow-2xl flex flex-col ${
// Mobile: always fullscreen, Desktop: modal sizing
'w-full h-full md:rounded-lg md:shadow-2xl' +
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
<div className="flex items-center justify-between p-4 border-b border-gray-200 flex-shrink-0 min-w-0">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-8 h-8 bg-blue-600 rounded flex items-center justify-center flex-shrink-0">
<span className="text-white text-sm font-mono">
@@ -295,14 +277,14 @@ function CodeEditor({ file, onClose, projectPath }) {
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
<h3 className="font-medium text-gray-900 truncate">{file.name}</h3>
{file.diffInfo && (
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded whitespace-nowrap">
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded whitespace-nowrap">
📝 Has changes
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
<p className="text-sm text-gray-500 truncate">{file.path}</p>
</div>
</div>
@@ -317,6 +299,18 @@ function CodeEditor({ file, onClose, projectPath }) {
</button>
)}
<button
onClick={() => setWordWrap(!wordWrap)}
className={`p-2 md:p-2 rounded-md hover:bg-gray-100 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center ${
wordWrap
? 'text-blue-600 bg-blue-50'
: 'text-gray-600 hover:text-gray-900'
}`}
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<span className="text-sm md:text-xs font-mono font-bold"></span>
</button>
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
@@ -384,7 +378,8 @@ function CodeEditor({ file, onClose, projectPath }) {
extensions={[
...getLanguageExtension(file.name),
diffField,
diffTheme
diffTheme,
...(wordWrap ? [EditorView.lineWrapping] : [])
]}
theme={isDarkMode ? oneDark : undefined}
height="100%"
@@ -421,7 +416,6 @@ function CodeEditor({ file, onClose, projectPath }) {
</div>
</div>
</div>
</>
);
}

View File

@@ -5,6 +5,7 @@ import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
import { cn } from '../lib/utils';
import CodeEditor from './CodeEditor';
import ImageViewer from './ImageViewer';
import { api } from '../utils/api';
function FileTree({ selectedProject }) {
const [files, setFiles] = useState([]);
@@ -22,7 +23,7 @@ function FileTree({ selectedProject }) {
const fetchFiles = async () => {
setLoading(true);
try {
const response = await fetch(`/api/projects/${selectedProject.name}/files`);
const response = await api.getFiles(selectedProject.name);
if (!response.ok) {
const errorText = await response.text();

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles } from 'lucide-react';
import { MicButton } from './MicButton.jsx';
import { authenticatedFetch } from '../utils/api';
function GitPanel({ selectedProject, isMobile }) {
const [gitStatus, setGitStatus] = useState(null);
@@ -55,14 +56,14 @@ function GitPanel({ selectedProject, isMobile }) {
setIsLoading(true);
try {
const response = await fetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
const response = await authenticatedFetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
const data = await response.json();
console.log('Git status response:', data);
if (data.error) {
console.error('Git status error:', data.error);
setGitStatus(null);
setGitStatus({ error: data.error, details: data.details });
} else {
setGitStatus(data);
setCurrentBranch(data.branch || 'main');
@@ -93,7 +94,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchBranches = async () => {
try {
const response = await fetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
const response = await authenticatedFetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
const data = await response.json();
if (!data.error && data.branches) {
@@ -106,7 +107,7 @@ function GitPanel({ selectedProject, isMobile }) {
const switchBranch = async (branchName) => {
try {
const response = await fetch('/api/git/checkout', {
const response = await authenticatedFetch('/api/git/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -133,7 +134,7 @@ function GitPanel({ selectedProject, isMobile }) {
setIsCreatingBranch(true);
try {
const response = await fetch('/api/git/create-branch', {
const response = await authenticatedFetch('/api/git/create-branch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -162,7 +163,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchFileDiff = async (filePath) => {
try {
const response = await fetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (!data.error && data.diff) {
@@ -178,7 +179,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchRecentCommits = async () => {
try {
const response = await fetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
const response = await authenticatedFetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
const data = await response.json();
if (!data.error && data.commits) {
@@ -191,7 +192,7 @@ function GitPanel({ selectedProject, isMobile }) {
const fetchCommitDiff = async (commitHash) => {
try {
const response = await fetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
const response = await authenticatedFetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
const data = await response.json();
if (!data.error && data.diff) {
@@ -208,7 +209,7 @@ function GitPanel({ selectedProject, isMobile }) {
const generateCommitMessage = async () => {
setIsGeneratingMessage(true);
try {
const response = await fetch('/api/git/generate-commit-message', {
const response = await authenticatedFetch('/api/git/generate-commit-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -275,7 +276,7 @@ function GitPanel({ selectedProject, isMobile }) {
setIsCommitting(true);
try {
const response = await fetch('/api/git/commit', {
const response = await authenticatedFetch('/api/git/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -506,171 +507,191 @@ function GitPanel({ selectedProject, isMobile }) {
</button>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveView('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4" />
<span>Changes</span>
{/* Git Repository Not Found Message */}
{gitStatus?.error ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12">
<GitBranch className="w-20 h-20 mb-6 opacity-30" />
<h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3>
{gitStatus.details && (
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
)}
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
</p>
</div>
</button>
<button
onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</div>
</button>
</div>
{/* Changes View */}
{activeView === 'changes' && (
</div>
) : (
<>
{/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<textarea
ref={textareaRef}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
rows="3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleCommit();
}
}}
/>
<div className="absolute right-2 top-2 flex gap-1">
<button
onClick={generateCommitMessage}
disabled={selectedFiles.size === 0 || isGeneratingMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Generate commit message"
>
{isGeneratingMessage ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
</button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
{/* Tab Navigation - Only show when git is available */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setActiveView('changes')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'changes'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<FileText className="w-4 h-4" />
<span>Changes</span>
</div>
</button>
<button
onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</div>
</button>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<>
{/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<textarea
ref={textareaRef}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
rows="3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleCommit();
}
}}
/>
<div className="absolute right-2 top-2 flex gap-1">
<button
onClick={generateCommitMessage}
disabled={selectedFiles.size === 0 || isGeneratingMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Generate commit message"
>
{isGeneratingMessage ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
</button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={handleCommit}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
</button>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</>
)}
{/* File Selection Controls - Only show in changes view and when git is working */}
{activeView === 'changes' && gitStatus && !gitStatus.error && (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
</span>
<button
onClick={handleCommit}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
</button>
<div className="flex gap-2">
<button
onClick={() => {
const allFiles = new Set([
...(gitStatus?.modified || []),
...(gitStatus?.added || []),
...(gitStatus?.deleted || []),
...(gitStatus?.untracked || [])
]);
setSelectedFiles(allFiles);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Select All
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
onClick={() => setSelectedFiles(new Set())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Deselect All
</button>
</div>
</div>
</div>
)}
{/* Status Legend Toggle */}
{!gitStatus?.error && (
<div className="border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowLegend(!showLegend)}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
M
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
A
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
D
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
U
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
</div>
</div>
</div>
)}
</div>
)}
</>
)}
{/* File Selection Controls - Only show in changes view */}
{activeView === 'changes' && gitStatus && (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
</span>
<div className="flex gap-2">
<button
onClick={() => {
const allFiles = new Set([
...(gitStatus?.modified || []),
...(gitStatus?.added || []),
...(gitStatus?.deleted || []),
...(gitStatus?.untracked || [])
]);
setSelectedFiles(allFiles);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Select All
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
onClick={() => setSelectedFiles(new Set())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Deselect All
</button>
</div>
</div>
)}
{/* Status Legend Toggle */}
<div className="border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowLegend(!showLegend)}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
M
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
A
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
D
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
U
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
</div>
</div>
</div>
)}
</div>
{/* File List - Changes View */}
{activeView === 'changes' && (
{/* File List - Changes View - Only show when git is available */}
{activeView === 'changes' && !gitStatus?.error && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">
@@ -692,8 +713,8 @@ function GitPanel({ selectedProject, isMobile }) {
</div>
)}
{/* History View */}
{activeView === 'history' && (
{/* History View - Only show when git is available */}
{activeView === 'history' && !gitStatus?.error && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? (
<div className="flex items-center justify-center h-32">

View File

@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo';
const LoginForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { login } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username || !password) {
setError('Please enter both username and password');
return;
}
setIsLoading(true);
const result = await login(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome Back</h1>
<p className="text-muted-foreground mt-2">
Sign in to your Claude Code UI account
</p>
</div>
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
Enter your credentials to access Claude Code UI
</p>
</div>
</div>
</div>
</div>
);
};
export default LoginForm;

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
import SetupForm from './SetupForm';
import LoginForm from './LoginForm';
import ClaudeLogo from './ClaudeLogo';
const LoadingScreen = () => (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
</div>
<h1 className="text-2xl font-bold text-foreground mb-2">Claude Code UI</h1>
<div className="flex items-center justify-center space-x-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
<p className="text-muted-foreground mt-2">Loading...</p>
</div>
</div>
);
const ProtectedRoute = ({ children }) => {
const { user, isLoading, needsSetup } = useAuth();
if (isLoading) {
return <LoadingScreen />;
}
if (needsSetup) {
return <SetupForm />;
}
if (!user) {
return <LoginForm />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import ClaudeLogo from './ClaudeLogo';
const SetupForm = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const { register } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (username.length < 3) {
setError('Username must be at least 3 characters long');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
setIsLoading(true);
const result = await register(username, password);
if (!result.success) {
setError(result.error);
}
setIsLoading(false);
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="bg-card rounded-lg shadow-lg border border-border p-8 space-y-6">
{/* Logo and Title */}
<div className="text-center">
<div className="flex justify-center mb-4">
<ClaudeLogo size={64} />
</div>
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
<p className="text-muted-foreground mt-2">
Set up your account to get started
</p>
</div>
{/* Setup Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-foreground mb-1">
Username
</label>
<input
type="text"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-foreground mb-1">
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-foreground mb-1">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-md">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
</div>
)}
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-2 px-4 rounded-md transition-colors duration-200"
>
{isLoading ? 'Setting up...' : 'Create Account'}
</button>
</form>
<div className="text-center">
<p className="text-sm text-muted-foreground">
This is a single-user system. Only one account can be created.
</p>
</div>
</div>
</div>
</div>
);
};
export default SetupForm;

View File

@@ -322,10 +322,21 @@ function Shell({ selectedProject, selectedSession, isActive }) {
if (isConnecting || isConnected) return;
try {
// Get authentication token
const token = localStorage.getItem('auth-token');
if (!token) {
console.error('No authentication token found for Shell WebSocket connection');
return;
}
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const configResponse = await fetch('/api/config', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
@@ -343,7 +354,8 @@ function Shell({ selectedProject, selectedSession, isActive }) {
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/shell`;
// Include token in WebSocket URL as query parameter
const wsUrl = `${wsBaseUrl}/shell?token=${encodeURIComponent(token)}`;
ws.current = new WebSocket(wsUrl);

View File

@@ -6,6 +6,7 @@ import { Input } from './ui/input';
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2 } from 'lucide-react';
import { cn } from '../lib/utils';
import ClaudeLogo from './ClaudeLogo';
import { api } from '../utils/api';
// Move formatTimeAgo outside component to avoid recreation on every render
const formatTimeAgo = (dateString, currentTime) => {
@@ -132,13 +133,7 @@ function Sidebar({
const saveProjectName = async (projectName) => {
try {
const response = await fetch(`/api/projects/${projectName}/rename`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ displayName: editingName }),
});
const response = await api.renameProject(projectName, editingName);
if (response.ok) {
// Refresh projects to get updated data
@@ -164,9 +159,7 @@ function Sidebar({
}
try {
const response = await fetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
method: 'DELETE',
});
const response = await api.deleteSession(projectName, sessionId);
if (response.ok) {
// Call parent callback if provided
@@ -189,9 +182,7 @@ function Sidebar({
}
try {
const response = await fetch(`/api/projects/${projectName}`, {
method: 'DELETE',
});
const response = await api.deleteProject(projectName);
if (response.ok) {
// Call parent callback if provided
@@ -218,15 +209,7 @@ function Sidebar({
setCreatingProject(true);
try {
const response = await fetch('/api/projects/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
path: newProjectPath.trim()
}),
});
const response = await api.createProject(newProjectPath.trim());
if (response.ok) {
const result = await response.json();

View File

@@ -0,0 +1,158 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { api } from '../utils/api';
const AuthContext = createContext({
user: null,
token: null,
login: () => {},
register: () => {},
logout: () => {},
isLoading: true,
needsSetup: false,
error: null
});
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(localStorage.getItem('auth-token'));
const [isLoading, setIsLoading] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
const [error, setError] = useState(null);
// Check authentication status on mount
useEffect(() => {
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
setIsLoading(true);
setError(null);
// Check if system needs setup
const statusResponse = await api.auth.status();
const statusData = await statusResponse.json();
if (statusData.needsSetup) {
setNeedsSetup(true);
setIsLoading(false);
return;
}
// If we have a token, verify it
if (token) {
try {
const userResponse = await api.auth.user();
if (userResponse.ok) {
const userData = await userResponse.json();
setUser(userData.user);
setNeedsSetup(false);
} else {
// Token is invalid
localStorage.removeItem('auth-token');
setToken(null);
setUser(null);
}
} catch (error) {
console.error('Token verification failed:', error);
localStorage.removeItem('auth-token');
setToken(null);
setUser(null);
}
}
} catch (error) {
console.error('Auth status check failed:', error);
setError('Failed to check authentication status');
} finally {
setIsLoading(false);
}
};
const login = async (username, password) => {
try {
setError(null);
const response = await api.auth.login(username, password);
const data = await response.json();
if (response.ok) {
setToken(data.token);
setUser(data.user);
localStorage.setItem('auth-token', data.token);
return { success: true };
} else {
setError(data.error || 'Login failed');
return { success: false, error: data.error || 'Login failed' };
}
} catch (error) {
console.error('Login error:', error);
const errorMessage = 'Network error. Please try again.';
setError(errorMessage);
return { success: false, error: errorMessage };
}
};
const register = async (username, password) => {
try {
setError(null);
const response = await api.auth.register(username, password);
const data = await response.json();
if (response.ok) {
setToken(data.token);
setUser(data.user);
setNeedsSetup(false);
localStorage.setItem('auth-token', data.token);
return { success: true };
} else {
setError(data.error || 'Registration failed');
return { success: false, error: data.error || 'Registration failed' };
}
} catch (error) {
console.error('Registration error:', error);
const errorMessage = 'Network error. Please try again.';
setError(errorMessage);
return { success: false, error: errorMessage };
}
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem('auth-token');
// Optional: Call logout endpoint for logging
if (token) {
api.auth.logout().catch(error => {
console.error('Logout endpoint error:', error);
});
}
};
const value = {
user,
token,
login,
register,
logout,
isLoading,
needsSetup,
error
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

81
src/utils/api.js Normal file
View File

@@ -0,0 +1,81 @@
// Utility function for authenticated API calls
export const authenticatedFetch = (url, options = {}) => {
const token = localStorage.getItem('auth-token');
const defaultHeaders = {
'Content-Type': 'application/json',
};
if (token) {
defaultHeaders['Authorization'] = `Bearer ${token}`;
}
return fetch(url, {
...options,
headers: {
...defaultHeaders,
...options.headers,
},
});
};
// API endpoints
export const api = {
// Auth endpoints (no token required)
auth: {
status: () => fetch('/api/auth/status'),
login: (username, password) => fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
register: (username, password) => fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}),
user: () => authenticatedFetch('/api/auth/user'),
logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }),
},
// Protected endpoints
config: () => authenticatedFetch('/api/config'),
projects: () => authenticatedFetch('/api/projects'),
sessions: (projectName, limit = 5, offset = 0) =>
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
sessionMessages: (projectName, sessionId) =>
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}/messages`),
renameProject: (projectName, displayName) =>
authenticatedFetch(`/api/projects/${projectName}/rename`, {
method: 'PUT',
body: JSON.stringify({ displayName }),
}),
deleteSession: (projectName, sessionId) =>
authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {
method: 'DELETE',
}),
deleteProject: (projectName) =>
authenticatedFetch(`/api/projects/${projectName}`, {
method: 'DELETE',
}),
createProject: (path) =>
authenticatedFetch('/api/projects/create', {
method: 'POST',
body: JSON.stringify({ path }),
}),
readFile: (projectName, filePath) =>
authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),
saveFile: (projectName, filePath, content) =>
authenticatedFetch(`/api/projects/${projectName}/file`, {
method: 'PUT',
body: JSON.stringify({ filePath, content }),
}),
getFiles: (projectName) =>
authenticatedFetch(`/api/projects/${projectName}/files`),
transcribe: (formData) =>
authenticatedFetch('/api/transcribe', {
method: 'POST',
body: formData,
headers: {}, // Let browser set Content-Type for FormData
}),
};

View File

@@ -21,10 +21,21 @@ export function useWebSocket() {
const connect = async () => {
try {
// Get authentication token
const token = localStorage.getItem('auth-token');
if (!token) {
console.warn('No authentication token found for WebSocket connection');
return;
}
// Fetch server configuration to get the correct WebSocket URL
let wsBaseUrl;
try {
const configResponse = await fetch('/api/config');
const configResponse = await fetch('/api/config', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const config = await configResponse.json();
wsBaseUrl = config.wsUrl;
@@ -44,7 +55,8 @@ export function useWebSocket() {
wsBaseUrl = `${protocol}//${window.location.hostname}:${apiPort}`;
}
const wsUrl = `${wsBaseUrl}/ws`;
// Include token in WebSocket URL as query parameter
const wsUrl = `${wsBaseUrl}/ws?token=${encodeURIComponent(token)}`;
const websocket = new WebSocket(wsUrl);
websocket.onopen = () => {

View File

@@ -1,3 +1,5 @@
import { api } from './api';
export async function transcribeWithWhisper(audioBlob, onStatusChange) {
const formData = new FormData();
const fileName = `recording_${Date.now()}.webm`;
@@ -14,10 +16,7 @@ export async function transcribeWithWhisper(audioBlob, onStatusChange) {
onStatusChange('transcribing');
}
const response = await fetch('/api/transcribe', {
method: 'POST',
body: formData,
});
const response = await api.transcribe(formData);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));