diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js
index e87f8627d..1f91c86e5 100644
--- a/packages/bruno-app/src/components/CodeEditor/index.js
+++ b/packages/bruno-app/src/components/CodeEditor/index.js
@@ -8,123 +8,19 @@
import React from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
-import { getMockDataHints } from 'utils/codemirror/mock-data-hints';
+import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+const CodeMirror = require('codemirror');
+window.jsonlint = jsonlint;
+window.JSHINT = JSHINT;
+
const TAB_SIZE = 2;
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
- window.jsonlint = jsonlint;
- window.JSHINT = JSHINT;
- //This should be done dynamically if possible
- const hintWords = [
- 'res',
- 'res.status',
- 'res.statusText',
- 'res.headers',
- 'res.body',
- 'res.responseTime',
- 'res.getStatus()',
- 'res.getStatusText()',
- 'res.getHeader(name)',
- 'res.getHeaders()',
- 'res.getBody()',
- 'res.setBody(data)',
- 'res.getResponseTime()',
- 'req',
- 'req.url',
- 'req.method',
- 'req.headers',
- 'req.body',
- 'req.timeout',
- 'req.getUrl()',
- 'req.setUrl(url)',
- 'req.getMethod()',
- 'req.getAuthMode()',
- 'req.setMethod(method)',
- 'req.getHeader(name)',
- 'req.getHeaders()',
- 'req.setHeader(name, value)',
- 'req.setHeaders(data)',
- 'req.getBody()',
- 'req.setBody(data)',
- 'req.setMaxRedirects(maxRedirects)',
- 'req.getTimeout()',
- 'req.setTimeout(timeout)',
- 'req.getExecutionMode()',
- 'req.getName()',
- 'bru',
- 'bru.cwd()',
- 'bru.getEnvName()',
- 'bru.getProcessEnv(key)',
- 'bru.hasEnvVar(key)',
- 'bru.getEnvVar(key)',
- 'bru.getFolderVar(key)',
- 'bru.getCollectionVar(key)',
- 'bru.setEnvVar(key,value)',
- 'bru.deleteEnvVar(key)',
- 'bru.hasVar(key)',
- 'bru.getVar(key)',
- 'bru.setVar(key,value)',
- 'bru.deleteVar(key)',
- 'bru.deleteAllVars()',
- 'bru.setNextRequest(requestName)',
- 'req.disableParsingResponseJson()',
- 'bru.getRequestVar(key)',
- 'bru.runRequest(requestPathName)',
- 'bru.getAssertionResults()',
- 'bru.getTestResults()',
- 'bru.sleep(ms)',
- 'bru.getCollectionName()',
- 'bru.getGlobalEnvVar(key)',
- 'bru.setGlobalEnvVar(key, value)',
- 'bru.runner',
- 'bru.runner.setNextRequest(requestName)',
- 'bru.runner.skipRequest()',
- 'bru.runner.stopExecution()',
- 'bru.interpolate(str)'
- ];
-
- CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
- const cursor = editor.getCursor();
- const currentLine = editor.getLine(cursor.line);
- let startBru = cursor.ch;
- let endBru = startBru;
- while (endBru < currentLine.length && /[\w.]/.test(currentLine.charAt(endBru))) ++endBru;
- while (startBru && /[\w.]/.test(currentLine.charAt(startBru - 1))) --startBru;
- let curWordBru = startBru != endBru && currentLine.slice(startBru, endBru);
-
- let start = cursor.ch;
- let end = start;
- while (end < currentLine.length && /[\w]/.test(currentLine.charAt(end))) ++end;
- while (start && /[\w]/.test(currentLine.charAt(start - 1))) --start;
- const jsHinter = CodeMirror.hint.javascript;
- let result = jsHinter(editor) || { list: [] };
- result.to = CodeMirror.Pos(cursor.line, end);
- result.from = CodeMirror.Pos(cursor.line, start);
- if (curWordBru) {
- hintWords.forEach((h) => {
- if (h.includes('.') == curWordBru.includes('.') && h.startsWith(curWordBru)) {
- result.list.push(curWordBru.includes('.') ? h.split('.')?.at(-1) : h);
- }
- });
- result.list?.sort();
- }
- return result;
- });
-
- CodeMirror.commands.autocomplete = (cm, hint, options) => {
- cm.showHint({ hint, ...options });
- };
-}
-
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -290,52 +186,21 @@ export default class CodeEditor extends React.Component {
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
- editor.on('keyup', this._onKeyUpMockDataHints);
this.addOverlay();
- }
-
- if (this.props.mode == 'javascript') {
- editor.on('keyup', function (cm, event) {
- const cursor = editor.getCursor();
- const currentLine = editor.getLine(cursor.line);
- let start = cursor.ch;
- let end = start;
- while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end;
- while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start;
- let curWord = start != end && currentLine.slice(start, end);
- // Qualify if autocomplete will be shown
- if (
- /^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event.key) &&
- curWord.length > 0 &&
- !/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) &&
- /(? getAllVariables(this.props.collection, this.props.item);
- const hints = getMockDataHints(cm);
- if (!hints) {
- if (cm.state.completionActive) {
- cm.state.completionActive.close();
- }
- return;
+ this.brunoAutoCompleteCleanup = setupAutoComplete(
+ editor,
+ getVariables,
+ autoCompleteOptions
+ );
}
-
- cm.showHint({
- hint: () => hints,
- completeSingle: false
- });
}
componentDidUpdate(prevProps) {
@@ -371,11 +236,13 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
- this.editor.off('keyup', this._onKeyUpMockDataHints);
this.editor = null;
}
this._unbindSearchHandler();
+ if (this.brunoAutoCompleteCleanup) {
+ this.brunoAutoCompleteCleanup();
+ }
}
render() {
diff --git a/packages/bruno-app/src/components/CodeEditor/index.spec.js b/packages/bruno-app/src/components/CodeEditor/index.spec.js
index 087a5a797..973a8d3a9 100644
--- a/packages/bruno-app/src/components/CodeEditor/index.spec.js
+++ b/packages/bruno-app/src/components/CodeEditor/index.spec.js
@@ -41,65 +41,11 @@ const setupEditorWithRef = () => {
return { ref, rerender };
};
-describe('CodeEditor Autocomplete', () => {
+describe('CodeEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
- it('shows autocomplete suggestions when typing {{$ra', () => {
- // Setup
- const { ref } = setupEditorWithRef();
- const editorInstance = ref.current;
- expect(editorInstance).toBeTruthy();
-
- const editor = editorInstance.editor;
- expect(editor).toBeTruthy();
-
- // Configure editor state
- setupEditorState(editor, {
- value: '{{$r',
- cursorPosition: 4
- });
-
- // Trigger autocomplete
- const _onKeyUpMockDataHints = editor._onKeyUpMockDataHints;
- expect(typeof _onKeyUpMockDataHints).toBe('function');
-
- act(() => {
- _onKeyUpMockDataHints(editor, { text: ['a'], origin: '+input' });
- });
-
- // Assertions
- expect(editor.showHint).toHaveBeenCalled();
- const call = editor.showHint.mock.calls[0][0];
- expect(typeof call.hint).toBe('function');
-
- const hints = call.hint();
- expect(Array.isArray(hints.list)).toBe(true);
- expect(hints.list.some((s) => s.startsWith('$'))).toBe(true);
- expect(hints.list.every((match) => match.startsWith('$ra'))).toBe(true);
- });
-
- it('does not show hints for regular text input', () => {
- // Setup
- const { ref } = setupEditorWithRef();
- const editor = ref.current.editor;
-
- // Configure editor state
- setupEditorState(editor, {
- value: 'regular text',
- cursorPosition: 11
- });
-
- // Trigger input
- const _onKeyUpMockDataHints = editor._onKeyUpMockDataHints;
-
- act(() => {
- _onKeyUpMockDataHints(editor, { text: ['x'], origin: '+input' });
- });
-
- // Assert no hints shown for regular text
- expect(editor.showHint).not.toHaveBeenCalled();
- });
+ it("add CodeEditor related tests here", () => {});
});
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
index 6fe979cbf..625df1ff7 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
@@ -53,6 +53,7 @@ const Script = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'bru']}
/>
@@ -66,6 +67,7 @@ const Script = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js
index d87a1dea4..975758ee1 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Tests/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Tests/index.js
@@ -37,6 +37,7 @@ const Tests = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
index 0f6e05f1f..4ee0002a2 100644
--- a/packages/bruno-app/src/components/FolderSettings/Headers/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
@@ -9,6 +9,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
+import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
@@ -117,6 +118,7 @@ const Headers = ({ collection, folder }) => {
}
collection={collection}
item={folder}
+ autocomplete={MimeTypes}
/>
diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js
index 628fa5cb5..5c3ca5b0d 100644
--- a/packages/bruno-app/src/components/FolderSettings/Script/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js
@@ -55,6 +55,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'bru']}
/>
@@ -68,6 +69,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/index.js b/packages/bruno-app/src/components/FolderSettings/Tests/index.js
index 8854b06cd..ae20a3b8e 100644
--- a/packages/bruno-app/src/components/FolderSettings/Tests/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Tests/index.js
@@ -38,6 +38,7 @@ const Tests = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js
index 1a6709813..e73e506e1 100644
--- a/packages/bruno-app/src/components/MultiLineEditor/index.js
+++ b/packages/bruno-app/src/components/MultiLineEditor/index.js
@@ -2,14 +2,10 @@ import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
+import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
-
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
-}
+const CodeMirror = require('codemirror');
class MultiLineEditor extends Component {
constructor(props) {
@@ -78,14 +74,21 @@ class MultiLineEditor extends Component {
'Shift-Tab': false
}
});
- if (this.props.autocomplete) {
- this.editor.on('keyup', (cm, event) => {
- if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
- /*Enter - do not open autocomplete list just after item has been selected in it*/
- CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
- }
- });
- }
+
+ // Setup AutoComplete Helper
+ const autoCompleteOptions = {
+ showHintsFor: ['variables'],
+ anywordAutocompleteHints: this.props.autocomplete
+ };
+
+ const getVariables = () => getAllVariables(this.props.collection, this.props.item);
+
+ this.brunoAutoCompleteCleanup = setupAutoComplete(
+ this.editor,
+ getVariables,
+ autoCompleteOptions
+ );
+
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -125,6 +128,9 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
+ if (this.brunoAutoCompleteCleanup) {
+ this.brunoAutoCompleteCleanup();
+ }
this.editor.getWrapperElement().remove();
}
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
index 228a54fa8..0a7fd98c9 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
@@ -68,6 +68,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
+ showHintsFor={['variables']}
/>
>
);
diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
index 6571c14ae..decc7bd1d 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
@@ -18,12 +18,7 @@ import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
-
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
-}
+const CodeMirror = require('codemirror');
const md = new MD();
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
index a0cc8729e..d562684e5 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
@@ -59,6 +59,7 @@ const RequestBody = ({ item, collection }) => {
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
+ showHintsFor={['variables']}
/>
);
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js
index ec4f4df95..adcf3ebe6 100644
--- a/packages/bruno-app/src/components/RequestPane/Script/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Script/index.js
@@ -52,6 +52,7 @@ const Script = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
+ showHintsFor={['req', 'bru']}
/>
@@ -66,6 +67,7 @@ const Script = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js
index d0d19c283..b9c9633be 100644
--- a/packages/bruno-app/src/components/RequestPane/Tests/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js
@@ -37,6 +37,7 @@ const Tests = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
+ showHintsFor={['req', 'res', 'bru']}
/>
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
index ea3ed43a7..5729da88b 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
@@ -68,6 +68,7 @@ const CodeView = ({ language, item }) => {
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
mode={lang}
+ showHintsFor={['variables']}
/>
>
diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js
index a82bfe94a..483ba2443 100644
--- a/packages/bruno-app/src/components/SingleLineEditor/index.js
+++ b/packages/bruno-app/src/components/SingleLineEditor/index.js
@@ -2,16 +2,11 @@ import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
-import { getMockDataHints } from 'utils/codemirror/mock-data-hints';
+import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
-
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
-}
+const CodeMirror = require('codemirror');
class SingleLineEditor extends Component {
constructor(props) {
@@ -78,16 +73,20 @@ class SingleLineEditor extends Component {
}
});
- if (this.props.autocomplete) {
- this.editor.on('keyup', (cm, event) => {
- if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.key !== 'Enter') {
- /*Enter - do not open autocomplete list just after item has been selected in it*/
- CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
- }
- });
- }
- this.editor.on('keyup', this._onKeyUpMockDataHints);
+ // Setup AutoComplete Helper
+ const autoCompleteOptions = {
+ showHintsFor: ['variables'],
+ anywordAutocompleteHints: this.props.autocomplete
+ };
+ const getVariables = () => getAllVariables(this.props.collection, this.props.item);
+
+ this.brunoAutoCompleteCleanup = setupAutoComplete(
+ this.editor,
+ getVariables,
+ autoCompleteOptions
+ );
+
this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -117,26 +116,6 @@ class SingleLineEditor extends Component {
}
};
- _onKeyUpMockDataHints(cm, event) {
- // This prevents triggering hints for non-character keys (e.g., Arrow keys, Meta).
- if (!/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event?.key)) {
- return;
- }
-
- const hints = getMockDataHints(cm);
- if (!hints) {
- if (cm.state.completionActive) {
- cm.state.completionActive.close();
- }
- return;
- }
-
- cm.showHint({
- hint: () => hints,
- completeSingle: false
- });
- }
-
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
@@ -167,10 +146,12 @@ class SingleLineEditor extends Component {
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
- this.editor.off('keyup', this._onKeyUpMockDataHints);
this.editor.getWrapperElement().remove();
this.editor = null;
}
+ if (this.brunoAutoCompleteCleanup) {
+ this.brunoAutoCompleteCleanup();
+ }
}
addOverlay = (variables) => {
diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js
index ed5539dbb..f758ce263 100644
--- a/packages/bruno-app/src/pages/Bruno/index.js
+++ b/packages/bruno-app/src/pages/Bruno/index.js
@@ -10,40 +10,37 @@ import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
import 'codemirror/addon/scroll/simplescrollbars.css';
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
-if (!SERVER_RENDERED) {
- require('codemirror/mode/javascript/javascript');
- require('codemirror/mode/xml/xml');
- require('codemirror/mode/sparql/sparql');
- require('codemirror/addon/comment/comment');
- require('codemirror/addon/dialog/dialog');
- require('codemirror/addon/edit/closebrackets');
- require('codemirror/addon/edit/matchbrackets');
- require('codemirror/addon/fold/brace-fold');
- require('codemirror/addon/fold/foldgutter');
- require('codemirror/addon/fold/xml-fold');
- require('codemirror/addon/hint/javascript-hint');
- require('codemirror/addon/hint/show-hint');
- require('codemirror/addon/lint/lint');
- require('codemirror/addon/lint/json-lint');
- require('codemirror/addon/mode/overlay');
- require('codemirror/addon/scroll/simplescrollbars');
- require('codemirror/addon/search/jump-to-line');
- require('codemirror/addon/search/search');
- require('codemirror/addon/search/searchcursor');
- require('codemirror/addon/display/placeholder');
- require('codemirror/keymap/sublime');
+require('codemirror/mode/javascript/javascript');
+require('codemirror/mode/xml/xml');
+require('codemirror/mode/sparql/sparql');
+require('codemirror/addon/comment/comment');
+require('codemirror/addon/dialog/dialog');
+require('codemirror/addon/edit/closebrackets');
+require('codemirror/addon/edit/matchbrackets');
+require('codemirror/addon/fold/brace-fold');
+require('codemirror/addon/fold/foldgutter');
+require('codemirror/addon/fold/xml-fold');
+require('codemirror/addon/hint/javascript-hint');
+require('codemirror/addon/hint/show-hint');
+require('codemirror/addon/lint/lint');
+require('codemirror/addon/lint/json-lint');
+require('codemirror/addon/mode/overlay');
+require('codemirror/addon/scroll/simplescrollbars');
+require('codemirror/addon/search/jump-to-line');
+require('codemirror/addon/search/search');
+require('codemirror/addon/search/searchcursor');
+require('codemirror/addon/display/placeholder');
+require('codemirror/keymap/sublime');
- require('codemirror-graphql/hint');
- require('codemirror-graphql/info');
- require('codemirror-graphql/jump');
- require('codemirror-graphql/lint');
- require('codemirror-graphql/mode');
+require('codemirror-graphql/hint');
+require('codemirror-graphql/info');
+require('codemirror-graphql/jump');
+require('codemirror-graphql/lint');
+require('codemirror-graphql/mode');
- require('utils/codemirror/brunoVarInfo');
- require('utils/codemirror/javascript-lint');
- require('utils/codemirror/autocomplete');
-}
+require('utils/codemirror/brunoVarInfo');
+require('utils/codemirror/javascript-lint');
+require('utils/codemirror/autocomplete');
export default function Main() {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js
index 9c068f20e..282763a4b 100644
--- a/packages/bruno-app/src/utils/codemirror/autocomplete.js
+++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js
@@ -1,40 +1,589 @@
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
+import { mockDataFunctions } from '@usebruno/common';
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
- CodeMirror.registerHelper('hint', 'anyword', (editor, options) => {
- const word = /[\w$-]+/;
- const wordlist = (options && options.autocomplete) || [];
- let cur = editor.getCursor(),
- curLine = editor.getLine(cur.line);
- let end = cur.ch,
- start = end;
- while (start && word.test(curLine.charAt(start - 1))) --start;
- let curWord = start != end && curLine.slice(start, end);
+const CodeMirror = require('codemirror');
- // Check if curWord is a valid string before proceeding
- if (typeof curWord !== 'string' || curWord.length < 3) {
- return null; // Abort the hint
+// Static API hints - Bruno JavaScript API (subgrouped by category)
+const STATIC_API_HINTS = {
+ req: [
+ 'req',
+ 'req.url',
+ 'req.method',
+ 'req.headers',
+ 'req.body',
+ 'req.timeout',
+ 'req.getUrl()',
+ 'req.setUrl(url)',
+ 'req.getMethod()',
+ 'req.getAuthMode()',
+ 'req.setMethod(method)',
+ 'req.getHeader(name)',
+ 'req.getHeaders()',
+ 'req.setHeader(name, value)',
+ 'req.setHeaders(data)',
+ 'req.getBody()',
+ 'req.setBody(data)',
+ 'req.setMaxRedirects(maxRedirects)',
+ 'req.getTimeout()',
+ 'req.setTimeout(timeout)',
+ 'req.getExecutionMode()',
+ 'req.getName()',
+ 'req.disableParsingResponseJson()'
+ ],
+ res: [
+ 'res',
+ 'res.status',
+ 'res.statusText',
+ 'res.headers',
+ 'res.body',
+ 'res.responseTime',
+ 'res.getStatus()',
+ 'res.getStatusText()',
+ 'res.getHeader(name)',
+ 'res.getHeaders()',
+ 'res.getBody()',
+ 'res.setBody(data)',
+ 'res.getResponseTime()'
+ ],
+ bru: [
+ 'bru',
+ 'bru.cwd()',
+ 'bru.getEnvName()',
+ 'bru.getProcessEnv(key)',
+ 'bru.hasEnvVar(key)',
+ 'bru.getEnvVar(key)',
+ 'bru.getFolderVar(key)',
+ 'bru.getCollectionVar(key)',
+ 'bru.setEnvVar(key,value)',
+ 'bru.deleteEnvVar(key)',
+ 'bru.hasVar(key)',
+ 'bru.getVar(key)',
+ 'bru.setVar(key,value)',
+ 'bru.deleteVar(key)',
+ 'bru.deleteAllVars()',
+ 'bru.setNextRequest(requestName)',
+ 'bru.getRequestVar(key)',
+ 'bru.runRequest(requestPathName)',
+ 'bru.getAssertionResults()',
+ 'bru.getTestResults()',
+ 'bru.sleep(ms)',
+ 'bru.getCollectionName()',
+ 'bru.getGlobalEnvVar(key)',
+ 'bru.setGlobalEnvVar(key, value)',
+ 'bru.runner',
+ 'bru.runner.setNextRequest(requestName)',
+ 'bru.runner.skipRequest()',
+ 'bru.runner.stopExecution()',
+ 'bru.interpolate(str)'
+ ]
+};
+
+// Mock data functions - prefixed with $
+const MOCK_DATA_HINTS = Object.keys(mockDataFunctions).map(key => `$${key}`);
+
+// Constants for word pattern matching
+const WORD_PATTERN = /[\w.$-/]/;
+const VARIABLE_PATTERN = /\{\{([\w$.-]*)$/;
+const NON_CHARACTER_KEYS = /^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/;
+
+/**
+ * Generate progressive hints for a given full hint
+ * @param {string} fullHint - The complete hint string
+ * @returns {string[]} Array of progressive 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;
+};
+
+/**
+ * Check if a variable key should be skipped
+ * @param {string} key - The variable key to check
+ * @returns {boolean} True if the key should be skipped
+ */
+const shouldSkipVariableKey = (key) => {
+ return key === 'pathParams' || key === 'maskedEnvVariables' || key === 'process';
+};
+
+/**
+ * Transform variables object into flat hint list
+ * @param {Object} allVariables - All available variables
+ * @returns {string[]} Array of variable hints
+ */
+const transformVariablesToHints = (allVariables = {}) => {
+ const hints = [];
+
+ // Process all variables without type-specific handling
+ Object.keys(allVariables).forEach(key => {
+ if (!shouldSkipVariableKey(key)) {
+ hints.push(key);
}
-
- const list = (options && options.list) || [];
- const re = new RegExp(word.source, 'g');
- for (let dir = -1; dir <= 1; dir += 2) {
- let line = cur.line,
- endLine = Math.min(Math.max(line + dir * 500, editor.firstLine()), editor.lastLine()) + dir;
- for (; line != endLine; line += dir) {
- let text = editor.getLine(line),
- m;
- while ((m = re.exec(text))) {
- if (line == cur.line && curWord.length < 3) continue;
- list.push(...wordlist.filter((el) => el.toLowerCase().startsWith(curWord.toLowerCase())));
- }
- }
- }
- return { list: [...new Set(list)], from: CodeMirror.Pos(cur.line, start), to: CodeMirror.Pos(cur.line, end) };
});
+
+ // Handle process environment variables
+ if (allVariables.process && allVariables.process.env) {
+ Object.keys(allVariables.process.env).forEach(key => {
+ hints.push(`process.env.${key}`);
+ });
+ }
+
+ return hints;
+};
+
+/**
+ * Add API hints to categorized hints based on showHintsFor configuration
+ * @param {Set} apiHints - Set to add API hints to
+ * @param {string[]} showHintsFor - Array of hint types to show
+ */
+const addApiHintsToSet = (apiHints, showHintsFor) => {
+ const apiTypes = ['req', 'res', 'bru'];
+
+ apiTypes.forEach(apiType => {
+ if (showHintsFor.includes(apiType)) {
+ STATIC_API_HINTS[apiType].forEach(hint => {
+ generateProgressiveHints(hint).forEach(h => apiHints.add(h));
+ });
+ }
+ });
+};
+
+/**
+ * Add variable hints to categorized hints
+ * @param {Set} variableHints - Set to add variable hints to
+ * @param {Object} allVariables - All available variables
+ */
+const addVariableHintsToSet = (variableHints, allVariables) => {
+ // Add mock data hints
+ MOCK_DATA_HINTS.forEach(hint => {
+ generateProgressiveHints(hint).forEach(h => variableHints.add(h));
+ });
+
+ // Add variable hints with progressive hints
+ const variableHintsList = transformVariablesToHints(allVariables);
+ variableHintsList.forEach(hint => {
+ generateProgressiveHints(hint).forEach(h => variableHints.add(h));
+ });
+};
+
+/**
+ * Add custom hints to categorized hints
+ * @param {Set} anywordHints - Set to add custom hints to
+ * @param {Object} options - Options containing custom hints
+ */
+const addCustomHintsToSet = (anywordHints, options) => {
+ if (options.anywordAutocompleteHints && Array.isArray(options.anywordAutocompleteHints)) {
+ options.anywordAutocompleteHints.forEach(hint => {
+ generateProgressiveHints(hint).forEach(h => anywordHints.add(h));
+ });
+ }
+};
+
+/**
+ * Build categorized hints list from all sources
+ * @param {Object} allVariables - All available variables
+ * @param {Object} options - Configuration options
+ * @returns {Object} Categorized hints object
+ */
+const buildCategorizedHintsList = (allVariables = {}, options = {}) => {
+ const categorizedHints = {
+ api: new Set(),
+ variables: new Set(),
+ anyword: new Set()
+ };
+
+ const showHintsFor = options.showHintsFor || [];
+
+ // Add different types of hints
+ addApiHintsToSet(categorizedHints.api, showHintsFor);
+ addVariableHintsToSet(categorizedHints.variables, allVariables);
+ addCustomHintsToSet(categorizedHints.anyword, options);
+
+ return {
+ api: Array.from(categorizedHints.api).sort(),
+ variables: Array.from(categorizedHints.variables).sort(),
+ anyword: Array.from(categorizedHints.anyword).sort()
+ };
+};
+
+/**
+ * Calculate replacement positions for variable context
+ * @param {Object} cursor - Current cursor position
+ * @param {Object} startPos - Start position of variable
+ * @param {string} wordMatch - The matched word
+ * @returns {Object} From and to positions for replacement
+ */
+const calculateVariableReplacementPositions = (cursor, startPos, wordMatch) => {
+ let replaceFrom, replaceTo;
+
+ if (wordMatch.endsWith('.')) {
+ replaceFrom = cursor;
+ replaceTo = cursor;
+ } else {
+ const lastDotIndex = wordMatch.lastIndexOf('.');
+ if (lastDotIndex !== -1) {
+ replaceFrom = { line: cursor.line, ch: startPos.ch + lastDotIndex + 1 };
+ replaceTo = cursor;
+ } else {
+ replaceFrom = startPos;
+ replaceTo = cursor;
+ }
+ }
+
+ return { replaceFrom, replaceTo };
+};
+
+/**
+ * Calculate replacement positions for regular word context
+ * @param {Object} cursor - Current cursor position
+ * @param {number} start - Start position of word
+ * @param {number} end - End position of word
+ * @param {string} word - The matched word
+ * @returns {Object} From and to positions for replacement
+ */
+const calculateWordReplacementPositions = (cursor, start, end, word) => {
+ let replaceFrom, replaceTo;
+
+ if (word.endsWith('.')) {
+ replaceFrom = { line: cursor.line, ch: end };
+ replaceTo = cursor;
+ } else {
+ const lastDotIndex = word.lastIndexOf('.');
+ if (lastDotIndex !== -1) {
+ replaceFrom = { line: cursor.line, ch: start + lastDotIndex + 1 };
+ replaceTo = { line: cursor.line, ch: end };
+ } else {
+ replaceFrom = { line: cursor.line, ch: start };
+ replaceTo = { line: cursor.line, ch: end };
+ }
+ }
+
+ return { replaceFrom, replaceTo };
+};
+
+/**
+ * Determine context based on word prefix
+ * @param {string} word - The word to analyze
+ * @returns {string} The determined context
+ */
+const determineWordContext = (word) => {
+ if (word.startsWith('req') || word.startsWith('res') || word.startsWith('bru')) {
+ return 'api';
+ }
+ return 'anyword';
+};
+
+/**
+ * Extract word from current line with boundaries
+ * @param {string} currentLine - The current line content
+ * @param {number} cursorPosition - Current cursor position
+ * @returns {Object|null} Word information or null if no word found
+ */
+const extractWordFromLine = (currentLine, cursorPosition) => {
+ let start = cursorPosition;
+ let end = start;
+
+ while (end < currentLine.length && WORD_PATTERN.test(currentLine.charAt(end))) {
+ ++end;
+ }
+ while (start && WORD_PATTERN.test(currentLine.charAt(start - 1))) {
+ --start;
+ }
+
+ if (start === end) {
+ return null;
+ }
+
+ return {
+ word: currentLine.slice(start, end),
+ start,
+ end
+ };
+};
+
+/**
+ * Get current word being typed at cursor position with context information
+ * @param {Object} cm - CodeMirror instance
+ * @returns {Object|null} Word information with context or null
+ */
+const getCurrentWordWithContext = (cm) => {
+ const cursor = cm.getCursor();
+ const currentLine = cm.getLine(cursor.line);
+ const currentString = cm.getRange({ line: cursor.line, ch: 0 }, cursor);
+
+ // Check for variable pattern {{word
+ const variableMatch = currentString.match(VARIABLE_PATTERN);
+ if (variableMatch) {
+ const wordMatch = variableMatch[1];
+ const startPos = { line: cursor.line, ch: currentString.lastIndexOf('{{') + 2 };
+ const { replaceFrom, replaceTo } = calculateVariableReplacementPositions(cursor, startPos, wordMatch);
+
+ return {
+ word: wordMatch,
+ from: replaceFrom,
+ to: replaceTo,
+ context: 'variables',
+ requiresBraces: true
+ };
+ }
+
+ // Check for regular word
+ const wordInfo = extractWordFromLine(currentLine, cursor.ch);
+ if (!wordInfo) {
+ return null;
+ }
+
+ const { word, start, end } = wordInfo;
+ const { replaceFrom, replaceTo } = calculateWordReplacementPositions(cursor, start, end, word);
+ const context = determineWordContext(word);
+
+ return {
+ word,
+ from: replaceFrom,
+ to: replaceTo,
+ context,
+ requiresBraces: false
+ };
+};
+
+/**
+ * Extract next segment suggestions from filtered hints
+ * @param {string[]} filteredHints - Pre-filtered hints
+ * @param {string} currentInput - Current user input
+ * @returns {string[]} Array of suggestion segments
+ */
+const extractNextSegmentSuggestions = (filteredHints, currentInput) => {
+ const suggestions = new Set();
+
+ filteredHints.forEach(hint => {
+ if (!hint.toLowerCase().startsWith(currentInput.toLowerCase())) {
+ return;
+ }
+
+ // Handle exact match case
+ if (hint.toLowerCase() === currentInput.toLowerCase()) {
+ suggestions.add(hint.substring(hint.lastIndexOf('.') + 1));
+ return;
+ }
+
+ const inputLength = currentInput.length;
+
+ if (currentInput.endsWith('.')) {
+ // Show next segment after the dot
+ const afterDot = hint.substring(inputLength);
+ const nextDot = afterDot.indexOf('.');
+ const segment = nextDot === -1 ? afterDot : afterDot.substring(0, nextDot);
+ suggestions.add(segment);
+ } else {
+ // Show complete current segment
+ const lastDotInInput = currentInput.lastIndexOf('.');
+ const currentSegmentStart = lastDotInInput + 1;
+ const nextDotAfterInput = hint.indexOf('.', currentSegmentStart);
+ const segment = nextDotAfterInput === -1
+ ? hint.substring(currentSegmentStart)
+ : hint.substring(currentSegmentStart, nextDotAfterInput);
+ suggestions.add(segment);
+ }
+ });
+
+ return Array.from(suggestions).sort();
+};
+
+/**
+ * Extract the relevant part of hints based on user input
+ * @param {string[]} filteredHints - Pre-filtered hints
+ * @param {string} currentInput - Current user input
+ * @returns {string[]} Array of hint parts
+ */
+const getHintParts = (filteredHints, currentInput) => {
+ if (!filteredHints || filteredHints.length === 0) {
+ return [];
+ }
+
+ return extractNextSegmentSuggestions(filteredHints, currentInput);
+};
+
+/**
+ * Get allowed hints based on context and configuration
+ * @param {Object} categorizedHints - All categorized hints
+ * @param {string} context - Current context
+ * @param {string[]} showHintsFor - Allowed hint types
+ * @returns {string[]} Array of allowed hints
+ */
+const getAllowedHintsByContext = (categorizedHints, context, showHintsFor) => {
+ let allowedHints = [];
+
+ if (context === 'variables' && showHintsFor.includes('variables')) {
+ allowedHints = [...categorizedHints.variables];
+ } else if (context === 'api') {
+ const hasApiHints = showHintsFor.some(hint => ['req', 'res', 'bru'].includes(hint));
+ if (hasApiHints) {
+ allowedHints = [...categorizedHints.api];
+ }
+ } else if (context === 'anyword') {
+ allowedHints = [...categorizedHints.anyword];
+ }
+
+ return allowedHints;
+};
+
+/**
+ * Filter hints based on current word and allowed hint types
+ * @param {Object} categorizedHints - All categorized hints
+ * @param {string} currentWord - Current word being typed
+ * @param {string} context - Current context
+ * @param {string[]} showHintsFor - Allowed hint types
+ * @returns {string[]} Filtered hints
+ */
+const filterHintsByContext = (categorizedHints, currentWord, context, showHintsFor = []) => {
+ if (!currentWord) {
+ return [];
+ }
+
+ const allowedHints = getAllowedHintsByContext(categorizedHints, context, showHintsFor);
+
+ const filtered = allowedHints.filter(hint => {
+ return hint.toLowerCase().startsWith(currentWord.toLowerCase());
+ });
+
+ const hintParts = getHintParts(filtered, currentWord);
+
+ return hintParts.slice(0, 50);
+};
+
+/**
+ * Create hint list for variables context
+ * @param {string[]} filteredHints - Filtered hints
+ * @param {Object} from - Start position
+ * @param {Object} to - End position
+ * @returns {Object} Hint object with list and positions
+ */
+const createVariableHintList = (filteredHints, from, to) => {
+ const hintList = filteredHints.map(hint => ({
+ text: hint,
+ displayText: hint
+ }));
+
+ return {
+ list: hintList,
+ from,
+ to
+ };
+};
+
+/**
+ * Create hint list for non-variable contexts
+ * @param {string[]} filteredHints - Filtered hints
+ * @param {Object} from - Start position
+ * @param {Object} to - End position
+ * @returns {Object} Hint object with list and positions
+ */
+const createStandardHintList = (filteredHints, from, to) => {
+ return {
+ list: filteredHints,
+ from,
+ to
+ };
+};
+
+/**
+ * Bruno AutoComplete Helper - Main function with context awareness
+ * @param {Object} cm - CodeMirror instance
+ * @param {Object} allVariables - All available variables
+ * @param {Object} options - Configuration options
+ * @returns {Object|null} Hint object or null
+ */
+export const getAutoCompleteHints = (cm, allVariables = {}, options = {}) => {
+ if (!allVariables) {
+ return null;
+ }
+
+ const wordInfo = getCurrentWordWithContext(cm);
+ if (!wordInfo) {
+ return null;
+ }
+
+ const { word, from, to, context, requiresBraces } = wordInfo;
+ const showHintsFor = options.showHintsFor || [];
+
+ // Check if this context requires braces but we're not in a brace context
+ if (context === 'variables' && !requiresBraces) {
+ return null;
+ }
+
+ const categorizedHints = buildCategorizedHintsList(allVariables, options);
+ const filteredHints = filterHintsByContext(categorizedHints, word, context, showHintsFor);
+
+ if (filteredHints.length === 0) {
+ return null;
+ }
+
+ if (context === 'variables') {
+ return createVariableHintList(filteredHints, from, to);
+ }
+
+ return createStandardHintList(filteredHints, from, to);
+};
+
+/**
+ * Handle keyup events for autocomplete
+ * @param {Object} cm - CodeMirror instance
+ * @param {Event} event - The keyup event
+ * @param {Function} getAllVariablesFunc - Function to get all variables
+ * @param {Object} options - Configuration options
+ */
+const handleKeyupForAutocomplete = (cm, event, getAllVariablesFunc, options) => {
+ // Skip non-character keys
+ if (!NON_CHARACTER_KEYS.test(event?.key)) {
+ return;
+ }
+
+ const allVariables = getAllVariablesFunc();
+ const hints = getAutoCompleteHints(cm, allVariables, options);
+
+ if (!hints) {
+ if (cm.state.completionActive) {
+ cm.state.completionActive.close();
+ }
+ return;
+ }
+
+ cm.showHint({
+ hint: () => hints,
+ completeSingle: false
+ });
+};
+
+/**
+ * Setup Bruno AutoComplete Helper on a CodeMirror editor
+ * @param {Object} editor - CodeMirror editor instance
+ * @param {Function} getAllVariablesFunc - Function to get all variables
+ * @param {Object} options - Configuration options
+ * @returns {Function} Cleanup function
+ */
+export const setupAutoComplete = (editor, getAllVariablesFunc, options = {}) => {
+ if (!editor) {
+ return;
+ }
+
+ const keyupHandler = (cm, event) => {
+ handleKeyupForAutocomplete(cm, event, getAllVariablesFunc, options);
+ };
+
+ editor.on('keyup', keyupHandler);
+
+ return () => {
+ editor.off('keyup', keyupHandler);
+ };
+};
+
+// Initialize autocomplete command if not already present
+if (!CodeMirror.commands.autocomplete) {
CodeMirror.commands.autocomplete = (cm, hint, options) => {
cm.showHint({ hint, ...options });
};
-}
+}
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js
new file mode 100644
index 000000000..a14f05917
--- /dev/null
+++ b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js
@@ -0,0 +1,540 @@
+const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
+
+const _mockedCodemirror = {
+ commands: {},
+ getCursor: jest.fn(),
+ getLine: jest.fn(),
+ getRange: jest.fn(),
+ showHint: jest.fn(),
+ on: jest.fn(),
+ off: jest.fn(),
+ state: {}
+};
+
+jest.mock('codemirror', () => {
+ return _mockedCodemirror;
+});
+
+// Import the functions to test
+import {
+ getAutoCompleteHints,
+ setupAutoComplete
+} from './autocomplete';
+
+describe('Bruno Autocomplete', () => {
+ let mockedCodemirror;
+
+ beforeEach(() => {
+ mockedCodemirror = _mockedCodemirror;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('getAutoCompleteHints', () => {
+ describe('Variable autocomplete', () => {
+ it('should provide variable hints when typing inside double curly braces', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 9 });
+ mockedCodemirror.getLine.mockReturnValue('{{envVar}}');
+ mockedCodemirror.getRange.mockReturnValue('{{envVar');
+ const allVariables = {
+ envVar1: 'value1',
+ envVar2: 'value2',
+ };
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ text: 'envVar1', displayText: 'envVar1' }),
+ expect.objectContaining({ text: 'envVar2', displayText: 'envVar2' })
+ ])
+ );
+ });
+
+ it('should include mock data functions with $ prefix', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 9 });
+ mockedCodemirror.getRange.mockReturnValue('{{$randomI');
+
+ const result = getAutoCompleteHints(mockedCodemirror, {}, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result.list).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ displayText: '$randomInt' })
+ ])
+ );
+ });
+
+ it('should handle process environment variables', () => {
+ const allVariables = {
+ process: {
+ env: {
+ NODE_ENV: 'development',
+ API_URL: 'https://api.example.com'
+ }
+ }
+ };
+
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 14 });
+ mockedCodemirror.getRange.mockReturnValue('{{process.env.N');
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ displayText: 'NODE_ENV' })
+ ])
+ );
+ });
+
+ it('should skip special internal keys', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 });
+ mockedCodemirror.getRange.mockReturnValue('{{path');
+
+ const allVariables = {
+ pathParams: { id: '123' },
+ maskedEnvVariables: { secret: '***' },
+ path: 'value'
+ };
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ displayText: 'path' })
+ ])
+ );
+ expect(result.list).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ displayText: 'pathParams' })
+ ])
+ );
+ });
+
+ it('should handle nested object variables', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 12 });
+ mockedCodemirror.getRange.mockReturnValue('{{config.api.');
+
+ const allVariables = {
+ 'config.api.url': 'https://echo.usebruno.com',
+ 'config.api.client_id': 'client_id',
+ 'config.api.client_secret': 'client_secret',
+ 'config.app.name': 'bruno'
+ };
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ displayText: 'url' }),
+ expect.objectContaining({ displayText: 'client_id' }),
+ expect.objectContaining({ displayText: 'client_secret' })
+ ])
+ );
+ });
+ });
+
+ describe('API object context (req, res, bru)', () => {
+ const testCases = [
+ {
+ name: 'req object',
+ input: 'req.',
+ expected: ['url', 'method', 'headers', 'body', 'timeout']
+ },
+ {
+ name: 'res object',
+ input: 'res.',
+ expected: ['status', 'statusText', 'headers', 'body', 'responseTime']
+ },
+ {
+ name: 'bru object',
+ input: 'bru.',
+ expected: ['cwd()', 'getEnvName()', 'getProcessEnv(key)', 'hasEnvVar(key)', 'getEnvVar(key)']
+ }
+ ];
+
+ testCases.forEach(({ name, input, expected }) => {
+ it(`should provide ${name} hints`, () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: input.length });
+ mockedCodemirror.getLine.mockReturnValue(input);
+ mockedCodemirror.getRange.mockReturnValue(input);
+
+ const result = getAutoCompleteHints(mockedCodemirror, {}, {
+ showHintsFor: ['req', 'res', 'bru']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(expect.arrayContaining(expected));
+ });
+ });
+
+ it('should provide method hints for nested req objects', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 7 });
+ mockedCodemirror.getLine.mockReturnValue('req.get');
+ mockedCodemirror.getRange.mockReturnValue('req.get');
+
+ const result = getAutoCompleteHints(mockedCodemirror, {}, {
+ showHintsFor: ['req']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining([
+ 'getUrl()',
+ 'getMethod()',
+ 'getAuthMode()',
+ 'getHeader(name)',
+ 'getHeaders()',
+ 'getBody()',
+ 'getTimeout()',
+ 'getExecutionMode()',
+ 'getName()'
+ ])
+ );
+ });
+
+ it('should handle bru.runner sub-object', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 11 });
+ mockedCodemirror.getLine.mockReturnValue('bru.runner.');
+ mockedCodemirror.getRange.mockReturnValue('bru.runner.');
+
+ const result = getAutoCompleteHints(mockedCodemirror, {}, {
+ showHintsFor: ['bru']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining([
+ 'setNextRequest(requestName)',
+ 'skipRequest()',
+ 'stopExecution()'
+ ])
+ );
+ });
+ });
+
+ describe('Custom hints and anyword context', () => {
+ it('should provide custom anyword hints', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 7 });
+ mockedCodemirror.getLine.mockReturnValue('Content-');
+ mockedCodemirror.getRange.mockReturnValue('Content-');
+
+ const options = {
+ anywordAutocompleteHints: ['Content-Type', 'Content-Encoding', 'Content-Length']
+ };
+
+ const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining(['Content-Type', 'Content-Encoding', 'Content-Length'])
+ );
+ });
+
+ it('should handle progressive hints for custom hints', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 6 });
+ mockedCodemirror.getLine.mockReturnValue('utils.');
+ mockedCodemirror.getRange.mockReturnValue('utils.');
+
+ const options = {
+ anywordAutocompleteHints: ['utils.string.trim', 'utils.string.capitalize', 'utils.array.map']
+ };
+
+ const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining(['string', 'array'])
+ );
+ });
+ });
+
+ describe('Filtering and options', () => {
+ beforeEach(() => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 });
+ mockedCodemirror.getLine.mockReturnValue('req.');
+ mockedCodemirror.getRange.mockReturnValue('req.');
+ });
+
+ it('should respect showHintsFor option for excluding hints', () => {
+ const options = { showHintsFor: ['res', 'bru'] };
+ const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
+ showHintsFor: ['req']
+ });
+
+ expect(result).toBeNull();
+ });
+
+ it('should show hints when included in showHintsFor', () => {
+ const options = { showHintsFor: ['req'] };
+ const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
+ showHintsFor: ['req']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining(['url', 'method'])
+ );
+ });
+
+ it('should filter variables based on showHintsFor', () => {
+ mockedCodemirror.getLine.mockReturnValue('{{varNa}}');
+ mockedCodemirror.getRange.mockReturnValue('{{varNa');
+
+ const allVariables = { envVar1: 'value1' };
+ const options = { showHintsFor: ['req', 'res', 'bru'] };
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, options);
+
+ expect(result).toBeNull();
+ });
+
+ it('should limit results to 50 hints', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 });
+ mockedCodemirror.getLine.mockReturnValue('{{varName}}');
+ mockedCodemirror.getRange.mockReturnValue('{{v');
+
+ const allVariables = {};
+ for (let i = 0; i < 100; i++) {
+ allVariables[`var${i}`] = `value${i}`;
+ }
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list.length).toBeLessThanOrEqual(50);
+ });
+
+ it('should sort hints alphabetically', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 });
+ mockedCodemirror.getLine.mockReturnValue('{{v.');
+ mockedCodemirror.getRange.mockReturnValue('{{v.');
+
+ const allVariables = {
+ 'v.zebra': 'value1',
+ 'v.apple': 'value2',
+ 'v.banana': 'value3'
+ };
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ const displayTexts = result.list.map(item =>
+ typeof item === 'object' ? item.displayText : item
+ );
+
+ const userVars = displayTexts.filter(text => !text.startsWith('$'));
+ expect(userVars).toEqual(['apple', 'banana', 'zebra']);
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should return null when no word is found at cursor', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 });
+ mockedCodemirror.getLine.mockReturnValue(' ');
+ mockedCodemirror.getRange.mockReturnValue('');
+
+ const result = getAutoCompleteHints(mockedCodemirror, {});
+
+ expect(result).toBeNull();
+ });
+
+ it('should handle empty or null variables', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 });
+ mockedCodemirror.getLine.mockReturnValue('{{varName}}');
+ mockedCodemirror.getRange.mockReturnValue('{{varName');
+
+ const emptyResult = getAutoCompleteHints(mockedCodemirror, {});
+ const nullResult = getAutoCompleteHints(mockedCodemirror, null);
+
+ expect(emptyResult).toBeNull();
+ expect(nullResult).toBeNull();
+ });
+
+ it('should handle cursor at end of line', () => {
+ const line = 'req.getHea';
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: line.length });
+ mockedCodemirror.getLine.mockReturnValue(line);
+ mockedCodemirror.getRange.mockReturnValue(line);
+
+ const result = getAutoCompleteHints(mockedCodemirror, {}, {
+ showHintsFor: ['req']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list).toEqual(
+ expect.arrayContaining(['getHeader(name)', 'getHeaders()'])
+ );
+ });
+
+ it('should handle case-insensitive matching', () => {
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 10 });
+ mockedCodemirror.getLine.mockReturnValue('{{varName}}');
+ mockedCodemirror.getRange.mockReturnValue('{{var');
+
+ const allVariables = {
+ variable1: 'value1',
+ Variable2: 'value2',
+ VARIABLE3: 'value3'
+ };
+
+ const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
+ showHintsFor: ['variables']
+ });
+
+ expect(result).toBeTruthy();
+ expect(result.list.length).toBe(3);
+ });
+ });
+ });
+
+ describe('setupAutoComplete', () => {
+ let mockGetAllVariables;
+ let cleanupFn;
+
+ beforeEach(() => {
+ mockGetAllVariables = jest.fn(() => ({ }));
+ mockedCodemirror.state = {};
+ });
+
+ afterEach(() => {
+ if (cleanupFn) {
+ cleanupFn();
+ }
+ });
+
+ describe('Setup and cleanup', () => {
+ it('should setup keyup event listener and return cleanup function', () => {
+ cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables);
+
+ expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function));
+ expect(cleanupFn).toBeInstanceOf(Function);
+
+ cleanupFn();
+ expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function));
+ });
+
+ it('should not setup if editor is null', () => {
+ const result = setupAutoComplete(null, mockGetAllVariables);
+
+ expect(result).toBeUndefined();
+ expect(mockedCodemirror.on).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Event handling', () => {
+ it('should trigger hints on character key press', () => {
+ cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables, {
+ showHintsFor: ['req']
+ });
+ const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
+
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 });
+ mockedCodemirror.getLine.mockReturnValue('req.');
+ mockedCodemirror.getRange.mockReturnValue('req.');
+
+ const mockEvent = { key: 'a' };
+ keyupHandler(mockedCodemirror, mockEvent);
+
+ expect(mockGetAllVariables).toHaveBeenCalled();
+ expect(mockedCodemirror.showHint).toHaveBeenCalled();
+ });
+
+ it('should not trigger hints on non-character keys', () => {
+ cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables);
+ const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
+
+ const nonCharacterKeys = ['Shift', 'Tab', 'Enter', 'Escape', 'ArrowUp', 'ArrowDown', 'Meta'];
+
+ nonCharacterKeys.forEach(key => {
+ const mockEvent = { key };
+ keyupHandler(mockedCodemirror, mockEvent);
+ });
+
+ expect(mockedCodemirror.showHint).not.toHaveBeenCalled();
+ });
+
+ it('should close existing completion when no hints available', () => {
+ cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables);
+ const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
+
+ const mockCompletion = { close: jest.fn() };
+ mockedCodemirror.state.completionActive = mockCompletion;
+
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 });
+ mockedCodemirror.getLine.mockReturnValue(' ');
+ mockedCodemirror.getRange.mockReturnValue('');
+
+ const mockEvent = { key: 'a' };
+ keyupHandler(mockedCodemirror, mockEvent);
+
+ expect(mockCompletion.close).toHaveBeenCalled();
+ });
+
+ it('should pass options to getAutoCompleteHints', () => {
+ const options = { showHintsFor: ['req'] };
+ cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables, options);
+ const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
+
+ mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 });
+ mockedCodemirror.getLine.mockReturnValue('req.');
+ mockedCodemirror.getRange.mockReturnValue('req.');
+
+ const mockEvent = { key: 'a' };
+ keyupHandler(mockedCodemirror, mockEvent);
+
+ expect(mockedCodemirror.showHint).toHaveBeenCalledWith({
+ hint: expect.any(Function),
+ completeSingle: false
+ });
+ });
+ });
+ });
+
+ describe('CodeMirror integration', () => {
+ it('should define autocomplete command if not exists', () => {
+ delete mockedCodemirror.commands.autocomplete;
+
+ jest.isolateModules(() => {
+ require('./autocomplete');
+ });
+
+ expect(mockedCodemirror.commands.autocomplete).toBeDefined();
+ expect(typeof mockedCodemirror.commands.autocomplete).toBe('function');
+ });
+
+ it('should not override existing autocomplete command', () => {
+ const existingCommand = jest.fn();
+ mockedCodemirror.commands.autocomplete = existingCommand;
+
+ jest.isolateModules(() => {
+ require('./autocomplete');
+ });
+
+ expect(mockedCodemirror.commands.autocomplete).toBe(existingCommand);
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/utils/common/codemirror.js b/packages/bruno-app/src/utils/common/codemirror.js
index 64c98989c..7306c63fb 100644
--- a/packages/bruno-app/src/utils/common/codemirror.js
+++ b/packages/bruno-app/src/utils/common/codemirror.js
@@ -1,12 +1,7 @@
import get from 'lodash/get';
import { mockDataFunctions } from '@usebruno/common';
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
-
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
-}
+const CodeMirror = require('codemirror');
const pathFoundInVariables = (path, obj) => {
const value = get(obj, path);
|