From a99ee0f9f3247a6e0fad8da1d455633010ea056b Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Thu, 3 Sep 2026 23:40:00 -0700 Subject: [PATCH] feat(toolbar): gate builds to root-level sources Enable toolbar compilation only when a ready compiler has root-level C/C++ sources, while preserving explicit terminal builds for nested files.\n\nCloses #84 --- scripts/e2e-multifile-build.test.mjs | 14 +++--- scripts/e2e-workspace-file-tracking.test.mjs | 39 ++++++++++++++++ src/ui/build-request.mjs | 27 ++++------- src/ui/styles.css | 17 +++++++ src/ui/terminal.js | 12 ----- src/ui/toolbar.js | 49 +++++++++++++------- 6 files changed, 105 insertions(+), 53 deletions(-) diff --git a/scripts/e2e-multifile-build.test.mjs b/scripts/e2e-multifile-build.test.mjs index 226afbe..dc817e0 100644 --- a/scripts/e2e-multifile-build.test.mjs +++ b/scripts/e2e-multifile-build.test.mjs @@ -8,7 +8,6 @@ import { selectWorkspaceSources, buildCompileOverlay, isProjectSource, - isRejectedSource, normalizeOverlayPath, } from '../src/ui/build-request.mjs'; import { parseCompilePlan } from '../src/workers/compile-plan.mjs'; @@ -24,12 +23,15 @@ import { parseDiagnostics, diagnosticsForPath } from '../src/ui/diagnostics.mjs' // ── Toolbar project-source discovery ────────────────────────────────────────── -test('e2e: toolbar build selects every recursive .cpp/.cxx and ignores .c/.cc', () => { +test('e2e: toolbar build selects every root C/C++ source and ignores nested sources', () => { const entries = [ - { path: 'main.cpp', kind: 'file' }, + { path: 'MAIN.CPP', kind: 'file' }, + { path: 'legacy.c', kind: 'file' }, + { path: 'module.cc', kind: 'file' }, + { path: 'utility.CxX', kind: 'file' }, { path: 'src/util.cxx', kind: 'file' }, + { path: 'src/compat.C', kind: 'file' }, { path: 'src', kind: 'directory' }, - { path: 'legacy.c', kind: 'file' }, { path: 'vendor/old.cc', kind: 'file' }, { path: 'include/app.hpp', kind: 'file' }, { path: 'README.md', kind: 'file' }, @@ -37,9 +39,9 @@ test('e2e: toolbar build selects every recursive .cpp/.cxx and ignores .c/.cc', const sources = selectWorkspaceSources(entries); - assert.deepEqual(sources, ['main.cpp', 'src/util.cxx']); + assert.deepEqual(sources, ['MAIN.CPP', 'legacy.c', 'module.cc', 'utility.CxX']); + assert.ok(isProjectSource('a.c') && isProjectSource('a.cc')); assert.ok(isProjectSource('a.cpp') && isProjectSource('a.cxx')); - assert.ok(isRejectedSource('a.c') && isRejectedSource('a.cc')); assert.equal(isProjectSource('a.hpp'), false); }); diff --git a/scripts/e2e-workspace-file-tracking.test.mjs b/scripts/e2e-workspace-file-tracking.test.mjs index 822653f..c02fa83 100644 --- a/scripts/e2e-workspace-file-tracking.test.mjs +++ b/scripts/e2e-workspace-file-tracking.test.mjs @@ -915,6 +915,43 @@ function shortcutEvent(key, { metaKey = false, ctrlKey = false, shiftKey = false }; } +test('e2e: toolbar compilation requires a ready compiler and root-level C/C++ sources', async () => { + const ctx = await setupToolbar(); + await ctx.toolbar.restoreWorkspace({ + name: 'nested-only', + entries: [ + { path: 'src/main.CPP', kind: 'file' }, + { path: 'lib/helper.cc', kind: 'file' }, + ], + }, [], null); + + assert.equal(ctx.document.getElementById('btn-compile').disabled, true); + assert.equal(ctx.document.getElementById('btn-compile-run').disabled, true); + ctx.document.dispatch('keydown', shortcutEvent('B', { ctrlKey: true, shiftKey: true }).event); + ctx.document.dispatch('keydown', shortcutEvent('F5').event); + await tick(); + assert.deepEqual(ctx.workerCalls, [], 'shortcuts cannot bypass ineligible toolbar state'); + + await ctx.toolbar.restoreWorkspace({ + name: 'root-sources', + entries: [ + { path: 'main.CPP', kind: 'file' }, + { path: 'legacy.c', kind: 'file' }, + { path: 'module.cc', kind: 'file' }, + { path: 'src/ignored.cxx', kind: 'file' }, + ], + }, [], null); + + assert.equal(ctx.document.getElementById('btn-compile').disabled, true, 'worker is still loading'); + ctx.worker.onmessage({ data: { type: 'compiler-ready', capabilities: {} } }); + assert.equal(ctx.document.getElementById('btn-compile').disabled, false); + assert.equal(ctx.document.getElementById('btn-compile-run').disabled, false); + + ctx.document.getElementById('btn-compile').click(); + await tick(); + assert.deepEqual(ctx.workerCalls[0].sourcePaths, ['legacy.c', 'main.CPP', 'module.cc']); +}); + test('e2e: New file with no workspace opens the folder picker; cancel leaves state unchanged', async () => { const ctx = await setupToolbar({ openFolderResult: null }); ctx.toolbar.resetToNewProject(); // no workspace, single main.cpp tab @@ -1291,6 +1328,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.worker.onmessage({ data: { type: 'compiler-ready', capabilities: {} } }); ctx.document.getElementById('btn-compile-run').click(); await tick(); assert.equal(ctx.workerCalls[0].type, 'compile'); @@ -1337,6 +1375,7 @@ test('e2e: compile actions after worker replacement post to the replacement work const replacementWorker = { postMessage(msg) { replacementCalls.push(msg); }, onmessage: null }; ctx.toolbar.setWorker(replacementWorker); + replacementWorker.onmessage({ data: { type: 'compiler-ready', capabilities: {} } }); ctx.document.getElementById('btn-compile').click(); await tick(); diff --git a/src/ui/build-request.mjs b/src/ui/build-request.mjs index f8c00f6..3e8c3c2 100644 --- a/src/ui/build-request.mjs +++ b/src/ui/build-request.mjs @@ -15,16 +15,8 @@ 'use strict'; -/** Extensions that whole-project discovery compiles (MVP product constraint). */ -export const PROJECT_SOURCE_EXTENSIONS = ['cpp', 'cxx']; - -/** - * Extensions that look like C/C++ sources but are intentionally rejected by this - * MVP. `.cc` is commonly C++ elsewhere, but the spec deliberately excludes it so - * discovery stays predictable; explicit terminal targets fail loudly instead of - * compiling silently. - */ -export const REJECTED_SOURCE_EXTENSIONS = ['c', 'cc']; +/** Extensions accepted by the C++ compiler, matched case-insensitively. */ +export const PROJECT_SOURCE_EXTENSIONS = ['c', 'cc', 'cpp', 'cxx']; /** Lower-cased extension (without the dot) of a path, or '' if none. */ export function fileExtension(path) { @@ -33,24 +25,20 @@ export function fileExtension(path) { return dot > 0 ? base.slice(dot + 1).toLowerCase() : ''; } -/** True when `path` is a project source file (`.cpp`/`.cxx`). */ +/** True when `path` is a project source file. */ export function isProjectSource(path) { return PROJECT_SOURCE_EXTENSIONS.includes(fileExtension(path)); } -/** True when `path` is a rejected source kind (`.c`/`.cc`) under this MVP. */ -export function isRejectedSource(path) { - return REJECTED_SOURCE_EXTENSIONS.includes(fileExtension(path)); -} - /** Strip leading "./" and "/" so overlay/source paths are workspace-relative. */ export function normalizeOverlayPath(path) { return String(path || '').replace(/^(\.\/)+/, '').replace(/^\/+/, ''); } /** - * Pick every recursive `.cpp`/`.cxx` file from workspace snapshot entries, - * ignoring `.c`/`.cc`. Result is de-duplicated and sorted for determinism. + * Pick root-level C/C++ sources from workspace snapshot entries. Nested source + * files require an explicit terminal command. Result is de-duplicated and + * sorted for determinism. * * @param {Array<{path:string, kind:string}>} entries * @returns {string[]} workspace-relative source paths @@ -59,7 +47,8 @@ export function selectWorkspaceSources(entries = []) { const out = new Set(); for (const entry of entries || []) { if (!entry || entry.kind !== 'file' || !entry.path) continue; - if (isProjectSource(entry.path)) out.add(normalizeOverlayPath(entry.path)); + const path = normalizeOverlayPath(entry.path); + if (!path.includes('/') && isProjectSource(path)) out.add(path); } return [...out].sort(); } diff --git a/src/ui/styles.css b/src/ui/styles.css index 6cd929d..32f5c9f 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -107,6 +107,23 @@ button:active { filter: brightness(0.9); } +button:disabled, +button:disabled:hover, +button:disabled:active { + cursor: not-allowed; + opacity: 0.5; + background: var(--surface0); + border-color: transparent; + filter: none; +} + +.btn-primary:disabled, +.btn-primary:disabled:hover, +.btn-primary:disabled:active { + background: var(--surface0); + color: var(--subtext0); +} + .btn-primary { background: var(--green); color: var(--crust); diff --git a/src/ui/terminal.js b/src/ui/terminal.js index fc13c3e..d8427be 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -27,7 +27,6 @@ import { parseGxxArgs, resolveWorkspacePath, resolveRunTarget, - isRejectedSource, normalizeOverlayPath, } from './build-request.mjs'; import { validateNewDirectoryPath, validateNewFilePath } from './workspace-fs.mjs'; @@ -826,17 +825,6 @@ async function executeCommand(cmdLine) { function cmdGxx(args) { const { std, outputName, flags, sourcePaths } = parseGxxArgs(args); - // MVP policy: reject `.c`/`.cc` explicit inputs rather than compiling silently. - const rejected = sourcePaths.filter(isRejectedSource); - if (rejected.length) { - term.write( - `${C.red}g++: ${rejected.join(', ')}: .c/.cc sources are not supported in this MVP ` + - `(only .cpp and .cxx).${C.reset}${CRLF}` - ); - writePrompt(); - return; - } - // No explicit sources → compile the single editor buffer (works with or // without an open folder), preserving legacy behaviour. if (sourcePaths.length === 0) { diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index ca0e16f..9cab3d4 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -31,6 +31,8 @@ let _fileName = 'main.cpp'; let _workspace = null; let _runAfterSuccessfulCompile = false; let _runPreparationActive = false; +let _compilerReady = false; +let _compilerBusy = false; let _lastRunBinaryBytes = null; const _expandedWorkspaceDirectories = new Set(); const WORKSPACE_SYNC_INTERVAL_MS = 2_000; @@ -116,10 +118,12 @@ export function setWorker(worker) { _worker = worker; _runAfterSuccessfulCompile = false; _runPreparationActive = false; + _compilerReady = false; + _compilerBusy = false; _terminalAPI?.setWorkerCapabilities?.({ jspi: false }); handleWorkerMessages(); updateStatusBar('compiler', 'loading', 'Compiler loading…'); - setButtonsEnabled(false); + updateCompileButtons(); } export function getLastRunBinaryBytes() { @@ -128,7 +132,7 @@ export function getLastRunBinaryBytes() { export function setRunPreparing(preparing) { _runPreparationActive = preparing; - updateCompileButtons(!preparing); + updateCompileButtons(); updateStatusBar( 'compiler', preparing ? 'busy' : 'ready', @@ -205,14 +209,18 @@ function handleWorkerMessages() { async function handleWorkerMessage(data) { switch (data.type) { case 'compiler-loading': + _compilerReady = false; + _compilerBusy = false; updateStatusBar( 'compiler', 'loading', `Loading compiler… ${data.progress}%` ); - setButtonsEnabled(false); + updateCompileButtons(); break; case 'compiler-ready': + _compilerReady = true; + _compilerBusy = false; _terminalAPI.setWorkerCapabilities?.(data.capabilities); { const report = createBrowserCompatibilityReport(globalThis, data.capabilities); @@ -230,8 +238,10 @@ async function handleWorkerMessage(data) { break; case 'compiler-error': + _compilerReady = false; + _compilerBusy = false; updateStatusBar('compiler', 'error', 'Compiler unavailable'); - setButtonsEnabled(false); + updateCompileButtons(); _terminalAPI.printInfo( `⚠ Compiler not available:\n${data.message}\n\n` + 'Run: npm run fetch-clang then reload the extension.' @@ -240,12 +250,14 @@ async function handleWorkerMessage(data) { break; case 'compile-start': + _compilerBusy = true; updateStatusBar('compiler', 'busy', 'Compiling…'); - setButtonsEnabled(false); + updateCompileButtons(); _editorAPI.clearDiagnostics(); break; case 'compile-result': { + _compilerBusy = false; const shouldRunAfterCompile = _runAfterSuccessfulCompile; _runAfterSuccessfulCompile = false; updateCompileButtons(); @@ -278,9 +290,10 @@ async function handleWorkerMessage(data) { } case 'run-start': + _compilerBusy = true; _terminalAPI.onRunStart?.(data); updateStatusBar('compiler', 'busy', 'Running…'); - setButtonsEnabled(false); + updateCompileButtons(); break; case 'stdout': @@ -292,6 +305,7 @@ async function handleWorkerMessage(data) { break; case 'run-result': { + _compilerBusy = false; updateCompileButtons(); updateStatusBar('compiler', 'ready', 'Compiler ready'); _terminalAPI.onRunResult(data); @@ -683,7 +697,7 @@ async function actionSaveAs() { } async function actionCompile() { - if (!_worker || _runPreparationActive || !workspaceHasCppFile()) return; + if (!canCompileFromToolbar()) return; _runAfterSuccessfulCompile = false; const payload = await assembleCompilePayload({}); _worker.postMessage({ type: 'compile', ...payload }); @@ -694,7 +708,7 @@ async function actionRun() { } async function actionCompileRun() { - if (!_worker || _runPreparationActive || !workspaceHasCppFile()) return; + if (!canCompileFromToolbar()) return; _runAfterSuccessfulCompile = true; const payload = await assembleCompilePayload({}); _worker.postMessage({ type: 'compile', ...payload }); @@ -707,7 +721,7 @@ async function actionCompileRun() { * the active editor buffer into its tab, layer all dirty tab content over the * 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 every root-level C/C++ workspace file (toolbar project build) * * @param {{ sourcePaths?:string[], std?:string, flags?:string[], outputName?:(string|null) }} opts * @returns {Promise} worker `compile` message payload @@ -777,14 +791,17 @@ function setButtonsEnabled(enabled) { }); } -function workspaceHasCppFile() { - return Boolean(_workspace?.entries?.some( - (entry) => entry.kind === 'file' && entry.path.toLowerCase().endsWith('.cpp') - )); +function canCompileFromToolbar() { + return Boolean(_worker) && _compilerReady && !_compilerBusy && + !_runPreparationActive && workspaceHasToolbarSource(); +} + +function workspaceHasToolbarSource() { + return selectWorkspaceSources(_workspace?.entries).length > 0; } -function updateCompileButtons(enabled = true) { - setButtonsEnabled(enabled && workspaceHasCppFile()); +function updateCompileButtons() { + setButtonsEnabled(canCompileFromToolbar()); } /** Update the filename shown in the status bar and sidebar. */ @@ -1144,7 +1161,7 @@ function clearWorkspaceMode() { _expandedWorkspaceDirectories.clear(); const tree = document.getElementById('file-tree'); if (tree) tree.innerHTML = ''; - updateCompileButtons(false); + updateCompileButtons(); stopWorkspaceSyncPolling(); }