From ad60c4da6fd0ac6094ebbd135ccb64bafffa755f Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Tue, 3 Nov 2026 12:31:23 -0800 Subject: [PATCH 1/2] fix(terminal): reconcile tabs after rm Close tabs for files and recursively removed directories only after their host-backed terminal rm deletion succeeds.\n\nFixes #126 --- scripts/e2e-workspace-file-tracking.test.mjs | 25 ++++++++++++++++++ src/ui/app.js | 6 ++++- src/ui/toolbar.js | 27 ++++++++++++++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/scripts/e2e-workspace-file-tracking.test.mjs b/scripts/e2e-workspace-file-tracking.test.mjs index 8cb07a4..0e9c2e5 100644 --- a/scripts/e2e-workspace-file-tracking.test.mjs +++ b/scripts/e2e-workspace-file-tracking.test.mjs @@ -1746,6 +1746,31 @@ test('e2e: runtime vfsDeletes are deleted from the workspace and refreshed', asy assert.equal(ctx.terminalCalls.refresh.at(-1).entries.length, 0); }); +test('e2e: terminal rm reconciles removed tabs only after host deletion succeeds', async () => { + const ctx = await setupToolbar(); + await ctx.toolbar.restoreWorkspace( + { + name: 'p', + entries: [ + { path: 'old.cpp', kind: 'file' }, + { path: 'generated', kind: 'directory' }, + { path: 'generated/old.cpp', kind: 'file' }, + { path: 'keep.cpp', kind: 'file' }, + ], + }, + ['old.cpp', 'generated/old.cpp', 'keep.cpp'], + 'old.cpp' + ); + + ctx.toolbar.reconcileTerminalRm( + { name: 'p', entries: [{ path: 'keep.cpp', kind: 'file' }] }, + ['old.cpp', 'generated'] + ); + + assert.deepEqual(ctx.toolbar.getOpenTabPaths(), ['keep.cpp']); + assert.equal(ctx.terminalCalls.refresh.at(-1).entries[0].path, 'keep.cpp'); +}); + test('e2e: run-result sync keeps deleted open tabs while removing them from Explorer', async () => { const ctx = await setupToolbar({ refreshWorkspaceResult: { diff --git a/src/ui/app.js b/src/ui/app.js index 0a6fcca..ab1fa3d 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -29,6 +29,7 @@ import { resetToNewProject, assembleCompilePayload, applyWorkspaceSnapshot, + reconcileTerminalRm, } from './toolbar.js'; import { createSessionPersistence, createPersistenceGate } from './session-persistence.mjs'; import { registerPageUnload } from './page-lifecycle.mjs'; @@ -130,7 +131,10 @@ window.addEventListener('DOMContentLoaded', async () => { if (result?.ok) snapshot = result.snapshot; } if (snapshot) { - applyWorkspaceSnapshot(snapshot); + reconcileTerminalRm( + snapshot, + results.filter((result) => result?.ok).map((result) => result.path) + ); await persistenceGate.persistState(); } return { results }; diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index 8a567e9..0aec64f 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -574,6 +574,29 @@ export function applyWorkspaceSnapshot(snapshot) { updateCompileButtons(); } +/** + * Adopt the result of a successful terminal `rm` command. Terminal deletion is + * intentionally stricter than runtime VFS cleanup: a user explicitly removed + * the entry, so any tab backed by that path must not remain in the session. + * + * @param {object} snapshot – authoritative workspace snapshot after host deletion + * @param {string[]} removedPaths – successfully removed file or directory paths + */ +export function reconcileTerminalRm(snapshot, removedPaths = []) { + if (!snapshot) return; + applyWorkspaceSnapshot(snapshot); + + const removed = removedPaths + .map((path) => normalizeOverlayPath(path)) + .filter(Boolean); + for (const path of [..._openTabs.keys()]) { + if (removed.some((target) => path === target || path.startsWith(`${target}/`))) { + closeTab(path, { discard: true }); + } + } + updateExplorerDeleteButton(); +} + /** Reveal a path created through the Explorer's explicit New File action. */ function revealWorkspacePath(path) { for (const dir of directoriesForPath(path)) { @@ -1001,13 +1024,13 @@ function renameActiveTabPath(nextPath) { } /** Close the tab for the given path, prompting if it has unsaved changes. */ -function closeTab(path) { +function closeTab(path, { discard = false } = {}) { if (!_openTabs.has(path)) return; const tab = _openTabs.get(path); const name = workspaceBaseName(path) || path; let switchedTabs = false; - if (tab.dirty && !confirm(`Close "${name}" with unsaved changes?`)) return; + if (tab.dirty && !discard && !confirm(`Close "${name}" with unsaved changes?`)) return; const paths = [..._openTabs.keys()]; const idx = paths.indexOf(path); From d162cb745af73e6b622e8b5ac2acf48c480a941a Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Tue, 3 Nov 2026 12:37:18 -0800 Subject: [PATCH 2/2] fix(terminal): bind rm deletion callback Wire the application removal handler into the terminal so rm reaches the host-backed workspace deletion API, and report an unavailable service instead of silently succeeding. --- scripts/e2e-terminal-rm.test.mjs | 24 ++++++++++++++++++++++++ src/ui/terminal.js | 15 ++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/scripts/e2e-terminal-rm.test.mjs b/scripts/e2e-terminal-rm.test.mjs index e4df75b..18331b1 100644 --- a/scripts/e2e-terminal-rm.test.mjs +++ b/scripts/e2e-terminal-rm.test.mjs @@ -58,6 +58,15 @@ test('e2e: rm sends multiple files and recursive directories to the callback', a ]); }); +test('e2e: rm sends dotfiles to the removal callback', async () => { + const ctx = setupTerminalHarness(); + setWorkspace({ name: 'project', entries: [{ path: '.DS_Store', kind: 'file' }] }); + + await __executeTerminalCommandForTesting('rm .DS_Store'); + + assert.deepEqual(ctx.removeCalls, [{ paths: ['.DS_Store'], recursive: false }]); +}); + test('e2e: rm rejects root escape, no operands, and unsupported options', async () => { const ctx = setupTerminalHarness(); setWorkspace({ name: 'project', entries: [{ path: 'src', kind: 'directory' }] }); @@ -87,3 +96,18 @@ test('e2e: rm formats filesystem errors for each failed target', async () => { assert.ok(output.includes("rm: cannot remove 'missing.cpp': No such file or directory")); assert.ok(output.includes("rm: cannot remove 'build': Is a directory")); }); + +test('e2e: rm never silently succeeds when the removal service is unavailable', async () => { + const writes = []; + __setTerminalTestHarness({ + term: { clear() {}, write(text) { writes.push(text); } }, + onRemove: null, + }); + setWorkspace({ name: 'project', entries: [{ path: '.DS_Store', kind: 'file' }] }); + + await __executeTerminalCommandForTesting('rm .DS_Store'); + + assert.ok( + writes.join('').includes("rm: cannot remove '.DS_Store': Filesystem unavailable") + ); +}); diff --git a/src/ui/terminal.js b/src/ui/terminal.js index cf22d20..00f67df 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -293,6 +293,7 @@ function createStdinSessionId() { * readWorkspaceFile?: (path:string) => Promise, * onMkdir?: (request:{path:string, parents:boolean}) => Promise, * onTouch?: (request:{path:string}) => Promise, + * onRemove?: (request:{paths:string[], recursive:boolean}) => Promise<{results:object[]}>, * }} callbacks */ export function createTerminal(container, { @@ -307,6 +308,7 @@ export function createTerminal(container, { readWorkspaceFile, onMkdir, onTouch, + onRemove, visualTheme = 'browser-cpp-dark', }) { _onCompile = onCompile; @@ -320,6 +322,7 @@ export function createTerminal(container, { _readWorkspaceFile = readWorkspaceFile || null; _onMkdir = onMkdir || null; _onTouch = onTouch || null; + _onRemove = onRemove || null; _supportsInteractiveStdin = supportsInteractiveStdin; _getStdinTransport = getStdinTransport; _createStdinSessionId = createStdinSessionId; @@ -1203,7 +1206,15 @@ async function cmdRm(args) { return; } - const result = await _onRemove?.({ paths, recursive: parsed.recursive }); + if (!_onRemove) { + for (const path of paths) { + term.write(`${C.red}${formatRmFilesystemError({ path, error: 'service-unavailable' })}${C.reset}${CRLF}`); + } + writePrompt(); + return; + } + + const result = await _onRemove({ paths, recursive: parsed.recursive }); for (const item of result?.results || []) { if (!item?.ok) { term.write(`${C.red}${formatRmFilesystemError(item)}${C.reset}${CRLF}`); @@ -1508,6 +1519,8 @@ function formatRmFilesystemError(result) { return `rm: cannot remove '${path}': Permission denied`; case 'directory-not-empty': return `rm: cannot remove '${path}': Directory not empty`; + case 'service-unavailable': + return `rm: cannot remove '${path}': Filesystem unavailable`; default: return `rm: cannot remove '${path}'`; }