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);