mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-18 06:37:33 +00:00
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.
This commit is contained in:
@@ -384,6 +384,7 @@ async function getProjects(progressCallback = null) {
|
|||||||
const config = await loadProjectConfig();
|
const config = await loadProjectConfig();
|
||||||
const projects = [];
|
const projects = [];
|
||||||
const existingProjects = new Set();
|
const existingProjects = new Set();
|
||||||
|
const codexSessionsIndexRef = { sessionsByProject: null };
|
||||||
let totalProjects = 0;
|
let totalProjects = 0;
|
||||||
let processedProjects = 0;
|
let processedProjects = 0;
|
||||||
let directories = [];
|
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
|
// Extract actual project directory from JSONL sessions
|
||||||
const actualProjectDir = await extractProjectDirectory(entry.name);
|
const actualProjectDir = await extractProjectDirectory(entry.name);
|
||||||
|
|
||||||
@@ -460,7 +459,9 @@ async function getProjects(progressCallback = null) {
|
|||||||
|
|
||||||
// Also fetch Codex sessions for this project
|
// Also fetch Codex sessions for this project
|
||||||
try {
|
try {
|
||||||
project.codexSessions = await getCodexSessions(actualProjectDir);
|
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||||
|
indexRef: codexSessionsIndexRef,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||||
project.codexSessions = [];
|
project.codexSessions = [];
|
||||||
@@ -546,7 +547,9 @@ async function getProjects(progressCallback = null) {
|
|||||||
|
|
||||||
// Try to fetch Codex sessions for manual projects too
|
// Try to fetch Codex sessions for manual projects too
|
||||||
try {
|
try {
|
||||||
project.codexSessions = await getCodexSessions(actualProjectDir);
|
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||||
|
indexRef: codexSessionsIndexRef,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
|
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
|
// Fetch Codex sessions for a given project path
|
||||||
async function getCodexSessions(projectPath, options = {}) {
|
async function getCodexSessions(projectPath, options = {}) {
|
||||||
const { limit = 5 } = options;
|
const { limit = 5, indexRef = null } = options;
|
||||||
try {
|
try {
|
||||||
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||||
const sessions = [];
|
if (!normalizedProjectPath) {
|
||||||
|
|
||||||
// Check if the directory exists
|
|
||||||
try {
|
|
||||||
await fs.access(codexSessionsDir);
|
|
||||||
} catch (error) {
|
|
||||||
// No Codex sessions directory
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively find all .jsonl files in the sessions directory
|
if (indexRef && !indexRef.sessionsByProject) {
|
||||||
const findJsonlFiles = async (dir) => {
|
indexRef.sessionsByProject = await buildCodexSessionsIndex();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort sessions by last activity (newest first)
|
const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex();
|
||||||
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
const sessions = sessionsByProject.get(normalizedProjectPath) || [];
|
||||||
|
|
||||||
// Return limited sessions for performance (0 = unlimited for deletion)
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Error fetching Codex sessions:', error);
|
console.error('Error fetching Codex sessions:', error);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { NavigateFunction } from 'react-router-dom';
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
import { api, authenticatedFetch } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
import type {
|
import type {
|
||||||
AppSocketMessage,
|
AppSocketMessage,
|
||||||
AppTab,
|
AppTab,
|
||||||
@@ -23,7 +23,7 @@ const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
|||||||
const projectsHaveChanges = (
|
const projectsHaveChanges = (
|
||||||
prevProjects: Project[],
|
prevProjects: Project[],
|
||||||
nextProjects: Project[],
|
nextProjects: Project[],
|
||||||
includeCursorSessions: boolean,
|
includeExternalSessions: boolean,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (prevProjects.length !== nextProjects.length) {
|
if (prevProjects.length !== nextProjects.length) {
|
||||||
return true;
|
return true;
|
||||||
@@ -46,11 +46,14 @@ const projectsHaveChanges = (
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!includeCursorSessions) {
|
if (!includeExternalSessions) {
|
||||||
return false;
|
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<Project[]> => {
|
|
||||||
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({
|
export function useProjectsState({
|
||||||
sessionId,
|
sessionId,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -149,15 +127,14 @@ export function useProjectsState({
|
|||||||
setIsLoadingProjects(true);
|
setIsLoadingProjects(true);
|
||||||
const response = await api.projects();
|
const response = await api.projects();
|
||||||
const projectData = (await response.json()) as Project[];
|
const projectData = (await response.json()) as Project[];
|
||||||
const projectsWithCursor = await loadCursorSessionsForProjects(projectData);
|
|
||||||
|
|
||||||
setProjects((prevProjects) => {
|
setProjects((prevProjects) => {
|
||||||
if (prevProjects.length === 0) {
|
if (prevProjects.length === 0) {
|
||||||
return projectsWithCursor;
|
return projectData;
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectsHaveChanges(prevProjects, projectsWithCursor, true)
|
return projectsHaveChanges(prevProjects, projectData, true)
|
||||||
? projectsWithCursor
|
? projectData
|
||||||
: prevProjects;
|
: prevProjects;
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -421,7 +398,7 @@ export function useProjectsState({
|
|||||||
const freshProjects = (await response.json()) as Project[];
|
const freshProjects = (await response.json()) as Project[];
|
||||||
|
|
||||||
setProjects((prevProjects) =>
|
setProjects((prevProjects) =>
|
||||||
projectsHaveChanges(prevProjects, freshProjects, false) ? freshProjects : prevProjects,
|
projectsHaveChanges(prevProjects, freshProjects, true) ? freshProjects : prevProjects,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!selectedProject) {
|
if (!selectedProject) {
|
||||||
|
|||||||
Reference in New Issue
Block a user