Merge branch 'usebruno:main' into feature/add-raw-file-request-body-option

This commit is contained in:
zachary-berdell-elliott 2024-08-10 15:26:06 -06:00 committed by GitHub
commit 069a038f61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 490 additions and 149 deletions

View File

@ -15,6 +15,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies
run: npm ci --legacy-peer-deps
@ -50,6 +52,8 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies
run: npm ci --legacy-peer-deps
@ -71,15 +75,3 @@ jobs:
with:
files: packages/bruno-tests/collection/junit.xml
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
npm run build:bruno-query
# construction de bruno common
npm run build:bruno-common
# démarrage de next (terminal 1)
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"/>
#### Apoiadores Bronze
<a href="https://zuplo.link/bruno">
<img src="../../assets/images/sponsors/zuplo.png" width="120"/>
</a>
### Links Importantes 📌
- [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": {
"name": "bruno",
"version": "v1.21.0",
"version": "v1.23.1",
"dependencies": {
"@aws-sdk/credential-providers": "3.525.0",
"@usebruno/common": "0.1.0",

View File

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

View File

@ -16,6 +16,7 @@ import stripJsonComments from 'strip-json-comments';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const TAB_SIZE = 2;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
@ -121,7 +122,7 @@ export default class CodeEditor extends React.Component {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json',
keyMap: 'sublime',
autoCloseBrackets: true,
@ -169,7 +170,33 @@ export default class CodeEditor extends React.Component {
'Ctrl-Y': 'foldAll',
'Cmd-Y': 'foldAll',
'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: {
widget: (from, to) => {
@ -289,7 +316,7 @@ export default class CodeEditor extends React.Component {
}
return (
<StyledWrapper
className="h-full w-full"
className="h-full w-full flex flex-col relative"
aria-label="Code Editor"
font={this.props.font}
ref={(node) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,11 +9,11 @@ const Vars = ({ item, collection }) => {
return (
<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>
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
</div>
<div className="flex-1">
<div>
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
</div>

View File

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

View File

@ -14,6 +14,8 @@ const Wrapper = styled.div`
display: none;
}
scrollbar-width: none;
li {
display: inline-flex;
max-width: 150px;

View File

@ -1,6 +1,19 @@
import styled from 'styled-components';
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 {
.link {
color: ${(props) => props.theme.textLink};

View File

@ -23,6 +23,7 @@ const getRelativePath = (fullPath, pathname) => {
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
// ref for the runner output body
const runnerBodyRef = useRef();
@ -78,11 +79,11 @@ export default function RunnerResults({ collection }) {
.filter(Boolean);
const runCollection = () => {
dispatch(runCollectionFolder(collection.uid, null, true));
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay)));
};
const runAgain = () => {
dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive));
dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay)));
};
const resetRunner = () => {
@ -116,6 +117,20 @@ export default function RunnerResults({ collection }) {
You have <span className="font-medium">{totalRequestsInCollection}</span> requests in this collection.
</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}>
Run Collection
</button>
@ -167,10 +182,14 @@ export default function RunnerResults({ collection }) {
</span>
{item.status !== 'error' && item.status !== 'completed' ? (
<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="mr-1">{get(item.responseReceived, 'status')}</span>
<span>{get(item.responseReceived, 'statusText')}</span>)
(<span className="mr-1">{item.responseReceived?.status}</span>
<span>{item.responseReceived?.statusText}</span>)
</span>
) : (
<span className="danger text-xs cursor-pointer" onClick={() => setSelectedItem(item)}>
(request failed)
</span>
)}
</div>

View File

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

View File

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

View File

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

View File

@ -160,7 +160,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full"
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"

View File

@ -161,7 +161,16 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
return (
<StyledWrapper>
<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>
<label htmlFor="requestName" className="block font-semibold">
Type
@ -220,6 +229,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
id="request-name"
type="text"
name="requestName"
placeholder="Request Name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
@ -252,6 +262,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
id="request-url"
type="text"
name="requestUrl"
placeholder="Request URL"
className="px-3 w-full "
autoComplete="off"
autoCorrect="off"

View File

@ -129,7 +129,7 @@ const Sidebar = () => {
Star
</GitHubButton> */}
</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.24.0</div>
</div>
</div>
</div>

View File

@ -1,8 +1,9 @@
import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { defineCodeMirrorBrunoVariablesMode, MaskedEditor } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
@ -20,12 +21,28 @@ class SingleLineEditor extends Component {
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
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, {
lineWrapping: false,
lineNumbers: false,
@ -37,21 +54,9 @@ class SingleLineEditor extends Component {
scrollbarStyle: null,
tabindex: 0,
extraKeys: {
Enter: () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
Enter: runHandler,
'Ctrl-Enter': runHandler,
'Cmd-Enter': runHandler,
'Alt-Enter': () => {
if (this.props.allowNewlines) {
this.editor.setValue(this.editor.getValue() + '\n');
@ -60,23 +65,11 @@ class SingleLineEditor extends Component {
this.props.onRun();
}
},
'Shift-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
'Shift-Enter': runHandler,
'Cmd-S': saveHandler,
'Ctrl-S': saveHandler,
'Cmd-F': noopHandler,
'Ctrl-F': noopHandler,
// Tabbing disabled to make tabindex work
Tab: false,
'Shift-Tab': false
@ -93,8 +86,24 @@ class SingleLineEditor extends Component {
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
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 = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
@ -122,6 +131,12 @@ class SingleLineEditor extends Component {
this.cachedValue = 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;
}
@ -135,8 +150,35 @@ class SingleLineEditor extends Component {
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() {
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>;
return (
<div className="flex flex-row justify-between w-full">
<StyledWrapper ref={this.editorRef} className="single-line-editor grow" />
{this.secretEye(this.props.isSecret)}
</div>
);
}
}
export default SingleLineEditor;

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 {
from {
opacity: 0;

View File

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

View File

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

View File

@ -192,10 +192,7 @@ export const sendCollectionOauth2Request = (collectionUid, itemUid) => (dispatch
const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
const externalSecrets = getExternalCollectionSecretsForActiveEnvironment({ collection });
const secretVariables = getFormattedCollectionSecretVariables({ externalSecrets });
_sendCollectionOauth2Request(collection, environment, collectionCopy.runtimeVariables, itemUid, secretVariables)
_sendCollectionOauth2Request(collection, environment, collectionCopy.runtimeVariables)
.then((response) => {
if (response?.data?.error) {
toast.error(response?.data?.error);
@ -284,7 +281,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
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 collection = findCollectionByUid(state.collections.collections, collectionUid);
@ -315,7 +312,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dis
collectionCopy,
environment,
collectionCopy.runtimeVariables,
recursive
recursive,
delay
)
.then(resolve)
.catch((err) => {

View File

@ -58,6 +58,15 @@ body::-webkit-scrollbar-thumb,
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
* making all the checkboxes and radios bigger

View File

@ -20,7 +20,11 @@ const darkTheme = {
input: {
bg: '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: {
@ -154,7 +158,7 @@ const darkTheme = {
modal: {
title: {
color: '#ccc',
bg: 'rgb(48, 48, 49)',
bg: 'rgb(38, 38, 39)',
iconColor: '#ccc'
},
body: {

View File

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

View File

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

View File

@ -12,6 +12,64 @@ const pathFoundInVariables = (path, obj) => {
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) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
const { pathParams = {}, ...variables } = _variables || {};

View File

@ -72,11 +72,10 @@ const parseCurlCommand = (curlCommand) => {
parsedArguments.header.forEach((header) => {
if (header.indexOf('Cookie') !== -1) {
cookieString = header;
} else {
const components = header.split(/:(.*)/);
if (components[1]) {
headers[components[0]] = components[1].trim();
}
}
const components = header.split(/:(.*)/);
if (components[1]) {
headers[components[0]] = components[1].trim();
}
});
}

View File

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

View File

@ -428,17 +428,16 @@ class Watcher {
this.watchers = {};
}
addWatcher(win, watchPath, collectionUid, brunoConfig) {
addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false) {
if (this.watchers[watchPath]) {
this.watchers[watchPath].close();
}
const ignores = brunoConfig?.ignore || [];
const self = this;
setTimeout(() => {
const watcher = chokidar.watch(watchPath, {
ignoreInitial: false,
usePolling: watchPath.startsWith('\\\\') ? true : false,
usePolling: watchPath.startsWith('\\\\') || forcePolling ? true : false,
ignored: (filepath) => {
const normalizedPath = filepath.replace(/\\/g, '/');
const relativePath = path.relative(watchPath, normalizedPath);
@ -457,14 +456,35 @@ class Watcher {
depth: 20
});
let startedNewWatcher = false;
watcher
.on('add', (pathname) => add(win, pathname, collectionUid, watchPath))
.on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath))
.on('change', (pathname) => change(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) => {
// `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' && !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);
}

View File

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

View File

@ -408,7 +408,7 @@ const registerNetworkIpc = (mainWindow) => {
}
// 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(request, 'script.res'), get(collectionRoot, 'request.script.res')
@ -596,7 +596,7 @@ const registerNetworkIpc = (mainWindow) => {
// run 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,
] : [
testScript, get(collectionRoot, 'request.tests')
@ -825,7 +825,7 @@ const registerNetworkIpc = (mainWindow) => {
ipcMain.handle(
'renderer:run-collection-folder',
async (event, folder, collection, environment, runtimeVariables, recursive) => {
async (event, folder, collection, environment, runtimeVariables, recursive, delay) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null;
@ -944,6 +944,18 @@ const registerNetworkIpc = (mainWindow) => {
timeStart = Date.now();
let response, responseTime;
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} */
response = await axiosInstance(request);
timeEnd = Date.now();
@ -1036,7 +1048,7 @@ const registerNetworkIpc = (mainWindow) => {
// run 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
] : [
testScript, get(collectionRoot, 'request.tests')

View File

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

View File

@ -13,7 +13,7 @@ const stripLastLine = (text) => {
};
const getValueString = (value) => {
const hasNewLines = value.includes('\n');
const hasNewLines = value?.includes('\n');
if (!hasNewLines) {
return value;
@ -269,7 +269,6 @@ ${indentString(body.rawFile)}
multipartForms
.map((item) => {
const enabled = item.enabled ? '' : '~';
if (item.type === 'text') {
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 requestMethodSchema = Yup.string()
.oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'])
.oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'])
.required('method is required');
const graphqlBodySchema = Yup.object({

View File

@ -32,7 +32,7 @@ describe('Request Schema Validation', () => {
return Promise.all([
expect(requestSchema.validate(request)).rejects.toEqual(
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'
)
)
]);