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
10 changes: 6 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { isSensitivePath, isAllowedMemoryPath: _isAllowedMemoryPath, resolveAllowedMemoryPath: _resolveAllowedMemoryPath, isKnownProjectRoot: _isKnownProjectRoot } = require('./ipc-path-validator');
Expand Down Expand Up @@ -453,13 +453,13 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 456 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 456 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 457 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache;
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');
Expand Down Expand Up @@ -576,17 +576,19 @@
});

// --- 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);
Expand Down Expand Up @@ -2095,7 +2097,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2100 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
2 changes: 1 addition & 1 deletion preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions public/settings-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) || {};
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 10 additions & 3 deletions session-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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, {
Expand Down
71 changes: 71 additions & 0 deletions test/remove-project-folder-key.test.js
Original file line number Diff line number Diff line change
@@ -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 `<alias>::<encoded path>` (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');
});
147 changes: 147 additions & 0 deletions test/session-cache-hidden-alias.test.js
Original file line number Diff line number Diff line change
@@ -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 <alias>::<path> 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);
}
});
Loading