From 2c778b9646164475ddbbff1e849198439c4e68a3 Mon Sep 17 00:00:00 2001 From: HaydnG Date: Thu, 7 May 2026 17:11:38 +0100 Subject: [PATCH 1/2] Merge project groups that resolve to the same projectPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ~/.claude/projects// directories can resolve to the same project cwd because Claude Code's folder-name encoding scheme has changed over time (older runs encoded dots/spaces/@ as dashes; newer runs preserve them literally). buildProjectsFromCache previously keyed its projectMap by on-disk folder name, so the sidebar rendered both folders as separate groups. This also caused the sidebar to accumulate duplicate DOM nodes on every re-render (toggling the active-session filter, etc.) — morphdom keys on node.id, and project group ids derive from projectPath, so duplicate projectPath entries produced colliding ids that morphdom couldn't reconcile. Key the map by projectPath instead, merging cached rows, empty-folder fallback entries, and active-terminal injections from all encodings into a single project group. --- session-cache.js | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/session-cache.js b/session-cache.js index 4a306de..3089a9a 100644 --- a/session-cache.js +++ b/session-cache.js @@ -170,12 +170,16 @@ function buildProjectsFromCache(showArchived) { const global = getSetting('global') || {}; const hiddenProjects = new Set(global.hiddenProjects || []); - // Group by folder (worktree sessions appear as separate projects) + // Group by projectPath. Multiple ~/.claude/projects// directories can resolve + // to the same projectPath (Claude Code's folder-name encoding scheme has changed over + // time, leaving stragglers), so we merge them into a single sidebar group to avoid + // duplicate-id collisions in the morphdom render. const projectMap = new Map(); for (const row of cachedRows) { + if (!row.projectPath) continue; if (hiddenProjects.has(row.projectPath)) 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: row.folder, projectPath: row.projectPath, sessions: [] }); } const meta = metaMap.get(row.sessionId); const s = { @@ -192,7 +196,7 @@ function buildProjectsFromCache(showArchived) { archived: meta?.archived || 0, }; if (!showArchived && s.archived) continue; - projectMap.get(row.folder).sessions.push(s); + projectMap.get(row.projectPath).sessions.push(s); } // Include empty project directories (no sessions yet) @@ -200,11 +204,11 @@ function buildProjectsFromCache(showArchived) { 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: [] }); - } + const projectPath = deriveProjectPath(path.join(PROJECTS_DIR, d.name), d.name); + if (!projectPath) continue; + if (hiddenProjects.has(projectPath)) continue; + if (!projectMap.has(projectPath)) { + projectMap.set(projectPath, { folder: d.name, projectPath, sessions: [] }); } } } catch {} @@ -212,12 +216,13 @@ 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 = session.projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + 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)) { + const folder = session.projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + projectMap.set(session.projectPath, { folder, 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, From fb682d2a3f70e368147044a40fe08d63fe538a53 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Mon, 11 May 2026 13:04:02 -0700 Subject: [PATCH 2/2] Canonicalize merged project folder and cache empty-dir lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildProjectsFromCache now stores folder: encodeProjectPath(projectPath) for every projectMap insertion, so the merged group's id is deterministic regardless of which legacy on-disk folder happened to be scanned first. - Empty-dirs pass reads folder→projectPath through cache_meta (one query) instead of calling deriveProjectPath per folder per render. Folders the indexer hasn't seen yet fall back to deriveProjectPath and backfill cache_meta so subsequent renders are pure DB lookups. - Wire getAllFolderMeta through main.js → sessionCache.init. Co-Authored-By: Claude Opus 4.7 (1M context) --- main.js | 2 +- session-cache.js | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) 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 1d196f8..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; @@ -198,21 +199,38 @@ function buildProjectsFromCache(showArchived) { }; if (!showArchived && s.archived) continue; if (!projectMap.has(row.projectPath)) { - projectMap.set(row.projectPath, { folder: row.folder, projectPath: row.projectPath, sessions: [] }); + projectMap.set(row.projectPath, { + folder: encodeProjectPath(row.projectPath), + projectPath: row.projectPath, + sessions: [], + }); } 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) { - const projectPath = deriveProjectPath(path.join(PROJECTS_DIR, d.name), d.name); + 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: d.name, projectPath, sessions: [] }); + projectMap.set(projectPath, { + folder: encodeProjectPath(projectPath), + projectPath, + sessions: [], + }); } } } catch {}