2 Commits

5 changed files with 344 additions and 286 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-ui", "name": "claude-code-ui",
"version": "1.1.1", "version": "1.1.3",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {

View File

@@ -30,7 +30,7 @@ const os = require('os');
const pty = require('node-pty'); const pty = require('node-pty');
const fetch = require('node-fetch'); 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 { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git'); const gitRoutes = require('./routes/git');
@@ -76,6 +76,9 @@ function setupProjectsWatcher() {
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
try { try {
// Clear project directory cache when files change
clearProjectDirectoryCache();
// Get updated projects list // Get updated projects list
const updatedProjects = await getProjects(); const updatedProjects = await getProjects();
@@ -372,47 +375,15 @@ app.get('/api/projects/:projectName/files', async (req, res) => {
try { try {
const fs = require('fs').promises; 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 { try {
// First try to read metadata.json actualPath = await extractProjectDirectory(req.params.projectName);
const metadataPath = path.join(projectPath, 'metadata.json'); } catch (error) {
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8')); console.error('Error extracting project directory:', error);
actualPath = metadata.path || metadata.cwd; // Fallback to simple dash replacement
} catch (e) { actualPath = req.params.projectName.replace(/-/g, '/');
// 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;
}
}
} }
// Check if path exists // Check if path exists

View File

@@ -2,6 +2,17 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const readline = require('readline'); 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 // Load project configuration file
async function loadProjectConfig() { async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json'); const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
@@ -54,12 +65,20 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
return projectPath; return projectPath;
} }
// Extract the actual project directory from JSONL sessions // Extract the actual project directory from JSONL sessions (with caching)
async function extractProjectDirectory(projectName) { 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 projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const cwdCounts = new Map(); const cwdCounts = new Map();
let latestTimestamp = 0; let latestTimestamp = 0;
let latestCwd = null; let latestCwd = null;
let extractedPath;
try { try {
const files = await fs.readdir(projectDir); const files = await fs.readdir(projectDir);
@@ -67,9 +86,8 @@ async function extractProjectDirectory(projectName) {
if (jsonlFiles.length === 0) { if (jsonlFiles.length === 0) {
// Fall back to decoded project name if no sessions // Fall back to decoded project name if no sessions
return projectName.replace(/-/g, '/'); extractedPath = projectName.replace(/-/g, '/');
} } else {
// 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);
@@ -105,37 +123,50 @@ async function extractProjectDirectory(projectName) {
// Determine the best cwd to use // Determine the best cwd to use
if (cwdCounts.size === 0) { if (cwdCounts.size === 0) {
// No cwd found, fall back to decoded project name // No cwd found, fall back to decoded project name
return projectName.replace(/-/g, '/'); extractedPath = projectName.replace(/-/g, '/');
} } else if (cwdCounts.size === 1) {
if (cwdCounts.size === 1) {
// Only one cwd, use it // Only one cwd, use it
return Array.from(cwdCounts.keys())[0]; extractedPath = Array.from(cwdCounts.keys())[0];
} } else {
// Multiple cwd values - prefer the most recent one if it has reasonable usage // Multiple cwd values - prefer the most recent one if it has reasonable usage
const mostRecentCount = cwdCounts.get(latestCwd) || 0; const mostRecentCount = cwdCounts.get(latestCwd) || 0;
const maxCount = Math.max(...cwdCounts.values()); const maxCount = Math.max(...cwdCounts.values());
// Use most recent if it has at least 25% of the max count // Use most recent if it has at least 25% of the max count
if (mostRecentCount >= maxCount * 0.25) { if (mostRecentCount >= maxCount * 0.25) {
return latestCwd; extractedPath = latestCwd;
} } else {
// Otherwise use the most frequently used cwd // Otherwise use the most frequently used cwd
for (const [cwd, count] of cwdCounts.entries()) { for (const [cwd, count] of cwdCounts.entries()) {
if (count === maxCount) { if (count === maxCount) {
return cwd; extractedPath = cwd;
break;
}
} }
} }
// Fallback (shouldn't reach here) // Fallback (shouldn't reach here)
return latestCwd || projectName.replace(/-/g, '/'); if (!extractedPath) {
extractedPath = latestCwd || projectName.replace(/-/g, '/');
}
}
}
// Cache the result
projectDirectoryCache.set(projectName, extractedPath);
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
return extractedPath;
} catch (error) { } catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error); console.error(`Error extracting project directory for ${projectName}:`, error);
// Fall back to decoded project name // 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, addProjectManually,
loadProjectConfig, loadProjectConfig,
saveProjectConfig, saveProjectConfig,
extractProjectDirectory extractProjectDirectory,
clearProjectDirectoryCache
}; };

View File

@@ -3,15 +3,47 @@ const { exec } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { extractProjectDirectory } = require('../projects');
const router = express.Router(); const router = express.Router();
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Helper function to get the actual project path from the encoded project name // Helper function to get the actual project path from the encoded project name
function getActualProjectPath(projectName) { async function getActualProjectPath(projectName) {
// Claude stores projects with dashes instead of slashes try {
// Convert "-Users-dmieloch-Dev-experiments-claudecodeui" to "/Users/dmieloch/Dev/experiments/claudecodeui" return await extractProjectDirectory(projectName);
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fallback to the old method
return projectName.replace(/-/g, '/'); 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 // Get git status for a project
@@ -23,24 +55,11 @@ router.get('/status', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
console.log('Git status for project:', project, '-> path:', projectPath); console.log('Git status for project:', project, '-> path:', projectPath);
// Check if directory exists // Validate git repository
try { await validateGitRepository(projectPath);
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' });
}
// Get current branch // Get current branch
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); 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) { } catch (error) {
console.error('Git status error:', 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 { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Check if file is untracked // Check if file is untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
@@ -133,7 +162,10 @@ router.post('/commit', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Stage selected files // Stage selected files
for (const file of files) { for (const file of files) {
@@ -159,9 +191,12 @@ router.get('/branches', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
console.log('Git branches for project:', project, '-> path:', projectPath); console.log('Git branches for project:', project, '-> path:', projectPath);
// Validate git repository
await validateGitRepository(projectPath);
// Get all branches // Get all branches
const { stdout } = await execAsync('git branch -a', { cwd: projectPath }); const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
@@ -199,7 +234,7 @@ router.post('/checkout', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Checkout the branch // Checkout the branch
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath }); const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
@@ -220,7 +255,7 @@ router.post('/create-branch', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Create and checkout new branch // Create and checkout new branch
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath }); const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
@@ -241,7 +276,7 @@ router.get('/commits', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Get commit log with stats // Get commit log with stats
const { stdout } = await execAsync( const { stdout } = await execAsync(
@@ -292,7 +327,7 @@ router.get('/commit-diff', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Get diff for the commit // Get diff for the commit
const { stdout } = await execAsync( const { stdout } = await execAsync(
@@ -316,7 +351,7 @@ router.post('/generate-commit-message', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Get diff for selected files // Get diff for selected files
let combinedDiff = ''; let combinedDiff = '';

View File

@@ -62,7 +62,7 @@ function GitPanel({ selectedProject, isMobile }) {
if (data.error) { if (data.error) {
console.error('Git status error:', data.error); console.error('Git status error:', data.error);
setGitStatus(null); setGitStatus({ error: data.error, details: data.details });
} else { } else {
setGitStatus(data); setGitStatus(data);
setCurrentBranch(data.branch || 'main'); setCurrentBranch(data.branch || 'main');
@@ -506,7 +506,23 @@ function GitPanel({ selectedProject, isMobile }) {
</button> </button>
</div> </div>
{/* Tab Navigation */} {/* 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>
</div>
) : (
<>
{/* Tab Navigation - Only show when git is available */}
<div className="flex border-b border-gray-200 dark:border-gray-700"> <div className="flex border-b border-gray-200 dark:border-gray-700">
<button <button
onClick={() => setActiveView('changes')} onClick={() => setActiveView('changes')}
@@ -594,8 +610,8 @@ function GitPanel({ selectedProject, isMobile }) {
</> </>
)} )}
{/* File Selection Controls - Only show in changes view */} {/* File Selection Controls - Only show in changes view and when git is working */}
{activeView === 'changes' && gitStatus && ( {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="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"> <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 {selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
@@ -627,6 +643,7 @@ function GitPanel({ selectedProject, isMobile }) {
)} )}
{/* Status Legend Toggle */} {/* Status Legend Toggle */}
{!gitStatus?.error && (
<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)}
@@ -668,9 +685,12 @@ function GitPanel({ selectedProject, isMobile }) {
</div> </div>
)} )}
</div> </div>
)}
</>
)}
{/* File List - Changes View */} {/* File List - Changes View - Only show when git is available */}
{activeView === 'changes' && ( {activeView === 'changes' && !gitStatus?.error && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
@@ -692,8 +712,8 @@ function GitPanel({ selectedProject, isMobile }) {
</div> </div>
)} )}
{/* History View */} {/* History View - Only show when git is available */}
{activeView === 'history' && ( {activeView === 'history' && !gitStatus?.error && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">