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
24 changes: 24 additions & 0 deletions scripts/e2e-terminal-rm.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }] });
Expand Down Expand Up @@ -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")
);
});
25 changes: 25 additions & 0 deletions scripts/e2e-workspace-file-tracking.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 5 additions & 1 deletion src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
Expand Down
15 changes: 14 additions & 1 deletion src/ui/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ function createStdinSessionId() {
* readWorkspaceFile?: (path:string) => Promise<string|null>,
* onMkdir?: (request:{path:string, parents:boolean}) => Promise<object>,
* onTouch?: (request:{path:string}) => Promise<object>,
* onRemove?: (request:{paths:string[], recursive:boolean}) => Promise<{results:object[]}>,
* }} callbacks
*/
export function createTerminal(container, {
Expand All @@ -307,6 +308,7 @@ export function createTerminal(container, {
readWorkspaceFile,
onMkdir,
onTouch,
onRemove,
visualTheme = 'browser-cpp-dark',
}) {
_onCompile = onCompile;
Expand All @@ -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;
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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}'`;
}
Expand Down
27 changes: 25 additions & 2 deletions src/ui/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand Down
Loading