These tests will run any time a request in this collection is sent.
- {
- {
- {
return (
These tests will run any time a request in this collection is sent.
- props.theme.codemirror.border};
+ background: ${(props) => props.theme.codemirror.bg};
+ }
+
+ /* Flush line numbers to the left edge like CodeMirror */
+ .monaco-editor .margin-view-overlays .line-numbers {
+ text-align: left !important;
+ padding-left: 3px !important;
+ }
+
+ /* Bruno variable highlighting decorations */
+ .bruno-variable-valid {
+ color: ${(props) => props.theme.codemirror.variable.valid} !important;
+ font-weight: 500;
+ }
+
+ .bruno-variable-invalid {
+ color: ${(props) => props.theme.codemirror.variable.invalid} !important;
+ font-weight: 500;
+ text-decoration: wavy underline ${(props) => props.theme.codemirror.variable.invalid};
+ text-underline-offset: 3px;
+ }
+
+ .bruno-variable-prompt {
+ color: ${(props) => props.theme.codemirror.variable.prompt} !important;
+ font-weight: 500;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/MonacoEditor/index.js b/packages/bruno-app/src/components/MonacoEditor/index.js
new file mode 100644
index 00000000000..3996cc5237d
--- /dev/null
+++ b/packages/bruno-app/src/components/MonacoEditor/index.js
@@ -0,0 +1,290 @@
+import React, { useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
+import { useDispatch } from 'react-redux';
+import 'utils/monaco/workers';
+import * as monaco from 'monaco-editor';
+import { mapCodeMirrorModeToMonaco } from 'utils/monaco/languageMapping';
+import { registerBrunoTheme, getCurrentThemeName } from 'utils/monaco/brunoTheme';
+import { registerBrunoApiTypes } from 'utils/monaco/brunoApiTypes';
+import { setupVariableHighlighting, setupVariableTooltip } from 'utils/monaco/variableHighlighting';
+import { setupAutoComplete } from 'utils/monaco/autocomplete';
+import { useTheme as useStyledTheme } from 'styled-components';
+import StyledWrapper from './StyledWrapper';
+
+const TAB_SIZE = 2;
+
+const MonacoEditor = forwardRef(({
+ value = '',
+ mode,
+ theme,
+ onEdit,
+ onRun,
+ onSave,
+ readOnly = false,
+ font,
+ fontSize,
+ enableLineWrapping = true,
+ enableVariableHighlighting = false,
+ onScroll,
+ initialScroll = 0,
+ collection,
+ item
+}, ref) => {
+ const containerRef = useRef(null);
+ const editorRef = useRef(null);
+ const cachedValueRef = useRef(value);
+ const onEditRef = useRef(onEdit);
+ const onRunRef = useRef(onRun);
+ const onSaveRef = useRef(onSave);
+ const onScrollRef = useRef(onScroll);
+ const collectionRef = useRef(collection);
+ const itemRef = useRef(item);
+ const variableCleanupRef = useRef(null);
+ const tooltipCleanupRef = useRef(null);
+ const autocompleteCleanupRef = useRef(null);
+
+ const styledTheme = useStyledTheme();
+ const dispatch = useDispatch();
+
+ // Expose a ref-compatible interface matching CodeEditor's class instance shape.
+ // Consumers call ref.current.editor.refresh() — Monaco's automaticLayout handles this,
+ // so layout() is called as a safe equivalent.
+ useImperativeHandle(ref, () => ({
+ editor: {
+ refresh: () => {
+ editorRef.current?.layout();
+ }
+ }
+ }));
+
+ // Keep callback refs up to date
+ useEffect(() => { onEditRef.current = onEdit; }, [onEdit]);
+ useEffect(() => { onRunRef.current = onRun; }, [onRun]);
+ useEffect(() => { onSaveRef.current = onSave; }, [onSave]);
+ useEffect(() => { onScrollRef.current = onScroll; }, [onScroll]);
+ useEffect(() => { collectionRef.current = collection; }, [collection]);
+ useEffect(() => { itemRef.current = item; }, [item]);
+
+ const language = mapCodeMirrorModeToMonaco(mode);
+
+ // Create editor on mount, dispose on unmount
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ // Register Bruno API types (idempotent, only runs once)
+ registerBrunoApiTypes();
+
+ const editor = monaco.editor.create(containerRef.current, {
+ value: value || '',
+ language,
+ // Use a stable base theme for creation; the theme-sync effect registers
+ // and applies the full Bruno theme before the browser paints.
+ theme: getCurrentThemeName(theme),
+ readOnly,
+ tabSize: TAB_SIZE,
+ automaticLayout: true,
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ wordWrap: enableLineWrapping ? 'on' : 'off',
+ fontFamily: font && font !== 'default' ? font : undefined,
+ fontSize: fontSize || 13,
+ lineNumbers: 'on',
+ lineNumbersMinChars: 1,
+ lineDecorationsWidth: 0,
+ glyphMargin: false,
+ renderLineHighlight: 'line',
+ matchBrackets: 'always',
+ autoClosingBrackets: 'always',
+ folding: true,
+ fixedOverflowWidgets: true,
+ quickSuggestions: {
+ other: true,
+ comments: false,
+ strings: true
+ },
+ scrollbar: {
+ verticalScrollbarSize: 8,
+ horizontalScrollbarSize: 8
+ }
+ });
+
+ editorRef.current = editor;
+
+ // Listen for content changes
+ const contentDisposable = editor.onDidChangeModelContent(() => {
+ const newValue = editor.getValue();
+ cachedValueRef.current = newValue;
+ if (onEditRef.current) {
+ onEditRef.current(newValue);
+ }
+ });
+
+ // Keybinding: Cmd/Ctrl+Enter → onRun
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
+ if (onRunRef.current) onRunRef.current();
+ });
+
+ // Keybinding: Cmd/Ctrl+S → onSave
+ editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
+ if (onSaveRef.current) onSaveRef.current();
+ });
+
+ // Add mousetrap class for global shortcut passthrough
+ const textarea = editor.getDomNode()?.querySelector('textarea');
+ if (textarea) {
+ textarea.classList.add('mousetrap');
+ }
+
+ // Setup variable highlighting and tooltip — gated by enableVariableHighlighting
+ if (collectionRef.current && enableVariableHighlighting) {
+ variableCleanupRef.current = setupVariableHighlighting(editor, collectionRef.current, itemRef.current);
+ tooltipCleanupRef.current = setupVariableTooltip(
+ editor,
+ () => collectionRef.current,
+ () => itemRef.current,
+ dispatch
+ );
+ }
+
+ // Setup variable autocomplete ({{variable}} suggestions)
+ // API hints (req, res, bru) are handled by Monaco's built-in IntelliSense via brunoApiTypes
+ if (collectionRef.current) {
+ autocompleteCleanupRef.current = setupAutoComplete(
+ editor,
+ () => collectionRef.current,
+ () => itemRef.current
+ );
+ }
+
+ // Apply initial scroll position
+ if (initialScroll > 0) {
+ editor.setScrollTop(initialScroll);
+ }
+
+ return () => {
+ // Save scroll position before disposing
+ if (onScrollRef.current && editor) {
+ onScrollRef.current({
+ doc: { scrollTop: editor.getScrollTop() }
+ });
+ }
+ // Clean up variable highlighting, tooltip, and autocomplete
+ variableCleanupRef.current?.();
+ tooltipCleanupRef.current?.();
+ autocompleteCleanupRef.current?.();
+ contentDisposable.dispose();
+ editor.dispose();
+ editorRef.current = null;
+ };
+ // Only run on mount/unmount
+ }, []);
+
+ // Re-apply variable highlighting and tooltip when collection/item/flag changes
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor || !collection) return;
+
+ if (enableVariableHighlighting) {
+ // Clean up and re-setup highlighting with updated variables
+ variableCleanupRef.current?.();
+ variableCleanupRef.current = setupVariableHighlighting(editor, collection, item);
+
+ // Setup tooltip if not already active
+ if (!tooltipCleanupRef.current) {
+ tooltipCleanupRef.current = setupVariableTooltip(
+ editor,
+ () => collectionRef.current,
+ () => itemRef.current,
+ dispatch
+ );
+ }
+ } else {
+ // Tear down when disabled
+ variableCleanupRef.current?.();
+ variableCleanupRef.current = null;
+ tooltipCleanupRef.current?.();
+ tooltipCleanupRef.current = null;
+ }
+ }, [collection, item, enableVariableHighlighting]);
+
+ // Sync external value changes without resetting scroll, selections, or undo history
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ if (value !== cachedValueRef.current) {
+ cachedValueRef.current = value;
+ const model = editor.getModel();
+ if (!model) return;
+
+ const selections = editor.getSelections();
+ const scrollTop = editor.getScrollTop();
+ const fullRange = model.getFullModelRange();
+
+ model.pushEditOperations(
+ selections,
+ [{ range: fullRange, text: value || '' }],
+ () => selections
+ );
+
+ editor.setScrollTop(scrollTop);
+ }
+ }, [value]);
+
+ // Sync theme — re-register Bruno theme when it changes
+ useEffect(() => {
+ const themeName = registerBrunoTheme(styledTheme, theme);
+ monaco.editor.setTheme(themeName);
+ }, [theme, styledTheme]);
+
+ // Sync language
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (!editor) return;
+ const model = editor.getModel();
+ if (model) {
+ monaco.editor.setModelLanguage(model, language);
+ }
+ }, [language]);
+
+ // Sync readOnly
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (editor) {
+ editor.updateOptions({ readOnly });
+ }
+ }, [readOnly]);
+
+ // Sync word wrap
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (editor) {
+ editor.updateOptions({ wordWrap: enableLineWrapping ? 'on' : 'off' });
+ }
+ }, [enableLineWrapping]);
+
+ // Sync font settings
+ useEffect(() => {
+ const editor = editorRef.current;
+ if (editor) {
+ editor.updateOptions({
+ fontFamily: font && font !== 'default' ? font : undefined,
+ fontSize: fontSize || 13
+ });
+ }
+ }, [font, fontSize]);
+
+ return (
+
+
+
+ );
+});
+
+export default MonacoEditor;
diff --git a/packages/bruno-app/src/components/MonacoEditor/index.spec.js b/packages/bruno-app/src/components/MonacoEditor/index.spec.js
new file mode 100644
index 00000000000..99d49ba829d
--- /dev/null
+++ b/packages/bruno-app/src/components/MonacoEditor/index.spec.js
@@ -0,0 +1,276 @@
+import React from 'react';
+import { render, act } from '@testing-library/react';
+import { ThemeProvider } from 'styled-components';
+
+const mockDispatch = jest.fn();
+jest.mock('react-redux', () => ({
+ useDispatch: () => mockDispatch
+}));
+
+const mockDispose = jest.fn();
+const mockGetValue = jest.fn(() => '');
+const mockSetValue = jest.fn();
+const mockGetPosition = jest.fn(() => ({ lineNumber: 1, column: 1 }));
+const mockSetPosition = jest.fn();
+const mockSetScrollTop = jest.fn();
+const mockGetScrollTop = jest.fn(() => 0);
+const mockUpdateOptions = jest.fn();
+const mockAddCommand = jest.fn();
+const mockOnDidChangeModelContent = jest.fn(() => ({ dispose: jest.fn() }));
+const mockClassListAdd = jest.fn();
+const mockGetDomNode = jest.fn(() => ({
+ querySelector: jest.fn(() => ({
+ classList: { add: mockClassListAdd }
+ }))
+}));
+const mockGetModel = jest.fn(() => ({
+ getValue: jest.fn(() => ''),
+ getPositionAt: jest.fn(() => ({ lineNumber: 1, column: 1 })),
+ getFullModelRange: jest.fn(() => ({ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 })),
+ pushEditOperations: jest.fn()
+}));
+const mockCreateDecorationsCollection = jest.fn(() => ({
+ set: jest.fn(),
+ clear: jest.fn()
+}));
+const mockLayout = jest.fn();
+
+const mockGetSelections = jest.fn(() => [{ startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }]);
+
+const mockEditor = {
+ dispose: mockDispose,
+ getValue: mockGetValue,
+ setValue: mockSetValue,
+ getPosition: mockGetPosition,
+ setPosition: mockSetPosition,
+ setScrollTop: mockSetScrollTop,
+ getScrollTop: mockGetScrollTop,
+ getSelections: mockGetSelections,
+ updateOptions: mockUpdateOptions,
+ addCommand: mockAddCommand,
+ onDidChangeModelContent: mockOnDidChangeModelContent,
+ getDomNode: mockGetDomNode,
+ getModel: mockGetModel,
+ createDecorationsCollection: mockCreateDecorationsCollection,
+ layout: mockLayout
+};
+
+jest.mock('utils/monaco/workers', () => {});
+
+jest.mock('monaco-editor', () => ({
+ editor: {
+ create: jest.fn(() => mockEditor),
+ setTheme: jest.fn(),
+ setModelLanguage: jest.fn(),
+ defineTheme: jest.fn()
+ },
+ languages: {
+ typescript: {
+ javascriptDefaults: {
+ addExtraLib: jest.fn(),
+ setDiagnosticsOptions: jest.fn(),
+ setCompilerOptions: jest.fn()
+ },
+ ScriptTarget: { ES2020: 7 }
+ },
+ registerHoverProvider: jest.fn(() => ({ dispose: jest.fn() }))
+ },
+ KeyMod: { CtrlCmd: 2048 },
+ KeyCode: { Enter: 3, KeyS: 49 },
+ Range: jest.fn()
+}));
+
+jest.mock('utils/monaco/brunoTheme', () => ({
+ registerBrunoTheme: jest.fn(() => 'bruno-light'),
+ getCurrentThemeName: jest.fn(() => 'vs')
+}));
+
+jest.mock('utils/monaco/brunoApiTypes', () => ({
+ registerBrunoApiTypes: jest.fn()
+}));
+
+jest.mock('utils/monaco/variableHighlighting', () => ({
+ setupVariableHighlighting: jest.fn(() => jest.fn()),
+ setupVariableTooltip: jest.fn(() => jest.fn())
+}));
+
+jest.mock('utils/monaco/autocomplete', () => ({
+ setupAutoComplete: jest.fn(() => jest.fn())
+}));
+
+const MOCK_THEME = {
+ codemirror: {
+ bg: '#1e1e1e',
+ border: '#333',
+ variable: {
+ valid: '#247c2f',
+ invalid: '#b82e28',
+ prompt: '#0078d4'
+ },
+ tokens: {
+ definition: '#0078d4',
+ property: '#0078d4',
+ string: '#6f402f',
+ number: '#d63384',
+ atom: '#a6142a',
+ variable: '#d63384',
+ keyword: '#a6142a',
+ comment: '#808080',
+ operator: '#838383',
+ tag: '#a6142a',
+ tagBracket: '#838383'
+ }
+ },
+ textLink: '#007acc'
+};
+
+// Import after mocks are set up
+const MonacoEditor = require('./index').default;
+const monaco = require('monaco-editor');
+const { registerBrunoTheme } = require('utils/monaco/brunoTheme');
+const { setupVariableHighlighting, setupVariableTooltip } = require('utils/monaco/variableHighlighting');
+const { setupAutoComplete } = require('utils/monaco/autocomplete');
+
+describe('MonacoEditor', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetValue.mockReturnValue('');
+ });
+
+ const renderEditor = (props = {}) => {
+ return render(
+
+
+
+ );
+ };
+
+ it('renders without crashing', () => {
+ const { container } = renderEditor();
+ expect(container.querySelector('.monaco-editor-container')).toBeTruthy();
+ });
+
+ it('creates a Monaco editor instance on mount', () => {
+ renderEditor({ value: 'const x = 1;' });
+ expect(monaco.editor.create).toHaveBeenCalledTimes(1);
+ expect(monaco.editor.create).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({
+ value: 'const x = 1;'
+ })
+ );
+ });
+
+ it('registers Bruno theme on mount', () => {
+ renderEditor({ theme: 'dark' });
+ expect(registerBrunoTheme).toHaveBeenCalledWith(MOCK_THEME, 'dark');
+ });
+
+ it('uses the correct language for javascript mode', () => {
+ renderEditor({ mode: 'application/javascript' });
+ expect(monaco.editor.create).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({
+ language: 'javascript'
+ })
+ );
+ });
+
+ it('applies readOnly option', () => {
+ renderEditor({ readOnly: true });
+ expect(monaco.editor.create).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({
+ readOnly: true
+ })
+ );
+ });
+
+ it('applies font and fontSize', () => {
+ renderEditor({ font: 'Fira Code', fontSize: 16 });
+ expect(monaco.editor.create).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({
+ fontFamily: 'Fira Code',
+ fontSize: 16
+ })
+ );
+ });
+
+ it('applies word wrap settings', () => {
+ renderEditor({ enableLineWrapping: true });
+ expect(monaco.editor.create).toHaveBeenCalledWith(
+ expect.any(HTMLElement),
+ expect.objectContaining({
+ wordWrap: 'on'
+ })
+ );
+ });
+
+ it('registers keybindings for onRun and onSave', () => {
+ renderEditor({ onRun: jest.fn(), onSave: jest.fn() });
+ expect(mockAddCommand).toHaveBeenCalledTimes(2);
+ });
+
+ it('disposes editor on unmount', () => {
+ const { unmount } = renderEditor();
+ act(() => {
+ unmount();
+ });
+ expect(mockDispose).toHaveBeenCalledTimes(1);
+ });
+
+ it('sets up variable highlighting and tooltip when enableVariableHighlighting is true', () => {
+ const collection = { uid: 'col-1' };
+ const item = { uid: 'item-1' };
+ renderEditor({ collection, item, enableVariableHighlighting: true });
+ expect(setupVariableHighlighting).toHaveBeenCalledWith(mockEditor, collection, item);
+ expect(setupVariableTooltip).toHaveBeenCalled();
+ });
+
+ it('does not set up highlighting or tooltip when enableVariableHighlighting is not set', () => {
+ const collection = { uid: 'col-1' };
+ const item = { uid: 'item-1' };
+ renderEditor({ collection, item });
+ expect(setupVariableHighlighting).not.toHaveBeenCalled();
+ expect(setupVariableTooltip).not.toHaveBeenCalled();
+ });
+
+ it('does not set up highlighting or tooltip when collection is not provided', () => {
+ renderEditor({});
+ expect(setupVariableHighlighting).not.toHaveBeenCalled();
+ expect(setupVariableTooltip).not.toHaveBeenCalled();
+ });
+
+ it('sets up autocomplete when collection is provided', () => {
+ const collection = { uid: 'col-1' };
+ const item = { uid: 'item-1' };
+ renderEditor({ collection, item });
+ expect(setupAutoComplete).toHaveBeenCalledWith(
+ mockEditor,
+ expect.any(Function),
+ expect.any(Function)
+ );
+ });
+
+ it('does not set up autocomplete when collection is not provided', () => {
+ renderEditor({});
+ expect(setupAutoComplete).not.toHaveBeenCalled();
+ });
+
+ it('adds mousetrap class to textarea', () => {
+ renderEditor();
+ expect(mockGetDomNode).toHaveBeenCalled();
+ expect(mockClassListAdd).toHaveBeenCalledWith('mousetrap');
+ });
+
+ it('applies initial scroll position', () => {
+ renderEditor({ initialScroll: 100 });
+ expect(mockSetScrollTop).toHaveBeenCalledWith(100);
+ });
+
+ it('does not apply scroll when initialScroll is 0', () => {
+ renderEditor({ initialScroll: 0 });
+ expect(mockSetScrollTop).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/bruno-app/src/components/Preferences/Beta/index.js b/packages/bruno-app/src/components/Preferences/Beta/index.js
index 8a4ea4cf250..174fc4e175d 100644
--- a/packages/bruno-app/src/components/Preferences/Beta/index.js
+++ b/packages/bruno-app/src/components/Preferences/Beta/index.js
@@ -19,6 +19,11 @@ const BETA_FEATURES = [
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
label: 'OpenAPI Sync',
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
+ },
+ {
+ id: BETA_FEATURE_IDS.MONACO_EDITOR,
+ label: 'Monaco Editor (Beta)',
+ description: 'Use Monaco Editor (VS Code editor) for scripting. Provides multi-cursor editing, better autocomplete, and other VS Code features for pre-request, post-response, and test scripts.'
}
];
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
index 39f47844281..33f4e8f62d9 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
@@ -1,7 +1,7 @@
import React from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
-import CodeEditor from 'components/CodeEditor';
+import ScriptEditor from 'components/ScriptEditor';
import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams';
import MultipartFormParams from 'components/RequestPane/MultipartFormParams';
import { useDispatch, useSelector } from 'react-redux';
@@ -61,7 +61,7 @@ const RequestBody = ({ item, collection }) => {
return (
- {
- {
- {
return (
-
import('components/MonacoEditor'));
+
+const EditorLoadingFallback = () => (
+
+ Loading editor...
+
+);
+
+const ScriptEditor = forwardRef((props, ref) => {
+ const useMonaco = useBetaFeature(BETA_FEATURES.MONACO_EDITOR);
+
+ if (!useMonaco) {
+ return ;
+ }
+
+ return (
+ }>
+
+
+ );
+});
+
+ScriptEditor.displayName = 'ScriptEditor';
+
+export default ScriptEditor;
diff --git a/packages/bruno-app/src/components/ScriptEditor/index.spec.js b/packages/bruno-app/src/components/ScriptEditor/index.spec.js
new file mode 100644
index 00000000000..bd4656a40c1
--- /dev/null
+++ b/packages/bruno-app/src/components/ScriptEditor/index.spec.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { ThemeProvider } from 'styled-components';
+
+const mockCodeEditor = jest.fn((props) => {
+ return ;
+});
+
+jest.mock('components/CodeEditor', () => mockCodeEditor);
+
+jest.mock('components/MonacoEditor', () => ({
+ __esModule: true,
+ default: function MockMonacoEditor(props) {
+ return ;
+ }
+}));
+
+const MOCK_THEME = {
+ codemirror: {
+ bg: '#1e1e1e',
+ border: '#333'
+ }
+};
+
+const createMockStore = (monacoEnabled = false) => {
+ return configureStore({
+ reducer: {
+ app: (state = {
+ preferences: {
+ beta: {
+ 'monaco-editor': monacoEnabled
+ }
+ }
+ }) => state
+ }
+ });
+};
+
+const ScriptEditor = require('./index').default;
+
+describe('ScriptEditor', () => {
+ it('renders CodeEditor when Monaco beta feature is disabled', () => {
+ const store = createMockStore(false);
+ const { getByTestId, queryByTestId } = render(
+
+
+
+
+
+ );
+
+ expect(getByTestId('codemirror-editor')).toBeTruthy();
+ expect(queryByTestId('monaco-editor')).toBeNull();
+ });
+
+ it('renders MonacoEditor when Monaco beta feature is enabled', async () => {
+ const store = createMockStore(true);
+ const { findByTestId, queryByTestId } = render(
+
+
+
+
+
+ );
+
+ expect(await findByTestId('monaco-editor')).toBeTruthy();
+ expect(queryByTestId('codemirror-editor')).toBeNull();
+ });
+
+ it('passes props through to the selected editor', () => {
+ const store = createMockStore(false);
+ const mockOnEdit = jest.fn();
+ mockCodeEditor.mockClear();
+
+ render(
+
+
+
+
+
+ );
+
+ expect(mockCodeEditor).toHaveBeenCalledWith(
+ expect.objectContaining({ mode: 'javascript', onEdit: mockOnEdit }),
+ undefined
+ );
+ });
+});
diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js
index 84e7e35b8ea..2bc803f6c03 100644
--- a/packages/bruno-app/src/globalStyles.js
+++ b/packages/bruno-app/src/globalStyles.js
@@ -549,6 +549,24 @@ const GlobalStyle = createGlobalStyle`
line-height: 1.25rem;
}
+ /* Monaco tooltip textarea editor (used instead of CodeMirror inline editor) */
+ .CodeMirror-brunoVarInfo .var-value-editor-textarea {
+ display: none;
+ width: 100%;
+ min-height: 1.75rem;
+ max-height: 11.125rem;
+ resize: vertical;
+ box-sizing: border-box;
+ padding: 0.375rem 0.5rem;
+ border: none;
+ outline: none;
+ background: inherit;
+ color: inherit;
+ font-family: Inter, sans-serif;
+ font-size: inherit;
+ line-height: 1.25rem;
+ }
+
// Active/selected hint - using theme colors instead of hardcoded blue
.CodeMirror-hint-active {
background: ${(props) => props.theme.dropdown.hoverBg} !important;
diff --git a/packages/bruno-app/src/utils/beta-features.js b/packages/bruno-app/src/utils/beta-features.js
index 84f9eb8551e..14000268f0c 100644
--- a/packages/bruno-app/src/utils/beta-features.js
+++ b/packages/bruno-app/src/utils/beta-features.js
@@ -6,7 +6,8 @@ import { useSelector } from 'react-redux';
*/
export const BETA_FEATURES = Object.freeze({
NODE_VM: 'nodevm',
- OPENAPI_SYNC: 'openapi-sync'
+ OPENAPI_SYNC: 'openapi-sync',
+ MONACO_EDITOR: 'monaco-editor'
});
/**
diff --git a/packages/bruno-app/src/utils/monaco/autocomplete.js b/packages/bruno-app/src/utils/monaco/autocomplete.js
new file mode 100644
index 00000000000..5ee8bf1d47b
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/autocomplete.js
@@ -0,0 +1,218 @@
+import * as monaco from 'monaco-editor';
+import { mockDataFunctions } from '@usebruno/common';
+import { getAllVariables } from 'utils/collections';
+
+const MOCK_DATA_HINTS = Object.keys(mockDataFunctions).map((key) => `$${key}`);
+
+const VARIABLE_PATTERN = /\{\{([\w$.-]*)$/;
+
+// ─── Variable Hints ─────────────────────────────────────────
+
+const shouldSkipVariableKey = (key) => {
+ return key === 'pathParams' || key === 'maskedEnvVariables' || key === 'process';
+};
+
+const transformVariablesToHints = (allVariables = {}) => {
+ const hints = [];
+ Object.keys(allVariables).forEach((key) => {
+ if (!shouldSkipVariableKey(key)) {
+ hints.push(key);
+ }
+ });
+ if (allVariables.process && allVariables.process.env) {
+ Object.keys(allVariables.process.env).forEach((key) => {
+ hints.push(`process.env.${key}`);
+ });
+ }
+ return hints;
+};
+
+const generateProgressiveHints = (fullHint) => {
+ const parts = fullHint.split('.');
+ const progressiveHints = [];
+ for (let i = 1; i <= parts.length; i++) {
+ progressiveHints.push(parts.slice(0, i).join('.'));
+ }
+ return progressiveHints;
+};
+
+// ─── Segment Extraction (matches CodeMirror logic) ──────────
+
+const extractNextSegmentSuggestions = (filteredHints, currentInput) => {
+ const prefixMatches = new Set();
+ const substringMatches = new Set();
+ const lowerInput = currentInput.toLowerCase();
+
+ filteredHints.forEach((hint) => {
+ const lowerHint = hint.toLowerCase();
+
+ if (lowerHint.startsWith(lowerInput)) {
+ if (lowerHint === lowerInput) {
+ prefixMatches.add(hint.substring(hint.lastIndexOf('.') + 1));
+ return;
+ }
+
+ const inputLength = currentInput.length;
+
+ if (currentInput.endsWith('.')) {
+ const afterDot = hint.substring(inputLength);
+ const nextDot = afterDot.indexOf('.');
+ const segment = nextDot === -1 ? afterDot : afterDot.substring(0, nextDot);
+ prefixMatches.add(segment);
+ } else {
+ const lastDotInInput = currentInput.lastIndexOf('.');
+ const currentSegmentStart = lastDotInInput + 1;
+ const nextDotAfterInput = hint.indexOf('.', currentSegmentStart);
+ const segment = nextDotAfterInput === -1
+ ? hint.substring(currentSegmentStart)
+ : hint.substring(currentSegmentStart, nextDotAfterInput);
+ prefixMatches.add(segment);
+ }
+ } else if (lowerHint.includes(lowerInput)) {
+ substringMatches.add(hint);
+ }
+ });
+
+ return [...Array.from(prefixMatches).sort(), ...Array.from(substringMatches).sort()];
+};
+
+// ─── Build Hints ────────────────────────────────────────────
+
+const buildVariableHints = (allVariables) => {
+ const hints = new Set();
+ MOCK_DATA_HINTS.forEach((h) => generateProgressiveHints(h).forEach((p) => hints.add(p)));
+ transformVariablesToHints(allVariables).forEach((h) => generateProgressiveHints(h).forEach((p) => hints.add(p)));
+ return Array.from(hints).sort();
+};
+
+// ─── Singleton Completion Provider ──────────────────────────
+// Providers are registered once globally per language. Each editor
+// registers/unregisters itself in the registry so the provider can
+// resolve the correct collection and item for the active model.
+
+const editorRegistry = new Map();
+let providersRegistered = false;
+
+const SUPPORTED_LANGUAGES = ['javascript', 'json', 'xml', 'html', 'yaml', 'plaintext', 'markdown', 'shell'];
+
+const completionProvider = {
+ triggerCharacters: ['{', '.', '$'],
+ provideCompletionItems(model, position) {
+ const entry = editorRegistry.get(model.uri.toString());
+ if (!entry) {
+ return { suggestions: [] };
+ }
+
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column
+ });
+
+ const variableMatch = textUntilPosition.match(VARIABLE_PATTERN);
+ if (!variableMatch) {
+ return { suggestions: [] };
+ }
+
+ const typed = variableMatch[1]; // text after {{
+ // Convert 0-indexed string position to 1-indexed Monaco column
+ const braceStartCol = textUntilPosition.lastIndexOf('{{') + 2 + 1;
+
+ const collection = entry.getCollection();
+ const item = entry.getItem();
+ const allVariables = getAllVariables(collection, item);
+ const { pathParams, maskedEnvVariables, ...varLookup } = allVariables;
+
+ const allHints = buildVariableHints(varLookup);
+ const filtered = allHints.filter((h) => h.toLowerCase().includes(typed.toLowerCase()));
+ const segments = extractNextSegmentSuggestions(filtered, typed).slice(0, 50);
+
+ // Determine replacement range — replace from last dot or from start of typed text
+ let replaceStartCol;
+ if (typed.endsWith('.')) {
+ replaceStartCol = position.column;
+ } else {
+ const lastDot = typed.lastIndexOf('.');
+ replaceStartCol = lastDot !== -1
+ ? braceStartCol + lastDot + 1
+ : braceStartCol;
+ }
+
+ const range = new monaco.Range(
+ position.lineNumber,
+ replaceStartCol,
+ position.lineNumber,
+ position.column
+ );
+
+ return {
+ // incomplete: true tells Monaco to re-invoke the provider on each keystroke,
+ // so suggestions update as the user types inside {{...}}
+ incomplete: true,
+ suggestions: segments.map((segment, i) => ({
+ label: segment,
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: segment,
+ range,
+ sortText: String(i).padStart(4, '0')
+ }))
+ };
+ }
+};
+
+const ensureProvidersRegistered = () => {
+ if (providersRegistered) return;
+ providersRegistered = true;
+ SUPPORTED_LANGUAGES.forEach((lang) =>
+ monaco.languages.registerCompletionItemProvider(lang, completionProvider)
+ );
+};
+
+/**
+ * Sets up variable autocomplete for the Monaco editor.
+ * Providers are registered once globally (singleton); each editor instance
+ * registers itself in a model-keyed registry so the shared provider can
+ * resolve the correct collection/item context.
+ *
+ * Returns a dispose function.
+ */
+export const setupAutoComplete = (editor, collectionRef, itemRef) => {
+ const getCollection = () => (typeof collectionRef === 'function' ? collectionRef() : collectionRef);
+ const getItem = () => (typeof itemRef === 'function' ? itemRef() : itemRef);
+
+ const modelUri = editor.getModel()?.uri.toString();
+ if (modelUri) {
+ editorRegistry.set(modelUri, { getCollection, getItem });
+ }
+
+ ensureProvidersRegistered();
+
+ // Auto-trigger suggestions when typing inside {{...}}
+ // This ensures the suggest widget opens regardless of language-specific quickSuggestions settings
+ const contentChangeDisposable = editor.onDidChangeModelContent(() => {
+ const position = editor.getPosition();
+ if (!position) return;
+
+ const model = editor.getModel();
+ if (!model) return;
+
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column
+ });
+
+ if (VARIABLE_PATTERN.test(textUntilPosition)) {
+ editor.trigger('bruno-autocomplete', 'editor.action.triggerSuggest', {});
+ }
+ });
+
+ return () => {
+ if (modelUri) {
+ editorRegistry.delete(modelUri);
+ }
+ contentChangeDisposable.dispose();
+ };
+};
diff --git a/packages/bruno-app/src/utils/monaco/brunoApiTypes.js b/packages/bruno-app/src/utils/monaco/brunoApiTypes.js
new file mode 100644
index 00000000000..f291edf846c
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/brunoApiTypes.js
@@ -0,0 +1,661 @@
+import * as monaco from 'monaco-editor';
+
+/**
+ * Registers the Bruno scripting API type definitions with Monaco's
+ * JavaScript language service. Enables autocomplete and hover docs
+ * for bru, req, and res objects.
+ *
+ * Type definitions are derived from the source implementations in
+ * packages/bruno-js/src/ (bru.js, bruno-request.js, bruno-response.js).
+ *
+ * Idempotent — addExtraLib with the same filename replaces the previous
+ * registration, and setDiagnosticsOptions/setCompilerOptions are safe to
+ * call multiple times.
+ *
+ * Must be called after Monaco is loaded (not at module top level).
+ */
+let typesRegistered = false;
+
+export const registerBrunoApiTypes = () => {
+ if (typesRegistered) return;
+ typesRegistered = true;
+
+ const typeDefs = `
+/**
+ * Bruno Request object — available in pre-request and post-response scripts.
+ *
+ * Shorthand properties (url, method, headers, body, timeout) are read-only.
+ * Use the setter methods to modify request properties.
+ *
+ * @see https://docs.usebruno.com/scripting/request
+ */
+declare const req: BrunoRequest;
+
+/**
+ * Bruno Response object — available in post-response scripts and tests.
+ *
+ * The response object is also callable as a function for query operations:
+ * \`res('data.users[0].name')\`
+ *
+ * @see https://docs.usebruno.com/scripting/response
+ */
+declare const res: BrunoResponse;
+
+/**
+ * Bruno Runtime API — available in all scripts (pre-request, post-response, tests).
+ *
+ * Provides access to variables, environment, cookies, and utility functions.
+ *
+ * @see https://docs.usebruno.com/scripting/bru
+ */
+declare const bru: BrunoRuntime;
+
+/**
+ * Chai-compatible expect assertion function.
+ * @example
+ * expect(res.getStatus()).to.equal(200);
+ * expect(res.getBody().name).to.be.a('string');
+ */
+declare const expect: any;
+
+/**
+ * Define a test case.
+ * @param name - The name of the test
+ * @param fn - The test function (can be async)
+ * @example
+ * test('should return 200', () => {
+ * expect(res.getStatus()).to.equal(200);
+ * });
+ */
+declare function test(name: string, fn: () => void | Promise): void;
+
+interface BrunoRequest {
+ /** The request URL (read-only shorthand — use setUrl() to modify) */
+ readonly url: string;
+ /** The HTTP method (read-only shorthand — use setMethod() to modify) */
+ readonly method: string;
+ /** The request headers (read-only shorthand — use setHeader()/setHeaders() to modify) */
+ readonly headers: Record;
+ /**
+ * The request body. Automatically parsed to an object if Content-Type is JSON.
+ * (read-only shorthand — use setBody() to modify)
+ */
+ readonly body: any;
+ /** The request timeout in ms (read-only shorthand — use setTimeout() to modify) */
+ readonly timeout: number;
+ /** The request name */
+ readonly name: string;
+ /** Path parameters as key-value pairs */
+ readonly pathParams: Record;
+ /** Tags associated with this request */
+ readonly tags: string[];
+
+ /** Get the full request URL */
+ getUrl(): string;
+
+ /**
+ * Set the request URL.
+ * @param url - The new URL
+ * @example req.setUrl('https://api.example.com/users');
+ */
+ setUrl(url: string): void;
+
+ /**
+ * Get the hostname from the URL.
+ * @example req.getHost() // 'api.example.com'
+ */
+ getHost(): string;
+
+ /**
+ * Get the path from the URL. Path parameters are interpolated.
+ * @example req.getPath() // '/api/users/123'
+ */
+ getPath(): string;
+
+ /**
+ * Get the query string from the URL (without the leading '?').
+ * @example req.getQueryString() // 'page=1&limit=10'
+ */
+ getQueryString(): string;
+
+ /** Get the HTTP method (GET, POST, PUT, etc.) */
+ getMethod(): string;
+
+ /**
+ * Set the HTTP method.
+ * @param method - The HTTP method (GET, POST, PUT, DELETE, PATCH, etc.)
+ */
+ setMethod(method: string): void;
+
+ /**
+ * Get the authentication mode used for this request.
+ * @returns One of: 'oauth2', 'oauth1', 'bearer', 'basic', 'awsv4', 'digest', 'wsse', 'none'
+ */
+ getAuthMode(): 'oauth2' | 'oauth1' | 'bearer' | 'basic' | 'awsv4' | 'digest' | 'wsse' | 'none';
+
+ /**
+ * Get a specific header value by name.
+ * @param name - The header name (case-sensitive)
+ */
+ getHeader(name: string): string | undefined;
+
+ /** Get all request headers as a key-value object */
+ getHeaders(): Record;
+
+ /**
+ * Set a single request header.
+ * @param name - The header name
+ * @param value - The header value
+ * @example req.setHeader('Authorization', 'Bearer token123');
+ */
+ setHeader(name: string, value: string): void;
+
+ /**
+ * Replace all request headers.
+ * @param headers - Object containing all headers
+ */
+ setHeaders(headers: Record): void;
+
+ /**
+ * Delete a single request header.
+ * Also prevents default headers (like User-Agent) from being re-added.
+ * @param name - The header name to delete
+ */
+ deleteHeader(name: string): void;
+
+ /**
+ * Delete multiple request headers.
+ * @param names - Array of header names to delete
+ * @example req.deleteHeaders(['X-Custom-Header', 'Authorization']);
+ */
+ deleteHeaders(names: string[]): void;
+
+ /**
+ * Get the request body.
+ * JSON bodies are automatically parsed to objects.
+ * Pass \`{ raw: true }\` to get the raw string body.
+ * @example
+ * req.getBody() // { name: 'John' } (parsed)
+ * req.getBody({ raw: true }) // '{"name":"John"}' (raw string)
+ */
+ getBody(options?: { raw?: boolean }): any;
+
+ /**
+ * Set the request body.
+ * For JSON content types, objects are automatically stringified.
+ * Pass \`{ raw: true }\` to set the body as-is without processing.
+ * @example
+ * req.setBody({ name: 'John' });
+ * req.setBody('raw string data', { raw: true });
+ */
+ setBody(data: any, options?: { raw?: boolean }): void;
+
+ /**
+ * Set the maximum number of redirects to follow.
+ * @param maxRedirects - Maximum redirects (0 to disable)
+ */
+ setMaxRedirects(maxRedirects: number): void;
+
+ /** Get the request timeout in milliseconds */
+ getTimeout(): number;
+
+ /**
+ * Set the request timeout.
+ * @param timeout - Timeout in milliseconds
+ */
+ setTimeout(timeout: number): void;
+
+ /**
+ * Get the execution mode of the request.
+ * @returns The execution mode string (e.g., 'standalone' or 'runner')
+ */
+ getExecutionMode(): string;
+
+ /** Get the request name as defined in the collection */
+ getName(): string;
+
+ /**
+ * Get the path parameters for this request.
+ * @returns Array of path parameter objects with name, value, and type
+ */
+ getPathParams(): Array<{ name: string; value: string; type: string }>;
+
+ /**
+ * Get the tags associated with this request.
+ * @returns Array of tag strings
+ */
+ getTags(): string[];
+
+ /**
+ * Disable automatic JSON parsing of the response body.
+ * Useful when you need the raw response string.
+ */
+ disableParsingResponseJson(): void;
+
+ /**
+ * Register an error handler that runs if the request fails.
+ * @param callback - Function called with the error object
+ * @example
+ * req.onFail((err) => {
+ * console.log('Request failed:', err.message);
+ * });
+ */
+ onFail(callback: (err: Error) => void): void;
+}
+
+interface BrunoResponse {
+ /** The HTTP status code (e.g., 200, 404) */
+ readonly status: number;
+ /** The HTTP status text (e.g., 'OK', 'Not Found') */
+ readonly statusText: string;
+ /** The response headers */
+ readonly headers: Record;
+ /** The response body (automatically parsed if JSON) */
+ readonly body: any;
+ /** The response time in milliseconds */
+ readonly responseTime: number;
+ /** The final URL after redirects */
+ readonly url: string;
+
+ /** Get the HTTP status code */
+ getStatus(): number;
+
+ /** Get the HTTP status text */
+ getStatusText(): string;
+
+ /**
+ * Get a specific response header value.
+ * @param name - The header name
+ */
+ getHeader(name: string): string | undefined;
+
+ /** Get all response headers */
+ getHeaders(): Record;
+
+ /** Get the response body */
+ getBody(): any;
+
+ /**
+ * Replace the response body. Also updates the underlying data buffer.
+ * @param data - The new body data
+ */
+ setBody(data: any): void;
+
+ /** Get the response time in milliseconds */
+ getResponseTime(): number;
+
+ /**
+ * Get the response size breakdown in bytes.
+ * @returns Object with header, body, and total sizes
+ * @example
+ * const size = res.getSize();
+ * console.log(size.body); // body size in bytes
+ * console.log(size.total); // total size in bytes
+ */
+ getSize(): { header: number; body: number; total: number };
+
+ /** Get the final URL after redirects */
+ getUrl(): string;
+
+ /**
+ * Get the raw response data buffer.
+ * @returns The underlying Buffer object
+ */
+ getDataBuffer(): any;
+}
+
+interface BrunoCookieJar {
+ getCookie(url: string, name: string, callback?: Function): any;
+ getCookies(url: string, callback?: Function): any[];
+ setCookie(rawCookie: string, url: string, callback?: Function): void;
+ setCookies(cookies: string[]): void;
+ clear(): void;
+ deleteCookies(url: string): void;
+ deleteCookie(url: string, name: string): void;
+ hasCookie(url: string, name: string): boolean;
+}
+
+interface BrunoCookies {
+ /** Get a cookie value by name */
+ get(name: string): string | undefined;
+ /** Check if a cookie exists */
+ has(name: string): boolean;
+ /** Get the first cookie */
+ one(): any;
+ /** Get all cookies as an array */
+ all(): any[];
+ /** Get the number of cookies */
+ count(): number;
+ /** Get a cookie by index */
+ idx(index: number): any;
+ /** Find the index of a cookie by name */
+ indexOf(name: string): number;
+ /** Find a cookie matching a predicate */
+ find(predicate: (cookie: any) => boolean): any;
+ /** Filter cookies matching a predicate */
+ filter(predicate: (cookie: any) => boolean): any[];
+ /** Iterate over each cookie */
+ each(callback: (cookie: any) => void): void;
+ /** Map cookies to a new array */
+ map(callback: (cookie: any) => T): T[];
+ /** Reduce cookies to a single value */
+ reduce(callback: (acc: T, cookie: any) => T, initial: T): T;
+ /** Convert all cookies to a key-value object */
+ toObject(): Record;
+ /** Convert all cookies to a string */
+ toString(): string;
+ /** Add a new cookie */
+ add(name: string, value: string): void;
+ /** Add or update a cookie */
+ upsert(name: string, value: string): void;
+ /** Remove a cookie by name */
+ remove(name: string): void;
+ /** Delete a cookie by name */
+ delete(name: string): void;
+ /** Clear all cookies */
+ clear(): void;
+ /** Get the underlying cookie jar for advanced operations */
+ jar(): BrunoCookieJar;
+}
+
+interface BrunoRunner {
+ /**
+ * Set the next request to execute in the runner.
+ * @param requestName - The name of the next request
+ */
+ setNextRequest(requestName: string): void;
+ /** Skip the current request in the runner */
+ skipRequest(): void;
+ /** Stop the runner execution entirely */
+ stopExecution(): void;
+}
+
+interface BrunoUtils {
+ /**
+ * Minify a JSON string or object by removing whitespace.
+ * @param json - A JSON string or object to minify
+ * @returns The minified JSON string
+ * @throws Error if the input is not valid JSON
+ * @example bru.utils.minifyJson('{ "key": "value" }') // '{"key":"value"}'
+ */
+ minifyJson(json: string | object): string;
+
+ /**
+ * Minify an XML string by removing whitespace and indentation.
+ * @param xml - The XML string to minify
+ * @returns The minified XML string
+ * @throws Error if the input is not a valid string
+ */
+ minifyXml(xml: string): string;
+}
+
+interface BrunoRuntime {
+ /**
+ * Get the current working directory of the collection.
+ * @returns The absolute path to the collection folder
+ */
+ cwd(): string;
+
+ /**
+ * Get the collection name.
+ * @returns The name of the current collection
+ */
+ getCollectionName(): string;
+
+ /**
+ * Check if running in safe mode (QuickJS sandbox).
+ * @returns true if safe mode (QuickJS), false if Node VM
+ */
+ isSafeMode(): boolean;
+
+ // ─── Environment Variables ─────────────────────────────────
+
+ /**
+ * Get the active environment name.
+ * @returns The name of the currently selected environment
+ * @example
+ * const envName = bru.getEnvName(); // 'Production'
+ */
+ getEnvName(): string;
+
+ /**
+ * Get an environment variable value. Values containing \`{{references}}\` are interpolated.
+ * @param key - The variable name
+ * @example bru.getEnvVar('baseUrl') // 'https://api.example.com'
+ */
+ getEnvVar(key: string): any;
+
+ /**
+ * Set an environment variable. The value is available for the duration of the request lifecycle.
+ * @param key - The variable name (alphanumeric, hyphens, underscores, dots only)
+ * @param value - The variable value
+ * @example bru.setEnvVar('token', 'abc123');
+ */
+ setEnvVar(key: string, value: any): void;
+ /**
+ * Set an environment variable with options.
+ * @param key - The variable name
+ * @param value - The variable value (must be a string when persist is true)
+ * @param options - Options object
+ * @param options.persist - If true, the value is persisted to the environment file on disk
+ * @example bru.setEnvVar('token', 'abc123', { persist: true });
+ */
+ setEnvVar(key: string, value: any, options: { persist?: boolean }): void;
+
+ /**
+ * Delete an environment variable.
+ * @param key - The variable name to delete
+ */
+ deleteEnvVar(key: string): void;
+
+ /**
+ * Get all environment variables as a key-value object.
+ * @returns Object containing all environment variables (excludes internal __name__)
+ */
+ getAllEnvVars(): Record;
+
+ /** Delete all environment variables (the environment name is preserved) */
+ deleteAllEnvVars(): void;
+
+ /**
+ * Check if an environment variable exists.
+ * @param key - The variable name
+ */
+ hasEnvVar(key: string): boolean;
+
+ // ─── Global Environment Variables ──────────────────────────
+
+ /**
+ * Get a global environment variable value. Values are interpolated.
+ * @param key - The variable name
+ */
+ getGlobalEnvVar(key: string): any;
+
+ /**
+ * Set a global environment variable.
+ * @param key - The variable name
+ * @param value - The variable value
+ */
+ setGlobalEnvVar(key: string, value: any): void;
+
+ /**
+ * Get all global environment variables as a key-value object.
+ */
+ getAllGlobalEnvVars(): Record;
+
+ // ─── Collection / Folder / Request Variables ───────────────
+
+ /**
+ * Get a collection-level variable value. Values are interpolated.
+ * @param key - The variable name
+ */
+ getCollectionVar(key: string): any;
+
+ /**
+ * Check if a collection-level variable exists.
+ * @param key - The variable name
+ */
+ hasCollectionVar(key: string): boolean;
+
+ /**
+ * Get a folder-level variable value. Values are interpolated.
+ * @param key - The variable name
+ */
+ getFolderVar(key: string): any;
+
+ /**
+ * Get a request-level variable value. Values are interpolated.
+ * @param key - The variable name
+ */
+ getRequestVar(key: string): any;
+
+ // ─── Runtime Variables ─────────────────────────────────────
+
+ /**
+ * Check if a runtime variable exists.
+ * @param key - The variable name
+ */
+ hasVar(key: string): boolean;
+
+ /**
+ * Get a runtime variable value. Values are interpolated.
+ * @param key - The variable name (alphanumeric, hyphens, underscores, dots only)
+ */
+ getVar(key: string): any;
+
+ /**
+ * Set a runtime variable. Available for the duration of the request lifecycle.
+ * @param key - The variable name (alphanumeric, hyphens, underscores, dots only)
+ * @param value - The variable value
+ * @example bru.setVar('userId', response.body.id);
+ */
+ setVar(key: string, value: any): void;
+
+ /**
+ * Delete a runtime variable.
+ * @param key - The variable name
+ */
+ deleteVar(key: string): void;
+
+ /** Delete all runtime variables */
+ deleteAllVars(): void;
+
+ /**
+ * Get all runtime variables as a key-value object.
+ */
+ getAllVars(): Record;
+
+ // ─── Process Environment ───────────────────────────────────
+
+ /**
+ * Get a process environment variable (from system or .env file).
+ * @param key - The environment variable name
+ * @example bru.getProcessEnv('API_KEY')
+ */
+ getProcessEnv(key: string): string | undefined;
+
+ // ─── OAuth2 ────────────────────────────────────────────────
+
+ /**
+ * Get an OAuth2 credential variable (e.g., access token).
+ * @param key - The credential variable key (e.g., '$oauth2.credentialId.access_token')
+ */
+ getOauth2CredentialVar(key: string): any;
+
+ /**
+ * Reset an OAuth2 credential, clearing its cached token.
+ * @param credentialId - The credential ID to reset
+ */
+ resetOauth2Credential(credentialId: string): void;
+
+ // ─── Request Control ───────────────────────────────────────
+
+ /**
+ * Set the next request to execute (in runner mode).
+ * @param nextRequest - The name of the next request to execute
+ */
+ setNextRequest(nextRequest: string): void;
+
+ /** Runner control methods for managing collection runner execution */
+ runner: BrunoRunner;
+
+ // ─── HTTP Requests from Scripts ────────────────────────────
+
+ /**
+ * Send an arbitrary HTTP request from within a script.
+ * Uses the collection's proxy and TLS settings if configured.
+ * @param requestConfig - Axios-compatible request config object
+ * @returns Promise resolving to the response
+ * @example
+ * const response = await bru.sendRequest({
+ * method: 'GET',
+ * url: 'https://api.example.com/users',
+ * headers: { 'Authorization': 'Bearer token' }
+ * });
+ */
+ sendRequest(requestConfig: any): Promise;
+ /**
+ * Send an HTTP request with a callback.
+ * @param requestConfig - Axios-compatible request config object
+ * @param callback - Callback receiving (error, response)
+ * @example
+ * bru.sendRequest({ method: 'GET', url: '/api/users' }, (err, res) => {
+ * if (err) console.log(err);
+ * else console.log(res.status);
+ * });
+ */
+ sendRequest(requestConfig: any, callback: (err: any, res: any) => void): Promise;
+
+ // ─── Test Results ──────────────────────────────────────────
+
+ /** Get assertion results from the current run */
+ getAssertionResults(): any[];
+ /** Get test results from the current run */
+ getTestResults(): any[];
+
+ // ─── Utilities ─────────────────────────────────────────────
+
+ /**
+ * Sleep for the specified duration.
+ * @param ms - Duration in milliseconds
+ * @example await bru.sleep(1000); // wait 1 second
+ */
+ sleep(ms: number): Promise;
+
+ /**
+ * Interpolate Bruno variables in a string or object.
+ * Replaces \`{{variableName}}\` with resolved values from all variable scopes.
+ * @param str - The string or object to interpolate
+ * @returns The interpolated string or object
+ * @example bru.interpolate('Hello {{name}}') // 'Hello John'
+ */
+ interpolate(str: string): string;
+ /**
+ * Interpolate Bruno variables in an object (deep interpolation).
+ * @param obj - The object to interpolate
+ * @returns A new object with all string values interpolated
+ */
+ interpolate(obj: object): object;
+
+ /** Cookie management for the current request URL */
+ cookies: BrunoCookies;
+
+ /** Utility functions for data transformation */
+ utils: BrunoUtils;
+}
+`;
+
+ monaco.languages.typescript.javascriptDefaults.addExtraLib(typeDefs, 'bruno-api.d.ts');
+
+ monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
+ noSemanticValidation: false,
+ noSyntaxValidation: false
+ });
+
+ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
+ target: monaco.languages.typescript.ScriptTarget.ES2020,
+ allowNonTsExtensions: true,
+ allowJs: true,
+ checkJs: false
+ });
+};
diff --git a/packages/bruno-app/src/utils/monaco/brunoTheme.js b/packages/bruno-app/src/utils/monaco/brunoTheme.js
new file mode 100644
index 00000000000..aedecb374a1
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/brunoTheme.js
@@ -0,0 +1,161 @@
+import * as monaco from 'monaco-editor';
+
+let registeredThemeId = null;
+
+/**
+ * Registers a custom Monaco theme based on the active Bruno styled-components theme.
+ * Maps the codemirror.tokens.* colors from the Bruno theme to Monaco token rules.
+ *
+ * @param {Object} brunoTheme - The styled-components theme object (props.theme)
+ * @param {string} displayedTheme - 'dark' or 'light'
+ * @returns {string} The registered Monaco theme name
+ */
+export const registerBrunoTheme = (brunoTheme, displayedTheme) => {
+ const isDark = displayedTheme === 'dark';
+ const base = isDark ? 'vs-dark' : 'vs';
+ const tokens = brunoTheme?.codemirror?.tokens || {};
+ const bg = brunoTheme?.codemirror?.bg || (isDark ? '#1a1a1a' : '#ffffff');
+ const fg = brunoTheme?.text || (isDark ? '#d4d4d4' : '#1e1e1e');
+ const gutterBg = brunoTheme?.codemirror?.gutter?.bg || bg;
+
+ // Normalize bg — Monaco needs hex, not hsl() strings
+ const editorBg = normalizeColor(bg);
+ const editorFg = normalizeColor(fg);
+ const gutterBgNorm = normalizeColor(gutterBg);
+
+ const themeName = `bruno-${displayedTheme}`;
+
+ monaco.editor.defineTheme(themeName, {
+ base,
+ inherit: true,
+ rules: [
+ { token: 'comment', foreground: normalizeHex(tokens.comment) },
+ { token: 'comment.js', foreground: normalizeHex(tokens.comment) },
+ { token: 'comment.block', foreground: normalizeHex(tokens.comment) },
+ { token: 'string', foreground: normalizeHex(tokens.string) },
+ { token: 'string.js', foreground: normalizeHex(tokens.string) },
+ { token: 'number', foreground: normalizeHex(tokens.number) },
+ { token: 'number.js', foreground: normalizeHex(tokens.number) },
+ { token: 'keyword', foreground: normalizeHex(tokens.keyword) },
+ { token: 'keyword.js', foreground: normalizeHex(tokens.keyword) },
+ { token: 'identifier', foreground: normalizeHex(tokens.variable) },
+ { token: 'identifier.js', foreground: normalizeHex(tokens.variable) },
+ { token: 'type.identifier', foreground: normalizeHex(tokens.definition) },
+ { token: 'type.identifier.js', foreground: normalizeHex(tokens.definition) },
+ { token: 'delimiter', foreground: normalizeHex(tokens.operator) },
+ { token: 'delimiter.js', foreground: normalizeHex(tokens.operator) },
+ { token: 'delimiter.bracket', foreground: normalizeHex(tokens.tagBracket) },
+ { token: 'tag', foreground: normalizeHex(tokens.tag) },
+ { token: 'attribute.name', foreground: normalizeHex(tokens.property) },
+ { token: 'attribute.value', foreground: normalizeHex(tokens.string) },
+ // JSON specific
+ { token: 'string.key.json', foreground: normalizeHex(tokens.property) },
+ { token: 'string.value.json', foreground: normalizeHex(tokens.string) },
+ { token: 'number.json', foreground: normalizeHex(tokens.number) },
+ { token: 'keyword.json', foreground: normalizeHex(tokens.atom) }
+ ],
+ colors: {
+ 'editor.background': editorBg,
+ 'editor.foreground': editorFg,
+ 'editorGutter.background': gutterBgNorm,
+ 'editorLineNumber.foreground': isDark ? '#858585' : '#999999',
+ // Hover widget (JSDoc tooltips)
+ 'editorHoverWidget.background': normalizeColor(brunoTheme?.dropdown?.bg) || editorBg,
+ 'editorHoverWidget.border': normalizeColor(brunoTheme?.dropdown?.border) || (isDark ? '#454545' : '#c8c8c8'),
+ 'editorHoverWidget.foreground': editorFg,
+ // Suggest widget (autocomplete)
+ 'editorSuggestWidget.background': normalizeColor(brunoTheme?.dropdown?.bg) || editorBg,
+ 'editorSuggestWidget.border': normalizeColor(brunoTheme?.dropdown?.border) || (isDark ? '#454545' : '#c8c8c8'),
+ 'editorSuggestWidget.foreground': editorFg,
+ 'editorSuggestWidget.selectedBackground': normalizeColor(brunoTheme?.dropdown?.hoverBg) || (isDark ? '#04395e' : '#d6ebff'),
+ 'editorSuggestWidget.highlightForeground': normalizeColor(brunoTheme?.textLink) || (isDark ? '#569cd6' : '#0078d4'),
+ // Widget (shared - parameter hints, find widget, etc.)
+ 'editorWidget.background': normalizeColor(brunoTheme?.dropdown?.bg) || editorBg,
+ 'editorWidget.border': normalizeColor(brunoTheme?.dropdown?.border) || (isDark ? '#454545' : '#c8c8c8'),
+ 'editorWidget.foreground': editorFg
+ }
+ });
+
+ registeredThemeId = themeName;
+ return themeName;
+};
+
+/**
+ * Gets the currently registered theme name, or a fallback.
+ */
+export const getCurrentThemeName = (displayedTheme) => {
+ return registeredThemeId || (displayedTheme === 'dark' ? 'vs-dark' : 'vs');
+};
+
+/**
+ * Strip '#' prefix and return 6-char hex for Monaco token rules.
+ * Handles undefined/null gracefully.
+ */
+function normalizeHex(color) {
+ if (!color) return undefined;
+ const s = String(color).trim();
+ if (s.startsWith('#')) return s.slice(1);
+ return s;
+}
+
+// Memoize color conversions to avoid repeated computation
+const colorCache = new Map();
+
+/**
+ * Normalize a color to Monaco's #rrggbb format for theme colors.
+ * Handles hex and hsl() strings. Results are memoized.
+ */
+function normalizeColor(color) {
+ if (!color) return '#1e1e1e';
+ const s = String(color).trim();
+
+ const cached = colorCache.get(s);
+ if (cached) return cached;
+
+ let result;
+ if (s.startsWith('#')) {
+ result = s.length <= 7 ? s : s.slice(0, 7);
+ } else if (s.startsWith('hsl')) {
+ result = hslStringToHex(s);
+ } else {
+ result = s;
+ }
+
+ colorCache.set(s, result);
+ return result;
+}
+
+/**
+ * Convert an hsl()/hsla() string to #rrggbb hex using pure math.
+ * Avoids DOM manipulation and layout thrashing.
+ */
+function hslStringToHex(hslString) {
+ const match = hslString.match(/hsla?\(\s*([\d.]+)\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%/);
+ if (!match) return '#1e1e1e';
+
+ const h = parseFloat(match[1]) / 360;
+ const s = parseFloat(match[2]) / 100;
+ const l = parseFloat(match[3]) / 100;
+
+ let r, g, b;
+ if (s === 0) {
+ r = g = b = l;
+ } else {
+ const hue2rgb = (p, q, t) => {
+ if (t < 0) t += 1;
+ if (t > 1) t -= 1;
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
+ if (t < 1 / 2) return q;
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+ };
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+ r = hue2rgb(p, q, h + 1 / 3);
+ g = hue2rgb(p, q, h);
+ b = hue2rgb(p, q, h - 1 / 3);
+ }
+
+ const toHex = (c) => Math.round(c * 255).toString(16).padStart(2, '0');
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
+}
diff --git a/packages/bruno-app/src/utils/monaco/languageMapping.js b/packages/bruno-app/src/utils/monaco/languageMapping.js
new file mode 100644
index 00000000000..45d7172e16c
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/languageMapping.js
@@ -0,0 +1,28 @@
+const MODE_MAP = {
+ 'application/javascript': 'javascript',
+ 'javascript': 'javascript',
+ 'application/ld+json': 'json',
+ 'application/json': 'json',
+ 'application/xml': 'xml',
+ 'xml': 'xml',
+ 'application/html': 'html',
+ 'html': 'html',
+ 'application/yaml': 'yaml',
+ 'yaml': 'yaml',
+ 'application/text': 'plaintext',
+ 'text/plain': 'plaintext',
+ 'markdown': 'markdown',
+ 'shell': 'shell',
+ 'application/sparql-query': 'plaintext',
+ 'graphql': 'plaintext'
+};
+
+/**
+ * Maps a CodeMirror mode string to a Monaco Editor language ID.
+ * @param {string} mode - CodeMirror mode (e.g. 'application/javascript')
+ * @returns {string} Monaco language ID (e.g. 'javascript')
+ */
+export const mapCodeMirrorModeToMonaco = (mode) => {
+ if (!mode) return 'plaintext';
+ return MODE_MAP[mode] || 'plaintext';
+};
diff --git a/packages/bruno-app/src/utils/monaco/languageMapping.spec.js b/packages/bruno-app/src/utils/monaco/languageMapping.spec.js
new file mode 100644
index 00000000000..5898e5865b3
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/languageMapping.spec.js
@@ -0,0 +1,52 @@
+import { mapCodeMirrorModeToMonaco } from './languageMapping';
+
+describe('mapCodeMirrorModeToMonaco', () => {
+ it('maps JavaScript modes', () => {
+ expect(mapCodeMirrorModeToMonaco('application/javascript')).toBe('javascript');
+ expect(mapCodeMirrorModeToMonaco('javascript')).toBe('javascript');
+ });
+
+ it('maps JSON modes', () => {
+ expect(mapCodeMirrorModeToMonaco('application/ld+json')).toBe('json');
+ expect(mapCodeMirrorModeToMonaco('application/json')).toBe('json');
+ });
+
+ it('maps XML modes', () => {
+ expect(mapCodeMirrorModeToMonaco('application/xml')).toBe('xml');
+ expect(mapCodeMirrorModeToMonaco('xml')).toBe('xml');
+ });
+
+ it('maps YAML modes', () => {
+ expect(mapCodeMirrorModeToMonaco('application/yaml')).toBe('yaml');
+ expect(mapCodeMirrorModeToMonaco('yaml')).toBe('yaml');
+ });
+
+ it('maps HTML modes', () => {
+ expect(mapCodeMirrorModeToMonaco('application/html')).toBe('html');
+ expect(mapCodeMirrorModeToMonaco('html')).toBe('html');
+ });
+
+ it('maps plaintext modes', () => {
+ expect(mapCodeMirrorModeToMonaco('application/text')).toBe('plaintext');
+ expect(mapCodeMirrorModeToMonaco('text/plain')).toBe('plaintext');
+ });
+
+ it('maps markdown mode', () => {
+ expect(mapCodeMirrorModeToMonaco('markdown')).toBe('markdown');
+ });
+
+ it('maps shell mode', () => {
+ expect(mapCodeMirrorModeToMonaco('shell')).toBe('shell');
+ });
+
+ it('falls back to plaintext for unsupported modes', () => {
+ expect(mapCodeMirrorModeToMonaco('application/sparql-query')).toBe('plaintext');
+ expect(mapCodeMirrorModeToMonaco('graphql')).toBe('plaintext');
+ expect(mapCodeMirrorModeToMonaco('unknown-mode')).toBe('plaintext');
+ });
+
+ it('falls back to plaintext for null/undefined', () => {
+ expect(mapCodeMirrorModeToMonaco(null)).toBe('plaintext');
+ expect(mapCodeMirrorModeToMonaco(undefined)).toBe('plaintext');
+ });
+});
diff --git a/packages/bruno-app/src/utils/monaco/variableHighlighting.js b/packages/bruno-app/src/utils/monaco/variableHighlighting.js
new file mode 100644
index 00000000000..a40b4e756de
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/variableHighlighting.js
@@ -0,0 +1,533 @@
+import * as monaco from 'monaco-editor';
+import get from 'lodash/get';
+import { mockDataFunctions, interpolate, timeBasedDynamicVars } from '@usebruno/common';
+import { getAllVariables, getVariableScope, isVariableSecret } from 'utils/collections';
+import { updateVariableInScope } from 'providers/ReduxStore/slices/collections/actions';
+
+const VARIABLE_REGEX = /\{\{([^}]*)\}\}/g;
+const HOVER_DELAY = 50;
+const HIDE_DELAY = 500;
+const COPY_SUCCESS_TIMEOUT = 1000;
+
+const SCOPE_LABELS = {
+ 'global': 'Global',
+ 'environment': 'Environment',
+ 'collection': 'Collection',
+ 'folder': 'Folder',
+ 'request': 'Request',
+ 'runtime': 'Runtime',
+ 'process.env': 'Process Env',
+ 'dynamic': 'Dynamic',
+ 'oauth2': 'OAuth2',
+ 'undefined': 'Undefined',
+ 'pathParam': 'Path Param'
+};
+
+const READ_ONLY_SCOPES = new Set(['process.env', 'runtime', 'dynamic', 'oauth2', 'undefined']);
+
+const pathFoundInVariables = (path, obj) => {
+ return get(obj, path) !== undefined;
+};
+
+// ─── SVG Icons ───────────────────────────────────────────────
+
+const COPY_ICON = ``;
+const CHECK_ICON = ``;
+const EYE_ICON = ``;
+const EYE_OFF_ICON = ``;
+
+// ─── Decoration Highlighting ─────────────────────────────────
+
+/**
+ * Applies decorations to highlight {{variable}} patterns in the editor.
+ * Green for valid, red for invalid, blue for prompt variables.
+ */
+export const setupVariableHighlighting = (editor, collection, item) => {
+ const decorationCollection = editor.createDecorationsCollection([]);
+
+ const updateDecorations = () => {
+ const model = editor.getModel();
+ if (!model) return;
+
+ const variables = getAllVariables(collection, item);
+ const { pathParams = {}, maskedEnvVariables = [], ...varLookup } = variables;
+ const text = model.getValue();
+ const newDecorations = [];
+
+ let match;
+ VARIABLE_REGEX.lastIndex = 0;
+
+ while ((match = VARIABLE_REGEX.exec(text)) !== null) {
+ const varName = match[1];
+ const startOffset = match.index;
+ const endOffset = startOffset + match[0].length;
+ const startPos = model.getPositionAt(startOffset);
+ const endPos = model.getPositionAt(endOffset);
+
+ let className;
+ if (varName.startsWith('?')) {
+ className = 'bruno-variable-prompt';
+ } else {
+ const isMockVariable = varName.startsWith('$') && Object.hasOwn(mockDataFunctions, varName.substring(1));
+ const isValid = isMockVariable || pathFoundInVariables(varName, varLookup);
+ className = isValid ? 'bruno-variable-valid' : 'bruno-variable-invalid';
+ }
+
+ newDecorations.push({
+ range: new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column),
+ options: {
+ inlineClassName: className
+ }
+ });
+ }
+
+ decorationCollection.set(newDecorations);
+ };
+
+ updateDecorations();
+
+ let debounceTimer = null;
+ const disposable = editor.onDidChangeModelContent(() => {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(updateDecorations, 120);
+ });
+
+ return () => {
+ clearTimeout(debounceTimer);
+ disposable.dispose();
+ decorationCollection.clear();
+ };
+};
+
+// ─── Rich DOM Tooltip ────────────────────────────────────────
+
+/**
+ * Sets up a rich interactive tooltip for {{variable}} patterns,
+ * matching the CodeMirror brunoVarInfo tooltip behavior.
+ */
+export const setupVariableTooltip = (editor, collectionRef, itemRef, dispatch) => {
+ let activePopup = null;
+ let hoverTimeout = null;
+ let hideTimeout = null;
+ let currentVarName = null;
+
+ const getCollection = () => (typeof collectionRef === 'function' ? collectionRef() : collectionRef);
+ const getItem = () => (typeof itemRef === 'function' ? itemRef() : itemRef);
+
+ /**
+ * Find the {{variable}} at a given position in the editor.
+ * Returns { varName, startCol, endCol } or null.
+ */
+ const getVariableAtPosition = (position) => {
+ const model = editor.getModel();
+ if (!model) return null;
+
+ const line = model.getLineContent(position.lineNumber);
+ const matches = [...line.matchAll(/\{\{([^}]*)\}\}/g)];
+
+ for (const match of matches) {
+ const startCol = match.index + 1;
+ const endCol = startCol + match[0].length;
+ if (position.column >= startCol && position.column <= endCol) {
+ return { varName: match[1], startCol, endCol, lineNumber: position.lineNumber };
+ }
+ }
+ return null;
+ };
+
+ /**
+ * Detect the scope of a variable, handling special prefixes.
+ */
+ const detectScope = (varName) => {
+ const collection = getCollection();
+ const item = getItem();
+
+ if (varName.startsWith('$oauth2.')) {
+ const variables = getAllVariables(collection, item);
+ const value = get(variables, varName);
+ return { type: 'oauth2', value, data: {} };
+ }
+
+ if (varName.startsWith('$')) {
+ const fnName = varName.substring(1);
+ const exists = Object.hasOwn(mockDataFunctions, fnName);
+ const isTimeBased = timeBasedDynamicVars.has(fnName);
+ return { type: 'dynamic', value: '', data: { exists, fnName, isTimeBased } };
+ }
+
+ if (varName.startsWith('process.env.')) {
+ const envKey = varName.replace('process.env.', '');
+ const variables = getAllVariables(collection, item);
+ const value = get(variables, varName);
+ return { type: 'process.env', value, data: { envKey } };
+ }
+
+ const scopeInfo = getVariableScope(varName, collection, item);
+ if (scopeInfo) return scopeInfo;
+
+ // If variable doesn't exist in any scope, determine default scope based on context
+ // (matches CodeMirror brunoVarInfo behavior)
+ if (item && item.uid) {
+ const isFolder = item.type === 'folder';
+ if (isFolder) {
+ return { type: 'folder', value: '', data: { folder: item, variable: null } };
+ } else {
+ return { type: 'request', value: '', data: { item, variable: null } };
+ }
+ } else if (collection) {
+ return { type: 'collection', value: '', data: { collection, variable: null } };
+ }
+
+ return { type: 'undefined', value: undefined, data: {} };
+ };
+
+ /**
+ * Get the interpolated value of a variable.
+ */
+ const getInterpolatedValue = (rawValue) => {
+ if (rawValue === undefined || rawValue === null) return '';
+ const collection = getCollection();
+ const variables = getAllVariables(collection, getItem());
+ const { pathParams, maskedEnvVariables, ...varLookup } = variables;
+ try {
+ return interpolate(String(rawValue), varLookup);
+ } catch {
+ return String(rawValue);
+ }
+ };
+
+ /**
+ * Check if the raw value contains references to secret variables.
+ */
+ const containsSecretReferences = (rawValue) => {
+ if (!rawValue || typeof rawValue !== 'string') return false;
+ const collection = getCollection();
+ const item = getItem();
+ const matches = rawValue.matchAll(/\{\{([^}]+)\}\}/g);
+ for (const match of matches) {
+ const refName = match[1].trim();
+ const refScope = getVariableScope(refName, collection, item);
+ if (refScope && isVariableSecret(refScope)) return true;
+ }
+ return false;
+ };
+
+ /**
+ * Create and show the tooltip popup.
+ */
+ const showTooltip = (varInfo) => {
+ hidePopup();
+ const { varName, lineNumber, startCol } = varInfo;
+
+ // Prompt variables ({{?name}}) don't need a tooltip
+ if (varName.startsWith('?')) return;
+
+ const collection = getCollection();
+ const scopeInfo = detectScope(varName);
+ const scopeType = scopeInfo.type;
+ const scopeLabel = SCOPE_LABELS[scopeType] || 'Unknown';
+ let rawValue = scopeInfo.value !== undefined ? String(scopeInfo.value) : '';
+ const isReadOnly = READ_ONLY_SCOPES.has(scopeType)
+ || (collection?.runtimeVariables && collection.runtimeVariables[varName]);
+ const isSecret = scopeType !== 'undefined' && isVariableSecret(scopeInfo);
+ const hasSecretRefs = containsSecretReferences(rawValue);
+ const shouldMask = isSecret || hasSecretRefs;
+
+ let isRevealed = false;
+ let interpolatedValue = getInterpolatedValue(rawValue);
+ let displayValue = typeof scopeInfo.value === 'object'
+ ? JSON.stringify(scopeInfo.value, null, 2)
+ : interpolatedValue;
+
+ // ─── Build DOM ─────────────────────────────
+
+ const popup = document.createElement('div');
+ // Reuses the CodeMirror tooltip class to share global styles defined in globalStyles.js
+ popup.className = 'CodeMirror-brunoVarInfo';
+
+ // Header: name + scope badge
+ const header = document.createElement('div');
+ header.className = 'var-info-header';
+
+ const nameEl = document.createElement('span');
+ nameEl.className = 'var-name';
+ nameEl.textContent = varName;
+ nameEl.title = varName;
+ header.appendChild(nameEl);
+
+ const badge = document.createElement('span');
+ badge.className = 'var-scope-badge';
+ badge.textContent = scopeLabel;
+ header.appendChild(badge);
+
+ popup.appendChild(header);
+
+ // Dynamic variables: show only a note, no value container (matches CodeMirror)
+ if (scopeType === 'dynamic') {
+ if (!scopeInfo.data.exists) {
+ const warning = document.createElement('div');
+ warning.className = 'var-warning-note';
+ warning.textContent = `Unknown dynamic variable "${varName}". Check the variable name.`;
+ popup.appendChild(warning);
+ } else {
+ const note = document.createElement('div');
+ note.className = 'var-readonly-note';
+ note.textContent = scopeInfo.data.isTimeBased
+ ? 'Generates current timestamp on each request'
+ : 'Generates random value on each request';
+ popup.appendChild(note);
+ }
+
+ positionAndShowPopup(popup, varName, lineNumber, startCol);
+ return;
+ }
+
+ // Value container
+ const valueContainer = document.createElement('div');
+ valueContainer.className = 'var-value-container';
+
+ // Value display
+ const valueDisplay = document.createElement('div');
+ valueDisplay.className = isReadOnly ? 'var-value-display' : 'var-value-editable-display';
+
+ const updateValueDisplay = () => {
+ if (shouldMask && !isRevealed) {
+ valueDisplay.textContent = displayValue ? '*'.repeat(Math.min(displayValue.length, 20)) : '';
+ } else {
+ valueDisplay.textContent = displayValue || '';
+ }
+ };
+ updateValueDisplay();
+
+ // Editor (textarea) — hidden by default
+ let editorEl = null;
+ if (!isReadOnly && scopeType !== 'undefined') {
+ editorEl = document.createElement('textarea');
+ editorEl.className = 'var-value-editor-textarea';
+ editorEl.value = rawValue;
+
+ valueDisplay.addEventListener('click', () => {
+ valueDisplay.style.display = 'none';
+ editorEl.style.display = 'block';
+ editorEl.value = rawValue;
+ editorEl.focus();
+ editorEl.setSelectionRange(editorEl.value.length, editorEl.value.length);
+ });
+
+ editorEl.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ editorEl.blur();
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ editorEl.value = rawValue;
+ editorEl.style.display = 'none';
+ valueDisplay.style.display = '';
+ }
+ });
+
+ editorEl.addEventListener('blur', () => {
+ const newValue = editorEl.value;
+ if (newValue !== rawValue && dispatch) {
+ dispatch(updateVariableInScope(varName, newValue, scopeInfo, collection.uid));
+ rawValue = newValue;
+ interpolatedValue = getInterpolatedValue(rawValue);
+ displayValue = interpolatedValue;
+ updateValueDisplay();
+ }
+ editorEl.style.display = 'none';
+ valueDisplay.style.display = '';
+ });
+ }
+
+ valueContainer.appendChild(valueDisplay);
+ if (editorEl) {
+ valueContainer.appendChild(editorEl);
+ }
+
+ // Icons container
+ const icons = document.createElement('div');
+ icons.className = 'var-icons';
+
+ // Secret toggle button
+ if (shouldMask) {
+ const toggleBtn = document.createElement('button');
+ toggleBtn.className = 'secret-toggle-button';
+ toggleBtn.innerHTML = EYE_ICON;
+ toggleBtn.title = 'Reveal value';
+ toggleBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ isRevealed = !isRevealed;
+ toggleBtn.innerHTML = isRevealed ? EYE_OFF_ICON : EYE_ICON;
+ toggleBtn.title = isRevealed ? 'Mask value' : 'Reveal value';
+ updateValueDisplay();
+ });
+ icons.appendChild(toggleBtn);
+ }
+
+ // Copy button
+ if (scopeType !== 'undefined') {
+ const copyBtn = document.createElement('button');
+ copyBtn.className = 'copy-button';
+ copyBtn.innerHTML = COPY_ICON;
+ copyBtn.title = 'Copy value';
+ let copyTimeout = null;
+ copyBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ if (copyTimeout) return;
+ const textToCopy = typeof scopeInfo.value === 'object'
+ ? JSON.stringify(scopeInfo.value) : interpolatedValue;
+ navigator.clipboard.writeText(textToCopy).then(() => {
+ copyBtn.innerHTML = CHECK_ICON;
+ copyBtn.classList.add('copy-success');
+ copyTimeout = setTimeout(() => {
+ copyBtn.innerHTML = COPY_ICON;
+ copyBtn.classList.remove('copy-success');
+ copyTimeout = null;
+ }, COPY_SUCCESS_TIMEOUT);
+ });
+ });
+ icons.appendChild(copyBtn);
+ }
+
+ valueContainer.appendChild(icons);
+ popup.appendChild(valueContainer);
+
+ // Notes
+ if (isReadOnly && scopeType !== 'undefined') {
+ const note = document.createElement('div');
+ note.className = 'var-readonly-note';
+ if (scopeType === 'runtime') {
+ note.textContent = 'Set by scripts (read-only)';
+ } else if (scopeType === 'process.env') {
+ note.textContent = 'Process environment variable (read-only)';
+ } else if (scopeType === 'oauth2') {
+ note.textContent = 'OAuth2 credential (read-only)';
+ }
+ if (note.textContent) popup.appendChild(note);
+ }
+
+ if (scopeType === 'undefined') {
+ const warning = document.createElement('div');
+ warning.className = 'var-warning-note';
+ warning.textContent = 'Variable is not defined in any scope';
+ popup.appendChild(warning);
+ }
+
+ // ─── Position & Show ───────────────────────
+
+ positionAndShowPopup(popup, varName, lineNumber, startCol);
+ };
+
+ const positionAndShowPopup = (popup, varName, lineNumber, startCol) => {
+ document.body.appendChild(popup);
+ activePopup = popup;
+ currentVarName = varName;
+
+ // Get screen coordinates from editor
+ const editorDom = editor.getDomNode();
+ const scrolledPos = editor.getScrolledVisiblePosition({ lineNumber, column: startCol });
+
+ if (scrolledPos && editorDom) {
+ const editorRect = editorDom.getBoundingClientRect();
+ let top = editorRect.top + scrolledPos.top + scrolledPos.height + 4;
+ let left = editorRect.left + scrolledPos.left;
+
+ // If tooltip would overflow bottom, show above
+ const popupRect = popup.getBoundingClientRect();
+ if (top + popupRect.height > window.innerHeight) {
+ top = editorRect.top + scrolledPos.top - popupRect.height - 4;
+ }
+
+ // Prevent right overflow
+ if (left + popupRect.width > window.innerWidth) {
+ left = window.innerWidth - popupRect.width - 8;
+ }
+
+ // Prevent left overflow
+ if (left < 4) left = 4;
+
+ popup.style.position = 'fixed';
+ popup.style.top = `${top}px`;
+ popup.style.left = `${left}px`;
+ }
+
+ popup.style.opacity = '1';
+
+ // Keep popup alive while mouse is over it
+ popup.addEventListener('mouseover', () => {
+ clearTimeout(hideTimeout);
+ });
+ popup.addEventListener('mouseout', (e) => {
+ // Don't hide if moving within the popup
+ if (popup.contains(e.relatedTarget)) return;
+ startHideTimer();
+ });
+ };
+
+ const hidePopup = () => {
+ clearTimeout(hoverTimeout);
+ clearTimeout(hideTimeout);
+ if (activePopup) {
+ activePopup.style.opacity = '0';
+ const popupToRemove = activePopup;
+ setTimeout(() => {
+ popupToRemove.remove();
+ }, 150);
+ activePopup = null;
+ currentVarName = null;
+ }
+ };
+
+ const startHideTimer = () => {
+ clearTimeout(hideTimeout);
+ hideTimeout = setTimeout(hidePopup, HIDE_DELAY);
+ };
+
+ // ─── Event Handlers ──────────────────────────
+
+ const onMouseMove = editor.onMouseMove((e) => {
+ if (!e.target.position) {
+ clearTimeout(hoverTimeout);
+ if (activePopup) startHideTimer();
+ return;
+ }
+
+ const varInfo = getVariableAtPosition(e.target.position);
+
+ if (varInfo) {
+ // Same variable — keep current popup
+ if (activePopup && currentVarName === varInfo.varName) {
+ clearTimeout(hideTimeout);
+ return;
+ }
+
+ clearTimeout(hoverTimeout);
+ clearTimeout(hideTimeout);
+ hoverTimeout = setTimeout(() => {
+ showTooltip(varInfo);
+ }, HOVER_DELAY);
+ } else {
+ clearTimeout(hoverTimeout);
+ if (activePopup) startHideTimer();
+ }
+ });
+
+ const onMouseLeave = editor.onMouseLeave(() => {
+ clearTimeout(hoverTimeout);
+ if (activePopup) startHideTimer();
+ });
+
+ // Hide immediately when user types
+ const onChange = editor.onDidChangeModelContent(() => {
+ hidePopup();
+ });
+
+ return () => {
+ onMouseMove.dispose();
+ onMouseLeave.dispose();
+ onChange.dispose();
+ hidePopup();
+ };
+};
diff --git a/packages/bruno-app/src/utils/monaco/workers.js b/packages/bruno-app/src/utils/monaco/workers.js
new file mode 100644
index 00000000000..b9e56ea3a81
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/workers.js
@@ -0,0 +1,11 @@
+self.MonacoEnvironment = {
+ getWorker(_, label) {
+ if (label === 'json') {
+ return new Worker(new URL('monaco-editor/esm/vs/language/json/json.worker.js', import.meta.url));
+ }
+ if (label === 'javascript' || label === 'typescript') {
+ return new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker.js', import.meta.url));
+ }
+ return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url));
+ }
+};
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index 0f668874e9b..956c99014e5 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -47,7 +47,8 @@ const defaultPreferences = {
responsePaneOrientation: 'horizontal'
},
beta: {
- 'openapi-sync': false
+ 'openapi-sync': false,
+ 'monaco-editor': false
},
onboarding: {
hasLaunchedBefore: false,
@@ -114,7 +115,8 @@ const preferencesSchema = Yup.object().shape({
responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical'])
}),
beta: Yup.object({
- 'openapi-sync': Yup.boolean()
+ 'openapi-sync': Yup.boolean(),
+ 'monaco-editor': Yup.boolean()
}),
onboarding: Yup.object({
hasLaunchedBefore: Yup.boolean(),