Files
claudecodeui/server/projects.js
simos fc2a94a2e5 - Upgrading to Vite 7
- Refactor to use es modules
- Added permission mode
- Switched to better sqlite3
- several UX enhancements
2025-07-11 10:29:36 +00:00

619 lines
19 KiB
JavaScript
Executable File

import { promises as fs } from 'fs';
import fsSync from 'fs';
import path from 'path';
import readline from 'readline';
// Cache for extracted project directories
const projectDirectoryCache = new Map();
let cacheTimestamp = Date.now();
// Clear cache when needed (called when project files change)
function clearProjectDirectoryCache() {
projectDirectoryCache.clear();
cacheTimestamp = Date.now();
console.log('🗑️ Project directory cache cleared');
}
// Load project configuration file
async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
try {
const configData = await fs.readFile(configPath, 'utf8');
return JSON.parse(configData);
} catch (error) {
// Return empty config if file doesn't exist
return {};
}
}
// Save project configuration file
async function saveProjectConfig(config) {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
}
// Generate better display name from path
async function generateDisplayName(projectName, actualProjectDir = null) {
// Use actual project directory if provided, otherwise decode from project name
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
// Try to read package.json from the project path
try {
const packageJsonPath = path.join(projectPath, 'package.json');
const packageData = await fs.readFile(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageData);
// Return the name from package.json if it exists
if (packageJson.name) {
return packageJson.name;
}
} catch (error) {
// Fall back to path-based naming if package.json doesn't exist or can't be read
}
// If it starts with /, it's an absolute path
if (projectPath.startsWith('/')) {
const parts = projectPath.split('/').filter(Boolean);
if (parts.length > 3) {
// Show last 2 folders with ellipsis: "...projects/myapp"
return `.../${parts.slice(-2).join('/')}`;
} else {
// Show full path if short: "/home/user"
return projectPath;
}
}
return projectPath;
}
// 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);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
// Fall back to decoded project name if no sessions
extractedPath = projectName.replace(/-/g, '/');
} else {
// Process all JSONL files to collect cwd values
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
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
}
}
}
}
// 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, '/');
}
}
}
// Cache the result
projectDirectoryCache.set(projectName, extractedPath);
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
return extractedPath;
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fall back to decoded project name
extractedPath = projectName.replace(/-/g, '/');
// Cache the fallback result too
projectDirectoryCache.set(projectName, extractedPath);
return extractedPath;
}
}
async function getProjects() {
const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
const config = await loadProjectConfig();
const projects = [];
const existingProjects = new Set();
try {
// First, get existing projects from the file system
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
existingProjects.add(entry.name);
const projectPath = path.join(claudeDir, entry.name);
// Extract actual project directory from JSONL sessions
const actualProjectDir = await extractProjectDirectory(entry.name);
// Get display name from config or generate one
const customName = config[entry.name]?.displayName;
const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
const fullPath = actualProjectDir;
const project = {
name: entry.name,
path: actualProjectDir,
displayName: customName || autoDisplayName,
fullPath: fullPath,
isCustomName: !!customName,
sessions: []
};
// Try to get sessions for this project (just first 5 for performance)
try {
const sessionResult = await getSessions(entry.name, 5, 0);
project.sessions = sessionResult.sessions || [];
project.sessionMeta = {
hasMore: sessionResult.hasMore,
total: sessionResult.total
};
} catch (e) {
console.warn(`Could not load sessions for project ${entry.name}:`, e.message);
}
projects.push(project);
}
}
} catch (error) {
console.error('Error reading projects directory:', error);
}
// Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
// Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath;
if (!actualProjectDir) {
try {
actualProjectDir = await extractProjectDirectory(projectName);
} catch (error) {
// Fall back to decoded project name
actualProjectDir = projectName.replace(/-/g, '/');
}
}
const project = {
name: projectName,
path: actualProjectDir,
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
fullPath: actualProjectDir,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: []
};
projects.push(project);
}
}
return projects;
}
async function getSessions(projectName, limit = 5, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
return { sessions: [], hasMore: false, total: 0 };
}
// For performance, get file stats to sort by modification time
const filesWithStats = await Promise.all(
jsonlFiles.map(async (file) => {
const filePath = path.join(projectDir, file);
const stats = await fs.stat(filePath);
return { file, mtime: stats.mtime };
})
);
// Sort files by modification time (newest first) for better performance
filesWithStats.sort((a, b) => b.mtime - a.mtime);
const allSessions = new Map();
let processedCount = 0;
// Process files in order of modification time
for (const { file } of filesWithStats) {
const jsonlFile = path.join(projectDir, file);
const sessions = await parseJsonlSessions(jsonlFile);
// Merge sessions, avoiding duplicates by session ID
sessions.forEach(session => {
if (!allSessions.has(session.id)) {
allSessions.set(session.id, session);
}
});
processedCount++;
// Early exit optimization: if we have enough sessions and processed recent files
if (allSessions.size >= (limit + offset) * 2 && processedCount >= Math.min(3, filesWithStats.length)) {
break;
}
}
// Convert to array and sort by last activity
const sortedSessions = Array.from(allSessions.values()).sort((a, b) =>
new Date(b.lastActivity) - new Date(a.lastActivity)
);
const total = sortedSessions.length;
const paginatedSessions = sortedSessions.slice(offset, offset + limit);
const hasMore = offset + limit < total;
return {
sessions: paginatedSessions,
hasMore,
total,
offset,
limit
};
} catch (error) {
console.error(`Error reading sessions for project ${projectName}:`, error);
return { sessions: [], hasMore: false, total: 0 };
}
}
async function parseJsonlSessions(filePath) {
const sessions = new Map();
try {
const fileStream = fsSync.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
// console.log(`[JSONL Parser] Reading file: ${filePath}`);
let lineCount = 0;
for await (const line of rl) {
if (line.trim()) {
lineCount++;
try {
const entry = JSON.parse(line);
if (entry.sessionId) {
if (!sessions.has(entry.sessionId)) {
sessions.set(entry.sessionId, {
id: entry.sessionId,
summary: 'New Session',
messageCount: 0,
lastActivity: new Date(),
cwd: entry.cwd || ''
});
}
const session = sessions.get(entry.sessionId);
// Update summary if this is a summary entry
if (entry.type === 'summary' && entry.summary) {
session.summary = entry.summary;
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
// Use first user message as summary if no summary entry exists
const content = entry.message.content;
if (typeof content === 'string' && content.length > 0) {
// Skip command messages that start with <command-name>
if (!content.startsWith('<command-name>')) {
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
}
}
}
// Count messages instead of storing them all
session.messageCount = (session.messageCount || 0) + 1;
// Update last activity
if (entry.timestamp) {
session.lastActivity = new Date(entry.timestamp);
}
}
} catch (parseError) {
console.warn(`[JSONL Parser] Error parsing line ${lineCount}:`, parseError.message);
}
}
}
// console.log(`[JSONL Parser] Processed ${lineCount} lines, found ${sessions.size} sessions`);
} catch (error) {
console.error('Error reading JSONL file:', error);
}
// Convert Map to Array and sort by last activity
return Array.from(sessions.values()).sort((a, b) =>
new Date(b.lastActivity) - new Date(a.lastActivity)
);
}
// Get messages for a specific session
async function getSessionMessages(projectName, sessionId) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
return [];
}
const messages = [];
// Process all JSONL files to find messages for this session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = fsSync.createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
if (entry.sessionId === sessionId) {
messages.push(entry);
}
} catch (parseError) {
console.warn('Error parsing line:', parseError.message);
}
}
}
}
// Sort messages by timestamp
return messages.sort((a, b) =>
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
);
} catch (error) {
console.error(`Error reading messages for session ${sessionId}:`, error);
return [];
}
}
// Rename a project's display name
async function renameProject(projectName, newDisplayName) {
const config = await loadProjectConfig();
if (!newDisplayName || newDisplayName.trim() === '') {
// Remove custom name if empty, will fall back to auto-generated
delete config[projectName];
} else {
// Set custom display name
config[projectName] = {
displayName: newDisplayName.trim()
};
}
await saveProjectConfig(config);
return true;
}
// Delete a session from a project
async function deleteSession(projectName, sessionId) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
throw new Error('No session files found for this project');
}
// Check all JSONL files to find which one contains the session
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const content = await fs.readFile(jsonlFile, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
// Check if this file contains the session
const hasSession = lines.some(line => {
try {
const data = JSON.parse(line);
return data.sessionId === sessionId;
} catch {
return false;
}
});
if (hasSession) {
// Filter out all entries for this session
const filteredLines = lines.filter(line => {
try {
const data = JSON.parse(line);
return data.sessionId !== sessionId;
} catch {
return true; // Keep malformed lines
}
});
// Write back the filtered content
await fs.writeFile(jsonlFile, filteredLines.join('\n') + (filteredLines.length > 0 ? '\n' : ''));
return true;
}
}
throw new Error(`Session ${sessionId} not found in any files`);
} catch (error) {
console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
throw error;
}
}
// Check if a project is empty (has no sessions)
async function isProjectEmpty(projectName) {
try {
const sessionsResult = await getSessions(projectName, 1, 0);
return sessionsResult.total === 0;
} catch (error) {
console.error(`Error checking if project ${projectName} is empty:`, error);
return false;
}
}
// Delete an empty project
async function deleteProject(projectName) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
// First check if the project is empty
const isEmpty = await isProjectEmpty(projectName);
if (!isEmpty) {
throw new Error('Cannot delete project with existing sessions');
}
// Remove the project directory
await fs.rm(projectDir, { recursive: true, force: true });
// Remove from project config
const config = await loadProjectConfig();
delete config[projectName];
await saveProjectConfig(config);
return true;
} catch (error) {
console.error(`Error deleting project ${projectName}:`, error);
throw error;
}
}
// Add a project manually to the config (without creating folders)
async function addProjectManually(projectPath, displayName = null) {
const absolutePath = path.resolve(projectPath);
try {
// Check if the path exists
await fs.access(absolutePath);
} catch (error) {
throw new Error(`Path does not exist: ${absolutePath}`);
}
// Generate project name (encode path for use as directory name)
const projectName = absolutePath.replace(/\//g, '-');
// Check if project already exists in config or as a folder
const config = await loadProjectConfig();
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try {
await fs.access(projectDir);
throw new Error(`Project already exists for path: ${absolutePath}`);
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
if (config[projectName]) {
throw new Error(`Project already configured for path: ${absolutePath}`);
}
// Add to config as manually added project
config[projectName] = {
manuallyAdded: true,
originalPath: absolutePath
};
if (displayName) {
config[projectName].displayName = displayName;
}
await saveProjectConfig(config);
return {
name: projectName,
path: absolutePath,
fullPath: absolutePath,
displayName: displayName || await generateDisplayName(projectName, absolutePath),
isManuallyAdded: true,
sessions: []
};
}
export {
getProjects,
getSessions,
getSessionMessages,
parseJsonlSessions,
renameProject,
deleteSession,
isProjectEmpty,
deleteProject,
addProjectManually,
loadProjectConfig,
saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
};