Skip to content
Open
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
41 changes: 41 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
45 changes: 40 additions & 5 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<span class="arrow">&#9660;</span> <span class="project-name">${shortName}</span>`;
const missingIcon = project.missing ? '<svg class="project-missing-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> ' : '';
header.innerHTML = `<span class="arrow">&#9660;</span> ${missingIcon}<span class="project-name">${escapeHtml(shortName)}</span>`;

const scheduleBtn = document.createElement('button');
scheduleBtn.className = 'project-schedule-btn';
Expand All @@ -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 = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
header.appendChild(remapBtn);
}

const newBtn = document.createElement('button');
newBtn.className = 'project-new-btn';
newBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><line x1="6" y1="2" x2="6" y2="10"/><line x1="2" y1="6" x2="10" y2="6"/></svg>';
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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');
};
}
Expand Down Expand Up @@ -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');
Expand Down
34 changes: 34 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 5 additions & 2 deletions session-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) });
}
}
}
Expand Down Expand Up @@ -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;
Expand Down