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
80 changes: 79 additions & 1 deletion main.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron');
const { Worker } = require('worker_threads');

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 2 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'Worker' is assigned a value but never used. Allowed unused vars must match /^_/u
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
Expand All @@ -16,7 +16,7 @@
}

// getFolderIndexMtimeMs moved to session-cache.js
const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 19 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'cleanStaleLockFiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { fetchAndTransformUsage } = require('./claude-auth');
log.transports.file.level = app.isPackaged ? 'info' : 'debug';
log.transports.console.level = app.isPackaged ? 'info' : 'debug';
Expand All @@ -36,7 +36,7 @@
);

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

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

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

Check warning on line 39 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'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');

Expand Down Expand Up @@ -73,7 +73,7 @@
getMeta, getAllMeta, toggleStar, setName, setArchived,
isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions,
deleteCachedSession, deleteCachedFolder,
getFolderMeta, getAllFolderMeta, setFolderMeta,

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 76 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'getFolderMeta' is assigned a value but never used. Allowed unused vars must match /^_/u
upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType,
searchByType, isSearchIndexPopulated, searchFtsRecreated,
getSetting, setSetting, deleteSetting,
Expand Down Expand Up @@ -289,9 +289,9 @@
setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem,

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

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

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

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

Check warning on line 292 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

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

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 293 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
const { resolveJsonlPath } = require('./read-session-file');
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');


// --- IPC: browse-folder ---
Expand Down Expand Up @@ -367,6 +367,84 @@
}
});

// --- IPC: remap-project ---

/**
* Atomically rewrite cwd occurrences of oldPath → newPath in a single JSONL
* file. Uses a .tmp sibling + rename for crash safety. On any failure the .tmp
* orphan is cleaned up so it cannot block a future remap attempt.
*/
function rewriteJsonlAtomic(filePath, oldPath, newPath) {
const tmp = filePath + '.tmp';
try {
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');
fs.writeFileSync(tmp, updated);
fs.renameSync(tmp, filePath);
} catch (err) {
try { fs.unlinkSync(tmp); } catch {}
throw err;
}
}

ipcMain.handle('remap-project', (_event, oldPath, newPath) => {
try {
// Validate oldPath/newPath are strings (basic sanitisation)
if (typeof oldPath !== 'string' || typeof newPath !== 'string') {
return { error: 'Invalid arguments' };
}

// Re-check at handler entry: if oldPath came back, no remap is needed
if (fs.existsSync(oldPath)) {
return { error: 'Project path now exists — remap no longer needed' };
}

// Validate the new path exists and is a directory
let stat;
try { stat = fs.lstatSync(newPath); } catch { return { error: 'Path does not exist' }; }
if (!stat.isDirectory()) return { error: 'Path is not a directory' };

// Find the session folder for the old project path using the same encoding the CLI uses
const folder = encodeProjectPath(oldPath);
const folderPath = path.join(PROJECTS_DIR, folder);
if (!fs.existsSync(folderPath)) return { error: 'No session data found for this project' };

// Refuse if any active PTY session is running for this folder — rewriting
// files while a live claude process is appending them risks data loss
// (our snapshot + rename would silently drop lines appended between read
// and rename). The user must stop all sessions for this project first.
for (const [, session] of activeSessions) {
if (!session.exited && encodeProjectPath(session.projectPath) === folder) {
return { error: 'Active sessions for this project — stop them first' };
}
}

// Rewrite cwd in all session JSONL files (top-level + subagents) so
// `claude --resume` from CLI also picks up the new path.
const sessionFiles = enumerateSessionFiles(folderPath);
for (const { filePath } of sessionFiles) {
rewriteJsonlAtomic(filePath, oldPath, newPath);
}

// Refresh the folder cache so the new path takes effect in the UI
refreshFolder(folder);
notifyRendererProjectsChanged();
return { ok: true };
} catch (err) {
return { error: err.message };
}
});

// --- IPC: delete-worktree ---
// Validated path pattern: <project>/.<segment>/[worktrees/]<name>
// Matches .claude/worktrees/<n>, .claude-worktrees/<n>, .worktrees/<n>
Expand Down Expand Up @@ -1305,7 +1383,7 @@
const shellExtraArgs = [...(shellProfile.args || [])];
const isWsl = isWslShell(shell);
// For WSL, convert Windows path to /mnt/ path and pass via --cd;
// the spawn cwd must remain a valid Windows path for wsl.exe itself.

Check warning on line 1386 in main.js

View workflow job for this annotation

GitHub Actions / test (22)

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

Check warning on line 1386 in main.js

View workflow job for this annotation

GitHub Actions / test (20)

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
if (isWsl) {
const wslCwd = windowsToWslPath(projectPath);
shellExtraArgs.unshift('--cd', wslCwd);
Expand Down
1 change: 1 addition & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,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),
deleteWorktree: (worktreePath) => ipcRenderer.invoke('delete-worktree', worktreePath),
worktreeStatus: (worktreePath) => ipcRenderer.invoke('worktree-status', worktreePath),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
Expand Down
2 changes: 2 additions & 0 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,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 @@ -860,6 +861,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 @@ -474,14 +474,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 @@ -501,6 +502,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 @@ -509,8 +518,10 @@ function renderProjects(projects, resort) {

const sessionsList = buildSessionsList(fId, visible, older, subagentIndex, project.projectPath);

// 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 @@ -689,8 +700,24 @@ function rebindSidebarEvents(projects) {
loadProjects();
};
}
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 projectShortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/');
if (!confirm(`Remap ${projectShortName} to:\n${newPath}?`)) return;
const result = await window.api.remapProject(project.projectPath, newPath);
if (result.error) {
alert('Failed to remap: ' + result.error);
} else {
loadProjects();
}
};
}
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 @@ -790,6 +817,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 = () => {
if (item.dataset.subagent && session.parentSessionId) {
showSubagentTranscript(session);
Expand Down
34 changes: 34 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,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
5 changes: 5 additions & 0 deletions session-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ function buildProjectsFromCache(showArchived) {
folder: encodeProjectPath(row.projectPath),
projectPath: row.projectPath,
sessions: [],
missing: !fs.existsSync(row.projectPath),
});
}
projectMap.get(row.projectPath).sessions.push(s);
Expand Down Expand Up @@ -339,6 +340,7 @@ function buildProjectsFromCache(showArchived) {
folder: encodeProjectPath(projectPath),
projectPath,
sessions: [],
missing: !fs.existsSync(projectPath),
});
}
}
Expand Down Expand Up @@ -375,6 +377,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
Loading
Loading