mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-12 13:19:43 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca97a5284 | ||
|
|
1bdc75e37b |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.3",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -30,7 +30,7 @@ const os = require('os');
|
||||
const pty = require('node-pty');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects');
|
||||
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } = require('./projects');
|
||||
const { spawnClaude, abortClaudeSession } = require('./claude-cli');
|
||||
const gitRoutes = require('./routes/git');
|
||||
|
||||
@@ -76,6 +76,9 @@ function setupProjectsWatcher() {
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
|
||||
// Clear project directory cache when files change
|
||||
clearProjectDirectoryCache();
|
||||
|
||||
// Get updated projects list
|
||||
const updatedProjects = await getProjects();
|
||||
|
||||
@@ -372,47 +375,15 @@ app.get('/api/projects/:projectName/files', async (req, res) => {
|
||||
try {
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const projectPath = path.join(process.env.HOME, '.claude', 'projects', req.params.projectName);
|
||||
|
||||
// Try different methods to get the actual project path
|
||||
let actualPath = projectPath;
|
||||
|
||||
// Use extractProjectDirectory to get the actual project path
|
||||
let actualPath;
|
||||
try {
|
||||
// First try to read metadata.json
|
||||
const metadataPath = path.join(projectPath, 'metadata.json');
|
||||
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
||||
actualPath = metadata.path || metadata.cwd;
|
||||
} catch (e) {
|
||||
// Fallback: try to find the actual path by testing different dash interpretations
|
||||
let testPath = req.params.projectName;
|
||||
if (testPath.startsWith('-')) {
|
||||
testPath = testPath.substring(1);
|
||||
}
|
||||
|
||||
// Try to intelligently decode the path by testing which directories exist
|
||||
const pathParts = testPath.split('-');
|
||||
actualPath = '/' + pathParts.join('/');
|
||||
|
||||
// If the simple replacement doesn't work, try to find the correct path
|
||||
// by testing combinations where some dashes might be part of directory names
|
||||
if (!require('fs').existsSync(actualPath)) {
|
||||
// Try different combinations of dash vs slash
|
||||
for (let i = pathParts.length - 1; i >= 0; i--) {
|
||||
let testParts = [...pathParts];
|
||||
// Try joining some parts with dashes instead of slashes
|
||||
for (let j = i; j < testParts.length - 1; j++) {
|
||||
testParts[j] = testParts[j] + '-' + testParts[j + 1];
|
||||
testParts.splice(j + 1, 1);
|
||||
let testActualPath = '/' + testParts.join('/');
|
||||
if (require('fs').existsSync(testActualPath)) {
|
||||
actualPath = testActualPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (require('fs').existsSync(actualPath)) break;
|
||||
}
|
||||
}
|
||||
|
||||
actualPath = await extractProjectDirectory(req.params.projectName);
|
||||
} catch (error) {
|
||||
console.error('Error extracting project directory:', error);
|
||||
// Fallback to simple dash replacement
|
||||
actualPath = req.params.projectName.replace(/-/g, '/');
|
||||
}
|
||||
|
||||
// Check if path exists
|
||||
|
||||
@@ -2,6 +2,17 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
// Cache for extracted project directories
|
||||
const projectDirectoryCache = new Map();
|
||||
let cacheTimestamp = Date.now();
|
||||
|
||||
// Clear cache when needed (called when project files change)
|
||||
function clearProjectDirectoryCache() {
|
||||
projectDirectoryCache.clear();
|
||||
cacheTimestamp = Date.now();
|
||||
console.log('🗑️ Project directory cache cleared');
|
||||
}
|
||||
|
||||
// Load project configuration file
|
||||
async function loadProjectConfig() {
|
||||
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
|
||||
@@ -54,12 +65,20 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// Extract the actual project directory from JSONL sessions
|
||||
// Extract the actual project directory from JSONL sessions (with caching)
|
||||
async function extractProjectDirectory(projectName) {
|
||||
// Check cache first
|
||||
if (projectDirectoryCache.has(projectName)) {
|
||||
return projectDirectoryCache.get(projectName);
|
||||
}
|
||||
|
||||
console.log(`🔍 Extracting project directory for: ${projectName}`);
|
||||
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
const cwdCounts = new Map();
|
||||
let latestTimestamp = 0;
|
||||
let latestCwd = null;
|
||||
let extractedPath;
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectDir);
|
||||
@@ -67,75 +86,87 @@ async function extractProjectDirectory(projectName) {
|
||||
|
||||
if (jsonlFiles.length === 0) {
|
||||
// Fall back to decoded project name if no sessions
|
||||
return projectName.replace(/-/g, '/');
|
||||
}
|
||||
|
||||
// Process all JSONL files to collect cwd values
|
||||
for (const file of jsonlFiles) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
const fileStream = require('fs').createReadStream(jsonlFile);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
if (entry.cwd) {
|
||||
// Count occurrences of each cwd
|
||||
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
|
||||
extractedPath = projectName.replace(/-/g, '/');
|
||||
} else {
|
||||
// Process all JSONL files to collect cwd values
|
||||
for (const file of jsonlFiles) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
const fileStream = require('fs').createReadStream(jsonlFile);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Track the most recent cwd
|
||||
const timestamp = new Date(entry.timestamp || 0).getTime();
|
||||
if (timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
latestCwd = entry.cwd;
|
||||
if (entry.cwd) {
|
||||
// Count occurrences of each cwd
|
||||
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
|
||||
|
||||
// Track the most recent cwd
|
||||
const timestamp = new Date(entry.timestamp || 0).getTime();
|
||||
if (timestamp > latestTimestamp) {
|
||||
latestTimestamp = timestamp;
|
||||
latestCwd = entry.cwd;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the best cwd to use
|
||||
if (cwdCounts.size === 0) {
|
||||
// No cwd found, fall back to decoded project name
|
||||
extractedPath = projectName.replace(/-/g, '/');
|
||||
} else if (cwdCounts.size === 1) {
|
||||
// Only one cwd, use it
|
||||
extractedPath = Array.from(cwdCounts.keys())[0];
|
||||
} else {
|
||||
// Multiple cwd values - prefer the most recent one if it has reasonable usage
|
||||
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
|
||||
const maxCount = Math.max(...cwdCounts.values());
|
||||
|
||||
// Use most recent if it has at least 25% of the max count
|
||||
if (mostRecentCount >= maxCount * 0.25) {
|
||||
extractedPath = latestCwd;
|
||||
} else {
|
||||
// Otherwise use the most frequently used cwd
|
||||
for (const [cwd, count] of cwdCounts.entries()) {
|
||||
if (count === maxCount) {
|
||||
extractedPath = cwd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (shouldn't reach here)
|
||||
if (!extractedPath) {
|
||||
extractedPath = latestCwd || projectName.replace(/-/g, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the best cwd to use
|
||||
if (cwdCounts.size === 0) {
|
||||
// No cwd found, fall back to decoded project name
|
||||
return projectName.replace(/-/g, '/');
|
||||
}
|
||||
// Cache the result
|
||||
projectDirectoryCache.set(projectName, extractedPath);
|
||||
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
|
||||
|
||||
if (cwdCounts.size === 1) {
|
||||
// Only one cwd, use it
|
||||
return Array.from(cwdCounts.keys())[0];
|
||||
}
|
||||
|
||||
// Multiple cwd values - prefer the most recent one if it has reasonable usage
|
||||
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
|
||||
const maxCount = Math.max(...cwdCounts.values());
|
||||
|
||||
// Use most recent if it has at least 25% of the max count
|
||||
if (mostRecentCount >= maxCount * 0.25) {
|
||||
return latestCwd;
|
||||
}
|
||||
|
||||
// Otherwise use the most frequently used cwd
|
||||
for (const [cwd, count] of cwdCounts.entries()) {
|
||||
if (count === maxCount) {
|
||||
return cwd;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback (shouldn't reach here)
|
||||
return latestCwd || projectName.replace(/-/g, '/');
|
||||
return extractedPath;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||
// Fall back to decoded project name
|
||||
return projectName.replace(/-/g, '/');
|
||||
extractedPath = projectName.replace(/-/g, '/');
|
||||
|
||||
// Cache the fallback result too
|
||||
projectDirectoryCache.set(projectName, extractedPath);
|
||||
|
||||
return extractedPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,5 +613,6 @@ module.exports = {
|
||||
addProjectManually,
|
||||
loadProjectConfig,
|
||||
saveProjectConfig,
|
||||
extractProjectDirectory
|
||||
extractProjectDirectory,
|
||||
clearProjectDirectoryCache
|
||||
};
|
||||
@@ -3,15 +3,47 @@ const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { extractProjectDirectory } = require('../projects');
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
function getActualProjectPath(projectName) {
|
||||
// Claude stores projects with dashes instead of slashes
|
||||
// Convert "-Users-dmieloch-Dev-experiments-claudecodeui" to "/Users/dmieloch/Dev/experiments/claudecodeui"
|
||||
return projectName.replace(/-/g, '/');
|
||||
async function getActualProjectPath(projectName) {
|
||||
try {
|
||||
return await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||
// Fallback to the old method
|
||||
return projectName.replace(/-/g, '/');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to validate git repository
|
||||
async function validateGitRepository(projectPath) {
|
||||
try {
|
||||
// Check if directory exists
|
||||
await fs.access(projectPath);
|
||||
} catch {
|
||||
throw new Error(`Project path not found: ${projectPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Use --show-toplevel to get the root of the git repository
|
||||
const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
||||
const normalizedGitRoot = path.resolve(gitRoot.trim());
|
||||
const normalizedProjectPath = path.resolve(projectPath);
|
||||
|
||||
// Ensure the git root matches our project path (prevent using parent git repos)
|
||||
if (normalizedGitRoot !== normalizedProjectPath) {
|
||||
throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('Project directory is not a git repository')) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
||||
}
|
||||
}
|
||||
|
||||
// Get git status for a project
|
||||
@@ -23,24 +55,11 @@ router.get('/status', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
console.log('Git status for project:', project, '-> path:', projectPath);
|
||||
|
||||
// Check if directory exists
|
||||
try {
|
||||
await fs.access(projectPath);
|
||||
} catch {
|
||||
console.error('Project path not found:', projectPath);
|
||||
return res.json({ error: 'Project not found' });
|
||||
}
|
||||
|
||||
// Check if it's a git repository
|
||||
try {
|
||||
await execAsync('git rev-parse --git-dir', { cwd: projectPath });
|
||||
} catch {
|
||||
console.error('Not a git repository:', projectPath);
|
||||
return res.json({ error: 'Not a git repository' });
|
||||
}
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
@@ -79,7 +98,14 @@ router.get('/status', async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git status error:', error);
|
||||
res.json({ error: error.message });
|
||||
res.json({
|
||||
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
: 'Git operation failed',
|
||||
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
|
||||
? error.message
|
||||
: `Failed to get git status: ${error.message}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -92,7 +118,10 @@ router.get('/diff', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Check if file is untracked
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
@@ -133,7 +162,10 @@ router.post('/commit', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Stage selected files
|
||||
for (const file of files) {
|
||||
@@ -159,9 +191,12 @@ router.get('/branches', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
console.log('Git branches for project:', project, '-> path:', projectPath);
|
||||
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get all branches
|
||||
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
|
||||
|
||||
@@ -199,7 +234,7 @@ router.post('/checkout', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Checkout the branch
|
||||
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
|
||||
@@ -220,7 +255,7 @@ router.post('/create-branch', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Create and checkout new branch
|
||||
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
|
||||
@@ -241,7 +276,7 @@ router.get('/commits', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Get commit log with stats
|
||||
const { stdout } = await execAsync(
|
||||
@@ -292,7 +327,7 @@ router.get('/commit-diff', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Get diff for the commit
|
||||
const { stdout } = await execAsync(
|
||||
@@ -316,7 +351,7 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const projectPath = getActualProjectPath(project);
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// Get diff for selected files
|
||||
let combinedDiff = '';
|
||||
|
||||
@@ -62,7 +62,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
|
||||
if (data.error) {
|
||||
console.error('Git status error:', data.error);
|
||||
setGitStatus(null);
|
||||
setGitStatus({ error: data.error, details: data.details });
|
||||
} else {
|
||||
setGitStatus(data);
|
||||
setCurrentBranch(data.branch || 'main');
|
||||
@@ -506,171 +506,191 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Changes</span>
|
||||
{/* Git Repository Not Found Message */}
|
||||
{gitStatus?.error ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12">
|
||||
<GitBranch className="w-20 h-20 mb-6 opacity-30" />
|
||||
<h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3>
|
||||
{gitStatus.details && (
|
||||
<p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
|
||||
)}
|
||||
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 text-center">
|
||||
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('history')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'history'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
<span>History</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Commit Message Input */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Message (Ctrl+Enter to commit)"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
|
||||
rows="3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleCommit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 top-2 flex gap-1">
|
||||
<button
|
||||
onClick={generateCommitMessage}
|
||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Generate commit message"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<div style={{ display: 'none' }}>
|
||||
<MicButton
|
||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
||||
mode="default"
|
||||
className="p-1.5"
|
||||
{/* Tab Navigation - Only show when git is available */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Changes</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveView('history')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'history'
|
||||
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<History className="w-4 h-4" />
|
||||
<span>History</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
<>
|
||||
{/* Commit Message Input */}
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="Message (Ctrl+Enter to commit)"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
|
||||
rows="3"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
handleCommit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 top-2 flex gap-1">
|
||||
<button
|
||||
onClick={generateCommitMessage}
|
||||
disabled={selectedFiles.size === 0 || isGeneratingMessage}
|
||||
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Generate commit message"
|
||||
>
|
||||
{isGeneratingMessage ? (
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<div style={{ display: 'none' }}>
|
||||
<MicButton
|
||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
||||
mode="default"
|
||||
className="p-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File Selection Controls - Only show in changes view and when git is working */}
|
||||
{activeView === 'changes' && gitStatus && !gitStatus.error && (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const allFiles = new Set([
|
||||
...(gitStatus?.modified || []),
|
||||
...(gitStatus?.added || []),
|
||||
...(gitStatus?.deleted || []),
|
||||
...(gitStatus?.untracked || [])
|
||||
]);
|
||||
setSelectedFiles(allFiles);
|
||||
}}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Legend Toggle */}
|
||||
{!gitStatus?.error && (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
<span>File Status Guide</span>
|
||||
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{showLegend && (
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
|
||||
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
|
||||
M
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
|
||||
A
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
|
||||
D
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
|
||||
U
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* File Selection Controls - Only show in changes view */}
|
||||
{activeView === 'changes' && gitStatus && (
|
||||
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const allFiles = new Set([
|
||||
...(gitStatus?.modified || []),
|
||||
...(gitStatus?.added || []),
|
||||
...(gitStatus?.deleted || []),
|
||||
...(gitStatus?.untracked || [])
|
||||
]);
|
||||
setSelectedFiles(allFiles);
|
||||
}}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
<button
|
||||
onClick={() => setSelectedFiles(new Set())}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
|
||||
>
|
||||
Deselect All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Legend Toggle */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
|
||||
>
|
||||
<Info className="w-3 h-3" />
|
||||
<span>File Status Guide</span>
|
||||
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{showLegend && (
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
|
||||
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
|
||||
M
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
|
||||
A
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
|
||||
D
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
|
||||
U
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File List - Changes View */}
|
||||
{activeView === 'changes' && (
|
||||
{/* File List - Changes View - Only show when git is available */}
|
||||
{activeView === 'changes' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
@@ -692,8 +712,8 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History View */}
|
||||
{activeView === 'history' && (
|
||||
{/* History View - Only show when git is available */}
|
||||
{activeView === 'history' && !gitStatus?.error && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
|
||||
Reference in New Issue
Block a user