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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"lint": "eslint .",
"build": "npm run build:webpack && npm run build:targets",
"build:firefox": "npm run build",
"test:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-workspace-scan-progress.test.mjs scripts/e2e-terminal-gpp-glob.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-rm.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-settings-memory-debug.test.mjs scripts/e2e-settings-visual-theme.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs",
"test:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-workspace-scan-progress.test.mjs scripts/e2e-terminal-gpp-glob.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-rm.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-settings-memory-debug.test.mjs scripts/e2e-settings-visual-theme.test.mjs scripts/e2e-editor-spacing.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs",
"test:e2e:compiler": "npm run test:preflight-clang && node --experimental-detect-module --test scripts/e2e-compiler-link.test.mjs scripts/e2e-memory-debug-toolchain.test.mjs",
"test:preflight-clang": "node scripts/preflight-clang-artifacts.js",
"test:browser:chrome": "npm run test:e2e:compiler && node scripts/smoke-browser.mjs chrome",
Expand Down
57 changes: 57 additions & 0 deletions scripts/e2e-editor-spacing.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { convertLeadingIndentation, detectSpacing, isMakefilePath, spacingForPath } from '../src/ui/editor-spacing.mjs';
import { createExtensionSettings } from '../src/ui/settings.mjs';

test('classifies only Makefile and .makefile paths as Makefiles', () => {
assert.equal(isMakefilePath('Makefile'), true);
assert.equal(isMakefilePath('build/rules.MAKEFILE'), true);
assert.equal(isMakefilePath('GNUmakefile'), false);
assert.equal(isMakefilePath('main.cpp'), false);
});

test('uses independent safe defaults for Makefiles and general files', () => {
assert.equal(spacingForPath('Makefile', {}), 'tabs');
assert.equal(spacingForPath('main.cpp', {}), 'spaces-2');
});

test('converts only leading indentation and preserves interior whitespace', () => {
assert.equal(convertLeadingIndentation(' if (a) {\n x = 1;\n}', 'spaces-4', 'spaces-2'), ' if (a) {\n x = 1;\n}');
});

test('detects existing leading indentation before applying the selected style', () => {
assert.equal(detectSpacing('\tfirst\n\t\tsecond'), 'tabs');
assert.equal(detectSpacing(' first\n second'), 'spaces-4');
assert.equal(detectSpacing(' first\n second'), 'spaces-2');
});

test('persists the two category defaults', async () => {
let stored;
const settings = createExtensionSettings({ storage: { get: async () => ({}), set: async (value) => { stored = value; } } });
await settings.load();
await settings.setSpacing('makefileSpacing', 'spaces-4');
assert.equal(settings.get().makefileSpacing, 'spaces-4');
assert.equal(stored.browser_cpp_settings.defaultSpacing, 'spaces-2');
});

test('status bar exposes the spacing dropup after cursor status', async () => {
const html = await readFile(new URL('../src/ui/index.html', import.meta.url), 'utf8');
assert.match(html, /status-cursor[\s\S]*spacing-button[\s\S]*spacing-menu/);
assert.match(html, /Spacing: 2 spaces<\/button>/);
assert.doesNotMatch(html, /Spacing: 2 spaces [⌃^]/);
assert.match(html, /data-apply-spacing/);
});

test('only the selected spacing choice is styled boldly', async () => {
const css = await readFile(new URL('../src/ui/styles.css', import.meta.url), 'utf8');
assert.match(css, /\[role="menuitemradio"\]\[aria-checked="true"\]\s*[{]\s*font-weight:\s*700/);
assert.doesNotMatch(css, /aria-checked="true"\]::before/);
});

test('Monaco spacing selection disables content inference and updates indent size', async () => {
const editor = await readFile(new URL('../src/ui/editor.js', import.meta.url), 'utf8');
assert.match(editor, /detectIndentation:\s*false/);
assert.match(editor, /indentSize:\s*tabSize/);
assert.match(editor, /updateOptions\(\{\s*detectIndentation:\s*false,[\s\S]*indentSize:\s*tabSize/);
});
6 changes: 3 additions & 3 deletions scripts/e2e-settings-memory-debug.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,14 @@ for (const [name, createStorage] of [
const storage = createStorage();
const settings = createExtensionSettings({ storage });

assert.deepEqual(await settings.load(), { memoryDebugMode: false, visualTheme: 'browser-cpp-dark' });
assert.deepEqual(await settings.load(), { memoryDebugMode: false, visualTheme: 'browser-cpp-dark', defaultSpacing: 'spaces-2', makefileSpacing: 'tabs' });
await settings.setMemoryDebugMode(true);

assert.deepEqual(settings.get(), { memoryDebugMode: true, visualTheme: 'browser-cpp-dark' });
assert.deepEqual(settings.get(), { memoryDebugMode: true, visualTheme: 'browser-cpp-dark', defaultSpacing: 'spaces-2', makefileSpacing: 'tabs' });
assert.deepEqual((await new Promise((resolve) => {
const result = storage.get(SETTINGS_STORAGE_KEY, resolve);
if (result?.then) result.then(resolve);
}))[SETTINGS_STORAGE_KEY], { memoryDebugMode: true, visualTheme: 'browser-cpp-dark' });
}))[SETTINGS_STORAGE_KEY], { memoryDebugMode: true, visualTheme: 'browser-cpp-dark', defaultSpacing: 'spaces-2', makefileSpacing: 'tabs' });
});
}

Expand Down
6 changes: 4 additions & 2 deletions scripts/e2e-settings-visual-theme.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,12 @@ test('visual theme defaults, sanitizes legacy storage, and persists immediately'
assert.deepEqual(await settings.load(), {
memoryDebugMode: true,
visualTheme: DEFAULT_VISUAL_THEME_ID,
defaultSpacing: 'spaces-2',
makefileSpacing: 'tabs',
});
await settings.setVisualTheme('vs');
assert.deepEqual(settings.get(), { memoryDebugMode: true, visualTheme: 'vs' });
assert.deepEqual(storage.value, { memoryDebugMode: true, visualTheme: 'vs' });
assert.deepEqual(settings.get(), { memoryDebugMode: true, visualTheme: 'vs', defaultSpacing: 'spaces-2', makefileSpacing: 'tabs' });
assert.deepEqual(storage.value, { memoryDebugMode: true, visualTheme: 'vs', defaultSpacing: 'spaces-2', makefileSpacing: 'tabs' });
});

test('monochrome themes persist and unknown values still use the existing default', async () => {
Expand Down
12 changes: 12 additions & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { getExtensionVersionLabel } from '../extension-api.mjs';
import { selectRunBinaryBytes } from './build-request.mjs';
import { createExtensionSettings, initSettingsPanel } from './settings.mjs';
import { getVisualTheme } from './themes.mjs';
import { initSpacingControl, SPACING_OPTIONS, spacingForPath } from './editor-spacing.mjs';

// ── Boot ──────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -172,7 +173,18 @@ window.addEventListener('DOMContentLoaded', async () => {
persistWorkspace: () => persistenceGate.persistWorkspace(),
scheduleState: () => persistenceGate.scheduleState(),
getCompileOptions: () => ({ memoryDebugMode: extensionSettings.get().memoryDebugMode }),
onActiveTabChange: (path) => {
editorAPI.setIndentation(SPACING_OPTIONS[spacingForPath(path, extensionSettings.get())]);
spacingControl?.refresh();
},
});
const spacingControl = initSpacingControl({
document,
settings: extensionSettings,
editorAPI,
getActivePath: getActiveTabPath,
});
editorAPI.setIndentation(SPACING_OPTIONS[spacingForPath(null, extensionSettings.get())]);
extensionSettings.subscribe(applyVisualTheme);
resetToNewProject();

Expand Down
87 changes: 87 additions & 0 deletions src/ui/editor-spacing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use strict';

export const SPACING_OPTIONS = Object.freeze({
'spaces-2': Object.freeze({ id: 'spaces-2', label: '2 spaces ..', tabSize: 2, insertSpaces: true }),
'spaces-4': Object.freeze({ id: 'spaces-4', label: '4 spaces ....', tabSize: 4, insertSpaces: true }),
tabs: Object.freeze({ id: 'tabs', label: 'Tab ⇥', tabSize: 4, insertSpaces: false }),
});

export function normalizeSpacing(value, fallback = 'spaces-2') {
return SPACING_OPTIONS[value] ? value : fallback;
}

export function isMakefilePath(path) {
const name = String(path || '').split('/').pop() || '';
return name === 'Makefile' || /\.makefile$/i.test(name);
}

export function spacingForPath(path, settings) {
return isMakefilePath(path)
? normalizeSpacing(settings.makefileSpacing, 'tabs')
: normalizeSpacing(settings.defaultSpacing, 'spaces-2');
}

export function detectSpacing(text) {
const prefixes = String(text).match(/^[\t ]+(?=\S)/gm) || [];
if (prefixes.some((prefix) => prefix.includes('\t'))) return 'tabs';
const widths = prefixes.map((prefix) => prefix.length).filter(Boolean);
if (!widths.length) return 'spaces-2';
const width = Math.min(...widths);
return width >= 4 ? 'spaces-4' : 'spaces-2';
}

export function convertLeadingIndentation(text, fromId, toId) {
const from = SPACING_OPTIONS[normalizeSpacing(fromId)];
const to = SPACING_OPTIONS[normalizeSpacing(toId)];
if (fromId === toId) return text;
const indent = to.insertSpaces ? ' '.repeat(to.tabSize) : '\t';
return String(text).split(/(\r?\n)/).map((line) => line.replace(/^[\t ]+/, (prefix) => {
if (from.insertSpaces) {
const levels = Math.floor(prefix.length / from.tabSize);
return indent.repeat(levels) + prefix.slice(levels * from.tabSize);
}
const levels = (prefix.match(/\t/g) || []).length;
return indent.repeat(levels) + prefix.replace(/\t/g, '');
})).join('');
}

export function initSpacingControl({ document, settings, editorAPI, getActivePath, onApply }) {
const button = document.getElementById('spacing-button');
const menu = document.getElementById('spacing-menu');
if (!button || !menu) return { refresh() {}, destroy() {} };
let open = false;
const active = () => spacingForPath(getActivePath?.(), settings.get());
const close = (focus = false) => { open = false; menu.hidden = true; button.setAttribute('aria-expanded', 'false'); if (focus) button.focus(); };
const refresh = () => {
const id = active(); const option = SPACING_OPTIONS[id];
button.textContent = `Spacing: ${option.label.replace(/ \.{2,4}$/, '')}`;
button.setAttribute('aria-label', `Spacing: ${option.label}`);
for (const item of menu.querySelectorAll('[role="menuitemradio"]')) item.setAttribute('aria-checked', String(item.dataset.spacing === id));
};
const choose = async (id) => {
const category = isMakefilePath(getActivePath?.()) ? 'makefileSpacing' : 'defaultSpacing';
await settings.setSpacing(category, id);
editorAPI.setIndentation(SPACING_OPTIONS[id]); refresh(); close(true);
};
const click = (event) => {
const choice = event.target.closest?.('[data-spacing]');
if (choice) return choose(choice.dataset.spacing);
if (event.target.closest?.('[data-apply-spacing]')) {
const model = editorAPI.getEditor?.()?.getModel?.();
if (model) {
const original = editorAPI.getValue();
const converted = convertLeadingIndentation(original, detectSpacing(original), active());
if (converted !== original) editorAPI.setValue(converted);
}
onApply?.(); close(true);
}
};
const toggle = () => { open = !open; menu.hidden = !open; button.setAttribute('aria-expanded', String(open)); };
const keydown = (event) => { if (event.key === 'Escape' && open) close(true); };
const outsideClick = (event) => {
if (open && !button.contains(event.target) && !menu.contains(event.target)) close();
};
button.addEventListener('click', toggle); menu.addEventListener('click', click); document.addEventListener('keydown', keydown); document.addEventListener('click', outsideClick);
const unsubscribe = settings.subscribe(refresh); refresh();
return { refresh, destroy() { unsubscribe(); button.removeEventListener('click', toggle); menu.removeEventListener('click', click); document.removeEventListener('keydown', keydown); document.removeEventListener('click', outsideClick); } };
}
14 changes: 13 additions & 1 deletion src/ui/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ export function createEditor(container, { theme = 'browser-cpp-dark' } = {}) {
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace",
fontLigatures: true,
lineHeight: 22,
tabSize: 4,
detectIndentation: false,
tabSize: 2,
indentSize: 2,
insertSpaces: true,
wordWrap: 'off',
minimap: { enabled: true },
Expand Down Expand Up @@ -206,6 +208,16 @@ export function setValue(text) {
if (_editor) _editor.setValue(text);
}

/** Set Monaco's indentation behavior without mutating document content. */
export function setIndentation({ tabSize, insertSpaces }) {
_editor?.updateOptions({
detectIndentation: false,
tabSize,
indentSize: tabSize,
insertSpaces,
});
}

/** Change the editor language (e.g. 'cpp', 'c', 'plaintext'). */
export function setLanguage(lang) {
if (_editor) {
Expand Down
10 changes: 10 additions & 0 deletions src/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@ <h2 id="settings-heading">Settings</h2>
<span id="status-compiler" class="status-item">Initialising compiler…</span>
<span id="status-file" class="status-item"></span>
<span id="status-cursor" class="status-item">Ln 1, Col 1</span>
<div class="spacing-control">
<button id="spacing-button" class="status-item spacing-button" aria-haspopup="menu" aria-expanded="false">Spacing: 2 spaces</button>
<div id="spacing-menu" class="spacing-menu" role="menu" hidden>
<button role="menuitemradio" data-spacing="spaces-2" aria-checked="true">2 spaces ..</button>
<button role="menuitemradio" data-spacing="spaces-4" aria-checked="false">4 spaces ....</button>
<button role="menuitemradio" data-spacing="tabs" aria-checked="false">Tab ⇥</button>
<hr>
<button role="menuitem" data-apply-spacing>Apply to file</button>
</div>
</div>
<span id="status-version" class="status-item status-version"></span>
</footer>

Expand Down
11 changes: 10 additions & 1 deletion src/ui/settings.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getExtensionAPI, getExtensionRuntimeError } from '../extension-api.mjs'
import { DEFAULT_VISUAL_THEME_ID, VISUAL_THEMES, getVisualTheme } from './themes.mjs';

export const SETTINGS_STORAGE_KEY = 'browser_cpp_settings';
export const DEFAULT_SETTINGS = Object.freeze({ memoryDebugMode: false, visualTheme: DEFAULT_VISUAL_THEME_ID });
export const DEFAULT_SETTINGS = Object.freeze({ memoryDebugMode: false, visualTheme: DEFAULT_VISUAL_THEME_ID, defaultSpacing: 'spaces-2', makefileSpacing: 'tabs' });

function getStorageArea() {
return getExtensionAPI()?.storage?.local ?? null;
Expand All @@ -14,6 +14,8 @@ function sanitizeSettings(value) {
return {
memoryDebugMode: value?.memoryDebugMode === true,
visualTheme: getVisualTheme(value?.visualTheme).id,
defaultSpacing: ['spaces-2', 'spaces-4', 'tabs'].includes(value?.defaultSpacing) ? value.defaultSpacing : 'spaces-2',
makefileSpacing: ['spaces-2', 'spaces-4', 'tabs'].includes(value?.makefileSpacing) ? value.makefileSpacing : 'tabs',
};
}

Expand Down Expand Up @@ -95,6 +97,13 @@ export function createExtensionSettings({
}
return { ...current };
},
async setSpacing(key, spacing) {
if (!['defaultSpacing', 'makefileSpacing'].includes(key)) return { ...current };
current = { ...current, [key]: ['spaces-2', 'spaces-4', 'tabs'].includes(spacing) ? spacing : current[key] };
notify();
try { await storageSet(storage, { [SETTINGS_STORAGE_KEY]: { ...current } }); } catch (error) { onError(error); }
return { ...current };
},

subscribe(subscriber) {
subscribers.add(subscriber);
Expand Down
9 changes: 9 additions & 0 deletions src/ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,15 @@ select:focus {
color: var(--subtext0);
}

.spacing-control { position: relative; }
.spacing-button { background: none; border: 0; color: inherit; padding: 0; font-size: inherit; }
.spacing-button:hover, .spacing-button:focus-visible { color: var(--text); outline: 1px solid var(--blue); }
.spacing-menu { position: absolute; bottom: calc(100% + 5px); left: 0; z-index: 20; min-width: 145px; padding: 4px; background: var(--mantle); border: 1px solid var(--surface1); box-shadow: 0 4px 12px #0006; }
.spacing-menu button { display: flex; width: 100%; background: none; color: var(--text); border: 0; border-radius: 2px; padding: 4px 7px; text-align: left; }
.spacing-menu button:hover, .spacing-menu button:focus-visible { background: var(--surface0); outline: none; }
.spacing-menu [role="menuitemradio"][aria-checked="true"] { font-weight: 700; }
.spacing-menu hr { border: 0; border-top: 1px solid var(--surface1); margin: 4px 0; }

.status-item { white-space: nowrap; }
.status-version { margin-left: auto; }

Expand Down
6 changes: 6 additions & 0 deletions src/ui/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ let _persistSession = null;
let _persistWorkspaceSession = null;
let _schedulePersistSession = null;
let _getCompileOptions = () => ({ memoryDebugMode: false });
let _onActiveTabChange = null;
let _persistTimer = null;

function describeRuntimeWritebackIssue(reason) {
Expand Down Expand Up @@ -115,6 +116,7 @@ export function initToolbar(worker, editorAPI, terminalAPI, fsAPI, persistence)
_persistWorkspaceSession = persistence?.persistWorkspace ?? _persistSession;
_schedulePersistSession = persistence?.scheduleState ?? null;
_getCompileOptions = persistence?.getCompileOptions ?? (() => ({ memoryDebugMode: false }));
_onActiveTabChange = persistence?.onActiveTabChange ?? null;
}

bindButtons();
Expand Down Expand Up @@ -994,6 +996,7 @@ function switchToTab(path) {
setFileName(path);

renderTabBar();
_onActiveTabChange?.(_activeTabPath);
schedulePersist(); // debounced – tracks active tab changes
}

Expand All @@ -1020,6 +1023,7 @@ function renameActiveTabPath(nextPath) {
_activeTabPath = nextPath;
setFileName(nextPath);
renderTabBar();
_onActiveTabChange?.(_activeTabPath);
schedulePersist();
}

Expand Down Expand Up @@ -1052,6 +1056,7 @@ function closeTab(path, { discard = false } = {}) {
const statusFile = document.getElementById('status-file');
if (statusFile) statusFile.textContent = '';
renderTabBar();
_onActiveTabChange?.(null);
}
} else {
renderTabBar();
Expand Down Expand Up @@ -1347,6 +1352,7 @@ async function openFolderWorkspace() {
_loadingFile = false;
const statusFile = document.getElementById('status-file');
if (statusFile) statusFile.textContent = '';
_onActiveTabChange?.(null);
renderWorkspaceSidebar(workspace);
await _persistWorkspaceSession?.();
return true;
Expand Down
Loading