diff --git a/package.json b/package.json index c2f1130..fcc1289 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/e2e-editor-spacing.test.mjs b/scripts/e2e-editor-spacing.test.mjs new file mode 100644 index 0000000..fbf0506 --- /dev/null +++ b/scripts/e2e-editor-spacing.test.mjs @@ -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/); +}); diff --git a/scripts/e2e-settings-memory-debug.test.mjs b/scripts/e2e-settings-memory-debug.test.mjs index 760f38a..0cb2208 100644 --- a/scripts/e2e-settings-memory-debug.test.mjs +++ b/scripts/e2e-settings-memory-debug.test.mjs @@ -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' }); }); } diff --git a/scripts/e2e-settings-visual-theme.test.mjs b/scripts/e2e-settings-visual-theme.test.mjs index 9d8452a..595abf7 100644 --- a/scripts/e2e-settings-visual-theme.test.mjs +++ b/scripts/e2e-settings-visual-theme.test.mjs @@ -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 () => { diff --git a/src/ui/app.js b/src/ui/app.js index 09e88f5..222a1b6 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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 ────────────────────────────────────────────────────────────────────── @@ -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(); diff --git a/src/ui/editor-spacing.mjs b/src/ui/editor-spacing.mjs new file mode 100644 index 0000000..925951e --- /dev/null +++ b/src/ui/editor-spacing.mjs @@ -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); } }; +} diff --git a/src/ui/editor.js b/src/ui/editor.js index 0776b7c..9f3c7b8 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -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 }, @@ -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) { diff --git a/src/ui/index.html b/src/ui/index.html index caff0be..80146d4 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -155,6 +155,16 @@