Merge remote-tracking branch 'origin' into feature/add-raw-file-request-body-option

This commit is contained in:
Zachary Elliott 2024-08-17 14:07:24 -06:00
commit e7a10939ef
85 changed files with 854 additions and 217 deletions

View File

@ -15,6 +15,8 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@ -50,6 +52,8 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@ -71,15 +75,3 @@ jobs:
with: with:
files: packages/bruno-tests/collection/junit.xml files: packages/bruno-tests/collection/junit.xml
comment_mode: always comment_mode: always
prettier:
name: Prettier
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Run Prettier
run: npm run test:prettier:web

View File

@ -62,6 +62,9 @@ npm run build:graphql-docs
# construction de bruno query # construction de bruno query
npm run build:bruno-query npm run build:bruno-query
# construction de bruno common
npm run build:bruno-common
# démarrage de next (terminal 1) # démarrage de next (terminal 1)
npm run dev:web npm run dev:web

View File

@ -0,0 +1,84 @@
## Urobme bruno lepším, spoločne !!
Sme radi, že chcete zlepšiť bruno. Nižšie sú uvedené pokyny, ako začať s výchovou bruno na vašom počítači.
### Technologický zásobník
Bruno je vytvorené pomocou Next.js a React. Na dodávanie desktopovej verzie (ktorá podporuje lokálne kolekcie) používame aj electron.
Balíčky, ktoré používame:
- CSS - Tailwind
- Editory kódu - Codemirror
- Správa stavu - Redux
- Ikony - Tabler Icons
- Formuláre - formik
- Overovanie schém - Yup
- Klient požiadaviek - axios
- Sledovač súborového systému - chokidar
### Závislosti
Budete potrebovať [NodeJS v18.x alebo najnovšiu verziu LTS](https://nodejs.org/en/) a npm versiu 8.x. V projekte používame pracovné priestory npm
## Vývoj
Bruno sa vyvíja ako desktopová aplikácia. Aplikáciu je potrebné načítať spustením aplikácie Next.js v jednom termináli a potom spustiť aplikáciu electron v inom termináli.
### Závislosti
- NodeJS v18
### Miestny vývoj
```bash
# použite verziu nodejs 18
nvm use
# nainštalovať balíčky
npm i --legacy-peer-deps
# zostaviť balíčky
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
# spustite ďalšiu aplikáciu (terminál 1)
npm run dev:web
# spustite aplikáciu electron (terminál 2)
npm run dev:electron
```
### Riešenie problémov
Pri spustení `npm install` sa môžete stretnúť s chybou `Unsupported platform`. Ak chcete túto chybu odstrániť, musíte odstrániť súbory `node_modules`, `package-lock.json` a spustiť `npm install`. Tým by sa mali nainštalovať všetky potrebné balíky potrebné na spustenie aplikácie.
```shell
# Odstrániť node_modules v podadresároch
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
done
# Odstráňte package-lock v podadresároch
find . -type f -name "package-lock.json" -delete
```
### Testovanie
````bash
# bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
```
### Vyrobenie Pull Request
- Prosím, aby PR boli malé a zamerané na jednu vec
- Prosím, dodržujte formát vytvárania vetiev
- feature/[názov funkcie]: Táto vetva by mala obsahovať zmeny pre konkrétnu funkciu
- Príklad: feature/dark-mode
- bugfix/[názov chyby]: Táto vetva by mala obsahovať iba opravy konkrétnej chyby
- Príklad: bugfix/bug-1

View File

@ -103,6 +103,12 @@ Ou qualquer sistema de controle de versão de sua escolha.
<img src="../../assets/images/sponsors/commit-company.png" width="70"/> <img src="../../assets/images/sponsors/commit-company.png" width="70"/>
#### Apoiadores Bronze
<a href="https://zuplo.link/bruno">
<img src="../../assets/images/sponsors/zuplo.png" width="120"/>
</a>
### Links Importantes 📌 ### Links Importantes 📌
- [Nossa Visão de Longo Prazo](https://github.com/usebruno/bruno/discussions/269) - [Nossa Visão de Longo Prazo](https://github.com/usebruno/bruno/discussions/269)

2
package-lock.json generated
View File

@ -19668,7 +19668,7 @@
}, },
"packages/bruno-electron": { "packages/bruno-electron": {
"name": "bruno", "name": "bruno",
"version": "v1.21.0", "version": "v1.23.1",
"dependencies": { "dependencies": {
"@aws-sdk/credential-providers": "3.525.0", "@aws-sdk/credential-providers": "3.525.0",
"@usebruno/common": "0.1.0", "@usebruno/common": "0.1.0",

View File

@ -6,6 +6,7 @@ const StyledWrapper = styled.div`
border: solid 1px ${(props) => props.theme.codemirror.border}; border: solid 1px ${(props) => props.theme.codemirror.border};
font-family: ${(props) => (props.font ? props.font : 'default')}; font-family: ${(props) => (props.font ? props.font : 'default')};
line-break: anywhere; line-break: anywhere;
flex: 1 1 0;
} }
.CodeMirror-overlayscroll-horizontal div, .CodeMirror-overlayscroll-horizontal div,

View File

@ -16,6 +16,7 @@ import stripJsonComments from 'strip-json-comments';
let CodeMirror; let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const TAB_SIZE = 2;
if (!SERVER_RENDERED) { if (!SERVER_RENDERED) {
CodeMirror = require('codemirror'); CodeMirror = require('codemirror');
@ -121,7 +122,7 @@ export default class CodeEditor extends React.Component {
value: this.props.value || '', value: this.props.value || '',
lineNumbers: true, lineNumbers: true,
lineWrapping: true, lineWrapping: true,
tabSize: 2, tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json', mode: this.props.mode || 'application/ld+json',
keyMap: 'sublime', keyMap: 'sublime',
autoCloseBrackets: true, autoCloseBrackets: true,
@ -169,7 +170,33 @@ export default class CodeEditor extends React.Component {
'Ctrl-Y': 'foldAll', 'Ctrl-Y': 'foldAll',
'Cmd-Y': 'foldAll', 'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll', 'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll' 'Cmd-I': 'unfoldAll',
'Cmd-/': (cm) => {
// comment/uncomment every selected line(s)
const selections = cm.listSelections();
selections.forEach((range) => {
for (let i = range.from().line; i <= range.to().line; i++) {
const selectedLine = cm.getLine(i);
// if commented line, remove comment
if (selectedLine.trim().startsWith('//')) {
cm.replaceRange(
selectedLine.replace(/^(\s*)\/\/\s?/, '$1'),
{ line: i, ch: 0 },
{ line: i, ch: selectedLine.length }
);
continue;
}
// otherwise add comment
cm.replaceRange(
selectedLine.search(/\S|$/) >= TAB_SIZE
? ' '.repeat(TAB_SIZE) + '// ' + selectedLine.trim()
: '// ' + selectedLine,
{ line: i, ch: 0 },
{ line: i, ch: selectedLine.length }
);
}
});
}
}, },
foldOptions: { foldOptions: {
widget: (from, to) => { widget: (from, to) => {
@ -289,7 +316,7 @@ export default class CodeEditor extends React.Component {
} }
return ( return (
<StyledWrapper <StyledWrapper
className="h-full w-full" className="h-full w-full flex flex-col relative"
aria-label="Code Editor" aria-label="Code Editor"
font={this.props.font} font={this.props.font}
ref={(node) => { ref={(node) => {

View File

@ -138,6 +138,7 @@ const AwsV4Auth = ({ collection }) => {
onSave={handleSave} onSave={handleSave}
onChange={(val) => handleSecretAccessKeyChange(val)} onChange={(val) => handleSecretAccessKeyChange(val)}
collection={collection} collection={collection}
isSecret={true}
/> />
</div> </div>

View File

@ -62,6 +62,7 @@ const BasicAuth = ({ collection }) => {
onSave={handleSave} onSave={handleSave}
onChange={(val) => handlePasswordChange(val)} onChange={(val) => handlePasswordChange(val)}
collection={collection} collection={collection}
isSecret={true}
/> />
</div> </div>
</StyledWrapper> </StyledWrapper>

View File

@ -37,6 +37,7 @@ const BearerAuth = ({ collection }) => {
onSave={handleSave} onSave={handleSave}
onChange={(val) => handleTokenChange(val)} onChange={(val) => handleTokenChange(val)}
collection={collection} collection={collection}
isSecret={true}
/> />
</div> </div>
</StyledWrapper> </StyledWrapper>

View File

@ -62,6 +62,7 @@ const DigestAuth = ({ collection }) => {
onSave={handleSave} onSave={handleSave}
onChange={(val) => handlePasswordChange(val)} onChange={(val) => handlePasswordChange(val)}
collection={collection} collection={collection}
isSecret={true}
/> />
</div> </div>
</StyledWrapper> </StyledWrapper>

View File

@ -78,7 +78,7 @@ const OAuth2AuthorizationCode = ({ collection }) => {
return ( return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col"> <StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => { {inputsConfig.map((input) => {
const { key, label } = input; const { key, label, isSecret } = input;
return ( return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}> <div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label> <label className="block font-medium">{label}</label>
@ -90,6 +90,7 @@ const OAuth2AuthorizationCode = ({ collection }) => {
onChange={(val) => handleChange(key, val)} onChange={(val) => handleChange(key, val)}
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
isSecret={isSecret}
/> />
</div> </div>
</div> </div>

View File

@ -17,7 +17,8 @@ const inputsConfig = [
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
label: 'Client Secret' label: 'Client Secret',
isSecret: true
}, },
{ {
key: 'scope', key: 'scope',

View File

@ -42,7 +42,7 @@ const OAuth2ClientCredentials = ({ collection }) => {
return ( return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col"> <StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => { {inputsConfig.map((input) => {
const { key, label } = input; const { key, label, isSecret } = input;
return ( return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}> <div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label> <label className="block font-medium">{label}</label>
@ -54,6 +54,7 @@ const OAuth2ClientCredentials = ({ collection }) => {
onChange={(val) => handleChange(key, val)} onChange={(val) => handleChange(key, val)}
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
isSecret={isSecret}
/> />
</div> </div>
</div> </div>

View File

@ -9,7 +9,8 @@ const inputsConfig = [
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
label: 'Client Secret' label: 'Client Secret',
isSecret: true
}, },
{ {
key: 'scope', key: 'scope',

View File

@ -44,7 +44,7 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
return ( return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col"> <StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => { {inputsConfig.map((input) => {
const { key, label } = input; const { key, label, isSecret } = input;
return ( return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}> <div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label> <label className="block font-medium">{label}</label>
@ -56,6 +56,7 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
onChange={(val) => handleChange(key, val)} onChange={(val) => handleChange(key, val)}
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
isSecret={isSecret}
/> />
</div> </div>
</div> </div>

View File

@ -17,7 +17,8 @@ const inputsConfig = [
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
label: 'Client Secret' label: 'Client Secret',
isSecret: true
}, },
{ {
key: 'scope', key: 'scope',

View File

@ -74,6 +74,7 @@ const PresetsSettings = ({ collection }) => {
id="request-url" id="request-url"
type="text" type="text"
name="requestUrl" name="requestUrl"
placeholder='Request URL'
className="block textbox" className="block textbox"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"

View File

@ -1,14 +1,6 @@
import styled from 'styled-components'; import styled from 'styled-components';
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
div.CodeMirror {
/* todo: find a better way */
height: calc(100vh - 240px);
.CodeMirror-scroll {
padding-bottom: 0px;
}
}
.editing-mode { .editing-mode {
cursor: pointer; cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow}; color: ${(props) => props.theme.colors.text.yellow};

View File

@ -37,8 +37,8 @@ const Documentation = ({ item, collection }) => {
} }
return ( return (
<StyledWrapper className="mt-1 h-full w-full relative"> <StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
<div className="editing-mode mb-2" role="tab" onClick={toggleViewMode}> <div className="editing-mode" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'} {isEditing ? 'Preview' : 'Edit'}
</div> </div>

View File

@ -40,10 +40,15 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.dropdown.iconColor}; color: ${(props) => props.theme.dropdown.iconColor};
} }
&:hover { &:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg}; background-color: ${(props) => props.theme.dropdown.hoverBg};
} }
&:disabled {
cursor: not-allowed;
color: gray;
}
&.border-top { &.border-top {
border-top: solid 1px ${(props) => props.theme.dropdown.separator}; border-top: solid 1px ${(props) => props.theme.dropdown.separator};
} }

View File

@ -5,7 +5,6 @@ import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor'; import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import { maskInputValue } from 'utils/collections';
import { useFormik } from 'formik'; import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex'; import { variableNameRegex } from 'utils/common/regex';
@ -96,10 +95,10 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<table> <table>
<thead> <thead>
<tr> <tr>
<td>Enabled</td> <td className="text-center">Enabled</td>
<td>Name</td> <td>Name</td>
<td>Value</td> <td>Value</td>
<td>Secret</td> <td className="text-center">Secret</td>
<td></td> <td></td>
</tr> </tr>
</thead> </thead>
@ -109,7 +108,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<td className="text-center"> <td className="text-center">
<input <input
type="checkbox" type="checkbox"
className="mr-3 mousetrap" className="mousetrap"
name={`${index}.enabled`} name={`${index}.enabled`}
checked={variable.enabled} checked={variable.enabled}
onChange={formik.handleChange} onChange={formik.handleChange}
@ -130,23 +129,22 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
/> />
<ErrorMessage name={`${index}.name`} /> <ErrorMessage name={`${index}.name`} />
</td> </td>
<td> <td className="flex flex-row flex-nowrap">
{variable.secret ? ( <div className="overflow-hidden grow w-full relative">
<div className="overflow-hidden text-ellipsis">{maskInputValue(variable.value)}</div>
) : (
<SingleLineEditor <SingleLineEditor
theme={storedTheme} theme={storedTheme}
collection={collection} collection={collection}
name={`${index}.value`} name={`${index}.value`}
value={variable.value} value={variable.value}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)} onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/> />
)} </div>
</td> </td>
<td> <td className="text-center">
<input <input
type="checkbox" type="checkbox"
className="mr-3 mousetrap" className="mousetrap"
name={`${index}.secret`} name={`${index}.secret`}
checked={variable.secret} checked={variable.secret}
onChange={formik.handleChange} onChange={formik.handleChange}

View File

@ -44,8 +44,8 @@ const Script = ({ collection, folder }) => {
<div className="text-xs mb-4 text-muted"> <div className="text-xs mb-4 text-muted">
Pre and post-request scripts that will run before and after any request inside this folder is sent. Pre and post-request scripts that will run before and after any request inside this folder is sent.
</div> </div>
<div className="flex-1 mt-2"> <div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="mb-1 title text-xs">Pre Request</div> <div className="title text-xs">Pre Request</div>
<CodeEditor <CodeEditor
collection={collection} collection={collection}
value={requestScript || ''} value={requestScript || ''}
@ -56,8 +56,8 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')} font={get(preferences, 'font.codeFont', 'default')}
/> />
</div> </div>
<div className="flex-1 mt-6"> <div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="mt-1 mb-1 title text-xs">Post Response</div> <div className="title text-xs">Post Response</div>
<CodeEditor <CodeEditor
collection={collection} collection={collection}
value={responseScript || ''} value={responseScript || ''}

View File

@ -50,7 +50,7 @@ const FolderSettings = ({ collection, folder }) => {
}; };
return ( return (
<StyledWrapper> <StyledWrapper className="flex flex-col h-full">
<div className="flex flex-col h-full relative px-4 py-4"> <div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist"> <div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}> <div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>

View File

@ -2,7 +2,6 @@ import styled from 'styled-components';
const StyledMarkdownBodyWrapper = styled.div` const StyledMarkdownBodyWrapper = styled.div`
background: transparent; background: transparent;
height: inherit;
.markdown-body { .markdown-body {
background: transparent; background: transparent;
overflow-y: auto; overflow-y: auto;

View File

@ -1,39 +1,42 @@
import React from 'react'; import React from 'react';
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord, IconBook } from '@tabler/icons'; import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord, IconBook } from '@tabler/icons';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { useDictionary } from 'providers/Dictionary/index';
const Support = () => { const Support = () => {
const { dictionary } = useDictionary();
return ( return (
<StyledWrapper> <StyledWrapper>
<div className="rows"> <div className="rows">
<div className="mt-2"> <div className="mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="flex items-end"> <a href="https://docs.usebruno.com" target="_blank" className="flex items-end">
<IconBook size={18} strokeWidth={2} /> <IconBook size={18} strokeWidth={2} />
<span className="label ml-2">Documentation</span> <span className="label ml-2">{dictionary.documentation}</span>
</a> </a>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="flex items-end"> <a href="https://github.com/usebruno/bruno/issues" target="_blank" className="flex items-end">
<IconSpeakerphone size={18} strokeWidth={2} /> <IconSpeakerphone size={18} strokeWidth={2} />
<span className="label ml-2">Report Issues</span> <span className="label ml-2">{dictionary.reportIssues}</span>
</a> </a>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<a href="https://discord.com/invite/KgcZUncpjq" target="_blank" className="flex items-end"> <a href="https://discord.com/invite/KgcZUncpjq" target="_blank" className="flex items-end">
<IconBrandDiscord size={18} strokeWidth={2} /> <IconBrandDiscord size={18} strokeWidth={2} />
<span className="label ml-2">Discord</span> <span className="label ml-2">{dictionary.discord}</span>
</a> </a>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-end"> <a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-end">
<IconBrandGithub size={18} strokeWidth={2} /> <IconBrandGithub size={18} strokeWidth={2} />
<span className="label ml-2">GitHub</span> <span className="label ml-2">{dictionary.gitHub}</span>
</a> </a>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<a href="https://twitter.com/use_bruno" target="_blank" className="flex items-end"> <a href="https://twitter.com/use_bruno" target="_blank" className="flex items-end">
<IconBrandTwitter size={18} strokeWidth={2} /> <IconBrandTwitter size={18} strokeWidth={2} />
<span className="label ml-2">Twitter</span> <span className="label ml-2">{dictionary.twitter}</span>
</a> </a>
</div> </div>
</div> </div>

View File

@ -150,6 +150,7 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
item={item} item={item}
isSecret={true}
/> />
</div> </div>

View File

@ -69,6 +69,7 @@ const BasicAuth = ({ item, collection }) => {
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
item={item} item={item}
isSecret={true}
/> />
</div> </div>
</StyledWrapper> </StyledWrapper>

View File

@ -43,6 +43,7 @@ const BearerAuth = ({ item, collection }) => {
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
item={item} item={item}
isSecret={true}
/> />
</div> </div>
</StyledWrapper> </StyledWrapper>

View File

@ -69,6 +69,7 @@ const DigestAuth = ({ item, collection }) => {
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
item={item} item={item}
isSecret={true}
/> />
</div> </div>
</StyledWrapper> </StyledWrapper>

View File

@ -80,7 +80,7 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
return ( return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col"> <StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => { {inputsConfig.map((input) => {
const { key, label } = input; const { key, label, isSecret } = input;
return ( return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}> <div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label> <label className="block font-medium">{label}</label>
@ -93,6 +93,7 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
item={item} item={item}
isSecret={isSecret}
/> />
</div> </div>
</div> </div>

View File

@ -17,7 +17,8 @@ const inputsConfig = [
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
label: 'Client Secret' label: 'Client Secret',
isSecret: true
}, },
{ {
key: 'scope', key: 'scope',

View File

@ -43,7 +43,7 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
return ( return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col"> <StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => { {inputsConfig.map((input) => {
const { key, label } = input; const { key, label, isSecret } = input;
return ( return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}> <div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label> <label className="block font-medium">{label}</label>
@ -56,6 +56,7 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
item={item} item={item}
isSecret={isSecret}
/> />
</div> </div>
</div> </div>

View File

@ -9,7 +9,8 @@ const inputsConfig = [
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
label: 'Client Secret' label: 'Client Secret',
isSecret: true
}, },
{ {
key: 'scope', key: 'scope',

View File

@ -45,7 +45,7 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
return ( return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col"> <StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{inputsConfig.map((input) => { {inputsConfig.map((input) => {
const { key, label } = input; const { key, label, isSecret } = input;
return ( return (
<div className="flex flex-col w-full gap-1" key={`input-${key}`}> <div className="flex flex-col w-full gap-1" key={`input-${key}`}>
<label className="block font-medium">{label}</label> <label className="block font-medium">{label}</label>
@ -58,6 +58,7 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
onRun={handleRun} onRun={handleRun}
collection={collection} collection={collection}
item={item} item={item}
isSecret={isSecret}
/> />
</div> </div>
</div> </div>

View File

@ -9,7 +9,8 @@ const inputsConfig = [
}, },
{ {
key: 'password', key: 'password',
label: 'Password' label: 'Password',
isSecret: true
}, },
{ {
key: 'clientId', key: 'clientId',
@ -17,7 +18,8 @@ const inputsConfig = [
}, },
{ {
key: 'clientSecret', key: 'clientSecret',
label: 'Client Secret' label: 'Client Secret',
isSecret: true
}, },
{ {
key: 'scope', key: 'scope',

View File

@ -137,7 +137,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
) : null} ) : null}
</div> </div>
<section <section
className={classnames('flex w-full', { className={classnames('flex w-full flex-1', {
'mt-5': !isMultipleContentTab 'mt-5': !isMultipleContentTab
})} })}
> >

View File

@ -4,6 +4,7 @@ const StyledWrapper = styled.div`
div.CodeMirror { div.CodeMirror {
background: ${(props) => props.theme.codemirror.bg}; background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border}; border: solid 1px ${(props) => props.theme.codemirror.border};
flex: 1 1 0;
} }
textarea.cm-editor { textarea.cm-editor {

View File

@ -74,7 +74,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
/> />
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}> <div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<div <div
className="tooltip mr-3" className="tooltip mx-3"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!item.draft) return; if (!item.draft) return;

View File

@ -1,10 +1,6 @@
import styled from 'styled-components'; import styled from 'styled-components';
const Wrapper = styled.div` const Wrapper = styled.div`
div.CodeMirror {
/* todo: find a better way */
height: calc(100vh - 220px);
}
`; `;
export default Wrapper; export default Wrapper;

View File

@ -40,8 +40,8 @@ const Script = ({ item, collection }) => {
return ( return (
<StyledWrapper className="w-full flex flex-col"> <StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2"> <div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="mb-1 title text-xs">Pre Request</div> <div className="title text-xs">Pre Request</div>
<CodeEditor <CodeEditor
collection={collection} collection={collection}
value={requestScript || ''} value={requestScript || ''}
@ -53,8 +53,8 @@ const Script = ({ item, collection }) => {
onSave={onSave} onSave={onSave}
/> />
</div> </div>
<div className="flex-1 mt-6"> <div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="mt-1 mb-1 title text-xs">Post Response</div> <div className="title text-xs">Post Response</div>
<CodeEditor <CodeEditor
collection={collection} collection={collection}
value={responseScript || ''} value={responseScript || ''}

View File

@ -9,11 +9,11 @@ const Vars = ({ item, collection }) => {
return ( return (
<StyledWrapper className="w-full flex flex-col"> <StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2"> <div className="mt-2">
<div className="mb-1 title text-xs">Pre Request</div> <div className="mb-1 title text-xs">Pre Request</div>
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" /> <VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
</div> </div>
<div className="flex-1"> <div>
<div className="mt-1 mb-1 title text-xs">Post Response</div> <div className="mt-1 mb-1 title text-xs">Post Response</div>
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" /> <VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
</div> </div>

View File

@ -30,7 +30,7 @@ const RequestNotFound = ({ itemUid }) => {
return ( return (
<div className="mt-6 px-6"> <div className="mt-6 px-6">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700 bg-yellow-100 p-4"> <div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
<div>Request no longer exists.</div> <div>Request no longer exists.</div>
<div className="mt-2"> <div className="mt-2">
This can happen when the .bru file associated with this request was deleted on your filesystem. This can happen when the .bru file associated with this request was deleted on your filesystem.

View File

@ -158,10 +158,9 @@ const RequestTabPanel = () => {
<section className="main flex flex-grow pb-4 relative"> <section className="main flex flex-grow pb-4 relative">
<section className="request-pane"> <section className="request-pane">
<div <div
className="px-4" className="px-4 h-full"
style={{ style={{
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`, width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
height: `calc(100% - ${DEFAULT_PADDING}px)`
}} }}
> >
{item.type === 'graphql-request' ? ( {item.type === 'graphql-request' ? (

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import { closeTabs } from 'providers/ReduxStore/slices/tabs'; import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
@ -12,12 +12,18 @@ import ConfirmRequestClose from './ConfirmRequestClose';
import RequestTabNotFound from './RequestTabNotFound'; import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab'; import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
import NewRequest from 'components/Sidebar/NewRequest/index';
const RequestTab = ({ tab, collection, folderUid }) => { const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { storedTheme } = useTheme(); const { storedTheme } = useTheme();
const [showConfirmClose, setShowConfirmClose] = useState(false); const [showConfirmClose, setShowConfirmClose] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const handleCloseClick = (event) => { const handleCloseClick = (event) => {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
@ -28,6 +34,19 @@ const RequestTab = ({ tab, collection, folderUid }) => {
); );
}; };
const handleRightClick = (_event) => {
const menuDropdown = dropdownTippyRef.current;
if (!menuDropdown) {
return;
}
if (menuDropdown.state.isShown) {
menuDropdown.hide();
} else {
menuDropdown.show();
}
};
const handleMouseUp = (e) => { const handleMouseUp = (e) => {
if (e.button === 1) { if (e.button === 1) {
e.stopPropagation(); e.stopPropagation();
@ -143,6 +162,7 @@ const RequestTab = ({ tab, collection, folderUid }) => {
)} )}
<div <div
className="flex items-baseline tab-label pl-2" className="flex items-baseline tab-label pl-2"
onContextMenu={handleRightClick}
onMouseUp={(e) => { onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e); if (!item.draft) return handleMouseUp(e);
@ -159,6 +179,15 @@ const RequestTab = ({ tab, collection, folderUid }) => {
<span className="ml-1 tab-name" title={item.name}> <span className="ml-1 tab-name" title={item.name}>
{item.name} {item.name}
</span> </span>
<RequestTabMenu
onDropdownCreate={onDropdownCreate}
tabIndex={tabIndex}
collectionRequestTabs={collectionRequestTabs}
tabItem={item}
collection={collection}
dropdownTippyRef={dropdownTippyRef}
dispatch={dispatch}
/>
</div> </div>
<div <div
className="flex px-2 close-icon-container" className="flex px-2 close-icon-container"
@ -195,4 +224,124 @@ const RequestTab = ({ tab, collection, folderUid }) => {
); );
}; };
function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, collection, dropdownTippyRef, dispatch }) {
const [showCloneRequestModal, setShowCloneRequestModal] = useState(false);
const [showAddNewRequestModal, setShowAddNewRequestModal] = useState(false);
const totalTabs = collectionRequestTabs.length || 0;
const currentTabUid = collectionRequestTabs[tabIndex]?.uid;
const currentTabItem = findItemInCollection(collection, currentTabUid);
const hasLeftTabs = tabIndex !== 0;
const hasRightTabs = totalTabs > tabIndex + 1;
const hasOtherTabs = totalTabs > 1;
async function handleCloseTab(event, tabUid) {
event.stopPropagation();
dropdownTippyRef.current.hide();
if (!tabUid) {
return;
}
try {
const item = findItemInCollection(collection, tabUid);
// silently save unsaved changes before closing the tab
if (item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true));
}
dispatch(closeTabs({ tabUids: [tabUid] }));
} catch (err) {}
}
function handleCloseOtherTabs(event) {
dropdownTippyRef.current.hide();
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
otherTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
function handleCloseTabsToTheLeft(event) {
dropdownTippyRef.current.hide();
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
leftTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
function handleCloseTabsToTheRight(event) {
dropdownTippyRef.current.hide();
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
rightTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
function handleCloseSavedTabs(event) {
event.stopPropagation();
const savedTabs = collection.items.filter((item) => !item.draft);
const savedTabIds = savedTabs.map((item) => item.uid) || [];
dispatch(closeTabs({ tabUids: savedTabIds }));
}
function handleCloseAllTabs(event) {
collectionRequestTabs.forEach((tab) => handleCloseTab(event, tab.uid));
}
return (
<Fragment>
{showAddNewRequestModal && (
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
)}
{showCloneRequestModal && (
<CloneCollectionItem
item={currentTabItem}
collection={collection}
onClose={() => setShowCloneRequestModal(false)}
/>
)}
<Dropdown onCreate={onDropdownCreate} icon={<span></span>} placement="bottom-start">
<button
className="dropdown-item w-full"
onClick={() => {
dropdownTippyRef.current.hide();
setShowAddNewRequestModal(true);
}}
>
New Request
</button>
<button
className="dropdown-item w-full"
onClick={() => {
dropdownTippyRef.current.hide();
setShowCloneRequestModal(true);
}}
>
Clone Request
</button>
<button className="dropdown-item w-full" onClick={(e) => handleCloseTab(e, currentTabUid)}>
Close
</button>
<button disabled={!hasOtherTabs} className="dropdown-item w-full" onClick={handleCloseOtherTabs}>
Close Others
</button>
<button disabled={!hasLeftTabs} className="dropdown-item w-full" onClick={handleCloseTabsToTheLeft}>
Close to the Left
</button>
<button disabled={!hasRightTabs} className="dropdown-item w-full" onClick={handleCloseTabsToTheRight}>
Close to the Right
</button>
<button className="dropdown-item w-full" onClick={handleCloseSavedTabs}>
Close Saved
</button>
<button className="dropdown-item w-full" onClick={handleCloseAllTabs}>
Close All
</button>
</Dropdown>
</Fragment>
);
}
export default RequestTab; export default RequestTab;

View File

@ -7,13 +7,14 @@ const Wrapper = styled.div`
padding: 0; padding: 0;
margin: 0; margin: 0;
display: flex; display: flex;
position: relative;
overflow: scroll; overflow: scroll;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
scrollbar-width: none;
li { li {
display: inline-flex; display: inline-flex;
max-width: 150px; max-width: 150px;

View File

@ -110,7 +110,14 @@ const RequestTabs = () => {
role="tab" role="tab"
onClick={() => handleClick(tab)} onClick={() => handleClick(tab)}
> >
<RequestTab key={tab.uid} tab={tab} collection={activeCollection} folderUid={tab.folderUid} /> <RequestTab
collectionRequestTabs={collectionRequestTabs}
tabIndex={index}
key={tab.uid}
tab={tab}
collection={activeCollection}
folderUid={tab.folderUid}
/>
</li> </li>
); );
}) })

View File

@ -1,6 +1,19 @@
import styled from 'styled-components'; import styled from 'styled-components';
const Wrapper = styled.div` const Wrapper = styled.div`
.textbox {
border: 1px solid #ccc;
padding: 0.2rem 0.5rem;
box-shadow: none;
border-radius: 0px;
outline: none;
box-shadow: none;
transition: border-color ease-in-out 0.1s;
border-radius: 3px;
background-color: ${(props) => props.theme.modal.input.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
}
.item-path { .item-path {
.link { .link {
color: ${(props) => props.theme.textLink}; color: ${(props) => props.theme.textLink};

View File

@ -23,6 +23,7 @@ const getRelativePath = (fullPath, pathname) => {
export default function RunnerResults({ collection }) { export default function RunnerResults({ collection }) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null); const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
// ref for the runner output body // ref for the runner output body
const runnerBodyRef = useRef(); const runnerBodyRef = useRef();
@ -78,11 +79,11 @@ export default function RunnerResults({ collection }) {
.filter(Boolean); .filter(Boolean);
const runCollection = () => { const runCollection = () => {
dispatch(runCollectionFolder(collection.uid, null, true)); dispatch(runCollectionFolder(collection.uid, null, true, Number(delay)));
}; };
const runAgain = () => { const runAgain = () => {
dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive)); dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay)));
}; };
const resetRunner = () => { const resetRunner = () => {
@ -116,6 +117,20 @@ export default function RunnerResults({ collection }) {
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection. You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
</div> </div>
<div className="mt-6">
<label>Delay (in ms)</label>
<input
type="number"
className="block textbox mt-2 py-5"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={delay}
onChange={(e) => setDelay(e.target.value)}
/>
</div>
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runCollection}> <button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runCollection}>
Run Collection Run Collection
</button> </button>
@ -167,10 +182,14 @@ export default function RunnerResults({ collection }) {
</span> </span>
{item.status !== 'error' && item.status !== 'completed' ? ( {item.status !== 'error' && item.status !== 'completed' ? (
<IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} /> <IconRefresh className="animate-spin ml-1" size={18} strokeWidth={1.5} />
) : ( ) : item.responseReceived?.status ? (
<span className="text-xs link cursor-pointer" onClick={() => setSelectedItem(item)}> <span className="text-xs link cursor-pointer" onClick={() => setSelectedItem(item)}>
(<span className="mr-1">{get(item.responseReceived, 'status')}</span> (<span className="mr-1">{item.responseReceived?.status}</span>
<span>{get(item.responseReceived, 'statusText')}</span>) <span>{item.responseReceived?.statusText}</span>)
</span>
) : (
<span className="danger text-xs cursor-pointer" onClick={() => setSelectedItem(item)}>
(request failed)
</span> </span>
)} )}
</div> </div>

View File

@ -58,6 +58,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
id="collection-item-name" id="collection-item-name"
type="text" type="text"
name="name" name="name"
placeholder='Enter Item name'
ref={inputRef} ref={inputRef}
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
autoComplete="off" autoComplete="off"

View File

@ -2,6 +2,7 @@ import styled from 'styled-components';
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
position: relative; position: relative;
height: 100%;
.copy-to-clipboard { .copy-to-clipboard {
position: absolute; position: absolute;

View File

@ -91,13 +91,13 @@ const Collections = () => {
<input <input
type="text" type="text"
name="search" name="search"
placeholder="search"
id="search" id="search"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
className="block w-full pl-7 py-1 sm:text-sm" className="block w-full pl-7 py-1 sm:text-sm"
placeholder="search"
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value.toLowerCase())} onChange={(e) => setSearchText(e.target.value.toLowerCase())}
/> />

View File

@ -115,7 +115,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
collectionLocation: Yup.string() collectionLocation: Yup.string()
.min(1, 'must be at least 1 character') .min(1, 'must be at least 1 character')
.max(500, 'must be 500 characters or less') .max(500, 'must be 500 characters or less')
.required('name is required') .required('Location is required')
}), }),
onSubmit: (values) => { onSubmit: (values) => {
handleSubmit(values.collectionLocation); handleSubmit(values.collectionLocation);
@ -124,7 +124,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
const browse = () => { const browse = () => {
dispatch(browseDirectory()) dispatch(browseDirectory())
.then((dirPath) => { .then((dirPath) => {
if (typeof dirPath === 'string' && dirPath.length > 0) {
formik.setFieldValue('collectionLocation', dirPath); formik.setFieldValue('collectionLocation', dirPath);
}
}) })
.catch((error) => { .catch((error) => {
formik.setFieldValue('collectionLocation', ''); formik.setFieldValue('collectionLocation', '');
@ -160,7 +162,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
type="text" type="text"
name="collectionLocation" name="collectionLocation"
readOnly={true} readOnly={true}
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"

View File

@ -109,7 +109,8 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
collectionUid: collection.uid, collectionUid: collection.uid,
itemUid: item ? item.uid : null, itemUid: item ? item.uid : null,
headers: request.headers, headers: request.headers,
body: request.body body: request.body,
auth: request.auth
}) })
) )
.then(() => onClose()) .then(() => onClose())
@ -161,7 +162,16 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
return ( return (
<StyledWrapper> <StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form
className="bruno-form"
onSubmit={formik.handleSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.handleSubmit();
}
}}
>
<div> <div>
<label htmlFor="requestName" className="block font-semibold"> <label htmlFor="requestName" className="block font-semibold">
Type Type
@ -220,6 +230,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
id="request-name" id="request-name"
type="text" type="text"
name="requestName" name="requestName"
placeholder="Request Name"
ref={inputRef} ref={inputRef}
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
autoComplete="off" autoComplete="off"
@ -252,6 +263,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
id="request-url" id="request-url"
type="text" type="text"
name="requestUrl" name="requestUrl"
placeholder="Request URL"
className="px-3 w-full " className="px-3 w-full "
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"

View File

@ -129,7 +129,7 @@ const Sidebar = () => {
Star Star
</GitHubButton> */} </GitHubButton> */}
</div> </div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.22.0</div> <div className="flex flex-grow items-center justify-end text-xs mr-2">v1.25.0</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,8 +1,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections'; import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
let CodeMirror; let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true; const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
@ -20,12 +21,28 @@ class SingleLineEditor extends Component {
this.cachedValue = props.value || ''; this.cachedValue = props.value || '';
this.editorRef = React.createRef(); this.editorRef = React.createRef();
this.variables = {}; this.variables = {};
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
} }
componentDidMount() { componentDidMount() {
// Initialize CodeMirror as a single line editor // Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */ /** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item); const variables = getAllVariables(this.props.collection, this.props.item);
const runHandler = () => {
if (this.props.onRun) {
this.props.onRun();
}
};
const saveHandler = () => {
if (this.props.onSave) {
this.props.onSave();
}
};
const noopHandler = () => {};
this.editor = CodeMirror(this.editorRef.current, { this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false, lineWrapping: false,
lineNumbers: false, lineNumbers: false,
@ -37,21 +54,9 @@ class SingleLineEditor extends Component {
scrollbarStyle: null, scrollbarStyle: null,
tabindex: 0, tabindex: 0,
extraKeys: { extraKeys: {
Enter: () => { Enter: runHandler,
if (this.props.onRun) { 'Ctrl-Enter': runHandler,
this.props.onRun(); 'Cmd-Enter': runHandler,
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Alt-Enter': () => { 'Alt-Enter': () => {
if (this.props.allowNewlines) { if (this.props.allowNewlines) {
this.editor.setValue(this.editor.getValue() + '\n'); this.editor.setValue(this.editor.getValue() + '\n');
@ -60,23 +65,11 @@ class SingleLineEditor extends Component {
this.props.onRun(); this.props.onRun();
} }
}, },
'Shift-Enter': () => { 'Shift-Enter': runHandler,
if (this.props.onRun) { 'Cmd-S': saveHandler,
this.props.onRun(); 'Ctrl-S': saveHandler,
} 'Cmd-F': noopHandler,
}, 'Ctrl-F': noopHandler,
'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 // Tabbing disabled to make tabindex work
Tab: false, Tab: false,
'Shift-Tab': false 'Shift-Tab': false
@ -93,8 +86,24 @@ class SingleLineEditor extends Component {
this.editor.setValue(String(this.props.value) || ''); this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit); this.editor.on('change', this._onEdit);
this.addOverlay(variables); this.addOverlay(variables);
this._enableMaskedEditor(this.props.isSecret);
this.setState({ maskInput: this.props.isSecret });
} }
/** Enable or disable masking the rendered content of the editor */
_enableMaskedEditor = (enabled) => {
if (typeof enabled !== 'boolean') return;
console.log('Enabling masked editor: ' + enabled);
if (enabled == true) {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
} else {
this.maskedEditor?.disable();
this.maskedEditor = null;
}
};
_onEdit = () => { _onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) { if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue(); this.cachedValue = this.editor.getValue();
@ -122,6 +131,12 @@ class SingleLineEditor extends Component {
this.cachedValue = String(this.props.value); this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || ''); this.editor.setValue(String(this.props.value) || '');
} }
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
this._enableMaskedEditor(this.props.isSecret);
// also set the maskInput flag to the new value
this.setState({ maskInput: this.props.isSecret });
}
this.ignoreChangeEvent = false; this.ignoreChangeEvent = false;
} }
@ -135,8 +150,35 @@ class SingleLineEditor extends Component {
this.editor.setOption('mode', 'brunovariables'); this.editor.setOption('mode', 'brunovariables');
}; };
toggleVisibleSecret = () => {
const isVisible = !this.state.maskInput;
this.setState({ maskInput: isVisible });
this._enableMaskedEditor(isVisible);
};
/**
* @brief Eye icon to show/hide the secret value
* @returns ReactComponent The eye icon
*/
secretEye = (isSecret) => {
return isSecret === true ? (
<button className="mx-2" onClick={() => this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
<IconEyeOff size={18} strokeWidth={2} />
) : (
<IconEye size={18} strokeWidth={2} />
)}
</button>
) : null;
};
render() { render() {
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>; return (
<div className="flex flex-row justify-between w-full overflow-x-auto">
<StyledWrapper ref={this.editorRef} className="single-line-editor grow" />
{this.secretEye(this.props.isSecret)}
</div>
);
} }
} }
export default SingleLineEditor; export default SingleLineEditor;

View File

@ -9,9 +9,11 @@ import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection'; import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation'; import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { useDictionary } from 'providers/Dictionary/index';
const Welcome = () => { const Welcome = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { dictionary } = useDictionary();
const [importedCollection, setImportedCollection] = useState(null); const [importedCollection, setImportedCollection] = useState(null);
const [importedTranslationLog, setImportedTranslationLog] = useState({}); const [importedTranslationLog, setImportedTranslationLog] = useState({});
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false); const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
@ -20,7 +22,7 @@ const Welcome = () => {
const handleOpenCollection = () => { const handleOpenCollection = () => {
dispatch(openCollection()).catch( dispatch(openCollection()).catch(
(err) => console.log(err) && toast.error('An error occurred while opening the collection') (err) => console.log(err) && toast.error(dictionary.errorWhileOpeningCollection)
); );
}; };
@ -38,12 +40,12 @@ const Welcome = () => {
.then(() => { .then(() => {
setImportCollectionLocationModalOpen(false); setImportCollectionLocationModalOpen(false);
setImportedCollection(null); setImportedCollection(null);
toast.success('Collection imported successfully'); toast.success(dictionary.collectionImportedSuccessfully);
}) })
.catch((err) => { .catch((err) => {
setImportCollectionLocationModalOpen(false); setImportCollectionLocationModalOpen(false);
console.error(err); console.error(err);
toast.error('An error occurred while importing the collection. Check the logs for more information.'); toast.error(dictionary.errorWhileImportingCollection);
}); });
}; };
@ -66,46 +68,45 @@ const Welcome = () => {
<Bruno width={50} /> <Bruno width={50} />
</div> </div>
<div className="text-xl font-semibold select-none">bruno</div> <div className="text-xl font-semibold select-none">bruno</div>
<div className="mt-4">Opensource IDE for exploring and testing APIs</div> <div className="mt-4">{dictionary.aboutBruno}</div>
<div className="uppercase font-semibold heading mt-10">Collections</div> <div className="uppercase font-semibold heading mt-10">{dictionary.collections}</div>
<div className="mt-4 flex items-center collection-options select-none"> <div className="mt-4 flex items-center collection-options select-none">
<div className="flex items-center" onClick={() => setCreateCollectionModalOpen(true)}> <div className="flex items-center" onClick={() => setCreateCollectionModalOpen(true)}>
<IconPlus size={18} strokeWidth={2} /> <IconPlus size={18} strokeWidth={2} />
<span className="label ml-2" id="create-collection"> <span className="label ml-2" id="create-collection">
Create Collection {dictionary.createCollection}
</span> </span>
</div> </div>
<div className="flex items-center ml-6" onClick={handleOpenCollection}> <div className="flex items-center ml-6" onClick={handleOpenCollection}>
<IconFolders size={18} strokeWidth={2} /> <IconFolders size={18} strokeWidth={2} />
<span className="label ml-2">Open Collection</span> <span className="label ml-2">{dictionary.openCollection}</span>
</div> </div>
<div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}> <div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}>
<IconDownload size={18} strokeWidth={2} /> <IconDownload size={18} strokeWidth={2} />
<span className="label ml-2" id="import-collection"> <span className="label ml-2" id="import-collection">
Import Collection {dictionary.importCollection}
</span> </span>
</div> </div>
</div> </div>
<div className="uppercase font-semibold heading mt-10 pt-6">Links</div> <div className="uppercase font-semibold heading mt-10 pt-6">Links</div>
<div className="mt-4 flex flex-col collection-options select-none"> <div className="mt-4 flex flex-col collection-options select-none">
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="inline-flex items-center"> <a href="https://docs.usebruno.com" target="_blank" className="inline-flex items-center">
<IconBook size={18} strokeWidth={2} /> <IconBook size={18} strokeWidth={2} />
<span className="label ml-2">Documentation</span> <span className="label ml-2">{dictionary.documentation}</span>
</a> </a>
</div> </div>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="inline-flex items-center"> <a href="https://github.com/usebruno/bruno/issues" target="_blank" className="inline-flex items-center">
<IconSpeakerphone size={18} strokeWidth={2} /> <IconSpeakerphone size={18} strokeWidth={2} />
<span className="label ml-2">Report Issues</span> <span className="label ml-2">{dictionary.reportIssues}</span>
</a> </a>
</div> </div>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-center"> <a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-center">
<IconBrandGithub size={18} strokeWidth={2} /> <IconBrandGithub size={18} strokeWidth={2} />
<span className="label ml-2">GitHub</span> <span className="label ml-2">{dictionary.gitHub}</span>
</a> </a>
</div> </div>
</div> </div>

View File

@ -0,0 +1,16 @@
export default {
aboutBruno: 'Opensource IDE for exploring and testing APIs',
collections: 'Collections',
createCollection: 'Create Collection',
openCollection: 'Open Collection',
importCollection: 'Import Collection',
documentation: 'Documentation',
reportIssues: 'Report Issues',
gitHub: 'GitHub',
collectionImportedSuccessfully: 'Collection imported successfully',
errorWhileOpeningCollection: 'An error occurred while opening the collection',
errorWhileImportingCollection:
'An error occurred while importing the collection. Check the logs for more information.',
discord: 'Discord',
twitter: 'Twitter'
};

View File

@ -0,0 +1,5 @@
import en from './en.js';
export const dictionaries = {
en
};

View File

@ -100,6 +100,11 @@ const GlobalStyle = createGlobalStyle`
} }
} }
input::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;

View File

@ -14,6 +14,7 @@ import 'codemirror/lib/codemirror.css';
import 'graphiql/graphiql.min.css'; import 'graphiql/graphiql.min.css';
import 'react-tooltip/dist/react-tooltip.css'; import 'react-tooltip/dist/react-tooltip.css';
import '@usebruno/graphql-docs/dist/esm/index.css'; import '@usebruno/graphql-docs/dist/esm/index.css';
import { DictionaryProvider } from 'providers/Dictionary/index';
function SafeHydrate({ children }) { function SafeHydrate({ children }) {
return <div suppressHydrationWarning>{typeof window === 'undefined' ? null : children}</div>; return <div suppressHydrationWarning>{typeof window === 'undefined' ? null : children}</div>;
@ -59,6 +60,7 @@ function MyApp({ Component, pageProps }) {
<NoSsr> <NoSsr>
<Provider store={ReduxStore}> <Provider store={ReduxStore}>
<ThemeProvider> <ThemeProvider>
<DictionaryProvider>
<ToastProvider> <ToastProvider>
<AppProvider> <AppProvider>
<HotkeysProvider> <HotkeysProvider>
@ -66,6 +68,7 @@ function MyApp({ Component, pageProps }) {
</HotkeysProvider> </HotkeysProvider>
</AppProvider> </AppProvider>
</ToastProvider> </ToastProvider>
</DictionaryProvider>
</ThemeProvider> </ThemeProvider>
</Provider> </Provider>
</NoSsr> </NoSsr>

View File

@ -1,4 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { get } from 'lodash';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { refreshScreenWidth } from 'providers/ReduxStore/slices/app'; import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import ConfirmAppClose from './ConfirmAppClose'; import ConfirmAppClose from './ConfirmAppClose';
@ -18,6 +19,13 @@ export const AppProvider = (props) => {
dispatch(refreshScreenWidth()); dispatch(refreshScreenWidth());
}, []); }, []);
useEffect(() => {
const platform = get(navigator, 'platform', '');
if(platform && platform.toLowerCase().indexOf('mac') > -1) {
document.body.classList.add('os-mac');
}
}, []);
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
dispatch(refreshScreenWidth()); dispatch(refreshScreenWidth());

View File

@ -60,7 +60,7 @@ const trackStart = () => {
event: 'start', event: 'start',
properties: { properties: {
os: platformLib.os.family, os: platformLib.os.family,
version: '1.22.0' version: '1.25.0'
} }
}); });
}; };

View File

@ -0,0 +1,28 @@
import React from 'react';
import { useState, useContext } from 'react';
import { dictionaries } from 'src/dictionaries/index';
export const DictionaryContext = React.createContext();
const DictionaryProvider = (props) => {
const [language, setLanguage] = useState('en');
const dictionary = dictionaries[language] ?? dictionaries.en;
return (
<DictionaryContext.Provider {...props} value={{ language, setLanguage, dictionary }}>
<>{props.children}</>
</DictionaryContext.Provider>
);
};
const useDictionary = () => {
const context = useContext(DictionaryContext);
if (context === undefined) {
throw new Error(`useDictionary must be used within a DictionaryProvider`);
}
return context;
};
export { useDictionary, DictionaryProvider };

View File

@ -154,6 +154,31 @@ export const HotkeysProvider = (props) => {
}; };
}, [activeTabUid]); }, [activeTabUid]);
// close all tabs
useEffect(() => {
Mousetrap.bind(['command+shift+w', 'ctrl+shift+w'], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
dispatch(
closeTabs({
tabUids: tabUids
})
);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind(['command+shift+w', 'ctrl+shift+w']);
};
}, [activeTabUid, tabs, collections, dispatch]);
return ( return (
<HotkeysContext.Provider {...props} value="hotkey"> <HotkeysContext.Provider {...props} value="hotkey">
{showSaveRequestModal && ( {showSaveRequestModal && (

View File

@ -39,7 +39,7 @@ import {
import { each } from 'lodash'; import { each } from 'lodash';
import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform'; import { resolveRequestFilename } from 'utils/common/platform';
import { parseQueryParams, splitOnFirst } from 'utils/url/index'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { name } from 'file-loader'; import { name } from 'file-loader';
@ -192,10 +192,7 @@ export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid); const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
const externalSecrets = getExternalCollectionSecretsForActiveEnvironment({ collection }); _sendCollectionOauth2Request(collection, environment, collectionCopy.runtimeVariables)
const secretVariables = getFormattedCollectionSecretVariables({ externalSecrets });
_sendCollectionOauth2Request(collection, environment, collectionCopy.runtimeVariables, itemUid, secretVariables)
.then((response) => { .then((response) => {
if (response?.data?.error) { if (response?.data?.error) {
toast.error(response?.data?.error); toast.error(response?.data?.error);
@ -284,7 +281,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err)); cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err));
}; };
export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => { export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
@ -315,7 +312,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dis
collectionCopy, collectionCopy,
environment, environment,
collectionCopy.runtimeVariables, collectionCopy.runtimeVariables,
recursive recursive,
delay
) )
.then(resolve) .then(resolve)
.catch((err) => { .catch((err) => {
@ -700,7 +698,7 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di
}; };
export const newHttpRequest = (params) => (dispatch, getState) => { export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body } = params; const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const state = getState(); const state = getState();
@ -710,11 +708,20 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
} }
const parts = splitOnFirst(requestUrl, '?'); const parts = splitOnFirst(requestUrl, '?');
const params = parseQueryParams(parts[1]); const queryParams = parseQueryParams(parts[1]);
each(params, (urlParam) => { each(queryParams, (urlParam) => {
urlParam.enabled = true; urlParam.enabled = true;
urlParam.type = 'query';
}); });
const pathParams = parsePathParams(requestUrl);
each(pathParams, (pathParm) => {
pathParams.enabled = true;
pathParm.type = 'path'
});
const params = [...queryParams, ...pathParams];
const item = { const item = {
uid: uuid(), uid: uuid(),
type: requestType, type: requestType,
@ -733,6 +740,9 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
multipartForm: null, multipartForm: null,
formUrlEncoded: null, formUrlEncoded: null,
rawFile: null rawFile: null
},
auth: auth ?? {
mode: 'none'
} }
} }
}; };

View File

@ -58,6 +58,15 @@ body::-webkit-scrollbar-thumb,
border-radius: 5rem; border-radius: 5rem;
} }
/*
* Mac-specific scrollbar styling
* This ensures that scrollbars are only visible when the user starts to scroll,
* providing a cleaner and more minimalistic appearance.
*/
body.os-mac * {
scrollbar-width: thin;
}
/* /*
* todo: this will be supported in the future to be changed via applying a theme * todo: this will be supported in the future to be changed via applying a theme
* making all the checkboxes and radios bigger * making all the checkboxes and radios bigger

View File

@ -20,7 +20,11 @@ const darkTheme = {
input: { input: {
bg: 'rgb(65, 65, 65)', bg: 'rgb(65, 65, 65)',
border: 'rgb(65, 65, 65)', border: 'rgb(65, 65, 65)',
focusBorder: 'rgb(65, 65, 65)' focusBorder: 'rgb(65, 65, 65)',
placeholder: {
color: '#a2a2a2',
opacity: 0.75
}
}, },
variables: { variables: {
@ -154,7 +158,7 @@ const darkTheme = {
modal: { modal: {
title: { title: {
color: '#ccc', color: '#ccc',
bg: 'rgb(48, 48, 49)', bg: 'rgb(38, 38, 39)',
iconColor: '#ccc' iconColor: '#ccc'
}, },
body: { body: {

View File

@ -20,7 +20,11 @@ const lightTheme = {
input: { input: {
bg: 'white', bg: 'white',
border: '#ccc', border: '#ccc',
focusBorder: '#8b8b8b' focusBorder: '#8b8b8b',
placeholder: {
color: '#a2a2a2',
opacity: 0.8
}
}, },
menubar: { menubar: {

View File

@ -2,10 +2,16 @@ const createContentType = (mode) => {
switch (mode) { switch (mode) {
case 'json': case 'json':
return 'application/json'; return 'application/json';
case 'text':
return 'text/plain';
case 'xml': case 'xml':
return 'application/xml'; return 'application/xml';
case 'sparql':
return 'application/sparql-query';
case 'formUrlEncoded': case 'formUrlEncoded':
return 'application/x-www-form-urlencoded'; return 'application/x-www-form-urlencoded';
case 'graphql':
return 'application/json';
case 'multipartForm': case 'multipartForm':
return 'multipart/form-data'; return 'multipart/form-data';
default: default:
@ -13,13 +19,19 @@ const createContentType = (mode) => {
} }
}; };
const createHeaders = (headers) => { const createHeaders = (request, headers) => {
return headers const enabledHeaders = headers
.filter((header) => header.enabled) .filter((header) => header.enabled)
.map((header) => ({ .map((header) => ({
name: header.name, name: header.name,
value: header.value value: header.value
})); }));
const contentType = createContentType(request.body?.mode);
if (contentType !== '') {
enabledHeaders.push({ name: 'content-type', value: contentType });
}
return enabledHeaders;
}; };
const createQuery = (queryParams = []) => { const createQuery = (queryParams = []) => {
@ -54,7 +66,7 @@ export const buildHarRequest = ({ request, headers }) => {
url: encodeURI(request.url), url: encodeURI(request.url),
httpVersion: 'HTTP/1.1', httpVersion: 'HTTP/1.1',
cookies: [], cookies: [],
headers: createHeaders(headers), headers: createHeaders(request, headers),
queryString: createQuery(request.params), queryString: createQuery(request.params),
postData: createPostData(request.body), postData: createPostData(request.body),
headersSize: 0, headersSize: 0,

View File

@ -3,7 +3,7 @@ import filter from 'lodash/filter';
import find from 'lodash/find'; import find from 'lodash/find';
export const doesRequestMatchSearchText = (request, searchText = '') => { export const doesRequestMatchSearchText = (request, searchText = '') => {
return request.name.toLowerCase().includes(searchText.toLowerCase()); return request?.name?.toLowerCase().includes(searchText.toLowerCase());
}; };
export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => { export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => {

View File

@ -12,6 +12,64 @@ const pathFoundInVariables = (path, obj) => {
return value !== undefined; return value !== undefined;
}; };
/**
* Changes the render behaviour for a given CodeMirror editor.
* Replaces all **rendered** characters, not the actual value, with the provided character.
*/
export class MaskedEditor {
/**
* @param {import('codemirror').Editor} editor CodeMirror editor instance
* @param {string} maskChar Target character being applied to all content
*/
constructor(editor, maskChar) {
this.editor = editor;
this.maskChar = maskChar;
this.enabled = false;
}
/**
* Set and apply new masking character
*/
enable = () => {
this.enabled = true;
this.editor.setValue(this.editor.getValue());
this.editor.on('inputRead', this.maskContent);
this.update();
};
/** Disables masking of the editor field. */
disable = () => {
this.enabled = false;
this.editor.off('inputRead', this.maskContent);
this.editor.setValue(this.editor.getValue());
};
/** Updates the rendered content if enabled. */
update = () => {
if (this.enabled) this.maskContent();
};
/** Replaces all rendered characters, with the provided character. */
maskContent = () => {
const content = this.editor.getValue();
this.editor.operation(() => {
// Clear previous masked text
this.editor.getAllMarks().forEach((mark) => mark.clear());
// Apply new masked text
for (let i = 0; i < content.length; i++) {
if (content[i] !== '\n') {
const maskedNode = document.createTextNode(this.maskChar);
this.editor.markText(
{ line: this.editor.posFromIndex(i).line, ch: this.editor.posFromIndex(i).ch },
{ line: this.editor.posFromIndex(i + 1).line, ch: this.editor.posFromIndex(i + 1).ch },
{ replacedWith: maskedNode, handleMouseEvents: true }
);
}
}
});
};
}
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => { export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) { CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
const { pathParams = {}, ...variables } = _variables || {}; const { pathParams = {}, ...variables } = _variables || {};

View File

@ -123,7 +123,7 @@ const curlToJson = (curlCommand) => {
request.urlWithoutQuery = 'http://' + request.urlWithoutQuery; request.urlWithoutQuery = 'http://' + request.urlWithoutQuery;
} }
requestJson.url = request.urlWithoutQuery.replace(/\/$/, ''); requestJson.url = request.urlWithoutQuery;
requestJson.raw_url = request.url; requestJson.raw_url = request.url;
requestJson.method = request.method; requestJson.method = request.method;
@ -160,14 +160,15 @@ const curlToJson = (curlCommand) => {
} }
if (request.auth) { if (request.auth) {
const splitAuth = request.auth.split(':'); if(request.auth.mode === 'basic'){
const user = splitAuth[0] || '';
const password = splitAuth[1] || '';
requestJson.auth = { requestJson.auth = {
user: repr(user), mode: 'basic',
password: repr(password) basic: {
}; username: repr(request.auth.basic?.username),
password: repr(request.auth.basic?.password)
}
}
}
} }
return Object.keys(requestJson).length ? requestJson : {}; return Object.keys(requestJson).length ? requestJson : {};

View File

@ -75,4 +75,15 @@ describe('curlToJson', () => {
} }
}); });
}); });
it('should return and parse a simple curl command with a trailing slash', () => {
const curlCommand = 'curl https://www.usebruno.com/';
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://www.usebruno.com/',
raw_url: 'https://www.usebruno.com/',
method: 'get'
});
});
}); });

View File

@ -56,7 +56,8 @@ export const getRequestFromCurlCommand = (curlCommand) => {
url: request.url, url: request.url,
method: request.method, method: request.method,
body, body,
headers: headers headers: headers,
auth: request.auth
}; };
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -36,7 +36,8 @@ const parseCurlCommand = (curlCommand) => {
boolean: ['I', 'head', 'compressed', 'L', 'k', 'silent', 's', 'G', 'get'], boolean: ['I', 'head', 'compressed', 'L', 'k', 'silent', 's', 'G', 'get'],
alias: { alias: {
H: 'header', H: 'header',
A: 'user-agent' A: 'user-agent',
u: 'user'
} }
}); });
@ -72,12 +73,11 @@ const parseCurlCommand = (curlCommand) => {
parsedArguments.header.forEach((header) => { parsedArguments.header.forEach((header) => {
if (header.indexOf('Cookie') !== -1) { if (header.indexOf('Cookie') !== -1) {
cookieString = header; cookieString = header;
} else { }
const components = header.split(/:(.*)/); const components = header.split(/:(.*)/);
if (components[1]) { if (components[1]) {
headers[components[0]] = components[1].trim(); headers[components[0]] = components[1].trim();
} }
}
}); });
} }
@ -188,10 +188,21 @@ const parseCurlCommand = (curlCommand) => {
} }
urlObject.search = null; // Clean out the search/query portion. urlObject.search = null; // Clean out the search/query portion.
let urlWithoutQuery = URL.format(urlObject);
let urlHost = urlObject?.host;
if (!url?.includes(`${urlHost}/`)) {
if (urlWithoutQuery && urlHost) {
const [beforeHost, afterHost] = urlWithoutQuery.split(urlHost);
urlWithoutQuery = beforeHost + urlHost + afterHost?.slice(1);
}
}
const request = { const request = {
url: url, url,
urlWithoutQuery: URL.format(urlObject) urlWithoutQuery
}; };
if (compressed) { if (compressed) {
request.compressed = true; request.compressed = true;
} }
@ -227,12 +238,19 @@ const parseCurlCommand = (curlCommand) => {
request.data = parsedArguments['data-urlencode']; request.data = parsedArguments['data-urlencode'];
} }
if (parsedArguments.u) { if (parsedArguments.user && typeof parsedArguments.user === 'string') {
request.auth = parsedArguments.u; const basicAuth = parsedArguments.user.split(':')
const username = basicAuth[0] || ''
const password = basicAuth[1] || ''
request.auth = {
mode: 'basic',
basic: {
username,
password
} }
if (parsedArguments.user) {
request.auth = parsedArguments.user;
} }
}
if (Array.isArray(request.data)) { if (Array.isArray(request.data)) {
request.dataArray = request.data; request.dataArray = request.data;
request.data = request.data.join('&'); request.data = request.data.join('&');

View File

@ -1,5 +1,5 @@
{ {
"version": "v1.22.0", "version": "v1.25.0",
"name": "bruno", "name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs", "description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com", "homepage": "https://www.usebruno.com",

View File

@ -428,17 +428,16 @@ class Watcher {
this.watchers = {}; this.watchers = {};
} }
addWatcher(win, watchPath, collectionUid, brunoConfig) { addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false) {
if (this.watchers[watchPath]) { if (this.watchers[watchPath]) {
this.watchers[watchPath].close(); this.watchers[watchPath].close();
} }
const ignores = brunoConfig?.ignore || []; const ignores = brunoConfig?.ignore || [];
const self = this;
setTimeout(() => { setTimeout(() => {
const watcher = chokidar.watch(watchPath, { const watcher = chokidar.watch(watchPath, {
ignoreInitial: false, ignoreInitial: false,
usePolling: watchPath.startsWith('\\\\') ? true : false, usePolling: watchPath.startsWith('\\\\') || forcePolling ? true : false,
ignored: (filepath) => { ignored: (filepath) => {
const normalizedPath = filepath.replace(/\\/g, '/'); const normalizedPath = filepath.replace(/\\/g, '/');
const relativePath = path.relative(watchPath, normalizedPath); const relativePath = path.relative(watchPath, normalizedPath);
@ -457,14 +456,36 @@ class Watcher {
depth: 20 depth: 20
}); });
let startedNewWatcher = false;
watcher watcher
.on('add', (pathname) => add(win, pathname, collectionUid, watchPath)) .on('add', (pathname) => add(win, pathname, collectionUid, watchPath))
.on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath)) .on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath))
.on('change', (pathname) => change(win, pathname, collectionUid, watchPath)) .on('change', (pathname) => change(win, pathname, collectionUid, watchPath))
.on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath)) .on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath))
.on('unlinkDir', (pathname) => unlinkDir(win, pathname, collectionUid, watchPath)); .on('unlinkDir', (pathname) => unlinkDir(win, pathname, collectionUid, watchPath))
.on('error', (error) => {
// `EMFILE` is an error code thrown when to many files are watched at the same time see: https://github.com/usebruno/bruno/issues/627
// `ENOSPC` stands for "Error No space" but is also thrown if the file watcher limit is reached.
// To prevent loops `!forcePolling` is checked.
if ((error.code === 'ENOSPC' || error.code === 'EMFILE') && !startedNewWatcher && !forcePolling) {
// This callback is called for every file the watcher is trying to watch. To prevent a spam of messages and
// Multiple watcher being started `startedNewWatcher` is set to prevent this.
startedNewWatcher = true;
watcher.close();
console.error(
`\nCould not start watcher for ${watchPath}:`,
'ENOSPC: System limit for number of file watchers reached!',
'Trying again with polling, this will be slower!\n',
'Update you system config to allow more concurrently watched files with:',
'"echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"'
);
this.addWatcher(win, watchPath, collectionUid, brunoConfig, true);
} else {
console.error(`An error occurred in the watcher for: ${watchPath}`, error);
}
});
self.watchers[watchPath] = watcher; this.watchers[watchPath] = watcher;
}, 100); }, 100);
} }

View File

@ -50,6 +50,7 @@ app.on('ready', async () => {
height, height,
minWidth: 1000, minWidth: 1000,
minHeight: 640, minHeight: 640,
show: false,
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
contextIsolation: true, contextIsolation: true,
@ -67,6 +68,9 @@ app.on('ready', async () => {
mainWindow.maximize(); mainWindow.maximize();
} }
mainWindow.once('ready-to-show', () => {
mainWindow.show();
})
const url = isDev const url = isDev
? 'http://localhost:3000' ? 'http://localhost:3000'
: format({ : format({

View File

@ -408,7 +408,7 @@ const registerNetworkIpc = (mainWindow) => {
} }
// run post-response script // run post-response script
const responseScript = compact(scriptingConfig.flow === 'natural' ? [ const responseScript = compact(scriptingConfig.flow === 'sequential' ? [
get(collectionRoot, 'request.script.res'), get(request, 'script.res') get(collectionRoot, 'request.script.res'), get(request, 'script.res')
] : [ ] : [
get(request, 'script.res'), get(collectionRoot, 'request.script.res') get(request, 'script.res'), get(collectionRoot, 'request.script.res')
@ -596,7 +596,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests // run tests
const testScript = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests'); const testScript = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
const testFile = compact(scriptingConfig.flow === 'natural' ? [ const testFile = compact(scriptingConfig.flow === 'sequential' ? [
get(collectionRoot, 'request.tests'), testScript, get(collectionRoot, 'request.tests'), testScript,
] : [ ] : [
testScript, get(collectionRoot, 'request.tests') testScript, get(collectionRoot, 'request.tests')
@ -825,7 +825,7 @@ const registerNetworkIpc = (mainWindow) => {
ipcMain.handle( ipcMain.handle(
'renderer:run-collection-folder', 'renderer:run-collection-folder',
async (event, folder, collection, environment, runtimeVariables, recursive) => { async (event, folder, collection, environment, runtimeVariables, recursive, delay) => {
const collectionUid = collection.uid; const collectionUid = collection.uid;
const collectionPath = collection.pathname; const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null; const folderUid = folder ? folder.uid : null;
@ -944,6 +944,18 @@ const registerNetworkIpc = (mainWindow) => {
timeStart = Date.now(); timeStart = Date.now();
let response, responseTime; let response, responseTime;
try { try {
if (delay && !Number.isNaN(delay) && delay > 0) {
const delayPromise = new Promise((resolve) => setTimeout(resolve, delay));
const cancellationPromise = new Promise((_, reject) => {
abortController.signal.addEventListener('abort', () => {
reject(new Error('Cancelled'));
});
});
await Promise.race([delayPromise, cancellationPromise]);
}
/** @type {import('axios').AxiosResponse} */ /** @type {import('axios').AxiosResponse} */
response = await axiosInstance(request); response = await axiosInstance(request);
timeEnd = Date.now(); timeEnd = Date.now();
@ -1036,7 +1048,7 @@ const registerNetworkIpc = (mainWindow) => {
// run tests // run tests
const testScript = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests'); const testScript = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
const testFile = compact(scriptingConfig.flow === 'natural' ? [ const testFile = compact(scriptingConfig.flow === 'sequential' ? [
get(collectionRoot, 'request.tests'), testScript get(collectionRoot, 'request.tests'), testScript
] : [ ] : [
testScript, get(collectionRoot, 'request.tests') testScript, get(collectionRoot, 'request.tests')
@ -1153,12 +1165,22 @@ const registerNetworkIpc = (mainWindow) => {
return `response.${extension}`; return `response.${extension}`;
}; };
const fileName = const getEncodingFormat = () => {
getFileNameFromContentDispositionHeader() || getFileNameFromUrlPath() || getFileNameBasedOnContentTypeHeader(); const contentType = getHeaderValue('content-type');
const extension = mime.extension(contentType) || 'txt';
return ['json', 'xml', 'html', 'yml', 'yaml', 'txt'].includes(extension) ? 'utf-8' : 'base64';
};
const determineFileName = () => {
return (
getFileNameFromContentDispositionHeader() || getFileNameFromUrlPath() || getFileNameBasedOnContentTypeHeader()
);
};
const fileName = determineFileName();
const filePath = await chooseFileToSave(mainWindow, fileName); const filePath = await chooseFileToSave(mainWindow, fileName);
if (filePath) { if (filePath) {
await writeBinaryFile(filePath, Buffer.from(response.dataBuffer, 'base64')); await writeBinaryFile(filePath, Buffer.from(response.dataBuffer, getEncodingFormat()));
} }
} catch (error) { } catch (error) {
return Promise.reject(error); return Promise.reject(error);

View File

@ -32,9 +32,6 @@ const resolveOAuth2AuthorizationCodeAccessToken = async (request, collectionUid)
client_secret: clientSecret, client_secret: clientSecret,
state: state state: state
}; };
if (scope) {
data['scope'] = scope;
}
if (pkce) { if (pkce) {
data['code_verifier'] = codeVerifier; data['code_verifier'] = codeVerifier;
} }

View File

@ -18,8 +18,8 @@ const mergeFolderLevelHeaders = (request, requestTreePath) => {
folderHeaders.set(header.name, header.value); folderHeaders.set(header.name, header.value);
} }
}); });
} else { } else if (i.uid === request.uid) {
let headers = get(i, 'request.headers', []); const headers = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'request.headers', []);
headers.forEach((header) => { headers.forEach((header) => {
if (header.enabled) { if (header.enabled) {
folderHeaders.set(header.name, header.value); folderHeaders.set(header.name, header.value);
@ -55,8 +55,8 @@ const mergeFolderLevelVars = (request, requestTreePath) => {
folderReqVars.set(_var.name, _var.value); folderReqVars.set(_var.name, _var.value);
} }
}); });
} else { } else if (i.uid === request.uid) {
let vars = get(i, 'request.vars.req', []); const vars = i?.draft ? get(i, 'draft.request.vars.req', []) : get(i, 'request.vars.req', []);
vars.forEach((_var) => { vars.forEach((_var) => {
if (_var.enabled) { if (_var.enabled) {
folderReqVars.set(_var.name, _var.value); folderReqVars.set(_var.name, _var.value);
@ -91,8 +91,8 @@ const mergeFolderLevelVars = (request, requestTreePath) => {
folderResVars.set(_var.name, _var.value); folderResVars.set(_var.name, _var.value);
} }
}); });
} else { } else if (i.uid === request.uid) {
let vars = get(i, 'request.vars.res', []); const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
vars.forEach((_var) => { vars.forEach((_var) => {
if (_var.enabled) { if (_var.enabled) {
folderResVars.set(_var.name, _var.value); folderResVars.set(_var.name, _var.value);
@ -147,7 +147,7 @@ const mergeFolderLevelScripts = (request, requestTreePath, scriptFlow) => {
} }
if (folderCombinedPostResScript.length) { if (folderCombinedPostResScript.length) {
if (scriptFlow === 'natural') { if (scriptFlow === 'sequential') {
request.script.res = compact([...folderCombinedPostResScript, request?.script?.res || '']).join(os.EOL); request.script.res = compact([...folderCombinedPostResScript, request?.script?.res || '']).join(os.EOL);
} else { } else {
request.script.res = compact([request?.script?.res || '', ...folderCombinedPostResScript.reverse()]).join(os.EOL); request.script.res = compact([request?.script?.res || '', ...folderCombinedPostResScript.reverse()]).join(os.EOL);
@ -155,7 +155,7 @@ const mergeFolderLevelScripts = (request, requestTreePath, scriptFlow) => {
} }
if (folderCombinedTests.length) { if (folderCombinedTests.length) {
if (scriptFlow === 'natural') { if (scriptFlow === 'sequential') {
request.tests = compact([...folderCombinedTests, request?.tests || '']).join(os.EOL); request.tests = compact([...folderCombinedTests, request?.tests || '']).join(os.EOL);
} else { } else {
request.tests = compact([request?.tests || '', ...folderCombinedTests.reverse()]).join(os.EOL); request.tests = compact([request?.tests || '', ...folderCombinedTests.reverse()]).join(os.EOL);
@ -309,7 +309,7 @@ const prepareRequest = (item, collection) => {
} }
}); });
// scriptFlow is either "sandwich" or "natural" // scriptFlow is either "sandwich" or "sequential"
const scriptFlow = collection.brunoConfig?.scripts?.flow ?? 'sandwich'; const scriptFlow = collection.brunoConfig?.scripts?.flow ?? 'sandwich';
const requestTreePath = getTreePathFromCollectionToItem(collection, item); const requestTreePath = getTreePathFromCollectionToItem(collection, item);
if (requestTreePath && requestTreePath.length > 0) { if (requestTreePath && requestTreePath.length > 0) {

View File

@ -13,7 +13,7 @@ const stripLastLine = (text) => {
}; };
const getValueString = (value) => { const getValueString = (value) => {
const hasNewLines = value.includes('\n'); const hasNewLines = value?.includes('\n');
if (!hasNewLines) { if (!hasNewLines) {
return value; return value;
@ -269,7 +269,6 @@ ${indentString(body.rawFile)}
multipartForms multipartForms
.map((item) => { .map((item) => {
const enabled = item.enabled ? '' : '~'; const enabled = item.enabled ? '' : '~';
if (item.type === 'text') { if (item.type === 'text') {
return `${enabled}${item.name}: ${getValueString(item.value)}`; return `${enabled}${item.name}: ${getValueString(item.value)}`;
} }

View File

@ -48,7 +48,7 @@ const varsSchema = Yup.object({
const requestUrlSchema = Yup.string().min(0).defined(); const requestUrlSchema = Yup.string().min(0).defined();
const requestMethodSchema = Yup.string() const requestMethodSchema = Yup.string()
.oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']) .oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'])
.required('method is required'); .required('method is required');
const graphqlBodySchema = Yup.object({ const graphqlBodySchema = Yup.object({

View File

@ -32,7 +32,7 @@ describe('Request Schema Validation', () => {
return Promise.all([ return Promise.all([
expect(requestSchema.validate(request)).rejects.toEqual( expect(requestSchema.validate(request)).rejects.toEqual(
validationErrorWithMessages( validationErrorWithMessages(
'method must be one of the following values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS' 'method must be one of the following values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, TRACE'
) )
) )
]); ]);