diff --git a/main.js b/main.js index a24a1b2..c61578f 100644 --- a/main.js +++ b/main.js @@ -341,6 +341,47 @@ ipcMain.handle('remove-project', (_event, projectPath) => { } }); +// --- IPC: remap-project --- +ipcMain.handle('remap-project', (_event, oldPath, newPath) => { + try { + const stat = fs.statSync(newPath); + if (!stat.isDirectory()) return { error: 'Path is not a directory' }; + + // Find the folder key for the old project path + const folder = oldPath.replace(/[/_]/g, '-').replace(/^-/, '-'); + const folderPath = path.join(PROJECTS_DIR, folder); + if (!fs.existsSync(folderPath)) return { error: 'No session data found for this project' }; + + // Rewrite cwd in all session JSONL files so CLI --resume also works + const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); + for (const file of jsonlFiles) { + const filePath = path.join(folderPath, file); + const content = fs.readFileSync(filePath, 'utf8'); + const updated = content.split('\n').map(line => { + if (!line) return line; + try { + const parsed = JSON.parse(line); + if (parsed.cwd === oldPath) { + parsed.cwd = newPath; + return JSON.stringify(parsed); + } + } catch {} + return line; + }).join('\n'); + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, updated); + fs.renameSync(tmp, filePath); + } + + // Refresh the folder cache so the new path takes effect + refreshFolder(folder); + notifyRendererProjectsChanged(); + return { ok: true }; + } catch (err) { + return { error: err.message }; + } +}); + // --- IPC: get-projects --- ipcMain.handle('open-external', (_event, url) => { log.info('[open-external IPC]', url); diff --git a/preload.js b/preload.js index 91d8b5e..a7b0e4a 100644 --- a/preload.js +++ b/preload.js @@ -35,6 +35,7 @@ contextBridge.exposeInMainWorld('api', { browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), + remapProject: (oldPath, newPath) => ipcRenderer.invoke('remap-project', oldPath, newPath), openExternal: (url) => ipcRenderer.invoke('open-external', url), // Send (fire-and-forget) diff --git a/public/app.js b/public/app.js index 9875965..ab8f1de 100644 --- a/public/app.js +++ b/public/app.js @@ -731,6 +731,7 @@ async function launchNewSession(project, sessionOptions) { if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; + showSession(sessionId); return; } if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); @@ -797,6 +798,7 @@ async function openSession(session, customOptions) { if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; + showSession(sessionId); return; } if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); diff --git a/public/sidebar.js b/public/sidebar.js index a26916f..34cd22c 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -278,14 +278,15 @@ function renderProjects(projects, resort) { // Build DOM const group = document.createElement('div'); - group.className = 'project-group'; + group.className = 'project-group' + (project.missing ? ' missing' : ''); group.id = fId; const header = document.createElement('div'); header.className = 'project-header'; header.id = 'ph-' + fId; const shortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/'); - header.innerHTML = ` ${shortName}`; + const missingIcon = project.missing ? ' ' : ''; + header.innerHTML = ` ${missingIcon}${escapeHtml(shortName)}`; const scheduleBtn = document.createElement('button'); scheduleBtn.className = 'project-schedule-btn'; @@ -305,6 +306,14 @@ function renderProjects(projects, resort) { archiveGroupBtn.innerHTML = ICONS.archive(18); header.appendChild(archiveGroupBtn); + if (project.missing) { + const remapBtn = document.createElement('button'); + remapBtn.className = 'project-remap-btn'; + remapBtn.title = 'Change project path'; + remapBtn.innerHTML = ''; + header.appendChild(remapBtn); + } + const newBtn = document.createElement('button'); newBtn.className = 'project-new-btn'; newBtn.innerHTML = ''; @@ -313,8 +322,10 @@ function renderProjects(projects, resort) { const sessionsList = buildSessionsList(fId, visible, older); - // Auto-collapse if most recent session is older than threshold, or project matched with no sessions - if (project._projectMatchedOnly) { + // Auto-collapse if project path is missing, most recent session is older than threshold, or project matched with no sessions + if (project.missing) { + header.classList.add('collapsed'); + } else if (project._projectMatchedOnly) { header.classList.add('collapsed'); } else if (searchMatchIds === null && !showStarredOnly && !showRunningOnly) { const mostRecent = filtered[0]?.modified; @@ -454,6 +465,22 @@ function rebindSidebarEvents(projects) { if (settingsBtn) { settingsBtn.onclick = (e) => { e.stopPropagation(); openSettingsViewer('project', project.projectPath); }; } + const remapBtn = header.querySelector('.project-remap-btn'); + if (remapBtn) { + remapBtn.onclick = async (e) => { + e.stopPropagation(); + const newPath = await window.api.browseFolder(); + if (!newPath) return; + const shortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/'); + if (!confirm(`Remap ${shortName} to:\n${newPath}?`)) return; + const result = await window.api.remapProject(project.projectPath, newPath); + if (result.error) { + alert('Failed to remap: ' + result.error); + } else { + loadProjects(); + } + }; + } const archiveGroupBtn = header.querySelector('.project-archive-btn'); if (archiveGroupBtn) { archiveGroupBtn.onclick = async (e) => { @@ -474,7 +501,7 @@ function rebindSidebarEvents(projects) { }; } header.onclick = (e) => { - if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn')) return; + if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn') || e.target.closest('.project-remap-btn')) return; header.classList.toggle('collapsed'); }; } @@ -558,6 +585,14 @@ function rebindSidebarEvents(projects) { const session = sessionMap.get(sessionId); if (!session) return; + // Sessions under missing projects can't be opened — the path no longer exists + if (item.closest('.project-group.missing')) { + item.classList.add('disabled'); + item.title = 'Project path no longer exists — use "Change path" to fix'; + item.onclick = () => {}; + return; + } + item.onclick = () => openSession(session); const pin = item.querySelector('.session-pin'); diff --git a/public/style.css b/public/style.css index f6070c0..011b282 100644 --- a/public/style.css +++ b/public/style.css @@ -470,6 +470,40 @@ body { display: flex; flex-direction: column; } display: none; } +/* Missing project path */ +.project-group.missing { + opacity: 0.55; +} +.project-group.missing:hover { + opacity: 0.8; +} +.project-missing-icon { + color: #e8a838; + vertical-align: middle; + margin-right: 2px; + flex-shrink: 0; +} +.project-remap-btn { + background: none; + border: none; + color: #e8a838; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + display: none; + align-items: center; +} +.project-group.missing .project-remap-btn { + display: inline-flex; +} +.project-remap-btn:hover { + background: rgba(232, 168, 56, 0.15); +} +.session-item.disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* Worktree nested groups */ .worktree-group { margin-left: 12px; diff --git a/session-cache.js b/session-cache.js index 4a306de..dfdcedf 100644 --- a/session-cache.js +++ b/session-cache.js @@ -175,7 +175,7 @@ function buildProjectsFromCache(showArchived) { 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: [] }); + projectMap.set(row.folder, { folder: row.folder, projectPath: row.projectPath, sessions: [], missing: !fs.existsSync(row.projectPath) }); } const meta = metaMap.get(row.sessionId); const s = { @@ -203,7 +203,7 @@ function buildProjectsFromCache(showArchived) { 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: [] }); + projectMap.set(d.name, { folder: d.name, projectPath, sessions: [], missing: !fs.existsSync(projectPath) }); } } } @@ -236,6 +236,9 @@ function buildProjectsFromCache(showArchived) { } projects.sort((a, b) => { + // Missing projects go to the bottom + if (a.missing && !b.missing) return 1; + if (!a.missing && b.missing) return -1; // Empty projects go to the bottom if (a.sessions.length === 0 && b.sessions.length > 0) return 1; if (b.sessions.length === 0 && a.sessions.length > 0) return -1;