Enhance project directory handling by integrating extractProjectDirectory and clearProjectDirectoryCache functions. Adjust git route handlers to utilize the new directory extraction logic for improved project path resolution.

This commit is contained in:
simos
2025-07-08 15:10:44 +00:00
parent c5e3bd0633
commit 1bdc75e37b
5 changed files with 182 additions and 131 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,75 +86,87 @@ 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); const fileStream = require('fs').createReadStream(jsonlFile);
const fileStream = require('fs').createReadStream(jsonlFile); const rl = readline.createInterface({
const rl = readline.createInterface({ input: fileStream,
input: fileStream, crlfDelay: Infinity
crlfDelay: Infinity });
});
for await (const line of rl) {
for await (const line of rl) { if (line.trim()) {
if (line.trim()) { try {
try { const entry = JSON.parse(line);
const entry = JSON.parse(line);
if (entry.cwd) {
// Count occurrences of each cwd
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
// Track the most recent cwd if (entry.cwd) {
const timestamp = new Date(entry.timestamp || 0).getTime(); // Count occurrences of each cwd
if (timestamp > latestTimestamp) { cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
latestTimestamp = timestamp;
latestCwd = entry.cwd; // 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 // Cache the result
if (cwdCounts.size === 0) { projectDirectoryCache.set(projectName, extractedPath);
// No cwd found, fall back to decoded project name console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
return projectName.replace(/-/g, '/');
}
if (cwdCounts.size === 1) { return extractedPath;
// 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, '/');
} 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);
return projectName.replace(/-/g, '/'); } 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 // 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');
@@ -594,8 +594,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
@@ -676,6 +676,19 @@ function GitPanel({ selectedProject, isMobile }) {
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" /> <RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
</div> </div>
) : gitStatus?.error ? (
<div className="flex flex-col items-center justify-center h-48 text-gray-500 dark:text-gray-400 px-6">
<GitBranch className="w-16 h-16 mb-4 opacity-30" />
<p className="text-lg font-medium mb-2 text-center">{gitStatus.error}</p>
{gitStatus.details && (
<p className="text-sm text-center leading-relaxed">{gitStatus.details}</p>
)}
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-xs text-blue-700 dark:text-blue-300 text-center">
<strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">git init</code> in your project directory to initialize git source control.
</p>
</div>
</div>
) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? ( ) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400"> <div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
<GitCommit className="w-12 h-12 mb-2 opacity-50" /> <GitCommit className="w-12 h-12 mb-2 opacity-50" />