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
14 changes: 8 additions & 6 deletions scripts/e2e-multifile-build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
selectWorkspaceSources,
buildCompileOverlay,
isProjectSource,
isRejectedSource,
normalizeOverlayPath,
} from '../src/ui/build-request.mjs';
import { parseCompilePlan } from '../src/workers/compile-plan.mjs';
Expand All @@ -24,22 +23,25 @@ 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' },
];

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);
});

Expand Down
39 changes: 39 additions & 0 deletions scripts/e2e-workspace-file-tracking.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();

Expand Down
27 changes: 8 additions & 19 deletions src/ui/build-request.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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();
}
Expand Down
17 changes: 17 additions & 0 deletions src/ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 0 additions & 12 deletions src/ui/terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
parseGxxArgs,
resolveWorkspacePath,
resolveRunTarget,
isRejectedSource,
normalizeOverlayPath,
} from './build-request.mjs';
import { validateNewDirectoryPath, validateNewFilePath } from './workspace-fs.mjs';
Expand Down Expand Up @@ -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) {
Expand Down
49 changes: 33 additions & 16 deletions src/ui/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -128,7 +132,7 @@ export function getLastRunBinaryBytes() {

export function setRunPreparing(preparing) {
_runPreparationActive = preparing;
updateCompileButtons(!preparing);
updateCompileButtons();
updateStatusBar(
'compiler',
preparing ? 'busy' : 'ready',
Expand Down Expand Up @@ -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);
Expand All @@ -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.'
Expand All @@ -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();
Expand Down Expand Up @@ -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':
Expand All @@ -292,6 +305,7 @@ async function handleWorkerMessage(data) {
break;

case 'run-result': {
_compilerBusy = false;
updateCompileButtons();
updateStatusBar('compiler', 'ready', 'Compiler ready');
_terminalAPI.onRunResult(data);
Expand Down Expand Up @@ -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 });
Expand All @@ -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 });
Expand All @@ -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<object>} worker `compile` message payload
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -1144,7 +1161,7 @@ function clearWorkspaceMode() {
_expandedWorkspaceDirectories.clear();
const tree = document.getElementById('file-tree');
if (tree) tree.innerHTML = '';
updateCompileButtons(false);
updateCompileButtons();
stopWorkspaceSyncPolling();
}

Expand Down
Loading