Skip to content
Open
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
9 changes: 9 additions & 0 deletions frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/e2e/codeConverter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', '');
});
Expand Down
12 changes: 9 additions & 3 deletions frontend/e2e/codeFormatter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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');
});
});
43 changes: 43 additions & 0 deletions frontend/e2e/helpers/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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'));
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
213 changes: 213 additions & 0 deletions frontend/src/components/inputs/MonacoCodeEditor.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={className}
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: '120px',
border: error ? '1px solid #ef4444' : '1px solid var(--border)',
backgroundColor: 'var(--background)',
borderRadius: '8px',
overflow: 'hidden',
position: 'relative',
...style,
}}
data-testid={dataTestId}
>
{label && (
<div
style={{
fontSize: '12px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--muted-foreground)',
padding: '8px 12px',
borderBottom: '1px solid var(--border)',
backgroundColor: 'var(--card)',
flexShrink: 0,
}}
>
{label}
</div>
)}
<div
ref={containerRef}
data-testid={dataTestId ? `${dataTestId}-content` : undefined}
aria-label={ariaLabel || label || placeholder || 'Code editor'}
style={{ flex: 1, position: 'relative', minHeight: 0 }}
/>
{!ready && !loadError && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'flex-start',
padding: '12px',
color: 'var(--muted-foreground)',
fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
fontSize: '14px',
pointerEvents: 'none',
}}
>
{placeholder || 'Loading editor...'}
</div>
)}
{loadError && (
<textarea
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
padding: '12px',
fontFamily: "'Menlo', 'Monaco', 'Courier New', monospace",
fontSize: '14px',
lineHeight: 1.5,
backgroundColor: 'transparent',
border: 'none',
color: 'var(--foreground)',
resize: 'none',
outline: 'none',
}}
/>
)}
</div>
);
}
Loading
Loading