diff --git a/encode-project-path.js b/encode-project-path.js new file mode 100644 index 0000000..5f1ce44 --- /dev/null +++ b/encode-project-path.js @@ -0,0 +1,14 @@ +// Mirror Claude CLI's project-folder naming so Switchboard-created folders +// match the ones the CLI writes for the same project path. +// Reverse-engineered from claude CLI 2.1.126. +function encodeProjectPath(projectPath) { + const sanitized = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); + if (sanitized.length <= 200) return sanitized; + let h = 0; + for (let i = 0; i < projectPath.length; i++) { + h = (h << 5) - h + projectPath.charCodeAt(i) | 0; + } + return sanitized.slice(0, 200) + '-' + Math.abs(h).toString(36); +} + +module.exports = { encodeProjectPath }; diff --git a/main.js b/main.js index a24a1b2..7799be8 100644 --- a/main.js +++ b/main.js @@ -28,6 +28,7 @@ const cleanPtyEnv = Object.fromEntries( // Shell profiles → shell-profiles.js const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles'); const { startScheduler } = require('./schedule-runner'); +const { encodeProjectPath } = require('./encode-project-path'); @@ -293,7 +294,7 @@ ipcMain.handle('add-project', (_event, projectPath) => { } // Create the corresponding folder in ~/.claude/projects/ so it persists - const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(projectPath); const folderPath = path.join(PROJECTS_DIR, folder); if (!fs.existsSync(folderPath)) { fs.mkdirSync(folderPath, { recursive: true }); @@ -329,7 +330,7 @@ ipcMain.handle('remove-project', (_event, projectPath) => { setSetting('global', global); // Clean up DB cache and search index for this folder - const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(projectPath); deleteCachedFolder(folder); deleteSearchFolder(folder); deleteSetting('project:' + projectPath); @@ -981,7 +982,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se if (!isPlainTerminal) { // Snapshot existing .jsonl files before spawning (for new session + fork/plan detection) - projectFolder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + projectFolder = encodeProjectPath(projectPath); const claudeProjectDir = path.join(PROJECTS_DIR, projectFolder); if (fs.existsSync(claudeProjectDir)) { try { diff --git a/public/app.js b/public/app.js index 9875965..7631359 100644 --- a/public/app.js +++ b/public/app.js @@ -670,7 +670,7 @@ async function loadProjects({ resort = false } = {}) { const activeTerminals = await window.api.getActiveTerminals(); for (const { sessionId, projectPath } of activeTerminals) { if (pendingSessions.has(sessionId)) continue; // already tracked - const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(projectPath); // Find the session object already injected by the backend let session; for (const proj of cachedAllProjects) { @@ -709,7 +709,7 @@ async function launchNewSession(project, sessionOptions) { }; // Track as pending (no .jsonl yet) - const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(projectPath); pendingSessions.set(sessionId, { session, projectPath, folder }); // Inject into cached project data so it appears in sidebar immediately diff --git a/public/dialogs.js b/public/dialogs.js index dab5b16..836ce2d 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -49,7 +49,7 @@ async function launchScheduleCreator(project) { }; // Inject into sidebar - const folder = project.projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(project.projectPath); pendingSessions.set(result.sessionId, { session, projectPath: project.projectPath, folder }); sessionMap.set(result.sessionId, session); for (const projList of [cachedProjects, cachedAllProjects]) { @@ -141,7 +141,7 @@ async function launchTerminalSession(project) { }; // Track as pending - const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(projectPath); pendingSessions.set(sessionId, { session, projectPath, folder }); // Inject into cached project data diff --git a/public/utils.js b/public/utils.js index 8702928..e76e641 100644 --- a/public/utils.js +++ b/public/utils.js @@ -1,5 +1,17 @@ // --- Utility functions (shared across renderer modules) --- +// Mirror Claude CLI's project-folder naming. Must stay in sync with +// encode-project-path.js (main process). Reverse-engineered from claude CLI 2.1.126. +function encodeProjectPath(projectPath) { + const sanitized = projectPath.replace(/[^a-zA-Z0-9]/g, '-'); + if (sanitized.length <= 200) return sanitized; + let h = 0; + for (let i = 0; i < projectPath.length; i++) { + h = (h << 5) - h + projectPath.charCodeAt(i) | 0; + } + return sanitized.slice(0, 200) + '-' + Math.abs(h).toString(36); +} + function cleanDisplayName(name) { if (!name) return name; const prefix = 'Implement the following plan:'; diff --git a/schedule-ipc.js b/schedule-ipc.js index 7d83e66..cd3d858 100644 --- a/schedule-ipc.js +++ b/schedule-ipc.js @@ -4,6 +4,7 @@ const path = require('path'); const fs = require('fs'); const os = require('os'); const crypto = require('crypto'); +const { encodeProjectPath } = require('./encode-project-path'); const CLAUDE_DIR = path.join(os.homedir(), '.claude'); const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); @@ -144,7 +145,7 @@ function init(log, runCommand) { const sessionId = crypto.randomUUID(); const msgId = crypto.randomUUID(); const timestamp = new Date().toISOString(); - const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(projectPath); const claudeProjectDir = path.join(PROJECTS_DIR, folder); fs.mkdirSync(claudeProjectDir, { recursive: true }); @@ -191,7 +192,7 @@ function init(log, runCommand) { const dotClaudeDir = path.dirname(commandsDir); const projectPath = path.dirname(dotClaudeDir); - const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folder = encodeProjectPath(projectPath); const schedule = { file: path.basename(filePath), filePath, projectPath, folder, diff --git a/session-cache.js b/session-cache.js index 4a306de..342eaff 100644 --- a/session-cache.js +++ b/session-cache.js @@ -4,6 +4,7 @@ const { Worker } = require('worker_threads'); const { getFolderIndexMtimeMs } = require('./folder-index-state'); const { deriveProjectPath } = require('./derive-project-path'); const { readSessionFile } = require('./read-session-file'); +const { encodeProjectPath } = require('./encode-project-path'); /** * Session cache module. @@ -170,13 +171,13 @@ function buildProjectsFromCache(showArchived) { const global = getSetting('global') || {}; const hiddenProjects = new Set(global.hiddenProjects || []); - // Group by folder (worktree sessions appear as separate projects) + // 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. const projectMap = new Map(); for (const row of cachedRows) { if (hiddenProjects.has(row.projectPath)) continue; - if (!projectMap.has(row.folder)) { - projectMap.set(row.folder, { folder: row.folder, projectPath: row.projectPath, sessions: [] }); - } const meta = metaMap.get(row.sessionId); const s = { sessionId: row.sessionId, @@ -192,6 +193,9 @@ 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: [] }); + } projectMap.get(row.folder).sessions.push(s); } @@ -212,7 +216,7 @@ 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(/^-/, '-'); + const folder = encodeProjectPath(session.projectPath); if (hiddenProjects.has(session.projectPath)) continue; if (!projectMap.has(folder)) { projectMap.set(folder, { folder, projectPath: session.projectPath, sessions: [] });