diff --git a/main.js b/main.js index 7799be8..2c587b7 100644 --- a/main.js +++ b/main.js @@ -262,7 +262,7 @@ sessionCache.init({ db: { deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, deleteSearchFolder, deleteSearchSession, upsertSearchEntries, - setFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName, + setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName, }, }); const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem, diff --git a/session-cache.js b/session-cache.js index 342eaff..2215a4f 100644 --- a/session-cache.js +++ b/session-cache.js @@ -13,7 +13,7 @@ const { encodeProjectPath } = require('./encode-project-path'); let PROJECTS_DIR, activeSessions, getMainWindow, log; let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession; let deleteSearchFolder, deleteSearchSession, upsertSearchEntries; -let setFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; +let setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; function init(ctx) { PROJECTS_DIR = ctx.PROJECTS_DIR; @@ -29,6 +29,7 @@ function init(ctx) { deleteSearchSession = ctx.db.deleteSearchSession; upsertSearchEntries = ctx.db.upsertSearchEntries; setFolderMeta = ctx.db.setFolderMeta; + getAllFolderMeta = ctx.db.getAllFolderMeta; getAllMeta = ctx.db.getAllMeta; getAllCached = ctx.db.getAllCached; getSetting = ctx.db.getSetting; @@ -171,12 +172,16 @@ function buildProjectsFromCache(showArchived) { const global = getSetting('global') || {}; const hiddenProjects = new Set(global.hiddenProjects || []); - // Group by folder (worktree sessions appear as separate projects). - // Only insert a project entry once we have a session that survives the - // archive filter — otherwise folders whose sessions are all archived would - // appear in the sidebar as undismissable phantom entries. + // Group by projectPath, not on-disk folder name. Multiple ~/.claude/projects// + // directories can resolve to the same projectPath (Claude Code's folder-name encoding + // scheme has changed over time, leaving legacy stragglers around), so we merge them into + // a single sidebar group to avoid duplicate-id collisions in the morphdom render. + // Only insert a project entry once we have a session that survives the archive filter — + // otherwise folders whose sessions are all archived would appear in the sidebar as + // undismissable phantom entries. const projectMap = new Map(); for (const row of cachedRows) { + if (!row.projectPath) continue; if (hiddenProjects.has(row.projectPath)) continue; const meta = metaMap.get(row.sessionId); const s = { @@ -193,22 +198,39 @@ function buildProjectsFromCache(showArchived) { archived: meta?.archived || 0, }; if (!showArchived && s.archived) continue; - if (!projectMap.has(row.folder)) { - projectMap.set(row.folder, { folder: row.folder, projectPath: row.projectPath, sessions: [] }); + if (!projectMap.has(row.projectPath)) { + projectMap.set(row.projectPath, { + folder: encodeProjectPath(row.projectPath), + projectPath: row.projectPath, + sessions: [], + }); } - projectMap.get(row.folder).sessions.push(s); + projectMap.get(row.projectPath).sessions.push(s); } - // Include empty project directories (no sessions yet) + // Include empty project directories (no sessions yet). Resolve folder→projectPath + // through cache_meta (populated by the indexer) instead of re-reading a JSONL off + // disk for every directory on every render. Fall back to deriveProjectPath only + // for folders the indexer hasn't seen yet, and backfill cache_meta so subsequent + // renders are pure DB reads. try { + const folderMeta = getAllFolderMeta(); const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }) .filter(d => d.isDirectory() && d.name !== '.git'); for (const d of dirs) { - if (!projectMap.has(d.name)) { - const projectPath = deriveProjectPath(path.join(PROJECTS_DIR, d.name), d.name); - if (projectPath && !hiddenProjects.has(projectPath)) { - projectMap.set(d.name, { folder: d.name, projectPath, sessions: [] }); - } + let projectPath = folderMeta.get(d.name)?.projectPath; + if (!projectPath) { + projectPath = deriveProjectPath(path.join(PROJECTS_DIR, d.name), d.name); + if (projectPath) setFolderMeta(d.name, projectPath, 0); + } + if (!projectPath) continue; + if (hiddenProjects.has(projectPath)) continue; + if (!projectMap.has(projectPath)) { + projectMap.set(projectPath, { + folder: encodeProjectPath(projectPath), + projectPath, + sessions: [], + }); } } } catch {} @@ -216,12 +238,16 @@ function buildProjectsFromCache(showArchived) { // Inject active plain terminal sessions so they participate in sorting for (const [sessionId, session] of activeSessions) { if (session.exited || !session.isPlainTerminal) continue; - const folder = encodeProjectPath(session.projectPath); + if (!session.projectPath) continue; if (hiddenProjects.has(session.projectPath)) continue; - if (!projectMap.has(folder)) { - projectMap.set(folder, { folder, projectPath: session.projectPath, sessions: [] }); + if (!projectMap.has(session.projectPath)) { + projectMap.set(session.projectPath, { + folder: encodeProjectPath(session.projectPath), + projectPath: session.projectPath, + sessions: [], + }); } - const proj = projectMap.get(folder); + const proj = projectMap.get(session.projectPath); if (!proj.sessions.some(s => s.sessionId === sessionId)) { proj.sessions.push({ sessionId, summary: 'Terminal', firstPrompt: '', projectPath: session.projectPath,