diff --git a/package-lock.json b/package-lock.json index 82dda71c045..5670f1b6578 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24158,6 +24158,18 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -24562,6 +24574,25 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/mousetrap": { "version": "1.6.5", "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", @@ -32862,6 +32893,7 @@ "mime-types": "^3.0.2", "moment": "^2.30.1", "moment-timezone": "^0.5.47", + "monaco-editor": "^0.55.1", "mousetrap": "^1.6.5", "nanoid": "3.3.8", "path": "^0.12.7", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index efbf326d51e..e443cf753e8 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -60,6 +60,7 @@ "mime-types": "^3.0.2", "moment": "^2.30.1", "moment-timezone": "^0.5.47", + "monaco-editor": "^0.55.1", "mousetrap": "^1.6.5", "nanoid": "3.3.8", "path": "^0.12.7", diff --git a/packages/bruno-app/rsbuild.config.mjs b/packages/bruno-app/rsbuild.config.mjs index f21f8066642..7dd701973ec 100644 --- a/packages/bruno-app/rsbuild.config.mjs +++ b/packages/bruno-app/rsbuild.config.mjs @@ -32,6 +32,16 @@ export default defineConfig({ tools: { rspack: { module: { + rules: [ + { + test: /monaco-editor\/esm\/vs\/.*\.worker/, + parser: { + javascript: { + dynamicImportMode: 'lazy' + } + } + }, + ], parser: { javascript: { // This loads the JavaScript contents from a library along with the main JavaScript bundle. diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js index ae379c01bba..29a6bd4df89 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js @@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'; import get from 'lodash/get'; import find from 'lodash/find'; import { useDispatch, useSelector } from 'react-redux'; -import CodeEditor from 'components/CodeEditor'; +import ScriptEditor from 'components/ScriptEditor'; import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs'; @@ -100,7 +100,7 @@ const Script = ({ collection }) => { - { - { return (
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(),