From 89334cda4e163d51aae41956c1e13caafe6c80b6 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Sun, 8 Feb 2026 17:40:14 +0300 Subject: [PATCH] perf(project-loading): eliminate repeated Codex session rescans and duplicate cursor fetches The staged changes remove the main source of project-load latency by avoiding repeated full scans of ~/.codex/sessions for every project and by removing redundant client-side cursor session refetches. Server changes (server/projects.js):\n- Add a per-request Codex index reference in getProjects so Codex metadata is built once and reused across all projects, including manually added ones.\n- Introduce normalizeComparablePath() to canonicalize project paths (including Windows long-path prefixes and case-insensitive matching on Windows).\n- Introduce findCodexJsonlFiles() + buildCodexSessionsIndex() to perform a single recursive Codex scan and group sessions by normalized cwd.\n- Update getCodexSessions() to accept indexRef and read from the prebuilt index, with fallback index construction when no ref is provided.\n- Preserve existing session limiting behavior (limit=5 default, limit=0 returns all). Client changes (src/hooks/useProjectsState.ts):\n- Remove loadCursorSessionsForProjects(), which previously triggered one extra /api/cursor/sessions request per project after /api/projects.\n- Use /api/projects response directly during initial load and refresh.\n- Expand projectsHaveChanges() to treat both cursorSessions and codexSessions as external session deltas.\n- Keep refresh comparison aligned with external session updates by using includeExternalSessions=true in sidebar refresh path. Impact:\n- Reduces backend work from roughly O(projects * codex_session_files) to O(codex_session_files + projects) for Codex discovery during a project load cycle.\n- Removes an additional client-side O(projects) network fan-out for Cursor session fetches.\n- Improves perceived and actual sidebar project-loading time, especially in large session datasets. --- server/projects.js | 168 +++++++++++++++++++++------------- src/hooks/useProjectsState.ts | 45 +++------ 2 files changed, 116 insertions(+), 97 deletions(-) diff --git a/server/projects.js b/server/projects.js index 475b323..fb54898 100755 --- a/server/projects.js +++ b/server/projects.js @@ -384,6 +384,7 @@ async function getProjects(progressCallback = null) { const config = await loadProjectConfig(); const projects = []; const existingProjects = new Set(); + const codexSessionsIndexRef = { sessionsByProject: null }; let totalProjects = 0; let processedProjects = 0; let directories = []; @@ -419,8 +420,6 @@ async function getProjects(progressCallback = null) { }); } - const projectPath = path.join(claudeDir, entry.name); - // Extract actual project directory from JSONL sessions const actualProjectDir = await extractProjectDirectory(entry.name); @@ -460,7 +459,9 @@ async function getProjects(progressCallback = null) { // Also fetch Codex sessions for this project try { - project.codexSessions = await getCodexSessions(actualProjectDir); + project.codexSessions = await getCodexSessions(actualProjectDir, { + indexRef: codexSessionsIndexRef, + }); } catch (e) { console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); project.codexSessions = []; @@ -546,7 +547,9 @@ async function getProjects(progressCallback = null) { // Try to fetch Codex sessions for manual projects too try { - project.codexSessions = await getCodexSessions(actualProjectDir); + project.codexSessions = await getCodexSessions(actualProjectDir, { + indexRef: codexSessionsIndexRef, + }); } catch (e) { console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); } @@ -1244,75 +1247,114 @@ async function getCursorSessions(projectPath) { } +function normalizeComparablePath(inputPath) { + if (!inputPath || typeof inputPath !== 'string') { + return ''; + } + + const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\') + ? inputPath.slice(4) + : inputPath; + const normalized = path.normalize(withoutLongPathPrefix.trim()); + + if (!normalized) { + return ''; + } + + const resolved = path.resolve(normalized); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +async function findCodexJsonlFiles(dir) { + const files = []; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await findCodexJsonlFiles(fullPath)); + } else if (entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + } catch (error) { + // Skip directories we can't read + } + + return files; +} + +async function buildCodexSessionsIndex() { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const sessionsByProject = new Map(); + + try { + await fs.access(codexSessionsDir); + } catch (error) { + return sessionsByProject; + } + + const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir); + + for (const filePath of jsonlFiles) { + try { + const sessionData = await parseCodexSessionFile(filePath); + if (!sessionData || !sessionData.id) { + continue; + } + + const normalizedProjectPath = normalizeComparablePath(sessionData.cwd); + if (!normalizedProjectPath) { + continue; + } + + const session = { + id: sessionData.id, + summary: sessionData.summary || 'Codex Session', + messageCount: sessionData.messageCount || 0, + lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), + cwd: sessionData.cwd, + model: sessionData.model, + filePath, + provider: 'codex', + }; + + if (!sessionsByProject.has(normalizedProjectPath)) { + sessionsByProject.set(normalizedProjectPath, []); + } + + sessionsByProject.get(normalizedProjectPath).push(session); + } catch (error) { + console.warn(`Could not parse Codex session file ${filePath}:`, error.message); + } + } + + for (const sessions of sessionsByProject.values()) { + sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + } + + return sessionsByProject; +} + // Fetch Codex sessions for a given project path async function getCodexSessions(projectPath, options = {}) { - const { limit = 5 } = options; + const { limit = 5, indexRef = null } = options; try { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - const sessions = []; - - // Check if the directory exists - try { - await fs.access(codexSessionsDir); - } catch (error) { - // No Codex sessions directory + const normalizedProjectPath = normalizeComparablePath(projectPath); + if (!normalizedProjectPath) { return []; } - // Recursively find all .jsonl files in the sessions directory - const findJsonlFiles = async (dir) => { - const files = []; - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...await findJsonlFiles(fullPath)); - } else if (entry.name.endsWith('.jsonl')) { - files.push(fullPath); - } - } - } catch (error) { - // Skip directories we can't read - } - return files; - }; - - const jsonlFiles = await findJsonlFiles(codexSessionsDir); - - // Process each file to find sessions matching the project path - for (const filePath of jsonlFiles) { - try { - const sessionData = await parseCodexSessionFile(filePath); - - // Check if this session matches the project path - // Handle Windows long paths with \\?\ prefix - const sessionCwd = sessionData?.cwd || ''; - const cleanSessionCwd = sessionCwd.startsWith('\\\\?\\') ? sessionCwd.slice(4) : sessionCwd; - const cleanProjectPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath; - - if (sessionData && (sessionData.cwd === projectPath || cleanSessionCwd === cleanProjectPath || path.relative(cleanSessionCwd, cleanProjectPath) === '')) { - sessions.push({ - id: sessionData.id, - summary: sessionData.summary || 'Codex Session', - messageCount: sessionData.messageCount || 0, - lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), - cwd: sessionData.cwd, - model: sessionData.model, - filePath: filePath, - provider: 'codex' - }); - } - } catch (error) { - console.warn(`Could not parse Codex session file ${filePath}:`, error.message); - } + if (indexRef && !indexRef.sessionsByProject) { + indexRef.sessionsByProject = await buildCodexSessionsIndex(); } - // Sort sessions by last activity (newest first) - sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex(); + const sessions = sessionsByProject.get(normalizedProjectPath) || []; // Return limited sessions for performance (0 = unlimited for deletion) - return limit > 0 ? sessions.slice(0, limit) : sessions; + return limit > 0 ? sessions.slice(0, limit) : [...sessions]; } catch (error) { console.error('Error fetching Codex sessions:', error); diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 8bb4dd3..11688fc 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { NavigateFunction } from 'react-router-dom'; -import { api, authenticatedFetch } from '../utils/api'; +import { api } from '../utils/api'; import type { AppSocketMessage, AppTab, @@ -23,7 +23,7 @@ const serialize = (value: unknown) => JSON.stringify(value ?? null); const projectsHaveChanges = ( prevProjects: Project[], nextProjects: Project[], - includeCursorSessions: boolean, + includeExternalSessions: boolean, ): boolean => { if (prevProjects.length !== nextProjects.length) { return true; @@ -46,11 +46,14 @@ const projectsHaveChanges = ( return true; } - if (!includeCursorSessions) { + if (!includeExternalSessions) { return false; } - return serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions); + return ( + serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) || + serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) + ); }); }; @@ -98,31 +101,6 @@ const isUpdateAdditive = ( ); }; -const loadCursorSessionsForProjects = async (projects: Project[]): Promise => { - const projectsWithCursor = [...projects]; - - for (const project of projectsWithCursor) { - try { - const projectPath = project.fullPath || project.path; - const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(projectPath ?? '')}`; - const response = await authenticatedFetch(url); - - if (!response.ok) { - project.cursorSessions = []; - continue; - } - - const data = await response.json(); - project.cursorSessions = data.success && Array.isArray(data.sessions) ? data.sessions : []; - } catch (error) { - console.error(`Error fetching Cursor sessions for project ${project.name}:`, error); - project.cursorSessions = []; - } - } - - return projectsWithCursor; -}; - export function useProjectsState({ sessionId, navigate, @@ -149,15 +127,14 @@ export function useProjectsState({ setIsLoadingProjects(true); const response = await api.projects(); const projectData = (await response.json()) as Project[]; - const projectsWithCursor = await loadCursorSessionsForProjects(projectData); setProjects((prevProjects) => { if (prevProjects.length === 0) { - return projectsWithCursor; + return projectData; } - return projectsHaveChanges(prevProjects, projectsWithCursor, true) - ? projectsWithCursor + return projectsHaveChanges(prevProjects, projectData, true) + ? projectData : prevProjects; }); } catch (error) { @@ -421,7 +398,7 @@ export function useProjectsState({ const freshProjects = (await response.json()) as Project[]; setProjects((prevProjects) => - projectsHaveChanges(prevProjects, freshProjects, false) ? freshProjects : prevProjects, + projectsHaveChanges(prevProjects, freshProjects, true) ? freshProjects : prevProjects, ); if (!selectedProject) {