diff --git a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js index 45e3e5834..a03c93506 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js @@ -45,7 +45,8 @@ const Headers = ({ collection }) => { const header = cloneDeep(_header); switch (type) { case 'name': { - header.name = e.target.value; + // Strip newlines from header keys + header.name = e.target.value.replace(/[\r\n]/g, ''); break; } case 'value': { diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js index fd15eee8c..e84ddc081 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Vars/VarsTable/index.js @@ -4,7 +4,7 @@ import { IconTrash } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; -import SingleLineEditor from 'components/SingleLineEditor'; +import MultiLineEditor from 'components/MultiLineEditor'; import InfoTip from 'components/InfoTip'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; @@ -114,7 +114,7 @@ const VarsTable = ({ collection, vars, varType }) => { /> - { const header = cloneDeep(_header); switch (type) { case 'name': { - header.name = e.target.value; + // Strip newlines from header keys + header.name = e.target.value.replace(/[\r\n]/g, ''); break; } case 'value': { diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js index b0815c018..69f083594 100644 --- a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js @@ -4,7 +4,7 @@ import { IconTrash } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; -import SingleLineEditor from 'components/SingleLineEditor'; +import MultiLineEditor from 'components/MultiLineEditor'; import InfoTip from 'components/InfoTip'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; @@ -113,7 +113,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => { /> - { <>
{hoveredRow === index && ( <> diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/index.js index 1d2f81bee..93b519464 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/index.js @@ -4,7 +4,8 @@ import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { IconPlus, IconTrash, IconAdjustmentsHorizontal } from '@tabler/icons'; import { cloneDeep } from "lodash"; -import SingleLineEditor from "components/SingleLineEditor/index"; +import SingleLineEditor from 'components/SingleLineEditor/index'; +import MultiLineEditor from 'components/MultiLineEditor/index'; import StyledWrapper from "./StyledWrapper"; import Table from "components/Table/index"; @@ -205,7 +206,7 @@ const AdditionalParams = ({ item = {}, request, updateAuth, collection, handleS /> - handleUpdateAdditionalParam({ diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index b5c2c69a7..4a1e37f4c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -13,7 +13,7 @@ import { updatePathParam, setQueryParams } from 'providers/ReduxStore/slices/collections'; -import SingleLineEditor from 'components/SingleLineEditor'; +import MultiLineEditor from 'components/MultiLineEditor'; import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import StyledWrapper from './StyledWrapper'; @@ -168,7 +168,7 @@ const QueryParams = ({ item, collection }) => { /> - { /> - { collection={collection} highlightPathParams={true} item={item} + showNewlineArrow={true} />
{ const dispatch = useDispatch(); const { storedTheme } = useTheme(); const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); - + const [isBulkEditMode, setIsBulkEditMode] = useState(false); const addHeader = () => { @@ -36,9 +36,11 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { const handleRun = () => dispatch(sendRequest(item, collection.uid)); const handleHeaderValueChange = (e, _header, type) => { const header = cloneDeep(_header); + switch (type) { case 'name': { - header.name = e.target.value; + // Strip newlines from header keys + header.name = e.target.value.replace(/[\r\n]/g, ''); break; } case 'value': { @@ -50,6 +52,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { break; } } + dispatch( updateRequestHeader({ header: header, @@ -154,7 +157,6 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { } onRun={handleRun} autocomplete={MimeTypes} - allowNewlines={true} collection={collection} item={item} /> diff --git a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js index cd3f83797..1189747b4 100644 --- a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js @@ -5,7 +5,7 @@ import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { addVar, updateVar, deleteVar, moveVar } 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 InfoTip from 'components/InfoTip'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; @@ -122,7 +122,7 @@ const VarsTable = ({ item, collection, vars, varType }) => { /> - { + if (!this.editor) return; + + // Clear existing markers + this._clearNewlineMarkers(); + + this.newlineMarkers = []; + const content = this.editor.getValue(); + + // Find all newlines and replace them with arrow widgets + for (let i = 0; i < content.length; i++) { + if (content[i] === '\n') { + const pos = this.editor.posFromIndex(i); + const nextPos = this.editor.posFromIndex(i + 1); + + // Create a widget to display the arrow + const arrow = document.createElement('span'); + arrow.className = 'newline-arrow'; + arrow.textContent = '↲'; + arrow.style.cssText = ` + color: #888; + font-size: 8px; + margin: 0 2px; + vertical-align: middle; + display: inline-block; + `; + + // Mark the newline character and replace it with the arrow widget + const marker = this.editor.markText(pos, nextPos, { + replacedWith: arrow, + handleMouseEvents: true + }); + + this.newlineMarkers.push(marker); + } + } + }; + + /** + * Clear all newline markers + */ + _clearNewlineMarkers = () => { + if (this.newlineMarkers) { + this.newlineMarkers.forEach((marker) => { + try { + marker.clear(); + } catch (e) { + // Marker might already be cleared + } + }); + this.newlineMarkers = []; + } + }; + toggleVisibleSecret = () => { const isVisible = !this.state.maskInput; this.setState({ maskInput: isVisible }); @@ -204,13 +277,15 @@ class SingleLineEditor extends Component { render() { return ( -
+
- {this.secretEye(this.props.isSecret)} +
+ {this.secretEye(this.props.isSecret)} +
); } diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index f3925ad62..4d0ce8d6c 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -20,12 +20,22 @@ 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* + pair = st* (quoted_key | key) st* ":" st* value st* + disable_char = "~" + quote_char = "\\"" + esc_char = "\\\\" + esc_quote_char = esc_char quote_char + quoted_key_char = ~(quote_char | esc_quote_char | nl) any + quoted_key = disable_char? quote_char (esc_quote_char | quoted_key_char)* quote_char key = keychar* - value = valuechar* + value = multilinetextblock | valuechar* // Text Blocks textblock = textline (~tagend nl textline)* @@ -137,10 +147,38 @@ const sem = grammar.createSemantics().addAttribute('ast', { res[key.ast] = value.ast ? value.ast.trim() : ''; return res; }, + quoted_key(disabled, _1, chars, _2) { + // unquote and handle disabled prefix + return (disabled ? disabled.sourceString : '') + chars.ast.join(''); + }, + esc_quote_char(_1, quote) { + // unescape + return quote.sourceString; + }, + quoted_key_char(char) { + // return the character itself + return char.sourceString; + }, key(chars) { return chars.sourceString ? chars.sourceString.trim() : ''; }, value(chars) { + if (chars.ctorName === 'list') { + return chars.ast; + } + 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() : ''; }, textblock(line, _1, rest) { @@ -152,6 +190,10 @@ const sem = grammar.createSemantics().addAttribute('ast', { textchar(char) { return char.sourceString; }, + multilinetextblock(_1, content, _2) { + // Join all the content between the triple quotes and trim it + return content.sourceString.trim(); + }, nl(_1, _2) { return ''; }, diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index 9258be11b..b7decd9ef 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -1,14 +1,10 @@ const _ = require('lodash'); -const { indentString, getValueString } = require('./utils'); +const { indentString, getValueString, getKeyString, getValueUrl } = require('./utils'); const jsonToExampleBru = require('./example/jsonToBru'); const enabled = (items = [], key = "enabled") => items.filter((item) => item[key]); const disabled = (items = [], key = "enabled") => items.filter((item) => !item[key]); -const quoteKey = (key) => { - const quotableChars = [':', '"', '{', '}', ' ']; - return quotableChars.some(char => key.includes(char)) ? ('"' + key.replaceAll('"', '\\"') + '"') : key; -} // remove the last line if two new lines are found const stripLastLine = (text) => { @@ -50,7 +46,7 @@ const jsonToBru = (json) => { const isStandard = standardMethods.has(method); bru += isStandard ? `${method} {` : `http {\n method: ${method}`; - bru += `\n url: ${url}`; + bru += `\n url: ${getValueUrl(url)}`; if (body?.length) { bru += `\n body: ${body}`; @@ -133,7 +129,7 @@ const jsonToBru = (json) => { if (enabled(queryParams).length) { bru += `\n${indentString( enabled(queryParams) - .map((item) => `${quoteKey(item.name)}: ${item.value}`) + .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -141,7 +137,7 @@ const jsonToBru = (json) => { if (disabled(queryParams).length) { bru += `\n${indentString( disabled(queryParams) - .map((item) => `~${quoteKey(item.name)}: ${item.value}`) + .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -152,7 +148,7 @@ const jsonToBru = (json) => { if (pathParams.length) { bru += 'params:path {'; - bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(pathParams.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; bru += '\n}\n\n'; } @@ -163,7 +159,7 @@ const jsonToBru = (json) => { if (enabled(headers).length) { bru += `\n${indentString( enabled(headers) - .map((item) => `${quoteKey(item.name)}: ${item.value}`) + .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -171,7 +167,7 @@ const jsonToBru = (json) => { if (disabled(headers).length) { bru += `\n${indentString( disabled(headers) - .map((item) => `~${quoteKey(item.name)}: ${item.value}`) + .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -184,7 +180,7 @@ const jsonToBru = (json) => { if (enabled(metadata).length) { bru += `\n${indentString( enabled(metadata) - .map((item) => `${item.name}: ${item.value}`) + .map((item) => `${item.name}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -192,7 +188,7 @@ const jsonToBru = (json) => { if (disabled(metadata).length) { bru += `\n${indentString( disabled(metadata) - .map((item) => `~${item.name}: ${item.value}`) + .map((item) => `~${item.name}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -360,7 +356,7 @@ ${indentString(`auto_fetch_token: ${(auth?.oauth2?.autoFetchToken ?? true).toStr ${indentString( authorizationHeaders .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -373,7 +369,7 @@ ${indentString( ${indentString( authorizationQueryParams .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -386,7 +382,7 @@ ${indentString( ${indentString( tokenHeaders .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -399,7 +395,7 @@ ${indentString( ${indentString( tokenQueryParams .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -412,7 +408,7 @@ ${indentString( ${indentString( tokenBodyValues .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -425,7 +421,7 @@ ${indentString( ${indentString( refreshHeaders .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -438,7 +434,7 @@ ${indentString( ${indentString( refreshQueryParams .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -451,7 +447,7 @@ ${indentString( ${indentString( refreshBodyValues .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -508,14 +504,14 @@ ${indentString(body.sparql)} if (enabled(body.formUrlEncoded).length) { const enabledValues = enabled(body.formUrlEncoded) - .map((item) => `${quoteKey(item.name)}: ${getValueString(item.value)}`) + .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n'); bru += `${indentString(enabledValues)}\n`; } if (disabled(body.formUrlEncoded).length) { const disabledValues = disabled(body.formUrlEncoded) - .map((item) => `~${quoteKey(item.name)}: ${getValueString(item.value)}`) + .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n'); bru += `${indentString(disabledValues)}\n`; } @@ -536,7 +532,7 @@ ${indentString(body.sparql)} item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : ''; if (item.type === 'text') { - return `${enabled}${quoteKey(item.name)}: ${getValueString(item.value)}${contentType}`; + return `${enabled}${getKeyString(item.name)}: ${getValueString(item.value)}${contentType}`; } if (item.type === 'file') { @@ -544,7 +540,7 @@ ${indentString(body.sparql)} const filestr = filepaths.join('|'); const value = `@file(${filestr})`; - return `${enabled}${quoteKey(item.name)}: ${value}${contentType}`; + return `${enabled}${getKeyString(item.name)}: ${value}${contentType}`; } }) .join('\n') @@ -644,19 +640,19 @@ ${indentString(body.sparql)} bru += `vars:pre-request {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; @@ -670,19 +666,19 @@ ${indentString(body.sparql)} bru += `vars:post-response {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; @@ -694,7 +690,7 @@ ${indentString(body.sparql)} if (enabled(assertions).length) { bru += `\n${indentString( enabled(assertions) - .map((item) => `${item.name}: ${item.value}`) + .map((item) => `${item.name}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -702,7 +698,7 @@ ${indentString(body.sparql)} if (disabled(assertions).length) { bru += `\n${indentString( disabled(assertions) - .map((item) => `~${item.name}: ${item.value}`) + .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/v2/src/jsonToCollectionBru.js index d5aa1c1e0..5016611c0 100644 --- a/packages/bruno-lang/v2/src/jsonToCollectionBru.js +++ b/packages/bruno-lang/v2/src/jsonToCollectionBru.js @@ -1,6 +1,6 @@ const _ = require('lodash'); -const { indentString } = require('./utils'); +const { indentString, getValueString, getKeyString } = require('./utils'); const enabled = (items = []) => items.filter((item) => item.enabled); const disabled = (items = []) => items.filter((item) => !item.enabled); @@ -30,7 +30,7 @@ const jsonToCollectionBru = (json) => { if (enabled(query).length) { bru += `\n${indentString( enabled(query) - .map((item) => `${item.name}: ${item.value}`) + .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -38,7 +38,7 @@ const jsonToCollectionBru = (json) => { if (disabled(query).length) { bru += `\n${indentString( disabled(query) - .map((item) => `~${item.name}: ${item.value}`) + .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -51,7 +51,7 @@ const jsonToCollectionBru = (json) => { if (enabled(headers).length) { bru += `\n${indentString( enabled(headers) - .map((item) => `${item.name}: ${item.value}`) + .map((item) => `${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -59,7 +59,7 @@ const jsonToCollectionBru = (json) => { if (disabled(headers).length) { bru += `\n${indentString( disabled(headers) - .map((item) => `~${item.name}: ${item.value}`) + .map((item) => `~${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )}`; } @@ -243,7 +243,7 @@ ${indentString(`auto_refresh_token: ${(auth?.oauth2?.autoRefreshToken ?? false). ${indentString( authorizationHeaders .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -256,7 +256,7 @@ ${indentString( ${indentString( authorizationQueryParams .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -269,7 +269,7 @@ ${indentString( ${indentString( tokenHeaders .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -282,9 +282,8 @@ ${indentString( ${indentString( tokenQueryParams .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) - .join('\n') - )} + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) + .join('\n'))} } `; @@ -295,7 +294,7 @@ ${indentString( ${indentString( tokenBodyValues .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -308,7 +307,7 @@ ${indentString( ${indentString( refreshHeaders .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -321,7 +320,7 @@ ${indentString( ${indentString( refreshQueryParams .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -334,7 +333,7 @@ ${indentString( ${indentString( refreshBodyValues .filter(item => item?.name?.length) - .map((item) => `${item.enabled ? '' : '~'}${item.name}: ${item.value}`) + .map((item) => `${item.enabled ? '' : '~'}${getKeyString(item.name)}: ${getValueString(item.value)}`) .join('\n') )} } @@ -355,19 +354,19 @@ ${indentString( bru += `vars:pre-request {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; @@ -381,19 +380,19 @@ ${indentString( bru += `vars:post-response {`; if (varsEnabled.length) { - bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsEnabled.map((item) => `${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalEnabled.length) { - bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalEnabled.map((item) => `@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsDisabled.length) { - bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsDisabled.map((item) => `~${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } if (varsLocalDisabled.length) { - bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${item.value}`).join('\n'))}`; + bru += `\n${indentString(varsLocalDisabled.map((item) => `~@${item.name}: ${getValueString(item.value)}`).join('\n'))}`; } bru += '\n}\n\n'; diff --git a/packages/bruno-lang/v2/src/utils.js b/packages/bruno-lang/v2/src/utils.js index 8dee48999..5b666cc68 100644 --- a/packages/bruno-lang/v2/src/utils.js +++ b/packages/bruno-lang/v2/src/utils.js @@ -8,14 +8,15 @@ const safeParseJson = (json) => { }; -const indentString = (str) => { +const indentString = (str, levels = 1) => { if (!str || !str.length) { return str || ''; } + const indent = ' '.repeat(levels); return str .split(/\r\n|\r|\n/) - .map((line) => ' ' + line) + .map((line) => indent + line) .join('\n'); }; @@ -37,7 +38,7 @@ const getValueString = (value) => { return ''; } - const hasNewLines = value?.includes('\n') || value?.includes('\r'); + const hasNewLines = value.includes('\n') || value.includes('\r'); if (!hasNewLines) { return value; @@ -47,9 +48,32 @@ const getValueString = (value) => { return `'''\n${indentString(value)}\n'''`; }; +const getKeyString = (key) => { + const quotableChars = [':', '"', '{', '}', ' ']; + return quotableChars.some((char) => key.includes(char)) ? ('"' + key.replaceAll('"', '\\"') + '"') : key; +}; + +const getValueUrl = (url) => { + // Handle null, undefined, and empty strings + if (!url) { + return ''; + } + + const hasNewLines = url.includes('\n') || url.includes('\r'); + + if (!hasNewLines) { + return url; + } + + // Wrap multiline values in triple quotes with 4-space indentation (2 levels) + return `'''\n${indentString(url, 2)}\n'''`; +}; + module.exports = { safeParseJson, indentString, outdentString, - getValueString + getValueString, + getKeyString, + getValueUrl }; diff --git a/packages/bruno-lang/v2/tests/bruToJson.spec.js b/packages/bruno-lang/v2/tests/bruToJson.spec.js index aa94ccef1..6ad965771 100644 --- a/packages/bruno-lang/v2/tests/bruToJson.spec.js +++ b/packages/bruno-lang/v2/tests/bruToJson.spec.js @@ -38,4 +38,88 @@ settings { expect(output).toEqual(expected); }); }); + + describe('multi-line values', () => { + it('parses multi-line values in URL, headers, params, and vars', () => { + const input = ` +meta { + name: new-line + type: http + seq: 1 +} + +get { + url: ''' + https://httpbin.io/anything?foo=hello + world +''' + body: none + auth: oauth2 +} + +params:query { + foo: ''' + hello + world + ''' +} + +headers { + "test header": ''' + t1 + t2 + ''' +} + +vars:pre-request { + test-var: ''' + t1 + t2 + ''' +} +`; + + const expected = { + meta: { + name: 'new-line', + type: 'http', + seq: '1' + }, + http: { + method: 'get', + url: 'https://httpbin.io/anything?foo=hello\nworld', + body: 'none', + auth: 'oauth2' + }, + params: [ + { + name: 'foo', + value: 'hello\nworld', + enabled: true, + type: 'query' + } + ], + headers: [ + { + name: 'test header', + value: 't1\nt2', + enabled: true + } + ], + vars: { + req: [ + { + name: 'test-var', + value: 't1\nt2', + enabled: true, + local: false + } + ] + } + }; + + const output = parser(input); + expect(output).toEqual(expected); + }); + }); }); diff --git a/packages/bruno-lang/v2/tests/getKeyString.spec.js b/packages/bruno-lang/v2/tests/getKeyString.spec.js new file mode 100644 index 000000000..278a4226a --- /dev/null +++ b/packages/bruno-lang/v2/tests/getKeyString.spec.js @@ -0,0 +1,56 @@ +const { getKeyString } = require('../src/utils'); + +describe('getKeyString', () => { + describe('should not quote keys without special characters', () => { + it('should return simple alphanumeric keys as-is', () => { + expect(getKeyString('hello')).toBe('hello'); + expect(getKeyString('world123')).toBe('world123'); + expect(getKeyString('API')).toBe('API'); + }); + + it('should return keys with hyphens as-is', () => { + expect(getKeyString('api-key')).toBe('api-key'); + expect(getKeyString('content-type')).toBe('content-type'); + }); + + it('should return keys with underscores as-is', () => { + expect(getKeyString('api_key')).toBe('api_key'); + expect(getKeyString('user_name')).toBe('user_name'); + }); + }); + + describe('should quote keys with special characters', () => { + it('should quote keys with colons', () => { + expect(getKeyString('key:value')).toBe('"key:value"'); + expect(getKeyString('disabled:colon:header')).toBe('"disabled:colon:header"'); + expect(getKeyString(':startsWithColon')).toBe('":startsWithColon"'); + expect(getKeyString('endsWithColon:')).toBe('"endsWithColon:"'); + }); + + it('should quote keys with spaces', () => { + expect(getKeyString('key with spaces')).toBe('"key with spaces"'); + expect(getKeyString(' leadingSpace')).toBe('" leadingSpace"'); + expect(getKeyString('trailingSpace ')).toBe('"trailingSpace "'); + expect(getKeyString('multiple spaces')).toBe('"multiple spaces"'); + }); + + it('should quote keys with curly braces', () => { + expect(getKeyString('{braces}')).toBe('"{braces}"'); + expect(getKeyString('{only-open')).toBe('"{only-open"'); + expect(getKeyString('only-close}')).toBe('"only-close}"'); + expect(getKeyString('nested{brace}here')).toBe('"nested{brace}here"'); + }); + + it('should quote keys with double quotes and escape them', () => { + expect(getKeyString('nested "quote"')).toBe('"nested \\"quote\\""'); + expect(getKeyString('"quoted"')).toBe('"\\"quoted\\""'); + expect(getKeyString('multiple "quotes" here "too"')).toBe('"multiple \\"quotes\\" here \\"too\\""'); + }); + + it('should quote keys with multiple special characters', () => { + expect(getKeyString('key: value')).toBe('"key: value"'); + expect(getKeyString('{key}: "value"')).toBe('"{key}: \\"value\\""'); + expect(getKeyString('complex:key with {braces}')).toBe('"complex:key with {braces}"'); + }); + }); +}); diff --git a/packages/bruno-lang/v2/tests/jsonToBru.spec.js b/packages/bruno-lang/v2/tests/jsonToBru.spec.js index 56e8ea059..059146469 100644 --- a/packages/bruno-lang/v2/tests/jsonToBru.spec.js +++ b/packages/bruno-lang/v2/tests/jsonToBru.spec.js @@ -53,4 +53,87 @@ describe('jsonToBru stringify', () => { expect(output).toMatch(new RegExp(`timeout: ${input.settings.timeout}`)); }); }); + + describe('multi-line values', () => { + it('handles multi-line values in URL, headers, params, and vars', () => { + const input = { + meta: { + name: 'new-line', + type: 'http', + seq: 1 + }, + http: { + method: 'get', + url: 'https://httpbin.io/anything?foo=hello\nworld', + body: 'none', + auth: 'oauth2' + }, + params: [ + { + name: 'foo', + value: 'hello\nworld', + enabled: true, + type: 'query' + } + ], + headers: [ + { + name: 'test header', + value: 't1\nt2', + enabled: true + } + ], + vars: { + req: [ + { + name: 'test-var', + value: 't1\nt2', + enabled: true + } + ] + } + }; + + const output = stringify(input); + + expect(output).toMatchInlineSnapshot(` + "meta { + name: new-line + type: http + seq: 1 + } + + get { + url: ''' + https://httpbin.io/anything?foo=hello + world + ''' + body: none + auth: oauth2 + } + + params:query { + foo: ''' + hello + world + ''' + } + + headers { + "test header": ''' + t1 + t2 + ''' + } + + vars:pre-request { + test-var: ''' + t1 + t2 + ''' + } + " + `); + }); + }); }); diff --git a/tests/request/newlines/newlines-persistence.spec.ts b/tests/request/newlines/newlines-persistence.spec.ts new file mode 100644 index 000000000..ac8f2ecb8 --- /dev/null +++ b/tests/request/newlines/newlines-persistence.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from '../../../playwright'; +import { openCollectionAndAcceptSandbox } from '../../utils/page/actions'; +import { getTableCell } from '../../utils/page/locators'; + +test('should persist request with newlines across app restarts', async ({ createTmpDir, launchElectronApp }) => { + const userDataPath = await createTmpDir('newlines-persistence-userdata'); + const collectionPath = await createTmpDir('newlines-persistence-collection'); + + // Create collection and request + const app1 = await launchElectronApp({ userDataPath }); + const page = await app1.firstWindow(); + + await page.locator('.dropdown-icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Create Collection' }).click(); + await page.getByLabel('Name').fill('newlines-persistence'); + await page.getByLabel('Location').fill(collectionPath); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + + const collection = page.locator('.collection-name').filter({ hasText: 'newlines-persistence' }); + await collection.locator('.collection-actions').hover(); + await collection.locator('.collection-actions .icon').click(); + await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); + await page.getByPlaceholder('Request Name').fill('persistence-test'); + await page.locator('#new-request-url').locator('.CodeMirror').click(); + await page.locator('#new-request-url').locator('textarea').fill('https://httpbin.org/get'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + + await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe'); + await page.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick(); + + // Add query param + await page.getByRole('tab', { name: 'Params' }).click(); + await page.getByRole('button', { name: /Add.*Param/i }).click(); + + const paramRow = page.locator('table tbody tr').last(); + await getTableCell(paramRow, 0).locator('input[type="text"]').fill('queryParamKey'); + + // Add header with newlines + await page.getByRole('tab', { name: 'Headers' }).click(); + await page.getByRole('button', { name: /Add.*Header/i }).click(); + + const headerRow = page.locator('table tbody tr').last(); + await getTableCell(headerRow, 0).locator('.CodeMirror').click(); + await getTableCell(headerRow, 0).locator('textarea').fill('headerKey'); + await getTableCell(headerRow, 1).locator('.CodeMirror').click(); + await getTableCell(headerRow, 1).locator('textarea').fill('header\nValue'); + + // Add Pre Request var with newlines + await page.getByRole('tab', { name: 'Vars' }).click(); + await page.locator('.btn-add-var').first().click(); + const preReqRow = page.locator('table').first().locator('tbody tr').first(); + await getTableCell(preReqRow, 0).locator('input[type="text"]').fill('preRequestVar'); + await getTableCell(preReqRow, 1).locator('.CodeMirror').click(); + await getTableCell(preReqRow, 1).locator('textarea').fill('pre\nRequest\nValue'); + + // Add Post Response var with newlines + await page.locator('.btn-add-var').last().click(); + const postResRow = page.locator('table').nth(1).locator('tbody tr').first(); + await getTableCell(postResRow, 0).locator('input[type="text"]').fill('postResponseVar'); + await getTableCell(postResRow, 1).locator('.CodeMirror').click(); + await getTableCell(postResRow, 1).locator('textarea').fill('post\nResponse\nValue'); + + await page.keyboard.press('Meta+s'); + await app1.close(); + + // Verify persistence after restart + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + + await page2.locator('.collection-name').filter({ hasText: 'newlines-persistence' }).click(); + await page2.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick(); + + // Verify params persisted + await page2.getByRole('tab', { name: 'Params' }).click(); + await expect(page2.locator('table tbody tr')).toHaveCount(1); + + // Verify headers persisted + await page2.getByRole('tab', { name: 'Headers' }).click(); + await expect(page2.locator('table tbody tr')).toHaveCount(1); + + // Verify vars persisted + await page2.getByRole('tab', { name: 'Vars' }).click(); + await expect(page2.locator('table').first().locator('tbody tr')).toHaveCount(1); + await expect(page2.locator('table').nth(1).locator('tbody tr')).toHaveCount(1); + + await app2.close(); +}); diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 85f383e53..dde7bf971 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -72,6 +72,8 @@ export const buildWebsocketCommonLocators = (page: Page) => ({ } }); +export const getTableCell = (row, index) => row.locator('td').nth(index); + export const buildGrpcCommonLocators = (page: Page) => ({ ...buildCommonLocators(page), method: {