import fs from 'node:fs/promises'; import path from 'node:path'; import { projectsDb, sessionsDb } from '@/modules/database/index.js'; import { sessionSynchronizerService } from '@/modules/providers/index.js'; import { WS_OPEN_STATE, connectedClients } from '@/modules/websocket/index.js'; import type { RealtimeClientConnection } from '@/shared/types.js'; import { AppError } from '@/shared/utils.js'; type SessionSummary = { id: string; summary: string; messageCount: number; lastActivity: string; }; type SessionsByProvider = Record<'claude' | 'cursor' | 'codex' | 'gemini', SessionSummary[]>; type SessionRepositoryRow = { provider: string; session_id: string; custom_name?: string | null; updated_at?: string | null; created_at?: string | null; }; export type ProjectListItem = { projectId: string; path: string; displayName: string; fullPath: string; isStarred: boolean; sessions: SessionSummary[]; cursorSessions: SessionSummary[]; codexSessions: SessionSummary[]; geminiSessions: SessionSummary[]; sessionMeta: { hasMore: boolean; total: number; }; }; type ProgressUpdate = { phase: 'loading' | 'complete'; current: number; total: number; currentProject?: string; }; type GetProjectsWithSessionsOptions = { skipSynchronization?: boolean; sessionsLimit?: number; sessionsOffset?: number; }; type SessionPaginationOptions = { limit?: number; offset?: number; }; type ProjectSessionsPageResult = { sessionsByProvider: SessionsByProvider; total: number; hasMore: boolean; }; export type ProjectSessionsPageApiView = { projectId: string; sessions: SessionSummary[]; cursorSessions: SessionSummary[]; codexSessions: SessionSummary[]; geminiSessions: SessionSummary[]; sessionMeta: { hasMore: boolean; total: number; }; }; const DEFAULT_PROJECT_SESSIONS_PAGE_SIZE = 20; const MAX_PROJECT_SESSIONS_PAGE_SIZE = 200; /** * Generate better display name from path. */ export async function generateDisplayName(projectName: string, actualProjectDir: string | null = null): Promise { // Use actual project directory if provided, otherwise decode from project name. const 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) as { name?: string }; // Return the name from package.json if it exists. if (packageJson.name) { return packageJson.name; } } catch { // 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); // Return only the last folder name. return parts[parts.length - 1] || projectPath; } return projectPath; } function normalizeSessionPagination(options: SessionPaginationOptions = {}): { limit: number; offset: number } { const rawLimit = Number.isFinite(options.limit) ? Math.floor(Number(options.limit)) : DEFAULT_PROJECT_SESSIONS_PAGE_SIZE; const rawOffset = Number.isFinite(options.offset) ? Math.floor(Number(options.offset)) : 0; return { limit: Math.min(Math.max(1, rawLimit), MAX_PROJECT_SESSIONS_PAGE_SIZE), offset: Math.max(0, rawOffset), }; } function mapSessionRowToSummary(row: SessionRepositoryRow): SessionSummary { return { id: row.session_id, summary: row.custom_name || '', messageCount: 0, lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(), }; } function bucketSessionRowsByProvider(rows: SessionRepositoryRow[]): SessionsByProvider { const byProvider: SessionsByProvider = { claude: [], cursor: [], codex: [], gemini: [], }; for (const row of rows) { const provider = row.provider as keyof SessionsByProvider; const bucket = byProvider[provider]; if (!bucket) { continue; } bucket.push(mapSessionRowToSummary(row)); } return byProvider; } /** * Reads one paginated project session slice from the DB and groups rows by provider. */ function readProjectSessionsPageByPath( projectPath: string, options: SessionPaginationOptions = {}, ): ProjectSessionsPageResult { const pagination = normalizeSessionPagination(options); const rows = sessionsDb.getSessionsByProjectPathPage( projectPath, pagination.limit, pagination.offset, ) as SessionRepositoryRow[]; const total = sessionsDb.countSessionsByProjectPath(projectPath); return { sessionsByProvider: bucketSessionRowsByProvider(rows), total, hasMore: pagination.offset + rows.length < total, }; } // Broadcast progress to all connected WebSocket clients function broadcastProgress(progress: ProgressUpdate) { const message = JSON.stringify({ type: 'loading_progress', ...progress, }); connectedClients.forEach((client: RealtimeClientConnection) => { if (client.readyState === WS_OPEN_STATE) { client.send(message); } }); } /** * Reads all projects from DB and returns provider-bucketed session summaries. */ export async function getProjectsWithSessions( options: GetProjectsWithSessionsOptions = {} ): Promise { if (!options.skipSynchronization) { await sessionSynchronizerService.synchronizeSessions(); } const projectRows = projectsDb.getProjectPaths() as Array<{ project_id: string; project_path: string; custom_project_name?: string | null; isStarred?: number; }>; const totalProjects = projectRows.length; const projects: ProjectListItem[] = []; let processedProjects = 0; for (const row of projectRows) { processedProjects += 1; const projectId = row.project_id; const projectPath = row.project_path; broadcastProgress({ phase: 'loading', current: processedProjects, total: totalProjects, currentProject: projectPath, }); const displayName = row.custom_project_name && row.custom_project_name.trim().length > 0 ? row.custom_project_name : await generateDisplayName(path.basename(projectPath) || projectPath, projectPath); const sessionsPage = readProjectSessionsPageByPath(projectPath, { limit: options.sessionsLimit, offset: options.sessionsOffset, }); projects.push({ projectId, path: projectPath, displayName, fullPath: projectPath, isStarred: Boolean(row.isStarred), sessions: sessionsPage.sessionsByProvider.claude, cursorSessions: sessionsPage.sessionsByProvider.cursor, codexSessions: sessionsPage.sessionsByProvider.codex, geminiSessions: sessionsPage.sessionsByProvider.gemini, sessionMeta: { hasMore: sessionsPage.hasMore, total: sessionsPage.total, }, }); } broadcastProgress({ phase: 'complete', current: totalProjects, total: totalProjects, }); return projects; } /** * Loads one paginated session slice for a specific project id. */ export async function getProjectSessionsPage( projectId: string, options: SessionPaginationOptions = {}, ): Promise { const projectRow = projectsDb.getProjectById(projectId); if (!projectRow) { throw new AppError(`Project "${projectId}" was not found.`, { code: 'PROJECT_NOT_FOUND', statusCode: 404, }); } const sessionsPage = readProjectSessionsPageByPath(projectRow.project_path, options); return { projectId: projectRow.project_id, sessions: sessionsPage.sessionsByProvider.claude, cursorSessions: sessionsPage.sessionsByProvider.cursor, codexSessions: sessionsPage.sessionsByProvider.codex, geminiSessions: sessionsPage.sessionsByProvider.gemini, sessionMeta: { hasMore: sessionsPage.hasMore, total: sessionsPage.total, }, }; }