diff --git a/scripts/e2e-session-persistence.test.mjs b/scripts/e2e-session-persistence.test.mjs index d227880..0b37f2f 100644 --- a/scripts/e2e-session-persistence.test.mjs +++ b/scripts/e2e-session-persistence.test.mjs @@ -472,7 +472,7 @@ test('e2e: reload chosen but browser denies permission still restores snapshot', }); }); -test('e2e: restores source fallback when no workspace handle is available', async () => { +test('e2e: ignores legacy source-only snapshots when no workspace handle is available', async () => { const storage = createStorageArea(); const handleStore = createHandleStore(); let restoredSource = null; @@ -522,8 +522,8 @@ test('e2e: restores source fallback when no workspace handle is available', asyn await secondSession.restoreSession(); - assert.equal(restoredSource, 'int main() { return 0; }\n'); - assert.equal(dirtyState, false); + assert.equal(restoredSource, null); + assert.equal(dirtyState, true); }); test('e2e: formats the installed extension version from either extension namespace', () => { @@ -538,12 +538,11 @@ test('e2e: formats the installed extension version from either extension namespa assert.equal(getExtensionVersionLabel({}), ''); }); -test('e2e: default and restored no-workspace source use an unsaved C++ tab that saves to a real path', async () => { +test('e2e: default no-workspace source uses an untitled tab that cannot compile', async () => { const originalDocument = global.document; global.document = createFakeDocument(); try { let editorValue = ''; - let suggestedName = null; initToolbar( { onmessage: null, postMessage() {} }, { @@ -556,28 +555,17 @@ test('e2e: default and restored no-workspace source use an unsaved C++ tab that { setWorkspace: () => {}, clearTerminal: () => {} }, { newFile: () => {}, - saveFile: async (_content, name) => { - suggestedName = name; - return 'hello.cpp'; - }, }, () => {} ); resetToNewProject(); - assert.deepEqual(getToolbarOpenTabPaths(), ['unsaved.cpp']); - assert.equal(getToolbarActiveTabPath(), 'unsaved.cpp'); + assert.deepEqual(getToolbarOpenTabPaths(), ['untitled:default']); + assert.equal(getToolbarActiveTabPath(), 'untitled:default'); assert.equal(global.document.getElementById('tab-bar').children[0].children[0].textContent, 'unsaved file'); - const payload = await assembleCompilePayload({}); - assert.equal(payload.primarySourcePath, 'unsaved.cpp'); - - global.document.getElementById('btn-save').click(); - await waitFor(() => getToolbarActiveTabPath() === 'hello.cpp', 'saved tab should use its real file path'); - assert.equal(suggestedName, 'main.cpp'); - assert.deepEqual(getToolbarOpenTabPaths(), ['hello.cpp']); - assert.equal(global.document.getElementById('tab-bar').children[0].children[0].textContent, 'hello.cpp'); + await assert.rejects(() => assembleCompilePayload({}), /Open a folder or save a file/); } finally { global.document = originalDocument; } diff --git a/scripts/e2e-session-restore-choice.test.mjs b/scripts/e2e-session-restore-choice.test.mjs index 2db0628..d6b3145 100644 --- a/scripts/e2e-session-restore-choice.test.mjs +++ b/scripts/e2e-session-restore-choice.test.mjs @@ -188,7 +188,7 @@ test('e2e: start-new clears session + handle + live fs state and loads default', assert.equal(remaining.browser_cpp_session, null, 'persisted session cleared'); }); -test('e2e: after start-new, the next persist saves fresh state not the abandoned workspace', async () => { +test('e2e: after start-new, the next persist leaves the untitled state unpersisted', async () => { const storage = createStorageArea(); const handleStore = createHandleStore(); const handle = { @@ -210,7 +210,7 @@ test('e2e: after start-new, the next persist saves fresh state not the abandoned }); // Shared live state: clearPersistedSession() must null the directory handle so - // a follow-up persist records the default source-only session. + // a follow-up persist leaves the default untitled session unpersisted. const live = { directoryHandle: handle, source: 'int main() { /* fresh */ }\n' }; const persistence = createSessionPersistence({ fsAPI: { @@ -235,9 +235,7 @@ test('e2e: after start-new, the next persist saves fresh state not the abandoned await persistence.persistSession(); const saved = (await storage.get('browser_cpp_session')).browser_cpp_session; - assert.ok(saved, 'a session is persisted'); - assert.equal(saved.source, 'int main() { /* fresh */ }\n'); - assert.equal(saved.openTabPaths, undefined, 'no workspace tabs persisted'); + assert.equal(saved, null, 'the untitled buffer is not persisted'); assert.equal(handleStore.current, null, 'no directory handle persisted'); }); @@ -294,7 +292,7 @@ test('e2e: snapshot-only session prompts before restoring (reload chosen)', asyn assert.deepEqual(restored[0].tabContentByPath, { 'main.cpp': 'int main(){}\n' }); }); -test('e2e: source-only session auto-restores without prompting', async () => { +test('e2e: untitled source is neither persisted nor restored', async () => { const storage = createStorageArea(); const handleStore = createHandleStore(); @@ -309,9 +307,9 @@ test('e2e: source-only session auto-restores without prompting', async () => { handleStore, }); await first.persistSession(); + assert.equal((await storage.get('browser_cpp_session')).browser_cpp_session, null); let restoredSource = null; - let dirty = true; let confirmCalls = 0; const second = createSessionPersistence({ fsAPI: { getDirectoryHandle: () => null, openFolderFromHandle: async () => null }, @@ -321,9 +319,7 @@ test('e2e: source-only session auto-restores without prompting', async () => { restoredSource = source; }, }, - markDirty: (value) => { - dirty = value; - }, + markDirty: () => {}, getOpenTabPaths: () => [], getActiveTabPath: () => null, restoreWorkspace: async () => {}, @@ -338,8 +334,7 @@ test('e2e: source-only session auto-restores without prompting', async () => { await second.restoreSession(); assert.equal(confirmCalls, 0, 'source-only sessions never prompt'); - assert.equal(restoredSource, 'int main() { return 0; }\n'); - assert.equal(dirty, false); + assert.equal(restoredSource, null); }); test('e2e: granted permission up front restores without prompting', async () => { diff --git a/scripts/e2e-terminal-git-removal.test.mjs b/scripts/e2e-terminal-git-removal.test.mjs index 3bc6d85..032384a 100644 --- a/scripts/e2e-terminal-git-removal.test.mjs +++ b/scripts/e2e-terminal-git-removal.test.mjs @@ -70,6 +70,7 @@ test('e2e: tab completion no longer suggests git', () => { test('e2e: git commands fall back to command not found', () => { const ctx = setupTerminalHarness(); + setWorkspace({ name: 'project', entries: [], git: { isRepo: false, branch: null, remotes: [] } }); typeText('git status'); pressEnter(); diff --git a/scripts/e2e-terminal-mkdir.test.mjs b/scripts/e2e-terminal-mkdir.test.mjs index e7dd77e..2b0bd78 100644 --- a/scripts/e2e-terminal-mkdir.test.mjs +++ b/scripts/e2e-terminal-mkdir.test.mjs @@ -131,11 +131,16 @@ test('e2e: mkdir reports filesystem errors using shell-style messages', async () assert.ok(ctx.writes.join('').includes("mkdir: cannot create directory 'src/include': No such file or directory")); }); -test('e2e: mkdir fails when no workspace folder is open', async () => { +test('e2e: every terminal command except help is gated before a workspace opens', async () => { const ctx = setupTerminalHarness(); await __executeTerminalCommandForTesting('mkdir include'); + await __executeTerminalCommandForTesting('ls'); + await __executeTerminalCommandForTesting('g++ main.cpp'); - assert.ok(ctx.writes.join('').includes('mkdir: no folder opened')); + assert.equal( + (ctx.writes.join('').match(/Open a folder or save a file before using terminal commands\./g) || []).length, + 3 + ); assert.deepEqual(ctx.mkdirCalls, []); }); diff --git a/scripts/e2e-workspace-file-tracking.test.mjs b/scripts/e2e-workspace-file-tracking.test.mjs index fc033d2..e310bc7 100644 --- a/scripts/e2e-workspace-file-tracking.test.mjs +++ b/scripts/e2e-workspace-file-tracking.test.mjs @@ -1173,6 +1173,7 @@ test('e2e: setWorker disconnects the old worker and binds messages to the replac test('e2e: compile-and-run pending state is cleared when the worker is replaced', async () => { const ctx = await setupToolbar(); + await ctx.toolbar.restoreWorkspace({ name: 'p', entries: [{ path: 'main.cpp', kind: 'file' }] }, [], null); ctx.document.getElementById('btn-compile-run').click(); await tick(); assert.equal(ctx.workerCalls[0].type, 'compile'); @@ -1214,6 +1215,7 @@ test('e2e: after worker replacement, stale run-result from the old worker cannot test('e2e: compile actions after worker replacement post to the replacement worker', async () => { const ctx = await setupToolbar(); + await ctx.toolbar.restoreWorkspace({ name: 'p', entries: [{ path: 'main.cpp', kind: 'file' }] }, [], null); const replacementCalls = []; const replacementWorker = { postMessage(msg) { replacementCalls.push(msg); }, onmessage: null }; diff --git a/src/ui/session-persistence.mjs b/src/ui/session-persistence.mjs index b47926d..7a1b437 100644 --- a/src/ui/session-persistence.mjs +++ b/src/ui/session-persistence.mjs @@ -141,13 +141,10 @@ function createIndexedDBHandleStore() { export function createSessionPersistence({ fsAPI, - editorAPI, - markDirty, getOpenTabPaths, getActiveTabPath, getOpenTabsSnapshot = () => null, restoreWorkspace, - restoreNoWorkspaceSource = (source) => editorAPI.setValue(source), storage = getStorageArea(), handleStore = createIndexedDBHandleStore(), confirmReload = () => true, @@ -261,10 +258,8 @@ export function createSessionPersistence({ return; } - if (session.source) { - restoreNoWorkspaceSource(session.source); - markDirty(false); - } + // Untitled buffers are deliberately ephemeral. Ignore legacy source-only + // snapshots rather than restoring content that was never saved to a folder. } catch (_) { // Storage not available – first run or non-extension context } @@ -298,12 +293,7 @@ export function createSessionPersistence({ }); } else { await handleStore.clear(); - await storageSet(storage, { - [STORAGE_KEY]: { - source: editorAPI.getValue(), - savedAt: Date.now(), - }, - }); + await storageSet(storage, { [STORAGE_KEY]: null }); } } catch (err) { console.warn('Failed to persist browser.cpp session:', err); diff --git a/src/ui/terminal.js b/src/ui/terminal.js index 22fe43a..f352c91 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -741,6 +741,12 @@ async function executeCommand(cmdLine) { const [cmd, ...args] = parts; + if (!workspaceName && cmd !== 'help') { + term.write(`${C.red}Open a folder or save a file before using terminal commands.${C.reset}${CRLF}`); + writePrompt(); + return; + } + switch (cmd) { case 'g++': case 'clang++': diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index e981672..c170cbe 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -46,7 +46,8 @@ const _openTabs = new Map(); let _activeTabPath = null; /** When true, programmatic setValue calls do not trigger markDirty(true). */ let _loadingFile = false; -const UNSAVED_TAB_PATH = 'unsaved.cpp'; +// Internal in-memory document identifier; never a workspace-relative path. +const UNSAVED_TAB_PATH = 'untitled:default'; const UNSAVED_TAB_LABEL = 'unsaved file'; // ── Session persistence callback ────────────────────────────────────────────── @@ -119,7 +120,7 @@ export function getLastRunBinaryBytes() { export function setRunPreparing(preparing) { _runPreparationActive = preparing; - setButtonsEnabled(!preparing); + updateCompileButtons(!preparing); updateStatusBar( 'compiler', preparing ? 'busy' : 'ready', @@ -215,7 +216,7 @@ async function handleWorkerMessage(data) { } } updateStatusBar('compiler', 'ready', 'Compiler ready'); - setButtonsEnabled(true); + updateCompileButtons(); _terminalAPI.printInfo('Clang WASM compiler loaded. Ready to compile C++20.'); _terminalAPI.showInitialPrompt?.(); break; @@ -239,7 +240,7 @@ async function handleWorkerMessage(data) { case 'compile-result': { const shouldRunAfterCompile = _runAfterSuccessfulCompile; _runAfterSuccessfulCompile = false; - setButtonsEnabled(true); + updateCompileButtons(); updateStatusBar('compiler', 'ready', 'Compiler ready'); // Render inline editor markers, scoped to the active file. The terminal @@ -283,7 +284,7 @@ async function handleWorkerMessage(data) { break; case 'run-result': { - setButtonsEnabled(true); + updateCompileButtons(); updateStatusBar('compiler', 'ready', 'Compiler ready'); _terminalAPI.onRunResult(data); // Write files created/modified by the program back to the workspace and @@ -481,6 +482,7 @@ export function applyWorkspaceSnapshot(snapshot) { _terminalAPI.refreshWorkspace?.(snapshot); pruneExpandedWorkspaceDirectories(snapshot); renderWorkspaceSidebar(snapshot); + updateCompileButtons(); } /** Reveal a path created through the Explorer's explicit New File action. */ @@ -589,15 +591,25 @@ async function reloadOverwrittenTabs(changedPaths) { * path, where the prior session is being intentionally abandoned. */ export function resetToNewProject() { + clearTransientProjectState(); restoreNoWorkspaceSource(_editorAPI.DEFAULT_SOURCE ?? ''); } +function clearTransientProjectState() { + _runAfterSuccessfulCompile = false; + _runPreparationActive = false; + _lastRunBinaryBytes = null; + _terminalAPI.clearTerminal?.(); + _editorAPI.clearDiagnostics?.(); +} + /** Restore a source-only session into the same no-workspace tab state as a new project. */ export function restoreNoWorkspaceSource(source) { closeAllTabs(); _fsAPI.newFile(); clearWorkspaceMode(); openTabForFile(UNSAVED_TAB_PATH, source); + markDirty(false); } async function actionSave() { @@ -615,19 +627,41 @@ async function actionSave() { return; } - const suggestedName = _activeTabPath === UNSAVED_TAB_PATH ? 'main.cpp' : _fileName; - const name = await _fsAPI.saveFile(_editorAPI.getValue(), suggestedName); - if (name) { - renameActiveTabPath(name); - markDirty(false); - } + await saveUntitledDocument(); } catch (err) { alert(`Could not save file:\n${err.message}`); } } +async function saveUntitledDocument() { + const workspace = await _fsAPI.openFolder(); + if (!workspace) return; + + const name = prompt('File name', 'main.cpp'); + if (name === null) { + _fsAPI.resetWorkspace?.(); + return; + } + + const result = await _fsAPI.createWorkspaceFile(name, _editorAPI.getValue()); + if (!result?.ok) { + _fsAPI.resetWorkspace?.(); + throw new Error(inlineCreateErrorMessage(result?.error)); + } + + setWorkspaceMode(result.snapshot ?? workspace); + applyWorkspaceSnapshot(result.snapshot ?? workspace); + renameActiveTabPath(result.path); + markDirty(false); + _persistSession?.(); +} + async function actionSaveAs() { try { + if (!_workspace && _activeTabPath === UNSAVED_TAB_PATH) { + await saveUntitledDocument(); + return; + } const suggestedName = _activeTabPath === UNSAVED_TAB_PATH ? 'main.cpp' : _fileName; const name = await _fsAPI.saveFileAs(_editorAPI.getValue(), suggestedName); if (name) { @@ -640,7 +674,7 @@ async function actionSaveAs() { } async function actionCompile() { - if (!_worker || _runPreparationActive) return; + if (!_worker || _runPreparationActive || !workspaceHasCppFile()) return; _runAfterSuccessfulCompile = false; const payload = await assembleCompilePayload({}); _worker.postMessage({ type: 'compile', ...payload }); @@ -651,7 +685,7 @@ async function actionRun() { } async function actionCompileRun() { - if (!_worker || _runPreparationActive) return; + if (!_worker || _runPreparationActive || !workspaceHasCppFile()) return; _runAfterSuccessfulCompile = true; const payload = await assembleCompilePayload({}); _worker.postMessage({ type: 'compile', ...payload }); @@ -665,7 +699,6 @@ async function actionCompileRun() { * on-disk workspace files, and choose the build target set: * - explicit `sourcePaths` (terminal `g++ a.cpp b.cpp`) * - otherwise every recursive `.cpp`/`.cxx` workspace file (toolbar project build) - * - otherwise, with no folder open, the single editor buffer (legacy behaviour) * * @param {{ sourcePaths?:string[], std?:string, flags?:string[], outputName?:(string|null) }} opts * @returns {Promise} worker `compile` message payload @@ -677,21 +710,8 @@ export async function assembleCompilePayload({ sourcePaths = null, std, flags = } const resolvedStd = std || document.getElementById('cpp-standard')?.value || 'c++20'; - const primarySourcePath = _activeTabPath || 'input.cpp'; - - // No folder open → preserve single-buffer compile for new unsaved files. - if (!_workspace) { - const buffer = _editorAPI.getValue(); - const path = normalizeOverlayPath(primarySourcePath) || 'input.cpp'; - return { - sourcePaths: [path], - files: [{ path, content: buffer }], - std: resolvedStd, - flags, - outputName, - primarySourcePath: path, - }; - } + if (!_workspace) throw new Error('Open a folder or save a file before compiling.'); + const primarySourcePath = _activeTabPath; const diskFiles = await _fsAPI.readAllWorkspaceFiles(); const dirtyContentByPath = {}; @@ -710,7 +730,7 @@ export async function assembleCompilePayload({ sourcePaths = null, std, flags = std: resolvedStd, flags, outputName, - primarySourcePath: normalizeOverlayPath(primarySourcePath), + primarySourcePath: primarySourcePath ? normalizeOverlayPath(primarySourcePath) : null, }; } @@ -748,16 +768,22 @@ function setButtonsEnabled(enabled) { }); } +function workspaceHasCppFile() { + return Boolean(_workspace?.entries?.some( + (entry) => entry.kind === 'file' && entry.path.toLowerCase().endsWith('.cpp') + )); +} + +function updateCompileButtons(enabled = true) { + setButtonsEnabled(enabled && workspaceHasCppFile()); +} + /** Update the filename shown in the status bar and sidebar. */ function setFileName(name) { _fileName = tabDisplayName(name); const statusFile = document.getElementById('status-file'); if (statusFile) statusFile.textContent = _fileName; - if (_workspace) { - highlightWorkspaceFile(name); - } else { - updateSidebar(_fileName); - } + if (_workspace) highlightWorkspaceFile(name); } /** Mark the current file as dirty (has unsaved changes). */ @@ -926,17 +952,6 @@ function closeAllTabs() { renderTabBar(); } -function updateSidebar(name) { - const tree = document.getElementById('file-tree'); - if (!tree) return; - tree.innerHTML = ''; - const li = document.createElement('li'); - li.className = 'active'; - li.setAttribute('role', 'treeitem'); - li.textContent = `📄 ${name}`; - tree.appendChild(li); -} - function renderWorkspaceSidebar(workspace) { const tree = document.getElementById('file-tree'); if (!tree) return; @@ -1083,6 +1098,7 @@ function setWorkspaceMode(workspace) { _workspace = workspace; _expandedWorkspaceDirectories.clear(); _terminalAPI.setWorkspace?.(workspace); + updateCompileButtons(); startWorkspaceSyncPolling(); } @@ -1090,6 +1106,9 @@ function clearWorkspaceMode() { _workspace = null; _expandedWorkspaceDirectories.clear(); _terminalAPI.setWorkspace?.(null); + const tree = document.getElementById('file-tree'); + if (tree) tree.innerHTML = ''; + updateCompileButtons(false); stopWorkspaceSyncPolling(); } @@ -1171,6 +1190,7 @@ function isMacPlatform() { async function openFolderWorkspace() { const workspace = await _fsAPI.openFolder(); if (!workspace) return false; + clearTransientProjectState(); closeAllTabs(); setWorkspaceMode(workspace); await openWorkspaceInitialFile(workspace);