mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-13 21:59:37 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d63b18c69 | ||
|
|
9f65756e2c | ||
|
|
db8c086f22 | ||
|
|
111311eac5 | ||
|
|
6934581488 | ||
|
|
c0f30afb23 | ||
|
|
aa1df3c9c8 | ||
|
|
ed920bb73b | ||
|
|
e72be46733 | ||
|
|
a5ddef58df | ||
|
|
02cc0257ae | ||
|
|
24282ababe | ||
|
|
71ac848d60 | ||
|
|
7329f89c96 | ||
|
|
00acc57161 | ||
|
|
9ac604de41 | ||
|
|
046f270a11 | ||
|
|
7feeebc2ae | ||
|
|
ad0bcba117 | ||
|
|
a56e06385d | ||
|
|
5ec51dacc3 | ||
|
|
a79028a124 | ||
|
|
2435d12a0b | ||
|
|
54d5583bc2 | ||
|
|
ce1e6c73b3 | ||
|
|
c6c11c236c | ||
|
|
211a3c4557 | ||
|
|
02a296739d | ||
|
|
1f3fe2df3d | ||
|
|
122b757fa2 | ||
|
|
45b3e54d04 | ||
|
|
4762a2d719 | ||
|
|
634e00264e | ||
|
|
fc2a94a2e5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -90,6 +90,7 @@ jspm_packages/
|
|||||||
# Temporary folders
|
# Temporary folders
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
.vite/
|
.vite/
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -33,10 +33,11 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile
|
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code from mobile
|
||||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code
|
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code
|
||||||
- **Integrated Shell Terminal** - Direct access to Claude Code CLI through built-in shell functionality
|
- **Integrated Shell Terminal** - Direct access to Claude Code CLI through built-in shell functionality
|
||||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||||
|
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||||
|
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
|||||||
|
|
||||||
### Prerequisites
|
### 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
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
@@ -72,6 +73,7 @@ cp .env.example .env
|
|||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
```
|
```
|
||||||
|
The application will start at the port you specified in your .env
|
||||||
|
|
||||||
5. **Open your browser:**
|
5. **Open your browser:**
|
||||||
- Development: `http://localhost:3001`
|
- Development: `http://localhost:3001`
|
||||||
@@ -120,17 +122,21 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
|||||||
- **Syntax Highlighting** - Support for multiple programming languages
|
- **Syntax Highlighting** - Support for multiple programming languages
|
||||||
- **File Operations** - Create, rename, delete files and directories
|
- **File Operations** - Create, rename, delete files and directories
|
||||||
|
|
||||||
|
#### Git Explorer
|
||||||
|
|
||||||
|
|
||||||
#### Session Management
|
#### Session Management
|
||||||
- **Session Persistence** - All conversations automatically saved
|
- **Session Persistence** - All conversations automatically saved
|
||||||
- **Session Organization** - Group sessions by project and timestamp
|
- **Session Organization** - Group sessions by project and timestamp
|
||||||
- **Session Actions** - Rename, delete, and export conversation history
|
- **Session Actions** - Rename, delete, and export conversation history
|
||||||
- **Cross-device Sync** - Access sessions from any device
|
- **Cross-device Sync** - Access sessions from any device
|
||||||
|
|
||||||
### Mobile Experience
|
### Mobile App
|
||||||
- **Responsive Design** - Optimized for all screen sizes
|
- **Responsive Design** - Optimized for all screen sizes
|
||||||
- **Touch-friendly Interface** - Swipe gestures and touch navigation
|
- **Touch-friendly Interface** - Swipe gestures and touch navigation
|
||||||
- **Mobile Navigation** - Bottom tab bar for easy thumb navigation
|
- **Mobile Navigation** - Bottom tab bar for easy thumb navigation
|
||||||
- **Adaptive Layout** - Collapsible sidebar and smart content prioritization
|
- **Adaptive Layout** - Collapsible sidebar and smart content prioritization
|
||||||
|
- **Add shortcut to Home Screen** - Add a shortcut to your home screen and the app will behave like a PWA
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -230,8 +236,10 @@ This project is open source and free to use, modify, and distribute under the GP
|
|||||||
- **Watch** for updates and new releases
|
- **Watch** for updates and new releases
|
||||||
- **Follow** the project for announcements
|
- **Follow** the project for announcements
|
||||||
|
|
||||||
|
### Sponsors
|
||||||
|
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<strong>Made with care for the Claude Code community</strong>
|
<strong>Made with care for the Claude Code community.</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1765
package-lock.json
generated
1765
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-ui",
|
"name": "claude-code-ui",
|
||||||
"version": "1.1.4",
|
"version": "1.3.0",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
|
"type": "module",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"better-sqlite3": "^12.2.0",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -42,13 +44,14 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
|
"multer": "^2.0.1",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"node-pty": "^1.0.0",
|
"node-pty": "^1.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.8.1",
|
"react-router-dom": "^6.8.1",
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
@@ -57,12 +60,12 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.43",
|
"@types/react": "^18.2.43",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-dom": "^18.2.17",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"vite": "^5.0.8"
|
"vite": "^7.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {},
|
||||||
|
|||||||
@@ -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
|
let activeClaudeProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
async function spawnClaude(command, options = {}, ws) {
|
async function spawnClaude(command, options = {}, ws) {
|
||||||
return new Promise(async (resolve, reject) => {
|
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 capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
|
|
||||||
@@ -14,7 +17,7 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
disallowedTools: [],
|
disallowedTools: [],
|
||||||
skipPermissions: false
|
skipPermissions: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build Claude CLI command - start with print/resume flags first
|
// Build Claude CLI command - start with print/resume flags first
|
||||||
const args = [];
|
const args = [];
|
||||||
|
|
||||||
@@ -23,6 +26,56 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
args.push('--print', command);
|
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
|
// Add resume flag if resuming
|
||||||
if (resume && sessionId) {
|
if (resume && sessionId) {
|
||||||
args.push('--resume', sessionId);
|
args.push('--resume', sessionId);
|
||||||
@@ -36,15 +89,38 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
args.push('--model', 'sonnet');
|
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
|
// 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');
|
args.push('--dangerously-skip-permissions');
|
||||||
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
|
console.log('⚠️ Using --dangerously-skip-permissions (skipping other tool settings)');
|
||||||
} else {
|
} else {
|
||||||
// Only add allowed/disallowed tools if not skipping permissions
|
// 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
|
// Add allowed tools
|
||||||
if (settings.allowedTools && settings.allowedTools.length > 0) {
|
if (allowedTools.length > 0) {
|
||||||
for (const tool of settings.allowedTools) {
|
for (const tool of allowedTools) {
|
||||||
args.push('--allowedTools', tool);
|
args.push('--allowedTools', tool);
|
||||||
console.log('✅ Allowing tool:', tool);
|
console.log('✅ Allowing tool:', tool);
|
||||||
}
|
}
|
||||||
@@ -57,17 +133,21 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
console.log('❌ Disallowing tool:', tool);
|
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 => {
|
console.log('Spawning Claude CLI:', 'claude', args.map(arg => {
|
||||||
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
const cleanArg = arg.replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||||
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
return cleanArg.includes(' ') ? `"${cleanArg}"` : cleanArg;
|
||||||
}).join(' '));
|
}).join(' '));
|
||||||
console.log('Working directory:', workingDir);
|
console.log('Working directory:', workingDir);
|
||||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
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, {
|
const claudeProcess = spawn('claude', args, {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
@@ -75,6 +155,10 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
env: { ...process.env } // Inherit all environment variables
|
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
|
// Store process reference for potential abort
|
||||||
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
||||||
activeClaudeProcesses.set(processKey, claudeProcess);
|
activeClaudeProcesses.set(processKey, claudeProcess);
|
||||||
@@ -138,7 +222,7 @@ async function spawnClaude(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle process completion
|
// Handle process completion
|
||||||
claudeProcess.on('close', (code) => {
|
claudeProcess.on('close', async (code) => {
|
||||||
console.log(`Claude CLI process exited with code ${code}`);
|
console.log(`Claude CLI process exited with code ${code}`);
|
||||||
|
|
||||||
// Clean up process reference
|
// 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
|
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) {
|
if (code === 0) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
@@ -201,7 +299,7 @@ function abortClaudeSession(sessionId) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
spawnClaude,
|
spawnClaude,
|
||||||
abortClaudeSession
|
abortClaudeSession
|
||||||
};
|
};
|
||||||
@@ -1,99 +1,85 @@
|
|||||||
const sqlite3 = require('sqlite3').verbose();
|
import Database from 'better-sqlite3';
|
||||||
const path = require('path');
|
import path from 'path';
|
||||||
const fs = require('fs');
|
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 DB_PATH = path.join(__dirname, 'auth.db');
|
||||||
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
|
const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
|
||||||
|
|
||||||
// Create database connection
|
// Create database connection
|
||||||
const db = new sqlite3.Database(DB_PATH, (err) => {
|
const db = new Database(DB_PATH);
|
||||||
if (err) {
|
console.log('Connected to SQLite database');
|
||||||
console.error('Error opening database:', err.message);
|
|
||||||
} else {
|
|
||||||
console.log('Connected to SQLite database');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize database with schema
|
// Initialize database with schema
|
||||||
const initializeDatabase = async () => {
|
const initializeDatabase = async () => {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
try {
|
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
|
||||||
const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
|
db.exec(initSQL);
|
||||||
db.exec(initSQL, (err) => {
|
console.log('Database initialized successfully');
|
||||||
if (err) {
|
} catch (error) {
|
||||||
console.error('Error initializing database:', err.message);
|
console.error('Error initializing database:', error.message);
|
||||||
reject(err);
|
throw error;
|
||||||
} else {
|
}
|
||||||
console.log('Database initialized successfully');
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading init SQL file:', error);
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// User database operations
|
// User database operations
|
||||||
const userDb = {
|
const userDb = {
|
||||||
// Check if any users exist
|
// Check if any users exist
|
||||||
hasUsers: () => {
|
hasUsers: () => {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
db.get('SELECT COUNT(*) as count FROM users', (err, row) => {
|
const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||||
if (err) reject(err);
|
return row.count > 0;
|
||||||
else resolve(row.count > 0);
|
} catch (err) {
|
||||||
});
|
throw err;
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create a new user
|
// Create a new user
|
||||||
createUser: (username, passwordHash) => {
|
createUser: (username, passwordHash) => {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
|
||||||
stmt.run(username, passwordHash, function(err) {
|
const result = stmt.run(username, passwordHash);
|
||||||
if (err) {
|
return { id: result.lastInsertRowid, username };
|
||||||
reject(err);
|
} catch (err) {
|
||||||
} else {
|
throw err;
|
||||||
resolve({ id: this.lastID, username });
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
stmt.finalize();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get user by username
|
// Get user by username
|
||||||
getUserByUsername: (username) => {
|
getUserByUsername: (username) => {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
db.get('SELECT * FROM users WHERE username = ? AND is_active = 1', [username], (err, row) => {
|
const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
|
||||||
if (err) reject(err);
|
return row;
|
||||||
else resolve(row);
|
} catch (err) {
|
||||||
});
|
throw err;
|
||||||
});
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update last login time
|
// Update last login time
|
||||||
updateLastLogin: (userId) => {
|
updateLastLogin: (userId) => {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [userId], (err) => {
|
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||||
if (err) reject(err);
|
} catch (err) {
|
||||||
else resolve();
|
throw err;
|
||||||
});
|
}
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get user by ID
|
// Get user by ID
|
||||||
getUserById: (userId) => {
|
getUserById: (userId) => {
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
db.get('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1', [userId], (err, row) => {
|
const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
|
||||||
if (err) reject(err);
|
return row;
|
||||||
else resolve(row);
|
} catch (err) {
|
||||||
});
|
throw err;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
db,
|
db,
|
||||||
initializeDatabase,
|
initializeDatabase,
|
||||||
userDb
|
userDb
|
||||||
|
|||||||
218
server/index.js
218
server/index.js
@@ -1,7 +1,13 @@
|
|||||||
// Load environment variables from .env file
|
// 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 {
|
try {
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const envPath = path.join(__dirname, '../.env');
|
const envPath = path.join(__dirname, '../.env');
|
||||||
const envFile = fs.readFileSync(envPath, 'utf8');
|
const envFile = fs.readFileSync(envPath, 'utf8');
|
||||||
envFile.split('\n').forEach(line => {
|
envFile.split('\n').forEach(line => {
|
||||||
@@ -19,31 +25,31 @@ try {
|
|||||||
|
|
||||||
console.log('PORT from env:', process.env.PORT);
|
console.log('PORT from env:', process.env.PORT);
|
||||||
|
|
||||||
const express = require('express');
|
import express from 'express';
|
||||||
const { WebSocketServer } = require('ws');
|
import { WebSocketServer } from 'ws';
|
||||||
const http = require('http');
|
import http from 'http';
|
||||||
const path = require('path');
|
import cors from 'cors';
|
||||||
const cors = require('cors');
|
import { promises as fsPromises } from 'fs';
|
||||||
const fs = require('fs').promises;
|
import { spawn } from 'child_process';
|
||||||
const { spawn } = require('child_process');
|
import os from 'os';
|
||||||
const os = require('os');
|
import pty from 'node-pty';
|
||||||
const pty = require('node-pty');
|
import fetch from 'node-fetch';
|
||||||
const fetch = require('node-fetch');
|
import mime from 'mime-types';
|
||||||
|
|
||||||
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } = require('./projects');
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||||
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
|
import { spawnClaude, abortClaudeSession } from './claude-cli.js';
|
||||||
const gitRoutes = require('./routes/git');
|
import gitRoutes from './routes/git.js';
|
||||||
const authRoutes = require('./routes/auth');
|
import authRoutes from './routes/auth.js';
|
||||||
const { initializeDatabase } = require('./database/db');
|
import { initializeDatabase } from './database/db.js';
|
||||||
const { validateApiKey, authenticateToken, authenticateWebSocket } = require('./middleware/auth');
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
|
|
||||||
// File system watcher for projects folder
|
// File system watcher for projects folder
|
||||||
let projectsWatcher = null;
|
let projectsWatcher = null;
|
||||||
const connectedClients = new Set();
|
const connectedClients = new Set();
|
||||||
|
|
||||||
// Setup file system watcher for Claude projects folder using chokidar
|
// Setup file system watcher for Claude projects folder using chokidar
|
||||||
function setupProjectsWatcher() {
|
async function setupProjectsWatcher() {
|
||||||
const chokidar = require('chokidar');
|
const chokidar = (await import('chokidar')).default;
|
||||||
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
const claudeProjectsPath = path.join(process.env.HOME, '.claude', 'projects');
|
||||||
|
|
||||||
if (projectsWatcher) {
|
if (projectsWatcher) {
|
||||||
@@ -124,18 +130,6 @@ function setupProjectsWatcher() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the first non-localhost IP address
|
|
||||||
function getServerIP() {
|
|
||||||
const interfaces = os.networkInterfaces();
|
|
||||||
for (const name of Object.keys(interfaces)) {
|
|
||||||
for (const iface of interfaces[name]) {
|
|
||||||
if (iface.family === 'IPv4' && !iface.internal) {
|
|
||||||
return iface.address;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 'localhost';
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
@@ -182,9 +176,7 @@ app.use(express.static(path.join(__dirname, '../dist')));
|
|||||||
|
|
||||||
// API Routes (protected)
|
// API Routes (protected)
|
||||||
app.get('/api/config', authenticateToken, (req, res) => {
|
app.get('/api/config', authenticateToken, (req, res) => {
|
||||||
// Always use the server's actual IP and port for WebSocket connections
|
const host = req.headers.host || `${req.hostname}:${PORT}`;
|
||||||
const serverIP = getServerIP();
|
|
||||||
const host = `${serverIP}:${PORT}`;
|
|
||||||
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
|
const protocol = req.protocol === 'https' || req.get('x-forwarded-proto') === 'https' ? 'wss' : 'ws';
|
||||||
|
|
||||||
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
|
console.log('Config API called - Returning host:', host, 'Protocol:', protocol);
|
||||||
@@ -283,14 +275,14 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
|
|||||||
|
|
||||||
console.log('📄 File read request:', projectName, filePath);
|
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
|
// Security check - ensure the path is safe and absolute
|
||||||
if (!filePath || !path.isAbsolute(filePath)) {
|
if (!filePath || !path.isAbsolute(filePath)) {
|
||||||
return res.status(400).json({ error: 'Invalid file path' });
|
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 });
|
res.json({ content, path: filePath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading file:', error);
|
console.error('Error reading file:', error);
|
||||||
@@ -312,8 +304,8 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
|
|||||||
|
|
||||||
console.log('🖼️ Binary file serve request:', projectName, filePath);
|
console.log('🖼️ Binary file serve request:', projectName, filePath);
|
||||||
|
|
||||||
const fs = require('fs');
|
// Using fs from import
|
||||||
const mime = require('mime-types');
|
// Using mime from import
|
||||||
|
|
||||||
// Security check - ensure the path is safe and absolute
|
// Security check - ensure the path is safe and absolute
|
||||||
if (!filePath || !path.isAbsolute(filePath)) {
|
if (!filePath || !path.isAbsolute(filePath)) {
|
||||||
@@ -322,7 +314,7 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
|
|||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(filePath);
|
await fsPromises.access(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(404).json({ error: 'File not found' });
|
return res.status(404).json({ error: 'File not found' });
|
||||||
}
|
}
|
||||||
@@ -358,7 +350,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
|
|||||||
|
|
||||||
console.log('💾 File save request:', projectName, filePath);
|
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
|
// Security check - ensure the path is safe and absolute
|
||||||
if (!filePath || !path.isAbsolute(filePath)) {
|
if (!filePath || !path.isAbsolute(filePath)) {
|
||||||
@@ -372,14 +364,14 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
|
|||||||
// Create backup of original file
|
// Create backup of original file
|
||||||
try {
|
try {
|
||||||
const backupPath = filePath + '.backup.' + Date.now();
|
const backupPath = filePath + '.backup.' + Date.now();
|
||||||
await fs.copyFile(filePath, backupPath);
|
await fsPromises.copyFile(filePath, backupPath);
|
||||||
console.log('📋 Created backup:', backupPath);
|
console.log('📋 Created backup:', backupPath);
|
||||||
} catch (backupError) {
|
} catch (backupError) {
|
||||||
console.warn('Could not create backup:', backupError.message);
|
console.warn('Could not create backup:', backupError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the new content
|
// Write the new content
|
||||||
await fs.writeFile(filePath, content, 'utf8');
|
await fsPromises.writeFile(filePath, content, 'utf8');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -401,7 +393,7 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
|
|||||||
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
app.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const fs = require('fs').promises;
|
// Using fsPromises from import
|
||||||
|
|
||||||
// Use extractProjectDirectory to get the actual project path
|
// Use extractProjectDirectory to get the actual project path
|
||||||
let actualPath;
|
let actualPath;
|
||||||
@@ -415,7 +407,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
|||||||
|
|
||||||
// Check if path exists
|
// Check if path exists
|
||||||
try {
|
try {
|
||||||
await fs.access(actualPath);
|
await fsPromises.access(actualPath);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
||||||
}
|
}
|
||||||
@@ -662,7 +654,7 @@ function handleShellConnection(ws) {
|
|||||||
// Audio transcription endpoint
|
// Audio transcription endpoint
|
||||||
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const multer = require('multer');
|
const multer = (await import('multer')).default;
|
||||||
const upload = multer({ storage: multer.memoryStorage() });
|
const upload = multer({ storage: multer.memoryStorage() });
|
||||||
|
|
||||||
// Handle multipart form data
|
// Handle multipart form data
|
||||||
@@ -682,7 +674,7 @@ app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create form data for OpenAI
|
// Create form data for OpenAI
|
||||||
const FormData = require('form-data');
|
const FormData = (await import('form-data')).default;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', req.file.buffer, {
|
formData.append('file', req.file.buffer, {
|
||||||
filename: req.file.originalname,
|
filename: req.file.originalname,
|
||||||
@@ -725,7 +717,7 @@ app.post('/api/transcribe', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
// Handle different enhancement modes
|
// Handle different enhancement modes
|
||||||
try {
|
try {
|
||||||
const OpenAI = require('openai');
|
const OpenAI = (await import('openai')).default;
|
||||||
const openai = new OpenAI({ apiKey });
|
const openai = new OpenAI({ apiKey });
|
||||||
|
|
||||||
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
|
let prompt, systemMessage, temperature = 0.7, maxTokens = 800;
|
||||||
@@ -808,18 +800,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
|
// Serve React app for all other routes
|
||||||
app.get('*', (req, res) => {
|
app.get('*', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
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) {
|
async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {
|
||||||
const fs = require('fs').promises;
|
// Using fsPromises from import
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
// Debug: log all entries including hidden files
|
// Debug: log all entries including hidden files
|
||||||
@@ -830,17 +914,39 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
|||||||
entry.name === 'dist' ||
|
entry.name === 'dist' ||
|
||||||
entry.name === 'build') continue;
|
entry.name === 'build') continue;
|
||||||
|
|
||||||
|
const itemPath = path.join(dirPath, entry.name);
|
||||||
const item = {
|
const item = {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: path.join(dirPath, entry.name),
|
path: itemPath,
|
||||||
type: entry.isDirectory() ? 'directory' : 'file'
|
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) {
|
if (entry.isDirectory() && currentDepth < maxDepth) {
|
||||||
// Recursively get subdirectories but limit depth
|
// Recursively get subdirectories but limit depth
|
||||||
try {
|
try {
|
||||||
// Check if we can access the directory before trying to read it
|
// 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);
|
item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently skip directories we can't access (permission denied, etc.)
|
// Silently skip directories we can't access (permission denied, etc.)
|
||||||
@@ -872,13 +978,13 @@ async function startServer() {
|
|||||||
try {
|
try {
|
||||||
// Initialize authentication database
|
// Initialize authentication database
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
console.log('✅ Database initialized successfully');
|
console.log('✅ Database initialization skipped (testing)');
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', async () => {
|
||||||
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
console.log(`Claude Code UI server running on http://0.0.0.0:${PORT}`);
|
||||||
|
|
||||||
// Start watching the projects folder for changes
|
// Start watching the projects folder for changes
|
||||||
setupProjectsWatcher();
|
await setupProjectsWatcher(); // Re-enabled with better-sqlite3
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to start server:', error);
|
console.error('❌ Failed to start server:', error);
|
||||||
@@ -886,4 +992,4 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startServer();
|
startServer();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
import jwt from 'jsonwebtoken';
|
||||||
const { userDb } = require('../database/db');
|
import { userDb } from '../database/db.js';
|
||||||
|
|
||||||
// Get JWT secret from environment or use default (for development)
|
// Get JWT secret from environment or use default (for development)
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
||||||
@@ -31,7 +31,7 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
// Verify user still exists and is active
|
// Verify user still exists and is active
|
||||||
const user = await userDb.getUserById(decoded.userId);
|
const user = userDb.getUserById(decoded.userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ const authenticateWebSocket = (token) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
validateApiKey,
|
validateApiKey,
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
generateToken,
|
generateToken,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const fs = require('fs').promises;
|
import { promises as fs } from 'fs';
|
||||||
const path = require('path');
|
import fsSync from 'fs';
|
||||||
const readline = require('readline');
|
import path from 'path';
|
||||||
|
import readline from 'readline';
|
||||||
|
|
||||||
// Cache for extracted project directories
|
// Cache for extracted project directories
|
||||||
const projectDirectoryCache = new Map();
|
const projectDirectoryCache = new Map();
|
||||||
@@ -10,7 +11,6 @@ let cacheTimestamp = Date.now();
|
|||||||
function clearProjectDirectoryCache() {
|
function clearProjectDirectoryCache() {
|
||||||
projectDirectoryCache.clear();
|
projectDirectoryCache.clear();
|
||||||
cacheTimestamp = Date.now();
|
cacheTimestamp = Date.now();
|
||||||
console.log('🗑️ Project directory cache cleared');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load project configuration file
|
// Load project configuration file
|
||||||
@@ -72,7 +72,6 @@ async function extractProjectDirectory(projectName) {
|
|||||||
return projectDirectoryCache.get(projectName);
|
return projectDirectoryCache.get(projectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔍 Extracting project directory for: ${projectName}`);
|
|
||||||
|
|
||||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||||
const cwdCounts = new Map();
|
const cwdCounts = new Map();
|
||||||
@@ -91,7 +90,7 @@ async function extractProjectDirectory(projectName) {
|
|||||||
// Process all JSONL files to collect cwd values
|
// Process all JSONL files to collect cwd values
|
||||||
for (const file of jsonlFiles) {
|
for (const file of jsonlFiles) {
|
||||||
const jsonlFile = path.join(projectDir, file);
|
const jsonlFile = path.join(projectDir, file);
|
||||||
const fileStream = require('fs').createReadStream(jsonlFile);
|
const fileStream = fsSync.createReadStream(jsonlFile);
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity
|
crlfDelay: Infinity
|
||||||
@@ -154,7 +153,6 @@ async function extractProjectDirectory(projectName) {
|
|||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
projectDirectoryCache.set(projectName, extractedPath);
|
projectDirectoryCache.set(projectName, extractedPath);
|
||||||
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
|
|
||||||
|
|
||||||
return extractedPath;
|
return extractedPath;
|
||||||
|
|
||||||
@@ -325,7 +323,7 @@ async function parseJsonlSessions(filePath) {
|
|||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fileStream = require('fs').createReadStream(filePath);
|
const fileStream = fsSync.createReadStream(filePath);
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity
|
crlfDelay: Infinity
|
||||||
@@ -409,7 +407,7 @@ async function getSessionMessages(projectName, sessionId) {
|
|||||||
// Process all JSONL files to find messages for this session
|
// Process all JSONL files to find messages for this session
|
||||||
for (const file of jsonlFiles) {
|
for (const file of jsonlFiles) {
|
||||||
const jsonlFile = path.join(projectDir, file);
|
const jsonlFile = path.join(projectDir, file);
|
||||||
const fileStream = require('fs').createReadStream(jsonlFile);
|
const fileStream = fsSync.createReadStream(jsonlFile);
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fileStream,
|
input: fileStream,
|
||||||
crlfDelay: Infinity
|
crlfDelay: Infinity
|
||||||
@@ -601,7 +599,7 @@ async function addProjectManually(projectPath, displayName = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
getProjects,
|
getProjects,
|
||||||
getSessions,
|
getSessions,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
import express from 'express';
|
||||||
const bcrypt = require('bcrypt');
|
import bcrypt from 'bcrypt';
|
||||||
const { userDb } = require('../database/db');
|
import { userDb } from '../database/db.js';
|
||||||
const { generateToken, authenticateToken } = require('../middleware/auth');
|
import { generateToken, authenticateToken } from '../middleware/auth.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ router.post('/register', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if users already exist (only allow one user)
|
// Check if users already exist (only allow one user)
|
||||||
const hasUsers = await userDb.hasUsers();
|
const hasUsers = userDb.hasUsers();
|
||||||
if (hasUsers) {
|
if (hasUsers) {
|
||||||
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
return res.status(403).json({ error: 'User already exists. This is a single-user system.' });
|
||||||
}
|
}
|
||||||
@@ -44,13 +44,13 @@ router.post('/register', async (req, res) => {
|
|||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
const user = await userDb.createUser(username, passwordHash);
|
const user = userDb.createUser(username, passwordHash);
|
||||||
|
|
||||||
// Generate token
|
// Generate token
|
||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
await userDb.updateLastLogin(user.id);
|
userDb.updateLastLogin(user.id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -79,7 +79,7 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user from database
|
// Get user from database
|
||||||
const user = await userDb.getUserByUsername(username);
|
const user = userDb.getUserByUsername(username);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({ error: 'Invalid username or password' });
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ router.post('/login', async (req, res) => {
|
|||||||
const token = generateToken(user);
|
const token = generateToken(user);
|
||||||
|
|
||||||
// Update last login
|
// Update last login
|
||||||
await userDb.updateLastLogin(user.id);
|
userDb.updateLastLogin(user.id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -122,4 +122,4 @@ router.post('/logout', authenticateToken, (req, res) => {
|
|||||||
res.json({ success: true, message: 'Logged out successfully' });
|
res.json({ success: true, message: 'Logged out successfully' });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
export default router;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
const express = require('express');
|
import express from 'express';
|
||||||
const { exec } = require('child_process');
|
import { exec } from 'child_process';
|
||||||
const { promisify } = require('util');
|
import { promisify } from 'util';
|
||||||
const path = require('path');
|
import path from 'path';
|
||||||
const fs = require('fs').promises;
|
import { promises as fs } from 'fs';
|
||||||
const { extractProjectDirectory } = require('../projects');
|
import { extractProjectDirectory } from '../projects.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -420,4 +420,276 @@ function generateSimpleCommitMessage(files, diff) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router;
|
// Get remote status (ahead/behind commits with smart remote detection)
|
||||||
|
router.get('/remote-status', async (req, res) => {
|
||||||
|
const { project } = req.query;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(400).json({ error: 'Project name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
// Get current branch
|
||||||
|
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||||
|
const branch = currentBranch.trim();
|
||||||
|
|
||||||
|
// Check if there's a remote tracking branch (smart detection)
|
||||||
|
let trackingBranch;
|
||||||
|
let remoteName;
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||||
|
trackingBranch = stdout.trim();
|
||||||
|
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||||
|
} catch (error) {
|
||||||
|
// No upstream branch configured
|
||||||
|
return res.json({
|
||||||
|
hasRemote: false,
|
||||||
|
branch,
|
||||||
|
message: 'No remote tracking branch configured'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ahead/behind counts
|
||||||
|
const { stdout: countOutput } = await execAsync(
|
||||||
|
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
|
||||||
|
{ cwd: projectPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
const [behind, ahead] = countOutput.trim().split('\t').map(Number);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hasRemote: true,
|
||||||
|
branch,
|
||||||
|
remoteBranch: trackingBranch,
|
||||||
|
remoteName,
|
||||||
|
ahead: ahead || 0,
|
||||||
|
behind: behind || 0,
|
||||||
|
isUpToDate: ahead === 0 && behind === 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Git remote status error:', error);
|
||||||
|
res.json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch from remote (using smart remote detection)
|
||||||
|
router.post('/fetch', async (req, res) => {
|
||||||
|
const { project } = req.body;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(400).json({ error: 'Project name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
// Get current branch and its upstream remote
|
||||||
|
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||||
|
const branch = currentBranch.trim();
|
||||||
|
|
||||||
|
let remoteName = 'origin'; // fallback
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||||
|
remoteName = stdout.trim().split('/')[0]; // Extract remote name
|
||||||
|
} catch (error) {
|
||||||
|
// No upstream, try to fetch from origin anyway
|
||||||
|
console.log('No upstream configured, using origin as fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
|
||||||
|
|
||||||
|
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Git fetch error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Fetch failed',
|
||||||
|
details: error.message.includes('Could not resolve hostname')
|
||||||
|
? 'Unable to connect to remote repository. Check your internet connection.'
|
||||||
|
: error.message.includes('fatal: \'origin\' does not appear to be a git repository')
|
||||||
|
? 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||||
|
: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pull from remote (fetch + merge using smart remote detection)
|
||||||
|
router.post('/pull', async (req, res) => {
|
||||||
|
const { project } = req.body;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(400).json({ error: 'Project name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
// Get current branch and its upstream remote
|
||||||
|
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||||
|
const branch = currentBranch.trim();
|
||||||
|
|
||||||
|
let remoteName = 'origin'; // fallback
|
||||||
|
let remoteBranch = branch; // fallback
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||||
|
const tracking = stdout.trim();
|
||||||
|
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||||
|
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||||
|
} catch (error) {
|
||||||
|
// No upstream, use fallback
|
||||||
|
console.log('No upstream configured, using origin/branch as fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
output: stdout || 'Pull completed successfully',
|
||||||
|
remoteName,
|
||||||
|
remoteBranch
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Git pull error:', error);
|
||||||
|
|
||||||
|
// Enhanced error handling for common pull scenarios
|
||||||
|
let errorMessage = 'Pull failed';
|
||||||
|
let details = error.message;
|
||||||
|
|
||||||
|
if (error.message.includes('CONFLICT')) {
|
||||||
|
errorMessage = 'Merge conflicts detected';
|
||||||
|
details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
|
||||||
|
} else if (error.message.includes('Please commit your changes or stash them')) {
|
||||||
|
errorMessage = 'Uncommitted changes detected';
|
||||||
|
details = 'Please commit or stash your local changes before pulling.';
|
||||||
|
} else if (error.message.includes('Could not resolve hostname')) {
|
||||||
|
errorMessage = 'Network error';
|
||||||
|
details = 'Unable to connect to remote repository. Check your internet connection.';
|
||||||
|
} else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
|
||||||
|
errorMessage = 'Remote not configured';
|
||||||
|
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
|
||||||
|
} else if (error.message.includes('diverged')) {
|
||||||
|
errorMessage = 'Branches have diverged';
|
||||||
|
details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: errorMessage,
|
||||||
|
details: details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Push commits to remote repository
|
||||||
|
router.post('/push', async (req, res) => {
|
||||||
|
const { project } = req.body;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(400).json({ error: 'Project name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
// Get current branch and its upstream remote
|
||||||
|
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||||
|
const branch = currentBranch.trim();
|
||||||
|
|
||||||
|
let remoteName = 'origin'; // fallback
|
||||||
|
let remoteBranch = branch; // fallback
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
||||||
|
const tracking = stdout.trim();
|
||||||
|
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||||
|
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||||
|
} catch (error) {
|
||||||
|
// No upstream, use fallback
|
||||||
|
console.log('No upstream configured, using origin/branch as fallback');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
output: stdout || 'Push completed successfully',
|
||||||
|
remoteName,
|
||||||
|
remoteBranch
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Git push error:', error);
|
||||||
|
|
||||||
|
// Enhanced error handling for common push scenarios
|
||||||
|
let errorMessage = 'Push failed';
|
||||||
|
let details = error.message;
|
||||||
|
|
||||||
|
if (error.message.includes('rejected')) {
|
||||||
|
errorMessage = 'Push rejected';
|
||||||
|
details = 'The remote has newer commits. Pull first to merge changes before pushing.';
|
||||||
|
} else if (error.message.includes('non-fast-forward')) {
|
||||||
|
errorMessage = 'Non-fast-forward push';
|
||||||
|
details = 'Your branch is behind the remote. Pull the latest changes first.';
|
||||||
|
} else if (error.message.includes('Could not resolve hostname')) {
|
||||||
|
errorMessage = 'Network error';
|
||||||
|
details = 'Unable to connect to remote repository. Check your internet connection.';
|
||||||
|
} else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
|
||||||
|
errorMessage = 'Remote not configured';
|
||||||
|
details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
|
||||||
|
} else if (error.message.includes('Permission denied')) {
|
||||||
|
errorMessage = 'Authentication failed';
|
||||||
|
details = 'Permission denied. Check your credentials or SSH keys.';
|
||||||
|
} else if (error.message.includes('no upstream branch')) {
|
||||||
|
errorMessage = 'No upstream branch';
|
||||||
|
details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: errorMessage,
|
||||||
|
details: details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discard changes for a specific file
|
||||||
|
router.post('/discard', async (req, res) => {
|
||||||
|
const { project, file } = req.body;
|
||||||
|
|
||||||
|
if (!project || !file) {
|
||||||
|
return res.status(400).json({ error: 'Project name and file path are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
// Check file status to determine correct discard command
|
||||||
|
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||||
|
|
||||||
|
if (!statusOutput.trim()) {
|
||||||
|
return res.status(400).json({ error: 'No changes to discard for this file' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = statusOutput.substring(0, 2);
|
||||||
|
|
||||||
|
if (status === '??') {
|
||||||
|
// Untracked file - delete it
|
||||||
|
await fs.unlink(path.join(projectPath, file));
|
||||||
|
} else if (status.includes('M') || status.includes('D')) {
|
||||||
|
// Modified or deleted file - restore from HEAD
|
||||||
|
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
||||||
|
} else if (status.includes('A')) {
|
||||||
|
// Added file - unstage it
|
||||||
|
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, message: `Changes discarded for ${file}` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Git discard error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -585,6 +585,7 @@ function AppContent() {
|
|||||||
onShowSettings={() => setShowToolsSettings(true)}
|
onShowSettings={() => setShowToolsSettings(true)}
|
||||||
autoExpandTools={autoExpandTools}
|
autoExpandTools={autoExpandTools}
|
||||||
showRawParameters={showRawParameters}
|
showRawParameters={showRawParameters}
|
||||||
|
autoScrollToBottom={autoScrollToBottom}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { useDropzone } from 'react-dropzone';
|
||||||
import TodoList from './TodoList';
|
import TodoList from './TodoList';
|
||||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||||
|
|
||||||
@@ -72,6 +73,19 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
<div className="text-sm whitespace-pre-wrap break-words">
|
<div className="text-sm whitespace-pre-wrap break-words">
|
||||||
{message.content}
|
{message.content}
|
||||||
</div>
|
</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">
|
<div className="text-xs text-blue-100 mt-1 text-right">
|
||||||
{new Date(message.timestamp).toLocaleTimeString()}
|
{new Date(message.timestamp).toLocaleTimeString()}
|
||||||
</div>
|
</div>
|
||||||
@@ -451,6 +465,32 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for exit_plan_mode tool
|
||||||
|
if (message.toolName === 'exit_plan_mode') {
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(message.toolInput);
|
||||||
|
if (input.plan) {
|
||||||
|
// Replace escaped newlines with actual newlines
|
||||||
|
const planContent = input.plan.replace(/\\n/g, '\n');
|
||||||
|
return (
|
||||||
|
<details className="mt-2" open={autoExpandTools}>
|
||||||
|
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
📋 View implementation plan
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert">
|
||||||
|
<ReactMarkdown>{planContent}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall back to regular display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Regular tool input display for other tools
|
// Regular tool input display for other tools
|
||||||
return (
|
return (
|
||||||
<details className="mt-2" open={autoExpandTools}>
|
<details className="mt-2" open={autoExpandTools}>
|
||||||
@@ -537,6 +577,30 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for exit_plan_mode tool results
|
||||||
|
if (message.toolName === 'exit_plan_mode') {
|
||||||
|
try {
|
||||||
|
// The content should be JSON with a "plan" field
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
if (parsed.plan) {
|
||||||
|
// Replace escaped newlines with actual newlines
|
||||||
|
const planContent = parsed.plan.replace(/\\n/g, '\n');
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="font-medium">Implementation Plan</span>
|
||||||
|
</div>
|
||||||
|
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||||
|
<ReactMarkdown>{planContent}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fall through to regular handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Special handling for interactive prompts
|
// Special handling for interactive prompts
|
||||||
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
|
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
@@ -875,6 +939,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
|
// ChatInterface: Main chat component with Session Protection System integration
|
||||||
//
|
//
|
||||||
// Session Protection System prevents automatic project updates from interrupting active conversations:
|
// Session Protection System prevents automatic project updates from interrupting active conversations:
|
||||||
@@ -903,6 +1004,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const [sessionMessages, setSessionMessages] = useState([]);
|
const [sessionMessages, setSessionMessages] = useState([]);
|
||||||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||||||
const [isSystemSessionChange, setIsSystemSessionChange] = 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 messagesEndRef = useRef(null);
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const scrollContainerRef = useRef(null);
|
const scrollContainerRef = useRef(null);
|
||||||
@@ -922,6 +1027,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
||||||
const [slashPosition, setSlashPosition] = useState(-1);
|
const [slashPosition, setSlashPosition] = useState(-1);
|
||||||
|
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
|
||||||
const [claudeStatus, setClaudeStatus] = useState(null);
|
const [claudeStatus, setClaudeStatus] = useState(null);
|
||||||
|
|
||||||
|
|
||||||
@@ -1379,6 +1485,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
setCurrentSessionId(pendingSessionId);
|
setCurrentSessionId(pendingSessionId);
|
||||||
sessionStorage.removeItem('pendingSessionId');
|
sessionStorage.removeItem('pendingSessionId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear persisted chat messages after successful completion
|
||||||
|
if (selectedProject && latestMessage.exitCode === 0) {
|
||||||
|
localStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session-aborted':
|
case 'session-aborted':
|
||||||
@@ -1520,14 +1631,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [input]);
|
}, [input]);
|
||||||
|
|
||||||
// Show only recent messages for better performance (last 100 messages)
|
// Show only recent messages for better performance
|
||||||
const visibleMessages = useMemo(() => {
|
const visibleMessages = useMemo(() => {
|
||||||
const maxMessages = 100;
|
if (chatMessages.length <= visibleMessageCount) {
|
||||||
if (chatMessages.length <= maxMessages) {
|
|
||||||
return chatMessages;
|
return chatMessages;
|
||||||
}
|
}
|
||||||
return chatMessages.slice(-maxMessages);
|
return chatMessages.slice(-visibleMessageCount);
|
||||||
}, [chatMessages]);
|
}, [chatMessages, visibleMessageCount]);
|
||||||
|
|
||||||
// Capture scroll position before render when auto-scroll is disabled
|
// Capture scroll position before render when auto-scroll is disabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1596,6 +1706,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}, []); // Only run once on mount
|
}, []); // 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) => {
|
const handleTranscript = useCallback((text) => {
|
||||||
if (text.trim()) {
|
if (text.trim()) {
|
||||||
setInput(prevInput => {
|
setInput(prevInput => {
|
||||||
@@ -1619,13 +1737,110 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
// Load earlier messages by increasing the visible message count
|
||||||
|
const loadEarlierMessages = useCallback(() => {
|
||||||
|
setVisibleMessageCount(prevCount => prevCount + 100);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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();
|
e.preventDefault();
|
||||||
if (!input.trim() || isLoading || !selectedProject) return;
|
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 = {
|
const userMessage = {
|
||||||
type: 'user',
|
type: 'user',
|
||||||
content: input,
|
content: input,
|
||||||
|
images: uploadedImages,
|
||||||
timestamp: new Date()
|
timestamp: new Date()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1672,7 +1887,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
|
|
||||||
const toolsSettings = getToolsSettings();
|
const toolsSettings = getToolsSettings();
|
||||||
|
|
||||||
// Send command to Claude CLI via WebSocket
|
// Send command to Claude CLI via WebSocket with images
|
||||||
sendMessage({
|
sendMessage({
|
||||||
type: 'claude-command',
|
type: 'claude-command',
|
||||||
command: input,
|
command: input,
|
||||||
@@ -1681,12 +1896,25 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
cwd: selectedProject.fullPath,
|
cwd: selectedProject.fullPath,
|
||||||
sessionId: currentSessionId,
|
sessionId: currentSessionId,
|
||||||
resume: !!currentSessionId,
|
resume: !!currentSessionId,
|
||||||
toolsSettings: toolsSettings
|
toolsSettings: toolsSettings,
|
||||||
|
permissionMode: permissionMode,
|
||||||
|
images: uploadedImages // Pass images to backend
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setAttachedImages([]);
|
||||||
|
setUploadingImages(new Map());
|
||||||
|
setImageErrors(new Map());
|
||||||
setIsTextareaExpanded(false);
|
setIsTextareaExpanded(false);
|
||||||
|
|
||||||
|
// Reset textarea height
|
||||||
|
|
||||||
|
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the saved draft since message was sent
|
// Clear the saved draft since message was sent
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||||
@@ -1726,6 +1954,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
|
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||||
@@ -1747,25 +1985,47 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
||||||
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
||||||
|
|
||||||
const newInput = textBeforeAt + '@' + file.path + textAfterQuery;
|
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
||||||
|
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
||||||
|
|
||||||
|
// Immediately ensure focus is maintained
|
||||||
|
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update input and cursor position
|
||||||
setInput(newInput);
|
setInput(newInput);
|
||||||
|
setCursorPosition(newCursorPos);
|
||||||
|
|
||||||
|
// Hide dropdown
|
||||||
setShowFileDropdown(false);
|
setShowFileDropdown(false);
|
||||||
setAtSymbolPosition(-1);
|
setAtSymbolPosition(-1);
|
||||||
|
|
||||||
// Focus back to textarea and set cursor position
|
// Set cursor position synchronously
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.focus();
|
// Use requestAnimationFrame for smoother updates
|
||||||
const newCursorPos = textBeforeAt.length + 1 + file.path.length;
|
requestAnimationFrame(() => {
|
||||||
setTimeout(() => {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||||||
setCursorPosition(newCursorPos);
|
// Ensure focus is maintained
|
||||||
}, 0);
|
if (!textareaRef.current.matches(':focus')) {
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e) => {
|
||||||
setInput(e.target.value);
|
const newValue = e.target.value;
|
||||||
|
setInput(newValue);
|
||||||
setCursorPosition(e.target.selectionStart);
|
setCursorPosition(e.target.selectionStart);
|
||||||
|
|
||||||
|
// Handle height reset when input becomes empty
|
||||||
|
if (!newValue.trim()) {
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
setIsTextareaExpanded(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTextareaClick = (e) => {
|
const handleTextareaClick = (e) => {
|
||||||
@@ -1790,6 +2050,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
|
// Don't render if no project is selected
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
return (
|
return (
|
||||||
@@ -1834,10 +2101,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{chatMessages.length > 100 && (
|
{chatMessages.length > visibleMessageCount && (
|
||||||
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
Showing last 100 messages ({chatMessages.length} total) •
|
Showing last {visibleMessageCount} messages ({chatMessages.length} total) •
|
||||||
<button className="ml-1 text-blue-600 hover:text-blue-700 underline">
|
<button
|
||||||
|
className="ml-1 text-blue-600 hover:text-blue-700 underline"
|
||||||
|
onClick={loadEarlierMessages}
|
||||||
|
>
|
||||||
Load earlier messages
|
Load earlier messages
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1888,18 +2158,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</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 */}
|
{/* Input Area - Fixed Bottom */}
|
||||||
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
|
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
|
||||||
@@ -1912,14 +2170,129 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
onAbort={handleAbortSession}
|
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">
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* File dropdown - positioned outside dropzone to avoid conflicts */}
|
||||||
|
{showFileDropdown && filteredFiles.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50 backdrop-blur-sm">
|
||||||
|
{filteredFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
className={`px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 touch-manipulation ${
|
||||||
|
index === selectedFileIndex
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent textarea from losing focus on mobile
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
selectFile(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{file.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||||
|
{file.path}
|
||||||
|
</div>
|
||||||
|
</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
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onClick={handleTextareaClick}
|
onClick={handleTextareaClick}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
onFocus={() => setIsInputFocused(true)}
|
onFocus={() => setIsInputFocused(true)}
|
||||||
onBlur={() => setIsInputFocused(false)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
@@ -1936,7 +2309,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
rows={1}
|
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' }}
|
style={{ height: 'auto' }}
|
||||||
/>
|
/>
|
||||||
{/* Clear button - shown when there's text */}
|
{/* Clear button - shown when there's text */}
|
||||||
@@ -1981,6 +2354,18 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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 */}
|
{/* Mic button - HIDDEN */}
|
||||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||||||
<MicButton
|
<MicButton
|
||||||
@@ -2016,37 +2401,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* File dropdown */}
|
|
||||||
{showFileDropdown && filteredFiles.length > 0 && (
|
|
||||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50">
|
|
||||||
{filteredFiles.map((file, index) => (
|
|
||||||
<div
|
|
||||||
key={file.path}
|
|
||||||
className={`px-4 py-2 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
|
|
||||||
index === selectedFileIndex
|
|
||||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
|
||||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
onClick={() => selectFile(file)}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-sm">{file.name}</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
|
||||||
{file.path}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Hint text */}
|
{/* Hint text */}
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
<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>
|
||||||
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
<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'
|
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||||||
}`}>
|
}`}>
|
||||||
Enter to send • @ for files
|
Enter to send • Tab for modes • @ for files
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { ScrollArea } from './ui/scroll-area';
|
import { ScrollArea } from './ui/scroll-area';
|
||||||
import { Button } from './ui/button';
|
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 { cn } from '../lib/utils';
|
||||||
import CodeEditor from './CodeEditor';
|
import CodeEditor from './CodeEditor';
|
||||||
import ImageViewer from './ImageViewer';
|
import ImageViewer from './ImageViewer';
|
||||||
@@ -13,6 +13,7 @@ function FileTree({ selectedProject }) {
|
|||||||
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
const [selectedImage, setSelectedImage] = useState(null);
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
|
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
@@ -20,6 +21,14 @@ function FileTree({ selectedProject }) {
|
|||||||
}
|
}
|
||||||
}, [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 () => {
|
const fetchFiles = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +61,35 @@ function FileTree({ selectedProject }) {
|
|||||||
setExpandedDirs(newExpanded);
|
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) => {
|
const renderFileTree = (items, level = 0) => {
|
||||||
return items.map((item) => (
|
return items.map((item) => (
|
||||||
<div key={item.path} className="select-none">
|
<div key={item.path} className="select-none">
|
||||||
@@ -135,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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
@@ -147,6 +308,51 @@ function FileTree({ selectedProject }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card">
|
<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">
|
<ScrollArea className="flex-1 p-4">
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
@@ -160,8 +366,10 @@ function FileTree({ selectedProject }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
||||||
{renderFileTree(files)}
|
{viewMode === 'simple' && renderFileTree(files)}
|
||||||
|
{viewMode === 'compact' && renderCompactView(files)}
|
||||||
|
{viewMode === 'detailed' && renderDetailedView(files)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
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 { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
|
||||||
import { MicButton } from './MicButton.jsx';
|
import { MicButton } from './MicButton.jsx';
|
||||||
import { authenticatedFetch } from '../utils/api';
|
import { authenticatedFetch } from '../utils/api';
|
||||||
|
|
||||||
@@ -24,6 +24,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
const [expandedCommits, setExpandedCommits] = useState(new Set());
|
const [expandedCommits, setExpandedCommits] = useState(new Set());
|
||||||
const [commitDiffs, setCommitDiffs] = useState({});
|
const [commitDiffs, setCommitDiffs] = useState({});
|
||||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||||
|
const [remoteStatus, setRemoteStatus] = useState(null);
|
||||||
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
const [isPulling, setIsPulling] = useState(false);
|
||||||
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
|
const [isCommitAreaCollapsed, setIsCommitAreaCollapsed] = useState(isMobile); // Collapsed by default on mobile
|
||||||
|
const [confirmAction, setConfirmAction] = useState(null); // { type: 'discard|commit|pull|push', file?: string, message?: string }
|
||||||
const textareaRef = useRef(null);
|
const textareaRef = useRef(null);
|
||||||
const dropdownRef = useRef(null);
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
@@ -31,6 +37,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
if (selectedProject) {
|
if (selectedProject) {
|
||||||
fetchGitStatus();
|
fetchGitStatus();
|
||||||
fetchBranches();
|
fetchBranches();
|
||||||
|
fetchRemoteStatus();
|
||||||
if (activeView === 'history') {
|
if (activeView === 'history') {
|
||||||
fetchRecentCommits();
|
fetchRecentCommits();
|
||||||
}
|
}
|
||||||
@@ -105,6 +112,24 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchRemoteStatus = async () => {
|
||||||
|
if (!selectedProject) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.error) {
|
||||||
|
setRemoteStatus(data);
|
||||||
|
} else {
|
||||||
|
setRemoteStatus(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching remote status:', error);
|
||||||
|
setRemoteStatus(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const switchBranch = async (branchName) => {
|
const switchBranch = async (branchName) => {
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch('/api/git/checkout', {
|
const response = await authenticatedFetch('/api/git/checkout', {
|
||||||
@@ -161,6 +186,140 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFetch = async () => {
|
||||||
|
setIsFetching(true);
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/git/fetch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
project: selectedProject.name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// Refresh status after successful fetch
|
||||||
|
fetchGitStatus();
|
||||||
|
fetchRemoteStatus();
|
||||||
|
} else {
|
||||||
|
console.error('Fetch failed:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching from remote:', error);
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePull = async () => {
|
||||||
|
setIsPulling(true);
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/git/pull', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
project: selectedProject.name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// Refresh status after successful pull
|
||||||
|
fetchGitStatus();
|
||||||
|
fetchRemoteStatus();
|
||||||
|
} else {
|
||||||
|
console.error('Pull failed:', data.error);
|
||||||
|
// TODO: Show user-friendly error message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error pulling from remote:', error);
|
||||||
|
} finally {
|
||||||
|
setIsPulling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePush = async () => {
|
||||||
|
setIsPushing(true);
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/git/push', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
project: selectedProject.name
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// Refresh status after successful push
|
||||||
|
fetchGitStatus();
|
||||||
|
fetchRemoteStatus();
|
||||||
|
} else {
|
||||||
|
console.error('Push failed:', data.error);
|
||||||
|
// TODO: Show user-friendly error message
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error pushing to remote:', error);
|
||||||
|
} finally {
|
||||||
|
setIsPushing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardChanges = async (filePath) => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/git/discard', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
project: selectedProject.name,
|
||||||
|
file: filePath
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
// Remove from selected files and refresh status
|
||||||
|
setSelectedFiles(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
newSet.delete(filePath);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
fetchGitStatus();
|
||||||
|
} else {
|
||||||
|
console.error('Discard failed:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error discarding changes:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmAndExecute = async () => {
|
||||||
|
if (!confirmAction) return;
|
||||||
|
|
||||||
|
const { type, file, message } = confirmAction;
|
||||||
|
setConfirmAction(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'discard':
|
||||||
|
await discardChanges(file);
|
||||||
|
break;
|
||||||
|
case 'commit':
|
||||||
|
await handleCommit();
|
||||||
|
break;
|
||||||
|
case 'pull':
|
||||||
|
await handlePull();
|
||||||
|
break;
|
||||||
|
case 'push':
|
||||||
|
await handlePush();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error executing ${type}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchFileDiff = async (filePath) => {
|
const fetchFileDiff = async (filePath) => {
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
const response = await authenticatedFetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||||
@@ -292,6 +451,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
setCommitMessage('');
|
setCommitMessage('');
|
||||||
setSelectedFiles(new Set());
|
setSelectedFiles(new Set());
|
||||||
fetchGitStatus();
|
fetchGitStatus();
|
||||||
|
fetchRemoteStatus();
|
||||||
} else {
|
} else {
|
||||||
console.error('Commit failed:', data.error);
|
console.error('Commit failed:', data.error);
|
||||||
}
|
}
|
||||||
@@ -384,39 +544,77 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
|
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
<div className="flex items-center px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800">
|
<div className={`flex items-center hover:bg-gray-50 dark:hover:bg-gray-800 ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => toggleFileSelected(filePath)}
|
onChange={() => toggleFileSelected(filePath)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="mr-2 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
className={`rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600 ${isMobile ? 'mr-1.5' : 'mr-2'}`}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="flex items-center flex-1 cursor-pointer"
|
className="flex items-center flex-1 cursor-pointer"
|
||||||
onClick={() => toggleFileExpanded(filePath)}
|
onClick={() => toggleFileExpanded(filePath)}
|
||||||
>
|
>
|
||||||
<div className="mr-2 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
|
<div className={`p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded ${isMobile ? 'mr-1' : 'mr-2'}`}>
|
||||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
<ChevronRight className={`w-3 h-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'}`}>{filePath}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(status === 'M' || status === 'D') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmAction({
|
||||||
|
type: 'discard',
|
||||||
|
file: filePath,
|
||||||
|
message: `Discard all changes to "${filePath}"? This action cannot be undone.`
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600 dark:text-red-400 font-medium flex items-center gap-1`}
|
||||||
|
title="Discard changes"
|
||||||
|
>
|
||||||
|
<Trash2 className={`${isMobile ? 'w-3 h-3' : 'w-3 h-3'}`} />
|
||||||
|
{isMobile && <span>Discard</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||||
|
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||||
|
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||||
|
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||||
|
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
title={getStatusLabel(status)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-1 text-sm truncate">{filePath}</span>
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
|
||||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
|
||||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
|
||||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
|
||||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
|
||||||
}`}
|
|
||||||
title={getStatusLabel(status)}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && diff && (
|
<div className={`bg-gray-50 dark:bg-gray-900 transition-all duration-400 ease-in-out overflow-hidden ${
|
||||||
<div className="bg-gray-50 dark:bg-gray-900">
|
isExpanded && diff
|
||||||
{isMobile && (
|
? 'max-h-[600px] opacity-100 translate-y-0'
|
||||||
<div className="flex justify-end p-2 border-b border-gray-200 dark:border-gray-700">
|
: 'max-h-0 opacity-0 -translate-y-1'
|
||||||
|
}`}>
|
||||||
|
{/* Operation header */}
|
||||||
|
<div className="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||||
|
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||||
|
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||||
|
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||||
|
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{getStatusLabel(status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isMobile && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -427,13 +625,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
>
|
>
|
||||||
{wrapText ? '↔️ Scroll' : '↩️ Wrap'}
|
{wrapText ? '↔️ Scroll' : '↩️ Wrap'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
<div className="max-h-96 overflow-y-auto p-2">
|
|
||||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="max-h-96 overflow-y-auto p-2">
|
||||||
)}
|
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -449,14 +646,36 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
<div className={`flex items-center justify-between border-b border-gray-200 dark:border-gray-700 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowBranchDropdown(!showBranchDropdown)}
|
onClick={() => setShowBranchDropdown(!showBranchDropdown)}
|
||||||
className="flex items-center space-x-2 px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
className={`flex items-center hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}
|
||||||
>
|
>
|
||||||
<GitBranch className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
<GitBranch className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||||
<span className="text-sm font-medium">{currentBranch}</span>
|
<div className="flex items-center gap-1">
|
||||||
|
<span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>
|
||||||
|
{/* Remote status indicators */}
|
||||||
|
{remoteStatus?.hasRemote && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
{remoteStatus.ahead > 0 && (
|
||||||
|
<span className="text-green-600 dark:text-green-400" title={`${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} ahead`}>
|
||||||
|
↑{remoteStatus.ahead}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{remoteStatus.behind > 0 && (
|
||||||
|
<span className="text-blue-600 dark:text-blue-400" title={`${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} behind`}>
|
||||||
|
↓{remoteStatus.behind}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{remoteStatus.isUpToDate && (
|
||||||
|
<span className="text-gray-500 dark:text-gray-400" title="Up to date with remote">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -495,16 +714,69 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||||
onClick={() => {
|
{/* Remote action buttons - smart logic based on ahead/behind status */}
|
||||||
fetchGitStatus();
|
{remoteStatus?.hasRemote && !remoteStatus?.isUpToDate && (
|
||||||
fetchBranches();
|
<>
|
||||||
}}
|
{/* Pull button - show when behind (primary action) */}
|
||||||
disabled={isLoading}
|
{remoteStatus.behind > 0 && (
|
||||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
<button
|
||||||
>
|
onClick={() => setConfirmAction({
|
||||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
type: 'pull',
|
||||||
</button>
|
message: `Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}?`
|
||||||
|
})}
|
||||||
|
disabled={isPulling}
|
||||||
|
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||||
|
title={`Pull ${remoteStatus.behind} commit${remoteStatus.behind !== 1 ? 's' : ''} from ${remoteStatus.remoteName}`}
|
||||||
|
>
|
||||||
|
<Download className={`w-3 h-3 ${isPulling ? 'animate-pulse' : ''}`} />
|
||||||
|
<span>{isPulling ? 'Pulling...' : `Pull ${remoteStatus.behind}`}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Push button - show when ahead (primary action when ahead only) */}
|
||||||
|
{remoteStatus.ahead > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction({
|
||||||
|
type: 'push',
|
||||||
|
message: `Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}?`
|
||||||
|
})}
|
||||||
|
disabled={isPushing}
|
||||||
|
className="px-2 py-1 text-xs bg-orange-600 text-white rounded hover:bg-orange-700 disabled:opacity-50 flex items-center gap-1"
|
||||||
|
title={`Push ${remoteStatus.ahead} commit${remoteStatus.ahead !== 1 ? 's' : ''} to ${remoteStatus.remoteName}`}
|
||||||
|
>
|
||||||
|
<Upload className={`w-3 h-3 ${isPushing ? 'animate-pulse' : ''}`} />
|
||||||
|
<span>{isPushing ? 'Pushing...' : `Push ${remoteStatus.ahead}`}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fetch button - show when ahead only or when diverged (secondary action) */}
|
||||||
|
{(remoteStatus.ahead > 0 || (remoteStatus.behind > 0 && remoteStatus.ahead > 0)) && (
|
||||||
|
<button
|
||||||
|
onClick={handleFetch}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center gap-1"
|
||||||
|
title={`Fetch from ${remoteStatus.remoteName}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{isFetching ? 'Fetching...' : 'Fetch'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
fetchGitStatus();
|
||||||
|
fetchBranches();
|
||||||
|
fetchRemoteStatus();
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`hover:bg-gray-100 dark:hover:bg-gray-800 rounded ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`${isLoading ? 'animate-spin' : ''} ${isMobile ? 'w-3 h-3' : 'w-4 h-4'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Git Repository Not Found Message */}
|
{/* Git Repository Not Found Message */}
|
||||||
@@ -523,8 +795,12 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Tab Navigation - Only show when git is available */}
|
{/* Tab Navigation - Only show when git is available and no files expanded */}
|
||||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
<div className={`flex border-b border-gray-200 dark:border-gray-700 transition-all duration-300 ease-in-out ${
|
||||||
|
expandedFiles.size === 0
|
||||||
|
? 'max-h-16 opacity-100 translate-y-0'
|
||||||
|
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||||
|
}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveView('changes')}
|
onClick={() => setActiveView('changes')}
|
||||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
@@ -556,68 +832,110 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
{/* Changes View */}
|
{/* Changes View */}
|
||||||
{activeView === 'changes' && (
|
{activeView === 'changes' && (
|
||||||
<>
|
<>
|
||||||
{/* Commit Message Input */}
|
{/* Mobile Commit Toggle Button / Desktop Always Visible - Hide when files expanded */}
|
||||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
<div className={`transition-all duration-300 ease-in-out ${
|
||||||
<div className="relative">
|
expandedFiles.size === 0
|
||||||
<textarea
|
? 'max-h-96 opacity-100 translate-y-0'
|
||||||
ref={textareaRef}
|
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||||
value={commitMessage}
|
}`}>
|
||||||
onChange={(e) => setCommitMessage(e.target.value)}
|
{isMobile && isCommitAreaCollapsed ? (
|
||||||
placeholder="Message (Ctrl+Enter to commit)"
|
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
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"
|
<button
|
||||||
rows="3"
|
onClick={() => setIsCommitAreaCollapsed(false)}
|
||||||
onKeyDown={(e) => {
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
>
|
||||||
handleCommit();
|
<GitCommit className="w-4 h-4" />
|
||||||
}
|
<span>Commit {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''}</span>
|
||||||
}}
|
<ChevronDown className="w-3 h-3" />
|
||||||
/>
|
</button>
|
||||||
<div className="absolute right-2 top-2 flex gap-1">
|
</div>
|
||||||
<button
|
) : (
|
||||||
onClick={generateCommitMessage}
|
<>
|
||||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
{/* Commit Message Input */}
|
||||||
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"
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
title="Generate commit message"
|
{/* Mobile collapse button */}
|
||||||
>
|
{isMobile && (
|
||||||
{isGeneratingMessage ? (
|
<div className="flex items-center justify-between mb-2">
|
||||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
<span className="text-sm font-medium">Commit Changes</span>
|
||||||
) : (
|
<button
|
||||||
<Sparkles className="w-4 h-4" />
|
onClick={() => setIsCommitAreaCollapsed(true)}
|
||||||
)}
|
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||||
</button>
|
>
|
||||||
<div style={{ display: 'none' }}>
|
<ChevronDown className="w-4 h-4 rotate-180" />
|
||||||
<MicButton
|
</button>
|
||||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
</div>
|
||||||
mode="default"
|
)}
|
||||||
className="p-1.5"
|
|
||||||
|
<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={() => setConfirmAction({
|
||||||
|
type: 'commit',
|
||||||
|
message: `Commit ${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} with message: "${commitMessage.trim()}"?`
|
||||||
|
})}
|
||||||
|
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>
|
||||||
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File Selection Controls - Only show in changes view and when git is working */}
|
{/* File Selection Controls - Only show in changes view and when git is working and no files expanded */}
|
||||||
{activeView === 'changes' && gitStatus && !gitStatus.error && (
|
{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">
|
<div className={`border-b border-gray-200 dark:border-gray-700 flex items-center justify-between transition-all duration-300 ease-in-out ${isMobile ? 'px-3 py-1.5' : 'px-4 py-2'} ${
|
||||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
expandedFiles.size === 0
|
||||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
|
? 'max-h-16 opacity-100 translate-y-0'
|
||||||
|
: 'max-h-0 opacity-0 -translate-y-2 overflow-hidden'
|
||||||
|
}`}>
|
||||||
|
<span className={`text-gray-600 dark:text-gray-400 ${isMobile ? 'text-xs' : 'text-xs'}`}>
|
||||||
|
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} {isMobile ? '' : 'files'} selected
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const allFiles = new Set([
|
const allFiles = new Set([
|
||||||
@@ -628,23 +946,23 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
]);
|
]);
|
||||||
setSelectedFiles(allFiles);
|
setSelectedFiles(allFiles);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
|
||||||
>
|
>
|
||||||
Select All
|
{isMobile ? 'All' : 'Select All'}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedFiles(new Set())}
|
onClick={() => setSelectedFiles(new Set())}
|
||||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
className={`text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 ${isMobile ? 'text-xs' : 'text-xs'}`}
|
||||||
>
|
>
|
||||||
Deselect All
|
{isMobile ? 'None' : 'Deselect All'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status Legend Toggle */}
|
{/* Status Legend Toggle - Hide on mobile by default */}
|
||||||
{!gitStatus?.error && (
|
{!gitStatus?.error && !isMobile && (
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLegend(!showLegend)}
|
onClick={() => setShowLegend(!showLegend)}
|
||||||
@@ -793,6 +1111,78 @@ function GitPanel({ selectedProject, isMobile }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{confirmAction && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setConfirmAction(null)} />
|
||||||
|
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className={`p-2 rounded-full mr-3 ${
|
||||||
|
confirmAction.type === 'discard' ? 'bg-red-100 dark:bg-red-900' : 'bg-yellow-100 dark:bg-yellow-900'
|
||||||
|
}`}>
|
||||||
|
<AlertTriangle className={`w-5 h-5 ${
|
||||||
|
confirmAction.type === 'discard' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{confirmAction.type === 'discard' ? 'Discard Changes' :
|
||||||
|
confirmAction.type === 'commit' ? 'Confirm Commit' :
|
||||||
|
confirmAction.type === 'pull' ? 'Confirm Pull' : 'Confirm Push'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{confirmAction.message}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmAction(null)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={confirmAndExecute}
|
||||||
|
className={`px-4 py-2 text-sm text-white rounded-md ${
|
||||||
|
confirmAction.type === 'discard'
|
||||||
|
? 'bg-red-600 hover:bg-red-700'
|
||||||
|
: confirmAction.type === 'commit'
|
||||||
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
: confirmAction.type === 'pull'
|
||||||
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
|
: 'bg-orange-600 hover:bg-orange-700'
|
||||||
|
} flex items-center space-x-2`}
|
||||||
|
>
|
||||||
|
{confirmAction.type === 'discard' ? (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>Discard</span>
|
||||||
|
</>
|
||||||
|
) : confirmAction.type === 'commit' ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
<span>Commit</span>
|
||||||
|
</>
|
||||||
|
) : confirmAction.type === 'pull' ? (
|
||||||
|
<>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Pull</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>Push</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
|
|
||||||
|
// CSS to remove xterm focus outline
|
||||||
|
const xtermStyles = `
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
.xterm:focus .xterm-screen {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
.xterm-screen:focus {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Inject styles
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
const styleSheet = document.createElement('style');
|
||||||
|
styleSheet.type = 'text/css';
|
||||||
|
styleSheet.innerText = xtermStyles;
|
||||||
|
document.head.appendChild(styleSheet);
|
||||||
|
}
|
||||||
|
|
||||||
// Global store for shell sessions to persist across tab switches
|
// Global store for shell sessions to persist across tab switches
|
||||||
const shellSessions = new Map();
|
const shellSessions = new Map();
|
||||||
|
|
||||||
@@ -138,6 +159,14 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (fitAddon.current) {
|
if (fitAddon.current) {
|
||||||
fitAddon.current.fit();
|
fitAddon.current.fit();
|
||||||
|
// Send terminal size to backend after reattaching
|
||||||
|
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.current.cols,
|
||||||
|
rows: terminal.current.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
@@ -226,6 +255,13 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
|
|
||||||
terminal.current.open(terminalRef.current);
|
terminal.current.open(terminalRef.current);
|
||||||
|
|
||||||
|
// Wait for terminal to be fully rendered, then fit
|
||||||
|
setTimeout(() => {
|
||||||
|
if (fitAddon.current) {
|
||||||
|
fitAddon.current.fit();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
|
||||||
// Add keyboard shortcuts for copy/paste
|
// Add keyboard shortcuts for copy/paste
|
||||||
terminal.current.attachCustomKeyEventHandler((event) => {
|
terminal.current.attachCustomKeyEventHandler((event) => {
|
||||||
// Ctrl+C or Cmd+C for copy (when text is selected)
|
// Ctrl+C or Cmd+C for copy (when text is selected)
|
||||||
@@ -252,10 +288,18 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure terminal takes full space
|
// Ensure terminal takes full space and notify backend of size
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (fitAddon.current) {
|
if (fitAddon.current) {
|
||||||
fitAddon.current.fit();
|
fitAddon.current.fit();
|
||||||
|
// Send terminal size to backend after fitting
|
||||||
|
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.current.cols,
|
||||||
|
rows: terminal.current.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
@@ -276,6 +320,14 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
if (fitAddon.current && terminal.current) {
|
if (fitAddon.current && terminal.current) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.current.fit();
|
fitAddon.current.fit();
|
||||||
|
// Send updated terminal size to backend after resize
|
||||||
|
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.current.cols,
|
||||||
|
rows: terminal.current.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -309,10 +361,18 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive || !isInitialized) return;
|
if (!isActive || !isInitialized) return;
|
||||||
|
|
||||||
// Fit terminal when tab becomes active
|
// Fit terminal when tab becomes active and notify backend
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (fitAddon.current) {
|
if (fitAddon.current) {
|
||||||
fitAddon.current.fit();
|
fitAddon.current.fit();
|
||||||
|
// Send terminal size to backend after tab activation
|
||||||
|
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.current.cols,
|
||||||
|
rows: terminal.current.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [isActive, isInitialized]);
|
}, [isActive, isInitialized]);
|
||||||
@@ -363,16 +423,38 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
|
|
||||||
// Send initial setup with project path and session info
|
// Wait for terminal to be ready, then fit and send dimensions
|
||||||
const initPayload = {
|
setTimeout(() => {
|
||||||
type: 'init',
|
if (fitAddon.current && terminal.current) {
|
||||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
// Force a fit to ensure proper dimensions
|
||||||
sessionId: selectedSession?.id,
|
fitAddon.current.fit();
|
||||||
hasSession: !!selectedSession
|
|
||||||
};
|
// Wait a bit more for fit to complete, then send dimensions
|
||||||
|
setTimeout(() => {
|
||||||
|
const initPayload = {
|
||||||
ws.current.send(JSON.stringify(initPayload));
|
type: 'init',
|
||||||
|
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||||
|
sessionId: selectedSession?.id,
|
||||||
|
hasSession: !!selectedSession,
|
||||||
|
cols: terminal.current.cols,
|
||||||
|
rows: terminal.current.rows
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.send(JSON.stringify(initPayload));
|
||||||
|
|
||||||
|
// Also send resize message immediately after init
|
||||||
|
setTimeout(() => {
|
||||||
|
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||||
|
ws.current.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.current.cols,
|
||||||
|
rows: terminal.current.rows
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.current.onmessage = (event) => {
|
ws.current.onmessage = (event) => {
|
||||||
@@ -442,7 +524,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-gray-900">
|
<div className="h-full flex flex-col bg-gray-900 w-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -494,7 +576,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
|||||||
|
|
||||||
{/* Terminal */}
|
{/* Terminal */}
|
||||||
<div className="flex-1 p-2 overflow-hidden relative">
|
<div className="flex-1 p-2 overflow-hidden relative">
|
||||||
<div ref={terminalRef} className="h-full w-full" />
|
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{!isInitialized && (
|
{!isInitialized && (
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { ScrollArea } from './ui/scroll-area';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Input } from './ui/input';
|
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 { cn } from '../lib/utils';
|
||||||
import ClaudeLogo from './ClaudeLogo';
|
import ClaudeLogo from './ClaudeLogo';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
@@ -61,14 +62,32 @@ function Sidebar({
|
|||||||
const [additionalSessions, setAdditionalSessions] = useState({});
|
const [additionalSessions, setAdditionalSessions] = useState({});
|
||||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
const [projectSortOrder, setProjectSortOrder] = useState('name');
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [editingSession, setEditingSession] = useState(null);
|
const [editingSession, setEditingSession] = useState(null);
|
||||||
const [editingSessionName, setEditingSessionName] = useState('');
|
const [editingSessionName, setEditingSessionName] = useState('');
|
||||||
const [generatingSummary, setGeneratingSummary] = 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) => {
|
const handleTouchClick = (callback) => {
|
||||||
return (e) => {
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
callback();
|
callback();
|
||||||
@@ -110,6 +129,45 @@ function Sidebar({
|
|||||||
}
|
}
|
||||||
}, [projects, isLoading]);
|
}, [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 toggleProject = (projectName) => {
|
||||||
const newExpanded = new Set(expandedProjects);
|
const newExpanded = new Set(expandedProjects);
|
||||||
if (newExpanded.has(projectName)) {
|
if (newExpanded.has(projectName)) {
|
||||||
@@ -120,6 +178,71 @@ function Sidebar({
|
|||||||
setExpandedProjects(newExpanded);
|
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) => {
|
const startEditing = (project) => {
|
||||||
setEditingProject(project.name);
|
setEditingProject(project.name);
|
||||||
@@ -280,12 +403,17 @@ function Sidebar({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get all sessions for a project (initial + additional)
|
// Filter projects based on search input
|
||||||
const getAllSessions = (project) => {
|
const filteredProjects = sortedProjects.filter(project => {
|
||||||
const initialSessions = project.sessions || [];
|
if (!searchFilter.trim()) return true;
|
||||||
const additional = additionalSessions[project.name] || [];
|
|
||||||
return [...initialSessions, ...additional];
|
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 (
|
return (
|
||||||
<div className="h-full flex flex-col bg-card md:select-none">
|
<div className="h-full flex flex-col bg-card md:select-none">
|
||||||
@@ -471,6 +599,30 @@ function Sidebar({
|
|||||||
</div>
|
</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 */}
|
{/* Projects List */}
|
||||||
<ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
|
<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">
|
<div className="md:space-y-1 pb-safe-area-inset-bottom">
|
||||||
@@ -494,10 +646,21 @@ function Sidebar({
|
|||||||
Run Claude CLI in a project directory to get started
|
Run Claude CLI in a project directory to get started
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 isExpanded = expandedProjects.has(project.name);
|
||||||
const isSelected = selectedProject?.name === project.name;
|
const isSelected = selectedProject?.name === project.name;
|
||||||
|
const isStarred = isProjectStarred(project.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={project.name} className="md:space-y-1">
|
<div key={project.name} className="md:space-y-1">
|
||||||
@@ -508,7 +671,8 @@ function Sidebar({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150",
|
"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={() => {
|
onClick={() => {
|
||||||
// On mobile, just toggle the folder - don't select the project
|
// On mobile, just toggle the folder - don't select the project
|
||||||
@@ -590,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 && (
|
{getAllSessions(project).length === 0 && (
|
||||||
<button
|
<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"
|
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"
|
||||||
@@ -631,7 +817,8 @@ function Sidebar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50",
|
"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={() => {
|
onClick={() => {
|
||||||
// Desktop behavior: select project and toggle
|
// Desktop behavior: select project and toggle
|
||||||
@@ -718,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
|
<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"
|
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) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
const [skipPermissions, setSkipPermissions] = useState(false);
|
const [skipPermissions, setSkipPermissions] = useState(false);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState(null);
|
const [saveStatus, setSaveStatus] = useState(null);
|
||||||
|
const [projectSortOrder, setProjectSortOrder] = useState('name');
|
||||||
|
|
||||||
// Common tool patterns
|
// Common tool patterns
|
||||||
const commonTools = [
|
const commonTools = [
|
||||||
@@ -51,11 +52,13 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
setAllowedTools(settings.allowedTools || []);
|
setAllowedTools(settings.allowedTools || []);
|
||||||
setDisallowedTools(settings.disallowedTools || []);
|
setDisallowedTools(settings.disallowedTools || []);
|
||||||
setSkipPermissions(settings.skipPermissions || false);
|
setSkipPermissions(settings.skipPermissions || false);
|
||||||
|
setProjectSortOrder(settings.projectSortOrder || 'name');
|
||||||
} else {
|
} else {
|
||||||
// Set defaults
|
// Set defaults
|
||||||
setAllowedTools([]);
|
setAllowedTools([]);
|
||||||
setDisallowedTools([]);
|
setDisallowedTools([]);
|
||||||
setSkipPermissions(false);
|
setSkipPermissions(false);
|
||||||
|
setProjectSortOrder('name');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading tool settings:', error);
|
console.error('Error loading tool settings:', error);
|
||||||
@@ -63,6 +66,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
setAllowedTools([]);
|
setAllowedTools([]);
|
||||||
setDisallowedTools([]);
|
setDisallowedTools([]);
|
||||||
setSkipPermissions(false);
|
setSkipPermissions(false);
|
||||||
|
setProjectSortOrder('name');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,6 +79,7 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
disallowedTools,
|
disallowedTools,
|
||||||
skipPermissions,
|
skipPermissions,
|
||||||
|
projectSortOrder,
|
||||||
lastUpdated: new Date().toISOString()
|
lastUpdated: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,6 +186,28 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,4 +445,4 @@ function ToolsSettings({ isOpen, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ToolsSettings;
|
export default ToolsSettings;
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) =>
|
|||||||
className={cn("relative overflow-hidden", className)}
|
className={cn("relative overflow-hidden", className)}
|
||||||
{...props}
|
{...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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,9 +32,31 @@ export const ThemeProvider = ({ children }) => {
|
|||||||
if (isDarkMode) {
|
if (isDarkMode) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
localStorage.setItem('theme', 'dark');
|
localStorage.setItem('theme', 'dark');
|
||||||
|
|
||||||
|
// Update iOS status bar style and theme color for dark mode
|
||||||
|
const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
|
||||||
|
if (statusBarMeta) {
|
||||||
|
statusBarMeta.setAttribute('content', 'black-translucent');
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (themeColorMeta) {
|
||||||
|
themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
localStorage.setItem('theme', 'light');
|
localStorage.setItem('theme', 'light');
|
||||||
|
|
||||||
|
// Update iOS status bar style and theme color for light mode
|
||||||
|
const statusBarMeta = document.querySelector('meta[name="apple-mobile-web-app-status-bar-style"]');
|
||||||
|
if (statusBarMeta) {
|
||||||
|
statusBarMeta.setAttribute('content', 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (themeColorMeta) {
|
||||||
|
themeColorMeta.setAttribute('content', '#ffffff'); // Light background color
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isDarkMode]);
|
}, [isDarkMode]);
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 217.2 91.2% 59.8%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +99,12 @@
|
|||||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Color transitions for theme switching */
|
/* Color transitions for theme switching - exclude interactive elements */
|
||||||
* {
|
body, div, section, article, aside, header, footer, nav, main,
|
||||||
|
h1, h2, h3, h4, h5, h6, p, span, blockquote,
|
||||||
|
ul, ol, li, dl, dt, dd,
|
||||||
|
table, thead, tbody, tfoot, tr, td, th,
|
||||||
|
form, fieldset, legend, label {
|
||||||
transition: background-color 200ms ease-in-out,
|
transition: background-color 200ms ease-in-out,
|
||||||
border-color 200ms ease-in-out,
|
border-color 200ms ease-in-out,
|
||||||
color 200ms ease-in-out;
|
color 200ms ease-in-out;
|
||||||
@@ -436,6 +440,12 @@
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-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 */
|
/* Preserve checkbox visibility */
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||||
|
|||||||
Reference in New Issue
Block a user