Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 44 additions & 18 deletions session-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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/<folder>/
// 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 = {
Expand All @@ -193,35 +198,56 @@ 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 {}

// 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,
Expand Down
Loading