mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-08 22:59:39 +00:00
- Refactor to use es modules - Added permission mode - Switched to better sqlite3 - several UX enhancements
619 lines
19 KiB
JavaScript
Executable File
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
|
|
}; |