diff --git a/main.js b/main.js index 6fbef20e..62772694 100644 --- a/main.js +++ b/main.js @@ -1,5 +1,6 @@ const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron'); const { Worker } = require('worker_threads'); +const { execFile } = require('child_process'); const path = require('path'); const fs = require('fs'); const os = require('os'); @@ -108,11 +109,12 @@ function createWindow() { } } + const appTitle = app.isPackaged ? 'Switchboard' : 'Switchboard (dev)'; mainWindow = new BrowserWindow({ ...bounds, minWidth: 800, minHeight: 500, - title: 'Switchboard', + title: appTitle, icon: path.join(__dirname, 'build', 'icon.png'), webPreferences: { preload: path.join(__dirname, 'preload.js'), @@ -351,6 +353,84 @@ ipcMain.handle('remove-project', (_event, projectPath) => { } }); +// --- IPC: delete-worktree --- +// Validated path pattern: /./[worktrees/] +// Matches .claude/worktrees/, .claude-worktrees/, .worktrees/ +const WORKTREE_PATH_RE = /^(.+?)\/\.(?:claude\/worktrees|claude-worktrees|worktrees)\/([^/]+)\/?$/; + +ipcMain.handle('delete-worktree', (_event, worktreePath) => { + return new Promise((resolve) => { + // Normalize trailing slash + const normalizedPath = worktreePath.replace(/\/$/, ''); + + // Validate path matches a known worktree layout + const match = normalizedPath.match(WORKTREE_PATH_RE); + if (!match) { + return resolve({ ok: false, error: 'Path does not match a recognized worktree layout' }); + } + const parentRepo = match[1]; + + // Helper: run git worktree remove, optionally double-force + function runRemove(doubleForce, callback) { + const args = ['-C', parentRepo, 'worktree', 'remove', '-f']; + if (doubleForce) args.push('-f'); + args.push('--', normalizedPath); + execFile('git', args, (err, _stdout, stderr) => callback(err, stderr)); + } + + runRemove(false, (err, stderr) => { + if (err && /locked/i.test(stderr || err.message || '')) { + // Retry with double force for locked worktrees + runRemove(true, (err2, stderr2) => { + if (err2) return resolve({ ok: false, error: (stderr2 || err2.message || String(err2)).trim() }); + afterRemove(); + }); + } else if (err) { + return resolve({ ok: false, error: (stderr || err.message || String(err)).trim() }); + } else { + afterRemove(); + } + }); + + function afterRemove() { + // Clean up DB cache: delete all sessions whose projectPath matches worktreePath + let removed = 0; + try { + const allRows = getAllCached(); + for (const row of allRows) { + if (row.projectPath === normalizedPath) { + deleteCachedSession(row.sessionId); + deleteSearchSession(row.sessionId); + removed++; + } + } + } catch (dbErr) { + log.warn('[delete-worktree] DB cleanup error:', dbErr.message); + } + + // Remove from hiddenProjects if present + try { + const global = getSetting('global') || {}; + if (Array.isArray(global.hiddenProjects) && global.hiddenProjects.includes(normalizedPath)) { + global.hiddenProjects = global.hiddenProjects.filter(p => p !== normalizedPath); + setSetting('global', global); + } + } catch {} + + // Also clean up folder meta + try { + const folder = encodeProjectPath(normalizedPath); + deleteCachedFolder(folder); + deleteSearchFolder(folder); + } catch {} + + log.info(`[delete-worktree] removed=${normalizedPath} sessions=${removed}`); + notifyRendererProjectsChanged(); + resolve({ ok: true, removed }); + } + }); +}); + // --- IPC: get-projects --- ipcMain.handle('open-external', (_event, url) => { log.info('[open-external IPC]', url); @@ -1471,6 +1551,12 @@ ipcMain.handle('updater-install', () => { }); // --- App lifecycle --- +// Differentiate the dev build from the released binary in the OS task switcher, +// dock, and About menu by suffixing the app name. No-op in packaged builds. +if (!app.isPackaged) { + app.setName('Switchboard (dev)'); +} + app.whenReady().then(() => { buildMenu(); createWindow(); diff --git a/preload.js b/preload.js index 347ef266..3341ab4e 100644 --- a/preload.js +++ b/preload.js @@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', { browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), + deleteWorktree: (worktreePath) => ipcRenderer.invoke('delete-worktree', worktreePath), openExternal: (url) => ipcRenderer.invoke('open-external', url), // Send (fire-and-forget) diff --git a/public/sidebar.js b/public/sidebar.js index 2bf193ea..e16615da 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -514,6 +514,12 @@ function renderProjects(projects, resort) { wtHideBtn.innerHTML = ''; wtHeader.appendChild(wtHideBtn); + const wtDeleteBtn = document.createElement('button'); + wtDeleteBtn.className = 'worktree-delete-btn'; + wtDeleteBtn.title = 'Delete worktree from disk'; + wtDeleteBtn.innerHTML = ''; + wtHeader.appendChild(wtDeleteBtn); + const wtNewBtn = document.createElement('button'); wtNewBtn.className = 'project-new-btn worktree-new-btn'; wtNewBtn.innerHTML = ''; @@ -676,8 +682,23 @@ function rebindSidebarEvents(projects) { loadProjects(); }; } + const wtDeleteBtn = wtHeader.querySelector('.worktree-delete-btn'); + if (wtDeleteBtn) { + wtDeleteBtn.onclick = async (e) => { + e.stopPropagation(); + const name = wtProject.projectPath.split('/').pop(); + if (!confirm(`Delete worktree "${name}" from disk?\n\nThis runs "git worktree remove -f" and permanently removes the working tree. This cannot be undone.`)) return; + const result = await window.api.deleteWorktree(wtProject.projectPath); + if (result && result.ok) { + loadProjects(); + } else { + const msg = (result && result.error) ? result.error : 'Unknown error'; + alert(`Failed to delete worktree: ${msg}`); + } + }; + } wtHeader.onclick = (e) => { - if (e.target.closest('.worktree-new-btn') || e.target.closest('.worktree-hide-btn')) return; + if (e.target.closest('.worktree-new-btn') || e.target.closest('.worktree-hide-btn') || e.target.closest('.worktree-delete-btn')) return; wtHeader.classList.toggle('collapsed'); }; });