diff --git a/frontend/bun.lock b/frontend/bun.lock index 9ecdb59..b999e59 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -45,6 +45,7 @@ "jwt-decode": "^4.0.0", "lucide-react": "^1.17.0", "marked": "^18.0.5", + "monaco-editor": "^0.56.0", "papaparse": "^5.5.3", "php-serialize": "^5.1.3", "qrcode": "^1.5.4", @@ -459,6 +460,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], @@ -559,6 +562,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dompurify": ["dompurify@3.4.8", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="], @@ -685,6 +690,8 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "monaco-editor": ["monaco-editor@0.56.0", "", { "dependencies": { "dompurify": "3.4.8", "marked": "14.0.0" } }, "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg=="], + "moo": ["moo@0.5.3", "", {}, "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -933,6 +940,8 @@ "codemirror/@codemirror/view": ["@codemirror/view@6.42.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg=="], + "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], + "nearley/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/frontend/e2e/codeConverter.spec.js b/frontend/e2e/codeConverter.spec.js index 49422b5..8e9b83b 100644 --- a/frontend/e2e/codeConverter.spec.js +++ b/frontend/e2e/codeConverter.spec.js @@ -103,7 +103,7 @@ test.describe('Code Converter', () => { }); test('shows error for invalid JSON with YAML method', async ({ page }) => { - await fillEditor(page, 'code-converter-input', 'not valid json'); + await fillEditor(page, 'code-converter-input', '{unclosed'); // Error should appear in the output pane (border turns red) await expectEditorText(page, 'code-converter-output', ''); }); diff --git a/frontend/e2e/codeFormatter.spec.js b/frontend/e2e/codeFormatter.spec.js index 0d5bbfd..e851baa 100644 --- a/frontend/e2e/codeFormatter.spec.js +++ b/frontend/e2e/codeFormatter.spec.js @@ -103,7 +103,12 @@ test.describe('Code Formatter', () => { await page.getByRole('button', { name: 'Load Sample' }).click(); await expectEditorNotEmpty(page, 'code-formatter-output'); - await expect(page.getByTestId('code-formatter-output').locator('.cm-gutters')).toBeVisible(); + await expect( + page + .getByTestId('code-formatter-output') + .locator('.cm-gutters, .monaco-editor .margin-view-overlays') + .first() + ).toBeVisible(); }); test('filter input filters JSON output', async ({ page }) => { @@ -135,7 +140,8 @@ test.describe('Code Formatter', () => { const copyButton = page.locator('button[title="Copy to clipboard"]').first(); await copyButton.click(); - const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); - expect(clipboardText).toBe('copy this input'); + await expect + .poll(() => page.evaluate(() => navigator.clipboard.readText())) + .toBe('copy this input'); }); }); diff --git a/frontend/e2e/helpers/editor.js b/frontend/e2e/helpers/editor.js index b8fd01a..fd56d67 100644 --- a/frontend/e2e/helpers/editor.js +++ b/frontend/e2e/helpers/editor.js @@ -17,8 +17,40 @@ export async function fillEditor(page, testId, value) { return; } + // Wait for the editor surface to mount (Monaco initializes async). + await expect + .poll(() => content.locator('.view-lines, .cm-editor').count(), { timeout: 15000 }) + .toBeGreaterThan(0); + await content.click(); await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A'); + + const monacoLines = content.locator('.view-line'); + if ((await monacoLines.count()) > 0) { + // Monaco (EditContext-based): use model.setValue() directly instead of + // insertText to avoid auto-indent reformatting multiline content. Map the + // pane's content element to its Monaco model via the exposed helper. + if (!value) { + await page.evaluate((id) => window.__monacoSetValue?.(id, ''), `${testId}-content`); + await page.waitForTimeout(300); + return; + } + const contentId = `${testId}-content`; + await expect + .poll( + async () => + await page.evaluate( + ([id, v]) => window.__monacoSetValue?.(id, v) ?? false, + [contentId, value] + ), + { timeout: 5000 } + ) + .toBe(true); + // Let React state sync after the model change + await page.waitForTimeout(300); + return; + } + await page.keyboard.press('Backspace'); if (value) { await page.keyboard.type(value); @@ -34,6 +66,17 @@ export async function readEditorText(page, testId) { return content.inputValue(); } + const monacoLines = content.locator('.view-line'); + if ((await monacoLines.count()) > 0) { + // Read straight from the Monaco model (mapped by content element) so tabs + // and exact whitespace survive; Monaco renders tabs as spaces in the DOM. + const modelValue = await page.evaluate( + (id) => window.__monacoGetValue?.(id) ?? null, + `${testId}-content` + ); + if (modelValue != null) return modelValue; + } + return content .locator('.cm-line') .evaluateAll((lines) => lines.map((line) => line.textContent ?? '').join('\n')); diff --git a/frontend/package.json b/frontend/package.json index 0b2fdff..aedd9eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,7 @@ "jwt-decode": "^4.0.0", "lucide-react": "^1.17.0", "marked": "^18.0.5", + "monaco-editor": "^0.56.0", "papaparse": "^5.5.3", "php-serialize": "^5.1.3", "qrcode": "^1.5.4", diff --git a/frontend/src/components/inputs/MonacoCodeEditor.jsx b/frontend/src/components/inputs/MonacoCodeEditor.jsx new file mode 100644 index 0000000..2d89b63 --- /dev/null +++ b/frontend/src/components/inputs/MonacoCodeEditor.jsx @@ -0,0 +1,213 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { getMonaco } from './monaco/monacoSetup'; +import { useMonacoDevtoolboxTheme } from './monaco/useMonacoTheme'; +import { MONACO_LANGUAGE_IDS } from './monaco/languages'; + +const BASE_OPTIONS = { + minimap: { enabled: false }, + fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace", + fontSize: 14, + lineHeight: 21, + scrollBeyondLastLine: false, + automaticLayout: true, + glyphMargin: false, + folding: false, + tabSize: 2, + smoothScrolling: true, + padding: { top: 8, bottom: 8 }, + overviewRulerLanes: 0, + scrollbar: { verticalScrollbarSize: 10, horizontalScrollbarSize: 10 }, +}; + +export default function MonacoCodeEditor({ + value = '', + onChange, + language, + readOnly = false, + placeholder, + label, + dataTestId, + ariaLabel, + showLineNumbers = false, + error = false, + wordWrap = true, + className = '', + style = {}, +}) { + const containerRef = useRef(null); + const editorRef = useRef(null); + const onChangeRef = useRef(onChange); + const valueRef = useRef(value); + + useMonacoDevtoolboxTheme(); + const [ready, setReady] = useState(false); + const [loadError, setLoadError] = useState(false); + + // Keep latest callbacks/props for imperative handlers (intentionally no deps) + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(() => { + onChangeRef.current = onChange; + valueRef.current = value; + }); + + // Create the editor once + useEffect(() => { + let cancelled = false; + getMonaco() + .then((monaco) => { + if (cancelled || !containerRef.current || editorRef.current) return; + const editor = monaco.editor.create(containerRef.current, { + ...BASE_OPTIONS, + value: valueRef.current ?? '', + language: MONACO_LANGUAGE_IDS[language?.toLowerCase()] || 'plaintext', + theme: 'devtoolbox', + readOnly, + domReadOnly: readOnly, + lineNumbers: showLineNumbers ? 'on' : 'off', + contextmenu: !readOnly, + wordWrap: wordWrap ? 'on' : 'off', + ariaLabel: ariaLabel || label || placeholder || 'Code editor', + ...(placeholder ? { placeholder } : {}), + }); + editor.onDidChangeModelContent(() => { + const next = editor.getValue(); + if (next !== valueRef.current) onChangeRef.current?.(next); + }); + editorRef.current = editor; + setReady(true); + }) + .catch(() => { + if (!cancelled) setLoadError(true); + }); + return () => { + cancelled = true; + const editor = editorRef.current; + if (editor) { + const model = editor.getModel(); + editor.dispose(); + if (model && !model.isDisposed()) model.dispose(); + editorRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync external value -> model (skip programmatic echo). + // NOTE: setValue resets the undo stack and cursor; acceptable for the pilot + // where external value changes come from tool actions (sample/clear), not + // concurrent editing. + useEffect(() => { + const editor = editorRef.current; + if (!editor || value === undefined) return; + if (editor.getValue() !== value) { + editor.getModel().setValue(value); + } + }, [value, ready]); + + // Sync options + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + editor.updateOptions({ + readOnly, + domReadOnly: readOnly, + lineNumbers: showLineNumbers ? 'on' : 'off', + wordWrap: wordWrap ? 'on' : 'off', + contextmenu: !readOnly, + ...(placeholder ? { placeholder } : {}), + ...(ariaLabel || label ? { ariaLabel: ariaLabel || label } : {}), + }); + }, [readOnly, showLineNumbers, placeholder, ariaLabel, label, ready]); + + // Sync language + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + const id = MONACO_LANGUAGE_IDS[language?.toLowerCase()] || 'plaintext'; + if (editor.getModel()?.getLanguageId() !== id) { + getMonaco().then((monaco) => monaco.editor.setModelLanguage(editor.getModel(), id)); + } + }, [language, ready]); + + return ( +
+ {label && ( +
+ {label} +
+ )} +
+ {!ready && !loadError && ( +
+ {placeholder || 'Loading editor...'} +
+ )} + {loadError && ( +