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: 10 additions & 0 deletions .ai/contexts/viewer-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ The bar (`#file-panel-held`) has `role="status"`, so a screen reader announces i

It is shown with `open(…, restore)` and re-read at once, like any return to the viewer: a write made while it was held — the diff the user just accepted, for instance — raises "changed on disk", and `_agreedBase` has not moved, so a save against that write is refused by main and asks. The bar is not shown over a diff: leaving an unanswered diff would leave the CLI waiting on it.

### Unsaved edits on quit, reload and close

Nothing ends the window with a dirty file tab without the user saying so. The scope is the file tabs of the file panel (the shown one and `heldFileTabs`, in every session of `filePanelState`); the Memory and Work Files panels, MCP diff tabs and Changes buffers are not covered.

- **Main** (`unsaved-guard.js`, one guard, `attach(win)` per window, `beforeQuit(event, win)` for the app). `main.js`'s `before-quit` handler calls `beforeQuit` first: it prevents the quit, asks the renderer, and calls `app.quit()` again on a yes, so the cleanup that follows (PTYs killed, MCP servers, watchers) runs only once the quit is confirmed and a Cancel leaves the app intact. This covers every `app.quit()` caller (☰ Quit, the last window closing). `updater-install` asks first (`confirmQuit`), because electron-updater's `quitAndInstall()` starts the installer before it calls `app.quit()`: a Cancel must leave the installer unstarted, and a yes pre-approves the quit that follows. A Windows `query-session-end` or `session-end` approves the quit, so logoff and shutdown never wait on the dialog. A window close, a quit and an install share one question while it is open. The window's own `close` event is held the same way for a plain window close. The question is `unsaved-check` (`id`, `'quit'` or `'reload'`); the renderer's `unsaved-check-result` (`id`, `proceed`) approves it. A `will-prevent-unload` (the renderer's `beforeunload` veto, which a reload hits) asks with `'reload'`; on a yes the next unload is allowed once (`allowNextUnload`, answered with `preventDefault()`, which in Electron means "unload anyway") and `webContents.reload()` is called. Every close asks the renderer, even with nothing dirty; a main-side dirty flag would save that round trip and is not built.
- **Bounded.** The renderer acknowledges (`unsaved-check-ack`) as soon as it receives the check, before any dialog. The 2.5 s bound (`DEFAULT_TIMEOUT_MS`) covers only send to ack: no ack answers yes, so a hung renderer never keeps the app from quitting. After the ack the guard waits for the answer without a limit, so a slow user or a slow save loses nothing, and answers yes only if the renderer process is gone (`render-process-gone`, `destroyed`) or a send fails. A late answer is ignored.
- **Renderer** (`file-panel.js`). `askAboutUnsavedEdits()` lists the dirty tabs (`collectUnsavedFileTabs`) in the `#unsaved-edits-dialog` dialog, built on the add-project dialog's classes (already in the frameless no-drag list). Save writes every dirty tab: the shown one through the viewer's own save (`ViewerPanel.saveNow()`, with its stale-disk confirm), a tab kept aside through `saveFileForPanel` with its snapshot's `agreedBase`. A save that fails (including a disk that moved) keeps the dialog open with the reason, and nothing is answered. Discard answers yes. Cancel and Escape answer no. A second request while the dialog is open gets the same dialog's answer.
- **`beforeunload`.** The renderer vetoes an unload while a file tab is dirty, unless `unloadApproved`, set for 10 s after the user answered yes. That is what stops a reload from the keyboard or devtools from slipping past, and what keeps the veto from asking a second time after an approved close.
- **Closing a session** does not drop its file panel: `destroySession` leaves `filePanelState` alone, so the tabs, dirty or not, are still there when the session is opened again, and the quit check still sees them. Deleting a session leaves its state in memory too, so its edits are asked about at quit rather than lost.

### A document not yet in the editor

`open()` puts its document in the editor only once the CodeMirror bundle has loaded. Until then the panel holds it as `_pendingContent`, and that is the buffer: `_isDirty` and `snapshot()` read it, so a restored tab replaced again before its editor exists is held with its edits, whichever route replaces it.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc
## Unreleased

### Fixed
- Quitting, closing the window or reloading while a file in the file panel has unsaved edits now asks first, in any session, kept-aside tabs included: Save writes them (a file that changed on disk is not overwritten), Discard drops them, Cancel stays. If Switchboard does not answer within a few seconds, it closes anyway. (#373)
- The IDE Emulation label in a session's terminal header now says whether the CLI is connected: it reads "IDE Emulation" only while it is, "IDE Emulation: waiting for CLI" when Switchboard is listening but the CLI has not connected, and "IDE Emulation: failed" when it could not start for that session, with the reason in its tooltip. A session whose IDE Emulation port was already taken no longer shows the label as if it worked. (#320)
- On Windows, the file panel no longer opens or saves a credential file (such as one under `.ssh`) through its 8.3 short name or a `\\?\` path. (#390)
### New
Expand Down
10 changes: 8 additions & 2 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
const { classifyTitleActivity } = require('./classify-title-activity');
const { windowFrameOptions, applicationMenuTemplate, zoomKey, nextZoomLevel, menuPopupPoint } = require('./window-frame');
const { createWhatsNew } = require('./changelog');
const { createUnsavedGuard } = require('./unsaved-guard');
const unsavedGuard = createUnsavedGuard({ ipcMain, quit: () => app.quit() });
const { cleanEnv } = require('./clean-env');

try { require('electron-reloader')(module, { watchRenderer: true }); } catch {};
Expand Down Expand Up @@ -66,7 +68,7 @@
}

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

Check warning on line 71 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 71 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, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand Down Expand Up @@ -357,6 +359,8 @@
`);
});

unsavedGuard.attach(mainWindow);

// Prevent Cmd+R / Ctrl+Shift+R from reloading the page (Chromium built-in).
// Ctrl+R alone on macOS is NOT a reload shortcut and must pass through to xterm
// for reverse-i-search.
Expand Down Expand Up @@ -474,8 +478,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 481 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 481 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 482 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, isIndexingFinished } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -2437,7 +2441,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 2444 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 Expand Up @@ -3025,9 +3029,10 @@
if (!autoUpdater) return;
return autoUpdater.downloadUpdate();
});
ipcMain.handle('updater-install', () => {
activityFlushedForQuit = true; // see .ai/contexts/activitywatch.md ("Quitting")
ipcMain.handle('updater-install', async () => {
if (!autoUpdater) return;
if (mainWindow && !(await unsavedGuard.confirmQuit(mainWindow))) return;
activityFlushedForQuit = true; // see .ai/contexts/activitywatch.md ("Quitting")
autoUpdater.quitAndInstall();
});

Expand Down Expand Up @@ -3221,6 +3226,7 @@

// see .ai/contexts/activitywatch.md ("Quitting")
app.on('before-quit', (event) => {
if (unsavedGuard.beforeQuit(event, mainWindow)) return;
if (!activityFlushedForQuit && activityReporter.hasPendingWork) {
event.preventDefault();
activityFlushedForQuit = true;
Expand Down
5 changes: 5 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ contextBridge.exposeInMainWorld('api', {
onIndexingFinished: (callback) => {
ipcRenderer.on('indexing-finished', () => callback());
},
onUnsavedCheck: (callback) => {
ipcRenderer.on('unsaved-check', (_event, id, reason) => callback(id, reason));
},
unsavedCheckAck: (id) => ipcRenderer.send('unsaved-check-ack', id),
unsavedCheckResult: (id, proceed) => ipcRenderer.send('unsaved-check-result', id, proceed),
onFullScreenChanged: (callback) => {
ipcRenderer.on('full-screen-changed', (_event, isFullScreen) => callback(isFullScreen));
},
Expand Down
142 changes: 142 additions & 0 deletions public/file-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ function initFilePanel() {
onDetachedSave: dropSavedHeldTabs,
});

window.addEventListener('beforeunload', (event) => {
if (unloadApproved || !collectUnsavedFileTabs().length) return;
event.preventDefault();
event.returnValue = false;
});
if (window.api.onUnsavedCheck) {
window.api.onUnsavedCheck(async (id) => {
window.api.unsavedCheckAck(id);
let proceed = true;
try { proceed = await askAboutUnsavedEdits(); } catch (err) { console.error('[unsaved-check]', err); }
window.api.unsavedCheckResult(id, proceed);
});
}

// ── Diff-specific UI ──
const diffContainer = document.createElement('div');
diffContainer.id = 'file-panel-diff';
Expand Down Expand Up @@ -566,6 +580,134 @@ function takeHeldFileTab(state, filePath) {
return tab;
}

// see .ai/contexts/viewer-panel.md ("Unsaved edits on quit, reload and close")
let unloadApproved = false;
let unsavedPrompt = null;

function collectUnsavedFileTabs() {
if (!fpViewerPanel) return [];
const found = new Set();
for (const state of filePanelState.values()) {
const tabs = [];
if (state.currentTab && state.currentTab.type === 'file') tabs.push(state.currentTab);
if (state.heldFileTabs) tabs.push(...state.heldFileTabs.values());
for (const tab of tabs) if (fileTabHasUnsavedEdits(tab)) found.add(tab);
}
return [...found];
}

async function saveUnsavedFileTab(tab) {
if (fpViewerOwner === tab) {
await fpViewerPanel.saveNow();
return fileTabHasUnsavedEdits(tab) ? 'not saved: it changed on disk, or could not be written' : null;
}
const saved = tab.viewerState;
const result = await window.api.saveFileForPanel(tab.filePath, saved.content, saved.agreedBase);
if (result && result.ok !== false) {
tab.viewerState = { ...saved, agreedBase: saved.content, lastSeenDisk: saved.content };
return null;
}
if (result && result.reason === 'stale') return 'not saved: it changed on disk since you opened it';
return `not saved: ${(result && result.error) || 'unknown error'}`;
}

function showUnsavedEditsDialog(tabs) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'add-project-overlay';
const dialog = document.createElement('div');
dialog.className = 'add-project-dialog';
dialog.id = 'unsaved-edits-dialog';
dialog.setAttribute('role', 'alertdialog');

const title = document.createElement('h3');
title.textContent = 'Unsaved file edits';
dialog.appendChild(title);

const hint = document.createElement('div');
hint.className = 'add-project-hint';
hint.textContent = 'These files have edits that are not saved. Discarding loses them.';
dialog.appendChild(hint);

const labels = heldTabLabels(tabs);
const list = document.createElement('ul');
tabs.forEach((tab, i) => {
const li = document.createElement('li');
li.textContent = labels[i];
li.title = tab.filePath;
list.appendChild(li);
});
dialog.appendChild(list);

const errorEl = document.createElement('div');
errorEl.className = 'add-project-error';
dialog.appendChild(errorEl);

const actions = document.createElement('div');
actions.className = 'add-project-actions';
const makeBtn = (id, cls, text) => {
const btn = document.createElement('button');
btn.id = id;
btn.className = cls;
btn.textContent = text;
actions.appendChild(btn);
return btn;
};
const cancelBtn = makeBtn('unsaved-cancel', 'add-project-cancel-btn', 'Cancel');
const discardBtn = makeBtn('unsaved-discard', 'add-project-cancel-btn', 'Discard');
const saveBtn = makeBtn('unsaved-save', 'add-project-add-btn', tabs.length > 1 ? 'Save all' : 'Save');
dialog.appendChild(actions);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
saveBtn.focus();

function finish(proceed) {
overlay.remove();
document.removeEventListener('keydown', onKey);
resolve(proceed);
}
function onKey(e) {
if (e.key === 'Escape') finish(false);
}
document.addEventListener('keydown', onKey);

cancelBtn.onclick = () => finish(false);
discardBtn.onclick = () => finish(true);
saveBtn.onclick = async () => {
for (const btn of [cancelBtn, discardBtn, saveBtn]) btn.disabled = true;
const failures = [];
for (let i = 0; i < tabs.length; i++) {
if (!fileTabHasUnsavedEdits(tabs[i])) continue;
let reason;
try { reason = await saveUnsavedFileTab(tabs[i]); } catch (err) { reason = `not saved: ${(err && err.message) || 'unknown error'}`; }
if (reason) failures.push(`${labels[i]} ${reason}`);
}
if (!failures.length) {
finish(true);
return;
}
errorEl.textContent = failures.join('. ');
errorEl.style.display = 'block';
for (const btn of [cancelBtn, discardBtn, saveBtn]) btn.disabled = false;
};
});
}

function askAboutUnsavedEdits() {
if (unsavedPrompt) return unsavedPrompt;
const tabs = collectUnsavedFileTabs();
if (!tabs.length) return Promise.resolve(true);
unsavedPrompt = showUnsavedEditsDialog(tabs).then((proceed) => {
unsavedPrompt = null;
if (proceed) {
unloadApproved = true;
setTimeout(() => { unloadApproved = false; }, 10000);
}
return proceed;
});
return unsavedPrompt;
}

function endCurrentTab(sessionId, state) {
const held = state.heldFileTabs ? [...state.heldFileTabs.values()].at(-1) : null;
if (held) {
Expand Down
10 changes: 10 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -5163,3 +5163,13 @@ body.window-frameless :is(#terminal-header-status, #terminal-header-id, #termina
body.window-frameless :is(.new-session-popover, .terminal-context-menu, .new-session-overlay, .add-project-overlay, .whats-new-overlay, .jsonl-screenshot-fullscreen, #update-toast, .restore-toast) {
-webkit-app-region: no-drag;
}

#unsaved-edits-dialog ul {
margin: 0 0 4px 0;
padding-left: 18px;
color: #b0b0c4;
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace;
max-height: 160px;
overflow-y: auto;
}
4 changes: 4 additions & 0 deletions public/viewer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,10 @@ class ViewerPanel {
this.toolbar.setWrapMode(this.wrapMode);
}

saveNow() {
return this._save();
}

// see .ai/contexts/viewer-panel.md ("Saving over a file that moved")
async _save() {
if (!this.opts.onSave || !this.filePath || this._pendingContent !== null) return;
Expand Down
Loading
Loading