mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 18:28:38 +00:00
refactor(projects): identify projects by DB projectId instead of folder-derived name
GET /api/projects used to scan ~/.claude/projects/ on every request, derive each project's identity from the encoded folder name, and re-parse JSONL files to build session lists. Using the folder-derived name as the project identifier leaked the Claude CLI's on-disk encoding into every API route, forced every downstream endpoint to re-resolve a real path via JSONL 'cwd' inspection, and made the project list endpoint O(projects x sessions) on disk I/O. This change switches the entire API surface to identify projects by the stable primary key from the 'projects' table and drives the listing straight from the DB: - Add projectsDb.getProjectPathById as the canonical projectId -> path resolver so routes no longer need to touch the filesystem to figure out where a project lives. - Rewrite getProjects so it reads the project list from the 'projects' table and the per-project session list from the 'sessions' table (one SELECT per project). No filesystem scanning happens for this endpoint anymore, which removes the dependency on ~/.claude/projects existing, on Cursor's MD5-hashed chat folders being discoverable, and on Codex's JSONL history being on disk. Per the migration spec each session now exposes 'summary' sourced from sessions.custom_name, 'messageCount' = 0 (message counting is not implemented), and sessionMeta.hasMore is pinned to false since this endpoint doesn't drive session pagination. - Introduce id-based wrappers (getSessionsById, renameProjectById, deleteSessionById, deleteProjectById, getProjectTaskMasterById) so every caller can pass projectId and resolve the real path through the DB. renameProjectById also writes to projects.custom_project_name so the DB-driven getProjects response reflects renames immediately; it keeps project-config.json in sync for any legacy reader that still consults the JSON file. - Migrate every /api/projects/:projectName route in server/index.js, server/routes/taskmaster.js, and server/routes/messages.js to :projectId, and change server/routes/git.js so the 'project' query/body parameter carries a projectId that is resolved through the DB before any git command runs. TaskMaster WebSocket broadcasts emit 'projectId' for the same reason so the frontend can match notifications against its current selection without another lookup. - Delete helpers that existed only to feed the old getProjects path (getCursorSessions, getGeminiCliSessions, getProjectTaskMaster) along with their unused imports (better-sqlite3's Database, applyCustomSessionNames). The legacy folder-name helpers (getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory) are kept as internal implementation details of the id-based wrappers and of destructive cleanup / conversation search, but they are no longer re-exported. - searchConversations still walks JSONL to produce match snippets (that data doesn't live in the DB), but it now includes the resolved projectId in each result so the sidebar can cross-reference hits with its already loaded project list without a second round-trip. Frontend migration: - Project.name is replaced by Project.projectId in src/types/app.ts, and ProjectSession.__projectName becomes __projectId so session tagging and sidebar state keys stay aligned with the backend identifier. Settings continues to use SettingsProject.name for legacy consumers, but it is populated from projectId by normalizeProjectForSettings. - All places that previously indexed per-project state by project.name (sidebar expanded/starred/loading/deletingProjects sets, additionalSessions map, projectHasMoreOverrides, starredProjects localStorage, command history and draft-input localStorage, TaskMaster caches) now key on projectId so state survives display-name edits and is consistent across the app. - src/utils/api.js renames every endpoint parameter to projectId, the unified messages endpoint takes projectId in its query string, and useSessionStore forwards projectId on fetchFromServer / fetchMore / refreshFromServer. Git panel, file tree, code editor, PRD editor, plugins context, MCP server flows and TaskMaster hooks are all updated to pass projectId. - DEFAULT_PROJECT_FOR_EMPTY_SHELL is updated to carry a 'default' projectId sentinel so the empty-shell placeholder still satisfies the Project contract. Bug fix bundled in: - sessionsDb.setName no longer bumps updated_at when a row already exists. Renaming is a label change, not activity, so there is no reason for it to reset 'last activity' in the sidebar. It also no longer relies on SQLite's CURRENT_TIMESTAMP, which stores a naive 'YYYY-MM-DD HH:MM:SS' value that JavaScript parses as local time and caused renamed sessions to appear shifted backwards by the client's UTC offset. When an INSERT actually happens it now writes ISO-8601 UTC with a 'Z' suffix. - buildSessionsByProviderFromDb normalizes any legacy naive timestamps in the sessions table to ISO-8601 UTC on the way out so rows written before this change also render correctly on the client. Other cleanup: - Removed the filesystem-first project-discovery comment block at the top of server/projects.js and replaced it with a short note that describes the new DB-driven flow and lists the few remaining filesystem-dependent helpers (message reads, search, destructive delete, manual project registration). - server/modules/providers/index.ts is added as a small barrel so the providers module exposes a stable public surface. Made-with: Cursor
This commit is contained in:
@@ -1,71 +1,39 @@
|
||||
/**
|
||||
* PROJECT DISCOVERY AND MANAGEMENT SYSTEM
|
||||
* ========================================
|
||||
*
|
||||
* This module manages project discovery for both Claude CLI and Cursor CLI sessions.
|
||||
*
|
||||
* ## Architecture Overview
|
||||
*
|
||||
* 1. **Claude Projects** (stored in ~/.claude/projects/)
|
||||
* - Each project is a directory named with the project path encoded (/ replaced with -)
|
||||
* - Contains .jsonl files with conversation history including 'cwd' field
|
||||
* - Project metadata stored in ~/.claude/project-config.json
|
||||
*
|
||||
* 2. **Cursor Projects** (stored in ~/.cursor/chats/)
|
||||
* - Each project directory is named with MD5 hash of the absolute project path
|
||||
* - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6...
|
||||
* - Contains session directories with SQLite databases (store.db)
|
||||
* - Project path is NOT stored in the database - only in the MD5 hash
|
||||
*
|
||||
* ## Project Discovery Strategy
|
||||
*
|
||||
* 1. **Claude Projects Discovery**:
|
||||
* - Scan ~/.claude/projects/ directory for Claude project folders
|
||||
* - Extract actual project path from .jsonl files (cwd field)
|
||||
* - Fall back to decoded directory name if no sessions exist
|
||||
*
|
||||
* 2. **Cursor Sessions Discovery**:
|
||||
* - For each KNOWN project (from Claude or manually added)
|
||||
* - Compute MD5 hash of the project's absolute path
|
||||
* - Check if ~/.cursor/chats/{md5_hash}/ directory exists
|
||||
* - Read session metadata from SQLite store.db files
|
||||
*
|
||||
* 3. **Manual Project Addition**:
|
||||
* - Users can manually add project paths via UI
|
||||
* - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag
|
||||
* - Allows discovering Cursor sessions for projects without Claude sessions
|
||||
*
|
||||
* ## Critical Limitations
|
||||
*
|
||||
* - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of
|
||||
* the cwd of each project. if someone has the time, you can try to reverse engineer it.
|
||||
*
|
||||
* - **Project relocation breaks history**: If a project directory is moved or renamed,
|
||||
* the MD5 hash changes, making old Cursor sessions inaccessible unless the old
|
||||
* path is known and manually added.
|
||||
*
|
||||
* ## Error Handling
|
||||
*
|
||||
* - Missing ~/.claude directory is handled gracefully with automatic creation
|
||||
* - ENOENT errors are caught and handled without crashing
|
||||
* - Empty arrays returned when no projects/sessions exist
|
||||
*
|
||||
* ## Caching Strategy
|
||||
*
|
||||
* - Project directory extraction is cached to minimize file I/O
|
||||
* - Cache is cleared when project configuration changes
|
||||
* - Session data is fetched on-demand, not cached
|
||||
* PROJECT DISCOVERY AND MANAGEMENT
|
||||
* ================================
|
||||
*
|
||||
* After the projectName → projectId migration, project and session listings
|
||||
* for `GET /api/projects` are sourced entirely from the database:
|
||||
*
|
||||
* - `projects` table (via `projectsDb`) — the canonical list of projects and
|
||||
* their absolute `project_path`.
|
||||
* - `sessions` table (via `sessionsDb`) — every provider's sessions for a
|
||||
* given project, keyed by `project_path`.
|
||||
*
|
||||
* Routes always address a project by its DB `projectId` and resolve the real
|
||||
* directory through `getProjectPathById` before touching disk.
|
||||
*
|
||||
* The filesystem-aware helpers kept in this module serve the remaining
|
||||
* features that still need on-disk data:
|
||||
* - Session message reads for each provider (Claude/Codex/Gemini) for
|
||||
* `GET /api/sessions/:sessionId/messages`.
|
||||
* - Conversation search (`searchConversations`) which scans JSONL history.
|
||||
* - Destructive project cleanup (`deleteProjectById` -> `deleteProject`)
|
||||
* which removes Claude/Cursor/Codex artifacts on disk.
|
||||
* - Manual project registration (`addProjectManually`) which syncs to
|
||||
* ~/.claude/project-config.json for backwards compatibility.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import fsSync from 'fs';
|
||||
import fsSync, { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import readline from 'readline';
|
||||
import crypto from 'crypto';
|
||||
import Database from 'better-sqlite3';
|
||||
import os from 'os';
|
||||
|
||||
import { sessionSynchronizerService } from '@/modules/providers';
|
||||
|
||||
import sessionManager from './sessionManager.js';
|
||||
import { applyCustomSessionNames } from './modules/database/index.js';
|
||||
import { projectsDb, sessionsDb } from './modules/database/index.js';
|
||||
import { getModuleDir, findAppRoot } from './utils/runtime-paths.js';
|
||||
|
||||
// Snapshot files are kept as incrementing artifacts under .tmp/project-dumps for later review.
|
||||
@@ -265,12 +233,56 @@ function normalizeTaskMasterInfo(taskMasterResult = null) {
|
||||
};
|
||||
}
|
||||
|
||||
async function getProjectTaskMaster(projectName) {
|
||||
const projectPath = await extractProjectDirectory(projectName);
|
||||
/**
|
||||
* Resolve the absolute project path for a database `projectId`.
|
||||
*
|
||||
* After the projectName → projectId migration, every API route receives a
|
||||
* `projectId` (the primary key from the `projects` table) and must translate
|
||||
* it into the real directory on disk through this helper. Returns `null` when
|
||||
* the id doesn't match any row so callers can respond with a 404.
|
||||
*/
|
||||
async function getProjectPathById(projectId) {
|
||||
if (!projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return projectsDb.getProjectPathById(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Claude CLI project folder name for an absolute path.
|
||||
*
|
||||
* Claude stores its JSONL history per project under
|
||||
* `~/.claude/projects/<encoded-path>/`. The folder name is derived from the
|
||||
* absolute path by replacing every non-alphanumeric character (except `-`) with
|
||||
* `-`. Filesystem helpers like `getSessions`/`deleteSession` still work on that
|
||||
* folder name, so routes that receive a `projectId` compute it from the path
|
||||
* resolved through the DB instead of keeping the encoded name as an identifier.
|
||||
*/
|
||||
function claudeFolderNameFromPath(projectPath) {
|
||||
if (!projectPath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskMaster details for a project, addressed by DB `projectId`.
|
||||
*
|
||||
* Resolves the project path through the DB and inspects the `.taskmaster`
|
||||
* folder on disk for metadata the TaskMaster panel displays.
|
||||
*/
|
||||
async function getProjectTaskMasterById(projectId) {
|
||||
const projectPath = await getProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskMasterResult = await detectTaskMasterFolder(projectPath);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
projectId,
|
||||
projectPath,
|
||||
taskmaster: normalizeTaskMasterInfo(taskMasterResult)
|
||||
};
|
||||
@@ -342,8 +354,10 @@ async function generateDisplayName(projectName, actualProjectDir = null) {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
// Extract the actual project directory from JSONL sessions (with caching)
|
||||
// TODO: Get the project id as parameter and return the actual project directory from the database
|
||||
// Resolve a Claude-encoded folder name back to an absolute project directory
|
||||
// by inspecting cached metadata and JSONL `cwd` fields. Used only by the
|
||||
// legacy name-based helpers below (`getSessions`, `deleteProject`, etc.) and
|
||||
// by the conversation search; id-based routes use `getProjectPathById`.
|
||||
async function extractProjectDirectory(projectName) {
|
||||
// Check cache first
|
||||
if (projectDirectoryCache.has(projectName)) {
|
||||
@@ -463,209 +477,115 @@ async function extractProjectDirectory(projectName) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group the `sessions` table rows for a project by provider.
|
||||
*
|
||||
* After the projectId migration, GET /api/projects no longer scans JSONL files
|
||||
* or any other session directory — every provider's session list comes from
|
||||
* the database. One `SELECT ... WHERE project_path = ?` gets us every row we
|
||||
* need, and we then bucket them by `provider` so each list (`sessions`,
|
||||
* `cursorSessions`, `codexSessions`, `geminiSessions`) can be built without
|
||||
* touching disk. Per the migration spec, each emitted session carries
|
||||
* `summary = custom_name`, `messageCount = 0` and `lastActivity` taken from
|
||||
* `updated_at` so the sidebar still sorts by recency.
|
||||
*/
|
||||
function buildSessionsByProviderFromDb(projectPath) {
|
||||
const rows = sessionsDb.getSessionsByProjectPath(projectPath);
|
||||
const byProvider = {
|
||||
claude: [],
|
||||
cursor: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
const bucket = byProvider[row.provider];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bucket.push({
|
||||
id: row.session_id,
|
||||
// The session summary intentionally mirrors the custom_name column only;
|
||||
// the historical JSONL-derived summary is no longer computed on this path.
|
||||
summary: row.custom_name || '',
|
||||
// messageCount is always 0 for now — counting is not implemented yet.
|
||||
messageCount: 0,
|
||||
lastActivity: row.updated_at ?? row.created_at ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort each bucket by recency so the sidebar's default ordering is preserved.
|
||||
for (const provider of Object.keys(byProvider)) {
|
||||
byProvider[provider].sort(
|
||||
(a, b) => new Date(b.lastActivity) - new Date(a.lastActivity),
|
||||
);
|
||||
}
|
||||
|
||||
return byProvider;
|
||||
}
|
||||
|
||||
async function getProjects(progressCallback = null) {
|
||||
const claudeDir = path.join(os.homedir(), '.claude', 'projects');
|
||||
const config = await loadProjectConfig();
|
||||
await sessionSynchronizerService.synchronizeSessions();
|
||||
// Source of truth for project listing is now the `projects` and `sessions`
|
||||
// tables — no directory scanning happens here. This keeps the API fast and
|
||||
// lets the frontend identify projects by their stable DB `projectId`.
|
||||
const projectRows = projectsDb.getProjectPaths();
|
||||
const totalProjects = projectRows.length;
|
||||
const projects = [];
|
||||
const existingProjects = new Set();
|
||||
const codexSessionsIndexRef = { sessionsByProject: null };
|
||||
let totalProjects = 0;
|
||||
let processedProjects = 0;
|
||||
let directories = [];
|
||||
|
||||
try {
|
||||
// Check if the .claude/projects directory exists
|
||||
await fs.access(claudeDir);
|
||||
for (const row of projectRows) {
|
||||
processedProjects++;
|
||||
|
||||
// First, get existing Claude projects from the file system
|
||||
const entries = await fs.readdir(claudeDir, { withFileTypes: true });
|
||||
directories = entries.filter(e => e.isDirectory());
|
||||
const projectId = row.project_id;
|
||||
const projectPath = row.project_path;
|
||||
|
||||
// Build set of existing project names for later
|
||||
directories.forEach(e => existingProjects.add(e.name));
|
||||
|
||||
// Count manual projects not already in directories
|
||||
const manualProjectsCount = Object.entries(config)
|
||||
.filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name))
|
||||
.length;
|
||||
|
||||
totalProjects = directories.length + manualProjectsCount;
|
||||
|
||||
for (const entry of directories) {
|
||||
processedProjects++;
|
||||
|
||||
// Emit progress
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: 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,
|
||||
sessions: [],
|
||||
geminiSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
project.sessionMeta = {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
applyCustomSessionNames(project.sessions, 'claude');
|
||||
|
||||
// Also fetch Cursor sessions for this project
|
||||
try {
|
||||
project.cursorSessions = await getCursorSessions(actualProjectDir);
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.cursorSessions, 'cursor');
|
||||
|
||||
// Also fetch Codex sessions for this project
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||
indexRef: codexSessionsIndexRef,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);
|
||||
project.codexSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Also fetch Gemini sessions for this project (UI + CLI)
|
||||
try {
|
||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||
const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||
project.geminiSessions = mergedGemini;
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);
|
||||
project.geminiSessions = [];
|
||||
}
|
||||
applyCustomSessionNames(project.geminiSessions, 'gemini');
|
||||
|
||||
projects.push(project);
|
||||
// console.log(`Loaded project: ${project.displayName} (${project.name}) with ${project.sessions.length} sessions, ${project.cursorSessions.length} Cursor sessions, ${project.codexSessions.length} Codex sessions, and ${project.geminiSessions.length} Gemini sessions.`);
|
||||
// console.log("Full project data:", project);
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: projectPath
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error reading projects directory:', error);
|
||||
}
|
||||
// Calculate total for manual projects only (no directories exist)
|
||||
totalProjects = Object.entries(config)
|
||||
.filter(([name, cfg]) => cfg.manuallyAdded)
|
||||
.length;
|
||||
|
||||
// Use the stored custom name when present, otherwise fall back to a
|
||||
// generated display name derived from the project path.
|
||||
const displayName = row.custom_project_name && row.custom_project_name.trim().length > 0
|
||||
? row.custom_project_name
|
||||
: await generateDisplayName(path.basename(projectPath) || projectPath, projectPath);
|
||||
|
||||
// All provider session lists are built from a single DB query — no JSONL
|
||||
// parsing, no filesystem walks, no in-memory session manager lookups.
|
||||
const sessionsByProvider = buildSessionsByProviderFromDb(projectPath);
|
||||
const claudeSessionsAll = sessionsByProvider.claude;
|
||||
const claudeSessions = claudeSessionsAll.slice(0, 5);
|
||||
|
||||
const project = {
|
||||
// Primary identifier used across the UI and API routes post-migration.
|
||||
projectId,
|
||||
path: projectPath,
|
||||
displayName,
|
||||
fullPath: projectPath,
|
||||
sessions: claudeSessions,
|
||||
cursorSessions: sessionsByProvider.cursor,
|
||||
codexSessions: sessionsByProvider.codex,
|
||||
geminiSessions: sessionsByProvider.gemini,
|
||||
// hasMore is pinned to false per the migration spec — pagination on the
|
||||
// project list is not driven by this endpoint anymore.
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: claudeSessionsAll.length
|
||||
}
|
||||
};
|
||||
|
||||
// Custom-name overrides are already baked into each row's `summary` field
|
||||
// by buildSessionsByProviderFromDb, so we don't need to re-apply them.
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
processedProjects++;
|
||||
|
||||
// Emit progress for manual projects
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'loading',
|
||||
current: processedProjects,
|
||||
total: totalProjects,
|
||||
currentProject: projectName
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
sessions: [],
|
||||
geminiSessions: [],
|
||||
sessionMeta: {
|
||||
hasMore: false,
|
||||
total: 0
|
||||
},
|
||||
cursorSessions: [],
|
||||
codexSessions: []
|
||||
};
|
||||
|
||||
// Try to fetch Cursor sessions for manual projects too
|
||||
try {
|
||||
project.cursorSessions = await getCursorSessions(actualProjectDir);
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.cursorSessions, 'cursor');
|
||||
|
||||
// Try to fetch Codex sessions for manual projects too
|
||||
try {
|
||||
project.codexSessions = await getCodexSessions(actualProjectDir, {
|
||||
indexRef: codexSessionsIndexRef,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.codexSessions, 'codex');
|
||||
|
||||
// Try to fetch Gemini sessions for manual projects too (UI + CLI)
|
||||
try {
|
||||
const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];
|
||||
const cliSessions = await getGeminiCliSessions(actualProjectDir);
|
||||
const uiIds = new Set(uiSessions.map(s => s.id));
|
||||
project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];
|
||||
} catch (e) {
|
||||
console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
applyCustomSessionNames(project.geminiSessions, 'gemini');
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit completion after all projects (including manual) are processed
|
||||
if (progressCallback) {
|
||||
progressCallback({
|
||||
phase: 'complete',
|
||||
@@ -1117,6 +1037,26 @@ async function getSessionMessages(projectName, sessionId, limit = null, offset =
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ID-based wrapper around `getSessions`.
|
||||
*
|
||||
* Resolves a `projectId` to the underlying Claude JSONL folder name (via the
|
||||
* DB-backed project path) and defers to the legacy filesystem reader. Keeps
|
||||
* the previous pagination shape so the sidebar's "Load more sessions" UI keeps
|
||||
* working after the migration.
|
||||
*/
|
||||
async function getSessionsById(projectId, limit = 5, offset = 0) {
|
||||
const projectPath = await getProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
return { sessions: [], hasMore: false, total: 0 };
|
||||
}
|
||||
|
||||
// Claude stores history under ~/.claude/projects/<encoded-path>/; derive the
|
||||
// folder name from the absolute path the DB gave us.
|
||||
const claudeFolderName = claudeFolderNameFromPath(projectPath);
|
||||
return getSessions(claudeFolderName, limit, offset);
|
||||
}
|
||||
|
||||
// Rename a project's display name
|
||||
async function renameProject(projectName, newDisplayName) {
|
||||
const config = await loadProjectConfig();
|
||||
@@ -1138,6 +1078,53 @@ async function renameProject(projectName, newDisplayName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ID-based wrapper around `renameProject`.
|
||||
*
|
||||
* Writes the new display name to the `projects.custom_project_name` column
|
||||
* (the source of truth for the DB-driven getProjects() response) and also
|
||||
* keeps the legacy project-config.json in sync for backwards compatibility
|
||||
* with any code that still reads it.
|
||||
*/
|
||||
async function renameProjectById(projectId, newDisplayName) {
|
||||
const projectPath = await getProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
throw new Error(`Unknown projectId: ${projectId}`);
|
||||
}
|
||||
|
||||
const trimmed = typeof newDisplayName === 'string' ? newDisplayName.trim() : '';
|
||||
// Persist on the DB row so getProjects() immediately reflects the change.
|
||||
projectsDb.updateCustomProjectNameById(projectId, trimmed.length > 0 ? trimmed : null);
|
||||
|
||||
// Keep the legacy file-based project config in lockstep so historic readers
|
||||
// that still consult project-config.json don't diverge.
|
||||
const claudeFolderName = claudeFolderNameFromPath(projectPath);
|
||||
try {
|
||||
await renameProject(claudeFolderName, trimmed);
|
||||
} catch (error) {
|
||||
console.warn(`[projects] Legacy renameProject sync failed for ${projectId}:`, error.message);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* ID-based wrapper around `deleteSession`.
|
||||
*
|
||||
* Resolves the real Claude history folder via the DB-backed path, then defers
|
||||
* to the filesystem deletion routine. Callers should still clean up any DB
|
||||
* bookkeeping (e.g. the sessions table) at the route layer.
|
||||
*/
|
||||
async function deleteSessionById(projectId, sessionId) {
|
||||
const projectPath = await getProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
throw new Error(`Unknown projectId: ${projectId}`);
|
||||
}
|
||||
|
||||
const claudeFolderName = claudeFolderNameFromPath(projectPath);
|
||||
return deleteSession(claudeFolderName, sessionId);
|
||||
}
|
||||
|
||||
// Delete a session from a project
|
||||
async function deleteSession(projectName, sessionId) {
|
||||
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
||||
@@ -1261,6 +1248,35 @@ async function deleteProject(projectName, force = false, deleteData = false) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ID-based wrapper around `deleteProject`.
|
||||
*
|
||||
* Resolves the project path via the DB, defers destructive filesystem cleanup
|
||||
* to `deleteProject`, then removes the row from the `projects` table so the
|
||||
* DB-driven GET /api/projects response no longer lists it.
|
||||
*/
|
||||
async function deleteProjectById(projectId, force = false, deleteData = false) {
|
||||
const projectPath = await getProjectPathById(projectId);
|
||||
if (!projectPath) {
|
||||
throw new Error(`Unknown projectId: ${projectId}`);
|
||||
}
|
||||
|
||||
const claudeFolderName = claudeFolderNameFromPath(projectPath);
|
||||
try {
|
||||
await deleteProject(claudeFolderName, force, deleteData);
|
||||
} catch (error) {
|
||||
// If the legacy Claude folder doesn't exist anymore we still want to drop
|
||||
// the DB row; rethrow otherwise so callers can surface the failure.
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the DB row so the DB-driven GET /api/projects stops listing it.
|
||||
projectsDb.deleteProjectById(projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Add a project manually to the config (without creating folders)
|
||||
async function addProjectManually(projectPath, displayName = null) {
|
||||
const absolutePath = path.resolve(projectPath);
|
||||
@@ -1309,110 +1325,6 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch Cursor sessions for a given project path
|
||||
async function getCursorSessions(projectPath) {
|
||||
try {
|
||||
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
||||
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
|
||||
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(cursorChatsPath);
|
||||
} catch (error) {
|
||||
// No sessions for this project
|
||||
return [];
|
||||
}
|
||||
|
||||
// List all session directories
|
||||
const sessionDirs = await fs.readdir(cursorChatsPath);
|
||||
const sessions = [];
|
||||
|
||||
for (const sessionId of sessionDirs) {
|
||||
const sessionPath = path.join(cursorChatsPath, sessionId);
|
||||
const storeDbPath = path.join(sessionPath, 'store.db');
|
||||
|
||||
try {
|
||||
// Check if store.db exists
|
||||
await fs.access(storeDbPath);
|
||||
|
||||
// Capture store.db mtime as a reliable fallback timestamp
|
||||
let dbStatMtimeMs = null;
|
||||
try {
|
||||
const stat = await fs.stat(storeDbPath);
|
||||
dbStatMtimeMs = stat.mtimeMs;
|
||||
} catch (_) { }
|
||||
|
||||
// Open SQLite database
|
||||
const db = new Database(storeDbPath, { readonly: true, fileMustExist: true });
|
||||
|
||||
// Get metadata from meta table
|
||||
const metaRows = db.prepare('SELECT key, value FROM meta').all();
|
||||
|
||||
// Parse metadata
|
||||
let metadata = {};
|
||||
for (const row of metaRows) {
|
||||
if (row.value) {
|
||||
try {
|
||||
// Try to decode as hex-encoded JSON
|
||||
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
||||
if (hexMatch) {
|
||||
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
||||
metadata[row.key] = JSON.parse(jsonStr);
|
||||
} else {
|
||||
metadata[row.key] = row.value.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
metadata[row.key] = row.value.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get message count
|
||||
const messageCountResult = db.prepare('SELECT COUNT(*) as count FROM blobs').get();
|
||||
|
||||
db.close();
|
||||
|
||||
// Extract session info
|
||||
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
|
||||
|
||||
// Determine timestamp - prefer createdAt from metadata, fall back to db file mtime
|
||||
let createdAt = null;
|
||||
if (metadata.createdAt) {
|
||||
createdAt = new Date(metadata.createdAt).toISOString();
|
||||
} else if (dbStatMtimeMs) {
|
||||
createdAt = new Date(dbStatMtimeMs).toISOString();
|
||||
} else {
|
||||
createdAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
id: sessionId,
|
||||
name: sessionName,
|
||||
createdAt: createdAt,
|
||||
lastActivity: createdAt, // For compatibility with Claude sessions
|
||||
messageCount: messageCountResult.count || 0,
|
||||
projectPath: projectPath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort sessions by creation time (newest first)
|
||||
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||
|
||||
// Return only the first 5 sessions for performance
|
||||
return sessions.slice(0, 5);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching Cursor sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function normalizeComparablePath(inputPath) {
|
||||
if (!inputPath || typeof inputPath !== 'string') {
|
||||
return '';
|
||||
@@ -2011,7 +1923,23 @@ async function searchConversations(query, limit = 50, onProjectResult = null, si
|
||||
file => file.endsWith('.jsonl') && !file.startsWith('agent-')
|
||||
);
|
||||
|
||||
// Also include the DB `projectId` so the frontend (which now identifies
|
||||
// projects by `projectId`) can match search results to the
|
||||
// currently-loaded project list without a second round-trip.
|
||||
let searchProjectId = null;
|
||||
try {
|
||||
const resolvedPath = await extractProjectDirectory(projectName);
|
||||
const dbRow = projectsDb.getProjectPath(resolvedPath);
|
||||
if (dbRow?.project_id) {
|
||||
searchProjectId = dbRow.project_id;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: if we cannot resolve the projectId, the result is still
|
||||
// usable on the backend but the frontend will skip the auto-select.
|
||||
}
|
||||
|
||||
const projectResult = {
|
||||
projectId: searchProjectId,
|
||||
projectName,
|
||||
projectDisplayName: displayName,
|
||||
sessions: []
|
||||
@@ -2438,82 +2366,6 @@ async function searchGeminiSessionsForProject(
|
||||
}
|
||||
}
|
||||
|
||||
async function getGeminiCliSessions(projectPath) {
|
||||
const normalizedProjectPath = normalizeComparablePath(projectPath);
|
||||
if (!normalizedProjectPath) return [];
|
||||
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
try {
|
||||
await fs.access(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessions = [];
|
||||
let projectDirs;
|
||||
try {
|
||||
projectDirs = await fs.readdir(geminiTmpDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const projectDir of projectDirs) {
|
||||
const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');
|
||||
let projectRoot;
|
||||
try {
|
||||
projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;
|
||||
|
||||
const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');
|
||||
let chatFiles;
|
||||
try {
|
||||
chatFiles = await fs.readdir(chatsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const chatFile of chatFiles) {
|
||||
if (!chatFile.endsWith('.json')) continue;
|
||||
try {
|
||||
const filePath = path.join(chatsDir, chatFile);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
if (!session.messages || !Array.isArray(session.messages)) continue;
|
||||
|
||||
const sessionId = session.sessionId || chatFile.replace('.json', '');
|
||||
const firstUserMsg = session.messages.find(m => m.type === 'user');
|
||||
let summary = 'Gemini CLI Session';
|
||||
if (firstUserMsg) {
|
||||
const text = Array.isArray(firstUserMsg.content)
|
||||
? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ')
|
||||
: (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : '');
|
||||
if (text) {
|
||||
summary = text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push({
|
||||
id: sessionId,
|
||||
summary,
|
||||
messageCount: session.messages.length,
|
||||
lastActivity: session.lastUpdated || session.startTime || null,
|
||||
provider: 'gemini'
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) =>
|
||||
new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)
|
||||
);
|
||||
}
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId) {
|
||||
const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');
|
||||
let projectDirs;
|
||||
@@ -2568,20 +2420,23 @@ async function getGeminiCliSessionMessages(sessionId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Only functions with consumers outside this module are exported. Folder-name
|
||||
// based helpers (`getSessions`, `renameProject`, `deleteSession`, etc.) are
|
||||
// kept as internal implementation details of the id-based wrappers below.
|
||||
export {
|
||||
getProjects,
|
||||
getSessions,
|
||||
getSessionsById,
|
||||
getSessionMessages,
|
||||
renameProject,
|
||||
deleteSession,
|
||||
deleteProject,
|
||||
renameProjectById,
|
||||
deleteSessionById,
|
||||
deleteProjectById,
|
||||
addProjectManually,
|
||||
getProjectTaskMaster,
|
||||
extractProjectDirectory,
|
||||
getProjectTaskMasterById,
|
||||
getProjectPathById,
|
||||
claudeFolderNameFromPath,
|
||||
clearProjectDirectoryCache,
|
||||
getCodexSessionMessages,
|
||||
deleteCodexSession,
|
||||
getGeminiCliSessions,
|
||||
getGeminiCliSessionMessages,
|
||||
searchConversations
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user