diff --git a/packages/bruno-app/src/components/MultiLineEditor/StyledWrapper.js b/packages/bruno-app/src/components/MultiLineEditor/StyledWrapper.js new file mode 100644 index 000000000..6b3a8d568 --- /dev/null +++ b/packages/bruno-app/src/components/MultiLineEditor/StyledWrapper.js @@ -0,0 +1,57 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + width: 100%; + height: fit-content; + max-height: 200px; + overflow: auto; + + .CodeMirror { + background: transparent; + height: fit-content; + font-size: 14px; + line-height: 30px; + overflow: hidden; + + .CodeMirror-scroll { + overflow: hidden !important; + ${'' /* padding-bottom: 50px !important; */} + position: relative; + display: contents; + margin: 0px; + padding: 0px; + } + + .CodeMirror-vscrollbar, + .CodeMirror-hscrollbar, + .CodeMirror-scrollbar-filler { + display: none; + } + + .CodeMirror-lines { + padding: 0; + } + + .CodeMirror-cursor { + height: 20px !important; + margin-top: 5px !important; + border-left: 1px solid ${(props) => props.theme.text} !important; + } + + pre { + font-family: Inter, sans-serif !important; + font-weight: 400; + } + + .CodeMirror-line { + color: ${(props) => props.theme.text}; + padding: 0; + } + + .CodeMirror-selected { + background-color: rgba(212, 125, 59, 0.3); + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js new file mode 100644 index 000000000..d020580e3 --- /dev/null +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -0,0 +1,174 @@ +import React, { Component } from 'react'; +import isEqual from 'lodash/isEqual'; +import { getAllVariables } from 'utils/collections'; +import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; +import StyledWrapper from './StyledWrapper'; + +let CodeMirror; +const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; + +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); + + // Check if curWord is a valid string before proceeding + if (typeof curWord !== 'string' || curWord.length < 3) { + return null; // Abort the hint + } + + 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) }; + }); + CodeMirror.commands.autocomplete = (cm, hint, options) => { + cm.showHint({ hint, ...options }); + }; +} + +class MultiLineEditor extends Component { + constructor(props) { + super(props); + // Keep a cached version of the value, this cache will be updated when the + // editor is updated, which can later be used to protect the editor from + // unnecessary updates during the update lifecycle. + this.cachedValue = props.value || ''; + this.editorRef = React.createRef(); + this.variables = {}; + } + componentDidMount() { + // Initialize CodeMirror as a single line editor + /** @type {import("codemirror").Editor} */ + this.editor = CodeMirror(this.editorRef.current, { + lineWrapping: false, + lineNumbers: false, + theme: this.props.theme === 'dark' ? 'monokai' : 'default', + mode: 'brunovariables', + brunoVarInfo: { + variables: getAllVariables(this.props.collection) + }, + scrollbarStyle: null, + tabindex: 0, + extraKeys: { + Enter: () => { + if (this.props.onRun) { + this.props.onRun(); + } + }, + 'Ctrl-Enter': () => { + if (this.props.onRun) { + this.props.onRun(); + } + }, + 'Cmd-Enter': () => { + if (this.props.onRun) { + this.props.onRun(); + } + }, + 'Alt-Enter': () => { + this.editor.setValue(this.editor.getValue() + '\n'); + this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 }); + }, + 'Shift-Enter': () => { + this.editor.setValue(this.editor.getValue() + '\n'); + this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 }); + }, + 'Cmd-S': () => { + if (this.props.onSave) { + this.props.onSave(); + } + }, + 'Ctrl-S': () => { + if (this.props.onSave) { + this.props.onSave(); + } + }, + 'Cmd-F': () => {}, + 'Ctrl-F': () => {}, + // Tabbing disabled to make tabindex work + Tab: false, + '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 }); + } + }); + } + this.editor.setValue(String(this.props.value) || ''); + this.editor.on('change', this._onEdit); + this.addOverlay(); + } + + _onEdit = () => { + if (!this.ignoreChangeEvent && this.editor) { + this.cachedValue = this.editor.getValue(); + if (this.props.onChange) { + this.props.onChange(this.cachedValue); + } + } + }; + + componentDidUpdate(prevProps) { + // Ensure the changes caused by this update are not interpreted as + // user-input changes which could otherwise result in an infinite + // event loop. + this.ignoreChangeEvent = true; + + let variables = getAllVariables(this.props.collection); + if (!isEqual(variables, this.variables)) { + this.editor.options.brunoVarInfo.variables = variables; + this.addOverlay(); + } + if (this.props.theme !== prevProps.theme && this.editor) { + this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default'); + } + if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) { + this.cachedValue = String(this.props.value); + this.editor.setValue(String(this.props.value) || ''); + } + if (this.editorRef?.current) { + this.editorRef.current.scrollTo(0, 10000); + } + this.ignoreChangeEvent = false; + } + + componentWillUnmount() { + this.editor.getWrapperElement().remove(); + } + + addOverlay = () => { + let variables = getAllVariables(this.props.collection); + this.variables = variables; + + defineCodeMirrorBrunoVariablesMode(variables, 'text/plain'); + this.editor.setOption('mode', 'brunovariables'); + }; + + render() { + return ; + } +} +export default MultiLineEditor; diff --git a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js index 6d1d5be25..a358e2ed3 100644 --- a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js @@ -9,7 +9,7 @@ import { updateFormUrlEncodedParam, deleteFormUrlEncodedParam } from 'providers/ReduxStore/slices/collections'; -import SingleLineEditor from 'components/SingleLineEditor'; +import MultiLineEditor from 'components/MultiLineEditor'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; @@ -92,7 +92,7 @@ const FormUrlEncodedParams = ({ item, collection }) => { /> - { collection={collection} /> ) : ( - { ) } onRun={handleRun} + allowNewlines={true} collection={collection} /> )} diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index db8b20f5f..762d9c9ed 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -7,6 +7,7 @@ import { useTheme } from 'providers/Theme'; import { addRequestHeader, updateRequestHeader, deleteRequestHeader } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import SingleLineEditor from 'components/SingleLineEditor'; +import MultiLineEditor from 'components/MultiLineEditor'; import StyledWrapper from './StyledWrapper'; import { headers as StandardHTTPHeaders } from 'know-your-http-well'; const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); @@ -99,7 +100,7 @@ const RequestHeaders = ({ item, collection }) => { /> - { ) } onRun={handleRun} + allowNewlines={true} collection={collection} /> diff --git a/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js b/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js index aed888e3d..592a75b28 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/SingleLineEditor/StyledWrapper.js @@ -13,20 +13,15 @@ const StyledWrapper = styled.div` line-height: 30px; overflow: hidden; - .CodeMirror-vscrollbar { - display: none !important; - } - .CodeMirror-scroll { overflow: hidden !important; padding-bottom: 50px !important; } - .CodeMirror-hscrollbar { - display: none !important; - } + .CodeMirror-vscrollbar, + .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler { - display: none !important; + display: none; } .CodeMirror-lines { @@ -46,8 +41,7 @@ const StyledWrapper = styled.div` .CodeMirror-line { color: ${(props) => props.theme.text}; - padding-left: 0; - padding-right: 0; + padding: 0; } .CodeMirror-selected { diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index afb0a43e0..6f12a6ce5 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -35,12 +35,16 @@ const grammar = ohm.grammar(`Bru { keychar = ~(tagend | st | nl | ":") any valuechar = ~(nl | tagend) any + // Multiline text block surrounded by ''' + multilinetextblockdelimiter = "'''" + multilinetextblock = multilinetextblockdelimiter (~multilinetextblockdelimiter any)* multilinetextblockdelimiter + // Dictionary Blocks dictionary = st* "{" pairlist? tagend pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)* pair = st* key st* ":" st* value st* key = keychar* - value = valuechar* + value = multilinetextblock | valuechar* // Dictionary for Assert Block assertdictionary = st* "{" assertpairlist? tagend @@ -186,6 +190,19 @@ const sem = grammar.createSemantics().addAttribute('ast', { return chars.sourceString ? chars.sourceString.trim() : ''; }, value(chars) { + try { + let isMultiline = chars.sourceString?.startsWith(`'''`) && chars.sourceString?.endsWith(`'''`); + if (isMultiline) { + const multilineString = chars.sourceString?.replace(/^'''|'''$/g, ''); + return multilineString + .split('\n') + .map((line) => line.slice(4)) + .join('\n'); + } + return chars.sourceString ? chars.sourceString.trim() : ''; + } catch (err) { + console.error(err); + } return chars.sourceString ? chars.sourceString.trim() : ''; }, assertdictionary(_1, _2, pairlist, _3) { diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index a59d7cd7c..3357e5d09 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -12,6 +12,23 @@ const stripLastLine = (text) => { return text.replace(/(\r?\n)$/, ''); }; +const getValueString = (value) => { + const hasNewLines = value.includes('\n'); + + if (!hasNewLines) { + return value; + } + + // Add one level of indentation to the contents of the multistring + const indentedLines = value + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); + + // Join the lines back together with newline characters and enclose them in triple single quotes + return `'''\n${indentedLines}\n'''`; +}; + const jsonToBru = (json) => { const { meta, http, query, headers, auth, body, script, tests, vars, assertions, docs } = json; @@ -202,24 +219,23 @@ ${indentString(body.sparql)} } if (body && body.formUrlEncoded && body.formUrlEncoded.length) { - bru += `body:form-urlencoded {`; + bru += `body:form-urlencoded {\n`; + if (enabled(body.formUrlEncoded).length) { - bru += `\n${indentString( - enabled(body.formUrlEncoded) - .map((item) => `${item.name}: ${item.value}`) - .join('\n') - )}`; + const enabledValues = enabled(body.formUrlEncoded) + .map((item) => `${item.name}: ${getValueString(item.value)}`) + .join('\n'); + bru += `${indentString(enabledValues)}\n`; } if (disabled(body.formUrlEncoded).length) { - bru += `\n${indentString( - disabled(body.formUrlEncoded) - .map((item) => `~${item.name}: ${item.value}`) - .join('\n') - )}`; + const disabledValues = disabled(body.formUrlEncoded) + .map((item) => `~${item.name}: ${getValueString(item.value)}`) + .join('\n'); + bru += `${indentString(disabledValues)}\n`; } - bru += '\n}\n\n'; + bru += '}\n\n'; } if (body && body.multipartForm && body.multipartForm.length) {