mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 01:48:33 +00:00
Compare commits
47 Commits
feature/su
...
v0.13.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb9fc8875 | ||
|
|
26a05f92cb | ||
|
|
fff7819c46 | ||
|
|
1d678ee0d9 | ||
|
|
fd4c188c95 | ||
|
|
97678b05fc | ||
|
|
a1c9625aee | ||
|
|
a9d74467ff | ||
|
|
bd670eceb6 | ||
|
|
1ccb66e92a | ||
|
|
d62881fe0d | ||
|
|
9ea95b4571 | ||
|
|
df9322b767 | ||
|
|
c27c750c3e | ||
|
|
94baee8e25 | ||
|
|
417b50b0ad | ||
|
|
bf5ee7e409 | ||
|
|
3ca0107f1b | ||
|
|
ec22fdb637 | ||
|
|
66024d04e9 | ||
|
|
330a7ad18a | ||
|
|
2976842588 | ||
|
|
51e0ea2c2d | ||
|
|
3b85e7ebcc | ||
|
|
49b0f3a322 | ||
|
|
45ca5ded96 | ||
|
|
b6528062f0 | ||
|
|
86094cc054 | ||
|
|
8e0bc68ada | ||
|
|
c36c7b44a6 | ||
|
|
0ac27dee56 | ||
|
|
ede122ab09 | ||
|
|
96e368cb18 | ||
|
|
942b75861c | ||
|
|
c1711ea01b | ||
|
|
dedfefbc9a | ||
|
|
65dd5df87e | ||
|
|
78d2393686 | ||
|
|
0b65c4580e | ||
|
|
9cc1bf1e2f | ||
|
|
b96e3d0f23 | ||
|
|
11c99d55dc | ||
|
|
d054dc4c78 | ||
|
|
9014dc5769 | ||
|
|
d346970241 | ||
|
|
bdb3051c2b | ||
|
|
f8325b22b3 |
12
.github/workflows/bump-homebrew-cask.yml
vendored
Normal file
12
.github/workflows/bump-homebrew-cask.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Bump Homebrew Cask
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: macos-10.15
|
||||
steps:
|
||||
- name: Bump Homebrew Cask
|
||||
run: brew bump-cask-pr bruno --version "${GITHUB_REF_NAME#v}"
|
||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx pretty-quick --staged
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"printWidth": 180
|
||||
}
|
||||
BIN
assets/images/cli-demo.png
Normal file
BIN
assets/images/cli-demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
assets/images/run-anywhere.png
Normal file
BIN
assets/images/run-anywhere.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 153 KiB |
BIN
assets/images/version-control.png
Normal file
BIN
assets/images/version-control.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -17,7 +17,9 @@
|
||||
"@faker-js/faker": "^7.6.0",
|
||||
"@jest/globals": "^29.2.0",
|
||||
"@playwright/test": "^1.27.1",
|
||||
"husky": "^8.0.3",
|
||||
"jest": "^29.2.0",
|
||||
"pretty-quick": "^3.1.3",
|
||||
"randomstring": "^1.2.2",
|
||||
"ts-jest": "^29.0.5"
|
||||
},
|
||||
@@ -30,9 +32,10 @@
|
||||
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
|
||||
"build:electron": "./scripts/build-electron.sh",
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:report": "npx playwright show-report"
|
||||
"test:report": "npx playwright show-report",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "3.2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePreferences } from 'providers/Preferences';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const General = () => {
|
||||
const {
|
||||
preferences,
|
||||
setPreferences,
|
||||
} = usePreferences();
|
||||
|
||||
const [sslVerification, setSslVerification] = useState(
|
||||
preferences.request.sslVerification
|
||||
);
|
||||
|
||||
const handleCheckboxChange = () => {
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
request: {
|
||||
...preferences.request,
|
||||
sslVerification: !sslVerification,
|
||||
},
|
||||
};
|
||||
|
||||
setPreferences(updatedPreferences)
|
||||
.then(() => {
|
||||
setSslVerification(!sslVerification);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center mt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sslVerification}
|
||||
onChange={handleCheckboxChange}
|
||||
className="mr-3 mousetrap"
|
||||
/>
|
||||
SSL Certificate Verification
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default General;
|
||||
@@ -0,0 +1,36 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
div.tabs {
|
||||
margin-top: -0.5rem;
|
||||
|
||||
div.tab {
|
||||
padding: 6px 0px;
|
||||
border: none;
|
||||
border-bottom: solid 2px transparent;
|
||||
margin-right: 1.25rem;
|
||||
color: var(--color-tab-inactive);
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:active,
|
||||
&:focus-within,
|
||||
&:focus-visible,
|
||||
&:target {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: ${(props) => props.theme.tabs.active.color} !important;
|
||||
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section.tab-panel {
|
||||
min-height: 300px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,8 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: var(--color-text);
|
||||
.collection-options {
|
||||
color: ${(props) => props.theme.text};
|
||||
.rows {
|
||||
svg {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord, IconBook } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Support = () => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="rows">
|
||||
<div className="mt-2">
|
||||
<a href="https://docs.usebruno.com" target="_blank" className="flex items-end">
|
||||
<IconBook size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">Documentation</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="flex items-end">
|
||||
<IconSpeakerphone size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">Report Issues</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a href="https://discord.com/invite/KgcZUncpjq" target="_blank" className="flex items-end">
|
||||
<IconBrandDiscord size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">Discord</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-end">
|
||||
<IconBrandGithub size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">Github</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a href="https://twitter.com/use_bruno" target="_blank" className="flex items-end">
|
||||
<IconBrandTwitter size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">Twitter</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Support;
|
||||
@@ -0,0 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: var(--color-text);
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
64
packages/bruno-app/src/components/Preferences/Theme/index.js
Normal file
64
packages/bruno-app/src/components/Preferences/Theme/index.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const Theme = () => {
|
||||
const { storedTheme, setStoredTheme } = useTheme();
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
theme: storedTheme
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
theme: Yup.string().oneOf(['light', 'dark']).required('theme is required')
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
setStoredTheme(values.theme);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className='bruno-form'>
|
||||
<div className="flex items-center mt-2">
|
||||
<input
|
||||
id="light-theme"
|
||||
className="cursor-pointer"
|
||||
type="radio"
|
||||
name="theme"
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
formik.handleSubmit()
|
||||
}}
|
||||
value="light"
|
||||
checked={formik.values.theme === 'light'}
|
||||
/>
|
||||
<label htmlFor="light-theme" className="ml-1 cursor-pointer select-none">
|
||||
Light
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="dark-theme"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="theme"
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
formik.handleSubmit()
|
||||
}}
|
||||
value="dark"
|
||||
checked={formik.values.theme === 'dark'}
|
||||
/>
|
||||
<label htmlFor="dark-theme" className="ml-1 cursor-pointer select-none">
|
||||
Dark
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Theme;
|
||||
54
packages/bruno-app/src/components/Preferences/index.js
Normal file
54
packages/bruno-app/src/components/Preferences/index.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import Modal from 'components/Modal/index';
|
||||
import classnames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
import Support from './Support';
|
||||
import General from './General';
|
||||
import Theme from './Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Preferences = ({ onClose }) => {
|
||||
const [tab, setTab] = useState('general');
|
||||
|
||||
const getTabClassname = (tabName) => {
|
||||
return classnames(`tab select-none ${tabName}`, {
|
||||
active: tabName === tab
|
||||
});
|
||||
};
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
case 'general': {
|
||||
return <General />;
|
||||
}
|
||||
|
||||
case 'theme': {
|
||||
return <Theme />;
|
||||
}
|
||||
|
||||
case 'support': {
|
||||
return <Support />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}>
|
||||
<div className="flex items-center px-2 tabs" role="tablist">
|
||||
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
|
||||
General
|
||||
</div>
|
||||
<div className={getTabClassname('theme')} role="tab" onClick={() => setTab('theme')}>
|
||||
Theme
|
||||
</div>
|
||||
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
|
||||
Support
|
||||
</div>
|
||||
</div>
|
||||
<section className="flex flex-grow px-2 mt-4 tab-panel">{getTabPanel(tab)}</section>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Preferences;
|
||||
@@ -27,6 +27,10 @@ const Wrapper = styled.div`
|
||||
&:nth-child(4) {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
background-color: ${(props) => props.theme.menubar.bg};
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
min-height: 100vh;
|
||||
|
||||
.menu-item {
|
||||
padding: 0.6rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
color: rgba(255, 255, 255);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
import { toggleLeftMenuBar } from 'providers/ReduxStore/slices/app';
|
||||
import { IconCode, IconFiles, IconMoon, IconChevronsLeft, IconLifebuoy } from '@tabler/icons';
|
||||
|
||||
import Link from 'next/link';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import BrunoSupport from 'components/BrunoSupport';
|
||||
import SwitchTheme from 'components/SwitchTheme';
|
||||
|
||||
const MenuBar = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useDispatch();
|
||||
const [openTheme, setOpenTheme] = useState(false);
|
||||
const [openBrunoSupport, setOpenBrunoSupport] = useState(false);
|
||||
const isPlatformElectron = isElectron();
|
||||
|
||||
const getClassName = (menu) => {
|
||||
return router.pathname === menu ? 'active menu-item' : 'menu-item';
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full flex flex-col">
|
||||
{openBrunoSupport && <BrunoSupport onClose={() => setOpenBrunoSupport(false)} />}
|
||||
{openTheme && <SwitchTheme onClose={() => setOpenTheme(false)} />}
|
||||
|
||||
<div className="flex flex-col">
|
||||
{/* Todo: Fix this: Clicking on this crashes the app */}
|
||||
{/* <Link href="/">
|
||||
<div className={getClassName('/')}>
|
||||
<IconCode size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
</Link> */}
|
||||
{/* <div className="menu-item">
|
||||
<IconUsers size={28} strokeWidth={1.5}/>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow justify-end">
|
||||
{/* <Link href="/login">
|
||||
<div className="menu-item">
|
||||
<IconUser size={28} strokeWidth={1.5}/>
|
||||
</div>
|
||||
</Link> */}
|
||||
<div className="menu-item" onClick={() => setOpenBrunoSupport(true)}>
|
||||
<IconLifebuoy size={28} strokeWidth={1.5}/>
|
||||
</div>
|
||||
<div className="menu-item" onClick={() => setOpenTheme(true)}>
|
||||
<IconMoon size={28} strokeWidth={1.5}/>
|
||||
</div>
|
||||
<div className="menu-item" onClick={() => dispatch(toggleLeftMenuBar())}>
|
||||
<IconChevronsLeft size={28} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuBar;
|
||||
@@ -1,13 +1,13 @@
|
||||
import MenuBar from './MenuBar';
|
||||
import TitleBar from './TitleBar';
|
||||
import Collections from './Collections';
|
||||
import StyledWrapper, { BottomWrapper, VersionNumber } from './StyledWrapper';
|
||||
import GitHubButton from 'react-github-btn'
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import GitHubButton from 'react-github-btn';
|
||||
import Preferences from 'components/Preferences';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconChevronsRight } from '@tabler/icons';
|
||||
import { updateLeftSidebarWidth, updateIsDragging, toggleLeftMenuBar } from 'providers/ReduxStore/slices/app';
|
||||
import { IconSettings } from '@tabler/icons';
|
||||
import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const MIN_LEFT_SIDEBAR_WIDTH = 222;
|
||||
@@ -15,7 +15,7 @@ const MAX_LEFT_SIDEBAR_WIDTH = 600;
|
||||
|
||||
const Sidebar = () => {
|
||||
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const leftMenuBarOpen = useSelector((state) => state.app.leftMenuBarOpen);
|
||||
const [ preferencesOpen, setPreferencesOpen ] = useState(false);
|
||||
|
||||
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
|
||||
|
||||
@@ -76,16 +76,14 @@ const Sidebar = () => {
|
||||
setAsideWidth(leftSidebarWidth);
|
||||
}, [leftSidebarWidth]);
|
||||
|
||||
const leftMenuBarWidth = leftMenuBarOpen ? 48 : 0;
|
||||
const collectionsWidth = asideWidth - leftMenuBarWidth;
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex relative">
|
||||
<aside>
|
||||
<div className="flex flex-row h-full w-full">
|
||||
{leftMenuBarOpen && <MenuBar />}
|
||||
{preferencesOpen && <Preferences onClose={() => setPreferencesOpen(false)} />}
|
||||
|
||||
<div className="flex flex-col w-full" style={{width: collectionsWidth}}>
|
||||
<div className="flex flex-col w-full" style={{width: asideWidth}}>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<TitleBar />
|
||||
<Collections />
|
||||
@@ -93,8 +91,7 @@ const Sidebar = () => {
|
||||
|
||||
<div className="footer flex px-1 py-2 items-center cursor-pointer select-none">
|
||||
<div className="flex items-center ml-1 text-xs ">
|
||||
{!leftMenuBarOpen && <IconChevronsRight size={24} strokeWidth={1.5} className="mr-2 hover:text-gray-700" onClick={() => dispatch(toggleLeftMenuBar())} />}
|
||||
{/* <IconLayoutGrid size={20} strokeWidth={1.5} className="mr-2"/> */}
|
||||
<IconSettings size={18} strokeWidth={1.5} className="mr-2 hover:text-gray-700" onClick={() => setPreferencesOpen(true)} />
|
||||
</div>
|
||||
<div className="pl-1" style={{position: 'relative', top: '3px'}}>
|
||||
{storedTheme === 'dark' ? (
|
||||
@@ -117,7 +114,7 @@ const Sidebar = () => {
|
||||
</GitHubButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.10.2</div>
|
||||
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.13.1</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import Modal from 'components/Modal/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const SwitchTheme = ({ onClose }) => {
|
||||
const { storedTheme, setStoredTheme } = useTheme();
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
theme: storedTheme
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
theme: Yup.string().oneOf(['light', 'dark']).required('theme is required')
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
setStoredTheme(values.theme);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Modal size="sm" title={'Switch Theme'} handleCancel={onClose} hideFooter={true}>
|
||||
<div className='bruno-form'>
|
||||
<div className="flex items-center mt-2">
|
||||
<input
|
||||
id="light-theme"
|
||||
className="cursor-pointer"
|
||||
type="radio"
|
||||
name="theme"
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
formik.handleSubmit()
|
||||
}}
|
||||
value="light"
|
||||
checked={formik.values.theme === 'light'}
|
||||
/>
|
||||
<label htmlFor="light-theme" className="ml-1 cursor-pointer select-none">
|
||||
Light
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="dark-theme"
|
||||
className="ml-4 cursor-pointer"
|
||||
type="radio"
|
||||
name="theme"
|
||||
onChange={(e) => {
|
||||
formik.handleChange(e);
|
||||
formik.handleSubmit()
|
||||
}}
|
||||
value="dark"
|
||||
checked={formik.values.theme === 'dark'}
|
||||
/>
|
||||
<label htmlFor="dark-theme" className="ml-1 cursor-pointer select-none">
|
||||
Dark
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwitchTheme;
|
||||
@@ -3,6 +3,7 @@ import { Provider } from 'react-redux';
|
||||
import { AppProvider } from 'providers/App';
|
||||
import { ToastProvider } from 'providers/Toaster';
|
||||
import { HotkeysProvider } from 'providers/Hotkeys';
|
||||
import { PreferencesProvider } from 'providers/Preferences';
|
||||
|
||||
import ReduxStore from 'providers/ReduxStore';
|
||||
import ThemeProvider from 'providers/Theme/index';
|
||||
@@ -46,9 +47,11 @@ function MyApp({ Component, pageProps }) {
|
||||
<ThemeProvider>
|
||||
<ToastProvider>
|
||||
<AppProvider>
|
||||
<HotkeysProvider>
|
||||
<Component {...pageProps} />
|
||||
</HotkeysProvider>
|
||||
<PreferencesProvider>
|
||||
<HotkeysProvider>
|
||||
<Component {...pageProps} />
|
||||
</HotkeysProvider>
|
||||
</PreferencesProvider>
|
||||
</AppProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
80
packages/bruno-app/src/providers/Preferences/index.js
Normal file
80
packages/bruno-app/src/providers/Preferences/index.js
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Preferences Provider
|
||||
*
|
||||
* This provider is responsible for managing the user's preferences in the app.
|
||||
* The preferences are stored in the browser local storage.
|
||||
*
|
||||
* On start, an IPC event is published to the main process to set the preferences in the electron process.
|
||||
*/
|
||||
|
||||
import { useEffect, createContext, useContext } from 'react';
|
||||
import * as Yup from 'yup';
|
||||
import useLocalStorage from 'hooks/useLocalStorage/index';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const defaultPreferences = {
|
||||
request: {
|
||||
sslVerification: true
|
||||
}
|
||||
};
|
||||
|
||||
const preferencesSchema = Yup.object().shape({
|
||||
request: Yup.object().shape({
|
||||
sslVerification: Yup.boolean()
|
||||
})
|
||||
});
|
||||
|
||||
export const PreferencesContext = createContext();
|
||||
export const PreferencesProvider = (props) => {
|
||||
const [preferences, setPreferences] = useLocalStorage('bruno.preferences', defaultPreferences);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
useEffect(() => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:set-preferences', preferences)
|
||||
.catch(err => {
|
||||
toast.error(err.message || 'Preferences sync error');
|
||||
});
|
||||
}, [preferences, toast]);
|
||||
|
||||
const validatedSetPreferences = (newPreferences) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
preferencesSchema.validate(newPreferences, { abortEarly: true })
|
||||
.then(validatedPreferences => {
|
||||
setPreferences(validatedPreferences);
|
||||
resolve(validatedPreferences);
|
||||
})
|
||||
.catch(error => {
|
||||
let errMsg = error.message || 'Preferences validation error';
|
||||
toast.error(errMsg);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// todo: setPreferences must validate the preferences object against a schema
|
||||
const value = {
|
||||
preferences,
|
||||
setPreferences: validatedSetPreferences
|
||||
};
|
||||
|
||||
return (
|
||||
<PreferencesContext.Provider value={value}>
|
||||
<>
|
||||
{props.children}
|
||||
</>
|
||||
</PreferencesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePreferences = () => {
|
||||
const context = useContext(PreferencesContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(`usePreferences must be used within a PreferencesProvider`);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export default PreferencesProvider;
|
||||
@@ -4,7 +4,6 @@ const initialState = {
|
||||
isDragging: false,
|
||||
idbConnectionReady: false,
|
||||
leftSidebarWidth: 222,
|
||||
leftMenuBarOpen: false,
|
||||
screenWidth: 500,
|
||||
showHomePage: false
|
||||
};
|
||||
@@ -16,14 +15,6 @@ export const appSlice = createSlice({
|
||||
idbConnectionReady: (state) => {
|
||||
state.idbConnectionReady = true;
|
||||
},
|
||||
toggleLeftMenuBar: (state) => {
|
||||
state.leftMenuBarOpen = !state.leftMenuBarOpen;
|
||||
if(state.leftMenuBarOpen) {
|
||||
state.leftSidebarWidth += 48;
|
||||
} else {
|
||||
state.leftSidebarWidth -= 48;
|
||||
}
|
||||
},
|
||||
refreshScreenWidth: (state) => {
|
||||
state.screenWidth = window.innerWidth;
|
||||
},
|
||||
@@ -42,6 +33,6 @@ export const appSlice = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
export const { idbConnectionReady, toggleLeftMenuBar, refreshScreenWidth, updateLeftSidebarWidth, updateIsDragging, showHomePage, hideHomePage } = appSlice.actions;
|
||||
export const { idbConnectionReady, refreshScreenWidth, updateLeftSidebarWidth, updateIsDragging, showHomePage, hideHomePage } = appSlice.actions;
|
||||
|
||||
export default appSlice.reducer;
|
||||
|
||||
@@ -122,6 +122,12 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
|
||||
response: null
|
||||
})
|
||||
);
|
||||
|
||||
if(err && err.message === "Error invoking remote method 'send-http-request': Error: Request cancelled") {
|
||||
console.log('>> request cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('>> sending request failed');
|
||||
console.log(err);
|
||||
toast.error(err ? err.message : 'Something went wrong!');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import themes from 'themes/index';
|
||||
import useLocalStorage from 'src/hooks/useLocalStorage/index';
|
||||
import useLocalStorage from 'hooks/useLocalStorage/index';
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import { ThemeProvider as SCThemeProvider } from 'styled-components';
|
||||
|
||||
@@ -49,3 +49,15 @@ export const safeStringifyJSON = (obj, indent=false) => {
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any characters that are not alphanumeric, spaces, hyphens, or underscores
|
||||
export const normalizeFileName = (name) => {
|
||||
if (!name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
const validChars = /[^\w\s-]/g;
|
||||
const formattedName = name.replace(validChars, '-');
|
||||
|
||||
return formattedName;
|
||||
}
|
||||
|
||||
19
packages/bruno-app/src/utils/common/index.spec.js
Normal file
19
packages/bruno-app/src/utils/common/index.spec.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { describe, it, expect } = require("@jest/globals");
|
||||
|
||||
import { normalizeFileName } from './index';
|
||||
|
||||
describe("common utils", () => {
|
||||
describe("normalizeFileName", () => {
|
||||
it("should remove special characters", () => {
|
||||
expect(normalizeFileName("hello world")).toBe("hello world");
|
||||
expect(normalizeFileName("hello-world")).toBe("hello-world");
|
||||
expect(normalizeFileName("hello_world")).toBe("hello_world");
|
||||
expect(normalizeFileName("hello_world-")).toBe("hello_world-");
|
||||
expect(normalizeFileName("hello_world-123")).toBe("hello_world-123");
|
||||
expect(normalizeFileName("hello_world-123!@#$%^&*()")).toBe("hello_world-123----------");
|
||||
expect(normalizeFileName("hello_world?")).toBe("hello_world-");
|
||||
expect(normalizeFileName("foo/bar/")).toBe("foo-bar-");
|
||||
expect(normalizeFileName("foo\\bar\\")).toBe("foo-bar-");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import each from 'lodash/each';
|
||||
import get from 'lodash/get';
|
||||
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { uuid } from 'utils/common';
|
||||
import { uuid, normalizeFileName } from 'utils/common';
|
||||
import { isItemARequest } from 'utils/collections';
|
||||
import { collectionSchema } from '@usebruno/schema';
|
||||
import { BrunoError } from 'utils/common/error';
|
||||
@@ -63,6 +63,8 @@ export const updateUidsInCollection = (_collection) => {
|
||||
export const transformItemsInCollection = (collection) => {
|
||||
const transformItems = (items = []) => {
|
||||
each(items, (item) => {
|
||||
item.name = normalizeFileName(item.name);
|
||||
|
||||
if (['http', 'graphql'].includes(item.type)) {
|
||||
item.type = `${item.type}-request`;
|
||||
if(item.request.query) {
|
||||
|
||||
BIN
packages/bruno-cli/assets/images/cli-demo.png
Normal file
BIN
packages/bruno-cli/assets/images/cli-demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
22
packages/bruno-cli/license.md
Normal file
22
packages/bruno-cli/license.md
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Anoop M D, Anusree P S and Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,18 +1,26 @@
|
||||
{
|
||||
"name": "@usebruno/cli",
|
||||
"version": "0.3.0",
|
||||
"version": "0.6.0",
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
"bru": "./bin/bru.js"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/usebruno/bruno/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/usebruno/bruno.git"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"bin",
|
||||
"readme.md",
|
||||
"package.json"
|
||||
],
|
||||
"dependencies": {
|
||||
"@usebruno/js": "0.2.0",
|
||||
"@usebruno/lang": "0.2.2",
|
||||
"@usebruno/js": "0.4.0",
|
||||
"@usebruno/lang": "0.3.0",
|
||||
"axios": "^1.3.2",
|
||||
"chai": "^4.3.7",
|
||||
"chalk": "^3.0.0",
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
# bruno-cli
|
||||
|
||||
Bru CLI
|
||||
With Bruno CLI, you can now run your API collections with ease using simple command line commands.
|
||||
|
||||
### Publish to Npm Registry
|
||||
This makes it easier to test your APIs in different environments, automate your testing process, and integrate your API tests with your continuous integration and deployment workflows.
|
||||
|
||||
## Installation
|
||||
To install the Bruno CLI, use the node package manager of your choice, such as NPM:
|
||||
```bash
|
||||
npm publish --access=public
|
||||
```
|
||||
npm install -g @usebruno/cli
|
||||
```
|
||||
|
||||
## Getting started
|
||||
Navigate to the directory where your API collection resides, and then run:
|
||||
```bash
|
||||
bru run
|
||||
```
|
||||
This command will run all the requests in your collection. You can also run a single request by specifying its filename:
|
||||
|
||||
```bash
|
||||
bru run request.bru
|
||||
```
|
||||
|
||||
Or run all requests in a collection's subfolder:
|
||||
```bash
|
||||
bru run folder
|
||||
```
|
||||
|
||||
If you need to use an environment, you can specify it with the --env option:
|
||||
```bash
|
||||
bru run folder --env Local
|
||||
```
|
||||
|
||||
## Demo
|
||||

|
||||
|
||||
## Support
|
||||
If you encounter any issues or have any feedback or suggestions, please raise them on our [GitHub repository](https://github.com/usebruno/bruno)
|
||||
|
||||
Thank you for using Bruno CLI!
|
||||
|
||||
|
||||
## License
|
||||
[MIT](license.md)
|
||||
@@ -35,6 +35,15 @@ const printRunSummary = (assertionResults, testResults) => {
|
||||
|
||||
console.log("\n" + chalk.bold(assertSummary));
|
||||
console.log(chalk.bold(testSummary));
|
||||
|
||||
return {
|
||||
totalAssertions,
|
||||
passedAssertions,
|
||||
failedAssertions,
|
||||
totalTests,
|
||||
passedTests,
|
||||
failedTests
|
||||
}
|
||||
};
|
||||
|
||||
const getBruFilesRecursively = (dir) => {
|
||||
@@ -45,6 +54,10 @@ const getBruFilesRecursively = (dir) => {
|
||||
|
||||
const traverse = (currentPath) => {
|
||||
const filesInCurrentDir = fs.readdirSync(currentPath);
|
||||
|
||||
if (currentPath.includes('node_modules')) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of filesInCurrentDir) {
|
||||
const filePath = path.join(currentPath, file);
|
||||
@@ -170,8 +183,14 @@ const handler = async function (argv) {
|
||||
testResults
|
||||
} = result;
|
||||
|
||||
printRunSummary(assertionResults, testResults);
|
||||
const summary = printRunSummary(assertionResults, testResults);
|
||||
console.log(chalk.dim(chalk.grey('Done.')));
|
||||
|
||||
if(summary.failedAssertions > 0 || summary.failedTests > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,12 +245,17 @@ const handler = async function (argv) {
|
||||
}
|
||||
}
|
||||
|
||||
printRunSummary(assertionResults, testResults);
|
||||
const summary = printRunSummary(assertionResults, testResults);
|
||||
console.log(chalk.dim(chalk.grey('Ran all requests.')));
|
||||
|
||||
if(summary.failedAssertions > 0 || summary.failedTests > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Something went wrong");
|
||||
console.error(chalk.red(err.message));
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -42,6 +42,17 @@ const interpolateVars = (request, envVars = {}, collectionVariables ={}) => {
|
||||
request.data = interpolate(request.data);
|
||||
}
|
||||
}
|
||||
} else if(request.headers["content-type"] === "application/x-www-form-urlencoded") {
|
||||
if(typeof request.data === "object") {
|
||||
try {
|
||||
let parsed = JSON.stringify(request.data);
|
||||
parsed = interpolate(parsed);
|
||||
request.data = JSON.parse(parsed);
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
request.data = interpolate(request.data);
|
||||
}
|
||||
|
||||
each(request.params, (param) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { get, each, filter } = require('lodash');
|
||||
const qs = require('qs');
|
||||
|
||||
const prepareRequest = (request) => {
|
||||
const headers = {};
|
||||
@@ -41,7 +40,7 @@ const prepareRequest = (request) => {
|
||||
const params = {};
|
||||
const enabledParams = filter(request.body.formUrlEncoded, (p) => p.enabled);
|
||||
each(enabledParams, (p) => (params[p.name] = p.value));
|
||||
axiosRequest.data = qs.stringify(params);
|
||||
axiosRequest.data = params;
|
||||
}
|
||||
|
||||
if (request.body.mode === 'multipartForm') {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const qs = require('qs');
|
||||
const chalk = require('chalk');
|
||||
const { forOwn, each, extend, get } = require('lodash');
|
||||
const FormData = require('form-data');
|
||||
@@ -39,6 +40,11 @@ const runSingleRequest = async function (filename, bruJson, collectionPath, coll
|
||||
// interpolate variables inside request
|
||||
interpolateVars(request, envVariables, collectionVariables);
|
||||
|
||||
// stringify the request url encoded params
|
||||
if(request.headers['content-type'] === 'application/x-www-form-urlencoded') {
|
||||
request.data = qs.stringify(request.data);
|
||||
}
|
||||
|
||||
// run request
|
||||
const response = await axios(request);
|
||||
|
||||
|
||||
11
packages/bruno-docs/package.json
Normal file
11
packages/bruno-docs/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@usebruno/docs",
|
||||
"version": "0.0.1",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"package.json"
|
||||
],
|
||||
"dependencies": {
|
||||
}
|
||||
}
|
||||
5
packages/bruno-docs/readme.md
Normal file
5
packages/bruno-docs/readme.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# bruno-docs
|
||||
|
||||
This is a wip.
|
||||
|
||||
We have a request to generate docs in a html file that can be hosted on a server so that the visitor can view the API and make requests without downloading/installing anything.
|
||||
56
packages/bruno-electron/electron-builder-config.js
Normal file
56
packages/bruno-electron/electron-builder-config.js
Normal file
@@ -0,0 +1,56 @@
|
||||
require('dotenv').config({ path: process.env.DOTENV_PATH })
|
||||
|
||||
const config = {
|
||||
"appId": "com.usebruno.app",
|
||||
"productName": "Bruno",
|
||||
"electronVersion": "21.1.1",
|
||||
"directories": {
|
||||
"buildResources": "resources",
|
||||
"output": "out"
|
||||
},
|
||||
"files": [
|
||||
"**/*"
|
||||
],
|
||||
"afterSign": "notarize.js",
|
||||
"mac": {
|
||||
"artifactName": "${name}_${version}_${arch}_${os}.${ext}",
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "resources/icons/mac/icon.icns",
|
||||
"hardenedRuntime": true,
|
||||
"identity": "Anoop MD (W7LPPWA48L)",
|
||||
"entitlements": "resources/entitlements.mac.plist",
|
||||
"entitlementsInherit": "resources/entitlements.mac.plist"
|
||||
},
|
||||
"linux": {
|
||||
"artifactName": "${name}_${version}_${arch}_linux.${ext}",
|
||||
"icon": "resources/icons/png",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"artifactName": "${name}_${version}_${arch}_win.${ext}",
|
||||
"icon": "resources/icons/png",
|
||||
"certificateFile": `${process.env.WIN_CERT_FILEPATH}`,
|
||||
"certificatePassword": `${process.env.WIN_CERT_PASSWORD}`,
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,37 +0,0 @@
|
||||
appId: com.usebruno.app
|
||||
productName: Bruno
|
||||
electronVersion: 21.1.1
|
||||
directories:
|
||||
buildResources: resources
|
||||
output: out
|
||||
files:
|
||||
- "**/*"
|
||||
afterSign: notarize.js
|
||||
mac:
|
||||
artifactName: ${name}_${version}_${arch}_${os}.${ext}
|
||||
category: public.app-category.developer-tools
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
icon: resources/icons/mac/icon.icns
|
||||
hardenedRuntime: true
|
||||
identity: "Anoop MD (W7LPPWA48L)"
|
||||
entitlements: resources/entitlements.mac.plist
|
||||
entitlementsInherit: resources/entitlements.mac.plist
|
||||
linux:
|
||||
artifactName: ${name}_${version}_${arch}_linux.${ext}
|
||||
icon: resources/icons/png
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
win:
|
||||
artifactName: ${name}_${version}_${arch}_win.${ext}
|
||||
icon: resources/icons/png
|
||||
certificateFile: sectigo.pfx
|
||||
certificatePassword: "secret"
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.10.2",
|
||||
"version": "0.13.1",
|
||||
"name": "bruno",
|
||||
"description": "Opensource API Client",
|
||||
"homepage": "https://www.usebruno.com",
|
||||
@@ -9,12 +9,12 @@
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"dev": "electron .",
|
||||
"dist": "electron-builder --win --linux --mac",
|
||||
"dist": "electron-builder --win --linux --mac --config electron-builder-config.js",
|
||||
"pack": "electron-builder --dir"
|
||||
},
|
||||
"dependencies": {
|
||||
"@usebruno/js": "0.2.0",
|
||||
"@usebruno/lang": "0.2.2",
|
||||
"@usebruno/js": "0.3.0",
|
||||
"@usebruno/lang": "0.3.0",
|
||||
"@usebruno/schema": "0.3.1",
|
||||
"axios": "^0.26.0",
|
||||
"chai": "^4.3.7",
|
||||
|
||||
@@ -8,6 +8,7 @@ class LastOpenedCollections {
|
||||
name: 'preferences',
|
||||
clearInvalidConfig: true
|
||||
});
|
||||
console.log(`Preferences file is located at: ${this.store.path}`);
|
||||
}
|
||||
|
||||
getAll() {
|
||||
|
||||
26
packages/bruno-electron/src/app/preferences.js
Normal file
26
packages/bruno-electron/src/app/preferences.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The preferences are stored in the browser local storage.
|
||||
* When the app is started, an IPC message is published from the renderer process to set the preferences.
|
||||
* The electron process uses this module to get the preferences.
|
||||
*
|
||||
* {
|
||||
* request: {
|
||||
* sslVerification: boolean
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
let preferences = {};
|
||||
|
||||
const getPreferences = () => {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
const setPreferences = (newPreferences) => {
|
||||
preferences = newPreferences;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPreferences,
|
||||
setPreferences
|
||||
};
|
||||
@@ -21,6 +21,7 @@ const { stringifyJson } = require('../utils/common');
|
||||
const { openCollectionDialog, openCollection } = require('../app/collections');
|
||||
const { generateUidBasedOnHash } = require('../utils/common');
|
||||
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
|
||||
const { setPreferences } = require("../app/preferences");
|
||||
|
||||
const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
// browse directory
|
||||
@@ -431,6 +432,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:set-preferences', async (event, preferences) => {
|
||||
setPreferences(preferences);
|
||||
});
|
||||
};
|
||||
|
||||
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const qs = require('qs');
|
||||
const https = require('https');
|
||||
const axios = require('axios');
|
||||
const Mustache = require('mustache');
|
||||
const FormData = require('form-data');
|
||||
@@ -10,6 +12,7 @@ const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../util
|
||||
const { uuid } = require('../../utils/common');
|
||||
const interpolateVars = require('./interpolate-vars');
|
||||
const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper');
|
||||
const { getPreferences } = require('../../app/preferences');
|
||||
|
||||
// override the default escape function to prevent escaping
|
||||
Mustache.escape = function (value) {
|
||||
@@ -69,16 +72,17 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
ipcMain.handle('send-http-request', async (event, item, collectionUid, collectionPath, environment, collectionVariables) => {
|
||||
const cancelTokenUid = uuid();
|
||||
|
||||
mainWindow.webContents.send('main:http-request-queued', {
|
||||
collectionUid,
|
||||
itemUid: item.uid,
|
||||
cancelTokenUid
|
||||
});
|
||||
|
||||
const _request = item.draft ? item.draft.request : item.request;
|
||||
const request = prepareRequest(_request);
|
||||
const envVars = getEnvVars(environment);
|
||||
|
||||
try {
|
||||
mainWindow.webContents.send('main:http-request-queued', {
|
||||
collectionUid,
|
||||
itemUid: item.uid,
|
||||
cancelTokenUid
|
||||
});
|
||||
|
||||
const _request = item.draft ? item.draft.request : item.request;
|
||||
const request = prepareRequest(_request);
|
||||
|
||||
// make axios work in node using form data
|
||||
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
||||
if(request.headers && request.headers['content-type'] === 'multipart/form-data') {
|
||||
@@ -94,8 +98,6 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
request.cancelToken = cancelToken.token;
|
||||
saveCancelToken(cancelTokenUid, cancelToken);
|
||||
|
||||
const envVars = getEnvVars(environment);
|
||||
|
||||
// run pre-request vars
|
||||
const preRequestVars = get(request, 'vars.req', []);
|
||||
if(preRequestVars && preRequestVars.length) {
|
||||
@@ -113,7 +115,7 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
const requestScript = get(request, 'script.req');
|
||||
if(requestScript && requestScript.length) {
|
||||
const scriptRuntime = new ScriptRuntime();
|
||||
const result = scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
|
||||
const result = await scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:script-environment-update', {
|
||||
envVariables: result.envVariables,
|
||||
@@ -124,6 +126,11 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
|
||||
interpolateVars(request, envVars, collectionVariables);
|
||||
|
||||
// stringify the request url encoded params
|
||||
if(request.headers['content-type'] === 'application/x-www-form-urlencoded') {
|
||||
request.data = qs.stringify(request.data);
|
||||
}
|
||||
|
||||
// todo:
|
||||
// i have no clue why electron can't send the request object
|
||||
// without safeParseJSON(safeStringifyJSON(request.data))
|
||||
@@ -139,6 +146,15 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
cancelTokenUid
|
||||
});
|
||||
|
||||
const preferences = getPreferences();
|
||||
const sslVerification = get(preferences, 'request.sslVerification', true);
|
||||
|
||||
if(!sslVerification) {
|
||||
request.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios(request);
|
||||
|
||||
// run post-response vars
|
||||
@@ -158,7 +174,7 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
const responseScript = get(request, 'script.res');
|
||||
if(responseScript && responseScript.length) {
|
||||
const scriptRuntime = new ScriptRuntime();
|
||||
const result = scriptRuntime.runResponseScript(responseScript, request, response, envVars, collectionVariables, collectionPath);
|
||||
const result = await scriptRuntime.runResponseScript(responseScript, request, response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:script-environment-update', {
|
||||
envVariables: result.envVariables,
|
||||
@@ -179,17 +195,15 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
});
|
||||
|
||||
// run tests
|
||||
const testFile = get(item, 'request.tests');
|
||||
if(testFile && testFile.length) {
|
||||
const testRuntime = new TestRuntime();
|
||||
const result = testRuntime.runTests(testFile, request, response, envVars, collectionVariables, collectionPath);
|
||||
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
|
||||
const testRuntime = new TestRuntime();
|
||||
const testResults = testRuntime.runTests(testFile, request, response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:test-results', {
|
||||
results: result.results,
|
||||
itemUid: item.uid,
|
||||
collectionUid
|
||||
});
|
||||
}
|
||||
mainWindow.webContents.send('main:test-results', {
|
||||
results: testResults.results,
|
||||
itemUid: item.uid,
|
||||
collectionUid
|
||||
});
|
||||
|
||||
deleteCancelToken(cancelTokenUid);
|
||||
|
||||
@@ -204,8 +218,36 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
// need to convey the error to the UI
|
||||
// and need not be always a network error
|
||||
deleteCancelToken(cancelTokenUid);
|
||||
|
||||
if (axios.isCancel(error)) {
|
||||
let error = new Error("Request cancelled");
|
||||
error.isCancel = true;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if(error.response) {
|
||||
if(error && error.response) {
|
||||
// run assertions
|
||||
const assertions = get(request, 'assertions');
|
||||
const assertRuntime = new AssertRuntime();
|
||||
const results = assertRuntime.runAssertions(assertions, request, error.response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:assertion-results', {
|
||||
results: results,
|
||||
itemUid: item.uid,
|
||||
collectionUid
|
||||
});
|
||||
|
||||
// run tests
|
||||
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
|
||||
const testRuntime = new TestRuntime();
|
||||
const testResults = testRuntime.runTests(testFile, request, error.response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:test-results', {
|
||||
results: testResults.results,
|
||||
itemUid: item.uid,
|
||||
collectionUid
|
||||
});
|
||||
|
||||
return {
|
||||
status: error.response.status,
|
||||
statusText: error.response.statusText,
|
||||
@@ -235,6 +277,15 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
const envVars = getEnvVars(environment);
|
||||
const request = prepareGqlIntrospectionRequest(endpoint, envVars);
|
||||
|
||||
const preferences = getPreferences();
|
||||
const sslVerification = get(preferences, 'request.sslVerification', true);
|
||||
|
||||
if(!sslVerification) {
|
||||
request.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios(request);
|
||||
|
||||
return {
|
||||
@@ -304,15 +355,15 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
let timeStart;
|
||||
let timeEnd;
|
||||
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'request-queued',
|
||||
...eventData
|
||||
});
|
||||
|
||||
const _request = item.draft ? item.draft.request : item.request;
|
||||
const request = prepareRequest(_request);
|
||||
|
||||
try {
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'request-queued',
|
||||
...eventData
|
||||
});
|
||||
|
||||
const _request = item.draft ? item.draft.request : item.request;
|
||||
const request = prepareRequest(_request);
|
||||
|
||||
// make axios work in node using form data
|
||||
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
|
||||
if(request.headers && request.headers['content-type'] === 'multipart/form-data') {
|
||||
@@ -335,7 +386,7 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
const requestScript = get(request, 'script.req');
|
||||
if(requestScript && requestScript.length) {
|
||||
const scriptRuntime = new ScriptRuntime();
|
||||
const result = scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
|
||||
const result = await scriptRuntime.runRequestScript(requestScript, request, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:script-environment-update', {
|
||||
envVariables: result.envVariables,
|
||||
@@ -361,6 +412,15 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
...eventData
|
||||
});
|
||||
|
||||
const preferences = getPreferences();
|
||||
const sslVerification = get(preferences, 'request.sslVerification', true);
|
||||
|
||||
if(!sslVerification) {
|
||||
request.httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
}
|
||||
|
||||
// send request
|
||||
timeStart = Date.now();
|
||||
const response = await axios(request);
|
||||
@@ -383,7 +443,7 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
const responseScript = get(request, 'script.res');
|
||||
if(responseScript && responseScript.length) {
|
||||
const scriptRuntime = new ScriptRuntime();
|
||||
const result = scriptRuntime.runResponseScript(responseScript, request, response, envVars, collectionVariables, collectionPath);
|
||||
const result = await scriptRuntime.runResponseScript(responseScript, request, response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:script-environment-update', {
|
||||
envVariables: result.envVariables,
|
||||
@@ -407,17 +467,15 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
}
|
||||
|
||||
// run tests
|
||||
const testFile = get(item, 'request.tests');
|
||||
if(testFile && testFile.length) {
|
||||
const testRuntime = new TestRuntime();
|
||||
const result = testRuntime.runTests(testFile, request, response, envVars, collectionVariables, collectionPath);
|
||||
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
|
||||
const testRuntime = new TestRuntime();
|
||||
const testResults = testRuntime.runTests(testFile, request, response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'test-results',
|
||||
testResults: result.results,
|
||||
...eventData
|
||||
});
|
||||
}
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'test-results',
|
||||
testResults: testResults.results,
|
||||
...eventData
|
||||
});
|
||||
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'response-received',
|
||||
@@ -448,7 +506,43 @@ const registerNetworkIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
size: error.response.headers['content-length'] || getSize(error.response.data),
|
||||
data: error.response.data,
|
||||
}
|
||||
|
||||
// run assertions
|
||||
const assertions = get(item, 'request.assertions');
|
||||
if(assertions && assertions.length) {
|
||||
const assertRuntime = new AssertRuntime();
|
||||
const results = assertRuntime.runAssertions(assertions, request, error.response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'assertion-results',
|
||||
assertionResults: results,
|
||||
itemUid: item.uid,
|
||||
collectionUid
|
||||
});
|
||||
}
|
||||
|
||||
// run tests
|
||||
const testFile = item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests');
|
||||
const testRuntime = new TestRuntime();
|
||||
const testResults = testRuntime.runTests(testFile, request, error.response, envVars, collectionVariables, collectionPath);
|
||||
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'test-results',
|
||||
testResults: testResults.results,
|
||||
...eventData
|
||||
});
|
||||
|
||||
// if we get a response from the server, we consider it as a success
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'response-received',
|
||||
error: error ? error.message : 'An error occurred while running the request',
|
||||
responseReceived: responseReceived,
|
||||
...eventData
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
mainWindow.webContents.send('main:run-folder-event', {
|
||||
type: 'error',
|
||||
error: error ? error.message : 'An error occurred while running the request',
|
||||
|
||||
@@ -42,6 +42,17 @@ const interpolateVars = (request, envVars = {}, collectionVariables ={}) => {
|
||||
request.data = interpolate(request.data);
|
||||
}
|
||||
}
|
||||
} else if(request.headers["content-type"] === "application/x-www-form-urlencoded") {
|
||||
if(typeof request.data === "object") {
|
||||
try {
|
||||
let parsed = JSON.stringify(request.data);
|
||||
parsed = interpolate(parsed);
|
||||
request.data = JSON.parse(parsed);
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
request.data = interpolate(request.data);
|
||||
}
|
||||
|
||||
each(request.params, (param) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { get, each, filter } = require('lodash');
|
||||
const qs = require('qs');
|
||||
|
||||
const prepareRequest = (request) => {
|
||||
const headers = {};
|
||||
@@ -39,7 +38,7 @@ const prepareRequest = (request) => {
|
||||
const params = {};
|
||||
const enabledParams = filter(request.body.formUrlEncoded, (p) => p.enabled);
|
||||
each(enabledParams, (p) => (params[p.name] = p.value));
|
||||
axiosRequest.data = qs.stringify(params);
|
||||
axiosRequest.data = params;
|
||||
}
|
||||
|
||||
if (request.body.mode === 'multipartForm') {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@usebruno/js",
|
||||
"version": "0.2.0",
|
||||
"version": "0.4.0",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
@@ -16,12 +16,14 @@
|
||||
"@usebruno/query": "0.1.0",
|
||||
"ajv": "^8.12.0",
|
||||
"atob": "^2.1.2",
|
||||
"axios": "^0.26.0",
|
||||
"btoa": "^1.2.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"json-query": "^2.2.2",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "3.3.4",
|
||||
"node-fetch": "2.*",
|
||||
"uuid": "^9.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ class Bru {
|
||||
this._collectionVariables = collectionVariables;
|
||||
}
|
||||
|
||||
getProcessEnv(key) {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
getEnvVar(key) {
|
||||
return this._envVariables[key];
|
||||
}
|
||||
@@ -16,7 +20,7 @@ class Bru {
|
||||
|
||||
// gracefully ignore if key is not present in environment
|
||||
if(!this._envVariables.hasOwnProperty(key)) {
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
this._envVariables[key] = value;
|
||||
@@ -35,4 +39,4 @@ class Bru {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bru;
|
||||
module.exports = Bru;
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
class BrunoResponse {
|
||||
constructor(res) {
|
||||
this.res = res;
|
||||
this.status = res.status;
|
||||
this.statusText = res.statusText;
|
||||
this.headers = res.headers;
|
||||
this.body = res.data;
|
||||
this.status = res ? res.status : null;
|
||||
this.statusText = res ? res.statusText : null;
|
||||
this.headers = res ? res.headers : null;
|
||||
this.body = res ? res.data : null;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.res.status;
|
||||
return this.res ? this.res.status : null;
|
||||
}
|
||||
|
||||
getHeader(name) {
|
||||
return this.res.header[name];
|
||||
return (this.res && this.res.headers) ? this.res.headers[name] : null;
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
return this.res.headers;
|
||||
return this.res ? this.res.headers : null;
|
||||
}
|
||||
|
||||
getBody() {
|
||||
return this.res.data;
|
||||
return this.res ? this.res.data : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
const { NodeVM } = require('vm2');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const stream = require('stream');
|
||||
const util = require('util');
|
||||
const zlib = require('zlib');
|
||||
const url = require('url');
|
||||
const punycode = require('punycode');
|
||||
const Bru = require('../bru');
|
||||
const BrunoRequest = require('../bruno-request');
|
||||
const BrunoResponse = require('../bruno-response');
|
||||
@@ -11,16 +18,17 @@ const lodash = require('lodash');
|
||||
const moment = require('moment');
|
||||
const uuid = require('uuid');
|
||||
const nanoid = require('nanoid');
|
||||
const axios = require('axios');
|
||||
const fetch = require('node-fetch');
|
||||
const CryptoJS = require('crypto-js');
|
||||
|
||||
class ScriptRuntime {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
runRequestScript(script, request, envVariables, collectionVariables, collectionPath) {
|
||||
async runRequestScript(script, request, envVariables, collectionVariables, collectionPath){
|
||||
const bru = new Bru(envVariables, collectionVariables);
|
||||
const req = new BrunoRequest(request);
|
||||
|
||||
const context = {
|
||||
bru,
|
||||
req
|
||||
@@ -32,19 +40,30 @@ class ScriptRuntime {
|
||||
external: true,
|
||||
root: [collectionPath],
|
||||
mock: {
|
||||
// node libs
|
||||
path,
|
||||
stream,
|
||||
util,
|
||||
url,
|
||||
http,
|
||||
https,
|
||||
punycode,
|
||||
zlib,
|
||||
// 3rd party libs
|
||||
atob,
|
||||
btoa,
|
||||
lodash,
|
||||
moment,
|
||||
uuid,
|
||||
nanoid,
|
||||
axios,
|
||||
'node-fetch': fetch,
|
||||
'crypto-js': CryptoJS
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
vm.run(script, path.join(collectionPath, 'vm.js'));
|
||||
|
||||
const asyncVM = vm.run(`module.exports = async () => { ${script} }`, path.join(collectionPath, 'vm.js'));
|
||||
await asyncVM();
|
||||
return {
|
||||
request,
|
||||
envVariables,
|
||||
@@ -52,7 +71,7 @@ class ScriptRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
runResponseScript(script, request, response, envVariables, collectionVariables, collectionPath) {
|
||||
async runResponseScript(script, request, response, envVariables, collectionVariables, collectionPath) {
|
||||
const bru = new Bru(envVariables, collectionVariables);
|
||||
const req = new BrunoRequest(request);
|
||||
const res = new BrunoResponse(response);
|
||||
@@ -75,12 +94,15 @@ class ScriptRuntime {
|
||||
moment,
|
||||
uuid,
|
||||
nanoid,
|
||||
axios,
|
||||
'node-fetch': fetch,
|
||||
'crypto-js': CryptoJS
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
vm.run(script, path.join(collectionPath, 'vm.js'));
|
||||
const asyncVM = vm.run(`module.exports = async () => { ${script} }`, path.join(collectionPath, 'vm.js'));
|
||||
await asyncVM();
|
||||
|
||||
return {
|
||||
response,
|
||||
|
||||
@@ -28,6 +28,15 @@ class TestRuntime {
|
||||
const __brunoTestResults = new TestResults();
|
||||
const test = Test(__brunoTestResults, chai);
|
||||
|
||||
if(!testsFile || !testsFile.length) {
|
||||
return {
|
||||
request,
|
||||
envVariables,
|
||||
collectionVariables,
|
||||
results: __brunoTestResults.getResults()
|
||||
};
|
||||
}
|
||||
|
||||
const context = {
|
||||
test,
|
||||
bru,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@usebruno/lang",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.0",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
@@ -20,6 +20,14 @@ You can use git or any version control of your choice to collaborate over your a
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Run across multiple platforms 🖥️
|
||||
 <br /><br />
|
||||
|
||||
### Collaborate via Git 👩💻🧑💻
|
||||
Or any version control system of your choice
|
||||
|
||||
 <br /><br />
|
||||
|
||||
### Website 📄
|
||||
Please visit [here](https://www.usebruno.com) to checkout our website and download the app
|
||||
|
||||
|
||||
Reference in New Issue
Block a user