25 Commits

Author SHA1 Message Date
viper151
046f270a11 Update package.json 2025-07-12 21:34:56 +02:00
lvalics
7feeebc2ae feat: Add image upload functionality with drag & drop, clipboard paste, and file picker (#46)
- Add drag & drop support for images with visual feedback
- Implement clipboard paste for images (Ctrl+V/Cmd+V)
- Add image upload button in chat interface
- Support multiple formats: PNG, JPG, JPEG, GIF, WebP, SVG
- Max 5 images per message, 5MB per image
- Add image preview with remove functionality
- Display images in chat message bubbles

Technical implementation:
- Frontend: Add react-dropzone for drag & drop
- Frontend: Create ImageAttachment component for previews
- Backend: Add multer for file upload handling
- Backend: Create /api/projects/:projectName/upload-images endpoint
- Backend: Convert images to base64 and save to temp files
- Claude CLI: Pass image paths as arguments
- Add automatic cleanup of temporary files
- Fix JWT auth to use correct token name
- Fix UI overlap with proper padding adjustments
- Remove non-essential console.logs for production

Security:
- Validate file types and sizes
- Sanitize filenames
- User-specific temp directories
- Proper JWT authentication

Infrastructure:
- Add .tmp/ to .gitignore
- Create comprehensive CHANGELOG.md
- Update package.json with new dependencies

Co-authored-by: viper151 <simosmik@gmail.com>
2025-07-12 21:30:55 +02:00
simos
ad0bcba117 fix: Enhance project sorting in sidebar
- Fixed combined sorting for projects: starred projects first, followed by sorting by last activity date or display name.
- Added helper functions to retrieve all sessions for a project and determine the last activity date.
- Improved sorting logic to accommodate user-defined display names and recent activity.
2025-07-12 19:13:26 +00:00
lvalics
a56e06385d feat: Add project search filter to sidebar
- Added search input below header to filter projects by name
- Search works on both display name and project path
- Clear button (X) appears when search is active
- Shows "No matching projects" message when filter returns no results
- Search box only visible when projects exist
- Case-insensitive search implementation
2025-07-12 20:57:09 +02:00
viper151
5ec51dacc3 Added stared project and ux enahncements
Added stared project and ux enahncements
2025-07-12 20:41:46 +02:00
viper151
a79028a124 Merge branch 'main' into feature/project-starring 2025-07-12 20:41:08 +02:00
viper151
2435d12a0b Merge pull request #37 from lvalics/feature/file-permissions 2025-07-12 20:25:11 +02:00
viper151
54d5583bc2 Merge branch 'main' into feature/file-permissions 2025-07-12 13:35:32 +02:00
viper151
ce1e6c73b3 feat: Add project sorting by date option
feat: Add project sorting by date option
2025-07-12 13:35:04 +02:00
viper151
c6c11c236c Update ToolsSettings.jsx 2025-07-12 13:33:26 +02:00
viper151
211a3c4557 Delete file-metadata-issue.md 2025-07-12 13:28:41 +02:00
Valics Lehel
02a296739d fix: Address project sorting feedback
- Sort by user-defined displayName instead of path/folder name
- Move project sorting under Appearance section
- Replace large toggle buttons with compact dropdown
- Use clearer labels: "Alphabetical" and "Recent Activity"
- Projects now sort by custom names when available
2025-07-12 14:17:56 +03:00
Valics Lehel
1f3fe2df3d feat: Add file metadata display with view modes
- Added file size, permissions (rwx format), and modified date display
- Implemented three view modes: simple, compact, and detailed
- Added server-side file stats collection in getFileTree
- View mode preference persisted in localStorage
- Detailed view shows table-like layout with column headers
- Compact view shows inline metadata
- Simple view maintains original basic tree structure
2025-07-11 23:14:07 +03:00
Valics Lehel
122b757fa2 feat: Add project sorting by date option
- Added ability to sort projects by name or most recent session activity
- Added projectSortOrder state and localStorage persistence
- Added UI controls in ToolsSettings to switch between sort modes
- Projects with no sessions sort last when sorting by date
- Real-time updates when sort preference changes
2025-07-11 22:42:22 +03:00
simos
45b3e54d04 Added stared project and ux enahncements 2025-07-11 12:35:27 +00:00
viper151
4762a2d719 Update README.md 2025-07-11 12:47:11 +02:00
viper151
634e00264e Merge pull request #34 from siteboon/update-vite
Plan mode and upgrading to Vite 7
2025-07-11 12:43:10 +02:00
simos
fc2a94a2e5 - Upgrading to Vite 7
- Refactor to use es modules
- Added permission mode
- Switched to better sqlite3
- several UX enhancements
2025-07-11 10:29:36 +00:00
viper151
d8bc6348d5 Merge pull request #29 from siteboon/login
Login
2025-07-10 20:52:08 +02:00
viper151
ac32026cfc Update package.json 2025-07-10 20:50:57 +02:00
simos
ec9ff3336a Update package version to 1.1.3, add new dependencies for authentication and database management, and implement user authentication features including registration and login. Enhance API routes for protected access and integrate WebSocket authentication. 2025-07-09 18:25:58 +00:00
simos
b27702797f Refactor CodeEditor component to improve dark mode support and enhance loading styles 2025-07-08 16:53:08 +00:00
simos
c8aa3d5d4e Add word wrap feature to CodeEditor component and clean up styles 2025-07-08 15:31:03 +00:00
simos
bca97a5284 Update GitPanel 2025-07-08 15:14:12 +00:00
simos
1bdc75e37b Enhance project directory handling by integrating extractProjectDirectory and clearProjectDirectoryCache functions. Adjust git route handlers to utilize the new directory extraction logic for improved project path resolution. 2025-07-08 15:10:44 +00:00
31 changed files with 3243 additions and 639 deletions

1
.gitignore vendored
View File

@@ -90,6 +90,7 @@ jspm_packages/
# Temporary folders
tmp/
temp/
.tmp/
# Vite
.vite/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v20.19.3

View File

@@ -44,7 +44,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
### Prerequisites
- [Node.js](https://nodejs.org/) v16 or higher
- [Node.js](https://nodejs.org/) v20 or higher
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
### Installation
@@ -234,4 +234,4 @@ This project is open source and free to use, modify, and distribute under the GP
<div align="center">
<strong>Made with care for the Claude Code community</strong>
</div>
</div>

979
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{
"name": "claude-code-ui",
"version": "1.1.1",
"version": "1.3.0",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
"scripts": {
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
@@ -33,17 +34,22 @@
"@uiw/react-codemirror": "^4.23.13",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-webgl": "^0.18.0",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.2.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",
"multer": "^2.0.1",
"node-fetch": "^2.7.0",
"node-pty": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-markdown": "^10.1.0",
"react-router-dom": "^6.8.1",
"tailwind-merge": "^3.3.1",
@@ -54,12 +60,12 @@
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.16",
"concurrently": "^8.2.2",
"postcss": "^8.4.32",
"sharp": "^0.34.2",
"tailwindcss": "^3.4.0",
"vite": "^5.0.8"
"vite": "^7.0.4"
}
}

View File

@@ -1,4 +1,4 @@
module.exports = {
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},

View File

@@ -1,10 +1,13 @@
const { spawn } = require('child_process');
import { spawn } from 'child_process';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
let activeClaudeProcesses = new Map(); // Track active processes by session ID
async function spawnClaude(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
const { sessionId, projectPath, cwd, resume, toolsSettings } = options;
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
@@ -14,7 +17,7 @@ async function spawnClaude(command, options = {}, ws) {
disallowedTools: [],
skipPermissions: false
};
// Build Claude CLI command - start with print/resume flags first
const args = [];
@@ -23,6 +26,56 @@ async function spawnClaude(command, options = {}, ws) {
args.push('--print', command);
}
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
const workingDir = cwd || process.cwd();
// Handle images by saving them to temporary files and passing paths to Claude
const tempImagePaths = [];
let tempDir = null;
if (images && images.length > 0) {
try {
// Create temp directory in the project directory so Claude can access it
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
await fs.mkdir(tempDir, { recursive: true });
// Save each image to a temp file
for (const [index, image] of images.entries()) {
// Extract base64 data and mime type
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) {
console.error('Invalid image data format');
continue;
}
const [, mimeType, base64Data] = matches;
const extension = mimeType.split('/')[1] || 'png';
const filename = `image_${index}.${extension}`;
const filepath = path.join(tempDir, filename);
// Write base64 data to file
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
tempImagePaths.push(filepath);
}
// Include the full image paths in the prompt for Claude to reference
// Only modify the command if we actually have images and a command
if (tempImagePaths.length > 0 && command && command.trim()) {
const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
const modifiedCommand = command + imageNote;
// Update the command in args
const printIndex = args.indexOf('--print');
if (printIndex !== -1 && args[printIndex + 1] === command) {
args[printIndex + 1] = modifiedCommand;
}
}
} catch (error) {
console.error('Error processing images for Claude:', error);
}
}
// Add resume flag if resuming
if (resume && sessionId) {
args.push('--resume', sessionId);
@@ -36,15 +89,38 @@ async function spawnClaude(command, options = {}, ws) {
args.push('--model', 'sonnet');
}
// Add permission mode if specified (works for both new and resumed sessions)
if (permissionMode && permissionMode !== 'default') {
args.push('--permission-mode', permissionMode);
console.log('🔒 Using permission mode:', permissionMode);
}
// Add tools settings flags
if (settings.skipPermissions) {
// Don't use --dangerously-skip-permissions when in plan mode
if (settings.skipPermissions && permissionMode !== 'plan') {
args.push('--dangerously-skip-permissions');
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
} else {
// Only add allowed/disallowed tools if not skipping permissions
// Collect all allowed tools, including plan mode defaults
let allowedTools = [...(settings.allowedTools || [])];
// Add plan mode specific tools
if (permissionMode === 'plan') {
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite'];
// Add plan mode tools that aren't already in the allowed list
for (const tool of planModeTools) {
if (!allowedTools.includes(tool)) {
allowedTools.push(tool);
}
}
console.log('📝 Plan mode: Added default allowed tools:', planModeTools);
}
// Add allowed tools
if (settings.allowedTools && settings.allowedTools.length > 0) {
for (const tool of settings.allowedTools) {
if (allowedTools.length > 0) {
for (const tool of allowedTools) {
args.push('--allowedTools', tool);
console.log('✅ Allowing tool:', tool);
}
@@ -57,17 +133,21 @@ async function spawnClaude(command, options = {}, ws) {
console.log('❌ Disallowing tool:', tool);
}
}
// Log when skip permissions is disabled due to plan mode
if (settings.skipPermissions && permissionMode === 'plan') {
console.log('📝 Skip permissions disabled due to plan mode');
}
}
// Use cwd (actual project directory) instead of projectPath (Claude's metadata directory)
const workingDir = cwd || process.cwd();
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
}).join(' '));
console.log('Working directory:', workingDir);
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
console.log('🔍 Full command args:', args);
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
const claudeProcess = spawn('claude', args, {
cwd: workingDir,
@@ -75,6 +155,10 @@ async function spawnClaude(command, options = {}, ws) {
env: { ...process.env } // Inherit all environment variables
});
// Attach temp file info to process for cleanup later
claudeProcess.tempImagePaths = tempImagePaths;
claudeProcess.tempDir = tempDir;
// Store process reference for potential abort
const processKey = capturedSessionId || sessionId || Date.now().toString();
activeClaudeProcesses.set(processKey, claudeProcess);
@@ -138,7 +222,7 @@ async function spawnClaude(command, options = {}, ws) {
});
// Handle process completion
claudeProcess.on('close', (code) => {
claudeProcess.on('close', async (code) => {
console.log(`Claude CLI process exited with code ${code}`);
// Clean up process reference
@@ -151,6 +235,20 @@ async function spawnClaude(command, options = {}, ws) {
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
}));
// Clean up temporary image files if any
if (claudeProcess.tempImagePaths && claudeProcess.tempImagePaths.length > 0) {
for (const imagePath of claudeProcess.tempImagePaths) {
await fs.unlink(imagePath).catch(err =>
console.error(`Failed to delete temp image ${imagePath}:`, err)
);
}
if (claudeProcess.tempDir) {
await fs.rm(claudeProcess.tempDir, { recursive: true, force: true }).catch(err =>
console.error(`Failed to delete temp directory ${claudeProcess.tempDir}:`, err)
);
}
}
if (code === 0) {
resolve();
} else {
@@ -201,7 +299,7 @@ function abortClaudeSession(sessionId) {
return false;
}
module.exports = {
export {
spawnClaude,
abortClaudeSession
};

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

@@ -0,0 +1,86 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const DB_PATH = path.join(__dirname, 'auth.db');
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
// Create database connection
const db = new Database(DB_PATH);
console.log('Connected to SQLite database');
// Initialize database with schema
const initializeDatabase = async () => {
try {
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
db.exec(initSQL);
console.log('Database initialized successfully');
} catch (error) {
console.error('Error initializing database:', error.message);
throw error;
}
};
// User database operations
const userDb = {
// Check if any users exist
hasUsers: () => {
try {
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
return row.count > 0;
} catch (err) {
throw err;
}
},
// Create a new user
createUser: (username, passwordHash) => {
try {
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
const result = stmt.run(username, passwordHash);
return { id: result.lastInsertRowid, username };
} catch (err) {
throw err;
}
},
// Get user by username
getUserByUsername: (username) => {
try {
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
return row;
} catch (err) {
throw err;
}
},
// Update last login time
updateLastLogin: (userId) => {
try {
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
} catch (err) {
throw err;
}
},
// Get user by ID
getUserById: (userId) => {
try {
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
return row;
} catch (err) {
throw err;
}
}
};
export {
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

@@ -1,7 +1,13 @@
// Load environment variables from .env file
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
try {
const fs = require('fs');
const path = require('path');
const envPath = path.join(__dirname, '../.env');
const envFile = fs.readFileSync(envPath, 'utf8');
envFile.split('\n').forEach(line => {
@@ -19,28 +25,31 @@ try {
console.log('PORT from env:', process.env.PORT);
const express = require('express');
const { WebSocketServer } = require('ws');
const http = require('http');
const path = require('path');
const cors = require('cors');
const fs = require('fs').promises;
const { spawn } = require('child_process');
const os = require('os');
const pty = require('node-pty');
const fetch = require('node-fetch');
import express from 'express';
import { WebSocketServer } from 'ws';
import http from 'http';
import cors from 'cors';
import { promises as fsPromises } from 'fs';
import { spawn } from 'child_process';
import os from 'os';
import pty from 'node-pty';
import fetch from 'node-fetch';
import mime from 'mime-types';
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git');
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
import gitRoutes from './routes/git.js';
import authRoutes from './routes/auth.js';
import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
// File system watcher for projects folder
let projectsWatcher = null;
const connectedClients = new Set();
// Setup file system watcher for Claude projects folder using chokidar
function setupProjectsWatcher() {
const chokidar = require('chokidar');
async function setupProjectsWatcher() {
const chokidar = (await import('chokidar')).default;
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
if (projectsWatcher) {
@@ -76,6 +85,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 +151,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 +201,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 +210,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 +221,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 +232,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 +243,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 +254,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 +265,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,21 +282,21 @@ 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;
console.log('📄 File read request:', projectName, filePath);
const fs = require('fs').promises;
// Using fsPromises from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const content = await fs.readFile(filePath, 'utf8');
const content = await fsPromises.readFile(filePath, 'utf8');
res.json({ content, path: filePath });
} catch (error) {
console.error('Error reading file:', error);
@@ -275,15 +311,15 @@ 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;
console.log('🖼️ Binary file serve request:', projectName, filePath);
const fs = require('fs');
const mime = require('mime-types');
// Using fs from import
// Using mime from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
@@ -292,7 +328,7 @@ app.get('/api/projects/:projectName/files/content', async (req, res) => {
// Check if file exists
try {
await fs.promises.access(filePath);
await fsPromises.access(filePath);
} catch (error) {
return res.status(404).json({ error: 'File not found' });
}
@@ -321,14 +357,14 @@ 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;
console.log('💾 File save request:', projectName, filePath);
const fs = require('fs').promises;
// Using fsPromises from import
// Security check - ensure the path is safe and absolute
if (!filePath || !path.isAbsolute(filePath)) {
@@ -342,14 +378,14 @@ app.put('/api/projects/:projectName/file', async (req, res) => {
// Create backup of original file
try {
const backupPath = filePath + '.backup.' + Date.now();
await fs.copyFile(filePath, backupPath);
await fsPromises.copyFile(filePath, backupPath);
console.log('📋 Created backup:', backupPath);
} catch (backupError) {
console.warn('Could not create backup:', backupError.message);
}
// Write the new content
await fs.writeFile(filePath, content, 'utf8');
await fsPromises.writeFile(filePath, content, 'utf8');
res.json({
success: true,
@@ -368,56 +404,24 @@ 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;
// Using fsPromises from import
// 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
try {
await fs.access(actualPath);
await fsPromises.access(actualPath);
} catch (e) {
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
}
@@ -438,12 +442,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,9 +666,9 @@ 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 multer = (await import('multer')).default;
const upload = multer({ storage: multer.memoryStorage() });
// Handle multipart form data
@@ -680,7 +688,7 @@ app.post('/api/transcribe', async (req, res) => {
try {
// Create form data for OpenAI
const FormData = require('form-data');
const FormData = (await import('form-data')).default;
const formData = new FormData();
formData.append('file', req.file.buffer, {
filename: req.file.originalname,
@@ -723,7 +731,7 @@ app.post('/api/transcribe', async (req, res) => {
// Handle different enhancement modes
try {
const OpenAI = require('openai');
const OpenAI = (await import('openai')).default;
const openai = new OpenAI({ apiKey });
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
@@ -806,18 +814,110 @@ Agent instructions:`;
}
});
// Image upload endpoint
app.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {
try {
const multer = (await import('multer')).default;
const path = (await import('path')).default;
const fs = (await import('fs')).promises;
const os = (await import('os')).default;
// Configure multer for image uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
cb(null, uniqueSuffix + '-' + sanitizedName);
}
});
const fileFilter = (req, file, cb) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
if (allowedMimes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
files: 5
}
});
// Handle multipart form data
upload.array('images', 5)(req, res, async (err) => {
if (err) {
return res.status(400).json({ error: err.message });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No image files provided' });
}
try {
// Process uploaded images
const processedImages = await Promise.all(
req.files.map(async (file) => {
// Read file and convert to base64
const buffer = await fs.readFile(file.path);
const base64 = buffer.toString('base64');
const mimeType = file.mimetype;
// Clean up temp file immediately
await fs.unlink(file.path);
return {
name: file.originalname,
data: `data:${mimeType};base64,${base64}`,
size: file.size,
mimeType: mimeType
};
})
);
res.json({ images: processedImages });
} catch (error) {
console.error('Error processing images:', error);
// Clean up any remaining files
await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => {})));
res.status(500).json({ error: 'Failed to process images' });
}
});
} catch (error) {
console.error('Error in image upload endpoint:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Serve React app for all other routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../dist/index.html'));
});
// Helper function to convert permissions to rwx format
function permToRwx(perm) {
const r = perm & 4 ? 'r' : '-';
const w = perm & 2 ? 'w' : '-';
const x = perm & 1 ? 'x' : '-';
return r + w + x;
}
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
const fs = require('fs').promises;
// Using fsPromises from import
const items = [];
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
// Debug: log all entries including hidden files
@@ -828,17 +928,39 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
entry.name === 'dist' ||
entry.name === 'build') continue;
const itemPath = path.join(dirPath, entry.name);
const item = {
name: entry.name,
path: path.join(dirPath, entry.name),
path: itemPath,
type: entry.isDirectory() ? 'directory' : 'file'
};
// Get file stats for additional metadata
try {
const stats = await fsPromises.stat(itemPath);
item.size = stats.size;
item.modified = stats.mtime.toISOString();
// Convert permissions to rwx format
const mode = stats.mode;
const ownerPerm = (mode >> 6) & 7;
const groupPerm = (mode >> 3) & 7;
const otherPerm = mode & 7;
item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();
item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);
} catch (statError) {
// If stat fails, provide default values
item.size = 0;
item.modified = null;
item.permissions = '000';
item.permissionsRwx = '---------';
}
if (entry.isDirectory() && currentDepth < maxDepth) {
// Recursively get subdirectories but limit depth
try {
// Check if we can access the directory before trying to read it
await fs.access(item.path, fs.constants.R_OK);
await fsPromises.access(item.path, fs.constants.R_OK);
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
} catch (e) {
// Silently skip directories we can't access (permission denied, etc.)
@@ -864,9 +986,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 initialization skipped (testing)');
server.listen(PORT, '0.0.0.0', async () => {
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
// Start watching the projects folder for changes
await setupProjectsWatcher(); // Re-enabled with better-sqlite3
});
} 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 @@
import jwt from 'jsonwebtoken';
import { userDb } from '../database/db.js';
// 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 = 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;
}
};
export {
validateApiKey,
authenticateToken,
generateToken,
authenticateWebSocket,
JWT_SECRET
};

View File

@@ -1,6 +1,18 @@
const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import readline from '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() {
@@ -54,12 +66,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 +87,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 = fsSync.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;
}
}
@@ -294,7 +326,7 @@ async function parseJsonlSessions(filePath) {
const sessions = new Map();
try {
const fileStream = require('fs').createReadStream(filePath);
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
@@ -378,7 +410,7 @@ async function getSessionMessages(projectName, sessionId) {
// Process all JSONL files to find messages for this session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = require('fs').createReadStream(jsonlFile);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
@@ -570,7 +602,7 @@ async function addProjectManually(projectPath, displayName = null) {
}
module.exports = {
export {
getProjects,
getSessions,
getSessionMessages,
@@ -582,5 +614,6 @@ module.exports = {
addProjectManually,
loadProjectConfig,
saveProjectConfig,
extractProjectDirectory
extractProjectDirectory,
clearProjectDirectoryCache
};

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

@@ -0,0 +1,125 @@
import express from 'express';
import bcrypt from 'bcrypt';
import { userDb } from '../database/db.js';
import { generateToken, authenticateToken } from '../middleware/auth.js';
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 = 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 = userDb.createUser(username, passwordHash);
// Generate token
const token = generateToken(user);
// Update last login
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 = 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
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' });
});
export default router;

View File

@@ -1,17 +1,49 @@
const express = require('express');
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs').promises;
import express from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js';
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 = '';
@@ -385,4 +420,4 @@ function generateSimpleCommitMessage(files, diff) {
}
}
module.exports = router;
export default router;

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
@@ -582,6 +585,7 @@ function AppContent() {
onShowSettings={() => setShowToolsSettings(true)}
autoExpandTools={autoExpandTools}
showRawParameters={showRawParameters}
autoScrollToBottom={autoScrollToBottom}
/>
</div>
@@ -633,12 +637,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

@@ -18,11 +18,13 @@
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
import ReactMarkdown from 'react-markdown';
import { useDropzone } from 'react-dropzone';
import TodoList from './TodoList';
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 }) => {
@@ -71,6 +73,19 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
</div>
{message.images && message.images.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
{message.images.map((img, idx) => (
<img
key={idx}
src={img.data}
alt={img.name}
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => window.open(img.data, '_blank')}
/>
))}
</div>
)}
<div className="text-xs text-blue-100 mt-1 text-right">
{new Date(message.timestamp).toLocaleTimeString()}
</div>
@@ -874,6 +889,43 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
);
});
// ImageAttachment component for displaying image previews
const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
const [preview, setPreview] = useState(null);
useEffect(() => {
const url = URL.createObjectURL(file);
setPreview(url);
return () => URL.revokeObjectURL(url);
}, [file]);
return (
<div className="relative group">
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
{uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="text-white text-xs">{uploadProgress}%</div>
</div>
)}
{error && (
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
)}
<button
onClick={onRemove}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
};
// ChatInterface: Main chat component with Session Protection System integration
//
// Session Protection System prevents automatic project updates from interrupting active conversations:
@@ -902,6 +954,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [sessionMessages, setSessionMessages] = useState([]);
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
const [permissionMode, setPermissionMode] = useState('default');
const [attachedImages, setAttachedImages] = useState([]);
const [uploadingImages, setUploadingImages] = useState(new Map());
const [imageErrors, setImageErrors] = useState(new Map());
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
const scrollContainerRef = useRef(null);
@@ -949,7 +1005,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');
}
@@ -1378,6 +1434,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setCurrentSessionId(pendingSessionId);
sessionStorage.removeItem('pendingSessionId');
}
// Clear persisted chat messages after successful completion
if (selectedProject && latestMessage.exitCode === 0) {
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
}
break;
case 'session-aborted':
@@ -1451,7 +1512,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
@@ -1595,6 +1656,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, []); // Only run once on mount
// Reset textarea height when input is cleared programmatically
useEffect(() => {
if (textareaRef.current && !input.trim()) {
textareaRef.current.style.height = 'auto';
setIsTextareaExpanded(false);
}
}, [input]);
const handleTranscript = useCallback((text) => {
if (text.trim()) {
setInput(prevInput => {
@@ -1618,13 +1687,105 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, []);
const handleSubmit = (e) => {
// Handle image files from drag & drop or file picker
const handleImageFiles = useCallback((files) => {
const validFiles = files.filter(file => {
if (!file.type.startsWith('image/')) {
return false;
}
if (file.size > 5 * 1024 * 1024) {
setImageErrors(prev => new Map(prev).set(file.name, 'File too large (max 5MB)'));
return false;
}
return true;
});
if (validFiles.length > 0) {
setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images
}
}, []);
// Handle clipboard paste for images
const handlePaste = useCallback(async (e) => {
const items = Array.from(e.clipboardData.items);
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
handleImageFiles([file]);
}
}
}
// Fallback for some browsers/platforms
if (items.length === 0 && e.clipboardData.files.length > 0) {
const files = Array.from(e.clipboardData.files);
const imageFiles = files.filter(f => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
handleImageFiles(imageFiles);
}
}
}, [handleImageFiles]);
// Setup dropzone
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']
},
maxSize: 5 * 1024 * 1024, // 5MB
maxFiles: 5,
onDrop: handleImageFiles,
noClick: true, // We'll use our own button
noKeyboard: true
});
const handleSubmit = async (e) => {
e.preventDefault();
if (!input.trim() || isLoading || !selectedProject) return;
// Upload images first if any
let uploadedImages = [];
if (attachedImages.length > 0) {
const formData = new FormData();
attachedImages.forEach(file => {
formData.append('images', file);
});
try {
const token = localStorage.getItem('auth-token');
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, {
method: 'POST',
headers: headers,
body: formData
});
if (!response.ok) {
throw new Error('Failed to upload images');
}
const result = await response.json();
uploadedImages = result.images;
} catch (error) {
console.error('Image upload failed:', error);
setChatMessages(prev => [...prev, {
type: 'error',
content: `Failed to upload images: ${error.message}`,
timestamp: new Date()
}]);
return;
}
}
const userMessage = {
type: 'user',
content: input,
images: uploadedImages,
timestamp: new Date()
};
@@ -1671,7 +1832,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const toolsSettings = getToolsSettings();
// Send command to Claude CLI via WebSocket
// Send command to Claude CLI via WebSocket with images
sendMessage({
type: 'claude-command',
command: input,
@@ -1680,12 +1841,25 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
cwd: selectedProject.fullPath,
sessionId: currentSessionId,
resume: !!currentSessionId,
toolsSettings: toolsSettings
toolsSettings: toolsSettings,
permissionMode: permissionMode,
images: uploadedImages // Pass images to backend
}
});
setInput('');
setAttachedImages([]);
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
// Clear the saved draft since message was sent
if (selectedProject) {
localStorage.removeItem(`draft_input_${selectedProject.name}`);
@@ -1725,6 +1899,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}
// Handle Tab key for mode switching (only when file dropdown is not showing)
if (e.key === 'Tab' && !showFileDropdown) {
e.preventDefault();
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;
setPermissionMode(modes[nextIndex]);
return;
}
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
if (e.key === 'Enter') {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
@@ -1763,8 +1947,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
};
const handleInputChange = (e) => {
setInput(e.target.value);
const newValue = e.target.value;
setInput(newValue);
setCursorPosition(e.target.selectionStart);
// Handle height reset when input becomes empty
if (!newValue.trim()) {
e.target.style.height = 'auto';
setIsTextareaExpanded(false);
}
};
const handleTextareaClick = (e) => {
@@ -1789,6 +1980,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
};
const handleModeSwitch = () => {
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
const currentIndex = modes.indexOf(permissionMode);
const nextIndex = (currentIndex + 1) % modes.length;
setPermissionMode(modes[nextIndex]);
};
// Don't render if no project is selected
if (!selectedProject) {
return (
@@ -1887,18 +2085,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div ref={messagesEndRef} />
</div>
{/* Floating scroll to bottom button - positioned outside scrollable container */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="fixed bottom-20 sm:bottom-24 right-4 sm:right-6 w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 z-50"
title="Scroll to bottom"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
{/* Input Area - Fixed Bottom */}
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
@@ -1911,14 +2097,98 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
onAbort={handleAbortSession}
/>
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
<div className="max-w-4xl mx-auto mb-3">
<div className="flex items-center justify-center gap-3">
<button
type="button"
onClick={handleModeSwitch}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${
permissionMode === 'default'
? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
: permissionMode === 'acceptEdits'
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-100 dark:hover:bg-green-900/30'
: permissionMode === 'bypassPermissions'
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30'
}`}
title="Click to change permission mode (or press Tab in input)"
>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
permissionMode === 'default'
? 'bg-gray-500'
: permissionMode === 'acceptEdits'
? 'bg-green-500'
: permissionMode === 'bypassPermissions'
? 'bg-orange-500'
: 'bg-blue-500'
}`} />
<span>
{permissionMode === 'default' && 'Default Mode'}
{permissionMode === 'acceptEdits' && 'Accept Edits'}
{permissionMode === 'bypassPermissions' && 'Bypass Permissions'}
{permissionMode === 'plan' && 'Plan Mode'}
</span>
</div>
</button>
{/* Scroll to bottom button - positioned next to mode indicator */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
title="Scroll to bottom"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
<div className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
{/* Drag overlay */}
{isDragActive && (
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-sm font-medium">Drop images here</p>
</div>
</div>
)}
{/* Image attachments preview */}
{attachedImages.length > 0 && (
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex flex-wrap gap-2">
{attachedImages.map((file, index) => (
<ImageAttachment
key={index}
file={file}
onRemove={() => {
setAttachedImages(prev => prev.filter((_, i) => i !== index));
}}
uploadProgress={uploadingImages.get(file.name)}
error={imageErrors.get(file.name)}
/>
))}
</div>
</div>
)}
<div {...getRootProps()} className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
<input {...getInputProps()} />
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onClick={handleTextareaClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onInput={(e) => {
@@ -1935,7 +2205,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
placeholder="Ask Claude to help with your code... (@ to reference files)"
disabled={isLoading}
rows={1}
className="chat-input-placeholder w-full px-4 sm:px-6 py-3 sm:py-4 pr-28 sm:pr-40 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-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
className="chat-input-placeholder w-full pl-12 pr-28 sm:pr-40 py-3 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-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
style={{ height: 'auto' }}
/>
{/* Clear button - shown when there's text */}
@@ -1980,6 +2250,18 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</svg>
</button>
)}
{/* Image upload button */}
<button
type="button"
onClick={open}
className="absolute left-2 bottom-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Attach images"
>
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
{/* Mic button - HIDDEN */}
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton
@@ -2040,12 +2322,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</div>
{/* Hint text */}
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
Press Enter to send Shift+Enter for new line @ to reference files
Press Enter to send Shift+Enter for new line Tab to change modes @ to reference files
</div>
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
isInputFocused ? 'opacity-100' : 'opacity-0'
}`}>
Enter to send @ for files
Enter to send Tab for modes @ for files
</div>
</form>
</div>

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

@@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react';
import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Folder, FolderOpen, File, FileText, FileCode } from 'lucide-react';
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye } 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([]);
@@ -12,6 +13,7 @@ function FileTree({ selectedProject }) {
const [expandedDirs, setExpandedDirs] = useState(new Set());
const [selectedFile, setSelectedFile] = useState(null);
const [selectedImage, setSelectedImage] = useState(null);
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
useEffect(() => {
if (selectedProject) {
@@ -19,10 +21,18 @@ function FileTree({ selectedProject }) {
}
}, [selectedProject]);
// Load view mode preference from localStorage
useEffect(() => {
const savedViewMode = localStorage.getItem('file-tree-view-mode');
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
setViewMode(savedViewMode);
}
}, []);
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();
@@ -51,6 +61,35 @@ function FileTree({ selectedProject }) {
setExpandedDirs(newExpanded);
};
// Change view mode and save preference
const changeViewMode = (mode) => {
setViewMode(mode);
localStorage.setItem('file-tree-view-mode', mode);
};
// Format file size
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Format date as relative time
const formatRelativeTime = (date) => {
if (!date) return '-';
const now = new Date();
const past = new Date(date);
const diffInSeconds = Math.floor((now - past) / 1000);
if (diffInSeconds < 60) return 'just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} min ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`;
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)} days ago`;
return past.toLocaleDateString();
};
const renderFileTree = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
@@ -134,6 +173,129 @@ function FileTree({ selectedProject }) {
}
};
// Render detailed view with table-like layout
const renderDetailedView = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<div
className={cn(
"grid grid-cols-12 gap-2 p-2 hover:bg-accent cursor-pointer items-center",
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="col-span-5 flex items-center gap-2 min-w-0">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
<div className="col-span-2 text-sm text-muted-foreground">
{item.type === 'file' ? formatFileSize(item.size) : '-'}
</div>
<div className="col-span-3 text-sm text-muted-foreground">
{formatRelativeTime(item.modified)}
</div>
<div className="col-span-2 text-sm text-muted-foreground font-mono">
{item.permissionsRwx || '-'}
</div>
</div>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
renderDetailedView(item.children, level + 1)}
</div>
));
};
// Render compact view with inline details
const renderCompactView = (items, level = 0) => {
return items.map((item) => (
<div key={item.path} className="select-none">
<div
className={cn(
"flex items-center justify-between p-2 hover:bg-accent cursor-pointer",
)}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => {
if (item.type === 'directory') {
toggleDirectory(item.path);
} else if (isImageFile(item.name)) {
setSelectedImage({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
} else {
setSelectedFile({
name: item.name,
path: item.path,
projectPath: selectedProject.path,
projectName: selectedProject.name
});
}
}}
>
<div className="flex items-center gap-2 min-w-0">
{item.type === 'directory' ? (
expandedDirs.has(item.path) ? (
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
) : (
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)
) : (
getFileIcon(item.name)
)}
<span className="text-sm truncate text-foreground">
{item.name}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{item.type === 'file' && (
<>
<span>{formatFileSize(item.size)}</span>
<span className="font-mono">{item.permissionsRwx}</span>
</>
)}
</div>
</div>
{item.type === 'directory' &&
expandedDirs.has(item.path) &&
item.children &&
renderCompactView(item.children, level + 1)}
</div>
));
};
if (loading) {
return (
<div className="h-full flex items-center justify-center">
@@ -146,6 +308,51 @@ function FileTree({ selectedProject }) {
return (
<div className="h-full flex flex-col bg-card">
{/* View Mode Toggle */}
<div className="p-4 border-b border-border flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Files</h3>
<div className="flex gap-1">
<Button
variant={viewMode === 'simple' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => changeViewMode('simple')}
title="Simple view"
>
<List className="w-4 h-4" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => changeViewMode('compact')}
title="Compact view"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => changeViewMode('detailed')}
title="Detailed view"
>
<TableProperties className="w-4 h-4" />
</Button>
</div>
</div>
{/* Column Headers for Detailed View */}
{viewMode === 'detailed' && files.length > 0 && (
<div className="px-4 pt-2 pb-1 border-b border-border">
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
<div className="col-span-5">Name</div>
<div className="col-span-2">Size</div>
<div className="col-span-3">Modified</div>
<div className="col-span-2">Permissions</div>
</div>
</div>
)}
<ScrollArea className="flex-1 p-4">
{files.length === 0 ? (
@@ -159,8 +366,10 @@ function FileTree({ selectedProject }) {
</p>
</div>
) : (
<div className="space-y-1">
{renderFileTree(files)}
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
{viewMode === 'simple' && renderFileTree(files)}
{viewMode === 'compact' && renderCompactView(files)}
{viewMode === 'detailed' && renderDetailedView(files)}
</div>
)}
</ScrollArea>

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

@@ -3,9 +3,11 @@ import { ScrollArea } from './ui/scroll-area';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
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 { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } 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) => {
@@ -60,14 +62,32 @@ function Sidebar({
const [additionalSessions, setAdditionalSessions] = useState({});
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
const [currentTime, setCurrentTime] = useState(new Date());
const [projectSortOrder, setProjectSortOrder] = useState('name');
const [isRefreshing, setIsRefreshing] = useState(false);
const [editingSession, setEditingSession] = useState(null);
const [editingSessionName, setEditingSessionName] = useState('');
const [generatingSummary, setGeneratingSummary] = useState({});
const [searchFilter, setSearchFilter] = useState('');
// Touch handler to prevent double-tap issues on iPad
// Starred projects state - persisted in localStorage
const [starredProjects, setStarredProjects] = useState(() => {
try {
const saved = localStorage.getItem('starredProjects');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch (error) {
console.error('Error loading starred projects:', error);
return new Set();
}
});
// Touch handler to prevent double-tap issues on iPad (only for buttons, not scroll areas)
const handleTouchClick = (callback) => {
return (e) => {
// Only prevent default for buttons/clickable elements, not scrollable areas
if (e.target.closest('.overflow-y-auto') || e.target.closest('[data-scroll-container]')) {
return;
}
e.preventDefault();
e.stopPropagation();
callback();
@@ -109,6 +129,45 @@ function Sidebar({
}
}, [projects, isLoading]);
// Load project sort order from settings
useEffect(() => {
const loadSortOrder = () => {
try {
const savedSettings = localStorage.getItem('claude-tools-settings');
if (savedSettings) {
const settings = JSON.parse(savedSettings);
setProjectSortOrder(settings.projectSortOrder || 'name');
}
} catch (error) {
console.error('Error loading sort order:', error);
}
};
// Load initially
loadSortOrder();
// Listen for storage changes
const handleStorageChange = (e) => {
if (e.key === 'claude-tools-settings') {
loadSortOrder();
}
};
window.addEventListener('storage', handleStorageChange);
// Also check periodically when component is focused (for same-tab changes)
const checkInterval = setInterval(() => {
if (document.hasFocus()) {
loadSortOrder();
}
}, 1000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(checkInterval);
};
}, []);
const toggleProject = (projectName) => {
const newExpanded = new Set(expandedProjects);
if (newExpanded.has(projectName)) {
@@ -119,6 +178,71 @@ function Sidebar({
setExpandedProjects(newExpanded);
};
// Starred projects utility functions
const toggleStarProject = (projectName) => {
const newStarred = new Set(starredProjects);
if (newStarred.has(projectName)) {
newStarred.delete(projectName);
} else {
newStarred.add(projectName);
}
setStarredProjects(newStarred);
// Persist to localStorage
try {
localStorage.setItem('starredProjects', JSON.stringify([...newStarred]));
} catch (error) {
console.error('Error saving starred projects:', error);
}
};
const isProjectStarred = (projectName) => {
return starredProjects.has(projectName);
};
// Helper function to get all sessions for a project (initial + additional)
const getAllSessions = (project) => {
const initialSessions = project.sessions || [];
const additional = additionalSessions[project.name] || [];
return [...initialSessions, ...additional];
};
// Helper function to get the last activity date for a project
const getProjectLastActivity = (project) => {
const allSessions = getAllSessions(project);
if (allSessions.length === 0) {
return new Date(0); // Return epoch date for projects with no sessions
}
// Find the most recent session activity
const mostRecentDate = allSessions.reduce((latest, session) => {
const sessionDate = new Date(session.lastActivity);
return sessionDate > latest ? sessionDate : latest;
}, new Date(0));
return mostRecentDate;
};
// Combined sorting: starred projects first, then by selected order
const sortedProjects = [...projects].sort((a, b) => {
const aStarred = isProjectStarred(a.name);
const bStarred = isProjectStarred(b.name);
// First, sort by starred status
if (aStarred && !bStarred) return -1;
if (!aStarred && bStarred) return 1;
// For projects with same starred status, sort by selected order
if (projectSortOrder === 'date') {
// Sort by most recent activity (descending)
return getProjectLastActivity(b) - getProjectLastActivity(a);
} else {
// Sort by display name (user-defined) or fallback to name (ascending)
const nameA = a.displayName || a.name;
const nameB = b.displayName || b.name;
return nameA.localeCompare(nameB);
}
});
const startEditing = (project) => {
setEditingProject(project.name);
@@ -132,13 +256,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 +282,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 +305,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 +332,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();
@@ -297,12 +403,17 @@ function Sidebar({
}
};
// Helper function to get all sessions for a project (initial + additional)
const getAllSessions = (project) => {
const initialSessions = project.sessions || [];
const additional = additionalSessions[project.name] || [];
return [...initialSessions, ...additional];
};
// Filter projects based on search input
const filteredProjects = sortedProjects.filter(project => {
if (!searchFilter.trim()) return true;
const searchLower = searchFilter.toLowerCase();
const displayName = (project.displayName || project.name).toLowerCase();
const projectName = project.name.toLowerCase();
// Search in both display name and actual project name/path
return displayName.includes(searchLower) || projectName.includes(searchLower);
});
return (
<div className="h-full flex flex-col bg-card md:select-none">
@@ -488,6 +599,30 @@ function Sidebar({
</div>
)}
{/* Search Filter */}
{projects.length > 0 && !isLoading && (
<div className="px-3 md:px-4 py-2 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder="Search projects..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
/>
{searchFilter && (
<button
onClick={() => setSearchFilter('')}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 hover:bg-accent rounded"
>
<X className="w-3 h-3 text-muted-foreground" />
</button>
)}
</div>
</div>
)}
{/* Projects List */}
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
<div className="md:space-y-1 pb-safe-area-inset-bottom">
@@ -511,10 +646,21 @@ function Sidebar({
Run Claude CLI in a project directory to get started
</p>
</div>
) : filteredProjects.length === 0 ? (
<div className="text-center py-12 md:py-8 px-4">
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No matching projects</h3>
<p className="text-sm text-muted-foreground">
Try adjusting your search term
</p>
</div>
) : (
projects.map((project) => {
filteredProjects.map((project) => {
const isExpanded = expandedProjects.has(project.name);
const isSelected = selectedProject?.name === project.name;
const isStarred = isProjectStarred(project.name);
return (
<div key={project.name} className="md:space-y-1">
@@ -525,7 +671,8 @@ function Sidebar({
<div
className={cn(
"p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150",
isSelected && "bg-primary/5 border-primary/20"
isSelected && "bg-primary/5 border-primary/20",
isStarred && !isSelected && "bg-yellow-50/50 dark:bg-yellow-900/5 border-yellow-200/30 dark:border-yellow-800/30"
)}
onClick={() => {
// On mobile, just toggle the folder - don't select the project
@@ -607,6 +754,28 @@ function Sidebar({
</>
) : (
<>
{/* Star button */}
<button
className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border",
isStarred
? "bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800"
: "bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800"
)}
onClick={(e) => {
e.stopPropagation();
toggleStarProject(project.name);
}}
onTouchEnd={handleTouchClick(() => toggleStarProject(project.name))}
title={isStarred ? "Remove from favorites" : "Add to favorites"}
>
<Star className={cn(
"w-4 h-4 transition-colors",
isStarred
? "text-yellow-600 dark:text-yellow-400 fill-current"
: "text-gray-600 dark:text-gray-400"
)} />
</button>
{getAllSessions(project).length === 0 && (
<button
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
@@ -648,7 +817,8 @@ function Sidebar({
variant="ghost"
className={cn(
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50",
isSelected && "bg-accent text-accent-foreground"
isSelected && "bg-accent text-accent-foreground",
isStarred && !isSelected && "bg-yellow-50/50 dark:bg-yellow-900/10 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/20"
)}
onClick={() => {
// Desktop behavior: select project and toggle
@@ -735,6 +905,27 @@ function Sidebar({
</>
) : (
<>
{/* Star button */}
<div
className={cn(
"w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100",
isStarred
? "hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100"
: "hover:bg-accent"
)}
onClick={(e) => {
e.stopPropagation();
toggleStarProject(project.name);
}}
title={isStarred ? "Remove from favorites" : "Add to favorites"}
>
<Star className={cn(
"w-3 h-3 transition-colors",
isStarred
? "text-yellow-600 dark:text-yellow-400 fill-current"
: "text-muted-foreground"
)} />
</div>
<div
className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-accent flex items-center justify-center rounded cursor-pointer touch:opacity-100"
onClick={(e) => {

View File

@@ -15,6 +15,7 @@ function ToolsSettings({ isOpen, onClose }) {
const [skipPermissions, setSkipPermissions] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState(null);
const [projectSortOrder, setProjectSortOrder] = useState('name');
// Common tool patterns
const commonTools = [
@@ -51,11 +52,13 @@ function ToolsSettings({ isOpen, onClose }) {
setAllowedTools(settings.allowedTools || []);
setDisallowedTools(settings.disallowedTools || []);
setSkipPermissions(settings.skipPermissions || false);
setProjectSortOrder(settings.projectSortOrder || 'name');
} else {
// Set defaults
setAllowedTools([]);
setDisallowedTools([]);
setSkipPermissions(false);
setProjectSortOrder('name');
}
} catch (error) {
console.error('Error loading tool settings:', error);
@@ -63,6 +66,7 @@ function ToolsSettings({ isOpen, onClose }) {
setAllowedTools([]);
setDisallowedTools([]);
setSkipPermissions(false);
setProjectSortOrder('name');
}
};
@@ -75,6 +79,7 @@ function ToolsSettings({ isOpen, onClose }) {
allowedTools,
disallowedTools,
skipPermissions,
projectSortOrder,
lastUpdated: new Date().toISOString()
};
@@ -181,6 +186,28 @@ function ToolsSettings({ isOpen, onClose }) {
</span>
</button>
</div>
{/* Project Sorting - Moved under Appearance */}
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-foreground">
Project Sorting
</div>
<div className="text-sm text-muted-foreground">
How projects are ordered in the sidebar
</div>
</div>
<select
value={projectSortOrder}
onChange={(e) => setProjectSortOrder(e.target.value)}
className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-32"
>
<option value="name">Alphabetical</option>
<option value="date">Recent Activity</option>
</select>
</div>
</div>
</div>
</div>
@@ -418,4 +445,4 @@ function ToolsSettings({ isOpen, onClose }) {
);
}
export default ToolsSettings;
export default ToolsSettings;

View File

@@ -7,7 +7,13 @@ const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) =>
className={cn("relative overflow-hidden", className)}
{...props}
>
<div className="h-full w-full rounded-[inherit] overflow-auto">
<div
className="h-full w-full rounded-[inherit] overflow-auto"
style={{
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y'
}}
>
{children}
</div>
</div>

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>
);
};

View File

@@ -436,6 +436,12 @@
-webkit-tap-highlight-color: transparent;
}
/* Allow vertical scrolling in scroll containers */
.overflow-y-auto, [data-scroll-container] {
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
/* Preserve checkbox visibility */
input[type="checkbox"] {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);

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(() => ({}));