diff --git a/main.js b/main.js index cf1b6b02..f338286f 100644 --- a/main.js +++ b/main.js @@ -459,7 +459,7 @@ const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCache const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file'); // --- Remote SSH hosts (observation only) — see .ai/contexts/session-cache.md --- -const { isRemoteFolder, parseFolderKey } = require('./remote-hosts'); +const { isRemoteFolder, parseFolderKey, joinFolderKey } = require('./remote-hosts'); const REMOTE_READ_ONLY = 'remote sessions are read-only — this build observes them, it does not attach to them'; const { createSshTransport } = require('./remote-transport'); const { createRemoteIndexer } = require('./remote-index'); @@ -576,17 +576,19 @@ ipcMain.handle('add-project', (_event, projectPath) => { }); // --- IPC: remove-project --- -ipcMain.handle('remove-project', (_event, projectPath) => { +ipcMain.handle('remove-project', (_event, projectPath, folderKey) => { try { // Add to hidden projects list const global = getSetting('global') || {}; const hidden = global.hiddenProjects || []; - if (!hidden.includes(projectPath)) hidden.push(projectPath); + const { alias } = folderKey ? parseFolderKey(folderKey) : { alias: null }; + const hiddenEntry = alias === null ? projectPath : joinFolderKey(alias, projectPath); + if (!hidden.includes(hiddenEntry)) hidden.push(hiddenEntry); global.hiddenProjects = hidden; setSetting('global', global); // Clean up DB cache and search index for this folder - const folder = encodeProjectPath(projectPath); + const folder = folderKey || encodeProjectPath(projectPath); deleteCachedFolder(folder); deleteSearchFolder(folder); deleteSetting('project:' + projectPath); diff --git a/preload.js b/preload.js index 76d3c68f..0ea652e8 100644 --- a/preload.js +++ b/preload.js @@ -46,7 +46,7 @@ contextBridge.exposeInMainWorld('api', { browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), - removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), + removeProject: (projectPath, folderKey) => ipcRenderer.invoke('remove-project', projectPath, folderKey), remapProject: (oldPath, newPath) => ipcRenderer.invoke('remap-project', oldPath, newPath), deleteWorktree: (worktreePath) => ipcRenderer.invoke('delete-worktree', worktreePath), worktreeStatus: (worktreePath) => ipcRenderer.invoke('worktree-status', worktreePath), diff --git a/public/settings-panel.js b/public/settings-panel.js index 6d9d3917..103f0323 100644 --- a/public/settings-panel.js +++ b/public/settings-panel.js @@ -24,7 +24,7 @@ } } - async function openSettingsViewer(scope, projectPath) { + async function openSettingsViewer(scope, projectPath, folderKey) { const isProject = scope === 'project'; const settingsKey = isProject ? 'project:' + projectPath : 'global'; const current = (await window.api.getSetting(settingsKey)) || {}; @@ -644,7 +644,7 @@ if (removeBtn) { removeBtn.addEventListener('click', async () => { if (!confirm(`Hide project "${shortName}" from Switchboard?\n\nThis hides the project from the sidebar. Your session files are not deleted.`)) return; - await window.api.removeProject(projectPath); + await window.api.removeProject(projectPath, folderKey); settingsViewer.style.display = 'none'; document.getElementById('placeholder').style.display = 'flex'; if (typeof loadProjects === 'function') loadProjects(); diff --git a/public/sidebar.js b/public/sidebar.js index 0ffee734..278c27c4 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -989,7 +989,7 @@ function rebindSidebarEvents(projects) { } const settingsBtn = header.querySelector('.project-settings-btn'); if (settingsBtn) { - settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsViewer('project', project.projectPath); }; + settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsViewer('project', project.projectPath, project.folder); }; } const archiveGroupBtn = header.querySelector('.project-archive-btn'); if (archiveGroupBtn) { diff --git a/session-cache.js b/session-cache.js index 8ba2a2df..e4fbbae9 100644 --- a/session-cache.js +++ b/session-cache.js @@ -390,6 +390,13 @@ function reconcileCacheFromFilesystem() { } } +// A hidden entry is a bare projectPath (legacy, hides on every host) or +// alias+'::'+projectPath (hides only on that host). See remove-project in main.js. +function isProjectHidden(hiddenProjects, alias, projectPath) { + if (hiddenProjects.has(projectPath)) return true; + return alias !== null && hiddenProjects.has(joinFolderKey(alias, projectPath)); +} + /** Build projects response from cached data */ function buildProjectsFromCache(showArchived) { const metaMap = getAllMeta(); @@ -424,8 +431,8 @@ function buildProjectsFromCache(showArchived) { for (const row of cachedRows) { if (row.mergedIntoSessionId) continue; // rolled up into its parent below, not its own entry if (!row.projectPath) continue; - if (hiddenProjects.has(row.projectPath)) continue; const { alias } = parseFolderKey(row.folder); + if (isProjectHidden(hiddenProjects, alias, row.projectPath)) continue; const meta = metaMap.get(row.sessionId); const children = mergedChildrenByParent.get(row.sessionId) || []; let messageCount = row.messageCount; @@ -511,7 +518,7 @@ function buildProjectsFromCache(showArchived) { } } if (!projectPath) continue; - if (hiddenProjects.has(projectPath)) continue; + if (isProjectHidden(hiddenProjects, alias, projectPath)) continue; const key = groupKey(alias, projectPath); if (projectMap.has(key)) continue; // For a placeholder the on-disk name IS the ground truth — re-encoding @@ -533,7 +540,7 @@ function buildProjectsFromCache(showArchived) { for (const [sessionId, session] of activeSessions) { if (session.exited || !session.isPlainTerminal) continue; if (!session.projectPath) continue; - if (hiddenProjects.has(session.projectPath)) continue; + if (isProjectHidden(hiddenProjects, null, session.projectPath)) continue; const localKey = groupKey(null, session.projectPath); if (!projectMap.has(localKey)) { projectMap.set(localKey, { diff --git a/test/remove-project-folder-key.test.js b/test/remove-project-folder-key.test.js new file mode 100644 index 00000000..ae09bde0 --- /dev/null +++ b/test/remove-project-folder-key.test.js @@ -0,0 +1,71 @@ +// remove-project used to derive the cache folder key from projectPath alone +// (encodeProjectPath(projectPath)), which is wrong for a remote group: its real +// cache key is `::` (joinFolderKey in remote-hosts.js). +// Hiding a remote project therefore left its rows in session_cache and its +// entries in the search index forever. See session-cache-hidden-alias.test.js +// for the companion filtering fix (hidden entries becoming alias-qualified). +// +// main.js requires('electron'), so it cannot be require()'d under node:test +// (see test/delete-session.test.js and test/auto-update-setting.test.js for +// the same constraint). These assertions run against the handler's own source +// text, extracted by its ipcMain.handle(...) boundary. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); + +function handlerBody() { + const src = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); + const start = src.indexOf("ipcMain.handle('remove-project'"); + assert.ok(start !== -1, 'remove-project handler must exist'); + return src.slice(start, src.indexOf('\n});', start)); +} + +test('remove-project: accepts an explicit folder key and uses it for cache cleanup', () => { + const body = handlerBody(); + assert.match(body, /\('remove-project',\s*\(_event,\s*projectPath,\s*folderKey\)/, + 'the handler must accept a second folderKey argument'); + assert.match(body, /const folder = folderKey \|\| encodeProjectPath\(projectPath\);/, + 'the cache folder key must prefer the passed folderKey over deriving one from projectPath alone'); + const folderLine = body.indexOf('const folder = folderKey'); + const cleanup = body.slice(folderLine); + assert.match(cleanup, /deleteCachedFolder\(folder\)/, + 'session_cache cleanup must use the resolved folder key, not a re-derived one'); + assert.match(cleanup, /deleteSearchFolder\(folder\)/, + 'the search index cleanup must use the same resolved folder key'); +}); + +test('remove-project: writes an alias-qualified hidden entry for a remote group', () => { + const body = handlerBody(); + assert.match(body, /const \{ alias \} = folderKey \? parseFolderKey\(folderKey\) : \{ alias: null \};/, + 'the alias must be derived from the passed folder key, not guessed'); + assert.match(body, /const hiddenEntry = alias === null \? projectPath : joinFolderKey\(alias, projectPath\);/, + 'a remote group must be hidden under its alias-qualified entry, a local one under the bare path'); + const entryLine = body.indexOf('const hiddenEntry'); + const push = body.slice(entryLine); + assert.match(push, /hidden\.includes\(hiddenEntry\)/); + assert.match(push, /hidden\.push\(hiddenEntry\)/, + 'the computed hiddenEntry must be what gets pushed, not the bare projectPath'); +}); + +test('preload: removeProject forwards the folder key to the main process', () => { + const preload = fs.readFileSync(path.join(ROOT, 'preload.js'), 'utf8'); + assert.match(preload, + /removeProject: \(projectPath, folderKey\) => ipcRenderer\.invoke\('remove-project', projectPath, folderKey\)/, + 'the bridge must widen to two arguments end to end'); +}); + +test('renderer: the settings viewer threads the group folder key through to remove-project', () => { + const sidebar = fs.readFileSync(path.join(ROOT, 'public', 'sidebar.js'), 'utf8'); + assert.match(sidebar, /openSettingsViewer\('project', project\.projectPath, project\.folder\)/, + 'the folder key must be passed into the settings viewer alongside the project path'); + + const panel = fs.readFileSync(path.join(ROOT, 'public', 'settings-panel.js'), 'utf8'); + assert.match(panel, /async function openSettingsViewer\(scope, projectPath, folderKey\)/, + 'the settings viewer must accept the folder key it is given'); + assert.match(panel, /window\.api\.removeProject\(projectPath, folderKey\)/, + 'Hide Project must pass the folder key through, not just the projectPath'); +}); diff --git a/test/session-cache-hidden-alias.test.js b/test/session-cache-hidden-alias.test.js new file mode 100644 index 00000000..221a45e6 --- /dev/null +++ b/test/session-cache-hidden-alias.test.js @@ -0,0 +1,147 @@ +// Hiding a remote project group must not hide a different group (local, or a +// different host) that happens to share the same projectPath. See +// isProjectHidden in session-cache.js and the alias-qualified hidden entry +// written by remove-project in main.js. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const sessionCache = require('../session-cache'); +const { encodeProjectPath } = require('../encode-project-path'); +const { joinFolderKey } = require('../remote-hosts'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-hidden-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function makeFakeDb({ cachedRows = [], folderMeta = new Map(), hiddenProjects = [] } = {}) { + return { + deleteCachedFolder: () => {}, + getCachedByFolder: () => [], + upsertCachedSessions: () => {}, + touchCachedModified: () => {}, + deleteCachedSession: () => {}, + replaceSessionMetrics: () => {}, + deleteSearchFolder: () => {}, + deleteSearchSession: () => {}, + upsertSearchEntries: () => {}, + setFolderMeta: () => {}, + getAllFolderMeta: () => folderMeta, + getAllMeta: () => new Map(), + getAllCached: () => cachedRows, + getSetting: (key) => (key === 'global' ? { hiddenProjects } : {}), + getMeta: () => null, + setName: () => {}, + }; +} + +function initCache(projectsDir, db) { + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions: new Map(), + getMainWindow: () => null, + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + db, + }); + sessionCache.setRemoteRoots(new Map()); +} + +function row(sessionId, folder, projectPath) { + return { + sessionId, folder, projectPath, summary: sessionId, firstPrompt: sessionId, + modified: '2024-03-15T10:00:00.000Z', created: '2024-03-15T10:00:00.000Z', + messageCount: 1, parentSessionId: null, agentId: null, subagentType: null, + description: null, slug: null, aiTitle: null, + }; +} + +test('hiding :: hides only that host group, not local or a different alias sharing the path', () => { + const projectsDir = mkTmp(); + try { + const projectPath = '/srv/x'; + const bareFolder = encodeProjectPath(projectPath); + fs.mkdirSync(path.join(projectsDir, bareFolder)); + + const cachedRows = [ + row('local-s', bareFolder, projectPath), + row('planificator-s', joinFolderKey('planificator', bareFolder), projectPath), + row('otherhost-s', joinFolderKey('otherhost', bareFolder), projectPath), + ]; + + const db = makeFakeDb({ cachedRows, hiddenProjects: ['planificator::' + projectPath] }); + initCache(projectsDir, db); + + const projects = sessionCache.buildProjectsFromCache(true); + const relevant = projects.filter(p => p.projectPath === projectPath); + const aliases = relevant.map(p => p.remoteAlias).sort(); + + assert.deepEqual(aliases, [null, 'otherhost'], + `only the planificator group should be hidden; got groups for: ${aliases.join(', ')}`); + } finally { + cleanup(projectsDir); + } +}); + +test('hiding a bare path (legacy entry) still hides it on every host', () => { + const projectsDir = mkTmp(); + try { + const projectPath = '/srv/y'; + const bareFolder = encodeProjectPath(projectPath); + fs.mkdirSync(path.join(projectsDir, bareFolder)); + + const cachedRows = [ + row('local-s', bareFolder, projectPath), + row('remote-s', joinFolderKey('planificator', bareFolder), projectPath), + ]; + + const db = makeFakeDb({ cachedRows, hiddenProjects: [projectPath] }); + initCache(projectsDir, db); + + const projects = sessionCache.buildProjectsFromCache(true); + const relevant = projects.filter(p => p.projectPath === projectPath); + + assert.equal(relevant.length, 0, + 'a bare legacy hidden entry must still hide the project on every host'); + } finally { + cleanup(projectsDir); + } +}); + +test('an empty remote project directory (no sessions yet) is hidden only for the alias named in the hidden entry', () => { + const projectsDir = mkTmp(); + const remoteDir = mkTmp(); + try { + const projectPath = '/srv/z'; + const bareFolder = encodeProjectPath(projectPath); + fs.mkdirSync(path.join(remoteDir, bareFolder)); + + const folderMeta = new Map([ + [joinFolderKey('planificator', bareFolder), { projectPath }], + [joinFolderKey('otherhost', bareFolder), { projectPath }], + ]); + + const db = makeFakeDb({ cachedRows: [], folderMeta, hiddenProjects: ['planificator::' + projectPath] }); + initCache(projectsDir, db); + sessionCache.setRemoteRoots(new Map([ + ['planificator', remoteDir], + ['otherhost', remoteDir], + ])); + + const projects = sessionCache.buildProjectsFromCache(true); + const relevant = projects.filter(p => p.projectPath === projectPath); + const aliases = relevant.map(p => p.remoteAlias).sort(); + + assert.deepEqual(aliases, ['otherhost'], + `only the planificator group should be hidden; got groups for: ${aliases.join(', ')}`); + } finally { + cleanup(projectsDir); + cleanup(remoteDir); + } +});