- {
/>
- {
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js
index 66ba1ed3da..44b01b4647 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js
@@ -1,10 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- div.CodeMirror {
- height: inherit;
- }
-
div.title {
color: var(--color-tab-inactive);
}
diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/index.js b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
index 84af056f62..0ba25a0b53 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Script/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Script/index.js
@@ -12,7 +12,7 @@ const Script = ({ collection }) => {
const requestScript = get(collection, 'root.request.script.req', '');
const responseScript = get(collection, 'root.request.script.res', '');
- const { displayedTheme } = useTheme();
+ const { storedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const onRequestScriptEdit = (value) => {
@@ -47,9 +47,10 @@ const Script = ({ collection }) => {
@@ -59,8 +60,9 @@ const Script = ({ collection }) => {
{
collection={collection}
value={tests || ''}
theme={displayedTheme}
- onEdit={onEdit}
+ onChange={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
diff --git a/packages/bruno-app/src/components/Cookies/index.js b/packages/bruno-app/src/components/Cookies/index.js
index f7420bed87..69c3d2bdfc 100644
--- a/packages/bruno-app/src/components/Cookies/index.js
+++ b/packages/bruno-app/src/components/Cookies/index.js
@@ -1,7 +1,7 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'components/Modal';
-import { IconTrash } from '@tabler/icons';
+import { IconTrash } from '@tabler/icons-react';
import { deleteCookiesForDomain } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
diff --git a/packages/bruno-app/src/components/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/Documentation/StyledWrapper.js
index f0ffee808e..f159d94dcd 100644
--- a/packages/bruno-app/src/components/Documentation/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Documentation/StyledWrapper.js
@@ -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};
diff --git a/packages/bruno-app/src/components/Documentation/index.js b/packages/bruno-app/src/components/Documentation/index.js
index d4b7909655..4ac5a6ddc7 100644
--- a/packages/bruno-app/src/components/Documentation/index.js
+++ b/packages/bruno-app/src/components/Documentation/index.js
@@ -6,8 +6,8 @@ import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
-import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
+import CodeEditor from 'components/CodeEditor';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -48,7 +48,7 @@ const Documentation = ({ item, collection }) => {
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
value={docs || ''}
- onEdit={onEdit}
+ onChange={onEdit}
onSave={onSave}
mode="application/text"
/>
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js
deleted file mode 100644
index b1c09d5f24..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/StyledWrapper.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- .current-environment {
- background-color: ${(props) => props.theme.sidebar.badge.bg};
- border-radius: 15px;
-
- .caret {
- margin-left: 0.25rem;
- color: rgb(140, 140, 140);
- fill: rgb(140, 140, 140);
- }
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js
deleted file mode 100644
index 3595022874..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSelector/index.js
+++ /dev/null
@@ -1,93 +0,0 @@
-import React, { useRef, forwardRef, useState } from 'react';
-import find from 'lodash/find';
-import Dropdown from 'components/Dropdown';
-import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
-import { IconSettings, IconCaretDown, IconDatabase, IconDatabaseOff } from '@tabler/icons';
-import EnvironmentSettings from '../EnvironmentSettings';
-import toast from 'react-hot-toast';
-import { useDispatch } from 'react-redux';
-import StyledWrapper from './StyledWrapper';
-
-const EnvironmentSelector = ({ collection }) => {
- const dispatch = useDispatch();
- const dropdownTippyRef = useRef();
- const [openSettingsModal, setOpenSettingsModal] = useState(false);
- const { environments, activeEnvironmentUid } = collection;
- const activeEnvironment = activeEnvironmentUid ? find(environments, (e) => e.uid === activeEnvironmentUid) : null;
-
- const Icon = forwardRef((props, ref) => {
- return (
-
- {activeEnvironment ? activeEnvironment.name : 'No Environment'}
-
-
- );
- });
-
- const handleSettingsIconClick = () => {
- setOpenSettingsModal(true);
- dispatch(updateEnvironmentSettingsModalVisibility(true));
- };
-
- const handleModalClose = () => {
- setOpenSettingsModal(false);
- dispatch(updateEnvironmentSettingsModalVisibility(false));
- };
-
- const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
-
- const onSelect = (environment) => {
- dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
- .then(() => {
- if (environment) {
- toast.success(`Environment changed to ${environment.name}`);
- } else {
- toast.success(`No Environments are active now`);
- }
- })
- .catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
- };
-
- return (
-
-
-
} placement="bottom-end">
- {environments && environments.length
- ? environments.map((e) => (
-
{
- onSelect(e);
- dropdownTippyRef.current.hide();
- }}
- >
- {e.name}
-
- ))
- : null}
-
{
- dropdownTippyRef.current.hide();
- onSelect(null);
- }}
- >
-
- No Environment
-
-
-
-
- {openSettingsModal && }
-
- );
-};
-
-export default EnvironmentSelector;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CopyEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CopyEnvironment/index.js
deleted file mode 100644
index a9fdf3b4ac..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CopyEnvironment/index.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import Modal from 'components/Modal/index';
-import Portal from 'components/Portal/index';
-import { useFormik } from 'formik';
-import { copyEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import { useEffect, useRef } from 'react';
-import toast from 'react-hot-toast';
-import { useDispatch } from 'react-redux';
-import * as Yup from 'yup';
-
-const CopyEnvironment = ({ collection, environment, onClose }) => {
- const dispatch = useDispatch();
- const inputRef = useRef();
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- name: environment.name + ' - Copy'
- },
- validationSchema: Yup.object({
- name: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('name is required')
- }),
- onSubmit: (values) => {
- dispatch(copyEnvironment(values.name, environment.uid, collection.uid))
- .then(() => {
- toast.success('Environment created in collection');
- onClose();
- })
- .catch(() => toast.error('An error occurred while created the environment'));
- }
- });
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => {
- formik.handleSubmit();
- };
-
- return (
-
-
-
-
-
- );
-};
-
-export default CopyEnvironment;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
deleted file mode 100644
index e6947bd3ad..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import Portal from 'components/Portal';
-import Modal from 'components/Modal';
-import toast from 'react-hot-toast';
-import { useFormik } from 'formik';
-import { addEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import * as Yup from 'yup';
-import { useDispatch } from 'react-redux';
-
-const CreateEnvironment = ({ collection, onClose }) => {
- const dispatch = useDispatch();
- const inputRef = useRef();
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- name: ''
- },
- validationSchema: Yup.object({
- name: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('name is required')
- }),
- onSubmit: (values) => {
- dispatch(addEnvironment(values.name, collection.uid))
- .then(() => {
- toast.success('Environment created in collection');
- onClose();
- })
- .catch(() => toast.error('An error occurred while created the environment'));
- }
- });
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => {
- formik.handleSubmit();
- };
-
- return (
-
-
-
-
-
- );
-};
-
-export default CreateEnvironment;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js
deleted file mode 100644
index 48b874214e..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteEnvironment/StyledWrapper.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- button.submit {
- color: white;
- background-color: var(--color-background-danger) !important;
- border: inherit !important;
-
- &:hover {
- border: inherit !important;
- }
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteEnvironment/index.js
deleted file mode 100644
index 8ca6fc41de..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/DeleteEnvironment/index.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import Portal from 'components/Portal/index';
-import toast from 'react-hot-toast';
-import Modal from 'components/Modal/index';
-import { deleteEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import { useDispatch } from 'react-redux';
-import StyledWrapper from './StyledWrapper';
-
-const DeleteEnvironment = ({ onClose, environment, collection }) => {
- const dispatch = useDispatch();
- const onConfirm = () => {
- dispatch(deleteEnvironment(environment.uid, collection.uid))
- .then(() => {
- toast.success('Environment deleted successfully');
- onClose();
- })
- .catch(() => toast.error('An error occurred while deleting the environment'));
- };
-
- return (
-
-
-
- Are you sure you want to delete {environment.name} ?
-
-
-
- );
-};
-
-export default DeleteEnvironment;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js
deleted file mode 100644
index 715bf9e758..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/ConfirmSwitchEnv.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import React from 'react';
-import { IconAlertTriangle } from '@tabler/icons';
-import Modal from 'components/Modal';
-import { createPortal } from 'react-dom';
-
-const ConfirmSwitchEnv = ({ onCancel }) => {
- return createPortal(
- {
- e.stopPropagation();
- e.preventDefault();
- }}
- hideFooter={true}
- >
-
-
-
Hold on..
-
- You have unsaved changes in this environment.
-
-
- ,
- document.body
- );
-};
-
-export default ConfirmSwitchEnv;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
deleted file mode 100644
index 1f36d05ea3..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
+++ /dev/null
@@ -1,182 +0,0 @@
-import React from 'react';
-import { IconTrash } from '@tabler/icons';
-import { useTheme } from 'providers/Theme';
-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';
-import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import cloneDeep from 'lodash/cloneDeep';
-import toast from 'react-hot-toast';
-
-const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables }) => {
- const dispatch = useDispatch();
- const { storedTheme } = useTheme();
-
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: environment.variables || [],
- validationSchema: Yup.array().of(
- Yup.object({
- enabled: Yup.boolean(),
- name: Yup.string()
- .required('Name cannot be empty')
- .matches(
- variableNameRegex,
- 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
- )
- .trim(),
- secret: Yup.boolean(),
- type: Yup.string(),
- uid: Yup.string(),
- value: Yup.string().trim().nullable()
- })
- ),
- onSubmit: (values) => {
- if (!formik.dirty) {
- toast.error('Nothing to save');
- return;
- }
-
- dispatch(saveEnvironment(cloneDeep(values), environment.uid, collection.uid))
- .then(() => {
- toast.success('Changes saved successfully');
- formik.resetForm({ values });
- setIsModified(false);
- })
- .catch(() => toast.error('An error occurred while saving the changes'));
- }
- });
-
- // Effect to track modifications.
- React.useEffect(() => {
- setIsModified(formik.dirty);
- }, [formik.dirty]);
-
- const ErrorMessage = ({ name }) => {
- const meta = formik.getFieldMeta(name);
- if (!meta.error) {
- return null;
- }
-
- return (
-
- {meta.error}
-
- );
- };
-
- const addVariable = () => {
- const newVariable = {
- uid: uuid(),
- name: '',
- value: '',
- type: 'text',
- secret: false,
- enabled: true
- };
- formik.setFieldValue(formik.values.length, newVariable, false);
- };
-
- const handleRemoveVar = (id) => {
- formik.setValues(formik.values.filter((variable) => variable.uid !== id));
- };
-
- const handleReset = () => {
- formik.resetForm({ originalEnvironmentVariables });
- };
-
- return (
-
-
-
-
- + Add Variable
-
-
-
-
-
- Save
-
-
- Reset
-
-
-
- );
-};
-export default EnvironmentVariables;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
deleted file mode 100644
index f9fca74ec8..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/index.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
-import { useState } from 'react';
-import CopyEnvironment from '../../CopyEnvironment';
-import DeleteEnvironment from '../../DeleteEnvironment';
-import RenameEnvironment from '../../RenameEnvironment';
-import EnvironmentVariables from './EnvironmentVariables';
-
-const EnvironmentDetails = ({ environment, collection, setIsModified }) => {
- const [openEditModal, setOpenEditModal] = useState(false);
- const [openDeleteModal, setOpenDeleteModal] = useState(false);
- const [openCopyModal, setOpenCopyModal] = useState(false);
-
- return (
-
- {openEditModal && (
-
setOpenEditModal(false)} environment={environment} collection={collection} />
- )}
- {openDeleteModal && (
- setOpenDeleteModal(false)}
- environment={environment}
- collection={collection}
- />
- )}
- {openCopyModal && (
- setOpenCopyModal(false)} environment={environment} collection={collection} />
- )}
-
-
-
- {environment.name}
-
-
- setOpenEditModal(true)} />
- setOpenCopyModal(true)} />
- setOpenDeleteModal(true)} />
-
-
-
-
-
-
-
- );
-};
-
-export default EnvironmentDetails;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js
deleted file mode 100644
index 330ae082c6..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/StyledWrapper.js
+++ /dev/null
@@ -1,58 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- margin-inline: -1rem;
- margin-block: -1.5rem;
-
- background-color: ${(props) => props.theme.collection.environment.settings.bg};
-
- .environments-sidebar {
- background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
- border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
- min-height: 400px;
- height: 100%;
- max-height: 85vh;
- overflow-y: auto;
- }
-
- .environment-item {
- min-width: 150px;
- display: block;
- position: relative;
- cursor: pointer;
- padding: 8px 10px;
- border-left: solid 2px transparent;
- text-decoration: none;
-
- &:hover {
- text-decoration: none;
- background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
- }
- }
-
- .active {
- background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
- border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
- &:hover {
- background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
- }
- }
-
- .btn-create-environment,
- .btn-import-environment {
- padding: 8px 10px;
- cursor: pointer;
- border-bottom: none;
- color: ${(props) => props.theme.textLink};
-
- span:hover {
- text-decoration: underline;
- }
- }
-
- .btn-import-environment {
- color: ${(props) => props.theme.colors.text.muted};
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js
deleted file mode 100644
index 4517bd8d3f..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/index.js
+++ /dev/null
@@ -1,141 +0,0 @@
-import React, { useEffect, useState } from 'react';
-import { findEnvironmentInCollection } from 'utils/collections';
-import usePrevious from 'hooks/usePrevious';
-import EnvironmentDetails from './EnvironmentDetails';
-import CreateEnvironment from '../CreateEnvironment';
-import { IconDownload, IconShieldLock } from '@tabler/icons';
-import ImportEnvironment from '../ImportEnvironment';
-import ManageSecrets from '../ManageSecrets';
-import StyledWrapper from './StyledWrapper';
-import ConfirmSwitchEnv from './ConfirmSwitchEnv';
-
-const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified }) => {
- const { environments } = collection;
- const [openCreateModal, setOpenCreateModal] = useState(false);
- const [openImportModal, setOpenImportModal] = useState(false);
- const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
-
- const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
- const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
-
- const envUids = environments ? environments.map((env) => env.uid) : [];
- const prevEnvUids = usePrevious(envUids);
-
- useEffect(() => {
- if (selectedEnvironment) {
- setOriginalEnvironmentVariables(selectedEnvironment.variables);
- return;
- }
-
- const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
- if (environment) {
- setSelectedEnvironment(environment);
- } else {
- setSelectedEnvironment(environments && environments.length ? environments[0] : null);
- }
- }, [collection, environments, selectedEnvironment]);
-
- useEffect(() => {
- if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
- const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
- if (newEnv) {
- setSelectedEnvironment(newEnv);
- }
- }
-
- if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
- setSelectedEnvironment(environments && environments.length ? environments[0] : null);
- }
- }, [envUids, environments, prevEnvUids]);
-
- const handleEnvironmentClick = (env) => {
- if (!isModified) {
- setSelectedEnvironment(env);
- } else {
- setSwitchEnvConfirmClose(true);
- }
- };
-
- if (!selectedEnvironment) {
- return null;
- }
-
- const handleCreateEnvClick = () => {
- if (!isModified) {
- setOpenCreateModal(true);
- } else {
- setSwitchEnvConfirmClose(true);
- }
- };
-
- const handleImportClick = () => {
- if (!isModified) {
- setOpenImportModal(true);
- } else {
- setSwitchEnvConfirmClose(true);
- }
- };
-
- const handleSecretsClick = () => {
- setOpenManageSecretsModal(true);
- };
-
- const handleConfirmSwitch = (saveChanges) => {
- if (!saveChanges) {
- setSwitchEnvConfirmClose(false);
- }
- };
-
- return (
-
- {openCreateModal && setOpenCreateModal(false)} />}
- {openImportModal && setOpenImportModal(false)} />}
- {openManageSecretsModal && setOpenManageSecretsModal(false)} />}
-
-
-
- {switchEnvConfirmClose && (
-
- handleConfirmSwitch(false)} />
-
- )}
-
- {environments &&
- environments.length &&
- environments.map((env) => (
-
handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
- >
- {env.name}
-
- ))}
-
handleCreateEnvClick()}>
- + Create
-
-
-
-
handleImportClick()}>
-
- Import
-
-
handleSecretsClick()}>
-
- Managing Secrets
-
-
-
-
-
-
-
- );
-};
-
-export default EnvironmentList;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js
deleted file mode 100644
index 5caba79b21..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ImportEnvironment/index.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import React from 'react';
-import Portal from 'components/Portal';
-import toast from 'react-hot-toast';
-import { useDispatch } from 'react-redux';
-import importPostmanEnvironment from 'utils/importers/postman-environment';
-import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import { toastError } from 'utils/common/error';
-import Modal from 'components/Modal';
-
-const ImportEnvironment = ({ onClose, collection }) => {
- const dispatch = useDispatch();
-
- const handleImportPostmanEnvironment = () => {
- importPostmanEnvironment()
- .then((environment) => {
- dispatch(importEnvironment(environment.name, environment.variables, collection.uid))
- .then(() => {
- toast.success('Environment imported successfully');
- onClose();
- })
- .catch(() => toast.error('An error occurred while importing the environment'));
- })
- .catch((err) => toastError(err, 'Postman Import environment failed'));
- };
-
- return (
-
-
-
-
- Postman Environment
-
-
-
-
- );
-};
-
-export default ImportEnvironment;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js
deleted file mode 100644
index ca025003cd..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import Portal from 'components/Portal';
-import Modal from 'components/Modal';
-
-const ManageSecrets = ({ onClose }) => {
- return (
-
-
-
-
In any collection, there are secrets that need to be managed.
-
These secrets can be anything such as API keys, passwords, or tokens.
-
Bruno offers two approaches to manage secrets in collections.
-
- Read more about it in our{' '}
-
- docs
-
- .
-
-
-
-
- );
-};
-
-export default ManageSecrets;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js
deleted file mode 100644
index 84572db900..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/RenameEnvironment/index.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import React, { useEffect, useRef } from 'react';
-import Portal from 'components/Portal/index';
-import Modal from 'components/Modal/index';
-import toast from 'react-hot-toast';
-import { useFormik } from 'formik';
-import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
-import * as Yup from 'yup';
-import { useDispatch } from 'react-redux';
-
-const RenameEnvironment = ({ onClose, environment, collection }) => {
- const dispatch = useDispatch();
- const inputRef = useRef();
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- name: environment.name
- },
- validationSchema: Yup.object({
- name: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('name is required')
- }),
- onSubmit: (values) => {
- dispatch(renameEnvironment(values.name, environment.uid, collection.uid))
- .then(() => {
- toast.success('Environment renamed successfully');
- onClose();
- })
- .catch(() => toast.error('An error occurred while renaming the environment'));
- }
- });
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => {
- formik.handleSubmit();
- };
-
- return (
-
-
-
-
-
- );
-};
-
-export default RenameEnvironment;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/StyledWrapper.js
deleted file mode 100644
index 2dfad0cfe2..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/StyledWrapper.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- button.btn-create-environment {
- &:hover {
- span {
- text-decoration: underline;
- }
- }
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js
deleted file mode 100644
index 0a3f7e25bf..0000000000
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/index.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import Modal from 'components/Modal/index';
-import React, { useState } from 'react';
-import CreateEnvironment from './CreateEnvironment';
-import EnvironmentList from './EnvironmentList';
-import StyledWrapper from './StyledWrapper';
-import ImportEnvironment from './ImportEnvironment';
-
-const EnvironmentSettings = ({ collection, onClose }) => {
- const [isModified, setIsModified] = useState(false);
- const { environments } = collection;
- const [openCreateModal, setOpenCreateModal] = useState(false);
- const [openImportModal, setOpenImportModal] = useState(false);
- const [selectedEnvironment, setSelectedEnvironment] = useState(null);
-
- if (!environments || !environments.length) {
- return (
-
-
- {openCreateModal && setOpenCreateModal(false)} />}
- {openImportModal && setOpenImportModal(false)} />}
-
-
No environments found!
-
setOpenCreateModal(true)}
- >
- Create Environment
-
-
-
Or
-
-
setOpenImportModal(true)}
- >
- Import Environment
-
-
-
-
- );
- }
-
- return (
-
-
-
- );
-};
-
-export default EnvironmentSettings;
diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js
index a7b67264de..39160107d1 100644
--- a/packages/bruno-app/src/components/FilePickerEditor/index.js
+++ b/packages/bruno-app/src/components/FilePickerEditor/index.js
@@ -2,7 +2,7 @@ import React from 'react';
import path from 'path';
import { useDispatch } from 'react-redux';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
-import { IconX } from '@tabler/icons';
+import { IconX } from '@tabler/icons-react';
import { isWindowsOS } from 'utils/common/platform';
import slash from 'utils/common/slash';
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js
similarity index 69%
rename from packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
rename to packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js
index 7eec1394c3..9f723cb81c 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js
+++ b/packages/bruno-app/src/components/FolderSettings/Headers/StyledWrapper.js
@@ -9,20 +9,7 @@ const Wrapper = styled.div`
thead,
td {
- border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
- padding: 4px 10px;
-
- &:nth-child(1),
- &:nth-child(4) {
- width: 70px;
- }
- &:nth-child(5) {
- width: 40px;
- }
-
- &:nth-child(2) {
- width: 25%;
- }
+ border: 1px solid ${(props) => props.theme.table.border};
}
thead {
@@ -30,12 +17,20 @@ const Wrapper = styled.div`
font-size: 0.8125rem;
user-select: none;
}
- thead td {
+ td {
padding: 6px 10px;
+
+ &:nth-child(1) {
+ width: 30%;
+ }
+
+ &:nth-child(3) {
+ width: 70px;
+ }
}
}
- .btn-add-param {
+ .btn-add-header {
font-size: 0.8125rem;
}
@@ -43,7 +38,7 @@ const Wrapper = styled.div`
width: 100%;
border: solid 1px transparent;
outline: none !important;
- background-color: transparent;
+ background-color: inherit;
&:focus {
outline: none !important;
diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
new file mode 100644
index 0000000000..6b78f31031
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
@@ -0,0 +1,155 @@
+import React from 'react';
+import get from 'lodash/get';
+import cloneDeep from 'lodash/cloneDeep';
+import { IconTrash } from '@tabler/icons-react';
+import { useDispatch } from 'react-redux';
+import { useTheme } from 'providers/Theme';
+import { addFolderHeader, updateFolderHeader, deleteFolderHeader } from 'providers/ReduxStore/slices/collections';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import StyledWrapper from './StyledWrapper';
+import { headers as StandardHTTPHeaders } from 'know-your-http-well';
+import SingleLineEditor from 'components/CodeEditor/Codemirror/SingleLineEditor';
+import CodeEditor from 'components/CodeEditor';
+const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
+
+const Headers = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+ const headers = get(folder, 'root.request.headers', []);
+
+ const addHeader = () => {
+ dispatch(
+ addFolderHeader({
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
+ const handleHeaderValueChange = (e, _header, type) => {
+ const header = cloneDeep(_header);
+ switch (type) {
+ case 'name': {
+ header.name = e.target.value;
+ break;
+ }
+ case 'value': {
+ header.value = e.target.value;
+ break;
+ }
+ case 'enabled': {
+ header.enabled = e.target.checked;
+ break;
+ }
+ }
+ dispatch(
+ updateFolderHeader({
+ header: header,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ const handleRemoveHeader = (header) => {
+ dispatch(
+ deleteFolderHeader({
+ headerUid: header.uid,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ return (
+
+
+
+ + Add Header
+
+
+
+
+ Save
+
+
+
+ );
+};
+export default Headers;
diff --git a/packages/bruno-app/src/components/FolderSettings/index.js b/packages/bruno-app/src/components/FolderSettings/index.js
new file mode 100644
index 0000000000..b1d6ee2495
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/index.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import classnames from 'classnames';
+import { updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
+import { useDispatch } from 'react-redux';
+import Headers from './Headers';
+
+const FolderSettings = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const tab = folder?.settingsSelectedTab || 'headers';
+ const setTab = (tab) => {
+ dispatch(
+ updateSettingsSelectedTab({
+ collectionUid: folder.collectionUid,
+ folderUid: folder.uid,
+ tab
+ })
+ );
+ };
+
+ const getTabPanel = (tab) => {
+ switch (tab) {
+ case 'headers': {
+ return ;
+ }
+ // TODO: Add auth
+ }
+ };
+
+ const getTabClassname = (tabName) => {
+ return classnames(`tab select-none ${tabName}`, {
+ active: tabName === tab
+ });
+ };
+
+ return (
+
+
+
setTab('headers')}>
+ Headers
+
+ {/*
setTab('auth')}>
+ Auth
+
*/}
+
+
+
+ );
+};
+
+export default FolderSettings;
diff --git a/packages/bruno-app/src/components/MarkDown/index.jsx b/packages/bruno-app/src/components/MarkDown/index.jsx
index 80f28cacf3..16faded478 100644
--- a/packages/bruno-app/src/components/MarkDown/index.jsx
+++ b/packages/bruno-app/src/components/MarkDown/index.jsx
@@ -5,7 +5,13 @@ import * as React from 'react';
const md = new MarkdownIt();
const Markdown = ({ onDoubleClick, content }) => {
- const handleOnDoubleClick = (event) => {
+ const handleClick = (event) => {
+ if (event.target.href) {
+ event.preventDefault();
+ window.open(event.target.href, '_blank');
+ return;
+ }
+
if (event?.detail === 2) {
onDoubleClick();
}
@@ -14,11 +20,7 @@ const Markdown = ({ onDoubleClick, content }) => {
return (
-
+
);
};
diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js
index 49fcccf02b..6669b3e6cf 100644
--- a/packages/bruno-app/src/components/Modal/index.js
+++ b/packages/bruno-app/src/components/Modal/index.js
@@ -1,5 +1,6 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useRef } from 'react';
import StyledWrapper from './StyledWrapper';
+import { IconAlertTriangle } from '@tabler/icons-react';
const ModalHeader = ({ title, handleCancel, customHeader }) => (
@@ -51,12 +52,28 @@ const ModalFooter = ({
);
};
+const ErrorCallout = ({ message }) => {
+ return (
+
+ );
+};
+
const Modal = ({
size,
title,
customHeader,
confirmText,
cancelText,
+ errorMessage,
handleCancel,
handleConfirm,
children,
@@ -101,7 +118,10 @@ const Modal = ({
onClick(e) : null}>
closeModal({ type: 'icon' })} customHeader={customHeader} />
- {children}
+
+ {children}
+ {!!errorMessage ? : null}
+
{
diff --git a/packages/bruno-app/src/components/Notifications/index.js b/packages/bruno-app/src/components/Notifications/index.js
index 15a055c766..a0ae018980 100644
--- a/packages/bruno-app/src/components/Notifications/index.js
+++ b/packages/bruno-app/src/components/Notifications/index.js
@@ -1,4 +1,4 @@
-import { IconBell } from '@tabler/icons';
+import { IconBell } from '@tabler/icons-react';
import { useState } from 'react';
import StyledWrapper from './StyleWrapper';
import Modal from 'components/Modal/index';
diff --git a/packages/bruno-app/src/components/Preferences/Font/index.js b/packages/bruno-app/src/components/Preferences/Font/index.js
deleted file mode 100644
index 2f27fea8b7..0000000000
--- a/packages/bruno-app/src/components/Preferences/Font/index.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import React, { useState } from 'react';
-import get from 'lodash/get';
-import { useSelector, useDispatch } from 'react-redux';
-import { savePreferences } from 'providers/ReduxStore/slices/app';
-import StyledWrapper from './StyledWrapper';
-
-const Font = ({ close }) => {
- const dispatch = useDispatch();
- const preferences = useSelector((state) => state.app.preferences);
-
- const [codeFont, setCodeFont] = useState(get(preferences, 'font.codeFont', 'default'));
-
- const handleInputChange = (event) => {
- setCodeFont(event.target.value);
- };
-
- const handleSave = () => {
- dispatch(
- savePreferences({
- ...preferences,
- font: {
- codeFont
- }
- })
- ).then(() => {
- close();
- });
- };
-
- return (
-
- Code Editor Font
-
-
-
-
- Save
-
-
-
- );
-};
-
-export default Font;
diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js
index 9855c27471..9cab80c129 100644
--- a/packages/bruno-app/src/components/Preferences/General/index.js
+++ b/packages/bruno-app/src/components/Preferences/General/index.js
@@ -8,7 +8,7 @@ import * as Yup from 'yup';
import toast from 'react-hot-toast';
import path from 'path';
import slash from 'utils/common/slash';
-import { IconTrash } from '@tabler/icons';
+import { IconTrash } from '@tabler/icons-react';
const General = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
diff --git a/packages/bruno-app/src/components/Preferences/Font/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Interface/StyledWrapper.js
similarity index 100%
rename from packages/bruno-app/src/components/Preferences/Font/StyledWrapper.js
rename to packages/bruno-app/src/components/Preferences/Interface/StyledWrapper.js
diff --git a/packages/bruno-app/src/components/Preferences/Interface/ThemeSelects.jsx b/packages/bruno-app/src/components/Preferences/Interface/ThemeSelects.jsx
new file mode 100644
index 0000000000..5c133ce63a
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Interface/ThemeSelects.jsx
@@ -0,0 +1,54 @@
+import React from 'react';
+
+const ThemeSelects = ({ value, onChange }) => {
+ return (
+
+ {
+ onChange('light');
+ }}
+ value="light"
+ checked={value === 'light'}
+ />
+
+ Light
+
+
+ {
+ onChange('dark');
+ }}
+ value="dark"
+ checked={value === 'dark'}
+ />
+
+ Dark
+
+
+ {
+ onChange('system');
+ }}
+ value="system"
+ checked={value === 'system'}
+ />
+
+ System
+
+
+ );
+};
+
+export default ThemeSelects;
diff --git a/packages/bruno-app/src/components/Preferences/Interface/index.jsx b/packages/bruno-app/src/components/Preferences/Interface/index.jsx
new file mode 100644
index 0000000000..25fbb664dc
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Interface/index.jsx
@@ -0,0 +1,154 @@
+import { useFormik } from 'formik';
+import { useSelector, useDispatch } from 'react-redux';
+import { savePreferences } from 'providers/ReduxStore/slices/app';
+import { IconInfoCircle } from '@tabler/icons-react';
+import StyledWrapper from './StyledWrapper';
+import * as Yup from 'yup';
+import get from 'lodash/get';
+import React from 'react';
+import { useTheme } from 'providers/Theme';
+import ThemeSelects from 'components/Preferences/Interface/ThemeSelects';
+
+const interfacePrefsSchema = Yup.object().shape({
+ hideTabs: Yup.boolean().default(false),
+ font: Yup.object({
+ codeFont: Yup.string().default('default')
+ }),
+ editor: Yup.object({
+ monaco: Yup.boolean().default(false)
+ }),
+ theme: Yup.string().oneOf(['light', 'dark', 'system']).required('Theme is required')
+});
+
+const BetaAlert = () => {
+ return (
+
+
+
+
+
+
+
+ Monaco is a beta feature aiming to replace our current code editor.
+
+ Feel free to experiment with it and report our team any issue you may encounter.
+
+
+
+
+ );
+};
+
+const BetaBadge = ({ className = '' }) => {
+ return (
+
+ Beta
+
+ );
+};
+
+const Interface = ({ close }) => {
+ const { storedTheme, setStoredTheme } = useTheme();
+ const dispatch = useDispatch();
+ const preferences = useSelector((state) => state.app.preferences);
+
+ const handleSave = (values) => {
+ setStoredTheme(values.theme);
+ delete values.theme;
+
+ dispatch(
+ savePreferences({
+ ...preferences,
+ ...values
+ })
+ ).then(() => {
+ close();
+ });
+ };
+
+ const formik = useFormik({
+ initialValues: {
+ hideTabs: get(preferences, 'hideTabs', false),
+ font: {
+ codeFont: get(preferences, 'font.codeFont', 'default')
+ },
+ editor: {
+ monaco: get(preferences, 'editor.monaco', false)
+ },
+ theme: storedTheme
+ },
+ validationSchema: interfacePrefsSchema,
+ onSubmit: handleSave
+ });
+
+ return (
+
+ {
+ formik.setFieldValue('theme', newTheme);
+ }}
+ />
+
+
+ {
+ formik.setFieldValue('hideTabs', !formik.values.hideTabs);
+ }}
+ />
+
+ Hide tabs
+
+
+
+ Code Editor Font
+
+
+
+
+
+ {
+ formik.setFieldValue('editor.monaco', !formik.values.editor.monaco);
+ }}
+ className="mousetrap mr-0"
+ />
+
+ Enable Monaco Editor
+
+
+
+
+
+
+
+ Save
+
+
+
+ );
+};
+
+export default Interface;
diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
index 849421661c..15a301bd64 100644
--- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
+++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
@@ -6,7 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { useDispatch, useSelector } from 'react-redux';
-import { IconEye, IconEyeOff } from '@tabler/icons';
+import { IconEye, IconEyeOff } from '@tabler/icons-react';
import { useState } from 'react';
const ProxySettings = ({ close }) => {
diff --git a/packages/bruno-app/src/components/Preferences/Support/index.js b/packages/bruno-app/src/components/Preferences/Support/index.js
index dfd6fabed5..d155cc7f70 100644
--- a/packages/bruno-app/src/components/Preferences/Support/index.js
+++ b/packages/bruno-app/src/components/Preferences/Support/index.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { IconSpeakerphone, IconBrandTwitter, IconBrandGithub, IconBrandDiscord, IconBook } from '@tabler/icons';
+import { IconSpeakerphone, IconBrandDiscord } from '@tabler/icons-react';
import StyledWrapper from './StyledWrapper';
const Support = () => {
@@ -7,35 +7,17 @@ const Support = () => {
);
diff --git a/packages/bruno-app/src/components/Preferences/Theme/index.js b/packages/bruno-app/src/components/Preferences/Theme/index.js
index 7e9a2a1003..9080839cb2 100644
--- a/packages/bruno-app/src/components/Preferences/Theme/index.js
+++ b/packages/bruno-app/src/components/Preferences/Theme/index.js
@@ -5,8 +5,6 @@ import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const Theme = () => {
- const { storedTheme, setStoredTheme } = useTheme();
-
const formik = useFormik({
enableReinitialize: true,
initialValues: {
@@ -22,57 +20,7 @@ const Theme = () => {
return (
-
+
);
};
diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js
index 843fd8228d..782c3361c4 100644
--- a/packages/bruno-app/src/components/Preferences/index.js
+++ b/packages/bruno-app/src/components/Preferences/index.js
@@ -3,10 +3,9 @@ import classnames from 'classnames';
import React, { useState } from 'react';
import Support from './Support';
import General from './General';
-import Font from './Font';
-import Theme from './Theme';
import Proxy from './ProxySettings';
import StyledWrapper from './StyledWrapper';
+import Interface from 'components/Preferences/Interface';
const Preferences = ({ onClose }) => {
const [tab, setTab] = useState('general');
@@ -23,21 +22,17 @@ const Preferences = ({ onClose }) => {
return ;
}
- case 'proxy': {
- return ;
+ case 'interface': {
+ return ;
}
- case 'theme': {
- return ;
+ case 'proxy': {
+ return ;
}
case 'support': {
return ;
}
-
- case 'font': {
- return ;
- }
}
};
@@ -48,11 +43,8 @@ const Preferences = ({ onClose }) => {
setTab('general')}>
General
- setTab('theme')}>
- Theme
-
- setTab('font')}>
- Font
+
setTab('interface')}>
+ Interface
setTab('proxy')}>
Proxy
diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js
index 375fa0ec4c..359454f02a 100644
--- a/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Assertions/AssertionRow/index.js
@@ -1,6 +1,6 @@
import React from 'react';
-import { IconTrash } from '@tabler/icons';
-import SingleLineEditor from 'components/SingleLineEditor';
+import { IconTrash } from '@tabler/icons-react';
+import CodeEditor from 'src/components/CodeEditor';
import AssertionOperator from '../AssertionOperator';
import { useTheme } from 'providers/Theme';
@@ -173,10 +173,10 @@ const AssertionRow = ({
{!isUnaryOperator(operator) ? (
-
handleAssertionChange(
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js
index 2367d96452..25e46022dc 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js
@@ -1,6 +1,6 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
-import { IconCaretDown } from '@tabler/icons';
+import { IconCaretDown } from '@tabler/icons-react';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
index 7c144fbf86..e6785aa015 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
-import SingleLineEditor from 'components/SingleLineEditor';
+import CodeEditor from 'src/components/CodeEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@@ -129,7 +129,8 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
Access Key ID
-
{
Secret Access Key
-
{
Session Token
-
{
Service
-
{
Region
-
{
Profile Name
-
{
Username
-
{
Password
-
{
Token
-
{
Username
-
{
Password
-
{
const dispatch = useDispatch();
@@ -85,13 +85,14 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
{label}
- handleChange(key, val)}
onRun={handleRun}
collection={collection}
+ singleLine
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
index 7edb8bb25b..375d7f6bf8 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
@@ -2,11 +2,11 @@ import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
-import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
+import CodeEditor from 'components/CodeEditor';
const OAuth2ClientCredentials = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -48,13 +48,14 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
{label}
- handleChange(key, val)}
onRun={handleRun}
collection={collection}
+ singleLine
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
index 3fa12b9474..63b9530278 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
@@ -3,7 +3,7 @@ import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
-import { IconCaretDown } from '@tabler/icons';
+import { IconCaretDown } from '@tabler/icons-react';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { humanizeGrantType } from 'utils/collections';
import { useEffect } from 'react';
@@ -30,6 +30,7 @@ const GrantTypeSelector = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
+ ...oAuth,
grantType
}
})
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
index 1e64d4faa7..336e6615fa 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
@@ -2,11 +2,11 @@ import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
-import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
+import CodeEditor from 'components/CodeEditor';
const OAuth2AuthorizationCode = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -50,13 +50,14 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
{label}
- handleChange(key, val)}
onRun={handleRun}
collection={collection}
+ singleLine
/>
diff --git a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js
index a358e2ed3f..d60261603f 100644
--- a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js
@@ -1,7 +1,7 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
-import { IconTrash } from '@tabler/icons';
+import { IconTrash } from '@tabler/icons-react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
@@ -9,7 +9,7 @@ import {
updateFormUrlEncodedParam,
deleteFormUrlEncodedParam
} from 'providers/ReduxStore/slices/collections';
-import MultiLineEditor from 'components/MultiLineEditor';
+import CodeEditor from 'src/components/CodeEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@@ -92,7 +92,8 @@ const FormUrlEncodedParams = ({ item, collection }) => {
/>
- {
)
}
allowNewlines={true}
+ allowLinebreaks={true}
onRun={handleRun}
collection={collection}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
index 5bdd9c5e76..5acd914069 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
@@ -4,7 +4,6 @@ import get from 'lodash/get';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
-import QueryEditor from 'components/RequestPane/QueryEditor';
import Auth from 'components/RequestPane/Auth';
import GraphQLVariables from 'components/RequestPane/GraphQLVariables';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
@@ -18,6 +17,7 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
+import CodeEditor from 'components/CodeEditor';
const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
@@ -61,7 +61,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
switch (tab) {
case 'query': {
return (
-
);
}
@@ -151,7 +151,7 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
- {getTabPanel(focusedTab.requestPaneTab)}
+ {getTabPanel(focusedTab.requestPaneTab)}
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
index 8fe7473899..ec5164dadc 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, forwardRef } from 'react';
import useGraphqlSchema from './useGraphqlSchema';
-import { IconBook, IconDownload, IconLoader2, IconRefresh } from '@tabler/icons';
+import { IconBook, IconDownload, IconLoader2, IconRefresh } from '@tabler/icons-react';
import get from 'lodash/get';
import { findEnvironmentInCollection } from 'utils/collections';
import Dropdown from '../../Dropdown';
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/StyledWrapper.js
index 9f75832220..ec278887d7 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/StyledWrapper.js
@@ -1,10 +1,5 @@
import styled from 'styled-components';
-const StyledWrapper = styled.div`
- div.CodeMirror {
- /* todo: find a better way */
- height: calc(100vh - 220px);
- }
-`;
+const StyledWrapper = styled.div``;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
index a7978ebd77..9f264bd017 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
@@ -1,11 +1,11 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
-import CodeEditor from 'components/CodeEditor';
import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
+import CodeEditor from 'components/CodeEditor';
const GraphQLVariables = ({ variables, item, collection }) => {
const dispatch = useDispatch();
@@ -33,7 +33,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
value={variables || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
- onEdit={onEdit}
+ onChange={onEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
index df90082c67..c1ee13a807 100644
--- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -7,7 +7,6 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Auth from 'components/RequestPane/Auth';
-import AuthMode from 'components/RequestPane/Auth/AuthMode';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
@@ -16,6 +15,18 @@ import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
+const componentMap = (item, collection) => ({
+ params: ,
+ body: ,
+ headers: ,
+ auth: ,
+ vars: ,
+ assert: ,
+ script: ,
+ tests: ,
+ docs:
+});
+
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -30,41 +41,6 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
);
};
- const getTabPanel = (tab) => {
- switch (tab) {
- case 'params': {
- return ;
- }
- case 'body': {
- return ;
- }
- case 'headers': {
- return ;
- }
- case 'auth': {
- return ;
- }
- case 'vars': {
- return ;
- }
- case 'assert': {
- return ;
- }
- case 'script': {
- return ;
- }
- case 'tests': {
- return ;
- }
- case 'docs': {
- return ;
- }
- default: {
- return 404 | Not found
;
- }
- }
- };
-
if (!activeTabUid) {
return Something went wrong
;
}
@@ -73,6 +49,14 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return An error occurred!
;
}
+ const tabPanel = useMemo(() => {
+ const getTabPanel = (tab, item, collection) => {
+ const components = componentMap(item, collection);
+ return components[tab] || 404 | Not found
;
+ };
+
+ return getTabPanel(focusedTab.requestPaneTab, item, collection);
+ }, [focusedTab.requestPaneTab, item, collection]);
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
@@ -141,7 +125,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
'mt-5': !isMultipleContentTab
})}
>
- {getTabPanel(focusedTab.requestPaneTab)}
+ {tabPanel}
);
diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
index 1f1c9977ec..0252ca1df6 100644
--- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
@@ -1,7 +1,7 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
-import { IconTrash } from '@tabler/icons';
+import { IconTrash } from '@tabler/icons-react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
@@ -9,7 +9,7 @@ import {
updateMultipartFormParam,
deleteMultipartFormParam
} from 'providers/ReduxStore/slices/collections';
-import MultiLineEditor from 'components/MultiLineEditor';
+import CodeEditor from 'src/components/CodeEditor';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
@@ -121,7 +121,8 @@ const MultipartFormParams = ({ item, collection }) => {
collection={collection}
/>
) : (
- {
}
onRun={handleRun}
allowNewlines={true}
+ allowLinebreaks={true}
collection={collection}
/>
)}
diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js
index 99d5ed3b94..d429cc6b29 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/StyledWrapper.js
@@ -1,54 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- div.CodeMirror {
- background: ${(props) => props.theme.codemirror.bg};
- border: solid 1px ${(props) => props.theme.codemirror.border};
- /* todo: find a better way */
- height: calc(100vh - 220px);
- }
-
textarea.cm-editor {
position: relative;
}
-
- // Todo: dark mode temporary fix
- // Clean this
- .CodeMirror.cm-s-monokai {
- .CodeMirror-overlayscroll-horizontal div,
- .CodeMirror-overlayscroll-vertical div {
- background: #444444;
- }
- }
-
- .cm-s-monokai span.cm-property,
- .cm-s-monokai span.cm-attribute {
- color: #9cdcfe !important;
- }
-
- .cm-s-monokai span.cm-property,
- .cm-s-monokai span.cm-attribute {
- color: #9cdcfe !important;
- }
-
- .cm-s-monokai span.cm-string {
- color: #ce9178 !important;
- }
-
- .cm-s-monokai span.cm-number {
- color: #b5cea8 !important;
- }
-
- .cm-s-monokai span.cm-atom {
- color: #569cd6 !important;
- }
-
- .cm-variable-valid {
- color: green;
- }
- .cm-variable-invalid {
- color: red;
- }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
index 598af0212f..7d02d07228 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
@@ -14,7 +14,7 @@ import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
-import { IconWand } from '@tabler/icons';
+import { IconWand } from '@tabler/icons-react';
import onHasCompletion from './onHasCompletion';
@@ -50,7 +50,7 @@ export default class QueryEditor extends React.Component {
variables: getAllVariables(this.props.collection)
},
theme: this.props.editorTheme || 'graphiql',
- theme: this.props.theme === 'dark' ? 'monokai' : 'default',
+ // theme: this.props.theme === 'dark' ? 'monokai' : 'default',
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
index 162d57a43e..8b897b6df4 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
@@ -1,8 +1,7 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
-import has from 'lodash/has';
-import { IconTrash } from '@tabler/icons';
+import { IconTrash } from '@tabler/icons-react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
@@ -11,10 +10,10 @@ import {
updatePathParam,
updateQueryParam
} from 'providers/ReduxStore/slices/collections';
-import SingleLineEditor from 'components/SingleLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
+import CodeEditor from 'components/CodeEditor';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -114,7 +113,7 @@ const QueryParams = ({ item, collection }) => {
{queryParams && queryParams.length
- ? queryParams.map((param, index) => {
+ ? queryParams.map((param) => {
return (
@@ -130,7 +129,7 @@ const QueryParams = ({ item, collection }) => {
/>
- {
}
onRun={handleRun}
collection={collection}
+ singleLine
/>
@@ -198,7 +198,7 @@ const QueryParams = ({ item, collection }) => {
/>
- {
}
onRun={handleRun}
collection={collection}
+ singleLine
/>
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js
index c2bdf15f10..ffd6412b46 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js
@@ -1,5 +1,5 @@
import React, { useRef, forwardRef } from 'react';
-import { IconCaretDown } from '@tabler/icons';
+import { IconCaretDown } from '@tabler/icons-react';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js
deleted file mode 100644
index 2308dec4fb..0000000000
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/StyledWrapper.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- height: 2.3rem;
-
- div.method-selector-container {
- background-color: ${(props) => props.theme.requestTabPanel.url.bg};
- border-top-left-radius: 3px;
- border-bottom-left-radius: 3px;
- }
-
- div.input-container {
- background-color: ${(props) => props.theme.requestTabPanel.url.bg};
- border-top-right-radius: 3px;
- border-bottom-right-radius: 3px;
-
- input {
- background-color: ${(props) => props.theme.requestTabPanel.url.bg};
- outline: none;
- box-shadow: none;
-
- &:focus {
- outline: none !important;
- box-shadow: none !important;
- }
- }
- }
-
- .caret {
- color: rgb(140, 140, 140);
- fill: rgb(140 140 140);
- position: relative;
- top: 1px;
- }
-
- .tooltip {
- position: relative;
- display: inline-block;
- cursor: pointer;
- }
-
- .tooltip:hover .tooltiptext {
- visibility: visible;
- opacity: 1;
- }
-
- .tooltiptext {
- visibility: hidden;
- width: auto;
- background-color: ${(props) => props.theme.requestTabs.active.bg};
- color: ${(props) => props.theme.text};
- text-align: center;
- border-radius: 4px;
- padding: 4px 8px;
- position: absolute;
- z-index: 1;
- bottom: 34px;
- left: 50%;
- transform: translateX(-50%);
- opacity: 0;
- transition: opacity 0.3s;
- white-space: nowrap;
- }
-
- .tooltiptext::after {
- content: '';
- position: absolute;
- top: 100%;
- left: 50%;
- margin-left: -4px;
- border-width: 4px;
- border-style: solid;
- border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
- }
-
- .shortcut {
- font-size: 0.625rem;
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
deleted file mode 100644
index 88fe4ee014..0000000000
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import get from 'lodash/get';
-import { useDispatch } from 'react-redux';
-import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
-import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
-import HttpMethodSelector from './HttpMethodSelector';
-import { useTheme } from 'providers/Theme';
-import { IconDeviceFloppy, IconArrowRight } from '@tabler/icons';
-import SingleLineEditor from 'components/SingleLineEditor';
-import { isMacOS } from 'utils/common/platform';
-import StyledWrapper from './StyledWrapper';
-
-const QueryUrl = ({ item, collection, handleRun }) => {
- const { theme, storedTheme } = useTheme();
- const dispatch = useDispatch();
- const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
- const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
- const isMac = isMacOS();
- const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
-
- const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
-
- useEffect(() => {
- const el = document.querySelector('.method-selector-container');
- setMethodSelectorWidth(el.offsetWidth);
- }, [method]);
-
- const onSave = () => {
- dispatch(saveRequest(item.uid, collection.uid));
- };
-
- const onUrlChange = (value) => {
- dispatch(
- requestUrlChanged({
- itemUid: item.uid,
- collectionUid: collection.uid,
- url: value && typeof value === 'string' ? value.trim() : value
- })
- );
- };
-
- const onMethodSelect = (verb) => {
- dispatch(
- updateRequestMethod({
- method: verb,
- itemUid: item.uid,
- collectionUid: collection.uid
- })
- );
- };
-
- return (
-
-
-
-
-
-
onUrlChange(newValue)}
- onRun={handleRun}
- collection={collection}
- item={item}
- />
-
-
{
- e.stopPropagation();
- if (!item.draft) return;
- onSave();
- }}
- >
-
-
- Save ({saveShortcut})
-
-
-
-
-
-
- );
-};
-
-export default QueryUrl;
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
index ba04f3c78d..476ba6a35f 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
@@ -1,6 +1,6 @@
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
-import { IconCaretDown } from '@tabler/icons';
+import { IconCaretDown } from '@tabler/icons-react';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import { updateRequestBodyMode } from 'providers/ReduxStore/slices/collections';
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
index b776351d75..9d7ddb9e94 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
@@ -1,6 +1,5 @@
import React from 'react';
import get from 'lodash/get';
-import CodeEditor from 'components/CodeEditor';
import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams';
import MultipartFormParams from 'components/RequestPane/MultipartFormParams';
import { useDispatch, useSelector } from 'react-redux';
@@ -8,6 +7,7 @@ import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
+import CodeEditor from 'components/CodeEditor';
const RequestBody = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -30,7 +30,7 @@ const RequestBody = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
if (['json', 'xml', 'text', 'sparql'].includes(bodyMode)) {
- let codeMirrorMode = {
+ let mode = {
json: 'application/ld+json',
text: 'application/text',
xml: 'application/xml',
@@ -51,10 +51,11 @@ const RequestBody = ({ item, collection }) => {
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
value={bodyContent[bodyMode] || ''}
- onEdit={onEdit}
+ onChange={onEdit}
onRun={onRun}
onSave={onSave}
- mode={codeMirrorMode[bodyMode]}
+ mode={mode[bodyMode]}
+ withVariables
/>
);
diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
index 445505c078..42b0ef54b7 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
@@ -1,12 +1,12 @@
import React from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
-import { IconTrash } from '@tabler/icons';
+import { IconTrash } from '@tabler/icons-react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
-import SingleLineEditor from 'components/SingleLineEditor';
+import CodeEditor from 'src/components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -78,7 +78,8 @@ const RequestHeaders = ({ item, collection }) => {
return (
- {
/>
- {
}
onRun={handleRun}
allowNewlines={true}
+ allowLinebreaks={true}
collection={collection}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js
index 935b52ede9..ce4bc7a8e9 100644
--- a/packages/bruno-app/src/components/RequestPane/Script/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Script/index.js
@@ -46,8 +46,9 @@ const Script = ({ item, collection }) => {
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
+ height={'25vh'}
font={get(preferences, 'font.codeFont', 'default')}
- onEdit={onRequestScriptEdit}
+ onChange={onRequestScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
@@ -59,8 +60,9 @@ const Script = ({ item, collection }) => {
collection={collection}
value={responseScript || ''}
theme={displayedTheme}
+ height={'25vh'}
font={get(preferences, 'font.codeFont', 'default')}
- onEdit={onResponseScriptEdit}
+ onChange={onResponseScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js
index f80087a7f2..e0b03f21d4 100644
--- a/packages/bruno-app/src/components/RequestPane/Tests/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js
@@ -1,11 +1,11 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
-import CodeEditor from 'components/CodeEditor';
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
+import CodeEditor from 'components/CodeEditor';
const Tests = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -28,13 +28,13 @@ const Tests = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
-
+
{
/>
- {
return (
-
Pre Request
+
Pre Request
-
Post Response
+
Post Response
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index f719eb0f3e..9c1850bc95 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -10,14 +10,14 @@ import { findItemInCollection } from 'utils/collections';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
-import QueryUrl from 'components/RequestPane/QueryUrl';
import NetworkError from 'components/ResponsePane/NetworkError';
import RunnerResults from 'components/RunnerResults';
import VariablesEditor from 'components/VariablesEditor';
import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
-
import StyledWrapper from './StyledWrapper';
+import { RequestUrlBar } from 'src/feature/request-url-bar';
+import FolderSettings from 'components/FolderSettings';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -131,6 +131,10 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'collection-settings') {
return ;
}
+ if (focusedTab.type === 'folder-settings') {
+ const folder = findItemInCollection(collection, focusedTab.folderUid);
+ return ;
+ }
const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) {
@@ -147,9 +151,7 @@ const RequestTabPanel = () => {
return (
-
-
-
+
= ({ collectionUid, activeTabType }) => {
+ const dispatch = useDispatch();
+
+ const openTab = (type: 'collection-runner' | 'collection-settings' | 'variables') => {
+ dispatch(
+ addTab({
+ uid: uuid(),
+ collectionUid,
+ type
+ })
+ );
+ };
+
+ return (
+
+
+ openTab('collection-runner')}
+ >
+
+
+
+
+
+ openTab('variables')}
+ >
+
+
+
+
+
+ openTab('collection-settings')}
+ >
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
index ba77d47c98..e0420ff867 100644
--- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
@@ -1,33 +1,19 @@
import React from 'react';
+import { Switch } from '@mantine/core';
+import { useLocalStorage } from '@mantine/hooks';
import { uuid } from 'utils/common';
-import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
-import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
+import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons-react';
+import { EnvironmentSelector } from 'src/feature/environment-editor/components/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
+import { findItemInCollection } from 'utils/collections';
+import { CollectionTabButtons } from './CollectionTabButtons';
-const CollectionToolBar = ({ collection }) => {
+const CollectionToolBar = ({ collection, activeTabUid }) => {
const dispatch = useDispatch();
-
- const handleRun = () => {
- dispatch(
- addTab({
- uid: uuid(),
- collectionUid: collection.uid,
- type: 'collection-runner'
- })
- );
- };
-
- const viewVariables = () => {
- dispatch(
- addTab({
- uid: uuid(),
- collectionUid: collection.uid,
- type: 'variables'
- })
- );
- };
+ const tabs = useSelector((state) => state.tabs.tabs);
+ const activeTab = tabs.find((tab) => tab.uid === activeTabUid);
const viewCollectionSettings = () => {
dispatch(
@@ -39,23 +25,70 @@ const CollectionToolBar = ({ collection }) => {
);
};
+ let tabType = null;
+ let tabInfo = null;
+ switch (activeTab.type) {
+ case 'request':
+ const item = findItemInCollection(collection, activeTabUid);
+ if (item) {
+ tabInfo = item.name;
+ if (item.draft) {
+ tabInfo += '*';
+ }
+ }
+ break;
+ case 'collection-settings':
+ tabInfo = 'Settings';
+ tabType = 'collection-settings';
+ break;
+ case 'variables':
+ tabInfo = 'Variables';
+ tabType = 'variables';
+ break;
+ case 'collection-runner':
+ tabInfo = 'Runner';
+ tabType = 'collection-runner';
+ break;
+ case 'folder-settings':
+ tabInfo = 'Folder Settings';
+ break;
+ default:
+ console.error('No tab type case for: ', activeTab.type);
+ }
+
+ const [useNewRequest, setNewRequest] = useLocalStorage({
+ key: 'new-request',
+ defaultValue: 'false'
+ });
+
return (
-
-
-
{collection.name}
+
+
+
+ {collection.name}
+
+ {tabInfo ? (
+ <>
+
-
+
{tabInfo}
+ >
+ ) : null}
-
-
-
-
-
-
-
-
+
+ {
+ setNewRequest(evt.currentTarget.checked ? 'true' : 'false');
+ }}
+ checked={useNewRequest === 'true'}
+ />
+
+
+
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js
index d02704636a..4e8179d419 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { IconAlertTriangle } from '@tabler/icons';
+import { IconAlertTriangle } from '@tabler/icons-react';
import Modal from 'components/Modal';
const ConfirmRequestClose = ({ item, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js
index 7bf45446e1..1ccb39935a 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabNotFound.js
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
-import { IconAlertTriangle } from '@tabler/icons';
+import { IconAlertTriangle } from '@tabler/icons-react';
const RequestTabNotFound = ({ handleCloseClick }) => {
const [showErrorMessage, setShowErrorMessage] = useState(false);
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
index aebc3db754..0d25643f7a 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
@@ -1,22 +1,30 @@
import React from 'react';
-import { IconVariable, IconSettings, IconRun } from '@tabler/icons';
+import { IconVariable, IconSettings, IconRun, IconFolderCog } from '@tabler/icons-react';
-const SpecialTab = ({ handleCloseClick, type }) => {
- const getTabInfo = (type) => {
+const SpecialTab = ({ handleCloseClick, type, folderName }) => {
+ const getTabInfo = (type, folderName) => {
switch (type) {
case 'collection-settings': {
return (
<>
-
Collection
+
Collection
>
);
}
+ case 'folder-settings': {
+ return (
+
+
+ {folderName || 'Folder'}
+
+ );
+ }
case 'variables': {
return (
<>
-
Variables
+
Variables
>
);
}
@@ -24,7 +32,7 @@ const SpecialTab = ({ handleCloseClick, type }) => {
return (
<>
-
Runner
+
Runner
>
);
}
@@ -33,7 +41,7 @@ const SpecialTab = ({ handleCloseClick, type }) => {
return (
<>
-
{getTabInfo(type)}
+
{getTabInfo(type, folderName)}
handleCloseClick(e)}>
{
+const RequestTab = ({ tab, collection, folderUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [showConfirmClose, setShowConfirmClose] = useState(false);
@@ -41,12 +41,32 @@ const RequestTab = ({ tab, collection }) => {
}
};
+ const folder = useMemo(() => {
+ return folderUid ? findItemInCollection(collection, folderUid) : null;
+ }, [folderUid]);
+ if (['collection-settings', 'folder-settings', 'variables', 'collection-runner'].includes(tab.type)) {
+ return (
+
+
+
+ );
+ }
+
+ const item = findItemInCollection(collection, tab.uid);
+
+ if (!item) {
+ return (
+
+
+
+ );
+ }
+
+ const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
const getMethodColor = (method = '') => {
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
-
let color = '';
method = method.toLocaleLowerCase();
-
switch (method) {
case 'get': {
color = theme.request.methods.get;
@@ -77,30 +97,9 @@ const RequestTab = ({ tab, collection }) => {
break;
}
}
-
return color;
};
- if (['collection-settings', 'variables', 'collection-runner'].includes(tab.type)) {
- return (
-
-
-
- );
- }
-
- const item = findItemInCollection(collection, tab.uid);
-
- if (!item) {
- return (
-
-
-
- );
- }
-
- const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
-
return (
{showConfirmClose && (
diff --git a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js
index ec76ec5b5a..5b00b11511 100644
--- a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js
@@ -8,11 +8,8 @@ const Wrapper = styled.div`
margin: 0;
display: flex;
position: relative;
- overflow: scroll;
-
- &::-webkit-scrollbar {
- display: none;
- }
+ overflow-x: scroll;
+ scrollbar-width: none;
li {
display: inline-flex;
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js
index 3063771e86..4ab50c5078 100644
--- a/packages/bruno-app/src/components/RequestTabs/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/index.js
@@ -2,13 +2,18 @@ import React, { useState, useRef } from 'react';
import find from 'lodash/find';
import filter from 'lodash/filter';
import classnames from 'classnames';
-import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
+import { IconChevronRight, IconChevronLeft } from '@tabler/icons-react';
import { useSelector, useDispatch } from 'react-redux';
-import { focusTab } from 'providers/ReduxStore/slices/tabs';
+import { focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
+import ConfirmRequestClose from './RequestTab/ConfirmRequestClose/index';
+import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
+import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { findItemInCollection } from 'utils/collections';
+import get from 'lodash/get';
const RequestTabs = () => {
const dispatch = useDispatch();
@@ -19,7 +24,10 @@ const RequestTabs = () => {
const collections = useSelector((state) => state.collections.collections);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const screenWidth = useSelector((state) => state.app.screenWidth);
-
+ const hideTabs = useSelector((state) => get(state.app.preferences, 'hideTabs', false));
+ const [showConfirmClose, setShowConfirmClose] = useState(false);
+ const [item, setItem] = useState(null);
+ const [tab, setTab] = useState(null);
const getTabClassname = (tab, index) => {
return classnames('request-tab select-none', {
active: tab.uid === activeTabUid,
@@ -35,6 +43,23 @@ const RequestTabs = () => {
);
};
+ const handleMouseUp = (e, tab) => {
+ const item = findItemInCollection(activeCollection, tab.uid);
+ setItem(item);
+ setTab(tab);
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (e.button === 1) {
+ if (item?.draft) return setShowConfirmClose(true);
+ dispatch(
+ closeTabs({
+ tabUids: [tab.uid]
+ })
+ );
+ }
+ };
+
const createNewTab = () => setNewRequestModalOpen(true);
if (!activeTabUid) {
@@ -75,81 +100,107 @@ const RequestTabs = () => {
'has-chevrons': showChevrons
});
};
-
// Todo: Must support ephemeral requests
return (
+ {showConfirmClose && (
+ setShowConfirmClose(false)}
+ onCloseWithoutSave={() => {
+ dispatch(
+ deleteRequestDraft({
+ itemUid: item.uid,
+ collectionUid: activeCollection.uid
+ })
+ );
+ dispatch(
+ closeTabs({
+ tabUids: [tab.uid]
+ })
+ );
+ setShowConfirmClose(false);
+ }}
+ onSaveAndClose={() => {
+ dispatch(saveRequest(item.uid, activeCollection.uid))
+ .then(() => {
+ dispatch(
+ closeTabs({
+ tabUids: [tab.uid]
+ })
+ );
+ setShowConfirmClose(false);
+ })
+ .catch((err) => {
+ console.log('err', err);
+ });
+ }}
+ />
+ )}
{newRequestModalOpen && (
setNewRequestModalOpen(false)} />
)}
- {collectionRequestTabs && collectionRequestTabs.length ? (
- <>
-
-
-
- {showChevrons ? (
-
-
-
-
-
- ) : null}
- {/* Moved to post mvp */}
- {/*
-
-
-
- */}
-
-
- {collectionRequestTabs && collectionRequestTabs.length
- ? collectionRequestTabs.map((tab, index) => {
- return (
- handleClick(tab)}
- >
-
-
- );
- })
- : null}
-
-
-
- {showChevrons ? (
-
-
-
-
-
- ) : null}
-
+
+ {collectionRequestTabs?.length && !hideTabs ? (
+
+
+ {showChevrons ? (
+
- {/* Moved to post mvp */}
- {/*
+ ) : null}
+ {/* Moved to post mvp */}
+ {/*
+
+
+
+ */}
+
+
+ {collectionRequestTabs && collectionRequestTabs.length
+ ? collectionRequestTabs.map((tab, index) => {
+ return (
+ handleMouseUp(e, tab)}
+ key={tab.uid}
+ className={getTabClassname(tab, index)}
+ role="tab"
+ onClick={() => handleClick(tab)}
+ >
+
+
+ );
+ })
+ : null}
+
+
+
+ {showChevrons ? (
+
- */}
-
-
- >
+
+ ) : null}
+
+
+
+ {/* Moved to post mvp */}
+ {/*
+
+ */}
+
+
) : null}
);
diff --git a/packages/bruno-app/src/components/ResponsePane/Debug.tsx b/packages/bruno-app/src/components/ResponsePane/Debug.tsx
new file mode 100644
index 0000000000..7ea0f0bae3
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Debug.tsx
@@ -0,0 +1,49 @@
+/*
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Stack, Title, Text } from '@mantine/core';
+import { Inspector } from 'react-inspector';
+import { useTheme } from 'providers/Theme';
+import { ResponseTimings } from 'components/ResponsePane/ResponseTimings';
+
+type Logs = { title: string; data: string; date: number }[];
+type DebugInfo = { stage: string; logs: Logs }[];
+
+export const DebugTab: React.FC<{ debugInfo: DebugInfo; timings: unknown; maxWidth?: number }> = ({
+ debugInfo = [],
+ timings,
+ maxWidth
+}) => {
+ return (
+
+
+ {debugInfo.map(({ stage, logs }) => (
+
+
+ {stage}
+
+
+
+
+
+ ))}
+
+ );
+};
+
+const LogList: React.FC<{ logs: Logs }> = ({ logs }) => {
+ const { storedTheme } = useTheme();
+
+ const reactInspectorTheme = storedTheme === 'light' ? 'chromeLight' : 'chromeDark';
+
+ return logs.map(({ title, date, data }, index) => (
+
+
{title}
+
+ Occurred on {new Date(date).toLocaleTimeString()}
+
+
+
+ ));
+};
diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
index b203053fb5..c5663a61cd 100644
--- a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { IconRefresh } from '@tabler/icons';
+import { IconRefresh } from '@tabler/icons-react';
import { useDispatch } from 'react-redux';
import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
import StopWatch from '../../StopWatch';
diff --git a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js
index bca9e138af..8cff9df379 100644
--- a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { IconSend } from '@tabler/icons';
+import { IconSend } from '@tabler/icons-react';
import StyledWrapper from './StyledWrapper';
import { isMacOS } from 'utils/common/platform';
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultError/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultError/index.js
new file mode 100644
index 0000000000..b13d1ebf58
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultError/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+
+const QueryResultError = ({ error, width }) => {
+ return (
+
+
{error}
+
+ {error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
+
+ You can disable SSL verification in the Preferences.
+ To open the Preferences, click on the gear icon in the bottom left corner.
+
+ ) : null}
+
+ );
+};
+
+export default QueryResultError;
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js
index a07acc95f1..464ce167d3 100644
--- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js
@@ -1,10 +1,11 @@
-import { IconFilter, IconX } from '@tabler/icons';
+import { IconFilter, IconX } from '@tabler/icons-react';
import React, { useMemo } from 'react';
import { useRef } from 'react';
import { useState } from 'react';
import { Tooltip as ReactTooltip } from 'react-tooltip';
+import { debounce } from 'lodash';
-const QueryResultFilter = ({ filter, onChange, mode }) => {
+const QueryResultFilter = ({ onChange, mode }) => {
const inputRef = useRef(null);
const [isExpanded, toggleExpand] = useState(false);
@@ -43,6 +44,10 @@ const QueryResultFilter = ({ filter, onChange, mode }) => {
return null;
}, [mode]);
+ const debouncedOnChange = debounce((e) => {
+ onChange(e.target.value);
+ }, 250);
+
return (
{
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
+ onChange={debouncedOnChange}
className={`block ml-14 p-2 py-1 sm:text-sm transition-all duration-200 ease-in-out border border-gray-300 rounded-md ${
isExpanded ? 'w-full opacity-100' : 'w-[0] opacity-0'
}`}
- onChange={onChange}
/>
{isExpanded ?
:
}
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultMode/StyledWrapper.js
similarity index 100%
rename from packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js
rename to packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultMode/StyledWrapper.js
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultMode/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultMode/index.js
new file mode 100644
index 0000000000..4f9a9d8d59
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultMode/index.js
@@ -0,0 +1,136 @@
+import { getContentType, safeParseXML, safeStringifyJSON } from 'utils/common';
+import React, { useEffect, useMemo, useState } from 'react';
+import classnames from 'classnames';
+import StyledWrapper from 'components/ResponsePane/QueryResult/QueryResultMode/StyledWrapper';
+import QueryResultPreview from 'components/ResponsePane/QueryResult/QueryResultViewer';
+import QueryResultFilter from 'components/ResponsePane/QueryResult/QueryResultFilter';
+import { getMonacoModeFromContent } from 'utils/monaco/monacoUtils.ts';
+import { JSONPath } from 'jsonpath-plus';
+
+/**
+ * @param {string|object|undefined} data
+ * @param {string} mode
+ * @param {string} filter
+ * @returns {string}
+ */
+const formatResponse = (data, mode, filter) => {
+ if (data === undefined) {
+ return '';
+ }
+
+ if (mode.includes('json')) {
+ if (filter) {
+ try {
+ data = JSONPath({ path: filter, json: data });
+ } catch (e) {
+ console.warn('Could not filter with JSONPath.', e.message);
+ }
+ }
+
+ return safeStringifyJSON(data, true);
+ }
+
+ if (mode.includes('xml')) {
+ let parsed = safeParseXML(data, { collapseContent: true });
+ if (typeof parsed === 'string') {
+ return parsed;
+ }
+
+ return safeStringifyJSON(parsed, true);
+ }
+
+ if (typeof data === 'string') {
+ return data;
+ }
+
+ return safeStringifyJSON(data);
+};
+
+const QueryResultMode = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
+ const contentType = getContentType(headers);
+ const mode = getMonacoModeFromContent(contentType, data);
+ const [filter, setFilter] = useState('');
+ const formattedData = formatResponse(data, mode, filter);
+
+ const allowedPreviewModes = useMemo(() => {
+ // Always show raw
+ const allowedPreviewModes = ['raw'];
+
+ if (formattedData !== atob(dataBuffer) && formattedData !== '') {
+ allowedPreviewModes.unshift('pretty');
+ }
+
+ if (mode.includes('html') && typeof data === 'string') {
+ allowedPreviewModes.unshift('preview-web');
+ } else if (mode.includes('image')) {
+ allowedPreviewModes.unshift('preview-image');
+ } else if (contentType.includes('pdf')) {
+ allowedPreviewModes.unshift('preview-pdf');
+ } else if (contentType.includes('audio')) {
+ allowedPreviewModes.unshift('preview-audio');
+ } else if (contentType.includes('video')) {
+ allowedPreviewModes.unshift('preview-video');
+ }
+
+ if (error) {
+ allowedPreviewModes.unshift('error');
+ }
+
+ return allowedPreviewModes;
+ }, [mode, data, formattedData]);
+
+ const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
+ // Ensure the active Tab is always allowed
+ useEffect(() => {
+ if (!allowedPreviewModes.includes(previewTab)) {
+ setPreviewTab(allowedPreviewModes[0]);
+ }
+ }, [previewTab, allowedPreviewModes]);
+
+ const tabs = useMemo(() => {
+ if (allowedPreviewModes.length === 1) {
+ return null;
+ }
+
+ return allowedPreviewModes.map((previewMode) => (
+
setPreviewTab(previewMode)}
+ key={previewMode}
+ >
+ {previewMode.replace(/-(.*)/, ' ')}
+
+ ));
+ }, [allowedPreviewModes, previewTab]);
+
+ const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
+
+ return (
+
+
+ {tabs}
+
+
+ {queryFilterEnabled && }
+
+ );
+};
+
+export default QueryResultMode;
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultSizeWarning/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultSizeWarning/index.js
new file mode 100644
index 0000000000..7a58b04e2e
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultSizeWarning/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+
+const QueryResultSizeWarning = ({ size, width, dismissWarning }) => {
+ const sizeFormatted = (size / 1000 / 1000).toFixed(2);
+
+ return (
+
+
Response is larger than 5 MB ({sizeFormatted} MB)
+
Showing too large responses will make Bruno unresponsive or could crash the app
+
+
+ Show anyway
+
+
+ );
+};
+
+export default QueryResultSizeWarning;
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultViewer/PdfResultViewer/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultViewer/PdfResultViewer/index.js
new file mode 100644
index 0000000000..2e6617473f
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultViewer/PdfResultViewer/index.js
@@ -0,0 +1,22 @@
+import { useState } from 'react';
+import { Document, Page } from 'react-pdf';
+
+const PdfResultViewer = ({ dataBuffer }) => {
+ const [numPages, setNumPages] = useState(null);
+ function onDocumentLoadSuccess({ numPages }) {
+ // Only show up to 50 pages, because more will cause lag
+ setNumPages(Math.min(numPages, 50));
+ }
+
+ return (
+
+
+ {Array.from(new Array(numPages), (el, index) => (
+
+ ))}
+
+
+ );
+};
+
+export default PdfResultViewer;
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultViewer/index.js
similarity index 71%
rename from packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
rename to packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultViewer/index.js
index 13b280320f..835754e885 100644
--- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultViewer/index.js
@@ -1,12 +1,15 @@
-import CodeEditor from 'components/CodeEditor/index';
+import CodeEditor from 'components/CodeEditor';
import { get } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
-import { useState } from 'react';
+import React, { useState } from 'react';
import 'pdfjs-dist/build/pdf.worker';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
import 'react-pdf/dist/esm/Page/TextLayer.css';
+import { useTheme } from 'providers/Theme';
+import PdfResultViewer from 'components/ResponsePane/QueryResult/QueryResultViewer/PdfResultViewer';
+import QueryResultError from 'components/ResponsePane/QueryResult/QueryResultError';
const QueryResultPreview = ({
previewTab,
@@ -14,20 +17,17 @@ const QueryResultPreview = ({
data,
dataBuffer,
formattedData,
+ error,
item,
contentType,
collection,
mode,
- disableRunEventListener,
- displayedTheme
+ disableRunEventListener
}) => {
+ const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
- const [numPages, setNumPages] = useState(null);
- function onDocumentLoadSuccess({ numPages }) {
- setNumPages(numPages);
- }
// Fail safe, so we don't render anything with an invalid tab
if (!allowedPreviewModes.includes(previewTab)) {
return null;
@@ -55,14 +55,20 @@ const QueryResultPreview = ({
return
;
}
case 'preview-pdf': {
+ return
;
+ }
+ case 'pretty': {
return (
-
-
- {Array.from(new Array(numPages), (el, index) => (
-
- ))}
-
-
+
);
}
case 'preview-audio': {
@@ -75,6 +81,9 @@ const QueryResultPreview = ({
);
}
+ case 'error': {
+ return
;
+ }
default:
case 'raw': {
return (
@@ -83,8 +92,9 @@ const QueryResultPreview = ({
font={get(preferences, 'font.codeFont', 'default')}
theme={displayedTheme}
onRun={onRun}
- value={formattedData}
+ value={atob(dataBuffer)}
mode={mode}
+ height={'100%'}
readOnly
/>
);
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js
index ee956f1b12..ea4d806574 100644
--- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js
@@ -1,149 +1,59 @@
-import { debounce } from 'lodash';
-import QueryResultFilter from './QueryResultFilter';
-import { JSONPath } from 'jsonpath-plus';
-import React from 'react';
-import classnames from 'classnames';
-import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
-import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
-import QueryResultPreview from './QueryResultPreview';
+import React, { useEffect, useState } from 'react';
+import QueryResultError from './QueryResultError';
+import QueryResultMode from './QueryResultMode';
+import QueryResultSizeWarning from 'components/ResponsePane/QueryResult/QueryResultSizeWarning';
+import { getResponseBody } from 'utils/network';
-import StyledWrapper from './StyledWrapper';
-import { useState } from 'react';
-import { useMemo } from 'react';
-import { useEffect } from 'react';
-import { useTheme } from 'providers/Theme/index';
+const QueryResult = ({ item, collection, width, disableRunEventListener, headers, error }) => {
+ const [dismissedSizeWarning, setDismissedSizeWarning] = useState(false);
+ const [{ data, dataBuffer }, setData] = useState({ data: null, dataBuffer: null });
-const formatResponse = (data, mode, filter) => {
- if (data === undefined) {
- return '';
- }
-
- if (mode.includes('json')) {
- if (filter) {
- try {
- data = JSONPath({ path: filter, json: data });
- } catch (e) {
- console.warn('Could not filter with JSONPath.', e.message);
- }
+ const showSizeWarning = item.response?.size > 5_000_000 && !dismissedSizeWarning;
+ useEffect(() => {
+ if (showSizeWarning) {
+ return;
}
- return safeStringifyJSON(data, true);
- }
+ const abortController = new AbortController();
+ (async () => {
+ const data = await getResponseBody(item.uid);
+ if (!abortController.signal.aborted && data !== null) {
+ setData(data);
+ }
+ })();
- if (mode.includes('xml')) {
- let parsed = safeParseXML(data, { collapseContent: true });
- if (typeof parsed === 'string') {
- return parsed;
- }
+ return () => {
+ abortController.abort();
+ setData({ data: null, dataBuffer: null });
+ };
+ }, [item.response, showSizeWarning]);
- return safeStringifyJSON(parsed, true);
+ if (error && !dataBuffer) {
+ return
;
}
- if (typeof data === 'string') {
- return data;
+ if (showSizeWarning) {
+ const dismissWarning = () => {
+ setDismissedSizeWarning(true);
+ };
+ return
;
}
- return safeStringifyJSON(data);
-};
-
-const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
- const contentType = getContentType(headers);
- const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
- const [filter, setFilter] = useState(null);
- const formattedData = formatResponse(data, mode, filter);
- const { displayedTheme } = useTheme();
-
- const debouncedResultFilterOnChange = debounce((e) => {
- setFilter(e.target.value);
- }, 250);
-
- const allowedPreviewModes = useMemo(() => {
- // Always show raw
- const allowedPreviewModes = ['raw'];
-
- if (mode.includes('html') && typeof data === 'string') {
- allowedPreviewModes.unshift('preview-web');
- } else if (mode.includes('image')) {
- allowedPreviewModes.unshift('preview-image');
- } else if (contentType.includes('pdf')) {
- allowedPreviewModes.unshift('preview-pdf');
- } else if (contentType.includes('audio')) {
- allowedPreviewModes.unshift('preview-audio');
- } else if (contentType.includes('video')) {
- allowedPreviewModes.unshift('preview-video');
- }
-
- return allowedPreviewModes;
- }, [mode, data, formattedData]);
-
- const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
- // Ensure the active Tab is always allowed
- useEffect(() => {
- if (!allowedPreviewModes.includes(previewTab)) {
- setPreviewTab(allowedPreviewModes[0]);
- }
- }, [previewTab, allowedPreviewModes]);
-
- const tabs = useMemo(() => {
- if (allowedPreviewModes.length === 1) {
- return null;
- }
-
- return allowedPreviewModes.map((previewMode) => (
-
setPreviewTab(previewMode)}
- key={previewMode}
- >
- {previewMode.replace(/-(.*)/, ' ')}
-
- ));
- }, [allowedPreviewModes, previewTab]);
-
- const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
+ if (data === null && dataBuffer === null) {
+ return
Loading response...
;
+ }
return (
-
-
- {tabs}
-
- {error ? (
-
-
{error}
-
- {error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
-
- You can disable SSL verification in the Preferences.
- To open the Preferences, click on the gear icon in the bottom left corner.
-
- ) : null}
-
- ) : (
- <>
-
- {queryFilterEnabled && (
-
- )}
- >
- )}
-
+
);
};
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js
index 747543347f..569965cc1b 100644
--- a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js
@@ -1,5 +1,5 @@
import React from 'react';
-import { IconEraser } from '@tabler/icons';
+import { IconEraser } from '@tabler/icons-react';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js
index 7c183b0a6a..f00e65ac43 100644
--- a/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseSave/index.js
@@ -2,7 +2,7 @@ import React from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
-import { IconDownload } from '@tabler/icons';
+import { IconDownload } from '@tabler/icons-react';
const ResponseSave = ({ item }) => {
const { ipcRenderer } = window;
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseTimings.tsx b/packages/bruno-app/src/components/ResponsePane/ResponseTimings.tsx
new file mode 100644
index 0000000000..c71642a660
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseTimings.tsx
@@ -0,0 +1,51 @@
+/*
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Title, Stack, Table } from '@mantine/core';
+
+type ResponseTimingsProps = {
+ timings: {
+ total?: number;
+ request?: number;
+ preScript?: number;
+ postScript?: number;
+ test?: number;
+ };
+};
+
+export const ResponseTimings: React.FC
= ({ timings }) => {
+ if (!timings.total) {
+ return null;
+ }
+
+ return (
+
+ Timings
+
+
+
+ Name
+ Duration
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const TimingRow: React.FC<{ timing?: number; title: string }> = ({ timing, title }) => {
+ return (
+
+ {title}
+ {timing != undefined ? `${timing} ms` : 'Not executed'}
+
+ );
+};
diff --git a/packages/bruno-app/src/components/ResponsePane/TimelineNew.tsx b/packages/bruno-app/src/components/ResponsePane/TimelineNew.tsx
new file mode 100644
index 0000000000..79e90960e2
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/TimelineNew.tsx
@@ -0,0 +1,159 @@
+/*
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { useMemo } from 'react';
+import { Stack, Group, Text, Space, ThemeIcon, Alert, Spoiler } from '@mantine/core';
+import { IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react';
+import classes from './TimelinewNew.module.css';
+
+type RequestTimeline = {
+ // RequestInfo
+ finalOptions: {
+ method: string;
+ protocol: string;
+ hostname: string;
+ port: string;
+ path: string;
+ headers: Record;
+ };
+ requestBody?: string;
+ // Response
+ responseTime?: number;
+ statusCode?: number;
+ statusMessage?: String;
+ headers?: Record;
+ httpVersion?: string;
+ responseBody?: string;
+ error?: string;
+ info?: string;
+};
+
+const TimelineItem: React.FC<{ item: RequestTimeline }> = ({ item }) => {
+ const requestHeader: string[] = useMemo(() => {
+ const port = item.finalOptions.port ? `:${item.finalOptions.port}` : '';
+ const url = `${item.finalOptions.protocol}//${item.finalOptions.hostname}${port}${item.finalOptions.path}`;
+
+ const data = [`${item.finalOptions.method} ${url}`];
+ for (const [name, value] of Object.entries(item.finalOptions.headers)) {
+ if (Array.isArray(value)) {
+ for (const val of value) {
+ data.push(`${name}: ${val}`);
+ }
+ continue;
+ }
+ data.push(`${name}: ${value}`);
+ }
+
+ return data;
+ }, [item.finalOptions]);
+
+ let requestData;
+ if (item.requestBody !== undefined) {
+ const truncated = item.requestBody.length >= 2048 ? '... (Truncated)' : '';
+ requestData = `data ${item.requestBody}${truncated}`;
+ }
+
+ const responseHeader: string[] = useMemo(() => {
+ if (!item.statusCode) {
+ return ['N/A'];
+ }
+
+ const data = [`HTTP/${item.httpVersion} ${item.statusCode} ${item.statusMessage}`];
+ for (const [name, value] of Object.entries(item.headers ?? {})) {
+ if (!Array.isArray(value)) {
+ data.push(`${name}: ${value}`);
+ continue;
+ }
+ for (const val of value) {
+ data.push(`${name}: ${val}`);
+ }
+ }
+
+ return data;
+ }, [item.headers]);
+
+ let responseData;
+ if (item.responseBody !== undefined) {
+ const truncated = item.responseBody.length >= 2048 ? '... (Truncated)' : '';
+ responseData = `data ${item.responseBody}${truncated}`;
+ }
+
+ return (
+
+ {requestHeader.map((item, i) => (
+
+ >
+ {item}
+
+ ))}
+ {requestData !== undefined ? (
+
+ >
+ {requestData}
+
+ ) : null}
+
+
+ {responseHeader.map((item, i) => (
+
+ <
+ {item}
+
+ ))}
+ {responseData !== undefined ? (
+
+ <
+ {responseData}
+
+ ) : null}
+
+ {item.error !== undefined ? (
+
}>
+ {item.error}
+
+ ) : null}
+ {item.info !== undefined ? (
+
+
+
+
+ {item.info}
+
+ ) : null}
+
+ );
+};
+
+type TimelineNewProps = {
+ timeline: RequestTimeline[];
+ maxWidth?: number;
+};
+
+export const TimelineNew: React.FC = ({ timeline, maxWidth }) => {
+ if (!timeline) {
+ return No timeline data available
;
+ }
+
+ const items = timeline.map((item, index) => {
+ return ;
+ });
+
+ return (
+
+ {items}
+
+ );
+};
diff --git a/packages/bruno-app/src/components/ResponsePane/TimelinewNew.module.css b/packages/bruno-app/src/components/ResponsePane/TimelinewNew.module.css
new file mode 100644
index 0000000000..1d690fcee0
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/TimelinewNew.module.css
@@ -0,0 +1,7 @@
+.wordWrap {
+ overflow-wrap: anywhere;
+}
+
+.noUserselect {
+ user-select: none;
+}
diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js
index 02edc106ea..79510f4681 100644
--- a/packages/bruno-app/src/components/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/index.js
@@ -16,6 +16,8 @@ import TestResultsLabel from './TestResultsLabel';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
+import { TimelineNew } from 'components/ResponsePane/TimelineNew';
+import { DebugTab } from 'components/ResponsePane/Debug';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const dispatch = useDispatch();
@@ -42,8 +44,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
item={item}
collection={collection}
width={rightPaneWidth}
- data={response.data}
- dataBuffer={response.dataBuffer}
headers={response.headers}
error={response.error}
key={item.filename}
@@ -54,12 +54,22 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return ;
}
case 'timeline': {
- return ;
+ return item.response.isNew ? (
+
+ ) : (
+
+ );
+ }
+ case 'debug': {
+ return item.response.isNew ? (
+
+ ) : (
+ 'Only with new Request method'
+ );
}
case 'tests': {
return ;
}
-
default: {
return 404 | Not found
;
}
@@ -113,6 +123,9 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
selectTab('tests')}>
+ selectTab('debug')}>
+ Debug
+
{!isLoading ? (
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
index 007d398c0f..5cd5eed588 100644
--- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
@@ -1,7 +1,6 @@
import React, { useState } from 'react';
import get from 'lodash/get';
import classnames from 'classnames';
-import { safeStringifyJSON } from 'utils/common';
import QueryResult from 'components/ResponsePane/QueryResult';
import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
import StatusCode from 'components/ResponsePane/StatusCode';
@@ -11,11 +10,13 @@ import Timeline from 'components/ResponsePane/Timeline';
import TestResults from 'components/ResponsePane/TestResults';
import TestResultsLabel from 'components/ResponsePane/TestResultsLabel';
import StyledWrapper from './StyledWrapper';
+import { DebugTab } from 'components/ResponsePane/Debug';
+import { TimelineNew } from 'components/ResponsePane/TimelineNew';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response');
- const { requestSent, responseReceived, testResults, assertionResults } = item;
+ const { requestSent, responseReceived, testResults, assertionResults, isNew, timings, debug, timeline } = item;
const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0);
@@ -33,8 +34,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
collection={collection}
width={rightPaneWidth}
disableRunEventListener={true}
- data={responseReceived.data}
- dataBuffer={responseReceived.dataBuffer}
headers={responseReceived.headers}
key={item.filename}
/>
@@ -44,12 +43,20 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return
;
}
case 'timeline': {
+ if (isNew) {
+ return
;
+ }
return
;
}
case 'tests': {
return
;
}
-
+ case 'debug': {
+ if (isNew) {
+ return
;
+ }
+ return 'Only for new request Method';
+ }
default: {
return
404 | Not found
;
}
@@ -78,6 +85,9 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
selectTab('tests')}>
+
selectTab('debug')}>
+ Debug
+
diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx
index e415aeb3cf..5a3bdd5f5e 100644
--- a/packages/bruno-app/src/components/RunnerResults/index.jsx
+++ b/packages/bruno-app/src/components/RunnerResults/index.jsx
@@ -5,7 +5,7 @@ import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
-import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
+import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons-react';
import slash from 'utils/common/slash';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
deleted file mode 100644
index cd9857a159..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
+++ /dev/null
@@ -1,155 +0,0 @@
-import React, { useRef, useEffect } from 'react';
-import { useDispatch } from 'react-redux';
-import { useFormik } from 'formik';
-import * as Yup from 'yup';
-import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
-import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
-import toast from 'react-hot-toast';
-import Tooltip from 'components/Tooltip';
-import Modal from 'components/Modal';
-
-const CloneCollection = ({ onClose, collection }) => {
- const inputRef = useRef();
- const dispatch = useDispatch();
-
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- collectionName: '',
- collectionFolderName: '',
- collectionLocation: ''
- },
- validationSchema: Yup.object({
- collectionName: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('collection name is required'),
- collectionFolderName: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
- .required('folder name is required'),
- collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
- }),
- onSubmit: (values) => {
- dispatch(
- cloneCollection(
- values.collectionName,
- values.collectionFolderName,
- values.collectionLocation,
- collection.pathname
- )
- )
- .then(() => {
- toast.success('Collection created');
- onClose();
- })
- .catch(() => toast.error('An error occurred while creating the collection'));
- }
- });
-
- const browse = () => {
- dispatch(browseDirectory())
- .then((dirPath) => {
- // When the user closes the diolog without selecting anything dirPath will be false
- if (typeof dirPath === 'string') {
- formik.setFieldValue('collectionLocation', dirPath);
- }
- })
- .catch((error) => {
- formik.setFieldValue('collectionLocation', '');
- console.error(error);
- });
- };
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => formik.handleSubmit();
-
- return (
-
-
-
- );
-};
-
-export default CloneCollection;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
deleted file mode 100644
index 55c2b86dd2..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React, { useRef, useEffect } from 'react';
-import toast from 'react-hot-toast';
-import { useFormik } from 'formik';
-import * as Yup from 'yup';
-import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
-import { isItemAFolder } from 'utils/tabs';
-import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
-
-const CloneCollectionItem = ({ collection, item, onClose }) => {
- const dispatch = useDispatch();
- const isFolder = isItemAFolder(item);
- const inputRef = useRef();
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- name: item.name
- },
- validationSchema: Yup.object({
- name: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('name is required')
- }),
- onSubmit: (values) => {
- dispatch(cloneItem(values.name, item.uid, collection.uid))
- .then(() => {
- onClose();
- })
- .catch((err) => {
- toast.error(err ? err.message : 'An error occurred while cloning the request');
- });
- }
- });
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => formik.handleSubmit();
-
- return (
-
-
-
- );
-};
-
-export default CloneCollectionItem;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/StyledWrapper.js
deleted file mode 100644
index 48b874214e..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/StyledWrapper.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- button.submit {
- color: white;
- background-color: var(--color-background-danger) !important;
- border: inherit !important;
-
- &:hover {
- border: inherit !important;
- }
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
deleted file mode 100644
index 59f19dcb2e..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import React from 'react';
-import Modal from 'components/Modal';
-import { isItemAFolder } from 'utils/tabs';
-import { useDispatch } from 'react-redux';
-import { closeTabs } from 'providers/ReduxStore/slices/tabs';
-import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
-import { recursivelyGetAllItemUids } from 'utils/collections';
-import StyledWrapper from './StyledWrapper';
-
-const DeleteCollectionItem = ({ onClose, item, collection }) => {
- const dispatch = useDispatch();
- const isFolder = isItemAFolder(item);
- const onConfirm = () => {
- dispatch(deleteItem(item.uid, collection.uid)).then(() => {
- if (isFolder) {
- dispatch(
- closeTabs({
- tabUids: recursivelyGetAllItemUids(item.items)
- })
- );
- } else {
- dispatch(
- closeTabs({
- tabUids: [item.uid]
- })
- );
- }
- });
- onClose();
- };
-
- return (
-
-
- Are you sure you want to delete {item.name} ?
-
-
- );
-};
-
-export default DeleteCollectionItem;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
deleted file mode 100644
index 418658f036..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- position: relative;
-
- .copy-to-clipboard {
- position: absolute;
- cursor: pointer;
- top: 10px;
- right: 10px;
- z-index: 10;
- opacity: 0.5;
-
- &:hover {
- opacity: 1;
- }
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
deleted file mode 100644
index 6771f4ced4..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
+++ /dev/null
@@ -1,63 +0,0 @@
-import CodeEditor from 'components/CodeEditor/index';
-import get from 'lodash/get';
-import { HTTPSnippet } from 'httpsnippet';
-import { useTheme } from 'providers/Theme/index';
-import StyledWrapper from './StyledWrapper';
-import { buildHarRequest } from 'utils/codegenerator/har';
-import { useSelector } from 'react-redux';
-import { CopyToClipboard } from 'react-copy-to-clipboard';
-import toast from 'react-hot-toast';
-import { IconCopy } from '@tabler/icons';
-import { findCollectionByItemUid } from '../../../../../../../utils/collections/index';
-import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth';
-
-const CodeView = ({ language, item }) => {
- const { displayedTheme } = useTheme();
- const preferences = useSelector((state) => state.app.preferences);
- const { target, client, language: lang } = language;
- const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
- const collection = findCollectionByItemUid(
- useSelector((state) => state.collections.collections),
- item.uid
- );
-
- const collectionRootAuth = collection?.root?.request?.auth;
- const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');
-
- const headers = [
- ...getAuthHeaders(collectionRootAuth, requestAuth),
- ...(collection?.root?.request?.headers || []),
- ...(requestHeaders || [])
- ];
-
- let snippet = '';
- try {
- snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers })).convert(target, client);
- } catch (e) {
- console.error(e);
- snippet = 'Error generating code snippet';
- }
-
- return (
- <>
-
- toast.success('Copied to clipboard!')}
- >
-
-
-
-
- >
- );
-};
-
-export default CodeView;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
deleted file mode 100644
index 635c545e92..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- margin-inline: -1rem;
- margin-block: -1.5rem;
- background-color: ${(props) => props.theme.collection.environment.settings.bg};
-
- .generate-code-sidebar {
- background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
- border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
- min-height: 400px;
- height: 100%;
- }
-
- .generate-code-item {
- min-width: 150px;
- display: block;
- position: relative;
- cursor: pointer;
- padding: 8px 10px;
- border-left: solid 2px transparent;
- text-decoration: none;
-
- &:hover {
- text-decoration: none;
- background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
- }
- }
-
- .active {
- background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
- border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
- &:hover {
- background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
- }
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
deleted file mode 100644
index a62469910a..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
+++ /dev/null
@@ -1,141 +0,0 @@
-import Modal from 'components/Modal/index';
-import { useState } from 'react';
-import CodeView from './CodeView';
-import StyledWrapper from './StyledWrapper';
-import { isValidUrl } from 'utils/url';
-import { find, get } from 'lodash';
-import { findEnvironmentInCollection } from 'utils/collections';
-import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
-
-const languages = [
- {
- name: 'HTTP',
- target: 'http',
- client: 'http1.1'
- },
- {
- name: 'JavaScript-Fetch',
- target: 'javascript',
- client: 'fetch'
- },
- {
- name: 'Javascript-jQuery',
- target: 'javascript',
- client: 'jquery'
- },
- {
- name: 'Javascript-axios',
- target: 'javascript',
- client: 'axios'
- },
- {
- name: 'Python-Python3',
- target: 'python',
- client: 'python3'
- },
- {
- name: 'Python-Requests',
- target: 'python',
- client: 'requests'
- },
- {
- name: 'PHP',
- target: 'php',
- client: 'curl'
- },
- {
- name: 'Shell-curl',
- target: 'shell',
- client: 'curl'
- },
- {
- name: 'Shell-httpie',
- target: 'shell',
- client: 'httpie'
- }
-];
-
-const GenerateCodeItem = ({ collection, item, onClose }) => {
- const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
- let envVars = {};
- if (environment) {
- const vars = get(environment, 'variables', []);
- envVars = vars.reduce((acc, curr) => {
- acc[curr.name] = curr.value;
- return acc;
- }, {});
- }
-
- const requestUrl =
- get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
-
- // interpolate the url
- const interpolatedUrl = interpolateUrl({
- url: requestUrl,
- envVars,
- collectionVariables: collection.collectionVariables,
- processEnvVars: collection.processEnvVariables
- });
-
- // interpolate the path params
- const finalUrl = interpolateUrlPathParams(
- interpolatedUrl,
- get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
- );
-
- const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
- return (
-
-
-
-
-
- {languages &&
- languages.length &&
- languages.map((language) => (
-
setSelectedLanguage(language)}
- >
- {language.name}
-
- ))}
-
-
-
- {isValidUrl(finalUrl) ? (
-
- ) : (
-
-
-
Invalid URL: {finalUrl}
-
Please check the URL and try again
-
-
- )}
-
-
-
-
- );
-};
-
-export default GenerateCodeItem;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
deleted file mode 100644
index 74b25de478..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import React, { useRef, useEffect } from 'react';
-import { useFormik } from 'formik';
-import * as Yup from 'yup';
-import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
-import { isItemAFolder } from 'utils/tabs';
-import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
-
-const RenameCollectionItem = ({ collection, item, onClose }) => {
- const dispatch = useDispatch();
- const isFolder = isItemAFolder(item);
- const inputRef = useRef();
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- name: item.name
- },
- validationSchema: Yup.object({
- name: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('name is required')
- }),
- onSubmit: async (values) => {
- // if there is unsaved changes in the request,
- // save them before renaming the request
- if (!isFolder && item.draft) {
- await dispatch(saveRequest(item.uid, collection.uid, true));
- }
- dispatch(renameItem(values.name, item.uid, collection.uid));
- onClose();
- }
- });
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => formik.handleSubmit();
-
- return (
-
-
-
- );
-};
-
-export default RenameCollectionItem;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
index 14d7432fa2..9ccd78c070 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
@@ -2,16 +2,7 @@ import styled from 'styled-components';
const Wrapper = styled.div`
.menu-icon {
- color: ${(props) => props.theme.sidebar.dropdownIcon.color};
-
- .dropdown {
- div[aria-expanded='true'] {
- visibility: visible;
- }
- div[aria-expanded='false'] {
- visibility: hidden;
- }
- }
+ visibility: hidden;
}
.indent-block {
@@ -37,11 +28,7 @@ const Wrapper = styled.div`
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.menu-icon {
- .dropdown {
- div[aria-expanded='false'] {
- visibility: visible;
- }
- }
+ visibility: visible;
}
}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
index bf84b8289e..d5d2a9c518 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
@@ -1,29 +1,20 @@
-import React, { useState, useRef, forwardRef, useEffect } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
import range from 'lodash/range';
import filter from 'lodash/filter';
import classnames from 'classnames';
import { useDrag, useDrop } from 'react-dnd';
-import { IconChevronRight, IconDots } from '@tabler/icons';
+import { IconChevronRight } from '@tabler/icons-react';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
-import { moveItem, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { moveItem } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
-import Dropdown from 'components/Dropdown';
-import NewRequest from 'components/Sidebar/NewRequest';
-import NewFolder from 'components/Sidebar/NewFolder';
import RequestMethod from './RequestMethod';
-import RenameCollectionItem from './RenameCollectionItem';
-import CloneCollectionItem from './CloneCollectionItem';
-import DeleteCollectionItem from './DeleteCollectionItem';
-import RunCollectionItem from './RunCollectionItem';
-import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
-import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
-import NetworkError from 'components/ResponsePane/NetworkError/index';
+import { FolderMenu, RequestMenu } from 'src/feature/sidebar-menu';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
@@ -31,16 +22,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
- const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
- const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
- const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
- const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
- const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
- const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
- const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed);
- const [{ isDragging }, drag] = useDrag({
+ const [_, drag] = useDrag({
type: `COLLECTION_ITEM_${collection.uid}`,
item: item,
collect: (monitor) => ({
@@ -72,13 +56,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
}, [searchText, item]);
const dropdownTippyRef = useRef();
- const MenuIcon = forwardRef((props, ref) => {
- return (
-
-
-
- );
- });
const iconClassName = classnames({
'rotate-90': !itemIsCollapsed
@@ -96,15 +73,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
- const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid)).catch((err) =>
- toast.custom((t) =>
toast.dismiss(t.id)} />, {
- duration: 5000
- })
- );
- };
-
- const handleClick = (event) => {
+ const handleClick = () => {
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
@@ -151,7 +120,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
};
let indents = range(item.depth);
- const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', {
@@ -170,50 +138,8 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
}
- // we need to sort request items by seq property
- const sortRequestItems = (items = []) => {
- return items.sort((a, b) => a.seq - b.seq);
- };
-
- // we need to sort folder items by name alphabetically
- const sortFolderItems = (items = []) => {
- return items.sort((a, b) => a.name.localeCompare(b.name));
- };
- const handleGenerateCode = (e) => {
- e.stopPropagation();
- dropdownTippyRef.current.hide();
- if (item.request.url !== '' || (item.draft?.request.url !== undefined && item.draft?.request.url !== '')) {
- setGenerateCodeItemModalOpen(true);
- } else {
- toast.error('URL is required');
- }
- };
- const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
- const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
-
return (
- {renameItemModalOpen && (
- setRenameItemModalOpen(false)} />
- )}
- {cloneItemModalOpen && (
- setCloneItemModalOpen(false)} />
- )}
- {deleteItemModalOpen && (
- setDeleteItemModalOpen(false)} />
- )}
- {newRequestModalOpen && (
- setNewRequestModalOpen(false)} />
- )}
- {newFolderModalOpen && (
- setNewFolderModalOpen(false)} />
- )}
- {runCollectionModalOpen && (
- setRunCollectionModalOpen(false)} />
- )}
- {generateCodeItemModalOpen && (
- setGenerateCodeItemModalOpen(false)} />
- )}
drag(drop(node))}>
{indents && indents.length
@@ -245,7 +171,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
paddingLeft: 8
}}
>
-
-
} placement="bottom-start">
- {isFolder && (
- <>
-
{
- dropdownTippyRef.current.hide();
- setNewRequestModalOpen(true);
- }}
- >
- New Request
-
-
{
- dropdownTippyRef.current.hide();
- setNewFolderModalOpen(true);
- }}
- >
- New Folder
-
-
{
- dropdownTippyRef.current.hide();
- setRunCollectionModalOpen(true);
- }}
- >
- Run
-
- >
- )}
-
{
- dropdownTippyRef.current.hide();
- setRenameItemModalOpen(true);
- }}
- >
- Rename
-
-
{
- dropdownTippyRef.current.hide();
- setCloneItemModalOpen(true);
- }}
- >
- Clone
-
- {!isFolder && (
-
{
- dropdownTippyRef.current.hide();
- handleClick(null);
- handleRun();
- }}
- >
- Run
-
- )}
- {!isFolder && item.type === 'http-request' && (
-
{
- handleGenerateCode(e);
- }}
- >
- Generate Code
-
- )}
-
{
- dropdownTippyRef.current.hide();
- setDeleteItemModalOpen(true);
- }}
- >
- Delete
-
-
+ {isFolder ? (
+
+ ) : (
+
+ )}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
deleted file mode 100644
index 92e2524105..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import exportBrunoCollection from 'utils/collections/export';
-import exportPostmanCollection from 'utils/exporters/postman-collection';
-import { toastError } from 'utils/common/error';
-import cloneDeep from 'lodash/cloneDeep';
-import Modal from 'components/Modal';
-import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
-
-const ExportCollection = ({ onClose, collection }) => {
- const handleExportBrunoCollection = () => {
- const collectionCopy = cloneDeep(collection);
- exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
- onClose();
- };
-
- const handleExportPostmanCollection = () => {
- const collectionCopy = cloneDeep(collection);
- exportPostmanCollection(collectionCopy);
- onClose();
- };
-
- return (
-
-
-
- Bruno Collection
-
-
- Postman Collection
-
-
-
- );
-};
-
-export default ExportCollection;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
deleted file mode 100644
index 9cba09179d..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from 'react';
-import toast from 'react-hot-toast';
-import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
-import { IconFiles } from '@tabler/icons';
-import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
-
-const RemoveCollection = ({ onClose, collection }) => {
- const dispatch = useDispatch();
-
- const onConfirm = () => {
- dispatch(removeCollection(collection.uid))
- .then(() => {
- toast.success('Collection closed');
- onClose();
- })
- .catch(() => toast.error('An error occurred while closing the collection'));
- };
-
- return (
-
-
-
- {collection.name}
-
- {collection.pathname}
-
- Are you sure you want to close collection {collection.name} in Bruno?
-
-
- It will still be available in the file system at the above location and can be re-opened later.
-
-
- );
-};
-
-export default RemoveCollection;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
deleted file mode 100644
index 07a9274155..0000000000
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import React, { useRef, useEffect } from 'react';
-import { useFormik } from 'formik';
-import * as Yup from 'yup';
-import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
-import toast from 'react-hot-toast';
-import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
-
-const RenameCollection = ({ collection, onClose }) => {
- const dispatch = useDispatch();
- const inputRef = useRef();
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- name: collection.name
- },
- validationSchema: Yup.object({
- name: Yup.string()
- .min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .required('name is required')
- }),
- onSubmit: (values) => {
- dispatch(renameCollection(values.name, collection.uid));
- toast.success('Collection renamed!');
- onClose();
- }
- });
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => formik.handleSubmit();
-
- return (
-
-
-
- );
-};
-
-export default RenameCollection;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
index b8e0d21fd7..6a9183a541 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
@@ -13,28 +13,11 @@ const Wrapper = styled.div`
}
.collection-actions {
- .dropdown {
- div[aria-expanded='true'] {
- visibility: visible;
- }
- div[aria-expanded='false'] {
- visibility: hidden;
- }
- }
-
- svg {
- height: 22px;
- color: ${(props) => props.theme.sidebar.dropdownIcon.color};
- }
+ visibility: hidden;
}
-
&:hover {
.collection-actions {
- .dropdown {
- div[aria-expanded='false'] {
- visibility: visible;
- }
- }
+ visibility: visible;
}
}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
index 1c758f2716..e65b9a8430 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
@@ -1,56 +1,24 @@
-import React, { useState, forwardRef, useRef, useEffect } from 'react';
+import React, { useState, useRef, useEffect } from 'react';
import classnames from 'classnames';
-import { uuid } from 'utils/common';
import filter from 'lodash/filter';
import { useDrop } from 'react-dnd';
-import { IconChevronRight, IconDots } from '@tabler/icons';
-import Dropdown from 'components/Dropdown';
+import { IconChevronRight } from '@tabler/icons-react';
import { collectionClicked } from 'providers/ReduxStore/slices/collections';
import { moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
-import { addTab } from 'providers/ReduxStore/slices/tabs';
-import NewRequest from 'components/Sidebar/NewRequest';
-import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
-import RemoveCollection from './RemoveCollection';
-import ExportCollection from './ExportCollection';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
-import { isItemAFolder, isItemARequest, transformCollectionToSaveToExportAsFile } from 'utils/collections';
-import exportCollection from 'utils/collections/export';
+import { isItemAFolder, isItemARequest } from 'utils/collections';
-import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
-import CloneCollection from './CloneCollection/index';
+import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import { CollectionMenu } from 'src/feature/sidebar-menu';
const Collection = ({ collection, searchText }) => {
- const [showNewFolderModal, setShowNewFolderModal] = useState(false);
- const [showNewRequestModal, setShowNewRequestModal] = useState(false);
- const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
- const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
- const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
- const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed);
const dispatch = useDispatch();
const menuDropdownTippyRef = useRef();
- const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
- const MenuIcon = forwardRef((props, ref) => {
- return (
-
-
-
- );
- });
-
- const handleRun = () => {
- dispatch(
- addTab({
- uid: uuid(),
- collectionUid: collection.uid,
- type: 'collection-runner'
- })
- );
- };
useEffect(() => {
if (searchText && searchText.length) {
@@ -66,6 +34,21 @@ const Collection = ({ collection, searchText }) => {
const handleClick = (event) => {
dispatch(collectionClicked(collection.uid));
+
+ // if collection doesn't have any active environment
+ // try to load last selected environment
+ if (!collection.activeEnvironmentUid) {
+ window.ipcRenderer
+ .invoke('renderer:get-last-selected-environment', collection.uid)
+ .then((lastSelectedEnvName) => {
+ const collectionEnvironments = collection.environments || [];
+ const lastSelectedEnvironment = collectionEnvironments.find((env) => env.name === lastSelectedEnvName);
+
+ if (lastSelectedEnvironment) {
+ dispatch(selectEnvironment(lastSelectedEnvironment.uid, collection.uid));
+ }
+ });
+ }
};
const handleRightClick = (event) => {
@@ -79,16 +62,6 @@ const Collection = ({ collection, searchText }) => {
}
};
- const viewCollectionSettings = () => {
- dispatch(
- addTab({
- uid: uuid(),
- collectionUid: collection.uid,
- type: 'collection-settings'
- })
- );
- };
-
const [{ isOver }, drop] = useDrop({
accept: `COLLECTION_ITEM_${collection.uid}`,
drop: (draggedItem) => {
@@ -124,20 +97,6 @@ const Collection = ({ collection, searchText }) => {
return (
- {showNewRequestModal && setShowNewRequestModal(false)} />}
- {showNewFolderModal && setShowNewFolderModal(false)} />}
- {showRenameCollectionModal && (
- setShowRenameCollectionModal(false)} />
- )}
- {showRemoveCollectionModal && (
- setShowRemoveCollectionModal(false)} />
- )}
- {showExportCollectionModal && (
- setShowExportCollectionModal(false)} />
- )}
- {showCloneCollectionModalOpen && (
- setShowCloneCollectionModalOpen(false)} />
- )}
-
} placement="bottom-start">
-
{
- menuDropdownTippyRef.current.hide();
- setShowNewRequestModal(true);
- }}
- >
- New Request
-
-
{
- menuDropdownTippyRef.current.hide();
- setShowNewFolderModal(true);
- }}
- >
- New Folder
-
-
{
- menuDropdownTippyRef.current.hide();
- setShowCloneCollectionModalOpen(true);
- }}
- >
- Clone
-
-
{
- menuDropdownTippyRef.current.hide();
- handleRun();
- }}
- >
- Run
-
-
{
- menuDropdownTippyRef.current.hide();
- setShowRenameCollectionModal(true);
- }}
- >
- Rename
-
-
{
- menuDropdownTippyRef.current.hide();
- setShowExportCollectionModal(true);
- }}
- >
- Export
-
-
{
- menuDropdownTippyRef.current.hide();
- setShowRemoveCollectionModal(true);
- }}
- >
- Close
-
-
{
- menuDropdownTippyRef.current.hide();
- viewCollectionSettings();
- }}
- >
- Settings
-
-
+
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js
index 525109b370..1705ef06fa 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js
@@ -1,6 +1,6 @@
import React from 'react';
import Modal from 'components/Modal/index';
-import { IconFiles } from '@tabler/icons';
+import { IconFiles } from '@tabler/icons-react';
import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js
index e5a657ef95..01f0f3bb64 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -5,9 +5,8 @@ import {
IconFolders,
IconArrowsSort,
IconSortAscendingLetters,
- IconSortDescendingLetters,
- IconX
-} from '@tabler/icons';
+ IconSortDescendingLetters
+} from '@tabler/icons-react';
import Collection from '../Collections/Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
@@ -15,13 +14,14 @@ import CreateOrOpenCollection from './CreateOrOpenCollection';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
+import { ActionIcon, CloseButton, Input, Group, Tooltip, rem } from '@mantine/core';
// todo: move this to a separate folder
// the coding convention is to keep all the components in a folder named after the component
const CollectionsBadge = () => {
const dispatch = useDispatch();
- const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
+
const sortCollectionOrder = () => {
let order;
switch (collectionSortOrder) {
@@ -37,28 +37,24 @@ const CollectionsBadge = () => {
}
dispatch(sortCollections({ order }));
};
+
return (
-
-
-
-
-
-
- Collections
-
- {collections.length >= 1 && (
-
sortCollectionOrder()}>
- {collectionSortOrder == 'default' ? (
-
- ) : collectionSortOrder == 'alphabetical' ? (
-
- ) : (
-
- )}
-
+
+
+ {collectionSortOrder == 'default' ? (
+
+ ) : collectionSortOrder == 'alphabetical' ? (
+
+ ) : (
+
)}
-
-
+
+
);
};
@@ -70,7 +66,6 @@ const Collections = () => {
if (!collections || !collections.length) {
return (
-
);
@@ -80,42 +75,28 @@ const Collections = () => {
{createCollectionModalOpen ? setCreateCollectionModalOpen(false)} /> : null}
-
-
-
-
-
-
-
-
-
+
setSearchText(e.target.value.toLowerCase())}
+ placeholder={'Search for request'}
+ onChange={(evt) => setSearchText(evt.currentTarget.value)}
+ flex={1}
+ size="xs"
+ leftSection={
}
+ rightSectionPointerEvents="all"
+ rightSection={
+
setSearchText('')}
+ style={{ display: searchText ? undefined : 'none' }}
+ />
+ }
/>
- {searchText !== '' && (
-
- {
- setSearchText('');
- }}
- >
-
-
-
- )}
-
-
+
+
+
+
{collections && collections.length
? collections.map((c) => {
return (
diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
index 168c922cde..d4ac77a4ba 100644
--- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
@@ -7,6 +7,7 @@ import { createCollection } from 'providers/ReduxStore/slices/collections/action
import toast from 'react-hot-toast';
import Tooltip from 'components/Tooltip';
import Modal from 'components/Modal';
+import { dirnameRegex } from 'utils/common/regex';
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
@@ -25,9 +26,10 @@ const CreateCollection = ({ onClose }) => {
.max(50, 'must be 50 characters or less')
.required('collection name is required'),
collectionFolderName: Yup.string()
+ .trim()
+ .matches(dirnameRegex, 'Folder name contains invalid characters')
+ .max(250, 'must be 250 characters or less')
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
.required('folder name is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
diff --git a/packages/bruno-app/src/components/Sidebar/GoldenEdition/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/GoldenEdition/StyledWrapper.js
deleted file mode 100644
index 0d62440d70..0000000000
--- a/packages/bruno-app/src/components/Sidebar/GoldenEdition/StyledWrapper.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- color: ${(props) => props.theme.text};
- .collection-options {
- svg {
- position: relative;
- top: -1px;
- }
-
- .label {
- cursor: pointer;
- &:hover {
- text-decoration: underline;
- }
- }
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js b/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
deleted file mode 100644
index 4335bc2359..0000000000
--- a/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
+++ /dev/null
@@ -1,210 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import Modal from 'components/Modal/index';
-import { PostHog } from 'posthog-node';
-import { uuid } from 'utils/common';
-import { IconHeart, IconUser, IconUsers, IconPlus } from '@tabler/icons';
-import platformLib from 'platform';
-import StyledWrapper from './StyledWrapper';
-import { useTheme } from 'providers/Theme/index';
-
-let posthogClient = null;
-const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
-const getPosthogClient = () => {
- if (posthogClient) {
- return posthogClient;
- }
-
- posthogClient = new PostHog(posthogApiKey);
- return posthogClient;
-};
-const getAnonymousTrackingId = () => {
- let id = localStorage.getItem('bruno.anonymousTrackingId');
-
- if (!id || !id.length || id.length !== 21) {
- id = uuid();
- localStorage.setItem('bruno.anonymousTrackingId', id);
- }
-
- return id;
-};
-
-const HeartIcon = () => {
- return (
-
-
-
- );
-};
-
-const CheckIcon = () => {
- return (
-
-
-
- );
-};
-
-const GoldenEdition = ({ onClose }) => {
- const { displayedTheme } = useTheme();
-
- useEffect(() => {
- const anonymousId = getAnonymousTrackingId();
- const client = getPosthogClient();
- client.capture({
- distinctId: anonymousId,
- event: 'golden-edition-modal-opened',
- properties: {
- os: platformLib.os.family
- }
- });
- }, []);
-
- const goldenEditionBuyClick = () => {
- const anonymousId = getAnonymousTrackingId();
- const client = getPosthogClient();
- client.capture({
- distinctId: anonymousId,
- event: 'golden-edition-buy-clicked',
- properties: {
- os: platformLib.os.family
- }
- });
- };
-
- const goldenEditonIndividuals = [
- 'Inbuilt Bru File Explorer',
- 'Visual Git (Like Gitlens for Vscode)',
- 'GRPC, Websocket, SocketIO, MQTT',
- 'Load Data from File for Collection Run',
- 'Developer Tools',
- 'OpenAPI Designer',
- 'Performance/Load Testing',
- 'Inbuilt Terminal',
- 'Custom Themes'
- ];
-
- const goldenEditonOrganizations = [
- 'Centralized License Management',
- 'Integration with Secret Managers',
- 'Private Collection Registry',
- 'Request Forms',
- 'Priority Support'
- ];
-
- const [pricingOption, setPricingOption] = useState('individuals');
-
- const handlePricingOptionChange = (option) => {
- setPricingOption(option);
- };
-
- const themeBasedContainerClassNames = displayedTheme === 'light' ? 'text-gray-900' : 'text-white';
- const themeBasedTabContainerClassNames = displayedTheme === 'light' ? 'bg-gray-200' : 'bg-gray-800';
- const themeBasedActiveTabClassNames =
- displayedTheme === 'light' ? 'bg-white text-gray-900 font-medium' : 'bg-gray-700 text-white font-medium';
-
- return (
-
-
-
-
- {pricingOption === 'individuals' ? (
-
-
- $19
-
-
One Time Payment
-
perpetual license for 2 devices, with 2 years of updates
-
- ) : (
-
-
- $49
- / user
-
-
One Time Payment
-
perpetual license with 2 years of updates
-
- )}
-
-
handlePricingOptionChange('individuals')}
- >
- Individuals
-
-
handlePricingOptionChange('organizations')}
- >
- Organizations
-
-
-
-
-
- Support Bruno's Development
-
- {pricingOption === 'individuals' ? (
- <>
- {goldenEditonIndividuals.map((item, index) => (
-
-
- {item}
-
- ))}
- >
- ) : (
- <>
-
-
- Everything in the Individual Plan
-
- {goldenEditonOrganizations.map((item, index) => (
-
-
- {item}
-
- ))}
- >
- )}
-
-
-
-
- );
-};
-
-export default GoldenEdition;
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
index d829baf103..cb6dc6611c 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
@@ -3,8 +3,8 @@ import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
-import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
+import { useMutation } from '@tanstack/react-query';
const ImportCollection = ({ onClose, handleSubmit }) => {
const [options, setOptions] = useState({
@@ -15,37 +15,31 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
"When enabled, Bruno will try as best to translate the scripts from the imported collection to Bruno's format."
}
});
- const handleImportBrunoCollection = () => {
- importBrunoCollection()
- .then(({ collection }) => {
- handleSubmit({ collection });
- })
- .catch((err) => toastError(err, 'Import collection failed'));
- };
-
- const handleImportPostmanCollection = () => {
- importPostmanCollection(options)
- .then(({ collection, translationLog }) => {
- handleSubmit({ collection, translationLog });
- })
- .catch((err) => toastError(err, 'Postman Import collection failed'));
- };
- const handleImportInsomniaCollection = () => {
- importInsomniaCollection()
- .then(({ collection }) => {
- handleSubmit({ collection });
- })
- .catch((err) => toastError(err, 'Insomnia Import collection failed'));
- };
+ const importCollection = useMutation({
+ mutationFn: async (type) => {
+ switch (type) {
+ case 'bruno':
+ handleSubmit(await importBrunoCollection());
+ break;
+ case 'postman':
+ handleSubmit(await importPostmanCollection(options));
+ break;
+ case 'insomnia':
+ handleSubmit(await importInsomniaCollection());
+ break;
+ case 'openapi':
+ handleSubmit(await importOpenapiCollection());
+ break;
+ default:
+ throw new Error(`Unknown import type: "${type}"`);
+ }
+ },
+ onSuccess: () => {
+ onClose();
+ }
+ });
- const handleImportOpenapiCollection = () => {
- importOpenapiCollection()
- .then(({ collection }) => {
- handleSubmit({ collection });
- })
- .catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
- };
const toggleOptions = (event, optionKey) => {
setOptions({
...options,
@@ -55,11 +49,14 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
}
});
};
- const CollectionButton = ({ children, className, onClick }) => {
+
+ const CollectionButton = ({ children, className, type }) => {
return (
{
+ importCollection.mutate(type);
+ }}
className={`rounded bg-transparent px-2.5 py-1 text-xs font-semibold text-slate-900 dark:text-slate-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
@@ -67,15 +64,23 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
);
};
+
return (
-
+
-
Select the type of your existing collection :
+
Select the type of your existing collection:
- Bruno Collection
- Postman Collection
- Insomnia Collection
- OpenAPI V3 Spec
+ Bruno Collection
+ Postman Collection
+ Insomnia Collection
+ OpenAPI V3 Spec
{Object.entries(options || {}).map(([key, option]) => (
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
index 4211e8ff16..ac68cfd89d 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
@@ -4,7 +4,7 @@ import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
-import { IconAlertTriangle, IconArrowRight, IconCaretDown, IconCaretRight, IconCopy } from '@tabler/icons';
+import { IconAlertTriangle, IconCaretDown, IconCaretRight, IconCopy } from '@tabler/icons-react';
import toast from 'react-hot-toast';
const TranslationLog = ({ translationLog }) => {
diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
deleted file mode 100644
index 934a3bd29e..0000000000
--- a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import React, { useRef, useEffect } from 'react';
-import { useFormik } from 'formik';
-import toast from 'react-hot-toast';
-import * as Yup from 'yup';
-import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
-import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
-
-const NewFolder = ({ collection, item, onClose }) => {
- const dispatch = useDispatch();
- const inputRef = useRef();
- const formik = useFormik({
- enableReinitialize: true,
- initialValues: {
- folderName: ''
- },
- validationSchema: Yup.object({
- folderName: Yup.string()
- .trim()
- .min(1, 'must be at least 1 character')
- .required('name is required')
- .test({
- name: 'folderName',
- message: 'The folder name "environments" at the root of the collection is reserved in bruno',
- test: (value) => {
- if (item && item.uid) {
- return true;
- }
- return value && !value.trim().toLowerCase().includes('environments');
- }
- })
- }),
- onSubmit: (values) => {
- dispatch(newFolder(values.folderName, collection.uid, item ? item.uid : null))
- .then(() => onClose())
- .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the folder'));
- }
- });
-
- useEffect(() => {
- if (inputRef && inputRef.current) {
- inputRef.current.focus();
- }
- }, [inputRef]);
-
- const onSubmit = () => formik.handleSubmit();
-
- return (
-
-
-
- );
-};
-
-export default NewFolder;
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
index 8d8125e949..acaaaec2d8 100644
--- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
+++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
@@ -1,7 +1,7 @@
import React, { useRef, useEffect, useCallback } from 'react';
+import { useMutation } from '@tanstack/react-query';
import { useFormik } from 'formik';
import * as Yup from 'yup';
-import toast from 'react-hot-toast';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
@@ -12,6 +12,7 @@ import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelect
import { getDefaultRequestPaneTab } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { getRequestFromCurlCommand } from 'utils/curl';
+import toast from 'components/Toast';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
@@ -20,23 +21,78 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
- const getRequestType = (collectionPresets) => {
- if (!collectionPresets || !collectionPresets.requestType) {
- return 'http-request';
+ const createRequest = useMutation({
+ mutationFn: async (values) => {
+ // TODO: Is always false, remove?
+ if (isEphemeral) {
+ const uid = uuid();
+ await dispatch(
+ newEphemeralHttpRequest({
+ uid: uid,
+ requestName: values.requestName,
+ requestType: values.requestType,
+ requestUrl: values.requestUrl,
+ requestMethod: values.requestMethod,
+ collectionUid: collection.uid
+ })
+ );
+ await dispatch(
+ addTab({
+ uid: uid,
+ collectionUid: collection.uid,
+ requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
+ })
+ );
+ return;
+ }
+
+ switch (values.requestType) {
+ case 'from-curl':
+ const request = getRequestFromCurlCommand(values.curlCommand);
+ dispatch(
+ newHttpRequest({
+ requestName: values.requestName,
+ requestType: 'http-request',
+ requestUrl: request.url,
+ requestMethod: request.method,
+ collectionUid: collection.uid,
+ itemUid: item ? item.uid : null,
+ headers: request.headers,
+ body: request.body
+ })
+ );
+ return;
+ case 'http-request':
+ case 'graphql-request':
+ await dispatch(
+ newHttpRequest({
+ requestName: values.requestName,
+ requestType: values.requestType,
+ requestUrl: values.requestUrl,
+ requestMethod: values.requestMethod,
+ collectionUid: collection.uid,
+ itemUid: item ? item.uid : null
+ })
+ );
+ return;
+ default:
+ throw new Error(`Unknown request type: "${values.requestType}"`);
+ }
+ },
+ onSuccess: () => {
+ onClose();
+ toast.success(`New request created`);
}
+ });
+ const getInitialRequestType = (collectionPresets = {}) => {
// Note: Why different labels for the same thing?
// http-request and graphql-request are used inside the app's json representation of a request
// http and graphql are used in Bru DSL as well as collection exports
// We need to eventually standardize the app's DSL to use the same labels as bru DSL
- if (collectionPresets.requestType === 'http') {
- return 'http-request';
- }
-
if (collectionPresets.requestType === 'graphql') {
return 'graphql-request';
}
-
return 'http-request';
};
@@ -44,7 +100,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
enableReinitialize: true,
initialValues: {
requestName: '',
- requestType: getRequestType(collectionPresets),
+ requestType: getInitialRequestType(collectionPresets),
requestUrl: collectionPresets.requestUrl || '',
requestMethod: 'GET',
curlCommand: ''
@@ -52,8 +108,8 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
validationSchema: Yup.object({
requestName: Yup.string()
.trim()
- .min(1, 'must be at least 1 character')
- .required('name is required')
+ .min(1, 'Name must be at least 1 character long')
+ .required('Name is required')
.test({
name: 'requestName',
message: `The request names - collection and folder is reserved in bruno`,
@@ -74,61 +130,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
})
})
}),
- onSubmit: (values) => {
- if (isEphemeral) {
- const uid = uuid();
- dispatch(
- newEphemeralHttpRequest({
- uid: uid,
- requestName: values.requestName,
- requestType: values.requestType,
- requestUrl: values.requestUrl,
- requestMethod: values.requestMethod,
- collectionUid: collection.uid
- })
- )
- .then(() => {
- dispatch(
- addTab({
- uid: uid,
- collectionUid: collection.uid,
- requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
- })
- );
- onClose();
- })
- .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
- } else if (values.requestType === 'from-curl') {
- const request = getRequestFromCurlCommand(values.curlCommand);
- dispatch(
- newHttpRequest({
- requestName: values.requestName,
- requestType: 'http-request',
- requestUrl: request.url,
- requestMethod: request.method,
- collectionUid: collection.uid,
- itemUid: item ? item.uid : null,
- headers: request.headers,
- body: request.body
- })
- )
- .then(() => onClose())
- .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
- } else {
- dispatch(
- newHttpRequest({
- requestName: values.requestName,
- requestType: values.requestType,
- requestUrl: values.requestUrl,
- requestMethod: values.requestMethod,
- collectionUid: collection.uid,
- itemUid: item ? item.uid : null
- })
- )
- .then(() => onClose())
- .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
- }
- }
+ onSubmit: createRequest.mutate
});
useEffect(() => {
@@ -160,7 +162,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
return (
-
+
-
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js
index dfa9ec6a48..50498f61a0 100644
--- a/packages/bruno-app/src/components/Sidebar/index.js
+++ b/packages/bruno-app/src/components/Sidebar/index.js
@@ -1,17 +1,14 @@
import TitleBar from './TitleBar';
import Collections from './Collections';
import StyledWrapper from './StyledWrapper';
-import GitHubButton from 'react-github-btn';
import Preferences from 'components/Preferences';
import Cookies from 'components/Cookies';
-import GoldenEdition from './GoldenEdition';
import { useState, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
-import { IconSettings, IconCookie, IconHeart } from '@tabler/icons';
+import { IconSettings, IconCookie } from '@tabler/icons-react';
+import { ActionIcon, Group, Tooltip, rem } from '@mantine/core';
import { updateLeftSidebarWidth, updateIsDragging, showPreferences } from 'providers/ReduxStore/slices/app';
-import { useTheme } from 'providers/Theme';
-import Notifications from 'components/Notifications';
const MIN_LEFT_SIDEBAR_WIDTH = 221;
const MAX_LEFT_SIDEBAR_WIDTH = 600;
@@ -19,13 +16,10 @@ const MAX_LEFT_SIDEBAR_WIDTH = 600;
const Sidebar = () => {
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const preferencesOpen = useSelector((state) => state.app.showPreferences);
- const [goldenEditonOpen, setGoldenEditonOpen] = useState(false);
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
const [cookiesOpen, setCookiesOpen] = useState(false);
- const { storedTheme } = useTheme();
-
const dispatch = useDispatch();
const [dragging, setDragging] = useState(false);
@@ -82,7 +76,6 @@ const Sidebar = () => {
return (
- {goldenEditonOpen && setGoldenEditonOpen(false)} />}
{preferencesOpen &&
dispatch(showPreferences(false))} />}
{cookiesOpen && setCookiesOpen(false)} />}
@@ -93,44 +86,28 @@ const Sidebar = () => {
-
-
-
- {/* This will get moved to home page */}
- {/*
- Star
- */}
-
-
v1.18.0
-
+
+
+
+ dispatch(showPreferences(true))}
+ >
+
+
+
+
+
+ setCookiesOpen(true)}>
+
+
+
+
+
+ v1.18.0-lazer
+
diff --git a/packages/bruno-app/src/components/VariablesEditor/index.js b/packages/bruno-app/src/components/VariablesEditor/index.js
index 980a8c5c35..1a8ef21136 100644
--- a/packages/bruno-app/src/components/VariablesEditor/index.js
+++ b/packages/bruno-app/src/components/VariablesEditor/index.js
@@ -5,7 +5,7 @@ import { Inspector } from 'react-inspector';
import { useTheme } from 'providers/Theme';
import { findEnvironmentInCollection, maskInputValue } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
-import { IconEye, IconEyeOff } from '@tabler/icons';
+import { IconEye, IconEyeOff } from '@tabler/icons-react';
const KeyValueExplorer = ({ data = [], theme }) => {
const [showSecret, setShowSecret] = useState(false);
diff --git a/packages/bruno-app/src/components/Welcome/index.js b/packages/bruno-app/src/components/Welcome/index.js
index 385a714869..85589a6144 100644
--- a/packages/bruno-app/src/components/Welcome/index.js
+++ b/packages/bruno-app/src/components/Welcome/index.js
@@ -2,7 +2,16 @@ import { useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
-import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
+import {
+ IconBrandGithub,
+ IconPlus,
+ IconDownload,
+ IconFolders,
+ IconSpeakerphone,
+ IconBook,
+ IconCopyleft
+} from '@tabler/icons-react';
+import { Blockquote } from '@mantine/core';
import Bruno from 'components/Bruno';
import CreateCollection from 'components/Sidebar/CreateCollection';
@@ -97,18 +106,27 @@ const Welcome = () => {
+ } mt="xl" iconSize={36}>
+ Parts of Bruno-lazer are licensed under the GNU General Public License version 3 (GPL-3).
+
+ You can view the full corresponding source code at:{' '}
+
+ https://github.com/its-treason/bruno
+
+ .
+
);
};
diff --git a/packages/bruno-app/src/components/inputs/DirectoryPicker.tsx b/packages/bruno-app/src/components/inputs/DirectoryPicker.tsx
new file mode 100644
index 0000000000..e2b5d6dc77
--- /dev/null
+++ b/packages/bruno-app/src/components/inputs/DirectoryPicker.tsx
@@ -0,0 +1,37 @@
+import { Anchor, TextInput, TextInputProps, rem } from '@mantine/core';
+import React, { ChangeEvent, useCallback, useRef } from 'react';
+import toast from 'react-hot-toast';
+
+type DirectoryPickerProps = Omit & {
+ onChange: (change: ChangeEvent | string) => void;
+};
+
+export const DirectoryPicker: React.FC = (props) => {
+ const inputRef = useRef(null);
+
+ const onBrowse = useCallback(() => {
+ // This will block any user input until the dialog os closes
+ // @ts-expect-error
+ window.ipcRenderer
+ .invoke('renderer:browse-directory')
+ .then((selectedPath: unknown) => {
+ if (typeof selectedPath === 'string' && props.onChange) {
+ props.onChange(selectedPath);
+ inputRef.current.value = selectedPath;
+ }
+ })
+ .catch((error: unknown) => {
+ toast.error(`Could not browse for directory: ${error}`);
+ });
+ }, [inputRef, props.onChange]);
+
+ return (
+ Browse}
+ rightSectionPointerEvents="all"
+ rightSectionWidth={rem(75)}
+ />
+ );
+};
diff --git a/packages/bruno-app/src/feature/code-generator/components/CodeGenerator.tsx b/packages/bruno-app/src/feature/code-generator/components/CodeGenerator.tsx
new file mode 100644
index 0000000000..de12eb1aac
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/components/CodeGenerator.tsx
@@ -0,0 +1,50 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import React from 'react';
+import { useGenerateCode } from '../hooks/useGenerateCode';
+import { Alert, Button, CopyButton, rem } from '@mantine/core';
+import CodeEditor from 'components/CodeEditor';
+import { useDebouncedValue } from '@mantine/hooks';
+import { IconClipboard, IconClipboardCheck, IconClipboardCopy } from '@tabler/icons-react';
+
+type CodeGeneratorProps = {
+ collectionUid: string;
+ requestUid: string;
+ targetId: string;
+ clientId: string;
+};
+
+export const CodeGenerator: React.FC = ({ clientId, collectionUid, requestUid, targetId }) => {
+ // Debounce values here, to prevent flickering if invalid values are in state
+ const [debounced] = useDebouncedValue({ clientId, targetId }, 50);
+ const generateCodeResult = useGenerateCode(collectionUid, requestUid, debounced.targetId, debounced.clientId);
+
+ if (generateCodeResult.success === false) {
+ return (
+
+ {generateCodeResult.error}
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {({ copied, copy }) => (
+ : }
+ right={0}
+ mt={'xs'}
+ onClick={copy}
+ >
+ {copied ? 'Copied code' : 'Copy code'}
+
+ )}
+
+ >
+ );
+};
diff --git a/packages/bruno-app/src/feature/code-generator/components/CodeGeneratorModal.tsx b/packages/bruno-app/src/feature/code-generator/components/CodeGeneratorModal.tsx
new file mode 100644
index 0000000000..ae87715f26
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/components/CodeGeneratorModal.tsx
@@ -0,0 +1,40 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Modal } from '@mantine/core';
+import React from 'react';
+import { LanguageClientSelector } from './LanguageClientSelector';
+import { useLanguageClient } from '../hooks/useLangugeClient';
+import { CodeGenerator } from './CodeGenerator';
+
+type CodeGeneratorModalProps = {
+ requestUid?: string;
+ collectionUid?: string;
+ opened: boolean;
+ onClose: () => void;
+};
+
+export const CodeGeneratorModal: React.FC = ({
+ collectionUid,
+ requestUid,
+ opened,
+ onClose
+}) => {
+ const { clientId, setClientId, setTargetId, targetId } = useLanguageClient();
+
+ return (
+
+
+
+ {requestUid !== undefined && collectionUid !== undefined ? (
+
+ ) : null}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/code-generator/components/LanguageClientSelector.tsx b/packages/bruno-app/src/feature/code-generator/components/LanguageClientSelector.tsx
new file mode 100644
index 0000000000..1438e80f0e
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/components/LanguageClientSelector.tsx
@@ -0,0 +1,142 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Group, Select, Stack, Tabs, Text } from '@mantine/core';
+import { useEffect, useMemo } from 'react';
+
+const options = {
+ shell: ['curl', 'httpie', 'wget'],
+ powershell: ['restmethod', 'webrequest'],
+ c: ['libcurl'],
+ clojure: ['clj_http'],
+ csharp: ['httpclient', 'restsharp'],
+ go: ['native'],
+ http: ['http1.1'],
+ java: ['asynchttp', 'nethttp', 'okhttp', 'unirest'],
+ javascript: ['fetch', 'axios', 'jquery', 'xhr'],
+ kotlin: ['okhttp'],
+ node: ['fetch', 'native', 'axios', 'request', 'unirest'],
+ objc: ['nsurlsession'],
+ ocaml: ['cohttp'],
+ php: ['curl', 'guzzle', 'http1', 'http2'],
+ python: ['requests'],
+ r: ['httr'],
+ ruby: ['native'],
+ swift: ['urlsession']
+} as const;
+
+type OptionKeys = keyof typeof options | (typeof options)[keyof typeof options][number];
+const labels: Record = {
+ c: 'C',
+ libcurl: 'cURL',
+ clj_http: 'clj-http',
+ httpclient: 'HTTP Client',
+ restsharp: 'RestSharp',
+ native: 'Native',
+ 'http1.1': 'HTTP 1.1',
+ asynchttp: 'AsyncHttp',
+ nethttp: 'NetHttp',
+ okhttp: 'OkHttp',
+ unirest: 'Unirest',
+ fetch: 'fetch',
+ axios: 'Axios',
+ jquery: 'jQuery',
+ xhr: 'XHR',
+ request: 'Request',
+ nsurlsession: 'NSURLSession',
+ cohttp: 'Cohttp',
+ php: 'PHP',
+ curl: 'cURL',
+ guzzle: 'Guzzle',
+ http1: 'HttpRequest',
+ http2: 'pecl_http',
+ restmethod: 'RestMethod',
+ webrequest: 'WebRequest',
+ requests: 'requests',
+ httr: 'httr',
+ httpie: 'HTTPie',
+ wget: 'Wget',
+ urlsession: 'URLSession',
+ clojure: 'Clojure',
+ csharp: 'C#',
+ go: 'Go',
+ http: 'HTTP',
+ java: 'Java',
+ javascript: 'JavaScript',
+ kotlin: 'Kotlin',
+ node: 'Node.js',
+ objc: 'Objective-C',
+ ocaml: 'Ocaml',
+ powershell: 'PowerShell',
+ python: 'Python',
+ r: 'R',
+ ruby: 'Ruby',
+ shell: 'Shell',
+ swift: 'Swift'
+};
+
+type LanguageClientSelectorProps = {
+ targetId: string; // TargetId === Language
+ setTargetId: (newTargetId: string) => void;
+ clientId: string;
+ setClientId: (newClientId: string) => void;
+};
+
+export const LanguageClientSelector: React.FC = ({
+ clientId,
+ setClientId,
+ setTargetId,
+ targetId
+}) => {
+ const selectOptions = useMemo(() => {
+ return Object.keys(options).map((targetId) => ({
+ value: targetId,
+ label: labels[targetId] ?? targetId
+ }));
+ }, []);
+
+ const tabOptions = useMemo(() => {
+ const clientIds = options[targetId] ?? [];
+
+ return clientIds.map((clientId) => ({
+ value: clientId,
+ label: labels[clientId] ?? clientId
+ }));
+ }, [targetId]);
+
+ useEffect(() => {
+ if (clientId === '' && options[targetId]) {
+ setClientId(options[targetId][0]);
+ }
+ }, [clientId]);
+
+ return (
+
+ {
+ setTargetId(newValue);
+ }}
+ />
+
+
+ Library
+ setClientId(newClientId)} variant={'pills'}>
+
+ {tabOptions.map(({ value, label }) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/code-generator/hooks/useGenerateCode.ts b/packages/bruno-app/src/feature/code-generator/hooks/useGenerateCode.ts
new file mode 100644
index 0000000000..0d59869356
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/hooks/useGenerateCode.ts
@@ -0,0 +1,122 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { HTTPSnippet } from '@readme/httpsnippet';
+import { CollectionSchema } from '@usebruno/schema';
+import { get } from 'lodash';
+import { useMemo } from 'react';
+import { useSelector } from 'react-redux';
+import { findCollectionByUid, findEnvironmentInCollection, findItemInCollection } from 'utils/collections';
+import { interpolateUrl, interpolateUrlPathParams } from 'utils/url';
+import { buildHarRequest } from '../util/har';
+import { getAuthHeaders } from '../util/auth';
+import { interpolate } from '@usebruno/common';
+
+type GenerateCodeResult =
+ | {
+ success: true;
+ code: string;
+ }
+ | {
+ success: false;
+ error: string;
+ };
+
+type ReduxStore = { collections: { collections: CollectionSchema[] } };
+
+export function useGenerateCode(
+ collectionId: string,
+ requestId: string,
+ targetId: string,
+ clientId: string
+): GenerateCodeResult {
+ const collection: CollectionSchema = useSelector((store: ReduxStore) =>
+ findCollectionByUid(store.collections.collections, collectionId)
+ );
+
+ return useMemo((): GenerateCodeResult => {
+ if (!collection) {
+ return {
+ success: false,
+ error: `Could not find collection with ID: ${collectionId}. (This is a Bug)`
+ };
+ }
+ const item = findItemInCollection(collection, requestId);
+ if (!item) {
+ return {
+ success: false,
+ error: `Could not find request with ID: ${requestId}. (This is a Bug)`
+ };
+ }
+
+ const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
+ let envVars = {};
+ if (environment) {
+ const vars = get(environment, 'variables', []);
+ envVars = vars.reduce((acc, curr) => {
+ acc[curr.name] = curr.value;
+ return acc;
+ }, {});
+ }
+
+ const requestUrl =
+ get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
+
+ // interpolate the url
+ const interpolatedUrl = interpolateUrl({
+ url: requestUrl,
+ envVars,
+ collectionVariables: collection.collectionVariables,
+ processEnvVars: collection.processEnvVariables
+ });
+
+ // interpolate the path params
+ const finalUrl = interpolateUrlPathParams(
+ interpolatedUrl,
+ get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
+ );
+
+ const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
+
+ const collectionRootAuth = collection?.root?.request?.auth;
+ const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');
+
+ const headers = [
+ ...getAuthHeaders(collectionRootAuth, requestAuth),
+ ...(collection?.root?.request?.headers || []),
+ ...(requestHeaders || [])
+ ];
+
+ const harRequest = buildHarRequest({ request: item.request, headers });
+ try {
+ // @ts-expect-error TargetId as a type is not exposed from the lib
+ const code = new HTTPSnippet(harRequest).convert(targetId, clientId);
+ if (!code) {
+ return {
+ success: false,
+ error: 'Could not generate snippet. Unknown error'
+ };
+ }
+ return {
+ success: true,
+ // The generated snippet can still container variable placeholder
+ code: interpolate(code[0], {
+ ...envVars,
+ ...collection.collectionVariables,
+ process: {
+ env: {
+ ...collection.processEnvVariables
+ }
+ }
+ })
+ };
+ } catch (e) {
+ console.error('Could not generate snippet! Generator threw an error', e);
+ return {
+ success: false,
+ error: `Could not generate snippet! Generator threw an error: ${e}`
+ };
+ }
+ }, [collection, requestId, targetId, clientId]);
+}
diff --git a/packages/bruno-app/src/feature/code-generator/hooks/useLangugeClient.ts b/packages/bruno-app/src/feature/code-generator/hooks/useLangugeClient.ts
new file mode 100644
index 0000000000..43741145b7
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/hooks/useLangugeClient.ts
@@ -0,0 +1,44 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { useLocalStorage } from '@mantine/hooks';
+import { useEffect, useState } from 'react';
+
+export function useLanguageClient() {
+ const [targetId, setTargetId] = useLocalStorage({ key: 'code-generator-selected-target-id', defaultValue: 'shell' });
+ const [lastClientIds, setLastClientIds] = useLocalStorage({
+ key: 'code-generator-last-client-ids',
+ defaultValue: '{}'
+ });
+
+ const [clientId, setClientId] = useState('curl');
+
+ useEffect(() => {
+ // After changing the targetId select the last ClientId
+ const lastClientIdsObj = JSON.parse(lastClientIds);
+ if (lastClientIdsObj[targetId]) {
+ setClientId(lastClientIdsObj[targetId]);
+ } else {
+ // Effect hook in "useLanguageClient" will set the clientId to default
+ setClientId('');
+ }
+ }, [targetId]);
+
+ useEffect(() => {
+ if (!targetId) {
+ return;
+ }
+ // Save the last ClientId
+ const lastClientIdsObj = JSON.parse(lastClientIds);
+ lastClientIdsObj[targetId] = clientId;
+ setLastClientIds(JSON.stringify(lastClientIdsObj));
+ }, [clientId]);
+
+ return {
+ targetId,
+ setTargetId,
+ clientId,
+ setClientId
+ };
+}
diff --git a/packages/bruno-app/src/feature/code-generator/index.ts b/packages/bruno-app/src/feature/code-generator/index.ts
new file mode 100644
index 0000000000..a709290867
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/index.ts
@@ -0,0 +1,5 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+export { CodeGeneratorModal } from './components/CodeGeneratorModal';
diff --git a/packages/bruno-app/src/feature/code-generator/util/auth.ts b/packages/bruno-app/src/feature/code-generator/util/auth.ts
new file mode 100644
index 0000000000..efa99c04a7
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/util/auth.ts
@@ -0,0 +1,31 @@
+import get from 'lodash/get';
+
+// TODO: Add types here and maybe refactor
+export const getAuthHeaders = (collectionRootAuth, requestAuth) => {
+ const auth = collectionRootAuth && ['inherit'].includes(requestAuth?.mode) ? collectionRootAuth : requestAuth;
+
+ switch (auth.mode) {
+ case 'basic':
+ const username = get(auth, 'basic.username', '');
+ const password = get(auth, 'basic.password', '');
+ const basicToken = Buffer.from(`${username}:${password}`).toString('base64');
+
+ return [
+ {
+ enabled: true,
+ name: 'Authorization',
+ value: `Basic ${basicToken}`
+ }
+ ];
+ case 'bearer':
+ return [
+ {
+ enabled: true,
+ name: 'Authorization',
+ value: `Bearer ${get(auth, 'bearer.token', '')}`
+ }
+ ];
+ default:
+ return [];
+ }
+};
diff --git a/packages/bruno-app/src/feature/code-generator/util/har.ts b/packages/bruno-app/src/feature/code-generator/util/har.ts
new file mode 100644
index 0000000000..39994ed702
--- /dev/null
+++ b/packages/bruno-app/src/feature/code-generator/util/har.ts
@@ -0,0 +1,67 @@
+import { HarRequest } from '@readme/httpsnippet';
+import { HeaderSchema, ParamSchema, RequestBodySchema } from '@usebruno/schema';
+
+const createContentType = (mode: string) => {
+ switch (mode) {
+ case 'json':
+ return 'application/json';
+ case 'xml':
+ return 'application/xml';
+ case 'formUrlEncoded':
+ return 'application/x-www-form-urlencoded';
+ case 'multipartForm':
+ return 'multipart/form-data';
+ default:
+ return '';
+ }
+};
+
+const createHeaders = (headers: HeaderSchema[]) => {
+ return headers
+ .filter((header) => header.enabled)
+ .map((header) => ({
+ name: header.name,
+ value: header.value
+ }));
+};
+
+const createQuery = (queryParams: ParamSchema[]) => {
+ return queryParams
+ .filter((param) => param.enabled && param.type === 'query')
+ .map((param) => ({
+ name: param.name,
+ value: param.value
+ }));
+};
+
+const createPostData = (body: RequestBodySchema) => {
+ const contentType = createContentType(body.mode);
+ if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
+ return {
+ mimeType: contentType,
+ params: body[body.mode]
+ // @ts-expect-error TODO: Fix me. This needs to be refactored.
+ .filter((param) => param.enabled)
+ .map((param) => ({ name: param.name, value: param.value }))
+ };
+ } else {
+ return {
+ mimeType: contentType,
+ text: body[body.mode]
+ };
+ }
+};
+
+export const buildHarRequest = ({ request, headers }): HarRequest => {
+ return {
+ method: request.method,
+ url: encodeURI(request.url),
+ httpVersion: 'HTTP/1.1',
+ cookies: [],
+ headers: createHeaders(headers),
+ queryString: createQuery(request.params),
+ postData: createPostData(request.body),
+ headersSize: 0,
+ bodySize: 0
+ };
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/EnvironmentDrawer.module.css b/packages/bruno-app/src/feature/environment-editor/components/EnvironmentDrawer.module.css
new file mode 100644
index 0000000000..d12859f7d6
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/EnvironmentDrawer.module.css
@@ -0,0 +1,9 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+.content {
+ display: grid;
+ grid-template-rows: 1fr;
+ grid-template-columns: 14rem 1fr;
+}
diff --git a/packages/bruno-app/src/feature/environment-editor/components/EnvironmentDrawer.tsx b/packages/bruno-app/src/feature/environment-editor/components/EnvironmentDrawer.tsx
new file mode 100644
index 0000000000..f653f41c36
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/EnvironmentDrawer.tsx
@@ -0,0 +1,81 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Anchor, Box, Button, Divider, Drawer, Group, Space, rem } from '@mantine/core';
+import { IconDownload, IconPlus } from '@tabler/icons-react';
+import { EnvironmentList } from './list/EnvironmentList';
+import { EnvironmentForm } from './form/EnvironmentForm';
+import { useEnvironmentEditorProvider } from '../hooks/useEnvironmentEditorProvider';
+import { EnvironmentEditorProvider } from '../provider/EnvironmentEditorProvider';
+import classes from './EnvironmentDrawer.module.css';
+import { UnsavedEnvironmentModal } from './modals/UnsavedEnvironmentModal';
+import { CloneEnvironmentModal } from './modals/CloneEnvironmentModal';
+import { CreateEnvironmentModal } from './modals/CreateEnvironmentModal';
+import { DeleteEnvironmentModal } from './modals/DeleteEnvironmentModal';
+import { RenameEnvironmentModal } from './modals/RenameEnvironmentModal';
+import { ImportEnvironmentModal } from './modals/ImportEnvironmentModal';
+import { ManageSecretModals } from './modals/ManageSecretsModal';
+import { CollectionSchema } from '@usebruno/schema';
+
+type EnvironmentDrawerProps = {
+ opened: boolean;
+ onClose: () => void;
+ collection: CollectionSchema;
+};
+
+export const EnvironmentDrawer: React.FC = ({ opened, onClose, collection }) => {
+ const providerData = useEnvironmentEditorProvider(collection, onClose);
+
+ return (
+
+
+ providerData.openActionModal('create')}
+ leftSection={ }
+ >
+ New environment
+
+ providerData.openActionModal('import')}
+ leftSection={ }
+ >
+ Import environment
+
+
+
+
+ providerData.openActionModal('manage-secrets')}>Managing secrets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/EnvironmentSelector.tsx b/packages/bruno-app/src/feature/environment-editor/components/EnvironmentSelector.tsx
new file mode 100644
index 0000000000..31a7800680
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/EnvironmentSelector.tsx
@@ -0,0 +1,65 @@
+/*
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Group, Select, Tooltip, rem } from '@mantine/core';
+import { IconPencilCog } from '@tabler/icons-react';
+import { useEnvironmentSelector } from '../hooks/useEnvironmentSelector';
+import { EnvironmentDrawer } from 'src/feature/environment-editor';
+import { CollectionSchema } from '@usebruno/schema';
+
+type EnvironmentSelectorProps = {
+ collection: CollectionSchema;
+};
+
+export const EnvironmentSelector: React.FC = ({ collection }) => {
+ const {
+ activeEnvironment,
+ data,
+ onChange,
+
+ environmentModalOpen,
+ onEnvironmentModalOpen,
+ onEnvironmentModalClose
+ } = useEnvironmentSelector(collection);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentForm.tsx b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentForm.tsx
new file mode 100644
index 0000000000..aedc4b3eb1
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentForm.tsx
@@ -0,0 +1,93 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, ActionIconGroup, Box, Button, Group, Table, Title, Tooltip, rem } from '@mantine/core';
+import React, { useCallback, useMemo } from 'react';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import { IconArrowBack, IconCopy, IconDeviceFloppy, IconPencil, IconPlus, IconTrash } from '@tabler/icons-react';
+import { EnvironmentTableRow } from './EnvironmentTableRow';
+import { uuid } from 'utils/common';
+import { EnvironmentFormEmptyState } from './EnvironmentFormEmptyState';
+import classes from './EnvironmentTableRow.module.css';
+
+export const EnvironmentForm: React.FC = () => {
+ const { form, selectedEnvironment, onSubmit, openActionModal } = useEnvironmentEditor();
+ const addRow = useCallback(() => {
+ form.insertListItem('variables', { enabled: true, name: '', value: '', secret: false, uid: uuid(), type: 'text' });
+ }, [form]);
+
+ const tableRows = useMemo(() => {
+ return form.getValues().variables.map((val, index) => );
+ }, [form.getValues().variables.length]);
+
+ if (!selectedEnvironment) {
+ return null;
+ }
+
+ return (
+
+
+ {selectedEnvironment.name}
+
+
+ openActionModal('rename')}>
+
+
+
+
+ openActionModal('clone')}>
+
+
+
+
+ openActionModal('delete')}
+ >
+
+
+
+
+
+
+ } type="submit" form="env-edit-form">
+ Save
+
+ } variant="subtle">
+ Add variable
+
+
+ form.reset()}
+ leftSection={ }
+ variant="transparent"
+ >
+ Reset
+
+
+ {tableRows.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentFormEmptyState.tsx b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentFormEmptyState.tsx
new file mode 100644
index 0000000000..58a8707d8e
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentFormEmptyState.tsx
@@ -0,0 +1,16 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Paper, Stack, Text, Title } from '@mantine/core';
+
+export const EnvironmentFormEmptyState: React.FC = () => {
+ return (
+
+
+ No variables
+ This environment does not have any variables yet!
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentTableRow.module.css b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentTableRow.module.css
new file mode 100644
index 0000000000..4170840f6c
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentTableRow.module.css
@@ -0,0 +1,9 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+.tableRow {
+ display: grid;
+ grid-template-rows: 100%;
+ grid-template-columns: 70px 250px minmax(0px, auto) 60px 70px;
+}
diff --git a/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentTableRow.tsx b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentTableRow.tsx
new file mode 100644
index 0000000000..48349a9ee2
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/form/EnvironmentTableRow.tsx
@@ -0,0 +1,62 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Checkbox, Table, TextInput, rem } from '@mantine/core';
+import React from 'react';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import CodeEditor from 'components/CodeEditor';
+import { IconTrash } from '@tabler/icons-react';
+import classes from './EnvironmentTableRow.module.css';
+
+type EnvironmentTableRowProps = {
+ pos: number;
+};
+
+export const EnvironmentTableRow: React.FC = ({ pos }) => {
+ const { form, collection } = useEnvironmentEditor();
+
+ return (
+
+
+
+
+
+
+
+
+ {/* @ts-expect-error Needs be remade in ts */}
+
+
+
+
+
+
+ {
+ form.removeListItem('variables', pos);
+ }}
+ >
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/list/EnvironmentList.tsx b/packages/bruno-app/src/feature/environment-editor/components/list/EnvironmentList.tsx
new file mode 100644
index 0000000000..35affccaa7
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/list/EnvironmentList.tsx
@@ -0,0 +1,33 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import React, { useMemo } from 'react';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import { EnvironmentListItem } from './EnvironmentListItem';
+import { Stack } from '@mantine/core';
+
+export const EnvironmentList: React.FC = () => {
+ const { allEnvironments, selectedEnvironment, onEnvironmentSwitch } = useEnvironmentEditor();
+
+ const items = useMemo(() => {
+ return allEnvironments.map((env) => (
+ onEnvironmentSwitch(env.uid)}
+ />
+ ));
+ }, [allEnvironments, selectedEnvironment?.uid]);
+
+ if (allEnvironments.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {items}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/list/EnvironmentListItem.tsx b/packages/bruno-app/src/feature/environment-editor/components/list/EnvironmentListItem.tsx
new file mode 100644
index 0000000000..feecc922f2
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/list/EnvironmentListItem.tsx
@@ -0,0 +1,17 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import React from 'react';
+import { CollectionEnvironment } from '../../types';
+import { NavLink } from '@mantine/core';
+
+type EnvironmentListItemProps = {
+ environment: CollectionEnvironment;
+ active: boolean;
+ onClick: () => void;
+};
+
+export const EnvironmentListItem: React.FC = ({ environment, onClick, active }) => {
+ return ;
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/modals/CloneEnvironmentModal.tsx b/packages/bruno-app/src/feature/environment-editor/components/modals/CloneEnvironmentModal.tsx
new file mode 100644
index 0000000000..768ebe1e33
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/modals/CloneEnvironmentModal.tsx
@@ -0,0 +1,106 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, TextInput, rem } from '@mantine/core';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import { useForm } from '@mantine/form';
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { copyEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+
+export const CloneEnvironmentModal: React.FC = () => {
+ const { activeModal, setActiveModal, selectedEnvironment, allEnvironments, collection } = useEnvironmentEditor();
+ const dispatch = useDispatch();
+
+ const cloneEnvironment = useMutation({
+ mutationFn: async (values: { name: string; oldEnvId: string; collectionId: string }) => {
+ await dispatch(copyEnvironment(values.name, values.oldEnvId, values.collectionId));
+ },
+ onSuccess: () => {
+ toast.success('Cloned environment');
+ setActiveModal(null);
+ }
+ });
+
+ const cloneForm = useForm({
+ mode: 'uncontrolled',
+ initialValues: {
+ name: ''
+ },
+ validate: {
+ name: (value) => {
+ if (value.trim().length === 0) {
+ return 'Name cannot be empty';
+ }
+
+ const existingEnvironment = allEnvironments.find(
+ (env) => env.name.toLowerCase() === value.toLowerCase().trim()
+ );
+ if (existingEnvironment !== undefined) {
+ return 'An environment with this name already exists';
+ }
+
+ return null;
+ }
+ }
+ });
+ useEffect(() => {
+ cloneForm.setValues({ name: `${selectedEnvironment?.name} - Copy` });
+ cloneEnvironment.reset();
+ }, [selectedEnvironment, activeModal]);
+
+ return (
+ {
+ setActiveModal(null);
+ }}
+ title="Clone environment"
+ >
+ Choose a name for the clone of "{selectedEnvironment?.name}"
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/modals/CreateEnvironmentModal.tsx b/packages/bruno-app/src/feature/environment-editor/components/modals/CreateEnvironmentModal.tsx
new file mode 100644
index 0000000000..a0d8f18ec7
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/modals/CreateEnvironmentModal.tsx
@@ -0,0 +1,108 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, TextInput, rem } from '@mantine/core';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import { useForm } from '@mantine/form';
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { addEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+
+export const CreateEnvironmentModal: React.FC = () => {
+ const { activeModal, setActiveModal, selectedEnvironment, allEnvironments, collection } = useEnvironmentEditor();
+ const dispatch = useDispatch();
+
+ const createMutation = useMutation({
+ mutationFn: async (values: { name: string; collectionId: string }) => {
+ await dispatch(addEnvironment(values.name, values.collectionId));
+ },
+ onSuccess: () => {
+ toast.success('Created new environment');
+ setActiveModal(null);
+ }
+ });
+
+ const createForm = useForm({
+ mode: 'uncontrolled',
+ initialValues: {
+ name: ''
+ },
+ validate: {
+ name: (value) => {
+ if (value.trim().length === 0) {
+ return 'Name cannot be empty';
+ }
+
+ const existingEnvironment = allEnvironments.find(
+ (env) => env.name.toLowerCase() === value.toLowerCase().trim()
+ );
+ if (existingEnvironment !== undefined) {
+ return 'An environment with this name already exists';
+ }
+
+ return null;
+ }
+ }
+ });
+ useEffect(() => {
+ createForm.setValues({ name: '' });
+ createMutation.reset();
+ }, [activeModal]);
+
+ return (
+ {
+ setActiveModal(null);
+ }}
+ title="Create environment"
+ >
+ Choose a name for the new environment
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/modals/DeleteEnvironmentModal.tsx b/packages/bruno-app/src/feature/environment-editor/components/modals/DeleteEnvironmentModal.tsx
new file mode 100644
index 0000000000..5796b9ecf1
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/modals/DeleteEnvironmentModal.tsx
@@ -0,0 +1,64 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, rem } from '@mantine/core';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { deleteEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+
+export const DeleteEnvironmentModal: React.FC = () => {
+ const { activeModal, setActiveModal, selectedEnvironment, collection } = useEnvironmentEditor();
+ const dispatch = useDispatch();
+
+ const deleteMutation = useMutation({
+ mutationFn: async (values: { environmentId: string; collectionId: string }) => {
+ await dispatch(deleteEnvironment(values.environmentId, values.collectionId));
+ },
+ onSuccess: () => {
+ toast.success('Environment deleted');
+ setActiveModal(null);
+ }
+ });
+
+ return (
+ {
+ setActiveModal(null);
+ }}
+ title="Delete environment"
+ >
+ Do you really want to delete "{selectedEnvironment?.name}"?
+
+ {deleteMutation.error ? (
+ } mt={'md'}>
+ {String(deleteMutation.error)}
+
+ ) : null}
+
+
+ {
+ setActiveModal(null);
+ }}
+ >
+ Cancel
+
+
+ deleteMutation.mutate({ environmentId: selectedEnvironment!.uid, collectionId: collection.uid })
+ }
+ >
+ Delete
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/modals/ImportEnvironmentModal.tsx b/packages/bruno-app/src/feature/environment-editor/components/modals/ImportEnvironmentModal.tsx
new file mode 100644
index 0000000000..3833d501d9
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/modals/ImportEnvironmentModal.tsx
@@ -0,0 +1,107 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Radio, rem } from '@mantine/core';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import { useForm } from '@mantine/form';
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import importPostmanEnvironment from 'utils/importers/postman-environment';
+import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+
+export const ImportEnvironmentModal: React.FC = () => {
+ const { activeModal, setActiveModal, collection } = useEnvironmentEditor();
+ const dispatch = useDispatch();
+
+ const importMutation = useMutation({
+ mutationFn: async (values: { type: string; collectionId: string }) => {
+ let environment: any;
+ switch (values.type) {
+ case 'postman':
+ environment = await importPostmanEnvironment();
+ break;
+ default:
+ throw new Error(`Unknown environment type: ${values.type}`);
+ }
+
+ dispatch(importEnvironment(environment.name, environment.variables, collection.uid));
+ },
+ onSuccess: () => {
+ toast.success('Environment imported');
+ setActiveModal(null);
+ }
+ });
+
+ const importForm = useForm({
+ initialValues: {
+ type: undefined
+ },
+ validate: {
+ type: (value) => {
+ if (!value) {
+ return 'Please select the environment type';
+ }
+ return null;
+ }
+ }
+ });
+ useEffect(() => {
+ importMutation.reset();
+ importForm.reset();
+ }, [activeModal]);
+
+ return (
+ {
+ setActiveModal(null);
+ }}
+ title="Import environment"
+ >
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/modals/ManageSecretsModal.tsx b/packages/bruno-app/src/feature/environment-editor/components/modals/ManageSecretsModal.tsx
new file mode 100644
index 0000000000..bca5b18a8f
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/modals/ManageSecretsModal.tsx
@@ -0,0 +1,43 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Anchor, Button, Group, Modal, Stack, Text } from '@mantine/core';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+
+export const ManageSecretModals: React.FC = () => {
+ const { setActiveModal, activeModal } = useEnvironmentEditor();
+
+ return (
+ {
+ setActiveModal(null);
+ }}
+ title="Managing secrets"
+ >
+
+ In any collection, there are secrets that need to be managed.
+ These secrets can be anything such as API keys, passwords, or tokens.
+ Bruno offers two approaches to manage secrets in collections.
+
+ Read more about it in our{' '}
+
+ docs
+
+ .
+
+
+
+ {
+ setActiveModal(null);
+ }}
+ >
+ Close
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/modals/RenameEnvironmentModal.tsx b/packages/bruno-app/src/feature/environment-editor/components/modals/RenameEnvironmentModal.tsx
new file mode 100644
index 0000000000..7a80d4d9bc
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/modals/RenameEnvironmentModal.tsx
@@ -0,0 +1,111 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, TextInput, rem } from '@mantine/core';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+import { useForm } from '@mantine/form';
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+
+export const RenameEnvironmentModal: React.FC = () => {
+ const { activeModal, setActiveModal, selectedEnvironment, allEnvironments, collection } = useEnvironmentEditor();
+ const dispatch = useDispatch();
+
+ const renameMutation = useMutation({
+ mutationFn: async (values: { name: string; environmentId: string; collectionId: string }) => {
+ await dispatch(renameEnvironment(values.name, values.environmentId, values.collectionId));
+ },
+ onSuccess: () => {
+ toast.success('Renamed environment');
+ setActiveModal(null);
+ }
+ });
+
+ const renameForm = useForm({
+ mode: 'uncontrolled',
+ initialValues: {
+ name: ''
+ },
+ validate: {
+ name: (value) => {
+ if (value.trim().length === 0) {
+ return 'Name cannot be empty';
+ }
+
+ // This ensures, casing can be changed
+ if (value.trim().toLowerCase() === selectedEnvironment?.name.toLowerCase()) {
+ return null;
+ }
+
+ const existingEnvironment = allEnvironments.find(
+ (env) => env.name.toLowerCase() === value.toLowerCase().trim()
+ );
+ if (existingEnvironment !== undefined) {
+ return 'An environment with this name already exists';
+ }
+
+ return null;
+ }
+ }
+ });
+ useEffect(() => {
+ renameForm.setValues({ name: selectedEnvironment?.name ?? '' });
+ renameMutation.reset();
+ }, [activeModal]);
+
+ return (
+ {
+ setActiveModal(null);
+ }}
+ title="Rename environment"
+ >
+ Choose a new name for "{selectedEnvironment?.name}"
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/components/modals/UnsavedEnvironmentModal.tsx b/packages/bruno-app/src/feature/environment-editor/components/modals/UnsavedEnvironmentModal.tsx
new file mode 100644
index 0000000000..c50e81edbd
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/components/modals/UnsavedEnvironmentModal.tsx
@@ -0,0 +1,47 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Button, Group, Modal, Text } from '@mantine/core';
+import { useEnvironmentEditor } from '../../hooks/useEnvironmentEditor';
+
+export const UnsavedEnvironmentModal: React.FC = () => {
+ const { unsavedChangesCallback, setUnsavedChangesCallback, selectedEnvironment, form, onSubmit } =
+ useEnvironmentEditor();
+
+ return (
+ {
+ setUnsavedChangesCallback(null);
+ }}
+ title="You have unsaved changes"
+ >
+ Some changes in "{selectedEnvironment?.name}" are not saved!
+
+ {
+ form.reset();
+ unsavedChangesCallback!();
+ setUnsavedChangesCallback(null);
+ }}
+ >
+ Discard changes
+
+ {
+ onSubmit(form.getValues().variables).then(() => {
+ unsavedChangesCallback!();
+ setUnsavedChangesCallback(null);
+ });
+ }}
+ >
+ Save
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentEditor.ts b/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentEditor.ts
new file mode 100644
index 0000000000..9920c6b2ff
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentEditor.ts
@@ -0,0 +1,15 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { useContext } from 'react';
+import { EnvironmentProviderProps } from '../types';
+import { EnvironmentEditorProvider } from '../provider/EnvironmentEditorProvider';
+
+export const useEnvironmentEditor = (): EnvironmentProviderProps => {
+ const values = useContext(EnvironmentEditorProvider);
+ if (values === null) {
+ throw new Error('No "EnvironmentEditorProvider" context found!');
+ }
+ return values;
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentEditorProvider.ts b/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentEditorProvider.ts
new file mode 100644
index 0000000000..468aa65d64
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentEditorProvider.ts
@@ -0,0 +1,160 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { useCallback, useEffect, useState } from 'react';
+import { EnvironmentEditorModalTypes, EnvironmentProviderProps } from '../types';
+import { useForm } from '@mantine/form';
+import { useDispatch } from 'react-redux';
+import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { variableNameRegex } from 'utils/common/regex';
+import { EnvironmentSchema, EnvironmentVariableSchema } from '@usebruno/schema';
+
+type Collection = {
+ environments: EnvironmentSchema[];
+ activeEnvironmentUid: string | undefined;
+ uid: string;
+};
+
+export const useEnvironmentEditorProvider = (
+ collection: Collection,
+ closeModal: () => void
+): EnvironmentProviderProps => {
+ const dispatch = useDispatch();
+ const [selectedEnvironment, setSelectedEnvironment] = useState(
+ collection.environments[0] ?? null
+ );
+ const [unsavedChangesCallback, setUnsavedChangesCallback] = useState<(() => void) | null>(null);
+ const [activeModal, setActiveModal] = useState(null);
+
+ const form = useForm<{ variables: EnvironmentVariableSchema[] }>({
+ mode: 'uncontrolled',
+ initialValues: {
+ variables: []
+ },
+ validate: {
+ variables: {
+ name: (value) => {
+ if (value.trim().length === 0) {
+ return 'Name cannot be empty!';
+ }
+ if (value.match(variableNameRegex) === null) {
+ return 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
+ }
+ return null;
+ }
+ }
+ }
+ });
+
+ // Try to always select the active environment
+ useEffect(() => {
+ if (collection.activeEnvironmentUid === null) {
+ return;
+ }
+
+ const foundSelectedEnv = collection.environments.find((env) => env.uid === collection.activeEnvironmentUid);
+ if (!foundSelectedEnv) {
+ return;
+ }
+
+ setSelectedEnvironment(foundSelectedEnv);
+ form.setInitialValues({ variables: structuredClone(foundSelectedEnv.variables) });
+ form.reset();
+ }, [collection.activeEnvironmentUid]);
+
+ useEffect(() => {
+ // If the collection has no environments, reset the selected environment
+ if (collection.environments.length === 0) {
+ setSelectedEnvironment(null);
+ return;
+ }
+
+ const foundSelectedEnv = collection.environments.find((env) => env.name === selectedEnvironment?.name);
+ // If the selected environment is not found in the collection, select the first one
+ if (!foundSelectedEnv) {
+ setSelectedEnvironment(collection.environments[0]);
+ form.setInitialValues({ variables: structuredClone(collection.environments[0]?.variables ?? []) });
+ form.reset();
+ return;
+ }
+ }, [collection.environments, selectedEnvironment]);
+
+ const onSubmit = useCallback(
+ async (values: EnvironmentVariableSchema[]) => {
+ try {
+ await dispatch(saveEnvironment(structuredClone(values), selectedEnvironment?.uid, collection.uid));
+ } catch (error) {
+ console.error('Could not save environment', error);
+ toast.error('An error occurred while saving the changes');
+ throw error;
+ }
+ toast.success('Changes saved successfully');
+ form.setInitialValues({ variables: values });
+ form.reset();
+ },
+ [selectedEnvironment, collection.uid]
+ );
+
+ const onClose = useCallback(() => {
+ if (form.isDirty()) {
+ setUnsavedChangesCallback(() => {
+ return () => closeModal();
+ });
+ return;
+ }
+ closeModal();
+ }, [form, closeModal]);
+
+ const openActionModal = useCallback(
+ (modalType: EnvironmentEditorModalTypes, ignoreUnsaved = false) => {
+ if (form.isDirty() && ignoreUnsaved === false) {
+ setUnsavedChangesCallback(() => {
+ return () => setActiveModal(modalType);
+ });
+ return;
+ }
+ setActiveModal(modalType);
+ },
+ [form]
+ );
+
+ const onEnvironmentSwitch = useCallback(
+ (targetEnvironmentId: string, ignoreUnsaved = false) => {
+ if (form.isDirty() && ignoreUnsaved === false) {
+ setUnsavedChangesCallback(() => {
+ return () => onEnvironmentSwitch(targetEnvironmentId, true);
+ });
+ return;
+ }
+
+ const newEnvironment = collection.environments.find((env) => env.uid === targetEnvironmentId);
+ if (!newEnvironment) {
+ throw new Error(`Could not find env "${targetEnvironmentId}" for switching`);
+ }
+ setSelectedEnvironment(newEnvironment);
+ form.setInitialValues({ variables: structuredClone(newEnvironment.variables) });
+ form.reset();
+ },
+ [collection.environments, form]
+ );
+
+ return {
+ collection,
+ allEnvironments: collection.environments,
+ selectedEnvironment,
+ form,
+
+ onSubmit,
+ onEnvironmentSwitch,
+ onClose,
+
+ activeModal,
+ setActiveModal,
+ openActionModal,
+
+ unsavedChangesCallback,
+ setUnsavedChangesCallback
+ };
+};
diff --git a/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentSelector.ts b/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentSelector.ts
new file mode 100644
index 0000000000..5bacd40933
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/hooks/useEnvironmentSelector.ts
@@ -0,0 +1,73 @@
+/*
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { useCallback, useMemo, useState } from 'react';
+import { useDispatch } from 'react-redux';
+import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
+import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
+import { ComboboxItem } from '@mantine/core';
+
+type Collection = {
+ uid: string;
+ environments: {
+ uid: string;
+ name: string;
+ }[];
+ activeEnvironmentUid: string | null;
+};
+
+type UseEnvironmentSelectorData = {
+ data: ComboboxItem[];
+ activeEnvironment: string | null;
+ onChange: (newValue: string | null) => void;
+
+ environmentModalOpen: boolean;
+ onEnvironmentModalOpen: () => void;
+ onEnvironmentModalClose: () => void;
+};
+
+export function useEnvironmentSelector(collection: Collection): UseEnvironmentSelectorData {
+ const dispatch = useDispatch();
+
+ const { data, activeEnvironment } = useMemo(() => {
+ const data: ComboboxItem[] = collection.environments.map((env) => ({ label: env.name, value: env.uid }));
+ data.push({ label: 'No Environment', value: '' });
+ return {
+ data,
+ activeEnvironment: collection.activeEnvironmentUid ?? ''
+ };
+ }, [collection.activeEnvironmentUid, collection.environments]);
+
+ const onChange = useCallback(
+ (newValue: string | null) => {
+ let newUid = newValue;
+ if (!newValue) {
+ newUid = undefined;
+ }
+
+ dispatch(selectEnvironment(newUid, collection.uid));
+ },
+ [dispatch, collection.uid]
+ );
+
+ const [environmentModalOpen, setEnvironmentModalOpen] = useState(false);
+ const onEnvironmentModalOpen = useCallback(() => {
+ setEnvironmentModalOpen(true);
+ dispatch(updateEnvironmentSettingsModalVisibility(true));
+ }, []);
+ const onEnvironmentModalClose = useCallback(() => {
+ setEnvironmentModalOpen(false);
+ dispatch(updateEnvironmentSettingsModalVisibility(false));
+ }, []);
+
+ return {
+ data,
+ activeEnvironment,
+ onChange,
+
+ environmentModalOpen,
+ onEnvironmentModalOpen,
+ onEnvironmentModalClose
+ };
+}
diff --git a/packages/bruno-app/src/feature/environment-editor/index.ts b/packages/bruno-app/src/feature/environment-editor/index.ts
new file mode 100644
index 0000000000..5e51c79117
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/index.ts
@@ -0,0 +1,6 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+export { EnvironmentDrawer } from './components/EnvironmentDrawer';
+export { EnvironmentSelector } from './components/EnvironmentSelector';
diff --git a/packages/bruno-app/src/feature/environment-editor/provider/EnvironmentEditorProvider.ts b/packages/bruno-app/src/feature/environment-editor/provider/EnvironmentEditorProvider.ts
new file mode 100644
index 0000000000..9f344e5a48
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/provider/EnvironmentEditorProvider.ts
@@ -0,0 +1,8 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { createContext } from 'react';
+import { EnvironmentProviderProps } from '../types';
+
+export const EnvironmentEditorProvider = createContext(null);
diff --git a/packages/bruno-app/src/feature/environment-editor/types.ts b/packages/bruno-app/src/feature/environment-editor/types.ts
new file mode 100644
index 0000000000..ff33fccaa7
--- /dev/null
+++ b/packages/bruno-app/src/feature/environment-editor/types.ts
@@ -0,0 +1,27 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { UseFormReturnType } from '@mantine/form';
+import { EnvironmentSchema, EnvironmentVariableSchema } from '@usebruno/schema';
+
+export type EnvironmentProviderProps = {
+ collection: any;
+ allEnvironments: EnvironmentSchema[];
+
+ selectedEnvironment: EnvironmentSchema | null;
+ form: UseFormReturnType<{ variables: EnvironmentVariableSchema[] }>;
+
+ onSubmit: (values: EnvironmentVariableSchema[]) => Promise;
+ onEnvironmentSwitch: (targetEnvironmentId: string, ignoreUnsaved?: boolean) => void;
+ onClose: () => void;
+
+ activeModal: EnvironmentEditorModalTypes;
+ setActiveModal: (newType: EnvironmentEditorModalTypes) => void;
+ openActionModal: (actionModalType: EnvironmentEditorModalTypes) => void;
+
+ unsavedChangesCallback: (() => void) | null;
+ setUnsavedChangesCallback: (newCallback: null) => void;
+};
+
+export type EnvironmentEditorModalTypes = 'create' | 'clone' | 'rename' | 'delete' | 'import' | 'manage-secrets' | null;
diff --git a/packages/bruno-app/src/feature/request-url-bar/components/MethodSelector.tsx b/packages/bruno-app/src/feature/request-url-bar/components/MethodSelector.tsx
new file mode 100644
index 0000000000..75171c25b2
--- /dev/null
+++ b/packages/bruno-app/src/feature/request-url-bar/components/MethodSelector.tsx
@@ -0,0 +1,27 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ComboboxData, Select, SelectProps } from '@mantine/core';
+import React from 'react';
+
+const defaultMethod: ComboboxData = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD'];
+
+type MethodSelectorProps = Omit & {
+ withBorder?: boolean;
+};
+
+export const MethodSelector: React.FC = (props) => {
+ return (
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/request-url-bar/components/RequestUrlBar.module.css b/packages/bruno-app/src/feature/request-url-bar/components/RequestUrlBar.module.css
new file mode 100644
index 0000000000..8292623d16
--- /dev/null
+++ b/packages/bruno-app/src/feature/request-url-bar/components/RequestUrlBar.module.css
@@ -0,0 +1,18 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+.bar {
+ display: grid;
+ grid-template-rows: 100%;
+ grid-template-columns: 8rem 1fr min-content min-content;
+ border: 1px solid var(--mantine-color-gray-1);
+
+ align-items: center;
+
+ background-color: var(--mantine-color-white);
+ [data-mantine-color-scheme='dark'] & {
+ background-color: var(--mantine-color-dark-6);
+ border: 0;
+ }
+}
diff --git a/packages/bruno-app/src/feature/request-url-bar/components/RequestUrlBar.tsx b/packages/bruno-app/src/feature/request-url-bar/components/RequestUrlBar.tsx
new file mode 100644
index 0000000000..0c653c9e5c
--- /dev/null
+++ b/packages/bruno-app/src/feature/request-url-bar/components/RequestUrlBar.tsx
@@ -0,0 +1,93 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Button, Indicator, Loader, Paper, Tooltip, rem } from '@mantine/core';
+import classes from './RequestUrlBar.module.css';
+import { useDispatch } from 'react-redux';
+import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
+import { MethodSelector } from './MethodSelector';
+import { get } from 'lodash';
+import CodeEditor from 'components/CodeEditor';
+import { IconDeviceFloppy, IconSend2 } from '@tabler/icons-react';
+import { CollectionSchema, RequestItemSchema } from '@usebruno/schema';
+
+type RequestUrlBarProps = {
+ item: RequestItemSchema;
+ collection: CollectionSchema;
+ handleRun: () => void;
+};
+
+export const RequestUrlBar: React.FC = ({ collection, item, handleRun }) => {
+ const dispatch = useDispatch();
+
+ const onSave = () => {
+ dispatch(saveRequest(item.uid, collection.uid));
+ };
+
+ const onUrlChange = (value: string) => {
+ dispatch(
+ requestUrlChanged({
+ itemUid: item.uid,
+ collectionUid: collection.uid,
+ url: value
+ })
+ );
+ };
+
+ const onMethodSelect = (method) => {
+ dispatch(
+ updateRequestMethod({
+ method,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
+ const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
+
+ const isLoading = ['queued', 'sending'].includes(item.requestState);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleRun}
+ variant="filled"
+ disabled={isLoading}
+ >
+ Send
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/request-url-bar/index.ts b/packages/bruno-app/src/feature/request-url-bar/index.ts
new file mode 100644
index 0000000000..1052be58fd
--- /dev/null
+++ b/packages/bruno-app/src/feature/request-url-bar/index.ts
@@ -0,0 +1,6 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+export { RequestUrlBar } from './components/RequestUrlBar';
+export { MethodSelector } from './components/MethodSelector';
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/CollectionMenu.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/CollectionMenu.tsx
new file mode 100644
index 0000000000..6f6b550818
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/CollectionMenu.tsx
@@ -0,0 +1,110 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Menu, rem } from '@mantine/core';
+import {
+ IconCopy,
+ IconDots,
+ IconEdit,
+ IconFileExport,
+ IconFolderOpen,
+ IconFolderPlus,
+ IconMinimize,
+ IconPencil,
+ IconPlus,
+ IconRun,
+ IconSettings
+} from '@tabler/icons-react';
+import React from 'react';
+import { useSidebarActions } from '../hooks/useSidebarActions';
+
+const ICON_STYLE = { width: rem(16), height: rem(16) };
+
+type CollectionMenuProps = {
+ collectionUid: string;
+ onOpen: () => void;
+ onClose: () => void;
+};
+
+export const CollectionMenu: React.FC = ({ collectionUid, onClose, onOpen }) => {
+ const { setActiveAction, openRunner, openInExplorer, editBrunoJson, openCollectionSettings } = useSidebarActions();
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ onClick={() => setActiveAction('new-request', collectionUid)}
+ >
+ New request
+
+
+ }
+ onClick={() => setActiveAction('new-folder', collectionUid)}
+ >
+ New folder
+
+
+ }
+ onClick={() => setActiveAction('rename-collection', collectionUid)}
+ >
+ Rename
+
+
+ }
+ onClick={() => setActiveAction('clone-collection', collectionUid)}
+ >
+ Clone
+
+
+ }
+ onClick={() => setActiveAction('close-collection', collectionUid)}
+ >
+ Close
+
+
+
+
+ } onClick={() => openRunner(collectionUid)}>
+ Run
+
+
+ }
+ onClick={() => openCollectionSettings(collectionUid)}
+ >
+ Collection settings
+
+
+ }
+ onClick={() => setActiveAction('export-collection', collectionUid)}
+ >
+ Export
+
+
+
+
+ } onClick={() => openInExplorer(collectionUid)}>
+ Open in Explorer
+
+
+ } onClick={() => editBrunoJson(collectionUid)}>
+ Edit bruno.json
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/FolderMenu.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/FolderMenu.tsx
new file mode 100644
index 0000000000..4d9be4e255
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/FolderMenu.tsx
@@ -0,0 +1,101 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Menu, rem } from '@mantine/core';
+import {
+ IconCopy,
+ IconDots,
+ IconFolderCog,
+ IconFolderOpen,
+ IconFolderPlus,
+ IconPencil,
+ IconPlus,
+ IconRun,
+ IconTrash
+} from '@tabler/icons-react';
+import React from 'react';
+import { useSidebarActions } from '../hooks/useSidebarActions';
+
+const ICON_STYLE = { width: rem(18), height: rem(18) };
+
+type FolderMenuProps = {
+ collectionUid: string;
+ itemUid: string;
+ onOpen: () => void;
+ onClose: () => void;
+};
+
+export const FolderMenu: React.FC = ({ collectionUid, itemUid, onClose, onOpen }) => {
+ const { setActiveAction, openRunner, openInExplorer, openFolderSettings } = useSidebarActions();
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ onClick={() => setActiveAction('new-request', collectionUid, itemUid)}
+ >
+ New Request
+
+
+ }
+ onClick={() => setActiveAction('new-folder', collectionUid, itemUid)}
+ >
+ New Folder
+
+
+ }
+ onClick={() => setActiveAction('rename', collectionUid, itemUid)}
+ >
+ Rename
+
+
+ }
+ onClick={() => setActiveAction('clone', collectionUid, itemUid)}
+ >
+ Clone
+
+
+ }
+ onClick={() => setActiveAction('delete', collectionUid, itemUid)}
+ >
+ Delete
+
+
+
+
+ } onClick={() => openRunner(collectionUid)}>
+ Run
+
+
+ }
+ onClick={() => openFolderSettings(collectionUid, itemUid)}
+ >
+ Collection settings
+
+
+
+
+ }
+ onClick={() => openInExplorer(collectionUid, itemUid)}
+ >
+ Open in Explorer
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/RequestMenu.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/RequestMenu.tsx
new file mode 100644
index 0000000000..11b945f1b0
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/RequestMenu.tsx
@@ -0,0 +1,90 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Menu, rem } from '@mantine/core';
+import {
+ IconCode,
+ IconCopy,
+ IconDots,
+ IconEdit,
+ IconFolderOpen,
+ IconPencil,
+ IconRun,
+ IconTrash
+} from '@tabler/icons-react';
+import React from 'react';
+import { useSidebarActions } from '../hooks/useSidebarActions';
+
+const ICON_STYLE = { width: rem(16), height: rem(16) };
+
+type RequestMenuProps = {
+ collectionUid: string;
+ itemUid: string;
+ onOpen: () => void;
+ onClose: () => void;
+};
+
+export const RequestMenu: React.FC = ({ collectionUid, itemUid, onClose, onOpen }) => {
+ const { setActiveAction, runRequest, openInEditor, openInExplorer } = useSidebarActions();
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ onClick={() => setActiveAction('rename', collectionUid, itemUid)}
+ >
+ Rename
+
+
+ }
+ onClick={() => setActiveAction('clone', collectionUid, itemUid)}
+ >
+ Clone
+
+
+ }
+ onClick={() => setActiveAction('delete', collectionUid, itemUid)}
+ >
+ Delete
+
+
+
+
+ } onClick={() => runRequest(collectionUid, itemUid)}>
+ Run
+
+
+ }
+ onClick={() => setActiveAction('generate', collectionUid, itemUid)}
+ >
+ Generate code
+
+
+
+
+ }
+ onClick={() => openInExplorer(collectionUid, itemUid)}
+ >
+ Open in Explorer
+
+
+ } onClick={() => openInEditor(collectionUid, itemUid)}>
+ Open in Editor
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/SidebarActionProvider.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/SidebarActionProvider.tsx
new file mode 100644
index 0000000000..473bb638f4
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/SidebarActionProvider.tsx
@@ -0,0 +1,248 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { SidebarActionContext, SidebarActionTypes } from '../provider/SidebarActionContext';
+import { useDispatch, useSelector } from 'react-redux';
+import { CollectionSchema, RequestItemSchema } from '@usebruno/schema';
+import { CloneCollectionModal } from './modals/CloneCollectionModal';
+import { CloneItemModal } from './modals/CloneItemModal';
+import { CloseCollectionModal } from './modals/CloseCollectionModal';
+import { DeleteItemModal } from './modals/DeleteItemModal';
+import { ExportCollectionModal } from './modals/ExportCollectionModal';
+import { NewFolderModal } from './modals/NewFolderModal';
+import { NewRequestModal } from './modals/NewRequestModal';
+import { RenameCollectionModal } from './modals/RenameCollectionModal';
+import { RenameItemModal } from './modals/RenameItemModal';
+import { getDefaultRequestPaneTab, isItemARequest } from 'utils/collections';
+import {
+ selectEnvironment,
+ sendRequest,
+ shellOpenCollectionPath
+} from 'providers/ReduxStore/slices/collections/actions';
+import { getCollectionAndItem } from '../util/getCollectionAndItem';
+import { hideHomePage } from 'providers/ReduxStore/slices/app';
+import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
+import { collectionClicked, collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
+import { uuid } from 'utils/common';
+import { CodeGeneratorModal } from 'src/feature/code-generator';
+
+type ActiveAction = {
+ type: SidebarActionTypes;
+ collection: any | CollectionSchema; // TODO: Refactor
+ item?: any | RequestItemSchema;
+};
+
+type ReduxState = {
+ collections: {
+ collections: CollectionSchema[];
+ };
+};
+
+type SidebarActionProviderProps = {
+ children: ReactNode;
+};
+
+export const SidebarActionProvider: React.FC = ({ children }) => {
+ const dispatch = useDispatch();
+ const { collections } = useSelector((state: ReduxState) => state.collections);
+ // Ref this here so the callbacks don't need be executed for every change in the collection
+ const collectionsRef = useRef(collections);
+ useEffect(() => {
+ collectionsRef.current = collections;
+ }, [collections]);
+
+ const [activeAction, setActiveActionState] = useState(null);
+
+ const setActiveAction = useCallback(
+ (type: SidebarActionTypes, collectionUid: string, itemUid: string | undefined) => {
+ const [collection, item] = getCollectionAndItem(collectionsRef.current, collectionUid, itemUid);
+ setActiveActionState({ type, item, collection });
+ },
+ []
+ );
+
+ const openInExplorer = useCallback((collectionUid: string, itemUid?: string) => {
+ const [collection, item] = getCollectionAndItem(collectionsRef.current, collectionUid, itemUid);
+ const path = item ? item.pathname : collection.pathname;
+ dispatch(shellOpenCollectionPath(path, !itemUid, false));
+ }, []);
+ const openInEditor = useCallback((collectionUid: string, itemUid: string) => {
+ const [_, item] = getCollectionAndItem(collectionsRef.current, collectionUid, itemUid);
+ dispatch(shellOpenCollectionPath(item.pathname, true, true));
+ }, []);
+ const editBrunoJson = useCallback((collectionUid: string) => {
+ const [collection] = getCollectionAndItem(collectionsRef.current, collectionUid);
+ dispatch(shellOpenCollectionPath(collection.pathname, true, true));
+ }, []);
+
+ const itemClicked = useCallback((collectionUid: string, itemUid?: string) => {
+ const [collection, item] = getCollectionAndItem(collectionsRef.current, collectionUid, itemUid);
+
+ if (!item) {
+ dispatch(collectionClicked(collection.uid));
+
+ // TODO: This should happen on the collection open event
+ // if collection doesn't have any active environment
+ // try to load last selected environment
+ if (!collection.activeEnvironmentUid) {
+ // @ts-expect-error
+ window.ipcRenderer
+ .invoke('renderer:get-last-selected-environment', collection.uid)
+ .then((lastSelectedEnvName: string) => {
+ const collectionEnvironments = collection.environments || [];
+ const lastSelectedEnvironment = collectionEnvironments.find((env) => env.name === lastSelectedEnvName);
+ if (lastSelectedEnvironment) {
+ dispatch(selectEnvironment(lastSelectedEnvironment.uid, collection.uid));
+ }
+ });
+ }
+ return;
+ }
+
+ if (isItemARequest(item)) {
+ setTimeout(() => {
+ // TODO: This is bad
+ const activeTab = document.querySelector('.request-tab.active');
+ if (activeTab) {
+ activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ }, 50);
+ dispatch(hideHomePage());
+ dispatch(
+ addTab({
+ uid: item.uid,
+ collectionUid: collection.uid,
+ requestPaneTab: getDefaultRequestPaneTab(item)
+ })
+ );
+ dispatch(
+ focusTab({
+ uid: item.uid
+ })
+ );
+ return;
+ }
+ dispatch(
+ collectionFolderClicked({
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ }, []);
+ const openCollectionSettings = useCallback((collectionUid: string) => {
+ dispatch(
+ addTab({
+ uid: uuid(),
+ collectionUid,
+ type: 'collection-settings'
+ })
+ );
+ }, []);
+ const openFolderSettings = useCallback((collectionUid: string, folderUid: string) => {
+ dispatch(
+ addTab({
+ uid: uuid(),
+ collectionUid,
+ folderUid,
+ type: 'folder-settings'
+ })
+ );
+ }, []);
+ const openRunner = useCallback((collectionUid: string) => {
+ // TODO: This needs to handle folder stuff
+ dispatch(
+ addTab({
+ uid: uuid(),
+ collectionUid,
+ type: 'collection-runner'
+ })
+ );
+ }, []);
+ const runRequest = useCallback((collectionUid: string, itemUid: string) => {
+ const [_, item] = getCollectionAndItem(collectionsRef.current, collectionUid, itemUid);
+ dispatch(sendRequest(item, collectionUid));
+ }, []);
+
+ const contextData = useMemo(
+ () => ({
+ setActiveAction,
+
+ openInExplorer,
+ openInEditor,
+ editBrunoJson,
+
+ itemClicked,
+ openCollectionSettings,
+ openFolderSettings,
+ openRunner,
+ runRequest
+ }),
+ []
+ );
+
+ return (
+
+ setActiveActionState(null)}
+ collectionName={activeAction?.collection.name}
+ collectionPath={activeAction?.collection}
+ />
+ setActiveActionState(null)}
+ collectionUid={activeAction?.collection.uid ?? ''}
+ item={activeAction?.item}
+ />
+ setActiveActionState(null)}
+ collection={activeAction?.collection}
+ />
+ setActiveActionState(null)}
+ collectionUid={activeAction?.collection.uid ?? ''}
+ item={activeAction?.item}
+ />
+ setActiveActionState(null)}
+ collection={activeAction?.collection}
+ />
+ setActiveActionState(null)}
+ collectionUid={activeAction?.collection.uid ?? ''}
+ />
+ setActiveActionState(null)}
+ brunoConfig={activeAction?.collection.brunoConfig}
+ collectionUid={activeAction?.collection.uid ?? ''}
+ itemUid={activeAction?.item?.uid ?? ''}
+ />
+ setActiveActionState(null)}
+ collection={activeAction?.collection}
+ />
+ setActiveActionState(null)}
+ collectionUid={activeAction?.collection.uid ?? ''}
+ item={activeAction?.item}
+ />
+ setActiveActionState(null)}
+ collectionUid={activeAction?.collection.uid}
+ requestUid={activeAction?.item?.uid}
+ />
+
+ {children}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloneCollectionModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloneCollectionModal.tsx
new file mode 100644
index 0000000000..7ac15ad252
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloneCollectionModal.tsx
@@ -0,0 +1,138 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, TextInput, Tooltip, rem } from '@mantine/core';
+import { useForm, zodResolver } from '@mantine/form';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle, IconHelp } from '@tabler/icons-react';
+import { DirectoryPicker } from 'components/inputs/DirectoryPicker';
+import { CollectionSchema } from '@usebruno/schema';
+import { z } from 'zod';
+import { useEffect } from 'react';
+
+const cloneCollectionFormSchema = z.object({
+ name: z.string().min(1),
+ folder: z
+ .string()
+ .min(1)
+ .max(50)
+ .regex(/^[\w\-. ]+$/),
+ location: z.string().min(1)
+});
+type CloneCollectionFormSchema = z.infer;
+
+type CloneCollectionModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collectionName: string;
+ collectionPath: string;
+};
+
+export const CloneCollectionModal: React.FC = ({
+ opened,
+ onClose,
+ collectionName,
+ collectionPath
+}) => {
+ const dispatch = useDispatch();
+
+ const cloneMutation = useMutation({
+ mutationFn: async (values: CloneCollectionFormSchema) => {
+ await dispatch(cloneCollection(values.name, values.folder, values.location, collectionPath));
+ },
+ onSuccess: () => {
+ toast.success('Cloned collection');
+ onClose();
+ }
+ });
+
+ const cloneForm = useForm({
+ validate: zodResolver(cloneCollectionFormSchema)
+ });
+ useEffect(() => {
+ cloneForm.setInitialValues({
+ name: `${collectionName} - Clone`,
+ location: '',
+ folder: `${collectionName} - Clone`
+ });
+ cloneForm.reset();
+ }, [collectionName]);
+
+ return (
+ {
+ onClose();
+ cloneForm.reset();
+ cloneMutation.reset();
+ }}
+ title="Clone collection"
+ >
+ Cloning collection "{collectionName}"
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloneItemModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloneItemModal.tsx
new file mode 100644
index 0000000000..ec7295e520
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloneItemModal.tsx
@@ -0,0 +1,102 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, TextInput, rem } from '@mantine/core';
+import { useForm, zodResolver } from '@mantine/form';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { RequestItemSchema } from '@usebruno/schema';
+import { z } from 'zod';
+import { useEffect } from 'react';
+
+const cloneItemCollectionSchema = z.object({
+ name: z.string().min(1)
+});
+type CloneCollectionFormSchema = z.infer;
+
+type CloneItemModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collectionUid: string;
+ item: RequestItemSchema;
+};
+
+export const CloneItemModal: React.FC = ({ opened, onClose, collectionUid, item }) => {
+ const dispatch = useDispatch();
+
+ const cloneForm = useForm({
+ validate: zodResolver(cloneItemCollectionSchema)
+ });
+ useEffect(() => {
+ cloneForm.setInitialValues({
+ name: `${item?.name} - Clone`
+ });
+ cloneForm.reset();
+ }, [item?.name]);
+
+ const cloneMutation = useMutation({
+ mutationFn: async (values: CloneCollectionFormSchema) => {
+ await dispatch(cloneItem(values.name, item.uid, collectionUid));
+ },
+ onSuccess: () => {
+ toast.success('Cloned request');
+ onClose();
+ cloneForm.reset();
+ }
+ });
+
+ return (
+ {
+ onClose();
+ cloneForm.reset();
+ cloneMutation.reset();
+ }}
+ title="Clone collection"
+ >
+ Cloning collection "{item?.name}"
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloseCollectionModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloseCollectionModal.tsx
new file mode 100644
index 0000000000..d4a9c0c2bc
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/CloseCollectionModal.tsx
@@ -0,0 +1,75 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, rem } from '@mantine/core';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { CollectionSchema } from '@usebruno/schema';
+import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
+
+type CloseCollectionModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collection: CollectionSchema;
+};
+
+export const CloseCollectionModal: React.FC = ({ opened, onClose, collection }) => {
+ const dispatch = useDispatch();
+
+ const closeMutation = useMutation({
+ mutationFn: async () => {
+ await dispatch(removeCollection(collection.uid));
+ },
+ onSuccess: () => {
+ toast.success('Closed collection');
+ onClose();
+ }
+ });
+
+ return (
+ {
+ onClose();
+ closeMutation.reset();
+ }}
+ title="Close collection"
+ >
+ Do you want to close "{collection?.name}"?
+ It will still be available in the file system at the above location and can be re-opened later.
+
+ {closeMutation.error ? (
+ } mt={'md'}>
+ {String(closeMutation.error)}
+
+ ) : null}
+
+
+ {
+ onClose();
+ closeMutation.reset();
+ }}
+ disabled={closeMutation.isPending}
+ >
+ Cancel
+
+ {
+ closeMutation.mutate();
+ }}
+ >
+ Close
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/DeleteItemModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/DeleteItemModal.tsx
new file mode 100644
index 0000000000..79986de224
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/DeleteItemModal.tsx
@@ -0,0 +1,84 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, rem } from '@mantine/core';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { RequestItemSchema } from '@usebruno/schema';
+import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
+import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { recursivelyGetAllItemUids } from 'utils/collections';
+
+type DeleteItemModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collectionUid: string;
+ item?: RequestItemSchema;
+};
+
+export const DeleteItemModal: React.FC = ({ opened, onClose, collectionUid, item }) => {
+ const dispatch = useDispatch();
+
+ const deleteMutation = useMutation({
+ mutationFn: async () => {
+ await dispatch(deleteItem(item.uid, collectionUid));
+
+ const tabUids = item.type === 'folder' ? recursivelyGetAllItemUids(item.items) : [item.uid];
+ dispatch(
+ closeTabs({
+ tabUids
+ })
+ );
+ },
+ onSuccess: () => {
+ toast.success(`Deleted ${item.type === 'folder' ? 'folder' : 'request'}`);
+ onClose();
+ }
+ });
+
+ return (
+ {
+ onClose();
+ deleteMutation.reset();
+ }}
+ title="Delete request"
+ >
+ Do you really want to delete "{item?.name}"?
+
+ {deleteMutation.error ? (
+ } mt={'md'}>
+ {String(deleteMutation.error)}
+
+ ) : null}
+
+
+ {
+ onClose();
+ deleteMutation.reset();
+ }}
+ disabled={deleteMutation.isPending}
+ >
+ Cancel
+
+ {
+ deleteMutation.mutate();
+ }}
+ >
+ Delete
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/ExportCollectionModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/ExportCollectionModal.tsx
new file mode 100644
index 0000000000..c523a6a414
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/ExportCollectionModal.tsx
@@ -0,0 +1,86 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Radio, Stack, Text, rem } from '@mantine/core';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { CollectionSchema } from '@usebruno/schema';
+import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
+import exportBrunoCollection from 'utils/collections/export';
+import exportPostmanCollection from 'utils/exporters/postman-collection';
+import cloneDeep from 'lodash/cloneDeep';
+import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
+import { z } from 'zod';
+import { useForm, zodResolver } from '@mantine/form';
+import { useCallback } from 'react';
+
+const exportCollectionFormSchema = z.object({
+ type: z.enum(['bruno', 'postman'], { message: 'Please select the export format' })
+});
+type ExportCollectionFormSchema = z.infer;
+
+type CloseCollectionModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collection: CollectionSchema;
+};
+
+export const ExportCollectionModal: React.FC = ({ opened, onClose, collection }) => {
+ const handleSubmit = useCallback(
+ ({ type }: ExportCollectionFormSchema) => {
+ const collectionCopy = cloneDeep(collection);
+ switch (type) {
+ case 'bruno':
+ exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
+ break;
+ case 'postman':
+ exportPostmanCollection(collectionCopy);
+ break;
+ }
+ onClose();
+ },
+ [collection]
+ );
+
+ const exportForm = useForm({
+ validate: zodResolver(exportCollectionFormSchema)
+ });
+
+ return (
+ {
+ onClose();
+ exportForm.reset();
+ }}
+ title="Export collection"
+ >
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/NewFolderModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/NewFolderModal.tsx
new file mode 100644
index 0000000000..9b3881fed3
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/NewFolderModal.tsx
@@ -0,0 +1,100 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, TextInput, rem } from '@mantine/core';
+import { useForm, zodResolver } from '@mantine/form';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { z } from 'zod';
+
+const newFolderFormSchema = z.object({
+ name: z.string().min(1)
+});
+type NewFolderFormSchema = z.infer;
+
+type NewFolderModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collectionUid: string;
+ itemUid?: string;
+};
+
+export const NewFolderModal: React.FC = ({ opened, onClose, collectionUid, itemUid }) => {
+ const dispatch = useDispatch();
+
+ const newFolderForm = useForm({
+ validate: zodResolver(newFolderFormSchema),
+ initialValues: {
+ name: ''
+ }
+ });
+
+ const newFolderMutation = useMutation({
+ mutationFn: async (values: NewFolderFormSchema) => {
+ await dispatch(newFolder(values.name, collectionUid, null));
+ },
+ onSuccess: () => {
+ toast.success('Folder created');
+ newFolderForm.reset();
+ onClose();
+ }
+ });
+
+ return (
+ {
+ onClose();
+ newFolderForm.reset();
+ newFolderMutation.reset();
+ }}
+ title="New folder"
+ >
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/NewRequestModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/NewRequestModal.tsx
new file mode 100644
index 0000000000..ed302eb8e6
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/NewRequestModal.tsx
@@ -0,0 +1,185 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Radio, TextInput, Textarea, rem } from '@mantine/core';
+import { useForm, zodResolver } from '@mantine/form';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { z } from 'zod';
+import { getRequestFromCurlCommand } from 'utils/curl';
+import { BrunoConfigSchema } from '@usebruno/schema';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { MethodSelector } from 'src/feature/request-url-bar';
+import { useEffect } from 'react';
+
+const newRequestFormSchema = z.discriminatedUnion('type', [
+ z.object({
+ type: z.enum(['http-request', 'graphql-request']),
+ name: z.string().min(1).max(255),
+ method: z.string().min(1),
+ url: z.string()
+ }),
+ z.object({
+ type: z.literal('from-curl'),
+ name: z.string().min(1, 'Name is required'),
+ curlCommand: z.string().min(1, 'Curl command is required')
+ })
+]);
+type NewFolderFormSchema = z.infer;
+
+type NewRequestModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collectionUid: string;
+ brunoConfig: BrunoConfigSchema;
+ itemUid?: string;
+};
+
+export const NewRequestModal: React.FC = ({
+ opened,
+ onClose,
+ collectionUid,
+ itemUid,
+ brunoConfig
+}) => {
+ const dispatch = useDispatch();
+
+ const newRequestMutation = useMutation({
+ mutationFn: async (values: NewFolderFormSchema) => {
+ switch (values.type) {
+ case 'from-curl':
+ const request = getRequestFromCurlCommand(values.curlCommand);
+ if (!request) {
+ throw new Error('Could not generate request from cURL');
+ }
+ dispatch(
+ newHttpRequest({
+ requestName: values.name,
+ requestType: 'http-request',
+ requestUrl: request.url,
+ requestMethod: request.method,
+ collectionUid,
+ itemUid,
+ headers: request.headers,
+ body: request.body
+ })
+ );
+ return;
+ case 'http-request':
+ case 'graphql-request':
+ await dispatch(
+ newHttpRequest({
+ requestName: values.name,
+ requestType: values.type,
+ requestUrl: values.url,
+ requestMethod: values.method,
+ collectionUid,
+ itemUid
+ })
+ );
+ return;
+ default:
+ // @ts-expect-error
+ throw new Error(`Unknown request type: "${values.type}"`);
+ }
+ },
+ onSuccess: () => {
+ toast.success('Request created');
+ onClose();
+ }
+ });
+
+ const newRequestForm = useForm({
+ validate: zodResolver(newRequestFormSchema)
+ });
+ useEffect(() => {
+ newRequestForm.setInitialValues({
+ name: '',
+ method: 'GET',
+ type: brunoConfig?.presets?.requestType === 'graphql' ? 'graphql-request' : 'http-request',
+ url: brunoConfig?.presets?.requestUrl
+ });
+ newRequestForm.reset();
+ }, [collectionUid]);
+
+ return (
+ {
+ onClose();
+ newRequestForm.reset();
+ newRequestMutation.reset();
+ }}
+ title="New request"
+ size={'lg'}
+ >
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/RenameCollectionModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/RenameCollectionModal.tsx
new file mode 100644
index 0000000000..cba653a3c3
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/RenameCollectionModal.tsx
@@ -0,0 +1,100 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, TextInput, rem } from '@mantine/core';
+import { useForm, zodResolver } from '@mantine/form';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { CollectionSchema } from '@usebruno/schema';
+import { z } from 'zod';
+import { useEffect } from 'react';
+
+const renameCollectionFormSchema = z.object({
+ name: z.string().min(1)
+});
+type RenameCollectionFormSchema = z.infer;
+
+type RenameCollectionModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collection: CollectionSchema;
+};
+
+export const RenameCollectionModal: React.FC = ({ opened, onClose, collection }) => {
+ const dispatch = useDispatch();
+
+ const renameMutation = useMutation({
+ mutationFn: async (values: RenameCollectionFormSchema) => {
+ await dispatch(renameCollection(values.name, collection.pathname));
+ },
+ onSuccess: () => {
+ toast.success('Renamed collection');
+ onClose();
+ }
+ });
+
+ const renameForm = useForm({
+ validate: zodResolver(renameCollectionFormSchema)
+ });
+ useEffect(() => {
+ renameForm.setInitialValues({
+ name: `${collection?.name}`
+ });
+ renameForm.reset();
+ }, [collection?.name]);
+
+ return (
+ {
+ onClose();
+ renameForm.reset();
+ renameMutation.reset();
+ }}
+ title="Rename collection"
+ >
+ Rename collection "{collection?.name}"
+
+ {
+ renameMutation.mutate(values);
+ })}
+ >
+
+
+ {renameMutation.error ? (
+ } mt={'md'}>
+ {String(renameMutation.error)}
+
+ ) : null}
+
+
+ {
+ onClose();
+ renameForm.reset();
+ renameMutation.reset();
+ }}
+ disabled={renameMutation.isPending}
+ >
+ Cancel
+
+
+ Rename
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/components/modals/RenameItemModal.tsx b/packages/bruno-app/src/feature/sidebar-menu/components/modals/RenameItemModal.tsx
new file mode 100644
index 0000000000..baa1ba7383
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/components/modals/RenameItemModal.tsx
@@ -0,0 +1,108 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Alert, Button, Group, Modal, Text, TextInput, rem } from '@mantine/core';
+import { useForm, zodResolver } from '@mantine/form';
+import { useMutation } from '@tanstack/react-query';
+import { useDispatch } from 'react-redux';
+import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import toast from 'react-hot-toast';
+import { IconAlertCircle } from '@tabler/icons-react';
+import { RequestItemSchema } from '@usebruno/schema';
+import { z } from 'zod';
+import { useEffect } from 'react';
+
+const renameItemFormSchema = z.object({
+ name: z.string().min(1)
+});
+type RenameItemFormSchema = z.infer;
+
+type RenameItemModalProps = {
+ opened: boolean;
+ onClose: () => void;
+ collectionUid: string;
+ item: RequestItemSchema;
+};
+
+export const RenameItemModal: React.FC = ({ opened, onClose, collectionUid, item }) => {
+ const dispatch = useDispatch();
+
+ const renameForm = useForm({
+ validate: zodResolver(renameItemFormSchema)
+ });
+ useEffect(() => {
+ renameForm.setInitialValues({
+ name: `${item?.name}`
+ });
+ renameForm.reset();
+ }, [item?.name]);
+
+ const renameMutation = useMutation({
+ mutationFn: async (values: RenameItemFormSchema) => {
+ // if there is unsaved changes in the request,
+ // save them before renaming the request
+ if ((item.type === 'http-request' || item.type === 'folder') && item.draft) {
+ await dispatch(saveRequest(item.uid, collectionUid, true));
+ }
+ await dispatch(renameItem(values.name, item.uid, collectionUid));
+ onClose();
+ },
+ onSuccess: (_, values) => {
+ onClose();
+ toast.success(`Renamed from "${item.name}" to "${values.name}"`);
+ renameForm.reset();
+ }
+ });
+
+ return (
+ {
+ onClose();
+ renameForm.reset();
+ renameMutation.reset();
+ }}
+ title="Rename request"
+ >
+ Rename "{item?.name}"
+
+ {
+ renameMutation.mutate(values);
+ })}
+ >
+
+
+ {renameMutation.error ? (
+ } mt={'md'}>
+ {String(renameMutation.error)}
+
+ ) : null}
+
+
+ {
+ onClose();
+ renameForm.reset();
+ renameMutation.reset();
+ }}
+ disabled={renameMutation.isPending}
+ >
+ Cancel
+
+
+ Rename
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/hooks/useSidebarActions.ts b/packages/bruno-app/src/feature/sidebar-menu/hooks/useSidebarActions.ts
new file mode 100644
index 0000000000..d3eabe93e2
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/hooks/useSidebarActions.ts
@@ -0,0 +1,14 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { useContext } from 'react';
+import { SidebarActionContext } from '../provider/SidebarActionContext';
+
+export const useSidebarActions = () => {
+ const actions = useContext(SidebarActionContext);
+ if (actions === null) {
+ throw new Error('useSidebarActions must be used within the SidebarActionProvider');
+ }
+ return actions;
+};
diff --git a/packages/bruno-app/src/feature/sidebar-menu/index.ts b/packages/bruno-app/src/feature/sidebar-menu/index.ts
new file mode 100644
index 0000000000..4d4d16f402
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/index.ts
@@ -0,0 +1,9 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+export { CollectionMenu } from './components/CollectionMenu';
+export { FolderMenu } from './components/FolderMenu';
+export { RequestMenu } from './components/RequestMenu';
+export { SidebarActionProvider } from './components/SidebarActionProvider';
+export { useSidebarActions } from './hooks/useSidebarActions';
diff --git a/packages/bruno-app/src/feature/sidebar-menu/provider/SidebarActionContext.ts b/packages/bruno-app/src/feature/sidebar-menu/provider/SidebarActionContext.ts
new file mode 100644
index 0000000000..3d7cc5451b
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/provider/SidebarActionContext.ts
@@ -0,0 +1,34 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { createContext } from 'react';
+
+export type SidebarActionTypes =
+ | 'generate'
+ | 'clone'
+ | 'delete'
+ | 'rename'
+ | 'new-request'
+ | 'new-folder'
+ | 'clone-collection'
+ | 'close-collection'
+ | 'export-collection'
+ | 'rename-collection'
+ | null;
+
+export type SidebarActionContextData = {
+ setActiveAction: (type: SidebarActionTypes, collectionUid: string, itemUid?: string) => void;
+
+ openInExplorer: (collectionUid: string, itemUid?: string) => void;
+ openInEditor: (collectionUid: string, itemUid: string) => void;
+ editBrunoJson: (collectionUid: string) => void;
+
+ itemClicked: (collectionUid: string, itemUid?: string) => void;
+ openCollectionSettings: (collectionUid: string) => void;
+ openFolderSettings: (collectionUid: string, folderUid: string) => void;
+ openRunner: (collectionUid: string) => void;
+ runRequest: (collectionUid: string, itemUid: string) => void;
+};
+
+export const SidebarActionContext = createContext(null);
diff --git a/packages/bruno-app/src/feature/sidebar-menu/util/getCollectionAndItem.ts b/packages/bruno-app/src/feature/sidebar-menu/util/getCollectionAndItem.ts
new file mode 100644
index 0000000000..77ac7e09cb
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar-menu/util/getCollectionAndItem.ts
@@ -0,0 +1,41 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { CollectionSchema, RequestItemSchema } from '@usebruno/schema';
+import { findCollectionByUid, findItemInCollection } from 'utils/collections';
+
+export function getCollectionAndItem(
+ collections: CollectionSchema[],
+ collectionUid: string
+): [CollectionSchema, undefined];
+export function getCollectionAndItem(
+ collections: CollectionSchema[],
+ collectionUid: string,
+ itemUid: string
+): [CollectionSchema, RequestItemSchema];
+export function getCollectionAndItem(
+ collections: CollectionSchema[],
+ collectionUid: string,
+ itemUid?: string
+): [CollectionSchema, RequestItemSchema | undefined];
+export function getCollectionAndItem(
+ collections: CollectionSchema[],
+ collectionUid: string,
+ itemUid?: string
+): [CollectionSchema, RequestItemSchema | undefined] {
+ const collection = findCollectionByUid(collections, collectionUid);
+ if (!collection) {
+ throw new Error(`No collection with id ${collectionUid} found`);
+ }
+
+ if (itemUid) {
+ const item = findItemInCollection(collection, itemUid);
+ if (!item) {
+ throw new Error(`No item with id ${itemUid} in ${collection.name} found!`);
+ }
+ return [collection, item];
+ }
+
+ return [collection, undefined];
+}
diff --git a/packages/bruno-app/src/feature/sidebar/components/BottomButtons.tsx b/packages/bruno-app/src/feature/sidebar/components/BottomButtons.tsx
new file mode 100644
index 0000000000..139cc48929
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/BottomButtons.tsx
@@ -0,0 +1,42 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Group, Text, Tooltip, rem } from '@mantine/core';
+import { IconSettings, IconCookie } from '@tabler/icons-react';
+import Cookies from 'components/Cookies';
+import Preferences from 'components/Preferences';
+import React, { useState } from 'react';
+
+export const BottomButtons: React.FC = () => {
+ const [preferencesOpen, setPreferencesOpen] = useState(false);
+ const [cookiesOpen, setCookiesOpen] = useState(false);
+
+ return (
+
+
+
+ setPreferencesOpen(true)}>
+
+
+
+
+
+ setCookiesOpen(true)}>
+
+
+
+
+ {preferencesOpen && setPreferencesOpen(false)} />}
+ {cookiesOpen && setCookiesOpen(false)} />}
+
+
+ v1.18.0-lazer
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/CollectionFilter.tsx b/packages/bruno-app/src/feature/sidebar/components/CollectionFilter.tsx
new file mode 100644
index 0000000000..f9aa761300
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/CollectionFilter.tsx
@@ -0,0 +1,78 @@
+import { ActionIcon, CloseButton, Group, TextInput, Tooltip, rem } from '@mantine/core';
+import { useDebouncedState, useDebouncedValue } from '@mantine/hooks';
+import { IconArrowsSort, IconSearch, IconSortAscendingLetters, IconSortDescendingLetters } from '@tabler/icons-react';
+import { sortCollections, filterCollections } from 'providers/ReduxStore/slices/collections/actions';
+import React, { useEffect, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+type ReduxState = {
+ collections: {
+ collectionSortOrder: 'default' | 'alphabetical' | 'reverseAlphabetical';
+ collectionFilter: string;
+ };
+};
+
+export const CollectionFilter: React.FC = () => {
+ const dispatch = useDispatch();
+ const { collectionSortOrder, collectionFilter } = useSelector((state: ReduxState) => state.collections);
+
+ const [searchValue, setSearchValue] = useState(collectionFilter ?? '');
+ const [debounced] = useDebouncedValue(searchValue, 200);
+ useEffect(() => {
+ dispatch(filterCollections({ filter: debounced }));
+ }, [debounced]);
+
+ const sortCollectionOrder = () => {
+ let order;
+ switch (collectionSortOrder) {
+ case 'default':
+ order = 'alphabetical';
+ break;
+ case 'alphabetical':
+ order = 'reverseAlphabetical';
+ break;
+ case 'reverseAlphabetical':
+ order = 'default';
+ break;
+ }
+ dispatch(sortCollections({ order }));
+ };
+
+ return (
+
+ setSearchValue(evt.currentTarget.value)}
+ flex={1}
+ size="xs"
+ leftSection={ }
+ rightSectionPointerEvents="all"
+ rightSection={
+ setSearchValue('')}
+ style={{ display: searchValue ? undefined : 'none' }}
+ />
+ }
+ />
+
+
+
+ {collectionSortOrder == 'default' ? (
+
+ ) : collectionSortOrder == 'alphabetical' ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/CollectionItem.tsx b/packages/bruno-app/src/feature/sidebar/components/RequestList/CollectionItem.tsx
new file mode 100644
index 0000000000..d49560d3da
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/CollectionItem.tsx
@@ -0,0 +1,27 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { IconChevronDown } from '@tabler/icons-react';
+import { RequestItemWrapper } from './RequestItemWrapper';
+import classes from './Item.module.scss';
+import { CSSProperties } from 'react';
+
+type CollectionItemProps = {
+ type: 'collection';
+ name: string;
+ uid: string;
+ collapsed: boolean;
+ style: CSSProperties;
+};
+
+export const CollectionItem: React.FC = ({ collapsed, name, type, uid, style }) => {
+ return (
+
+
+
+ {name}
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/FolderItem.tsx b/packages/bruno-app/src/feature/sidebar/components/RequestList/FolderItem.tsx
new file mode 100644
index 0000000000..45fbb79574
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/FolderItem.tsx
@@ -0,0 +1,34 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { IconChevronDown } from '@tabler/icons-react';
+import { RequestItemWrapper } from './RequestItemWrapper';
+import classes from './Item.module.scss';
+import { CSSProperties } from 'react';
+
+type FolderItemProps = {
+ type: 'folder';
+ name: string;
+ uid: string;
+ collectionUid: string;
+ collapsed: boolean;
+ indent: number;
+ style: CSSProperties;
+};
+
+export const FolderItem: React.FC = ({ collapsed, name, type, uid, collectionUid, indent, style }) => {
+ return (
+
+
+ {name}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/Item.module.scss b/packages/bruno-app/src/feature/sidebar/components/RequestList/Item.module.scss
new file mode 100644
index 0000000000..4478b3f854
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/Item.module.scss
@@ -0,0 +1,29 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+.wrapper {
+ display: grid;
+ grid-template-rows: 100%;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ gap: var(--mantine-spacing-xs);
+}
+
+.text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ user-select: none;
+ text-align: left;
+}
+
+.icon {
+ width: 22px;
+ height: 22px;
+ transition: transform 0.3s ease;
+
+ &[data-collapsed='true'] {
+ transform: rotate(-90deg);
+ }
+}
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItem.tsx b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItem.tsx
new file mode 100644
index 0000000000..a069a1db41
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItem.tsx
@@ -0,0 +1,45 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { RequestItemWrapper } from './RequestItemWrapper';
+import classes from './Item.module.scss';
+import { RequestItemMethodIcon } from './RequestItemMethodIcon';
+import { CSSProperties } from 'react';
+
+type RequestItemProps = {
+ type: 'request';
+ name: string;
+ uid: string;
+ collectionUid: string;
+ method: string;
+ indent: number;
+ active: boolean;
+ style: CSSProperties;
+};
+
+export const RequestItem: React.FC = ({
+ name,
+ type,
+ uid,
+ collectionUid,
+ indent,
+ method,
+ active,
+ style
+}) => {
+ return (
+
+
+ {name}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemMethodIcon.tsx b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemMethodIcon.tsx
new file mode 100644
index 0000000000..d19bbbb4e1
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemMethodIcon.tsx
@@ -0,0 +1,40 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import classes from './Item.module.scss';
+import {
+ IconHttpDelete,
+ IconHttpGet,
+ IconHttpHead,
+ IconHttpOptions,
+ IconHttpPatch,
+ IconHttpPost,
+ IconHttpPut,
+ IconSend
+} from '@tabler/icons-react';
+
+type RequestItemMethodIconProps = {
+ method: string;
+};
+
+export const RequestItemMethodIcon: React.FC = ({ method }) => {
+ switch (method) {
+ case 'GET':
+ return ;
+ case 'POST':
+ return ;
+ case 'PUT':
+ return ;
+ case 'DELETE':
+ return ;
+ case 'PATCH':
+ return ;
+ case 'OPTIONS':
+ return ;
+ case 'HEAD':
+ return ;
+ default:
+ return ;
+ }
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemWrapper.module.scss b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemWrapper.module.scss
new file mode 100644
index 0000000000..c9b65533a5
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemWrapper.module.scss
@@ -0,0 +1,47 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+.box {
+ height: 36px;
+ padding: calc(var(--mantine-spacing-xs) / 2) var(--mantine-spacing-xs);
+ display: grid;
+ grid-template-rows: 100%;
+ grid-template-columns: 1fr auto;
+ gap: var(--mantine-spacing-xs);
+ position: relative;
+
+ &[data-active='true'] {
+ background-color: var(--mantine-primary-color-filled);
+ }
+
+ &[data-drop-hovered='true'] {
+ &::after {
+ content: '';
+ position: absolute;
+ width: 100%;
+ height: 5px;
+ bottom: 0;
+ background-color: var(--mantine-color-bright);
+ }
+ }
+
+ &:hover {
+ background-color: var(--mantine-color-default-hover);
+ cursor: pointer;
+ &[data-active='true'] {
+ background-color: var(--mantine-primary-color-filled-hover);
+ }
+ }
+
+ &:active:not(:has(button:active)) {
+ transform: translateY(calc(0.0625rem * var(--mantine-scale)));
+ }
+
+ // This will prevent the collection list div from overflowing and shit
+ & > * {
+ min-height: 0;
+ min-width: 0;
+ overflow: hidden;
+ }
+}
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemWrapper.tsx b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemWrapper.tsx
new file mode 100644
index 0000000000..f73dc894d2
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestItemWrapper.tsx
@@ -0,0 +1,95 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import React, { CSSProperties, ReactNode, useMemo, useState } from 'react';
+import classes from './RequestItemWrapper.module.scss';
+import { CollectionMenu, FolderMenu, RequestMenu } from 'src/feature/sidebar-menu';
+import { useSidebarActions } from 'src/feature/sidebar-menu/hooks/useSidebarActions';
+import { useDrag, useDrop } from 'react-dnd';
+import { useDispatch } from 'react-redux';
+import { moveItem, moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
+
+type RequestItemWrapperProps = {
+ uid?: string;
+ collectionUid: string;
+ children: ReactNode;
+ type: 'collection' | 'request' | 'folder';
+ indent: number;
+ className?: string;
+ active?: boolean;
+ style?: CSSProperties;
+};
+
+export const RequestItemWrapper: React.FC = ({
+ uid,
+ collectionUid,
+ children,
+ type,
+ indent,
+ className,
+ active = false,
+ style
+}) => {
+ const dispatch = useDispatch();
+ const { itemClicked } = useSidebarActions();
+ const [hover, setHover] = useState(false);
+ const [menuOpened, setMenuOpened] = useState(false);
+
+ const menu = useMemo(() => {
+ const onOpen = () => setMenuOpened(true);
+ const onClose = () => setMenuOpened(false);
+ switch (type) {
+ case 'collection':
+ return ;
+ case 'request':
+ return ;
+ case 'folder':
+ return ;
+ }
+ }, [type]);
+
+ const [, drag] = useDrag({
+ type: `COLLECTION_ITEM_${collectionUid}`,
+ item: { uid: uid ?? collectionUid },
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging()
+ })
+ });
+
+ const [{ isOverCurrent }, drop] = useDrop({
+ accept: `COLLECTION_ITEM_${collectionUid}`,
+ // Defined in useDrag
+ drop: (draggedItem: { uid: string }) => {
+ if (draggedItem.uid !== uid && draggedItem.uid !== collectionUid) {
+ if (uid) {
+ dispatch(moveItem(collectionUid, draggedItem.uid, uid));
+ } else {
+ dispatch(moveItemToRootOfCollection(collectionUid, draggedItem.uid));
+ }
+ }
+ },
+ collect: (monitor) => ({
+ isOverCurrent: monitor.isOver({ shallow: true })
+ })
+ });
+
+ return (
+ setHover(true)}
+ onMouseLeave={() => setHover(false)}
+ onClick={() => itemClicked(collectionUid, uid)}
+ data-active={active}
+ data-drop-hovered={isOverCurrent && type === 'request'}
+ ref={(ref) => drag(drop(ref))}
+ style={style}
+ >
+
+ {children}
+
+
evt.stopPropagation()}>{hover || menuOpened ? menu : null}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestList.tsx b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestList.tsx
new file mode 100644
index 0000000000..d151708deb
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/RequestList/RequestList.tsx
@@ -0,0 +1,52 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import React from 'react';
+import { useRequestList } from '../../hooks/useRequestList';
+import { CollectionItem } from './CollectionItem';
+import { RequestItem } from './RequestItem';
+import { FolderItem } from './FolderItem';
+import { FixedSizeList, ListChildComponentProps } from 'react-window';
+import { RequestListItem } from '../../types/requestList';
+import AutoSizer from 'react-virtualized-auto-sizer';
+
+const Row: React.FC> = ({ index, style, data }) => {
+ const item = data[index];
+ if (!item) {
+ return null;
+ }
+
+ switch (item.type) {
+ case 'collection':
+ return ;
+ case 'folder':
+ return ;
+ case 'request':
+ return ;
+ default:
+ // @ts-expect-error
+ return Unknown type {item.type}
;
+ }
+};
+
+export const RequestList: React.FC = () => {
+ const items = useRequestList();
+
+ return (
+
+ {({ height, width }) => (
+
+ {Row}
+
+ )}
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/ResizableSidebarBox.module.scss b/packages/bruno-app/src/feature/sidebar/components/ResizableSidebarBox.module.scss
new file mode 100644
index 0000000000..190989dae4
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/ResizableSidebarBox.module.scss
@@ -0,0 +1,49 @@
+.aside {
+ position: relative;
+ display: grid;
+ grid-template-columns: 100%;
+ grid-template-rows: auto auto 1fr auto;
+ max-height: 100vh;
+ width: 250px;
+ min-width: 240px;
+ max-width: 500px;
+
+ // This will prevent hover and events and stuff be applied, while dragging
+ &[data-dragging='true'] {
+ > *:not(:first-child) {
+ pointer-events: none;
+ }
+ }
+
+ // This will prevent the collection list div from overflowing and shit
+ & > * {
+ min-height: 0;
+ }
+}
+
+.resizable {
+ position: absolute;
+ right: -0.3rem;
+ width: 0.5rem;
+ height: 100%;
+ z-index: 2;
+
+ cursor: col-resize;
+
+ &::after {
+ content: '';
+ position: absolute;
+ width: 1px;
+ height: 100%;
+ left: 0.2rem;
+ background-color: var(--mantine-color-default-border);
+ }
+
+ &:hover::after {
+ background-color: var(--mantine-primary-color-filled-hover);
+ }
+ &:active::after {
+ background-color: var(--mantine-primary-color-filled);
+ width: 2px;
+ }
+}
diff --git a/packages/bruno-app/src/feature/sidebar/components/ResizableSidebarBox.tsx b/packages/bruno-app/src/feature/sidebar/components/ResizableSidebarBox.tsx
new file mode 100644
index 0000000000..81292e11ff
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/ResizableSidebarBox.tsx
@@ -0,0 +1,44 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { MouseEventHandler, ReactElement, useCallback, useRef } from 'react';
+import classes from './ResizableSidebarBox.module.scss';
+
+type ResizableSidebarBoxProps = {
+ children: [ReactElement, ReactElement, ReactElement, ReactElement];
+};
+
+export const ResizableSidebarBox: React.FC = ({ children }) => {
+ const aside = useRef(null);
+
+ const onDragStart: MouseEventHandler = useCallback((evt) => {
+ evt.preventDefault(); // This prevents text select
+
+ const mouseMoveListener = (evt: MouseEvent) => {
+ if (!aside.current) {
+ return;
+ }
+ aside.current.style.width = `${evt.clientX}px`;
+ aside.current.dataset.dragging = 'true';
+ };
+ document.addEventListener('mousemove', mouseMoveListener);
+
+ const dragEndListener = () => {
+ document.removeEventListener('mousemove', mouseMoveListener);
+ document.removeEventListener('mouseup', dragEndListener);
+ if (aside.current) {
+ aside.current.dataset.dragging = 'false';
+ }
+ };
+ document.addEventListener('mouseup', dragEndListener);
+ }, []);
+
+ return (
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/Sidebar.tsx b/packages/bruno-app/src/feature/sidebar/components/Sidebar.tsx
new file mode 100644
index 0000000000..8a7aa5bc4c
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/Sidebar.tsx
@@ -0,0 +1,29 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { SidebarActionProvider } from 'src/feature/sidebar-menu';
+import { BottomButtons } from './BottomButtons';
+import { RequestList } from './RequestList/RequestList';
+import { ResizableSidebarBox } from './ResizableSidebarBox';
+import { TopPanel } from './TopPanel';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+import { CollectionFilter } from './CollectionFilter';
+
+export const Sidebar: React.FC = ({}) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/components/TopPanel.tsx b/packages/bruno-app/src/feature/sidebar/components/TopPanel.tsx
new file mode 100644
index 0000000000..166f7629a4
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/components/TopPanel.tsx
@@ -0,0 +1,110 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { ActionIcon, Group, Text, Tooltip, rem } from '@mantine/core';
+import { IconPackageImport, IconFolderOpen, IconPlus } from '@tabler/icons-react';
+import Bruno from 'components/Bruno';
+import CreateCollection from 'components/Sidebar/CreateCollection';
+import ImportCollection from 'components/Sidebar/ImportCollection';
+import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
+import { showHomePage } from 'providers/ReduxStore/slices/app';
+import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
+import { useState } from 'react';
+import toast from 'react-hot-toast';
+import { useDispatch } from 'react-redux';
+
+export const TopPanel: React.FC = () => {
+ const [importedCollection, setImportedCollection] = useState(null);
+ const [importedTranslationLog, setImportedTranslationLog] = useState({});
+ const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
+ const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
+ const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
+ const dispatch = useDispatch();
+
+ const handleImportCollection = ({ collection, translationLog }) => {
+ setImportedCollection(collection);
+ if (translationLog) {
+ setImportedTranslationLog(translationLog);
+ }
+ setImportCollectionModalOpen(false);
+ setImportCollectionLocationModalOpen(true);
+ };
+
+ const handleImportCollectionLocation = (collectionLocation) => {
+ dispatch(importCollection(importedCollection, collectionLocation))
+ // @ts-expect-error
+ .then(() => {
+ setImportCollectionLocationModalOpen(false);
+ setImportedCollection(null);
+ toast.success('Collection imported successfully');
+ })
+ .catch((err) => {
+ setImportCollectionLocationModalOpen(false);
+ console.error(err);
+ toast.error('An error occurred while importing the collection. Check the logs for more information.');
+ });
+ };
+
+ const handleTitleClick = () => dispatch(showHomePage());
+
+ const handleOpenCollection = () => {
+ // @ts-expect-error
+ dispatch(openCollection()).catch((err) => {
+ console.error('Could not open collection!', err);
+ toast.error('An error occurred while opening the collection');
+ });
+ };
+
+ return (
+
+ {createCollectionModalOpen ? setCreateCollectionModalOpen(false)} /> : null}
+ {importCollectionModalOpen ? (
+ setImportCollectionModalOpen(false)} handleSubmit={handleImportCollection} />
+ ) : null}
+ {importCollectionLocationModalOpen ? (
+ setImportCollectionLocationModalOpen(false)}
+ handleSubmit={handleImportCollectionLocation}
+ />
+ ) : null}
+
+
+
+ Bruno lazer
+
+
+
+
+ setImportCollectionModalOpen(true)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ setCreateCollectionModalOpen(true)}
+ >
+
+
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/feature/sidebar/hooks/useRequestList.tsx b/packages/bruno-app/src/feature/sidebar/hooks/useRequestList.tsx
new file mode 100644
index 0000000000..022e2d7b5a
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/hooks/useRequestList.tsx
@@ -0,0 +1,98 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { useSelector } from 'react-redux';
+import { CollectionSchema, RequestItemSchema } from '@usebruno/schema';
+import { RequestListItem } from '../types/requestList';
+import { useMemo } from 'react';
+
+type ReduxState = {
+ collections: {
+ collectionSortOrder: 'default' | 'asc' | 'desc';
+ collectionFilter: string;
+ collections: CollectionSchema[];
+ };
+ tabs: {
+ activeTabUid: string | undefined;
+ };
+};
+
+export const useRequestList = (): RequestListItem[] => {
+ const { collections, collectionSortOrder, collectionFilter } = useSelector((state: ReduxState) => state.collections);
+ const activeTabUid = useSelector((state: ReduxState) => state.tabs.activeTabUid);
+
+ return useMemo(() => {
+ const items: RequestListItem[] = [];
+
+ const insertItemsRecursive = (
+ requestItems: RequestItemSchema[],
+ collectionUid: string,
+ indent: number,
+ parentUid: string | null,
+ filter: string | null
+ ) => {
+ const sorted = [...requestItems].sort((a, b) => {
+ if (a.seq === undefined && b.seq !== undefined) {
+ return -1;
+ } else if (a.seq !== undefined && b.seq === undefined) {
+ return 1;
+ } else if (a.seq === undefined && b.seq === undefined) {
+ return 0;
+ }
+ return a.seq < b.seq ? -1 : 1;
+ });
+ for (const requestItem of sorted) {
+ switch (requestItem.type) {
+ case 'http-request':
+ case 'graphql-request':
+ if (collectionFilter && !requestItem.name.includes(collectionFilter)) {
+ continue;
+ }
+ items.push({
+ type: 'request',
+ collectionUid,
+ indent,
+ method: requestItem.request.method,
+ name: requestItem.name,
+ uid: requestItem.uid,
+ parentUid,
+ active: activeTabUid === requestItem.uid
+ });
+ break;
+ case 'folder':
+ const collapsed = filter === null ? requestItem.collapsed : false;
+ items.push({
+ type: 'folder',
+ collectionUid,
+ indent,
+ name: requestItem.name,
+ uid: requestItem.uid,
+ parentUid,
+ collapsed
+ });
+ if (!collapsed) {
+ insertItemsRecursive(requestItem.items, collectionUid, indent + 1, requestItem.uid, filter);
+ }
+ break;
+ }
+ }
+ };
+
+ for (const collection of collections) {
+ items.push({
+ type: 'collection',
+ collapsed: collection.collapsed,
+ name: collection.name,
+ uid: collection.uid
+ });
+
+ const filter = collectionFilter.trim().length > 0 ? collectionFilter : null;
+ if (!collection.collapsed || filter !== null) {
+ insertItemsRecursive(collection.items, collection.uid, 1, null, filter);
+ }
+ }
+
+ return items;
+ }, [collections, collectionSortOrder, collectionFilter, activeTabUid]);
+};
diff --git a/packages/bruno-app/src/feature/sidebar/index.ts b/packages/bruno-app/src/feature/sidebar/index.ts
new file mode 100644
index 0000000000..696ac9551d
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/index.ts
@@ -0,0 +1,5 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+export { Sidebar } from './components/Sidebar';
diff --git a/packages/bruno-app/src/feature/sidebar/types/requestList.ts b/packages/bruno-app/src/feature/sidebar/types/requestList.ts
new file mode 100644
index 0000000000..adffa43a82
--- /dev/null
+++ b/packages/bruno-app/src/feature/sidebar/types/requestList.ts
@@ -0,0 +1,30 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+export type RequestListItem =
+ | {
+ type: 'collection';
+ name: string;
+ uid: string;
+ collapsed: boolean;
+ }
+ | {
+ type: 'folder';
+ name: string;
+ uid: string;
+ parentUid: string | null; // Used just the key
+ collectionUid: string;
+ collapsed: boolean;
+ indent: number;
+ }
+ | {
+ type: 'request';
+ name: string;
+ uid: string;
+ parentUid: string | null; // Used just the key
+ collectionUid: string;
+ method: string;
+ indent: number;
+ active: boolean;
+ };
diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js
index c2b1678133..d909210bc6 100644
--- a/packages/bruno-app/src/globalStyles.js
+++ b/packages/bruno-app/src/globalStyles.js
@@ -168,7 +168,6 @@ const GlobalStyle = createGlobalStyle`
// (macos scrollbar styling is the ideal style reference)
@media not all and (pointer: coarse) {
* {
- scrollbar-width: thin;
scrollbar-color: ${(props) => props.theme.scrollbar.color};
}
@@ -179,6 +178,7 @@ const GlobalStyle = createGlobalStyle`
*::-webkit-scrollbar-track {
background: transparent;
border-radius: 5px;
+ scrollbar-color: ${(props) => props.theme.scrollbar.color};
}
*::-webkit-scrollbar-thumb {
diff --git a/packages/bruno-app/src/pages/Bruno/Bruno.module.scss b/packages/bruno-app/src/pages/Bruno/Bruno.module.scss
new file mode 100644
index 0000000000..b4d20c6fed
--- /dev/null
+++ b/packages/bruno-app/src/pages/Bruno/Bruno.module.scss
@@ -0,0 +1,18 @@
+.wrapper {
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+
+ display: grid;
+ grid-template-columns: auto 1fr;
+ grid-template-rows: 100%;
+
+ & > section {
+ min-width: 0;
+ min-height: 0;
+
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ }
+}
diff --git a/packages/bruno-app/src/pages/Bruno/StyledWrapper.js b/packages/bruno-app/src/pages/Bruno/StyledWrapper.js
deleted file mode 100644
index 741978cdff..0000000000
--- a/packages/bruno-app/src/pages/Bruno/StyledWrapper.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import styled from 'styled-components';
-
-const Wrapper = styled.div`
- display: flex;
- width: 100%;
- height: 100%;
- min-height: 100vh;
- max-height: 100vh;
-
- &.is-dragging {
- cursor: col-resize !important;
- }
-
- section.main {
- display: flex;
-
- section.request-pane,
- section.response-pane {
- }
- }
-
- .fw-600 {
- font-weight: 600;
- }
-`;
-
-export default Wrapper;
diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js
index 71e24dcfa3..7776a29090 100644
--- a/packages/bruno-app/src/pages/Bruno/index.js
+++ b/packages/bruno-app/src/pages/Bruno/index.js
@@ -1,14 +1,13 @@
import React from 'react';
-import classnames from 'classnames';
import Welcome from 'components/Welcome';
import RequestTabs from 'components/RequestTabs';
import RequestTabPanel from 'components/RequestTabPanel';
-import Sidebar from 'components/Sidebar';
import { useSelector } from 'react-redux';
-import StyledWrapper from './StyledWrapper';
import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
import 'codemirror/addon/scroll/simplescrollbars.css';
+import { Sidebar } from 'src/feature/sidebar';
+import classes from './Bruno.module.scss';
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
@@ -46,32 +45,21 @@ if (!SERVER_RENDERED) {
export default function Main() {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
- const isDragging = useSelector((state) => state.app.isDragging);
const showHomePage = useSelector((state) => state.app.showHomePage);
- // Todo: write a better logging flow that can be used to log by turning on debug flag
- // Enable for debugging.
- // console.log(useSelector((state) => state.collections.collections));
-
- const className = classnames({
- 'is-dragging': isDragging
- });
-
return (
-
-
-
-
- {showHomePage ? (
-
- ) : (
- <>
-
-
- >
- )}
-
-
+
+
+
+ {showHomePage ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
);
}
diff --git a/packages/bruno-app/src/pages/ErrorBoundary/index.js b/packages/bruno-app/src/pages/ErrorBoundary/index.js
index e0550bf486..f3055cd413 100644
--- a/packages/bruno-app/src/pages/ErrorBoundary/index.js
+++ b/packages/bruno-app/src/pages/ErrorBoundary/index.js
@@ -11,11 +11,11 @@ class ErrorBoundary extends React.Component {
componentDidMount() {
// Add a global error event listener to capture client-side errors
window.onerror = (message, source, lineno, colno, error) => {
- this.setState({ hasError: true, error });
+ console.error('Trigger onerror', { error, source, message, lineno, colno });
};
}
componentDidCatch(error, errorInfo) {
- console.log({ error, errorInfo });
+ console.error('Triggered error boundary', { error, errorInfo });
this.setState({ hasError: true, error, errorInfo });
}
@@ -47,10 +47,10 @@ class ErrorBoundary extends React.Component {
Please report this under:
- https://github.com/usebruno/bruno/issues
+ https://github.com/its-treason/bruno/issues
diff --git a/packages/bruno-app/src/pages/_app.js b/packages/bruno-app/src/pages/_app.js
index cf8b3683ea..2e5fe7f285 100644
--- a/packages/bruno-app/src/pages/_app.js
+++ b/packages/bruno-app/src/pages/_app.js
@@ -3,17 +3,60 @@ import { Provider } from 'react-redux';
import { AppProvider } from 'providers/App';
import { ToastProvider } from 'providers/Toaster';
import { HotkeysProvider } from 'providers/Hotkeys';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { createTheme, MantineProvider, Timeline, Tooltip } from '@mantine/core';
import ReduxStore from 'providers/ReduxStore';
import ThemeProvider from 'providers/Theme/index';
import ErrorBoundary from './ErrorBoundary';
-import '../styles/app.scss';
import '../styles/globals.css';
import 'codemirror/lib/codemirror.css';
import 'graphiql/graphiql.min.css';
import 'react-tooltip/dist/react-tooltip.css';
-import '@usebruno/graphql-docs/dist/esm/index.css';
+import '@usebruno/graphql-docs/dist/style.css';
+import '@fontsource/inter/400.css';
+import '@fontsource/inter/500.css';
+import '@fontsource/inter/600.css';
+import '@fontsource/inter/700.css';
+import '@mantine/core/styles.css';
+
+const queryClient = new QueryClient();
+
+const theme = createTheme({
+ focusRing: 'never',
+ defaultRadius: 'xs',
+ primaryColor: 'orange',
+
+ colors: {
+ orange: [
+ '#fff5e1',
+ '#ffebcd',
+ '#fbd59e',
+ '#f7be6c',
+ '#f4aa41',
+ '#f29d25',
+ '#f29714',
+ '#d88304',
+ '#c07300',
+ '#a76200'
+ ]
+ },
+
+ components: {
+ Timeline: Timeline.extend({
+ defaultProps: {
+ radius: 'xs'
+ }
+ }),
+
+ Tooltip: Tooltip.extend({
+ defaultProps: {
+ openDelay: 250
+ }
+ })
+ }
+});
function SafeHydrate({ children }) {
return
{typeof window === 'undefined' ? null : children}
;
@@ -55,21 +98,25 @@ function MyApp({ Component, pageProps }) {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/packages/bruno-app/src/pages/_document.js b/packages/bruno-app/src/pages/_document.js
index 131fc50ddd..80ecd0d156 100644
--- a/packages/bruno-app/src/pages/_document.js
+++ b/packages/bruno-app/src/pages/_document.js
@@ -1,4 +1,5 @@
import Document, { Html, Head, Main, NextScript } from 'next/document';
+import { ColorSchemeScript } from '@mantine/core';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
@@ -31,10 +32,7 @@ export default class MyDocument extends Document {
return (
-
+
diff --git a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js
index cb04256bdc..dae23fefeb 100644
--- a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js
+++ b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js
@@ -8,7 +8,7 @@ import { findCollectionByUid, flattenItems, isItemARequest } from 'utils/collect
import { pluralizeWord } from 'utils/common';
import { completeQuitFlow } from 'providers/ReduxStore/slices/app';
import { saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
-import { IconAlertTriangle } from '@tabler/icons';
+import { IconAlertTriangle } from '@tabler/icons-react';
import Modal from 'components/Modal';
const SaveRequestsModal = ({ onClose }) => {
diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js
index c54d538670..366e0daeaf 100644
--- a/packages/bruno-app/src/providers/App/index.js
+++ b/packages/bruno-app/src/providers/App/index.js
@@ -3,17 +3,24 @@ import { useDispatch } from 'react-redux';
import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import ConfirmAppClose from './ConfirmAppClose';
import useIpcEvents from './useIpcEvents';
-import useTelemetry from './useTelemetry';
import StyledWrapper from './StyledWrapper';
+import { initMonaco } from 'utils/monaco/monacoUtils';
+import { useMonaco } from '@monaco-editor/react';
export const AppContext = React.createContext();
export const AppProvider = (props) => {
- useTelemetry();
useIpcEvents();
+ const monaco = useMonaco();
const dispatch = useDispatch();
+ useEffect(() => {
+ if (monaco) {
+ initMonaco(monaco);
+ }
+ }, [monaco]);
+
useEffect(() => {
dispatch(refreshScreenWidth());
}, []);
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index 467a8582c6..133185d9a5 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -11,6 +11,7 @@ import {
collectionUnlinkFileEvent,
processEnvUpdateEvent,
runFolderEvent,
+ folderAddFileEvent,
runRequestEvent,
scriptEnvironmentUpdateEvent
} from 'providers/ReduxStore/slices/collections';
@@ -48,6 +49,13 @@ const useIpcEvents = () => {
})
);
}
+ if (type === 'addFileDir') {
+ dispatch(
+ folderAddFileEvent({
+ file: val
+ })
+ );
+ }
if (type === 'change') {
dispatch(
collectionChangeFileEvent({
diff --git a/packages/bruno-app/src/providers/App/useTelemetry.js b/packages/bruno-app/src/providers/App/useTelemetry.js
deleted file mode 100644
index f973dd967e..0000000000
--- a/packages/bruno-app/src/providers/App/useTelemetry.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * Telemetry in bruno is just an anonymous visit counter (triggered once per day).
- * The only details shared are:
- * - OS (ex: mac, windows, linux)
- * - Bruno Version (ex: 1.3.0)
- * We don't track usage analytics / micro-interactions / crash logs / anything else.
- */
-
-import { useEffect } from 'react';
-import getConfig from 'next/config';
-import { PostHog } from 'posthog-node';
-import platformLib from 'platform';
-import { uuid } from 'utils/common';
-
-const { publicRuntimeConfig } = getConfig();
-const posthogApiKey = 'phc_7gtqSrrdZRohiozPMLIacjzgHbUlhalW1Bu16uYijMR';
-let posthogClient = null;
-
-const isPlaywrightTestRunning = () => {
- return publicRuntimeConfig.PLAYWRIGHT ? true : false;
-};
-
-const isDevEnv = () => {
- return publicRuntimeConfig.ENV === 'dev';
-};
-
-const getPosthogClient = () => {
- if (posthogClient) {
- return posthogClient;
- }
-
- posthogClient = new PostHog(posthogApiKey);
- return posthogClient;
-};
-
-const getAnonymousTrackingId = () => {
- let id = localStorage.getItem('bruno.anonymousTrackingId');
-
- if (!id || !id.length || id.length !== 21) {
- id = uuid();
- localStorage.setItem('bruno.anonymousTrackingId', id);
- }
-
- return id;
-};
-
-const trackStart = () => {
- if (isPlaywrightTestRunning()) {
- return;
- }
-
- if (isDevEnv()) {
- return;
- }
-
- const trackingId = getAnonymousTrackingId();
- const client = getPosthogClient();
- client.capture({
- distinctId: trackingId,
- event: 'start',
- properties: {
- os: platformLib.os.family,
- version: '1.18.0'
- }
- });
-};
-
-const useTelemetry = () => {
- useEffect(() => {
- trackStart();
- setInterval(trackStart, 24 * 60 * 60 * 1000);
- }, []);
-};
-
-export default useTelemetry;
diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js
index 8b0503b1cf..96c385a136 100644
--- a/packages/bruno-app/src/providers/Hotkeys/index.js
+++ b/packages/bruno-app/src/providers/Hotkeys/index.js
@@ -4,12 +4,12 @@ import find from 'lodash/find';
import Mousetrap from 'mousetrap';
import { useSelector, useDispatch } from 'react-redux';
import SaveRequest from 'components/RequestPane/SaveRequest';
-import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { EnvironmentDrawer } from 'src/feature/environment-editor';
export const HotkeysContext = React.createContext();
@@ -159,9 +159,13 @@ export const HotkeysProvider = (props) => {
{showSaveRequestModal && (
setShowSaveRequestModal(false)} />
)}
- {showEnvSettingsModal && (
- setShowEnvSettingsModal(false)} />
- )}
+ {getCurrentCollection() ? (
+ setShowEnvSettingsModal(false)}
+ />
+ ) : null}
{showNewRequestModal && (
setShowNewRequestModal(false)} />
)}
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/debug/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/debug/middleware.js
index 22a5fe2147..3310196a69 100644
--- a/packages/bruno-app/src/providers/ReduxStore/middlewares/debug/middleware.js
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/debug/middleware.js
@@ -2,14 +2,14 @@ import { createListenerMiddleware } from '@reduxjs/toolkit';
const debugMiddleware = createListenerMiddleware();
-debugMiddleware.startListening({
- predicate: () => true, // it'll track every change
- effect: (action, listenerApi) => {
- console.debug('---redux action---');
- console.debug('action', action.type); // which action did it
- console.debug('action.payload', action.payload);
- console.debug(listenerApi.getState()); // the updated store
- }
-});
+// debugMiddleware.startListening({
+// predicate: () => true, // it'll track every change
+// effect: (action, listenerApi) => {
+// console.debug('---redux action---');
+// console.debug('action', action.type); // which action did it
+// console.debug('action.payload', action.payload);
+// console.debug(listenerApi.getState()); // the updated store
+// }
+// });
export default debugMiddleware;
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index 3cd2768806..035d6b3cca 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -4,7 +4,6 @@ import toast from 'react-hot-toast';
const initialState = {
isDragging: false,
- idbConnectionReady: false,
leftSidebarWidth: 222,
screenWidth: 500,
showHomePage: false,
@@ -34,9 +33,6 @@ export const appSlice = createSlice({
name: 'app',
initialState,
reducers: {
- idbConnectionReady: (state) => {
- state.idbConnectionReady = true;
- },
refreshScreenWidth: (state) => {
state.screenWidth = window.innerWidth;
},
@@ -77,7 +73,6 @@ export const appSlice = createSlice({
});
export const {
- idbConnectionReady,
refreshScreenWidth,
updateLeftSidebarWidth,
updateIsDragging,
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
index 526b43a1ea..23bc121554 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
@@ -1,10 +1,8 @@
-import { collectionSchema, environmentSchema, itemSchema } from '@usebruno/schema';
import cloneDeep from 'lodash/cloneDeep';
import filter from 'lodash/filter';
import find from 'lodash/find';
import get from 'lodash/get';
import trim from 'lodash/trim';
-import path from 'path';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import {
@@ -14,13 +12,14 @@ import {
findParentItemInCollection,
getItemsToResequence,
isItemAFolder,
+ refreshUidsInItem,
+ findItemInCollectionByPathname,
isItemARequest,
moveCollectionItem,
moveCollectionItemToRootOfCollection,
- refreshUidsInItem,
transformRequestToSaveToFilesystem
} from 'utils/collections';
-import { uuid, waitForNextTick } from 'utils/common';
+import { collectionSchema, requestItemSchema, environmentSchema } from '@usebruno/schema';
import { PATH_SEPARATOR, getDirectoryName } from 'utils/common/platform';
import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network';
@@ -30,6 +29,7 @@ import {
removeCollection as _removeCollection,
selectEnvironment as _selectEnvironment,
sortCollections as _sortCollections,
+ filterCollections as _filterCollections,
requestCancelled,
resetRunResults,
responseReceived,
@@ -39,8 +39,9 @@ import {
import { each } from 'lodash';
import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
-import { parseQueryParams, splitOnFirst } from 'utils/url/index';
+import { uuid, waitForNextTick } from 'utils/common';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
+import { parseQueryParams, splitOnFirst } from 'utils/url';
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
const state = getState();
@@ -55,38 +56,38 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
});
};
-export const saveRequest = (itemUid, collectionUid, saveSilently) => (dispatch, getState) => {
+export const saveRequest = (itemUid, collectionUid, saveSilently) => async (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
- return new Promise((resolve, reject) => {
- if (!collection) {
- return reject(new Error('Collection not found'));
- }
+ if (!collection) {
+ throw new Error('Collection not found');
+ }
- const collectionCopy = cloneDeep(collection);
- const item = findItemInCollection(collectionCopy, itemUid);
- if (!item) {
- return reject(new Error('Not able to locate item'));
- }
+ const collectionCopy = cloneDeep(collection);
+ const item = findItemInCollection(collectionCopy, itemUid);
+ if (!item) {
+ throw new Error('Not able to locate item');
+ }
- const itemToSave = transformRequestToSaveToFilesystem(item);
- const { ipcRenderer } = window;
+ const itemToSave = transformRequestToSaveToFilesystem(item);
+ const { ipcRenderer } = window;
- itemSchema
- .validate(itemToSave)
- .then(() => ipcRenderer.invoke('renderer:save-request', item.pathname, itemToSave))
- .then(() => {
- if (!saveSilently) {
- toast.success('Request saved successfully');
- }
- })
- .then(resolve)
- .catch((err) => {
- toast.error('Failed to save request!');
- reject(err);
- });
- });
+ const parseResult = requestItemSchema.safeParse(itemToSave);
+ if (!parseResult.success) {
+ toast.error('Could not save, request item is invalid');
+ throw new Error(`Item is invalid: ${parseResult.error}`);
+ }
+
+ try {
+ await ipcRenderer.invoke('renderer:save-request', item.pathname, parseResult.data);
+ } catch (error) {
+ toast.error('Failed to save request!');
+ throw error;
+ }
+ if (!saveSilently) {
+ toast.success('Request saved successfully');
+ }
};
export const saveMultipleRequests = (items) => (dispatch, getState) => {
@@ -99,7 +100,7 @@ export const saveMultipleRequests = (items) => (dispatch, getState) => {
const collection = findCollectionByUid(collections, item.collectionUid);
if (collection) {
const itemToSave = transformRequestToSaveToFilesystem(item);
- const itemIsValid = itemSchema.validateSync(itemToSave);
+ const itemIsValid = requestItemSchema.safeParse(itemToSave).success;
if (itemIsValid) {
itemsToSave.push({
item: itemToSave,
@@ -172,6 +173,60 @@ export const sendCollectionOauth2Request = (collectionUid) => (dispatch, getStat
});
};
+export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState) => {
+ const state = getState();
+ const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ const folder = findItemInCollection(collection, folderUid);
+ return new Promise((resolve, reject) => {
+ if (!collection) {
+ return reject(new Error('Collection not found'));
+ }
+
+ if (!folder) {
+ return reject(new Error('Folder not found'));
+ }
+
+ const { ipcRenderer } = window;
+
+ ipcRenderer
+ .invoke('renderer:save-folder-root', folder.pathname, { root: folder.root, seq: folder.seq })
+ .then(() => toast.success('Folder Settings saved successfully'))
+ .then(resolve)
+ .catch((err) => {
+ toast.error('Failed to save folder settings!');
+ reject(err);
+ });
+ });
+};
+export const retrieveDirectoriesBetween = (pathname, parameter, filename) => {
+ const parameterIndex = pathname.indexOf(parameter);
+ const filenameIndex = pathname.indexOf(filename);
+ if (parameterIndex === -1 || filenameIndex === -1 || filenameIndex < parameterIndex) {
+ return [];
+ }
+ const directories = pathname
+ .substring(parameterIndex + parameter.length, filenameIndex)
+ .split(PATH_SEPARATOR)
+ .filter((directory) => directory.trim() !== '');
+ const reconstructedPaths = [];
+ let currentPath = pathname.substring(0, parameterIndex + parameter.length);
+ for (const directory of directories) {
+ currentPath += `${PATH_SEPARATOR}${directory}`;
+ reconstructedPaths.push(currentPath);
+ }
+ return reconstructedPaths;
+};
+export const mergeRequests = (parentRequest, childRequest) => {
+ return _.mergeWith({}, parentRequest, childRequest, customizer);
+};
+function customizer(objValue, srcValue, key) {
+ const exceptions = ['headers', 'params', 'vars'];
+ if (exceptions.includes(key) && _.isArray(objValue) && _.isArray(srcValue)) {
+ return _.unionBy(srcValue, objValue, 'name');
+ }
+ return undefined;
+}
+
export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -184,9 +239,26 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const itemCopy = cloneDeep(item || {});
const collectionCopy = cloneDeep(collection);
- const environment = findEnvironmentInCollection(collectionCopy, collection.activeEnvironmentUid);
+ const environment = findEnvironmentInCollection(collectionCopy, collectionCopy.activeEnvironmentUid);
+ const itemTree = retrieveDirectoriesBetween(itemCopy.pathname, collectionCopy.name, itemCopy.filename);
- sendNetworkRequest(itemCopy, collection, environment, collectionCopy.collectionVariables)
+ const folderDatas = itemTree.reduce((acc, currentPath) => {
+ const folder = findItemInCollectionByPathname(collectionCopy, currentPath);
+ if (folder?.root?.request) {
+ return mergeRequests(acc, folder.root?.request);
+ }
+ return acc;
+ }, {});
+ const mergeParams = mergeRequests(collectionCopy.root?.request ?? {}, folderDatas);
+ // merge collection and folder settings with request
+ const mergedCollection = {
+ ...collectionCopy,
+ root: {
+ ...collectionCopy.root,
+ request: mergeParams
+ }
+ };
+ sendNetworkRequest(itemCopy, mergedCollection, environment, collectionCopy.collectionVariables)
.then((response) => {
return dispatch(
responseReceived({
@@ -277,7 +349,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dis
collectionCopy,
environment,
collectionCopy.collectionVariables,
- recursive
+ recursive,
+ localStorage.getItem('new-request') === '"true"'
)
.then(resolve)
.catch((err) => {
@@ -353,17 +426,9 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
}
const dirname = getDirectoryName(item.pathname);
-
- let newPathname = '';
- if (item.type === 'folder') {
- newPathname = path.join(dirname, trim(newName));
- } else {
- const filename = resolveRequestFilename(newName);
- newPathname = path.join(dirname, filename);
- }
const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:rename-item', item.pathname, newPathname, newName).then(resolve).catch(reject);
+ ipcRenderer.invoke('renderer:rename-item', item.pathname, dirname, newName).then(resolve).catch(reject);
});
};
@@ -413,9 +478,9 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
- itemSchema
- .validate(itemToSave)
- .then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave))
+ requestItemSchema
+ .parseAsync(itemToSave)
+ .then(() => ipcRenderer.invoke('renderer:new-request', collection.pathname, itemToSave))
.then(resolve)
.catch(reject);
@@ -436,15 +501,15 @@ export const cloneItem = (newName, itemUid, collectionUid) => (dispatch, getStat
(i) => i.type !== 'folder' && trim(i.filename) === trim(filename)
);
if (!reqWithSameNameExists) {
- const dirname = getDirectoryName(item.pathname);
- const fullName = path.join(dirname, filename);
+ const pathname = getDirectoryName(item.pathname);
+ const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
const requestItems = filter(parentItem.items, (i) => i.type !== 'folder');
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
- itemSchema
- .validate(itemToSave)
- .then(() => ipcRenderer.invoke('renderer:new-request', fullName, itemToSave))
+ requestItemSchema
+ .parseAsync(itemToSave)
+ .then(() => ipcRenderer.invoke('renderer:new-request', pathname, itemToSave))
.then(resolve)
.catch(reject);
@@ -476,12 +541,7 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
if (item) {
const { ipcRenderer } = window;
- ipcRenderer
- .invoke('renderer:delete-item', item.pathname, item.type)
- .then(() => {
- resolve();
- })
- .catch((error) => reject(error));
+ ipcRenderer.invoke('renderer:delete-item', item.pathname, item.type).then(resolve).catch(reject);
}
return;
});
@@ -490,6 +550,10 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
export const sortCollections = (payload) => (dispatch) => {
dispatch(_sortCollections(payload));
};
+export const filterCollections = (payload) => (dispatch) => {
+ dispatch(_filterCollections(payload));
+};
+
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -517,7 +581,7 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa
// file item dragged onto another file item and both are in the same folder
// this is also true when both items are at the root level
- if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
+ if ((isItemARequest(draggedItem) || isItemAFolder(draggedItem)) && isItemARequest(targetItem) && sameParent) {
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
@@ -528,14 +592,19 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa
}
// file item dragged onto another file item which is at the root level
- if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
+ if (
+ (isItemARequest(draggedItem) || isItemAFolder(draggedItem)) &&
+ isItemARequest(targetItem) &&
+ !targetItemParent
+ ) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
+ const type = isItemARequest(draggedItem) ? 'file' : 'folder';
return ipcRenderer
- .invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
+ .invoke(`renderer:move-${type}-item`, draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
@@ -543,14 +612,15 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa
}
// file item dragged onto another file item and both are in different folders
- if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
+ if ((isItemARequest(draggedItem) || isItemAFolder(draggedItem)) && isItemARequest(targetItem) && !sameParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
+ const type = isItemARequest(draggedItem) ? 'file' : 'folder';
return ipcRenderer
- .invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
+ .invoke(`renderer:move-${type}-item`, draggedItemPathname, targetItemParent.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
@@ -569,8 +639,9 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
+ const type = isItemARequest(draggedItem) ? 'file' : 'folder';
return ipcRenderer
- .invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
+ .invoke(`renderer:move-${type}-item`, draggedItemPathname, targetItem.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
@@ -610,6 +681,10 @@ export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispa
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
+ if (targetItem.pathname.startsWith(draggedItemPathname)) {
+ return;
+ }
+
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
.then(resolve)
@@ -664,7 +739,7 @@ export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (di
export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body } = params;
- return new Promise((resolve, reject) => {
+ return new Promise(async (resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
@@ -709,19 +784,21 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
item.seq = requestItems.length + 1;
if (!reqWithSameNameExists) {
- const fullName = `${collection.pathname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
- // task middleware will track this and open the new request in a new tab once request is created
+ const newPath = await ipcRenderer.invoke('renderer:new-request', collection.pathname, item);
+ // the useCollectionNextAction() will track this and open the new request in a new tab
+ // once the request is created
dispatch(
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
- itemPathname: fullName
+ itemPathname: newPath
})
);
+
+ resolve();
} else {
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
@@ -735,19 +812,22 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
if (!reqWithSameNameExists) {
- const fullName = `${currentItem.pathname}${PATH_SEPARATOR}${filename}`;
const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:new-request', fullName, item).then(resolve).catch(reject);
- // task middleware will track this and open the new request in a new tab once request is created
+ const newPath = await ipcRenderer.invoke('renderer:new-request', currentItem.pathname, item);
+
+ // the useCollectionNextAction() will track this and open the new request in a new tab
+ // once the request is created
dispatch(
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
- itemPathname: fullName
+ itemPathname: newPath
})
);
+
+ resolve();
} else {
return reject(new Error('Duplicate request names are not allowed under the same folder'));
}
@@ -857,9 +937,23 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
environment.name = newName;
environmentSchema
- .validate(environment)
+ .parseAsync(environment)
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, newName))
- .then(resolve)
+ .then(() => {
+ // This will automaticly reselect the renamed environment
+ if (environmentUid === collection.activeEnvironmentUid) {
+ dispatch(
+ updateLastAction({
+ collectionUid,
+ lastAction: {
+ type: 'ADD_ENVIRONMENT',
+ payload: newName
+ }
+ })
+ );
+ }
+ resolve();
+ })
.catch(reject);
});
};
@@ -903,7 +997,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
environment.variables = variables;
environmentSchema
- .validate(environment)
+ .parseAsync(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
.then(resolve)
.catch(reject);
@@ -999,13 +1093,15 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
pathname: pathname,
items: [],
collectionVariables: {},
- brunoConfig: brunoConfig
+ environments: [],
+ brunoConfig: brunoConfig,
+ activeEnvironmentUid: null
};
return new Promise((resolve, reject) => {
collectionSchema
- .validate(collection)
- .then(() => dispatch(_createCollection(collection)))
+ .parseAsync(collection)
+ .then((parsedCollection) => dispatch(_createCollection(parsedCollection)))
.then(resolve)
.catch(reject);
});
@@ -1051,7 +1147,7 @@ export const collectionAddEnvFileEvent = (payload) => (dispatch, getState) => {
}
environmentSchema
- .validate(environment)
+ .parseAsync(environment)
.then(() =>
dispatch(
_collectionAddEnvFileEvent({
@@ -1068,7 +1164,13 @@ export const collectionAddEnvFileEvent = (payload) => (dispatch, getState) => {
export const importCollection = (collection, collectionLocation) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
-
ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation).then(resolve).catch(reject);
});
};
+
+export const shellOpenCollectionPath = (itemPath, isCollection, edit) => () => {
+ return new Promise((resolve, reject) => {
+ const { ipcRenderer } = window;
+ ipcRenderer.invoke('renderer:shell-open', itemPath, isCollection, edit).then(resolve).catch(reject);
+ });
+};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 2a851c2387..315c1c1458 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -20,7 +20,8 @@ import toast from 'react-hot-toast';
const initialState = {
collections: [],
- collectionSortOrder: 'default'
+ collectionSortOrder: 'default',
+ collectionFilter: ''
};
export const collectionsSlice = createSlice({
@@ -80,6 +81,9 @@ export const collectionsSlice = createSlice({
break;
}
},
+ filterCollections: (state, action) => {
+ state.collectionFilter = action.payload.filter;
+ },
updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -89,7 +93,7 @@ export const collectionsSlice = createSlice({
}
},
updateSettingsSelectedTab: (state, action) => {
- const { collectionUid, tab } = action.payload;
+ const { collectionUid, folderUid, tab } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -102,6 +106,10 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, meta.collectionUid);
if (collection) {
+ // Unset the selected environment, when it was deleted
+ if (collection.activeEnvironmentUid === environment.uid) {
+ collection.activeEnvironmentUid = undefined;
+ }
collection.environments = filter(collection.environments, (e) => e.uid !== environment.uid);
}
},
@@ -121,15 +129,19 @@ export const collectionsSlice = createSlice({
const { environmentUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
+ const { ipcRenderer } = window;
+
if (collection) {
if (environmentUid) {
const environment = findEnvironmentInCollection(collection, environmentUid);
if (environment) {
collection.activeEnvironmentUid = environmentUid;
+ ipcRenderer.invoke('renderer:update-last-selected-environment', collectionUid, environment.name);
}
} else {
collection.activeEnvironmentUid = null;
+ ipcRenderer.invoke('renderer:update-last-selected-environment', collectionUid, null);
}
}
},
@@ -165,6 +177,9 @@ export const collectionsSlice = createSlice({
if (item) {
item.name = action.payload.newName;
+ if (item.type === 'folder') {
+ item.pathname = path.join(path.dirname(item.pathname), action.payload.newName);
+ }
}
}
},
@@ -1114,6 +1129,44 @@ export const collectionsSlice = createSlice({
set(collection, 'root.docs', action.payload.docs);
}
},
+ addFolderHeader: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ const headers = get(folder, 'root.request.headers', []);
+ headers.push({
+ uid: uuid(),
+ name: '',
+ value: '',
+ description: '',
+ enabled: true
+ });
+ set(folder, 'root.request.headers', headers);
+ }
+ },
+ updateFolderHeader: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ const headers = get(folder, 'root.request.headers', []);
+ const header = find(headers, (h) => h.uid === action.payload.header.uid);
+ if (header) {
+ header.name = action.payload.header.name;
+ header.value = action.payload.header.value;
+ header.description = action.payload.header.description;
+ header.enabled = action.payload.header.enabled;
+ }
+ }
+ },
+ deleteFolderHeader: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
+ if (folder) {
+ let headers = get(folder, 'root.request.headers', []);
+ headers = filter(headers, (h) => h.uid !== action.payload.headerUid);
+ set(folder, 'root.request.headers', headers);
+ }
+ },
addCollectionHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1152,11 +1205,19 @@ export const collectionsSlice = createSlice({
set(collection, 'root.request.headers', headers);
}
},
+ folderAddFileEvent: (state, action) => {
+ const file = action.payload.file;
+ const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
+ const folder = findItemInCollectionByPathname(collection, file.meta.pathname);
+ if (folder) {
+ folder.root = file.data.root;
+ folder.seq = file.data.seq;
+ }
+ },
collectionAddFileEvent: (state, action) => {
const file = action.payload.file;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
const collection = findCollectionByUid(state.collections, file.meta.collectionUid);
-
if (isCollectionRoot) {
if (collection) {
collection.root = file.data;
@@ -1187,7 +1248,7 @@ export const collectionsSlice = createSlice({
currentSubItems = childItem.items;
}
- if (!currentSubItems.find((f) => f.name === file.meta.name)) {
+ if (file.meta.name != 'folder.bru' && !currentSubItems.find((f) => f.name === file.meta.name)) {
// this happens when you rename a file
// the add event might get triggered first, before the unlink event
// this results in duplicate uids causing react renderer to go mad
@@ -1310,6 +1371,7 @@ export const collectionsSlice = createSlice({
if (existingEnv) {
existingEnv.variables = environment.variables;
+ existingEnv.name = environment.name;
} else {
collection.environments.push(environment);
collection.environments.sort((a, b) => a.name.localeCompare(b.name));
@@ -1415,12 +1477,16 @@ export const collectionsSlice = createSlice({
const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
item.status = 'running';
item.requestSent = action.payload.requestSent;
+ item.isNew = action.payload.isNew || false;
}
if (type === 'response-received') {
const item = collection.runnerResult.items.find((i) => i.uid === request.uid);
item.status = 'completed';
item.responseReceived = action.payload.responseReceived;
+ item.timeline = action.payload.timeline;
+ item.timings = action.payload.timings;
+ item.debug = action.payload.debug;
}
if (type === 'test-results') {
@@ -1472,6 +1538,7 @@ export const {
renameCollection,
removeCollection,
sortCollections,
+ filterCollections,
updateLastAction,
updateSettingsSelectedTab,
collectionUnlinkEnvFileEvent,
@@ -1521,6 +1588,9 @@ export const {
addVar,
updateVar,
deleteVar,
+ addFolderHeader,
+ updateFolderHeader,
+ deleteFolderHeader,
addCollectionHeader,
updateCollectionHeader,
deleteCollectionHeader,
@@ -1537,6 +1607,7 @@ export const {
collectionUnlinkDirectoryEvent,
collectionAddEnvFileEvent,
collectionRenamedEvent,
+ folderAddFileEvent,
resetRunResults,
runRequestEvent,
runFolderEvent,
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index 74c503dad0..b64a71fade 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -38,7 +38,8 @@ export const tabsSlice = createSlice({
requestPaneWidth: null,
requestPaneTab: action.payload.requestPaneTab || 'params',
responsePaneTab: 'response',
- type: action.payload.type || 'request'
+ type: action.payload.type || 'request',
+ ...(action.payload.folderUid ? { folderUid: action.payload.folderUid } : {})
});
state.activeTabUid = action.payload.uid;
},
diff --git a/packages/bruno-app/src/providers/Theme/index.js b/packages/bruno-app/src/providers/Theme/index.js
index 44025197a4..e3492348b2 100644
--- a/packages/bruno-app/src/providers/Theme/index.js
+++ b/packages/bruno-app/src/providers/Theme/index.js
@@ -3,12 +3,13 @@ import useLocalStorage from 'hooks/useLocalStorage/index';
import { createContext, useContext, useEffect, useState } from 'react';
import { ThemeProvider as SCThemeProvider } from 'styled-components';
+import { useMantineColorScheme } from '@mantine/core';
export const ThemeContext = createContext();
export const ThemeProvider = (props) => {
const isBrowserThemeLight = window.matchMedia('(prefers-color-scheme: light)').matches;
const [displayedTheme, setDisplayedTheme] = useState(isBrowserThemeLight ? 'light' : 'dark');
- const [storedTheme, setStoredTheme] = useLocalStorage('bruno.theme', 'system');
+ const [storedTheme, setStoredTheme] = useLocalStorage('bruno.theme', 'dark');
const toggleHtml = () => {
const html = document.querySelector('html');
if (html) {
@@ -40,6 +41,11 @@ export const ThemeProvider = (props) => {
// storedTheme can have 3 values: 'light', 'dark', 'system'
// displayedTheme can have 2 values: 'light', 'dark'
+ const mantineColorSchema = useMantineColorScheme();
+ useEffect(() => {
+ mantineColorSchema.setColorScheme(displayedTheme);
+ }, [displayedTheme]);
+
const theme = storedTheme === 'system' ? themes[displayedTheme] : themes[storedTheme];
const themeOptions = Object.keys(themes);
const value = {
diff --git a/packages/bruno-app/src/styles/_buttons.scss b/packages/bruno-app/src/styles/_buttons.scss
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/packages/bruno-app/src/styles/app.scss b/packages/bruno-app/src/styles/app.scss
deleted file mode 100644
index 46e81fc093..0000000000
--- a/packages/bruno-app/src/styles/app.scss
+++ /dev/null
@@ -1 +0,0 @@
-@import 'buttons';
diff --git a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
index 1632aa43ae..00610cebf6 100644
--- a/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
+++ b/packages/bruno-app/src/utils/codemirror/brunoVarInfo.js
@@ -6,10 +6,7 @@
* LICENSE file at https://github.com/graphql/codemirror-graphql/tree/v0.8.3
*/
-// Todo: Fix this
-// import { interpolate } from '@usebruno/common';
-import brunoCommon from '@usebruno/common';
-const { interpolate } = brunoCommon;
+import { interpolate } from '@usebruno/common';
let CodeMirror;
const SERVER_RENDERED = typeof navigator === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index 6145ee2006..3e8ade6975 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -137,7 +137,8 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
- draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
+ const filename = draggedItem.type === 'folder' ? draggedItem.name : draggedItem.filename;
+ draggedItem.pathname = path.join(draggedItemParent.pathname, filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
@@ -146,7 +147,8 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
if (targetItem.type === 'folder') {
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
targetItem.items.push(draggedItem);
- draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
+ const filename = draggedItem.type === 'folder' ? draggedItem.name : draggedItem.filename;
+ draggedItem.pathname = path.join(targetItem.pathname, filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
@@ -154,12 +156,14 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
- draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
+ const filename = draggedItem.type === 'folder' ? draggedItem.name : draggedItem.filename;
+ draggedItem.pathname = path.join(targetItemParent.pathname, filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
- draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
+ const filename = draggedItem.type === 'folder' ? draggedItem.name : draggedItem.filename;
+ draggedItem.pathname = path.join(collection.pathname, filename);
}
}
};
@@ -189,7 +193,7 @@ export const getItemsToResequence = (parent, collection) => {
if (!parent) {
let index = 1;
each(collection.items, (item) => {
- if (isItemARequest(item)) {
+ if (isItemARequest(item) || (isItemAFolder(item) && item.seq !== undefined)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
@@ -202,7 +206,7 @@ export const getItemsToResequence = (parent, collection) => {
if (parent.items && parent.items.length) {
let index = 1;
each(parent.items, (item) => {
- if (isItemARequest(item)) {
+ if (isItemARequest(item) || isItemAFolder(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
@@ -694,7 +698,7 @@ export const getAllVariables = (collection, item) => {
},
process: {
env: {
- ...collection.processEnvVariables
+ ...collection?.processEnvVariables
}
}
};
diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js
index f31dd228f8..fc4bca9727 100644
--- a/packages/bruno-app/src/utils/common/index.js
+++ b/packages/bruno-app/src/utils/common/index.js
@@ -70,18 +70,6 @@ export const safeParseXML = (str, options) => {
}
};
-// 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;
-};
-
export const getContentType = (headers) => {
const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
@@ -98,6 +86,9 @@ export const getContentType = (headers) => {
return 'application/xml';
}
+ if (Array.isArray(contentType[0])) {
+ return contentType[0][0];
+ }
return contentType[0];
}
}
@@ -105,6 +96,10 @@ export const getContentType = (headers) => {
return '';
};
+export const sanitizeFilename = (name) => {
+ return name.replace(/[^\w-_.]/g, '_');
+};
+
export const startsWith = (str, search) => {
if (!str || !str.length || typeof str !== 'string') {
return false;
diff --git a/packages/bruno-app/src/utils/common/regex.js b/packages/bruno-app/src/utils/common/regex.js
index 53f46741e8..dc91b60ba6 100644
--- a/packages/bruno-app/src/utils/common/regex.js
+++ b/packages/bruno-app/src/utils/common/regex.js
@@ -1 +1,8 @@
+// See https://github.com/usebruno/bruno/pull/349 for more info
+// Strict regex for validating directories. Covers most edge cases like windows device names
+export const dirnameRegex = /^(?!CON|PRN|AUX|NUL|COM\d|LPT\d|^ |^-)[^<>:"/\\|?*\x00-\x1F]+[^\. ]$/;
+// Not so strict Regex for filenames, because files normally get a extension e.g. ".bru" and are not affect by
+// windows special names e.g. CON, PRN
+export const filenameRegex = /[<>:"/\\|?*\x00-\x1F]/;
+
export const variableNameRegex = /^[\w-.]*$/;
diff --git a/packages/bruno-app/src/utils/curl/parse-curl.js b/packages/bruno-app/src/utils/curl/parse-curl.js
index 77426da835..4532021496 100644
--- a/packages/bruno-app/src/utils/curl/parse-curl.js
+++ b/packages/bruno-app/src/utils/curl/parse-curl.js
@@ -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();
}
});
}
diff --git a/packages/bruno-app/src/utils/idb/index.js b/packages/bruno-app/src/utils/idb/index.js
deleted file mode 100644
index 28ef2790ea..0000000000
--- a/packages/bruno-app/src/utils/idb/index.js
+++ /dev/null
@@ -1,33 +0,0 @@
-export const saveCollectionToIdb = (connection, collection) => {
- return new Promise((resolve, reject) => {
- connection
- .then((db) => {
- let tx = db.transaction(`collection`, 'readwrite');
- let collectionStore = tx.objectStore('collection');
-
- collectionStore.put(collection);
-
- resolve(collection);
- })
- .catch((err) => reject(err));
- });
-};
-
-export const getCollectionsFromIdb = (connection) => {
- return new Promise((resolve, reject) => {
- connection
- .then((db) => {
- let tx = db.transaction('collection');
- let collectionStore = tx.objectStore('collection');
- return collectionStore.getAll();
- })
- .then((collections) => {
- if (!Array.isArray(collections)) {
- return new Error('IDB Corrupted');
- }
-
- return resolve(collections);
- })
- .catch((err) => reject(err));
- });
-};
diff --git a/packages/bruno-app/src/utils/importers/bruno-collection.js b/packages/bruno-app/src/utils/importers/bruno-collection.js
index d96802c39f..cf92e0f1be 100644
--- a/packages/bruno-app/src/utils/importers/bruno-collection.js
+++ b/packages/bruno-app/src/utils/importers/bruno-collection.js
@@ -34,8 +34,8 @@ const importCollection = () => {
.then(validateSchema)
.then((collection) => resolve({ collection }))
.catch((err) => {
- console.log(err);
- reject(new BrunoError('Import collection failed'));
+ console.error(err);
+ reject(new BrunoError(`Import collection failed: ${err.message}`));
});
});
};
diff --git a/packages/bruno-app/src/utils/importers/common.js b/packages/bruno-app/src/utils/importers/common.js
index c99048419d..a79f9a9c5c 100644
--- a/packages/bruno-app/src/utils/importers/common.js
+++ b/packages/bruno-app/src/utils/importers/common.js
@@ -2,21 +2,18 @@ import each from 'lodash/each';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
-import { uuid, normalizeFileName } from 'utils/common';
+import { uuid } from 'utils/common';
import { isItemARequest } from 'utils/collections';
import { collectionSchema } from '@usebruno/schema';
import { BrunoError } from 'utils/common/error';
export const validateSchema = (collection = {}) => {
- return new Promise((resolve, reject) => {
- collectionSchema
- .validate(collection)
- .then(() => resolve(collection))
- .catch((err) => {
- console.log(err);
- reject(new BrunoError('The Collection file is corrupted'));
- });
- });
+ const parseResult = collectionSchema.safeParse(collection);
+ if (parseResult.success) {
+ return parseResult.data;
+ }
+ console.error('Import failed, because schema did not match!', parseResult.error);
+ throw new BrunoError(`The Collection file is corrupted. Schema validation failed: ${parseResult.error.format()}`);
};
export const updateUidsInCollection = (_collection) => {
@@ -61,8 +58,6 @@ 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`;
diff --git a/packages/bruno-app/src/utils/importers/postman-collection.js b/packages/bruno-app/src/utils/importers/postman-collection.js
index 0683a3bbc6..7219cd12ba 100644
--- a/packages/bruno-app/src/utils/importers/postman-collection.js
+++ b/packages/bruno-app/src/utils/importers/postman-collection.js
@@ -59,6 +59,7 @@ let translationLog = {};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => {
brunoParent.items = brunoParent.items || [];
const folderMap = {};
+ const requestMap = {};
each(item, (i) => {
if (isItemAFolder(i)) {
@@ -84,6 +85,15 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
}
} else {
if (i.request) {
+ const baseRequestName = i.name;
+ let requestName = baseRequestName;
+ let count = 1;
+
+ while (requestMap[requestName]) {
+ requestName = `${baseRequestName}_${count}`;
+ count++;
+ }
+
let url = '';
if (typeof i.request.url === 'string') {
url = i.request.url;
@@ -93,7 +103,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
const brunoRequestItem = {
uid: uuid(),
- name: i.name,
+ name: requestName,
type: 'http-request',
request: {
url: url,
@@ -307,6 +317,7 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
});
brunoParent.items.push(brunoRequestItem);
+ requestMap[requestName] = brunoRequestItem;
}
}
});
@@ -393,9 +404,9 @@ const importCollection = (options) => {
.then(validateSchema)
.then((collection) => resolve({ collection, translationLog }))
.catch((err) => {
- console.log(err);
+ console.error(err);
translationLog = {};
- reject(new BrunoError('Import collection failed'));
+ reject(new BrunoError(`Import Postman collection failed: ${err.message}`));
})
.then(() => {
logTranslationDetails(translationLog);
diff --git a/packages/bruno-app/src/utils/monaco/monacoUtils.ts b/packages/bruno-app/src/utils/monaco/monacoUtils.ts
new file mode 100644
index 0000000000..51e9ea7857
--- /dev/null
+++ b/packages/bruno-app/src/utils/monaco/monacoUtils.ts
@@ -0,0 +1,608 @@
+/**
+ * This file is part of bruno-app.
+ * For license information, see the file LICENSE_GPL3 at the root directory of this distribution.
+ */
+import { Monaco } from '@monaco-editor/react';
+import { stringify } from 'lossless-json';
+import { IDisposable, Position, editor, IRange } from 'monaco-editor';
+import colors from 'tailwindcss/colors';
+
+type MonacoEditor = editor.IStandaloneCodeEditor;
+
+const buildSuggestions = (monaco: Monaco) => [
+ {
+ label: 'res',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'res.',
+ documentation: 'The response object.'
+ },
+ {
+ label: 'res.status',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'res.status',
+ documentation: 'The response status code.'
+ },
+ {
+ label: 'res.statusText',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'res.statusText',
+ documentation: 'The response status text.'
+ },
+ {
+ label: 'res.headers',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'res.headers',
+ documentation: 'The response headers.'
+ },
+ {
+ label: 'res.body',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'res.body',
+ documentation: 'The response body.'
+ },
+ {
+ label: 'res.responseTime',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'res.responseTime',
+ documentation: 'The response time.'
+ },
+ {
+ label: 'res.getStatus()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'res.getStatus()',
+ documentation: 'Returns the response status code.'
+ },
+ {
+ label: 'res.getStatusText()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'res.getStatusText()',
+ documentation: 'Returns the response status text.'
+ },
+ {
+ label: 'res.getHeader(name)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'res.getHeader()',
+ documentation: 'Returns the response header with the given name.'
+ },
+ {
+ label: 'res.getHeaders()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'res.getHeaders()',
+ documentation: 'Returns the response headers.'
+ },
+ {
+ label: 'res.getBody()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'res.getBody()',
+ documentation: 'Returns the response body.'
+ },
+ {
+ label: 'res.getResponseTime()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'res.getResponseTime()',
+ documentation: 'Returns the response time.'
+ },
+ {
+ label: 'req',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'req.',
+ documentation: 'The request object.'
+ },
+ {
+ label: 'req.url',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'req.url',
+ documentation: 'The request URL.'
+ },
+ {
+ label: 'req.method',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'req.method',
+ documentation: 'The request method.'
+ },
+ {
+ label: 'req.headers',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'req.headers',
+ documentation: 'The request headers.'
+ },
+ {
+ label: 'req.body',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'req.body',
+ documentation: 'The request body.'
+ },
+ {
+ label: 'req.timeout',
+ kind: monaco.languages.CompletionItemKind.Variable,
+ insertText: 'req.timeout',
+ documentation: 'The request timeout.'
+ },
+ {
+ label: 'req.getUrl()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.getUrl()',
+ documentation: 'Returns the request URL.'
+ },
+ {
+ label: 'req.setUrl(url)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.setUrl()',
+ documentation: 'Sets the request URL.'
+ },
+ {
+ label: 'req.getMethod()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.getMethod()',
+ documentation: 'Returns the request method.'
+ },
+ {
+ label: 'req.setMethod(method)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.setMethod()',
+ documentation: 'Sets the request method.'
+ },
+ {
+ label: 'req.getHeader(name)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.getHeader()',
+ documentation: 'Returns the request header with the given name.'
+ },
+ {
+ label: 'req.getHeaders()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.getHeaders()',
+ documentation: 'Returns the request headers.'
+ },
+ {
+ label: 'req.setHeader(name, value)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.setHeader()',
+ documentation: 'Sets the request header with the given name and value.'
+ },
+ {
+ label: 'req.setHeaders(data)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.setHeaders()',
+ documentation: 'Sets the request headers.'
+ },
+ {
+ label: 'req.getBody()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.getBody()',
+ documentation: 'Returns the request body.'
+ },
+ {
+ label: 'req.setBody(data)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.setBody()',
+ documentation: 'Sets the request body.'
+ },
+ {
+ label: 'req.setMaxRedirects(maxRedirects)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.setMaxRedirects()',
+ documentation: 'Sets the maximum number of redirects.'
+ },
+ {
+ label: 'req.getTimeout()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.getTimeout()',
+ documentation: 'Returns the request timeout.'
+ },
+ {
+ label: 'req.setTimeout(timeout)',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'req.setTimeout()',
+ documentation: 'Sets the request timeout.'
+ },
+ {
+ label: 'bru.getProcessEnv()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'bru.getProcessEnv()',
+ documentation: 'Returns the current process environment variables.'
+ },
+ {
+ label: 'bru.getEnvVar()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'bru.getEnvVar()',
+ documentation: 'Returns the value of the environment variable with the given key.'
+ },
+ {
+ label: 'bru.setEnvVar()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'bru.setEnvVar()',
+ documentation: 'Sets the value of the environment variable with the given key.'
+ },
+ {
+ label: 'bru.getVar()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'bru.getVar()',
+ documentation: 'Returns the value of the variable with the given key.'
+ },
+ {
+ label: 'bru.setVar()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'bru.setVar()',
+ documentation: 'Sets the value of the variable with the given key.'
+ },
+ {
+ label: 'bru.cwd()',
+ kind: monaco.languages.CompletionItemKind.Function,
+ insertText: 'bru.cwd()',
+ documentation: 'Returns the current working directory.'
+ }
+];
+
+// This function will check if we hover over a variable by first going the left and then to right to find the
+// opening and closing curly brackets
+export const getWordAtPosition = (
+ model: editor.ITextModel,
+ monaco: Monaco,
+ position: Position
+): null | [string, IRange] => {
+ const range = {
+ startColumn: position.column,
+ endColumn: position.column,
+ startLineNumber: position.lineNumber,
+ endLineNumber: position.lineNumber
+ };
+
+ // Check for the beginning {{ of a variable
+ for (let i = 0; true; i++) {
+ // Reached left char limit, just break here
+ if (i > 32) {
+ return null;
+ }
+
+ range.startColumn--;
+ // Reached the end of the line
+ if (range.startColumn === 0) {
+ return null;
+ }
+
+ const foundWord = model.getValueInRange(range);
+
+ // If we hover over the start of the variable go to the right and check if anything is there
+ if (foundWord === '{') {
+ range.startColumn++;
+ range.endColumn++;
+ continue;
+ }
+
+ // We reached the beginning of another variable
+ // e.g. example {{test}} here {{test}}
+ // ^^^^ cursor hovers here
+ // ^ This will be caught
+ if (foundWord.charAt(0) === '}') {
+ return null;
+ }
+
+ // Check if we reached the end of the
+ if (foundWord.charAt(0) === '{' && foundWord.charAt(1) === '{') {
+ break;
+ }
+ }
+
+ // Check for the ending }} of a variable
+ for (let i = 0; true; i++) {
+ // Reached left char limit, just break here
+ if (i > 32) {
+ return null;
+ }
+
+ range.endColumn++;
+ const foundWord = model.getValueInRange(range);
+
+ // Check if we found the end of the variable
+ const wordLength = foundWord.length;
+ if (foundWord.charAt(wordLength - 1) === '}' && foundWord.charAt(wordLength - 2) === '}') {
+ break;
+ }
+ }
+
+ const foundWord = model.getValueInRange(range);
+ // Trim {{, }} and any other spaces, then return the variable
+ return [
+ foundWord.substring(2, foundWord.length - 2).trim(),
+ new monaco.Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn)
+ ];
+};
+
+let hoverProvider: IDisposable | null;
+export const setMonacoVariables = (monaco: Monaco, variables: Record, mode = '*') => {
+ const allVariables = Object.entries(variables ?? {});
+ monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
+ diagnosticCodesToIgnore: [1109, 2580, 2451, 80005, 1375, 1378]
+ });
+ monaco.languages.setLanguageConfiguration(mode, {
+ autoClosingPairs: [{ open: '{{', close: '}}' }]
+ });
+ monaco.languages.setMonarchTokensProvider(mode, {
+ EnvVariables: Object.keys(variables ?? {}).map((key) => `{{${key}}}`),
+ tokenizer: {
+ root: [
+ [/[^{\/]+/, ''],
+ [
+ /\{{[^{}]+}}/,
+ {
+ cases: {
+ '@EnvVariables': 'EnvVariables',
+ '@default': 'UndefinedVariables'
+ }
+ }
+ ],
+ [
+ /(https?:\/\/)?\{{[^{}]+}}[^\s/]*\/?/,
+ {
+ cases: {
+ '@EnvVariables': 'EnvVariables',
+ '@default': 'UndefinedVariables'
+ }
+ }
+ ]
+ ]
+ }
+ });
+ const newHoverProvider = monaco.languages.registerHoverProvider(mode, {
+ provideHover: (model, position) => {
+ // Rebuild the hoverProvider to avoid memory leaks
+ const wordPos = getWordAtPosition(model, monaco, position);
+ if (wordPos === null) {
+ return null;
+ }
+ const [word, range] = wordPos;
+
+ const variable = allVariables.find(([key, _]) => key === word);
+ if (variable) {
+ // Ensure variables value is string
+ let value = '';
+ if (typeof variable[1] === 'object') {
+ try {
+ value = stringify(variable[1], null, 2) || 'Unknown object';
+ } catch (e) {
+ value = `Failed to stringify object: ${e}`;
+ }
+ } else {
+ value = String(variable[1]);
+ }
+
+ // Truncate value
+ if (value.length > 255) {
+ value = value.substring(0, 255) + '... (Truncated)';
+ }
+
+ return {
+ range,
+ contents: [{ value: `**${variable[0]}**` }, { value }]
+ };
+ } else {
+ return {
+ range,
+ contents: [{ value: `**${word}**` }, { value: 'Variable not found in environment.' }]
+ };
+ }
+ }
+ });
+ hoverProvider?.dispose();
+ hoverProvider = newHoverProvider;
+
+ const typedVariables = Object.entries(variables ?? {}).map(([key, value]) => `declare const ${key}: string`);
+ monaco.languages.typescript.javascriptDefaults.setExtraLibs([{ content: typedVariables.join('\n') }]);
+};
+
+export const initMonaco = (monaco: Monaco) => {
+ monaco.editor.defineTheme('bruno-dark', {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ {
+ token: 'UndefinedVariables',
+ foreground: '#f87171',
+ fontStyle: 'medium underline'
+ },
+ {
+ token: 'EnvVariables',
+ foreground: '#4ade80',
+ fontStyle: 'medium'
+ },
+ { background: colors.zinc[800], token: '' }
+ ],
+ colors: {
+ 'editor.background': '#00000000',
+ 'editor.foreground': '#ffffff',
+ 'editorGutter.background': colors.zinc[800]
+ }
+ });
+ monaco.editor.defineTheme('bruno-light', {
+ base: 'vs',
+ inherit: true,
+ rules: [
+ {
+ token: 'UndefinedVariables',
+ foreground: '#dc2626',
+ fontStyle: 'medium underline'
+ },
+ {
+ token: 'EnvVariables',
+ foreground: '#15803d',
+ fontStyle: 'medium'
+ },
+ { background: colors.zinc[50], token: '' }
+ ],
+ colors: {
+ 'editor.background': '#00000000',
+ 'editorGutter.background': colors.zinc[50]
+ }
+ });
+ monaco.languages.typescript.typescriptDefaults.addExtraLib(`
+ declare const res: {
+ status: number;
+ statusText: string;
+ headers: any;
+ body: any;
+ responseTime: number;
+ getStatus(): number;
+ getStatusText(): string;
+ getHeader(name: string): string;
+ getHeaders(): any;
+ getBody(): any;
+ getResponseTime(): number;
+ };
+ declare const req: {
+ url: string;
+ method: string;
+ headers: any;
+ body: any;
+ timeout: number;
+ getUrl(): string;
+ setUrl(url: string): void;
+ getMethod(): string;
+ setMethod(method: string): void;
+ getHeader(name: string): string;
+ getHeaders(): any;
+ setHeader(name: string, value: string): void;
+ setHeaders(data: any): void;
+ getBody(): any;
+ setBody(data: any): void;
+ setMaxRedirects(maxRedirects: number): void;
+ getTimeout(): number;
+ setTimeout(timeout: number): void;
+ };
+ declare const bru: {
+ hasEnvVar(key: string): boolean;
+ getEnvVar(key: string): any;
+ setEnvVar(key: string, value: any): void;
+ hasVar(key: string): boolean;
+ getVar(key: string): any;
+ setVar(key: string, value: any): void;
+ deleteVar(key: string): void;
+ getProcessEnv(): any;
+ cwd(): string;
+ };
+`);
+ monaco.languages.registerCompletionItemProvider('typescript', {
+ provideCompletionItems: () => ({
+ // @ts-expect-error `range` is missing here, but is still works
+ suggestions: buildSuggestions(monaco)
+ })
+ });
+ // javascript is solely used for the query editor
+ monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
+ diagnosticCodesToIgnore: [1109, 2580, 2451, 80005, 1375, 1378]
+ });
+ monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
+ diagnosticCodesToIgnore: [1109, 2580, 2451, 80005, 1375, 1378]
+ });
+ monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
+ allowComments: true
+ });
+};
+
+const createEditorAction = (id: string, keybindings: number[], label: string, run: () => void) => {
+ return {
+ id,
+ keybindings,
+ label,
+ run
+ };
+};
+
+export type BrunoEditorCallbacks = {
+ onChange?: (newValue: string) => void;
+ onSave?: () => void;
+ onRun?: () => void;
+};
+
+export const addMonacoCommands = (
+ monaco: Monaco,
+ editor: editor.IStandaloneCodeEditor,
+ callbacks: BrunoEditorCallbacks
+) => {
+ const editorActions = [
+ createEditorAction('save', [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], 'Save', () => {
+ callbacks.onChange && callbacks.onChange(editor.getValue());
+ callbacks.onSave && callbacks.onSave();
+ }),
+ createEditorAction('run', [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], 'Run', () => {
+ callbacks.onRun && callbacks.onRun();
+ }),
+ createEditorAction('foldAll', [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyY], 'FoldAll', () => {
+ editor.trigger('fold', 'editor.foldAll', null);
+ }),
+ createEditorAction('unfoldAll', [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyI], 'UnfoldAll', () => {
+ editor.trigger('fold', 'editor.unfoldAll', null);
+ })
+ ];
+ editorActions.forEach((action) => editor.addAction(action));
+};
+
+export const addMonacoSingleLineActions = (
+ editor: MonacoEditor,
+ monaco: Monaco,
+ allowNewlines: boolean,
+ setHeight: (newHeight: number) => void
+) => {
+ editor.onKeyDown((e) => {
+ if (e.keyCode === monaco.KeyCode.Enter && allowNewlines === false) {
+ // @ts-expect-error Not sure why the type does not work
+ if (editor.getContribution('editor.contrib.suggestController')?.model.state == 0) {
+ e.preventDefault();
+ }
+ }
+ });
+
+ editor.onDidPaste((e) => {
+ // Remove all newlines for the singleline editor
+ if (e.range.endLineNumber > 1 && allowNewlines === false) {
+ const modal = editor.getModel();
+ if (!modal) {
+ return;
+ }
+
+ let newContent = '';
+ let lineCount = modal.getLineCount() || 0;
+ for (let i = 0; i < lineCount; i++) {
+ newContent += modal.getLineContent(i + 1) || 1;
+ }
+ modal.setValue(newContent);
+ }
+ });
+
+ // This will remove the highlighting of hovered words
+ editor.onDidBlurEditorText(() => {
+ editor.setPosition({ column: 1, lineNumber: 1 });
+ });
+
+ editor.onDidContentSizeChange(() => {
+ setHeight(Math.min(128, editor.getContentHeight()));
+ });
+ setHeight(Math.min(128, editor.getContentHeight()));
+};
+
+export const getMonacoModeFromContent = (contentType: string, body: string | Record) => {
+ if (typeof body === 'object') {
+ return 'application/ld+json';
+ }
+ if (!contentType || typeof contentType !== 'string') {
+ return 'application/text';
+ }
+
+ if (contentType.includes('json')) {
+ return 'application/ld+json';
+ } else if (contentType.includes('xml')) {
+ return 'application/xml';
+ } else if (contentType.includes('html')) {
+ return 'application/html';
+ } else if (contentType.includes('text')) {
+ return 'application/text';
+ } else if (contentType.includes('application/edn')) {
+ return 'application/xml';
+ } else if (contentType.includes('yaml')) {
+ return 'application/yaml';
+ } else if (contentType.includes('image')) {
+ return 'application/image';
+ } else {
+ return 'application/text';
+ }
+};
diff --git a/packages/bruno-app/src/utils/network/index.js b/packages/bruno-app/src/utils/network/index.js
index e76a7debdb..89d935a138 100644
--- a/packages/bruno-app/src/utils/network/index.js
+++ b/packages/bruno-app/src/utils/network/index.js
@@ -6,15 +6,18 @@ export const sendNetworkRequest = async (item, collection, environment, collecti
sendHttpRequest(item, collection, environment, collectionVariables)
.then((response) => {
resolve({
- state: 'success',
- data: response.data,
- // Note that the Buffer is encoded as a base64 string, because Buffers / TypedArrays are not allowed in the redux store
- dataBuffer: response.dataBuffer,
+ state: response.error ? 'Error' : 'success',
headers: response.headers,
size: response.size,
status: response.status,
statusText: response.statusText,
- duration: response.duration
+ duration: response.duration,
+ isNew: response.isNew ?? false,
+ timeline: response.timeline,
+ timings: response.timings,
+ debug: response.debug,
+ error: response.error,
+ isError: response.error ? true : undefined
});
})
.catch((err) => reject(err));
@@ -27,12 +30,23 @@ const sendHttpRequest = async (item, collection, environment, collectionVariable
const { ipcRenderer } = window;
ipcRenderer
- .invoke('send-http-request', item, collection, environment, collectionVariables)
+ .invoke(
+ 'send-http-request',
+ item,
+ collection,
+ environment,
+ collectionVariables,
+ localStorage.getItem('new-request') === '"true"'
+ )
.then(resolve)
.catch(reject);
});
};
+export const getResponseBody = async (requestId) => {
+ return await window.ipcRenderer.invoke('renderer:get-response-body', requestId);
+};
+
export const sendCollectionOauth2Request = async (collection, environment, collectionVariables) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js
index 0b9f2201ae..bcfcb6769c 100644
--- a/packages/bruno-app/src/utils/url/index.js
+++ b/packages/bruno-app/src/utils/url/index.js
@@ -4,8 +4,7 @@ import each from 'lodash/each';
import filter from 'lodash/filter';
import find from 'lodash/find';
-import brunoCommon from '@usebruno/common';
-const { interpolate } = brunoCommon;
+import { interpolate } from '@usebruno/common';
const hasLength = (str) => {
if (!str || !str.length) {
diff --git a/packages/bruno-app/tsconfig.json b/packages/bruno-app/tsconfig.json
new file mode 100644
index 0000000000..2205d8ebd2
--- /dev/null
+++ b/packages/bruno-app/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "target": "es2017",
+ "baseUrl": "./",
+ "paths": {
+ "assets/*": ["src/assets/*"],
+ "components/*": ["src/components/*"],
+ "hooks/*": ["src/hooks/*"],
+ "themes/*": ["src/themes/*"],
+ "api/*": ["src/api/*"],
+ "pageComponents/*": ["src/pageComponents/*"],
+ "providers/*": ["src/providers/*"],
+ "utils/*": ["src/utils/*"]
+ },
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": false,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "incremental": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true
+ },
+ "exclude": ["node_modules", "dist"],
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
+}
diff --git a/packages/bruno-cli/changelog.md b/packages/bruno-cli/changelog.md
index 04d9abd736..2c9946d80c 100644
--- a/packages/bruno-cli/changelog.md
+++ b/packages/bruno-cli/changelog.md
@@ -25,6 +25,7 @@ For the release notes please see https://github.com/usebruno/bruno/releases
## 1.2.0
- Support for `bru.setNextRequest()`
+ > > > > > > > main
## 1.1.0
diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json
index c8e3b72cef..d52d630c74 100644
--- a/packages/bruno-cli/package.json
+++ b/packages/bruno-cli/package.json
@@ -25,8 +25,8 @@
],
"dependencies": {
"@aws-sdk/credential-providers": "3.525.0",
- "@usebruno/common": "0.1.0",
- "@usebruno/js": "0.12.0",
+ "@usebruno/common": "workspace:*",
+ "@usebruno/js": "0.11.0",
"@usebruno/lang": "0.12.0",
"aws4-axios": "^3.3.0",
"axios": "^1.5.1",
@@ -40,10 +40,8 @@
"inquirer": "^9.1.4",
"json-bigint": "^1.0.0",
"lodash": "^4.17.21",
- "mustache": "^4.2.0",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
- "vm2": "^3.9.13",
"xmlbuilder": "^15.1.1",
"yargs": "^17.6.2"
}
diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js
index 35192b1281..e51daa552c 100644
--- a/packages/bruno-cli/src/commands/run.js
+++ b/packages/bruno-cli/src/commands/run.js
@@ -179,6 +179,17 @@ const getCollectionRoot = (dir) => {
return collectionBruToJson(content);
};
+const getFolderRoot = (dir) => {
+ const folderRootPath = path.join(dir, 'folder.bru');
+ const exists = fs.existsSync(folderRootPath);
+ if (!exists) {
+ return {};
+ }
+
+ const content = fs.readFileSync(folderRootPath, 'utf8');
+ return collectionBruToJson(content);
+};
+
const builder = async (yargs) => {
yargs
.option('r', {
diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js
index 886974c0f7..c6ccbe0999 100644
--- a/packages/bruno-cli/src/runner/interpolate-vars.js
+++ b/packages/bruno-cli/src/runner/interpolate-vars.js
@@ -1,5 +1,6 @@
+const FormData = require('form-data');
+const { each, forOwn, cloneDeep, extend } = require('lodash');
const { interpolate } = require('@usebruno/common');
-const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -60,8 +61,9 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
if (typeof request.data === 'object') {
try {
let parsed = JSON.stringify(request.data);
- parsed = _interpolate(parsed);
- request.data = JSON.parse(parsed);
+ // Write the interpolated body into data, so one can see his values even if parsing fails
+ request.data = _interpolate(parsed);
+ request.data = JSON.parse(request.data);
} catch (err) {}
}
@@ -78,6 +80,16 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
request.data = JSON.parse(parsed);
} catch (err) {}
}
+ } else if (contentType === 'multipart/form-data') {
+ // make axios work in node using form data
+ // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
+ const form = new FormData();
+ forOwn(request.data, (value, name) => {
+ form.append(interpolate(name), interpolate(value));
+ });
+
+ extend(request.headers, form.getHeaders());
+ request.data = form;
} else {
request.data = _interpolate(request.data);
}
diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js
index 33e8216c33..033d82950d 100644
--- a/packages/bruno-cli/src/runner/run-single-request.js
+++ b/packages/bruno-cli/src/runner/run-single-request.js
@@ -9,7 +9,7 @@ const FormData = require('form-data');
const prepareRequest = require('./prepare-request');
const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
-const { ScriptRuntime, TestRuntime, VarsRuntime, AssertRuntime } = require('@usebruno/js');
+const { VarsRuntime, AssertRuntime, runScript } = require('@usebruno/js');
const { stripExtension } = require('../utils/filesystem');
const { getOptions } = require('../utils/bru');
const https = require('https');
@@ -39,21 +39,6 @@ const runSingleRequest = async function (
const scriptingConfig = get(brunoConfig, 'scripts', {});
- // 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') {
- const form = new FormData();
- forOwn(request.data, (value, key) => {
- if (value instanceof Array) {
- each(value, (v) => form.append(key, v));
- } else {
- form.append(key, value);
- }
- });
- extend(request.headers, form.getHeaders());
- request.data = form;
- }
-
// run pre-request vars
const preRequestVars = get(bruJson, 'request.vars.req');
if (preRequestVars?.length) {
@@ -73,21 +58,23 @@ const runSingleRequest = async function (
get(collectionRoot, 'request.script.req'),
get(bruJson, 'request.script.req')
]).join(os.EOL);
- if (requestScriptFile?.length) {
- const scriptRuntime = new ScriptRuntime();
- const result = await scriptRuntime.runRequestScript(
- decomment(requestScriptFile),
- request,
- envVariables,
- collectionVariables,
- collectionPath,
- null,
- processEnvVars,
- scriptingConfig
- );
- if (result?.nextRequestName !== undefined) {
- nextRequestName = result.nextRequestName;
- }
+ const variables = {
+ envVariables,
+ collectionVariables,
+ processEnvVars
+ };
+ const requestScriptResult = await runScript(
+ decomment(requestScriptFile),
+ request,
+ null,
+ variables,
+ false,
+ collectionPath,
+ scriptingConfig,
+ console.log
+ );
+ if (requestScriptResult?.nextRequestName !== undefined) {
+ nextRequestName = requestScriptResult.nextRequestName;
}
// interpolate variables inside request
@@ -293,22 +280,22 @@ const runSingleRequest = async function (
get(collectionRoot, 'request.script.res'),
get(bruJson, 'request.script.res')
]).join(os.EOL);
- if (responseScriptFile?.length) {
- const scriptRuntime = new ScriptRuntime();
- const result = await scriptRuntime.runResponseScript(
- decomment(responseScriptFile),
- request,
- response,
+ const result = await runScript(
+ decomment(responseScriptFile),
+ request,
+ response,
+ {
envVariables,
collectionVariables,
- collectionPath,
- null,
- processEnvVars,
- scriptingConfig
- );
- if (result?.nextRequestName !== undefined) {
- nextRequestName = result.nextRequestName;
- }
+ processEnvVars
+ },
+ false,
+ collectionPath,
+ scriptingConfig,
+ console.log
+ );
+ if (result?.nextRequestName !== undefined) {
+ nextRequestName = result.nextRequestName;
}
// run assertions
@@ -336,23 +323,22 @@ const runSingleRequest = async function (
}
// run tests
- let testResults = [];
const testFile = compact([get(collectionRoot, 'request.tests'), get(bruJson, 'request.tests')]).join(os.EOL);
- if (typeof testFile === 'string') {
- const testRuntime = new TestRuntime();
- const result = await testRuntime.runTests(
- decomment(testFile),
- request,
- response,
+ const testScriptResult = await runScript(
+ decomment(testFile),
+ request,
+ null,
+ {
envVariables,
collectionVariables,
- collectionPath,
- null,
- processEnvVars,
- scriptingConfig
- );
- testResults = get(result, 'results', []);
- }
+ processEnvVars
+ },
+ true,
+ collectionPath,
+ scriptingConfig,
+ console.log
+ );
+ const testResults = get(testScriptResult, 'results', []);
if (testResults?.length) {
each(testResults, (testResult) => {
diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js
index 262cca6503..2e9762ba2b 100644
--- a/packages/bruno-cli/src/utils/bru.js
+++ b/packages/bruno-cli/src/utils/bru.js
@@ -1,12 +1,6 @@
const _ = require('lodash');
-const Mustache = require('mustache');
const { bruToEnvJsonV2, bruToJsonV2, collectionBruToJson: _collectionBruToJson } = require('@usebruno/lang');
-// override the default escape function to prevent escaping
-Mustache.escape = function (value) {
- return value;
-};
-
const collectionBruToJson = (bru) => {
try {
const json = _collectionBruToJson(bru);
@@ -96,7 +90,7 @@ const getEnvVars = (environment = {}) => {
const envVars = {};
_.each(variables, (variable) => {
if (variable.enabled) {
- envVars[variable.name] = Mustache.escape(variable.value);
+ envVars[variable.name] = variable.value;
}
});
diff --git a/packages/bruno-common/package.json b/packages/bruno-common/package.json
index cc25f2337c..f2d6bfb3ee 100644
--- a/packages/bruno-common/package.json
+++ b/packages/bruno-common/package.json
@@ -2,33 +2,25 @@
"name": "@usebruno/common",
"version": "0.1.0",
"license": "MIT",
- "main": "dist/cjs/index.js",
- "module": "dist/esm/index.js",
- "types": "dist/index.d.ts",
"files": [
- "dist",
- "src",
- "package.json"
+ "dist"
],
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "module": "dist/index.mjs",
+ "private": true,
"scripts": {
+ "build": "vite build",
+ "dev": "vite build --watch",
"clean": "rimraf dist",
"test": "jest",
"test:watch": "jest --watch",
"prebuild": "npm run clean",
- "build": "rollup -c",
"prepack": "npm run test && npm run build"
},
"devDependencies": {
- "@rollup/plugin-commonjs": "^23.0.2",
- "@rollup/plugin-node-resolve": "^15.0.1",
- "@rollup/plugin-typescript": "^9.0.2",
- "rollup": "3.2.5",
- "rollup-plugin-dts": "^5.0.0",
- "rollup-plugin-peer-deps-external": "^2.2.4",
- "rollup-plugin-terser": "^7.0.2",
- "typescript": "^4.8.4"
- },
- "overrides": {
- "rollup": "3.2.5"
+ "rimraf": "^5.0.5",
+ "typescript": "^5.5",
+ "vite": "^5.1.5"
}
}
diff --git a/packages/bruno-common/readme.md b/packages/bruno-common/readme.md
index dd7caf77f6..8758abf71c 100644
--- a/packages/bruno-common/readme.md
+++ b/packages/bruno-common/readme.md
@@ -2,6 +2,8 @@
A collection of common utilities used across Bruno App, Electron and CLI packages.
+This package is compatible with Browser and Node.
+
### Publish to Npm Registry
```bash
diff --git a/packages/bruno-common/rollup.config.js b/packages/bruno-common/rollup.config.js
deleted file mode 100644
index 51aedecb68..0000000000
--- a/packages/bruno-common/rollup.config.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const { nodeResolve } = require('@rollup/plugin-node-resolve');
-const commonjs = require('@rollup/plugin-commonjs');
-const typescript = require('@rollup/plugin-typescript');
-const dts = require('rollup-plugin-dts');
-const { terser } = require('rollup-plugin-terser');
-const peerDepsExternal = require('rollup-plugin-peer-deps-external');
-
-const packageJson = require('./package.json');
-
-module.exports = [
- {
- input: 'src/index.ts',
- output: [
- {
- file: packageJson.main,
- format: 'cjs',
- sourcemap: true
- },
- {
- file: packageJson.module,
- format: 'esm',
- sourcemap: true
- }
- ],
- plugins: [
- peerDepsExternal(),
- nodeResolve({
- extensions: ['.css']
- }),
- commonjs(),
- typescript({ tsconfig: './tsconfig.json' }),
- terser()
- ]
- },
- {
- input: 'dist/esm/index.d.ts',
- output: [{ file: 'dist/index.d.ts', format: 'esm' }],
- plugins: [dts.default()]
- }
-];
diff --git a/packages/bruno-common/src/index.ts b/packages/bruno-common/src/index.ts
index 04a709c578..7d3b6e72dc 100644
--- a/packages/bruno-common/src/index.ts
+++ b/packages/bruno-common/src/index.ts
@@ -1,5 +1 @@
-import interpolate from './interpolate';
-
-export default {
- interpolate
-};
+export { default as interpolate } from './interpolate';
diff --git a/packages/bruno-common/src/interpolate/index.ts b/packages/bruno-common/src/interpolate/index.ts
index d4bd7cb6b0..372096b8f4 100644
--- a/packages/bruno-common/src/interpolate/index.ts
+++ b/packages/bruno-common/src/interpolate/index.ts
@@ -14,45 +14,58 @@
import { Set } from 'typescript';
import { flattenObject } from '../utils';
+function serializeObject(obj: Object) {
+ // Check if the object has a `toString` method like `Moment`
+ // Don't do it with arrays they serialize weirdly
+ if (typeof obj.toString === 'function' && !Array.isArray(obj)) {
+ try {
+ const result = obj.toString();
+ // The default object becomes '[object Object]' string
+ if (result !== '[object Object]') {
+ return result;
+ }
+ } catch {}
+ }
+
+ // Everything else will be json encoded
+ return JSON.stringify(obj);
+}
+
const interpolate = (str: string, obj: Record): string => {
if (!str || typeof str !== 'string' || !obj || typeof obj !== 'object') {
return str;
}
- const flattenedObj = flattenObject(obj);
-
- return replace(str, flattenedObj);
+ // Interpolate the variable as often as anything changes but no more than 3 times
+ let last = str;
+ for (let i = 0; i < 3; i++) {
+ const interpolated = doInterpolate(last, obj);
+ // Check if interpolated and last is the same and we can interpolate everything
+ if (interpolated === last) {
+ return interpolated;
+ }
+ last = interpolated;
+ }
+ return last;
};
-const replace = (
- str: string,
- flattenedObj: Record,
- visited = new Set(),
- results = new Map()
-): string => {
+function doInterpolate(str: string, obj: Record) {
const patternRegex = /\{\{([^}]+)\}\}/g;
-
+ const flattenedObj = flattenObject(obj);
return str.replace(patternRegex, (match, placeholder) => {
- const replacement = flattenedObj[placeholder];
-
- if (results.has(match)) {
- return results.get(match);
+ const replacement = flattenedObj[placeholder] || obj[placeholder];
+ // Return the original string so nothing gets replaced
+ if (replacement === undefined) {
+ return match;
}
- if (patternRegex.test(replacement) && !visited.has(match)) {
- visited.add(match);
- const result = replace(replacement, flattenedObj, visited, results);
- results.set(match, result);
-
- return result;
+ // Objects must be either JSON encoded or convert to a String via `toString`
+ if (typeof replacement === 'object') {
+ return serializeObject(replacement);
}
- visited.add(match);
- const result = replacement !== undefined ? replacement : match;
- results.set(match, result);
-
- return result;
+ return replacement;
});
-};
+}
export default interpolate;
diff --git a/packages/bruno-common/tsconfig.json b/packages/bruno-common/tsconfig.json
index 57a8bcc74e..17908a67de 100644
--- a/packages/bruno-common/tsconfig.json
+++ b/packages/bruno-common/tsconfig.json
@@ -1,19 +1,8 @@
{
+ "extends": "../../tsconfig.base.json",
"compilerOptions": {
- "target": "ES6",
- "esModuleInterop": true,
- "strict": true,
- "skipLibCheck": true,
- "jsx": "react",
- "module": "ESNext",
- "declaration": true,
- "declarationDir": "types",
- "sourceMap": true,
- "outDir": "dist",
- "moduleResolution": "node",
- "emitDeclarationOnly": true,
- "allowSyntheticDefaultImports": true,
- "forceConsistentCasingInFileNames": true
+ "outDir": "./dist",
+ "module": "CommonJS"
},
- "exclude": ["dist", "node_modules", "tests"]
+ "include": ["src/**/*"]
}
diff --git a/packages/bruno-common/vite.config.ts b/packages/bruno-common/vite.config.ts
new file mode 100644
index 0000000000..a565f5247a
--- /dev/null
+++ b/packages/bruno-common/vite.config.ts
@@ -0,0 +1,16 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ build: {
+ minify: false,
+ sourcemap: true,
+ lib: {
+ entry: resolve(__dirname, 'src/index.ts'),
+ name: 'index',
+ fileName: 'index',
+ formats: ['es', 'cjs']
+ }
+ },
+ clearScreen: false
+});
diff --git a/packages/bruno-core/.gitignore b/packages/bruno-core/.gitignore
new file mode 100644
index 0000000000..f6eabff32a
--- /dev/null
+++ b/packages/bruno-core/.gitignore
@@ -0,0 +1,22 @@
+# dependencies
+node_modules
+yarn.lock
+pnpm-lock.yaml
+package-lock.json
+.pnp
+.pnp.js
+
+# testing
+coverage
+
+# production
+dist
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/packages/bruno-core/LICENCE b/packages/bruno-core/LICENCE
new file mode 100644
index 0000000000..fc36f91c21
--- /dev/null
+++ b/packages/bruno-core/LICENCE
@@ -0,0 +1,15 @@
+ bruno-core Library with core functions and utilities for Bruno.
+ Copyright (C) 2024 Its-Treason
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
diff --git a/packages/bruno-core/jest.config.js b/packages/bruno-core/jest.config.js
new file mode 100644
index 0000000000..a58c252f80
--- /dev/null
+++ b/packages/bruno-core/jest.config.js
@@ -0,0 +1,5 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node'
+};
diff --git a/packages/bruno-core/package.json b/packages/bruno-core/package.json
new file mode 100644
index 0000000000..214e742027
--- /dev/null
+++ b/packages/bruno-core/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@usebruno/core",
+ "version": "0.1.0",
+ "license": "GPL-3.0-only",
+ "files": [
+ "dist"
+ ],
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "module": "dist/index.mjs",
+ "private": true,
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "check": "tsc --noEmit",
+ "clean": "rimraf dist",
+ "test": "jest"
+ },
+ "dependencies": {
+ "@usebruno/common": "workspace:*",
+ "@usebruno/query": "workspace:*",
+ "chai": "^4.3.7",
+ "chai-string": "^1.5.0",
+ "decomment": "^0.9.5",
+ "form-data": "^4.0.0",
+ "json-query": "^2.2.2",
+ "lodash": "^4.17.21",
+ "lossless-json": "^4.0.1",
+ "nanoid": "3.3.4",
+ "proxy-agent": "^6.4.0",
+ "qs": "^6.12.0",
+ "tough-cookie": "^4.1.3"
+ },
+ "devDependencies": {
+ "@types/chai": "^4.3.14",
+ "@types/chai-string": "^1.4.5",
+ "@types/decomment": "^0.9.5",
+ "@types/json-query": "^2.2.6",
+ "@types/node": "^20.11.25",
+ "@types/qs": "^6.9.14",
+ "@types/tough-cookie": "^4.0.5",
+ "rimraf": "^5.0.5",
+ "typescript": "^5.5"
+ }
+}
diff --git a/packages/bruno-core/readme.md b/packages/bruno-core/readme.md
new file mode 100644
index 0000000000..27e4cd8ab1
--- /dev/null
+++ b/packages/bruno-core/readme.md
@@ -0,0 +1,5 @@
+# bruno-core
+
+Core functions and utilities for Bruno.
+
+This module only made for usage with Node not the Browser/Electron.
diff --git a/packages/bruno-core/src/index.ts b/packages/bruno-core/src/index.ts
new file mode 100644
index 0000000000..f78a5df2c9
--- /dev/null
+++ b/packages/bruno-core/src/index.ts
@@ -0,0 +1,2 @@
+export * from './request/index';
+export * from './request/types';
diff --git a/packages/bruno-core/src/request/Callbacks.ts b/packages/bruno-core/src/request/Callbacks.ts
new file mode 100644
index 0000000000..659f692ca3
--- /dev/null
+++ b/packages/bruno-core/src/request/Callbacks.ts
@@ -0,0 +1,196 @@
+import { RequestContext } from './types';
+import { stringify, parse } from 'lossless-json';
+import { STATUS_CODES } from 'node:http';
+import { Cookie, CookieJar } from 'tough-cookie';
+import { cleanJson } from './runtime/utils';
+
+type Callback = (payload: any) => void;
+export type RawCallbacks = {
+ updateScriptEnvironment: Callback;
+ cookieUpdated: Callback;
+ requestEvent: Callback;
+ runFolderEvent: Callback;
+ consoleLog: Callback;
+};
+
+export class Callbacks {
+ constructor(private rawCallbacks: Partial) {}
+
+ private send(callbackName: keyof RawCallbacks, context: RequestContext, payload: any) {
+ const callback = this.rawCallbacks[callbackName];
+ if (!callback || context.abortController?.signal.aborted === true) {
+ return;
+ }
+ callback(payload);
+ }
+
+ requestQueued(context: RequestContext) {
+ this.send('requestEvent', context, {
+ type: 'request-queued',
+ requestUid: context.requestItem.uid,
+ collectionUid: context.collection.uid,
+ itemUid: context.requestItem.uid,
+ cancelTokenUid: context.cancelToken
+ });
+ }
+
+ requestSend(context: RequestContext) {
+ this.send('requestEvent', context, {
+ type: 'request-sent',
+ requestSent: {
+ url: context.requestItem.request.url,
+ method: context.requestItem.request.method,
+ headers: context.requestItem.request.headers,
+ data: parse(stringify('{}')!),
+ timestamp: Date.now()
+ },
+ collectionUid: context.collection.uid,
+ itemUid: context.requestItem.uid,
+ requestUid: context.uid,
+ cancelTokenUid: ''
+ });
+ }
+
+ assertionResults(context: RequestContext, results: any[]) {
+ this.send('requestEvent', context, {
+ type: 'assertion-results',
+ results: results,
+ itemUid: context.requestItem.uid,
+ requestUid: context.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+
+ testResults(context: RequestContext, results: any[]) {
+ this.send('requestEvent', context, {
+ type: 'test-results',
+ results: results,
+ itemUid: context.requestItem.uid,
+ requestUid: context.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+
+ updateScriptEnvironment(context: RequestContext, envVariables: any, collectionVariables: any) {
+ this.send('updateScriptEnvironment', context, {
+ envVariables,
+ collectionVariables,
+ requestUid: context.requestItem.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+
+ cookieUpdated(cookieJar: CookieJar) {
+ // @ts-expect-error Not sure why the store is not included in the type
+ cookieJar.store.getAllCookies((err: Error, cookies: Cookie[]) => {
+ if (err) {
+ throw err;
+ }
+
+ const domainCookieMap: Record = {};
+ cookies.forEach((cookie) => {
+ if (!cookie.domain) {
+ return;
+ }
+
+ if (!domainCookieMap[cookie.domain]) {
+ domainCookieMap[cookie.domain] = [cookie];
+ } else {
+ domainCookieMap[cookie.domain].push(cookie);
+ }
+ });
+
+ const domains = Object.keys(domainCookieMap);
+ const domainsWithCookies = [];
+
+ for (const domain of domains) {
+ const cookies = domainCookieMap[domain];
+ const validCookies = cookies.filter(
+ (cookie) => cookie.expires === 'Infinity' || cookie.expires.getTime() > Date.now()
+ );
+
+ if (validCookies.length) {
+ domainsWithCookies.push({
+ domain,
+ cookies: validCookies,
+ cookieString: validCookies.map((cookie) => cookie.cookieString()).join('; ')
+ });
+ }
+ }
+
+ if (!this.rawCallbacks.cookieUpdated) {
+ return;
+ }
+ this.rawCallbacks.cookieUpdated(cleanJson(domainsWithCookies));
+ });
+ }
+
+ consoleLog(type: string, args: any) {
+ if (!this.rawCallbacks.consoleLog) {
+ return;
+ }
+
+ this.rawCallbacks.consoleLog({ type, args });
+ }
+
+ folderRequestQueued(context: RequestContext) {
+ this.send('runFolderEvent', context, {
+ type: 'request-queued',
+ itemUid: context.requestItem.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+
+ folderRequestSent(context: RequestContext) {
+ this.send('runFolderEvent', context, {
+ type: 'request-sent',
+ requestSent: {
+ url: context.requestItem.request.url,
+ method: context.httpRequest!.options.method,
+ headers: context.httpRequest!.options.headers,
+ data: context.httpRequest!.body ?? undefined,
+ timestamp: Date.now()
+ },
+ isNew: true,
+ itemUid: context.requestItem.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+
+ folderResponseReceived(context: RequestContext) {
+ this.send('runFolderEvent', context, {
+ type: 'response-received',
+ responseReceived: {
+ status: context.response?.statusCode,
+ statusText: STATUS_CODES[context.response?.statusCode || 0] || 'Unknown',
+ headers: context.response?.headers,
+ duration: context.response?.responseTime,
+ size: context.response?.headers['content-size'] ?? 0,
+ responseTime: context.response?.responseTime
+ },
+ timeline: context.timeline,
+ timings: context.timings.getClean(),
+ debug: context.debug.getClean(),
+ itemUid: context.requestItem.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+
+ folderAssertionResults(context: RequestContext, results: any[]) {
+ this.send('runFolderEvent', context, {
+ type: 'assertion-results',
+ assertionResults: results,
+ itemUid: context.requestItem.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+
+ folderTestResults(context: RequestContext, results: any[]) {
+ this.send('runFolderEvent', context, {
+ type: 'test-results',
+ testResults: results,
+ itemUid: context.requestItem.uid,
+ collectionUid: context.collection.uid
+ });
+ }
+}
diff --git a/packages/bruno-core/src/request/DebugLogger.ts b/packages/bruno-core/src/request/DebugLogger.ts
new file mode 100644
index 0000000000..bfecd026a0
--- /dev/null
+++ b/packages/bruno-core/src/request/DebugLogger.ts
@@ -0,0 +1,20 @@
+import { cleanJson } from './runtime/utils';
+
+type Logs = { title: string; data: unknown; date: number }[];
+type LogStages = { stage: string; logs: Logs };
+
+export class DebugLogger extends Array {
+ public log(title: string, data?: unknown): void {
+ // We use structuredClone here to prevent any further changes through object references
+ const log = structuredClone({ title, data, date: Date.now() });
+ this[this.length - 1].logs.push(log);
+ }
+
+ public addStage(stage: string): void {
+ this.push({ stage, logs: [] });
+ }
+
+ getClean() {
+ return cleanJson(this);
+ }
+}
diff --git a/packages/bruno-core/src/request/Timeline.ts b/packages/bruno-core/src/request/Timeline.ts
new file mode 100644
index 0000000000..35dfbdbaff
--- /dev/null
+++ b/packages/bruno-core/src/request/Timeline.ts
@@ -0,0 +1,12 @@
+import { HttpRequestInfo } from './httpRequest/httpRequest';
+
+export class Timeline extends Array {
+ public add(request: HttpRequestInfo) {
+ const unref = { ...request };
+ if (unref.responseBody) {
+ // @ts-expect-error This is used for in the TimelineNew component in the frontned
+ unref.responseBody = unref.responseBody.toString().slice(0, 2048);
+ }
+ this.push(structuredClone(unref));
+ }
+}
diff --git a/packages/bruno-core/src/request/Timings.ts b/packages/bruno-core/src/request/Timings.ts
new file mode 100644
index 0000000000..2036130ccf
--- /dev/null
+++ b/packages/bruno-core/src/request/Timings.ts
@@ -0,0 +1,32 @@
+import { cleanJson } from './runtime/utils';
+
+type TimingName = 'request' | 'preScript' | 'postScript' | 'test' | 'total';
+
+export class Timings {
+ private startTimings: Record = {};
+ private timings: Record = {};
+
+ public startMeasure(name: TimingName): void {
+ this.startTimings[name] = performance.now();
+ }
+
+ public stopMeasure(name: TimingName): void {
+ const measurement = this.startTimings[name];
+ if (!measurement) {
+ throw new Error(`No measurement started for "${name}"`);
+ }
+ this.timings[name] = Math.round(performance.now() - measurement);
+ }
+
+ public stopAll(): void {
+ for (const [name, measurement] of Object.entries(this.startTimings)) {
+ if (this.timings[name] === undefined) {
+ this.timings[name] = Math.round(performance.now() - measurement);
+ }
+ }
+ }
+
+ public getClean() {
+ return cleanJson(this.timings);
+ }
+}
diff --git a/packages/bruno-core/src/request/httpRequest/awsSig4vAuth.ts b/packages/bruno-core/src/request/httpRequest/awsSig4vAuth.ts
new file mode 100644
index 0000000000..70267cc383
--- /dev/null
+++ b/packages/bruno-core/src/request/httpRequest/awsSig4vAuth.ts
@@ -0,0 +1,94 @@
+import { AuthMode } from '../types';
+import crypto from 'node:crypto';
+import { Readable } from 'stream';
+import { RequestOptions } from 'node:http';
+
+function createAwsV4AuthHeaders(
+ opts: RequestOptions,
+ body: string | Buffer = '',
+ authConfig: Extract
+): Record {
+ const method = opts.method!.toUpperCase();
+ const headers = opts.headers as Record;
+ const { hostname, pathname, searchParams } = new URL(opts.path!, `${opts.protocol}//${opts.hostname}`);
+
+ const amzDate = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '');
+ const dateStamp = amzDate.substring(0, 8);
+
+ // Set the host header if not present, otherwise undici will set it later
+ if (!headers['host']) {
+ headers['host'] = hostname;
+ }
+
+ // Reference: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
+ // Task 1: Create a Canonical Request
+ const canonicalHeaders = Object.keys(headers)
+ .filter((key) => key && headers[key])
+ .sort()
+ .map((key) => {
+ const val = headers[key];
+ if (Array.isArray(val)) {
+ return val.map((headerVal) => `${key.toLowerCase()}:${headerVal.trim()}`).join('\n');
+ }
+ return `${key.toLowerCase()}:${val.trim()}`;
+ })
+ .join('\n');
+ const signedHeaders = Object.keys(headers)
+ .filter((key) => key && headers[key])
+ .map((key) => key.toLowerCase())
+ .sort()
+ .join(';');
+
+ const canonicalQueryString = new URLSearchParams([...searchParams.entries()].sort()).toString();
+
+ if (body instanceof Readable) {
+ throw new Error(`TODO: Implement readable conversion`);
+ }
+ if (body instanceof FormData) {
+ throw new Error(`TODO: Implement FormData conversion`);
+ }
+
+ const hashedBody = crypto
+ .createHash('sha256')
+ .update(body || '')
+ .digest('hex');
+ const canonicalRequest = `${method}\n${pathname}\n${canonicalQueryString}\n${canonicalHeaders}\n\n${signedHeaders}\n${hashedBody}`;
+
+ // Task 2: Create a String to Sign
+ const credentialScope = `${dateStamp}/${authConfig.awsv4.region}/${authConfig.awsv4.service}/aws4_request`;
+ const hashedCanonicalRequest = crypto.createHash('sha256').update(canonicalRequest).digest('hex');
+ const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${credentialScope}\n${hashedCanonicalRequest}`;
+
+ // Task 3: Calculate Signature
+ const secret = authConfig.awsv4.secretAccessKey;
+ const dateKey = crypto
+ .createHmac('sha256', 'AWS4' + secret)
+ .update(dateStamp)
+ .digest();
+ const dateRegionKey = crypto.createHmac('sha256', dateKey).update(authConfig.awsv4.region).digest();
+ const dateRegionServiceKey = crypto.createHmac('sha256', dateRegionKey).update(authConfig.awsv4.service).digest();
+ const signingKey = crypto.createHmac('sha256', dateRegionServiceKey).update('aws4_request').digest();
+ const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex');
+
+ // Task 4: Create the final headers
+ const authorization = `AWS4-HMAC-SHA256 Credential=${authConfig.awsv4.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
+
+ return {
+ 'x-amz-date': amzDate,
+ 'x-amz-security-token': authConfig.awsv4.sessionToken,
+ authorization
+ };
+}
+
+export function addAwsAuthHeader(
+ authConfig: Extract,
+ requestOptions: RequestOptions,
+ body?: string | Buffer
+): void {
+ const authHeaders = createAwsV4AuthHeaders(requestOptions, body, authConfig);
+
+ requestOptions.headers = {
+ ...requestOptions.headers,
+ ...authHeaders
+ };
+}
diff --git a/packages/bruno-core/src/request/httpRequest/digestAuth.ts b/packages/bruno-core/src/request/httpRequest/digestAuth.ts
new file mode 100644
index 0000000000..efe46c9204
--- /dev/null
+++ b/packages/bruno-core/src/request/httpRequest/digestAuth.ts
@@ -0,0 +1,68 @@
+import crypto from 'crypto';
+import { AuthMode } from '../types';
+import { URL } from 'node:url';
+import { RequestOptions } from 'http';
+
+type DigestAuthDetails = {
+ algorithm: string;
+ 'Digest realm': string;
+ nonce: string;
+};
+
+function hash(input: string, algo: string) {
+ return crypto.createHash(algo).update(input).digest('hex');
+}
+
+export function handleDigestAuth(
+ statusCode: number,
+ headers: Record,
+ originalRequest: RequestOptions,
+ auth: AuthMode
+): boolean {
+ if (
+ auth.mode !== 'digest' || // Only execute if user configured digest as auth mode
+ statusCode !== 401 || // Only Apply auth if we really are unauthorized
+ !headers['www-authenticate'] || // Check if the Server returned the Auth details
+ // @ts-expect-error This header object is set up us, by the type for it is more broad
+ !!originalRequest.headers['authorization'] // Check if we already sent the Authorization header
+ ) {
+ return false;
+ }
+
+ const wwwAuth = Array.isArray(headers['www-authenticate'])
+ ? headers['www-authenticate'][0]
+ : headers['www-authenticate'];
+ const authDetails = String(wwwAuth)
+ .split(', ')
+ .map((v) => v.split('=').map((str) => str.replace(/"/g, '')))
+ .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) as DigestAuthDetails;
+
+ const nonceCount = '00000001';
+ const cnonce = crypto.randomBytes(24).toString('hex');
+ const uri = new URL(originalRequest.path!, `${originalRequest.protocol}//${originalRequest.hostname}`).pathname;
+
+ let algo = 'md5';
+ switch (authDetails.algorithm.toLowerCase()) {
+ case 'sha-256':
+ case 'sha256':
+ algo = 'sha256';
+ break;
+ case 'sha-512':
+ case 'sha512':
+ algo = 'sha512';
+ break;
+ }
+
+ const ha1 = hash(`${auth.digest.username}:${authDetails['Digest realm']}:${auth.digest.password}`, algo);
+ const ha2 = hash(`${originalRequest.method}:${uri}`, algo);
+ const response = hash(`${ha1}:${authDetails.nonce}:${nonceCount}:${cnonce}:auth:${ha2}`, algo);
+
+ const authorizationHeader =
+ `Digest username="${auth.digest.username}",realm="${authDetails['Digest realm']}",` +
+ `nonce="${authDetails.nonce}",uri="${uri}",qop="auth",algorithm="${authDetails.algorithm}",` +
+ `response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`;
+
+ originalRequest.headers!['authorization'] = authorizationHeader;
+
+ return true;
+}
diff --git a/packages/bruno-core/src/request/httpRequest/httpRequest.ts b/packages/bruno-core/src/request/httpRequest/httpRequest.ts
new file mode 100644
index 0000000000..a65c22e1ee
--- /dev/null
+++ b/packages/bruno-core/src/request/httpRequest/httpRequest.ts
@@ -0,0 +1,104 @@
+import { request as requestHttp } from 'node:http';
+import { request as requestHttps } from 'node:https';
+import { Buffer } from 'node:buffer';
+import { BrunoRequestOptions } from '../types';
+
+export type HttpRequestInfo = {
+ // RequestInfo
+ finalOptions: Readonly;
+ requestBody?: string;
+ // Response
+ responseTime?: number;
+ statusCode?: number;
+ statusMessage?: String;
+ headers?: Record;
+ httpVersion?: string;
+ responseBody?: Buffer;
+ error?: string;
+ info?: string;
+};
+
+export async function execHttpRequest(
+ options: BrunoRequestOptions,
+ body?: string | Buffer,
+ signal?: AbortSignal
+): Promise {
+ const requestInfo: HttpRequestInfo = {
+ finalOptions: { ...options, agent: undefined },
+ requestBody: body ? body.toString().slice(0, 2048) : undefined
+ };
+
+ const startTime = performance.now();
+ try {
+ await doExecHttpRequest(
+ requestInfo,
+ {
+ ...options,
+ signal
+ },
+ body
+ );
+ } catch (error) {
+ requestInfo.error = String(error);
+ }
+ requestInfo.responseTime = Math.round(performance.now() - startTime);
+
+ return requestInfo;
+}
+
+async function doExecHttpRequest(info: HttpRequestInfo, options: BrunoRequestOptions, body?: string | Buffer) {
+ if (options.protocol !== 'https:' && options.protocol !== 'http:') {
+ throw new Error(`Unsupported protocol: "${options.protocol}", only "https:" & "http:" are supported`);
+ }
+
+ const req = options.protocol === 'http:' ? requestHttp(options) : requestHttps(options);
+
+ let resolve: () => void;
+ const reqPromise = new Promise((res) => {
+ resolve = res;
+ });
+
+ let responseBuffers: Buffer[] = [];
+
+ req.on('response', (response) => {
+ info.statusCode = response.statusCode;
+ info.statusMessage = response.statusMessage;
+ // Remove `undefined` and make it an empty array instead
+ info.headers = Object.entries(response.headersDistinct).reduce>((acc, [key, val]) => {
+ acc[key] = val === undefined ? [''] : val;
+ return acc;
+ }, {});
+ info.httpVersion = response.httpVersion;
+
+ response.on('data', (chunk) => {
+ if (!Buffer.isBuffer(chunk)) {
+ // We did not set the encoding, so it must be a Buffer here
+ throw new Error('Expected data to be a buffer!');
+ }
+
+ responseBuffers.push(chunk);
+ });
+ response.on('end', () => {
+ info.responseBody = Buffer.concat(responseBuffers);
+ });
+ });
+
+ req.on('error', (err) => {
+ info.error = String(err);
+ if (err.name === 'AggregateError') {
+ // @ts-expect-error
+ info.error = err.errors.map(String).join('\n');
+ }
+ resolve();
+ });
+ req.on('close', () => {
+ resolve();
+ });
+
+ if (body) {
+ req.write(body);
+ }
+ req.end();
+
+ await reqPromise;
+}
diff --git a/packages/bruno-core/src/request/httpRequest/requestHandler.ts b/packages/bruno-core/src/request/httpRequest/requestHandler.ts
new file mode 100644
index 0000000000..9d4076cd40
--- /dev/null
+++ b/packages/bruno-core/src/request/httpRequest/requestHandler.ts
@@ -0,0 +1,178 @@
+import { BrunoRequestOptions, RequestContext } from '../types';
+import { handleDigestAuth } from './digestAuth';
+import { addAwsAuthHeader } from './awsSig4vAuth';
+import { HttpRequestInfo, execHttpRequest } from './httpRequest';
+import { Timeline } from '../Timeline';
+import { writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+import { CookieJar } from 'tough-cookie';
+import { URL } from 'node:url';
+
+export async function makeHttpRequest(context: RequestContext) {
+ if (context.timeline === undefined) {
+ context.timeline = new Timeline();
+ }
+
+ const body = context.httpRequest?.body;
+ let requestOptions = context.httpRequest!.options;
+
+ while (true) {
+ addMandatoryHeader(requestOptions, body);
+ if (context.preferences.request.sendCookies) {
+ await addCookieHeader(requestOptions, context.cookieJar);
+ }
+ if (context.requestItem.request.auth.mode === 'awsv4') {
+ addAwsAuthHeader(context.requestItem.request.auth, requestOptions, body);
+ }
+
+ // Deference the original options
+ context.debug.log('Request', {
+ options: {
+ ...requestOptions,
+ agent: undefined // agent cannot be stringified
+ }
+ });
+ const response = await execHttpRequest(requestOptions, body, context.abortController?.signal);
+
+ const nextRequest = await handleServerResponse(context, requestOptions, response);
+ context.timeline?.add(response);
+ if (nextRequest === false) {
+ await handleFinalResponse(response, context);
+ break;
+ }
+
+ requestOptions = nextRequest;
+ }
+}
+
+function addMandatoryHeader(requestOptions: BrunoRequestOptions, body?: string | Buffer) {
+ let hostHeader = requestOptions.hostname;
+ if (requestOptions.port) {
+ hostHeader += `:${requestOptions.port}`;
+ }
+ requestOptions.headers!['host'] = hostHeader;
+
+ if (body !== undefined) {
+ const length = Buffer.isBuffer(body) ? body.length : Buffer.byteLength(body);
+ requestOptions.headers!['content-length'] = String(length);
+ }
+}
+
+async function addCookieHeader(requestOptions: BrunoRequestOptions, cookieJar: CookieJar) {
+ const currentUrl = urlFromRequestOptions(requestOptions);
+ const cookieHeader = await cookieJar.getCookieString(currentUrl.href);
+ if (cookieHeader) {
+ requestOptions.headers!['cookie'] = cookieHeader;
+ }
+}
+
+async function handleServerResponse(
+ context: RequestContext,
+ request: BrunoRequestOptions,
+ response: HttpRequestInfo
+): Promise {
+ // We did not get a response / an error occurred
+ if (response.statusCode === undefined) {
+ return false;
+ }
+
+ if (context.preferences.request.storeCookies) {
+ await storeCookies(context.cookieJar, request, response);
+ }
+
+ const mustRedirect = handleRedirect(request, response);
+ if (mustRedirect) {
+ // Use the users redirect limit or default to 25
+ const redirectLimit = context.requestItem.request.maxRedirects ?? 25;
+ if (context.httpRequest!.redirectDepth >= redirectLimit) {
+ response.info = 'Server returned redirect, but redirect limit is reached';
+ return false;
+ }
+ context.httpRequest!.redirectDepth++;
+ response.info = 'Server returned redirect';
+ return request;
+ }
+
+ const digestAuthContinue = handleDigestAuth(
+ response.statusCode!,
+ response.headers!,
+ request,
+ context.requestItem.request.auth
+ );
+ if (digestAuthContinue) {
+ response.info = 'Server returned DigestAuth details';
+ return request;
+ }
+
+ response.info = 'Final response';
+ return false;
+}
+
+// This is basically copied from: https://github.com/nodejs/undici/blob/main/lib/handler/redirect-handler.js#L91
+function handleRedirect(request: BrunoRequestOptions, response: HttpRequestInfo): boolean {
+ // Should only be counted with one of these status codes
+ if (response.statusCode === undefined || ![300, 301, 302, 303, 307, 308].includes(response.statusCode)) {
+ return false;
+ }
+
+ // Check if we got a Location header
+ const newLocation = Array.isArray(response.headers!) ? response.headers![0] : response.headers!['location'];
+ if (!newLocation) {
+ return false;
+ }
+
+ // This will first build the Original request URL and then merge it with the location header.
+ // URL will automatically handle a relative Location header e.g. /new-site or an absolute location
+ // e.g. https://my-new-site.net
+ let newLocationUrl;
+ try {
+ const oldBaseUrl = urlFromRequestOptions(request);
+ newLocationUrl = new URL(newLocation, new URL(request.path, `${request.protocol}//${request.hostname}`));
+ } catch (error) {
+ throw new Error(
+ 'Could not create Url to redirect location! Server returned this location: ' +
+ `"${newLocation}", old path: "${request.path}" & old base: "${request.protocol}//${request.hostname}". ` +
+ `Original error: ${error}`
+ );
+ }
+
+ request.hostname = newLocationUrl.hostname;
+ request.port = newLocationUrl.port;
+ request.protocol = newLocationUrl.protocol;
+ request.path = `${newLocationUrl.pathname}${newLocationUrl.search}`;
+
+ return true;
+}
+
+async function storeCookies(cookieJar: CookieJar, request: BrunoRequestOptions, response: HttpRequestInfo) {
+ const currentUrl = urlFromRequestOptions(request);
+
+ for (const cookieString of response.headers!['set-cookie'] ?? []) {
+ await cookieJar.setCookie(cookieString, currentUrl.href);
+ }
+}
+
+async function handleFinalResponse(response: HttpRequestInfo, context: RequestContext) {
+ if (response.error || response.statusCode === undefined) {
+ throw new Error(response.error || 'Server did not return a response');
+ }
+
+ const targetPath = join(context.dataDir, context.requestItem.uid);
+ await writeFile(targetPath, response.responseBody!);
+
+ context.response = {
+ path: targetPath,
+ headers: response.headers!,
+ responseTime: response.responseTime!,
+ statusCode: response.statusCode,
+ size: response.responseBody?.length ?? 0
+ };
+}
+
+export function urlFromRequestOptions(opts: BrunoRequestOptions): URL {
+ let port = '';
+ if (opts.port) {
+ port = `:${port}`;
+ }
+ return new URL(`${opts.protocol}//${opts.hostname}${port}${opts.path}`);
+}
diff --git a/packages/bruno-core/src/request/index.ts b/packages/bruno-core/src/request/index.ts
new file mode 100644
index 0000000000..769c4c123a
--- /dev/null
+++ b/packages/bruno-core/src/request/index.ts
@@ -0,0 +1,115 @@
+import { DebugLogger } from './DebugLogger';
+import { Timings } from './Timings';
+import { Collection, CollectionEnvironment, Preferences, RequestContext, RequestItem } from './types';
+import { preRequestVars } from './preRequest/preRequestVars';
+import { preRequestScript } from './preRequest/preRequestScript';
+import { applyCollectionSettings } from './preRequest/applyCollectionSettings';
+import { createHttpRequest } from './preRequest/createHttpRequest';
+import { postRequestVars } from './postRequest/postRequestVars';
+import { postRequestScript } from './postRequest/postRequestScript';
+import { assertions } from './postRequest/assertions';
+import { tests } from './postRequest/tests';
+import { interpolateRequest } from './preRequest/interpolateRequest';
+import { Callbacks, RawCallbacks } from './Callbacks';
+import { nanoid } from 'nanoid';
+import { join } from 'node:path';
+import { rm } from 'node:fs/promises';
+import { makeHttpRequest } from './httpRequest/requestHandler';
+import { CookieJar } from 'tough-cookie';
+import { readResponseBodyAsync } from './runtime/utils';
+
+export async function request(
+ requestItem: RequestItem,
+ collection: Collection,
+ preferences: Preferences,
+ cookieJar: CookieJar,
+ dataDir: string,
+ cancelToken: string,
+ abortController: AbortController,
+ environment?: CollectionEnvironment,
+ rawCallbacks: Partial = {}
+) {
+ // Convert the EnvVariables into a Record
+ const environmentVariableRecord = (environment?.variables ?? []).reduce>((acc, env) => {
+ if (env.enabled) {
+ acc[env.name] = env.value;
+ }
+ return acc;
+ }, {});
+
+ const context: RequestContext = {
+ uid: nanoid(),
+ dataDir,
+ cancelToken,
+ abortController,
+
+ requestItem,
+ collection,
+ preferences,
+ cookieJar,
+ variables: {
+ process: {
+ process: {
+ // @ts-ignore
+ env: process.env
+ }
+ },
+ environment: environmentVariableRecord,
+ collection: collection.collectionVariables
+ },
+
+ callback: new Callbacks(rawCallbacks),
+ timings: new Timings(),
+ debug: new DebugLogger()
+ };
+
+ const targetPath = join(context.dataDir, context.requestItem.uid);
+ await rm(targetPath, { force: true });
+
+ try {
+ return await doRequest(context);
+ } catch (error) {
+ context.error = error instanceof Error ? error : new Error(String(error));
+ } finally {
+ context.timings.stopAll();
+ }
+
+ return context;
+}
+
+async function doRequest(context: RequestContext): Promise {
+ context.timings.startMeasure('total');
+ context.debug.addStage('Pre-Request');
+
+ context.callback.requestQueued(context);
+ context.callback.folderRequestQueued(context);
+
+ applyCollectionSettings(context);
+ preRequestVars(context);
+ await preRequestScript(context);
+ interpolateRequest(context);
+ await createHttpRequest(context);
+
+ context.callback.requestSend(context);
+
+ context.debug.addStage('Request');
+ context.timings.startMeasure('request');
+ await makeHttpRequest(context);
+ context.timings.stopMeasure('request');
+
+ context.debug.addStage('Post-Request');
+ context.callback.cookieUpdated(context.cookieJar);
+
+ const body = await readResponseBodyAsync(context.response!.path);
+
+ postRequestVars(context, body);
+ await postRequestScript(context, body);
+ assertions(context, body);
+ await tests(context, body);
+
+ context.timings.stopMeasure('total');
+
+ context.callback.folderResponseReceived(context);
+
+ return context;
+}
diff --git a/packages/bruno-core/src/request/postRequest/assertions.ts b/packages/bruno-core/src/request/postRequest/assertions.ts
new file mode 100644
index 0000000000..16833ab115
--- /dev/null
+++ b/packages/bruno-core/src/request/postRequest/assertions.ts
@@ -0,0 +1,21 @@
+import { AssertRuntime } from '../runtime/assert-runtime';
+import { RequestContext } from '../types';
+
+export function assertions(context: RequestContext, responseBody: any) {
+ const assertions = context.requestItem.request.assertions;
+ if (assertions) {
+ const assertRuntime = new AssertRuntime();
+ const results = assertRuntime.runAssertions(
+ assertions,
+ context.requestItem,
+ context.response,
+ responseBody,
+ context.variables.environment,
+ context.variables.collection,
+ context.collection.pathname
+ );
+
+ context.callback.folderAssertionResults(context, results);
+ context.callback.assertionResults(context, results);
+ }
+}
diff --git a/packages/bruno-core/src/request/postRequest/postRequestScript.ts b/packages/bruno-core/src/request/postRequest/postRequestScript.ts
new file mode 100644
index 0000000000..8577c37ceb
--- /dev/null
+++ b/packages/bruno-core/src/request/postRequest/postRequestScript.ts
@@ -0,0 +1,47 @@
+import { RequestContext } from '../types';
+import { runScript } from '../runtime/script-runner';
+import { EOL } from 'node:os';
+
+export async function postRequestScript(context: RequestContext, responseBody: any) {
+ const collectionPostRequestScript = context.collection.root?.request?.script?.res ?? '';
+ const requestPostRequestScript = context.requestItem.request.script.res ?? '';
+ const postRequestScript = collectionPostRequestScript + EOL + requestPostRequestScript;
+
+ context.debug.log('Post request script', {
+ collectionPostRequestScript,
+ requestPostRequestScript,
+ postRequestScript
+ });
+ context.timings.startMeasure('postScript');
+
+ let scriptResult;
+ try {
+ scriptResult = await runScript(
+ postRequestScript,
+ context.requestItem,
+ context.response!,
+ responseBody,
+ context.variables,
+ false,
+ context.collection.pathname,
+ context.collection.brunoConfig.scripts,
+ (type: string, payload: any) => context.callback.consoleLog(type, payload)
+ );
+ } catch (error) {
+ context.debug.log('Post request script error', { error });
+
+ throw error;
+ } finally {
+ context.timings.stopMeasure('postScript');
+ }
+
+ context.callback.updateScriptEnvironment(context, scriptResult.envVariables, scriptResult.collectionVariables);
+
+ context.debug.log('Post request script finished', scriptResult);
+
+ context.nextRequestName = scriptResult.nextRequestName;
+ // The script will use `cleanJson` to remove any weird things before sending to the mainWindow
+ // This destroys the references, so we update variables here manually
+ context.variables.collection = scriptResult.collectionVariables;
+ context.variables.environment = scriptResult.envVariables;
+}
diff --git a/packages/bruno-core/src/request/postRequest/postRequestVars.ts b/packages/bruno-core/src/request/postRequest/postRequestVars.ts
new file mode 100644
index 0000000000..283c9fd050
--- /dev/null
+++ b/packages/bruno-core/src/request/postRequest/postRequestVars.ts
@@ -0,0 +1,31 @@
+import { RequestContext } from '../types';
+import { VarsRuntime } from '../runtime/vars-runtime';
+
+export function postRequestVars(context: RequestContext, responseBody: any) {
+ const postRequestVars = context.requestItem.request.vars.res;
+ if (postRequestVars === undefined) {
+ context.debug.log('Post request variables skipped');
+ return;
+ }
+
+ const before = structuredClone(context.variables.collection);
+
+ const varsRuntime = new VarsRuntime();
+ // This will update context.variables.collection by reference inside the 'Bru' class
+ const varsResult = varsRuntime.runPostResponseVars(
+ postRequestVars,
+ context.requestItem,
+ context.response!,
+ responseBody,
+ context.variables.environment,
+ context.variables.collection,
+ context.collection.pathname,
+ context.variables.process
+ );
+
+ if (varsResult) {
+ context.callback.updateScriptEnvironment(context, undefined, varsResult.collectionVariables);
+ }
+
+ context.debug.log('Post request variables evaluated', { before, after: context.variables.collection });
+}
diff --git a/packages/bruno-core/src/request/postRequest/tests.ts b/packages/bruno-core/src/request/postRequest/tests.ts
new file mode 100644
index 0000000000..1e7792cf76
--- /dev/null
+++ b/packages/bruno-core/src/request/postRequest/tests.ts
@@ -0,0 +1,43 @@
+import { RequestContext } from '../types';
+import { EOL } from 'node:os';
+import { runScript } from '../runtime/script-runner';
+
+export async function tests(context: RequestContext, responseBody: any) {
+ const collectionPostRequestScript = context.collection.root?.request?.tests ?? '';
+ const requestPostRequestScript = context.requestItem.request.tests ?? '';
+ const postRequestScript = collectionPostRequestScript + EOL + requestPostRequestScript;
+
+ context.debug.log('Test script', {
+ collectionPostRequestScript,
+ requestPostRequestScript,
+ postRequestScript
+ });
+ context.timings.startMeasure('test');
+
+ let scriptResult;
+ try {
+ scriptResult = await runScript(
+ postRequestScript,
+ context.requestItem,
+ context.response!,
+ responseBody,
+ context.variables,
+ true,
+ context.collection.pathname,
+ context.collection.brunoConfig.scripts,
+ (type: string, payload: any) => context.callback.consoleLog(type, payload)
+ );
+ } catch (error) {
+ context.debug.log('Test script error', { error });
+
+ throw error;
+ } finally {
+ context.timings.stopMeasure('test');
+ }
+
+ context.callback.testResults(context, scriptResult.results);
+ context.callback.folderTestResults(context, scriptResult.results);
+ context.callback.updateScriptEnvironment(context, scriptResult.envVariables, scriptResult.collectionVariables);
+
+ context.debug.log('Test script finished', scriptResult);
+}
diff --git a/packages/bruno-core/src/request/preRequest/applyCollectionSettings.ts b/packages/bruno-core/src/request/preRequest/applyCollectionSettings.ts
new file mode 100644
index 0000000000..be14d3294c
--- /dev/null
+++ b/packages/bruno-core/src/request/preRequest/applyCollectionSettings.ts
@@ -0,0 +1,47 @@
+import { RequestContext } from '../types';
+
+function applyCollectionHeader(context: RequestContext) {
+ const mergedHeaders = [...(context.collection.root?.request?.headers ?? []), ...context.requestItem.request.headers];
+
+ context.debug.log('Collection header applied', {
+ collectionHeaders: context.collection.root?.request?.headers ?? [],
+ requestHeaders: context.requestItem.request.headers,
+ mergedHeaders
+ });
+
+ context.requestItem.request.headers = mergedHeaders;
+}
+
+function applyCollectionAuth(context: RequestContext) {
+ if (context.requestItem.request.auth.mode !== 'inherit') {
+ context.debug.log('Collection auth skipped', {
+ requestMode: context.requestItem.request.auth.mode,
+ collectionMode: context.collection.root?.request?.auth?.mode,
+ finalAuth: context.requestItem.request.auth
+ });
+ return;
+ }
+
+ context.requestItem.request.auth = context.collection.root?.request?.auth || { mode: 'none' };
+
+ context.debug.log('Collection auth applied', {
+ requestMode: 'inherit', // Its always inherit at this point
+ collectionMode: context.requestItem.request.auth.mode,
+ finalAuth: context.requestItem.request.auth
+ });
+}
+
+function applyGlobalProxy(context: RequestContext) {
+ const proxyStatus = context.collection.brunoConfig.proxy?.enabled ?? 'global';
+ if (proxyStatus === 'global') {
+ context.debug.log('Global proxy config applied', context.preferences.proxy);
+ context.collection.brunoConfig.proxy = context.preferences.proxy;
+ }
+}
+
+export function applyCollectionSettings(context: RequestContext) {
+ applyCollectionHeader(context);
+ applyCollectionAuth(context);
+
+ applyGlobalProxy(context);
+}
diff --git a/packages/bruno-core/src/request/preRequest/createHttpRequest.ts b/packages/bruno-core/src/request/preRequest/createHttpRequest.ts
new file mode 100644
index 0000000000..ed1d5e45ad
--- /dev/null
+++ b/packages/bruno-core/src/request/preRequest/createHttpRequest.ts
@@ -0,0 +1,307 @@
+import { platform, arch } from 'node:os';
+import { BrunoConfig, Preferences, RequestBody, RequestContext, RequestItem } from '../types';
+import { parse, stringify } from 'lossless-json';
+import { URL } from 'node:url';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { Buffer } from 'node:buffer';
+import qs from 'qs';
+import FormData from 'form-data';
+import { Agent } from 'node:http';
+import { ProxyAgent } from 'proxy-agent';
+import { DebugLogger } from '../DebugLogger';
+import { TlsOptions, rootCertificates } from 'node:tls';
+
+function createAuthHeader(requestItem: RequestItem): Record {
+ const auth = requestItem.request.auth;
+
+ switch (auth.mode) {
+ case 'basic':
+ const credentials = Buffer.from(`${auth.basic.username}:${auth.basic.password}`).toString('base64');
+ return {
+ authorization: `Basic ${credentials}`
+ };
+ case 'bearer':
+ return {
+ authorization: `Bearer ${auth.bearer.token}`
+ };
+ default:
+ return {};
+ }
+}
+
+const bodyContentTypeMap: Record = {
+ multipartForm: undefined,
+ formUrlEncoded: 'application/x-www-form-urlencoded',
+ json: 'application/json',
+ graphql: 'application/json',
+ xml: 'text/xml',
+ text: 'text/plain',
+ sparql: 'application/sparql-query',
+ none: undefined
+};
+
+type HeaderMetadata = {
+ brunoVersion: string;
+ isCli: boolean;
+};
+
+export function createDefaultRequestHeader(requestItem: RequestItem, meta: HeaderMetadata): Record {
+ const defaultHeaders: Record = {
+ 'user-agent': `Bruno/${meta.brunoVersion} (${meta.isCli ? 'CLI' : 'Electron'}; Lazer; ${platform()}/${arch()})`,
+ accept: '*/*',
+ ...createAuthHeader(requestItem)
+ };
+ const contentType = bodyContentTypeMap[requestItem.request.body.mode]!;
+ if (contentType) {
+ defaultHeaders['content-type'] = contentType;
+ }
+
+ return defaultHeaders;
+}
+
+function getRequestHeaders(context: RequestContext, extraHeaders: Record): Record {
+ const defaultHeader = createDefaultRequestHeader(context.requestItem, {
+ isCli: false,
+ brunoVersion: '1.14.0'
+ });
+
+ // Go through user header and merge them together with default header
+ const headers = context.requestItem.request.headers.reduce>(
+ (acc, header) => {
+ if (header.enabled) {
+ acc[header.name.toLowerCase()] = header.value;
+ }
+ return acc;
+ },
+ { ...defaultHeader, ...extraHeaders }
+ );
+
+ context.debug.log('Request headers', headers);
+
+ return headers;
+}
+
+async function getRequestBody(context: RequestContext): Promise<[string | Buffer | undefined, Record]> {
+ let bodyData;
+ let extraHeaders: Record = {};
+
+ const body = context.requestItem.request.body;
+ switch (body.mode) {
+ case 'multipartForm':
+ const formData = new FormData();
+ for (const item of body.multipartForm) {
+ if (!item.enabled) {
+ continue;
+ }
+ switch (item.type) {
+ case 'text':
+ formData.append(item.name, item.value);
+ break;
+ case 'file':
+ const fileData = await fs.readFile(item.value[0]!);
+ formData.append(item.name, fileData, path.basename(item.value[0]!));
+ break;
+ }
+ }
+
+ bodyData = formData.getBuffer();
+ extraHeaders = formData.getHeaders();
+ break;
+ case 'formUrlEncoded':
+ const combined = body.formUrlEncoded.reduce>((acc, item) => {
+ if (item.enabled) {
+ if (!acc[item.name]) {
+ acc[item.name] = [];
+ }
+ acc[item.name].push(item.value);
+ }
+ return acc;
+ }, {});
+
+ bodyData = qs.stringify(combined, { arrayFormat: 'repeat' });
+ break;
+ case 'json':
+ if (typeof body.json !== 'string') {
+ bodyData = stringify(body.json) ?? '';
+ break;
+ }
+ bodyData = body.json;
+ break;
+ case 'xml':
+ bodyData = body.xml;
+ break;
+ case 'text':
+ bodyData = body.text;
+ break;
+ case 'sparql':
+ bodyData = body.sparql;
+ break;
+ case 'none':
+ bodyData = undefined;
+ break;
+ case 'graphql':
+ let variables;
+ try {
+ variables = parse(body.graphql.variables || '{}');
+ } catch (e) {
+ throw new Error(
+ `Could not parse GraphQL variables JSON: ${e}\n\n=== Start of preview ===\n${body.graphql.variables}\n=== End of preview ===`
+ );
+ }
+ bodyData = stringify({
+ query: body.graphql.query,
+ variables
+ });
+ break;
+ default:
+ // @ts-expect-error body.mode is `never` here because the case should never happen
+ throw new Error(`No case defined for body mode: "${body.mode}"`);
+ }
+
+ return [bodyData, extraHeaders];
+}
+
+async function createClientCertOptions(
+ certConfig: Exclude,
+ preferences: Preferences,
+ host: string,
+ collectionPath: string
+): Promise {
+ let options: TlsOptions = {};
+
+ for (const { domain, certFilePath, keyFilePath, passphrase } of certConfig.certs) {
+ // Check if the Certificate was created for the current host
+ const hostRegex = '^https:\\/\\/' + domain.replaceAll('.', '\\.').replaceAll('*', '.*');
+ if (!host.match(hostRegex)) {
+ continue;
+ }
+
+ const absoluteCertFilePath = path.isAbsolute(certFilePath) ? certFilePath : path.join(collectionPath, certFilePath);
+ const cert = await fs.readFile(absoluteCertFilePath, { encoding: 'utf8' });
+
+ const absoluteKeyFilePath = path.isAbsolute(keyFilePath) ? keyFilePath : path.join(collectionPath, keyFilePath);
+ const key = await fs.readFile(absoluteKeyFilePath, { encoding: 'utf8' });
+
+ options = { cert, key, passphrase };
+ break;
+ }
+
+ const { customCaCertificate, keepDefaultCaCertificates } = preferences.request;
+ if (customCaCertificate.enabled && customCaCertificate.filePath) {
+ options.ca = await fs.readFile(customCaCertificate.filePath, { encoding: 'utf8' });
+
+ if (keepDefaultCaCertificates.enabled) {
+ options.ca += '\n' + rootCertificates.join('\n');
+ }
+ }
+
+ return options;
+}
+
+const protocolMap: Record['protocol'], string> = {
+ http: 'http:',
+ https: 'https:',
+ socks4: 'socks:',
+ socks5: 'socks:'
+};
+
+function createProxyAgent(
+ proxyConfig: Exclude,
+ host: string,
+ debug: DebugLogger,
+ tlsOptions: TlsOptions,
+ signal?: AbortSignal
+): Agent | null {
+ if (proxyConfig.enabled === false) {
+ return null;
+ }
+
+ const protocol = protocolMap[proxyConfig.protocol];
+ if (!protocol) {
+ throw new Error(
+ `Invalid proxy protocol: "${proxyConfig.protocol}". Expected one of ${Object.keys(protocolMap).join(', ')}`
+ );
+ }
+
+ let auth = '';
+ if (proxyConfig.auth?.enabled) {
+ auth = `${proxyConfig.auth.username}:${proxyConfig.auth.password}`;
+ }
+ let port = '';
+ if (proxyConfig.port) {
+ port = `:${proxyConfig.port}`;
+ }
+ const proxyUrl = `${proxyConfig.protocol}://${auth}@${proxyConfig.hostname}${port}`;
+
+ debug.log('Added ProxyAgent', { proxyUrl });
+ return new ProxyAgent({
+ getProxyForUrl: (url) => {
+ const mustByPass = proxyConfig.bypassProxy?.split(';').some((byPass) => byPass === '*' || byPass === host);
+ if (mustByPass) {
+ debug.log('Proxy bypassed', { url, bypass: proxyConfig.bypassProxy });
+ return '';
+ }
+ debug.log('Request proxied', { url, proxyUrl, bypass: proxyConfig.bypassProxy });
+ return proxyUrl;
+ },
+ signal,
+ ...tlsOptions
+ });
+}
+
+export async function createHttpRequest(context: RequestContext) {
+ // Make sure the URL starts with a protocol
+ if (/^([-+\w]{1,25})(:?\/\/|:)/.test(context.requestItem.request.url) === false) {
+ context.requestItem.request.url = `http://${context.requestItem.request.url}`;
+ }
+
+ let urlObject;
+ try {
+ urlObject = new URL(context.requestItem.request.url);
+ } catch (error) {
+ throw new Error(`Could not parse your URL "${context.requestItem.request.url}": "${error}"`);
+ }
+
+ let certOptions = {};
+ if (context.collection.brunoConfig.clientCertificates) {
+ certOptions = await createClientCertOptions(
+ context.collection.brunoConfig.clientCertificates,
+ context.preferences,
+ urlObject.host,
+ context.collection.pathname
+ );
+ }
+
+ const [body, extraHeaders] = await getRequestBody(context);
+ context.httpRequest = {
+ redirectDepth: 0,
+ body,
+ options: {
+ method: context.requestItem.request.method,
+ protocol: urlObject.protocol,
+ hostname: urlObject.hostname,
+ port: urlObject.port,
+ path: `${urlObject.pathname}${urlObject.search}${urlObject.hash}`,
+ headers: getRequestHeaders(context, extraHeaders),
+ timeout: context.preferences.request.timeout,
+ rejectUnauthorized: context.preferences.request.sslVerification,
+ ...certOptions
+ }
+ };
+
+ if (context.collection.brunoConfig.proxy) {
+ const agent = createProxyAgent(
+ context.collection.brunoConfig.proxy,
+ urlObject.host,
+ context.debug,
+ certOptions,
+ context.abortController?.signal
+ );
+ if (agent) {
+ context.httpRequest.options.agent = agent;
+ }
+ }
+
+ context.callback.folderRequestSent(context);
+}
diff --git a/packages/bruno-core/src/request/preRequest/interpolateRequest.ts b/packages/bruno-core/src/request/preRequest/interpolateRequest.ts
new file mode 100644
index 0000000000..014498af7d
--- /dev/null
+++ b/packages/bruno-core/src/request/preRequest/interpolateRequest.ts
@@ -0,0 +1,172 @@
+import { RequestContext } from '../types';
+import { interpolate } from '@usebruno/common';
+import { parse, stringify } from 'lossless-json';
+import decomment from 'decomment';
+
+// This is wrapper/shorthand for the original `interpolate` function.
+// The `name` parameter is used for debugLogging
+type InterpolationShorthandFunction = (target: string, name: string) => string;
+
+function interpolateBrunoConfigOptions(context: RequestContext, i: InterpolationShorthandFunction) {
+ const brunoConfig = context.collection.brunoConfig;
+
+ if (brunoConfig.clientCertificates?.certs) {
+ for (const cert of brunoConfig.clientCertificates?.certs) {
+ cert.certFilePath = i(cert.certFilePath, 'Certificate CertFilePath');
+ cert.keyFilePath = i(cert.keyFilePath, 'Certificate KeyFilePath');
+ cert.domain = i(cert.domain, 'Certificate domain');
+ cert.passphrase = i(cert.passphrase, 'Certificate passphrase');
+ }
+ }
+
+ if (brunoConfig.proxy) {
+ // @ts-expect-error User need to make sure this is correct. `createHttpRequest` will throw an erro when this is not correct
+ brunoConfig.proxy.protocol = i(brunoConfig.proxy.protocol, 'Proxy protocol');
+ brunoConfig.proxy.hostname = i(brunoConfig.proxy.hostname, 'Proxy hostname');
+ brunoConfig.proxy.port = Number(i(String(brunoConfig.proxy.port), 'Proxy port'));
+ if (brunoConfig.proxy.auth?.enabled) {
+ brunoConfig.proxy.auth.username = i(brunoConfig.proxy.auth.username, 'Proxy username');
+ brunoConfig.proxy.auth.password = (brunoConfig.proxy.auth.password, 'Proxy password');
+ }
+ }
+}
+
+function interpolateRequestItem(context: RequestContext, i: InterpolationShorthandFunction) {
+ const request = context.requestItem.request;
+
+ request.url = i(request.url, 'Request url');
+
+ let urlParsed;
+ try {
+ urlParsed = new URL(context.requestItem.request.url);
+ } catch (error) {
+ throw new Error(`Could not parse your URL "${context.requestItem.request.url}": "${error}"`);
+ }
+ const urlPathname = urlParsed.pathname
+ .split('/')
+ .filter((path) => path !== '')
+ .map((path) => {
+ // Doesn't start with a ":" so its not a path parameter
+ if (path[0] !== ':') {
+ return '/' + path;
+ }
+ const name = path.slice(1);
+ const existingPathParam = request.params.find((param) => param.type === 'path' && param.name === name);
+ return existingPathParam ? '/' + i(existingPathParam.value, `Path param "${existingPathParam.name}"`) : '';
+ })
+ .join('');
+ urlParsed.pathname = urlPathname;
+ request.url = urlParsed.href;
+
+ let pos = 0;
+ for (const header of request.headers) {
+ pos++;
+ header.name = i(header.name, `Header name #${pos}`);
+ header.value = i(header.value, `Header value #${pos}`);
+ }
+}
+
+function interpolateAuth(context: RequestContext, i: InterpolationShorthandFunction) {
+ const auth = context.requestItem.request.auth;
+
+ switch (auth.mode) {
+ case 'none':
+ case 'inherit':
+ break;
+ case 'basic':
+ auth.basic.username = i(auth.basic.username, 'Basic auth username');
+ auth.basic.password = i(auth.basic.password, 'Basic auth password');
+ break;
+ case 'bearer':
+ auth.bearer.token = i(auth.bearer.token, 'Bearer token');
+ break;
+ case 'digest':
+ auth.digest.username = i(auth.digest.username, 'Digest auth usernaem');
+ auth.digest.password = i(auth.digest.password, 'Digest auth password');
+ break;
+ case 'awsv4':
+ auth.awsv4.accessKeyId = i(auth.awsv4.accessKeyId, 'AWS auth AccessKeyId');
+ auth.awsv4.region = i(auth.awsv4.region, 'AWS auth Region');
+ auth.awsv4.profileName = i(auth.awsv4.profileName, 'AWS auth ProfileName');
+ auth.awsv4.service = i(auth.awsv4.service, 'AWS auth Service');
+ auth.awsv4.sessionToken = i(auth.awsv4.sessionToken, 'AWS auth SessionToken');
+ auth.awsv4.secretAccessKey = i(auth.awsv4.secretAccessKey, 'AWS auth SecretAccessKey');
+ break;
+ }
+}
+
+function interpolateBody(context: RequestContext, i: InterpolationShorthandFunction) {
+ const body = context.requestItem.request.body;
+ switch (body.mode) {
+ case 'text':
+ body.text = i(body.text, '');
+ break;
+ case 'json':
+ if (typeof body.json === 'object') {
+ body.json = stringify(body.json)!;
+ }
+ // Always decomment the body. Tolerant flag will ensure no error is thrown when json is invalid
+ body.json = decomment(body.json, { tolerant: true });
+ body.json = i(body.json, 'Json body');
+ try {
+ // @ts-ignore
+ body.json = parse(body.json);
+ } catch {}
+ break;
+ case 'multipartForm': {
+ let pos = 0;
+ for (const item of body.multipartForm) {
+ pos++;
+ if (item.type === 'text') {
+ item.value = i(item.value, `Multipart form value #${pos}`);
+ }
+ item.name = i(item.name, `Multipart form name #${pos}`);
+ }
+ break;
+ }
+ case 'formUrlEncoded': {
+ let pos = 0;
+ for (const item of body.formUrlEncoded) {
+ pos++;
+ item.value = i(item.value, `Form field value #${pos}`);
+ item.name = i(item.name, `Form field name #${pos}`);
+ }
+ break;
+ }
+ case 'xml':
+ body.xml = i(body.xml, 'XML body');
+ break;
+ case 'sparql':
+ body.sparql = i(body.sparql, 'SPARQL body');
+ break;
+ case 'graphql':
+ body.graphql.query = i(body.graphql.query, 'GraphQL query');
+ body.graphql.variables = i(body.graphql.variables, 'GraphQL variables');
+ break;
+ }
+}
+
+export function interpolateRequest(context: RequestContext) {
+ const combinedVars: Record = {
+ ...context.variables.environment,
+ ...context.variables.collection,
+ ...context.variables.process
+ };
+
+ const interpolationResults: Record = {};
+ const interpolateShorthand: InterpolationShorthandFunction = (before, name) => {
+ const after = interpolate(before, combinedVars);
+ // Only log when something has changed
+ if (before !== after) {
+ interpolationResults[name] = { before, after };
+ }
+ return after;
+ };
+
+ interpolateRequestItem(context, interpolateShorthand);
+ interpolateBody(context, interpolateShorthand);
+ interpolateAuth(context, interpolateShorthand);
+ interpolateBrunoConfigOptions(context, interpolateShorthand);
+
+ context.debug.log('Interpolated request', interpolationResults);
+}
diff --git a/packages/bruno-core/src/request/preRequest/preRequestScript.ts b/packages/bruno-core/src/request/preRequest/preRequestScript.ts
new file mode 100644
index 0000000000..86992e14d4
--- /dev/null
+++ b/packages/bruno-core/src/request/preRequest/preRequestScript.ts
@@ -0,0 +1,47 @@
+import { RequestContext } from '../types';
+import { runScript } from '../runtime/script-runner';
+import os from 'node:os';
+
+export async function preRequestScript(context: RequestContext) {
+ const collectionPreRequestScript = context.collection.root?.request?.script?.req ?? '';
+ const requestPreRequestScript = context.requestItem.request.script.req ?? '';
+ const preRequestScript = collectionPreRequestScript + os.EOL + requestPreRequestScript;
+
+ context.debug.log('Pre request script', {
+ collectionPreRequestScript,
+ requestPreRequestScript,
+ preRequestScript
+ });
+ context.timings.startMeasure('preScript');
+
+ let scriptResult;
+ try {
+ scriptResult = await runScript(
+ preRequestScript,
+ context.requestItem,
+ null,
+ null,
+ context.variables,
+ false,
+ context.collection.pathname,
+ context.collection.brunoConfig.scripts,
+ (type: string, payload: any) => context.callback.consoleLog(type, payload)
+ );
+ } catch (error) {
+ context.debug.log('Pre request script error', { error });
+
+ throw error;
+ } finally {
+ context.timings.stopMeasure('preScript');
+ }
+
+ context.callback.updateScriptEnvironment(context, scriptResult.envVariables, scriptResult.collectionVariables);
+
+ context.debug.log('Pre request script error', scriptResult);
+
+ context.nextRequestName = scriptResult.nextRequestName;
+ // The script will use `cleanJson` to remove any weird things before sending to the mainWindow
+ // This destroys the references, so we update variables here manually
+ context.variables.collection = scriptResult.collectionVariables;
+ context.variables.environment = scriptResult.envVariables;
+}
diff --git a/packages/bruno-core/src/request/preRequest/preRequestVars.ts b/packages/bruno-core/src/request/preRequest/preRequestVars.ts
new file mode 100644
index 0000000000..d87688c4d7
--- /dev/null
+++ b/packages/bruno-core/src/request/preRequest/preRequestVars.ts
@@ -0,0 +1,30 @@
+import { RequestContext } from '../types';
+import { VarsRuntime } from '../runtime/vars-runtime';
+
+export function preRequestVars(context: RequestContext) {
+ const preRequestVars = context.requestItem.request.vars.req;
+ // TODO: Always set preRequestVars
+ if (preRequestVars === undefined) {
+ context.debug.log('Pre request variables skipped');
+ return;
+ }
+
+ const before = structuredClone(context.variables.collection);
+
+ const varsRuntime = new VarsRuntime();
+ // This will update context.variables.collection by reference inside the 'Bru' class
+ const varsResult = varsRuntime.runPreRequestVars(
+ preRequestVars,
+ context.requestItem,
+ context.variables.environment,
+ context.variables.collection,
+ context.collection.pathname,
+ context.variables.process
+ );
+
+ if (varsResult) {
+ context.callback.updateScriptEnvironment(context, undefined, varsResult.collectionVariables);
+ }
+
+ context.debug.log('Pre request variables evaluated', { before, after: context.variables.collection });
+}
diff --git a/packages/bruno-core/src/request/runtime/assert-runtime.ts b/packages/bruno-core/src/request/runtime/assert-runtime.ts
new file mode 100644
index 0000000000..5512ee48da
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/assert-runtime.ts
@@ -0,0 +1,363 @@
+import _ from 'lodash';
+import { use, expect } from 'chai';
+import chaiString from 'chai-string';
+import { nanoid } from 'nanoid';
+import { Bru } from './dataObject/Bru';
+import { BrunoRequest } from './dataObject/BrunoRequest';
+import { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } from './utils';
+import { interpolate } from '@usebruno/common';
+import { RequestItem } from '../types';
+
+use(chaiString);
+use(function (chai, utils) {
+ // Custom assertion for checking if a variable is JSON
+ chai.Assertion.addProperty('json', function () {
+ const obj = this._obj;
+ const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && obj.constructor === Object;
+
+ // @ts-expect-error
+ this.assert(isJson, `expected ${utils.inspect(obj)} to be JSON`, `expected ${utils.inspect(obj)} not to be JSON`);
+ });
+});
+
+// Custom assertion for matching regex
+use(function (chai, utils) {
+ chai.Assertion.addMethod('match', function (regex) {
+ const obj = this._obj;
+ let match = false;
+ if (obj === undefined) {
+ match = false;
+ } else {
+ match = regex.test(obj);
+ }
+
+ // @ts-ignore
+ this.assert(
+ match,
+ `expected ${utils.inspect(obj)} to match ${regex}`,
+ `expected ${utils.inspect(obj)} not to match ${regex}`
+ );
+ });
+});
+
+/**
+ * Assertion operators
+ *
+ * eq : equal to
+ * neq : not equal to
+ * gt : greater than
+ * gte : greater than or equal to
+ * lt : less than
+ * lte : less than or equal to
+ * in : in
+ * notIn : not in
+ * contains : contains
+ * notContains : not contains
+ * length : length
+ * matches : matches
+ * notMatches : not matches
+ * startsWith : starts with
+ * endsWith : ends with
+ * between : between
+ * isEmpty : is empty
+ * isNull : is null
+ * isUndefined : is undefined
+ * isDefined : is defined
+ * isTruthy : is truthy
+ * isFalsy : is falsy
+ * isJson : is json
+ * isNumber : is number
+ * isString : is string
+ * isBoolean : is boolean
+ * isArray : is array
+ */
+const parseAssertionOperator = (str = '') => {
+ if (!str || typeof str !== 'string' || !str.length) {
+ return {
+ operator: 'eq',
+ value: str
+ };
+ }
+
+ const operators = [
+ 'eq',
+ 'neq',
+ 'gt',
+ 'gte',
+ 'lt',
+ 'lte',
+ 'in',
+ 'notIn',
+ 'contains',
+ 'notContains',
+ 'length',
+ 'matches',
+ 'notMatches',
+ 'startsWith',
+ 'endsWith',
+ 'between',
+ 'isEmpty',
+ 'isNull',
+ 'isUndefined',
+ 'isDefined',
+ 'isTruthy',
+ 'isFalsy',
+ 'isJson',
+ 'isNumber',
+ 'isString',
+ 'isBoolean',
+ 'isArray'
+ ];
+
+ const unaryOperators = [
+ 'isEmpty',
+ 'isNull',
+ 'isUndefined',
+ 'isDefined',
+ 'isTruthy',
+ 'isFalsy',
+ 'isJson',
+ 'isNumber',
+ 'isString',
+ 'isBoolean',
+ 'isArray'
+ ];
+
+ const [operator, ...rest] = str.trim().split(' ');
+ const value = rest.join(' ');
+
+ if (unaryOperators.includes(operator)) {
+ return {
+ operator,
+ value: ''
+ };
+ }
+
+ if (operators.includes(operator)) {
+ return {
+ operator,
+ value
+ };
+ }
+
+ return {
+ operator: 'eq',
+ value: str
+ };
+};
+
+const isUnaryOperator = (operator: string) => {
+ const unaryOperators = [
+ 'isEmpty',
+ 'isNull',
+ 'isUndefined',
+ 'isDefined',
+ 'isTruthy',
+ 'isFalsy',
+ 'isJson',
+ 'isNumber',
+ 'isString',
+ 'isBoolean',
+ 'isArray'
+ ];
+
+ return unaryOperators.includes(operator);
+};
+
+const evaluateRhsOperand = (rhsOperand: string, operator: string, context: any) => {
+ if (isUnaryOperator(operator)) {
+ return;
+ }
+
+ const combinedVariables = {
+ ...context.bru.collectionVariables,
+ ...context.bru.envVariables,
+ ...context.bru.processEnvVars
+ };
+
+ // gracefully allow both a,b as well as [a, b]
+ if (operator === 'in' || operator === 'notIn') {
+ if (rhsOperand.startsWith('[') && rhsOperand.endsWith(']')) {
+ rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
+ }
+
+ return rhsOperand
+ .split(',')
+ .map((v) => evaluateJsTemplateLiteral(interpolate(v.trim(), combinedVariables), context));
+ }
+
+ if (operator === 'between') {
+ const [lhs, rhs] = rhsOperand
+ .split(',')
+ .map((v) => evaluateJsTemplateLiteral(interpolate(v.trim(), combinedVariables), context));
+ return [lhs, rhs];
+ }
+
+ // gracefully allow both ^[a-Z] as well as /^[a-Z]/
+ if (operator === 'matches' || operator === 'notMatches') {
+ if (rhsOperand.startsWith('/') && rhsOperand.endsWith('/')) {
+ rhsOperand = rhsOperand.substring(1, rhsOperand.length - 1);
+ }
+
+ return interpolate(rhsOperand, combinedVariables);
+ }
+
+ return evaluateJsTemplateLiteral(interpolate(rhsOperand, combinedVariables), context);
+};
+
+export class AssertRuntime {
+ runAssertions(
+ assertions: any[],
+ request: RequestItem,
+ response: any,
+ responseBody: any,
+ envVariables: Record,
+ collectionVariables: Record,
+ collectionPath: string
+ ) {
+ const enabledAssertions = _.filter(assertions, (a) => a.enabled);
+ if (!enabledAssertions.length) {
+ return [];
+ }
+
+ const bru = new Bru(envVariables, collectionVariables, {}, collectionPath, 'the-env');
+ const req = new BrunoRequest(request, true);
+ const res = createResponseParser(response, responseBody);
+
+ const bruContext = {
+ bru,
+ req,
+ res
+ };
+
+ const context = {
+ ...envVariables,
+ ...collectionVariables,
+ ...bruContext
+ };
+
+ const assertionResults = [];
+
+ // parse assertion operators
+ for (const v of enabledAssertions) {
+ const lhsExpr = v.name;
+ const rhsExpr = v.value;
+ const { operator, value: rhsOperand } = parseAssertionOperator(rhsExpr);
+
+ try {
+ const lhs = evaluateJsExpression(lhsExpr, context);
+ const rhs = evaluateRhsOperand(rhsOperand, operator, context);
+
+ switch (operator) {
+ case 'eq':
+ expect(lhs).to.equal(rhs);
+ break;
+ case 'neq':
+ expect(lhs).to.not.equal(rhs);
+ break;
+ case 'gt':
+ expect(lhs).to.be.greaterThan(rhs);
+ break;
+ case 'gte':
+ expect(lhs).to.be.greaterThanOrEqual(rhs);
+ break;
+ case 'lt':
+ expect(lhs).to.be.lessThan(rhs);
+ break;
+ case 'lte':
+ expect(lhs).to.be.lessThanOrEqual(rhs);
+ break;
+ case 'in':
+ expect(lhs).to.be.oneOf(rhs);
+ break;
+ case 'notIn':
+ expect(lhs).to.not.be.oneOf(rhs);
+ break;
+ case 'contains':
+ expect(lhs).to.include(rhs);
+ break;
+ case 'notContains':
+ expect(lhs).to.not.include(rhs);
+ break;
+ case 'length':
+ expect(lhs).to.have.lengthOf(rhs);
+ break;
+ case 'matches':
+ expect(lhs).to.match(new RegExp(rhs));
+ break;
+ case 'notMatches':
+ expect(lhs).to.not.match(new RegExp(rhs));
+ break;
+ case 'startsWith':
+ expect(lhs).to.startWith(rhs);
+ break;
+ case 'endsWith':
+ expect(lhs).to.endWith(rhs);
+ break;
+ case 'between':
+ const [min, max] = rhs;
+ expect(lhs).to.be.within(min, max);
+ break;
+ case 'isEmpty':
+ expect(lhs).to.be.empty;
+ break;
+ case 'isNull':
+ expect(lhs).to.be.null;
+ break;
+ case 'isUndefined':
+ expect(lhs).to.be.undefined;
+ break;
+ case 'isDefined':
+ expect(lhs).to.not.be.undefined;
+ break;
+ case 'isTruthy':
+ expect(lhs).to.be.true;
+ break;
+ case 'isFalsy':
+ expect(lhs).to.be.false;
+ break;
+ case 'isJson':
+ // @ts-expect-error Set above with chai.use
+ expect(lhs).to.be.json;
+ break;
+ case 'isNumber':
+ expect(lhs).to.be.a('number');
+ break;
+ case 'isString':
+ expect(lhs).to.be.a('string');
+ break;
+ case 'isBoolean':
+ expect(lhs).to.be.a('boolean');
+ break;
+ case 'isArray':
+ expect(lhs).to.be.a('array');
+ break;
+ default:
+ expect(lhs).to.equal(rhs);
+ break;
+ }
+
+ assertionResults.push({
+ uid: nanoid(),
+ lhsExpr,
+ rhsExpr,
+ rhsOperand,
+ operator,
+ status: 'pass'
+ });
+ } catch (err) {
+ assertionResults.push({
+ uid: nanoid(),
+ lhsExpr,
+ rhsExpr,
+ rhsOperand,
+ operator,
+ status: 'fail',
+ error: err instanceof Error ? err.message : String(err)
+ });
+ }
+ }
+
+ return assertionResults;
+ }
+}
diff --git a/packages/bruno-core/src/request/runtime/dataObject/Bru.ts b/packages/bruno-core/src/request/runtime/dataObject/Bru.ts
new file mode 100644
index 0000000000..edb6dafd05
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/dataObject/Bru.ts
@@ -0,0 +1,81 @@
+import { interpolate } from '@usebruno/common';
+
+const variableNameRegex = /^[\w-.]*$/;
+
+export class Bru {
+ _nextRequest?: string;
+
+ constructor(
+ public envVariables: any,
+ public collectionVariables: any,
+ public processEnvVars: any,
+ private collectionPath: string,
+ private environmentName: string
+ ) {}
+
+ cwd() {
+ return this.collectionPath;
+ }
+
+ getEnvName() {
+ return this.environmentName;
+ }
+
+ getProcessEnv(key: string): unknown {
+ return this.processEnvVars[key];
+ }
+
+ hasEnvVar(key: string) {
+ return Object.hasOwn(this.envVariables, key);
+ }
+
+ getEnvVar(key: string) {
+ return interpolate(this.envVariables[key], this.processEnvVars);
+ }
+
+ setEnvVar(key: string, value: unknown) {
+ if (!key) {
+ throw new Error('Creating a env variable without specifying a name is not allowed.');
+ }
+
+ this.envVariables[key] = value;
+ }
+
+ hasVar(key: string) {
+ return Object.hasOwn(this.collectionVariables, key);
+ }
+
+ setVar(key: string, value: unknown) {
+ if (!key) {
+ throw new Error('Creating a variable without specifying a name is not allowed.');
+ }
+
+ if (!variableNameRegex.test(key)) {
+ throw new Error(
+ `Variable name: "${key}" contains invalid characters!` +
+ ' Names must only contain alpha-numeric characters, "-", "_", "."'
+ );
+ }
+
+ this.collectionVariables[key] = value;
+ }
+
+ deleteVar(key: string) {
+ delete this.collectionVariables[key];
+ }
+
+ getVar(key: string): unknown {
+ if (!variableNameRegex.test(key)) {
+ throw new Error(
+ `Variable name: "${key}" contains invalid characters!` +
+ ' Names must only contain alpha-numeric characters, "-", "_", "."'
+ );
+ }
+
+ return this.collectionVariables[key];
+ }
+
+ setNextRequest(nextRequest: string) {
+ this._nextRequest = nextRequest;
+ }
+}
diff --git a/packages/bruno-core/src/request/runtime/dataObject/BrunoRequest.ts b/packages/bruno-core/src/request/runtime/dataObject/BrunoRequest.ts
new file mode 100644
index 0000000000..2ee2bcfac2
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/dataObject/BrunoRequest.ts
@@ -0,0 +1,151 @@
+import { parse } from 'lossless-json';
+import { RequestItem } from '../../types';
+import decomment from 'decomment';
+
+export class BrunoRequest {
+ constructor(private _req: RequestItem, private readonly: boolean) {}
+
+ get url() {
+ return this.getUrl();
+ }
+ getUrl() {
+ return this._req.request.url;
+ }
+ setUrl(url: string) {
+ if (this.readonly) {
+ throw new Error('Cannot update "url" request is readonly');
+ }
+ this._req.request.url = url;
+ }
+
+ get authMode() {
+ return this.getAuthMode();
+ }
+ getAuthMode() {
+ return this._req.request.auth.mode;
+ }
+
+ get method() {
+ return this.getMethod();
+ }
+ getMethod() {
+ return this._req.request.method;
+ }
+ setMethod(method: string) {
+ if (this.readonly) {
+ throw new Error('Cannot update "method" request is readonly');
+ }
+ this._req.request.method = method;
+ }
+
+ get headers() {
+ return this.getHeaders();
+ }
+ getHeaders() {
+ return this._req.request.headers;
+ }
+ setHeaders(headers: Record) {
+ if (this.readonly) {
+ throw new Error('Cannot update "headers" request is readonly');
+ }
+ this._req.request.headers = Object.entries(headers).map(([name, value]) => {
+ return {
+ name,
+ value,
+ enabled: true
+ };
+ });
+ }
+
+ getHeader(name: string): string | null {
+ const header = this._req.request.headers.find((header) => header.name.toLowerCase() === name.toLowerCase());
+ return header?.value ?? null;
+ }
+ setHeader(name: string, value: string) {
+ if (this.readonly) {
+ throw new Error('Cannot update "header" request is readonly');
+ }
+ const newHeader = {
+ name,
+ value,
+ enabled: false
+ };
+
+ const index = this._req.request.headers.findIndex((header) => header.name.toLowerCase() === name.toLowerCase());
+ if (index === -1) {
+ this._req.request.headers.push(newHeader);
+ return;
+ }
+ this._req.request.headers[index] = newHeader;
+ }
+
+ get body() {
+ return this.getBody();
+ }
+ getBody() {
+ switch (this._req.request.body.mode) {
+ case 'text':
+ return this._req.request.body.text;
+ case 'sparql':
+ return this._req.request.body.sparql;
+ case 'multipartForm':
+ return this._req.request.body.multipartForm;
+ case 'xml':
+ return this._req.request.body.xml;
+ case 'formUrlEncoded':
+ return this._req.request.body.formUrlEncoded;
+ case 'json':
+ if (typeof this._req.request.body.json === 'string') {
+ try {
+ return parse(decomment(this._req.request.body.json, { tolerant: true }));
+ } catch {}
+ }
+ return this._req.request.body.json;
+ }
+ }
+ setBody(data: any) {
+ if (this.readonly) {
+ throw new Error('Cannot update "body" request is readonly');
+ }
+ switch (this._req.request.body.mode) {
+ case 'text':
+ this._req.request.body.text = data;
+ break;
+ case 'sparql':
+ this._req.request.body.sparql = data;
+ break;
+ case 'multipartForm':
+ this._req.request.body.multipartForm = data;
+ break;
+ case 'xml':
+ this._req.request.body.xml = data;
+ break;
+ case 'formUrlEncoded':
+ this._req.request.body.formUrlEncoded = data;
+ break;
+ case 'json':
+ this._req.request.body.json = data;
+ break;
+ }
+ }
+
+ setMaxRedirects(maxRedirects: number) {
+ if (this.readonly) {
+ throw new Error('Cannot update "maxRedirects" request is readonly');
+ }
+ this._req.request.maxRedirects = maxRedirects;
+ }
+
+ get timeout() {
+ return this.getTimeout();
+ }
+ getTimeout(): number {
+ return this._req.request.timeout;
+ }
+ setTimeout(timeout: number) {
+ if (this.readonly) {
+ throw new Error('Cannot update "timeout" request is readonly');
+ }
+ this._req.request.timeout = timeout;
+ }
+}
diff --git a/packages/bruno-core/src/request/runtime/dataObject/BrunoResponse.ts b/packages/bruno-core/src/request/runtime/dataObject/BrunoResponse.ts
new file mode 100644
index 0000000000..fd27b08106
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/dataObject/BrunoResponse.ts
@@ -0,0 +1,36 @@
+import { Response } from '../../types';
+
+export class BrunoResponse {
+ constructor(private _res: Response, public body: any) {}
+
+ get status() {
+ return this.getStatus();
+ }
+ getStatus(): number {
+ return this._res.statusCode;
+ }
+
+ getHeader(name: string): string | undefined {
+ const header = this._res.headers[name];
+
+ return Array.isArray(header) ? header[0] : header;
+ }
+
+ get headers() {
+ return this.getHeaders();
+ }
+ getHeaders() {
+ return this._res.headers;
+ }
+
+ getBody() {
+ return this.body;
+ }
+
+ get responseTime() {
+ return this.getResponseTime();
+ }
+ getResponseTime() {
+ return this._res.responseTime;
+ }
+}
diff --git a/packages/bruno-core/src/request/runtime/dataObject/Test.ts b/packages/bruno-core/src/request/runtime/dataObject/Test.ts
new file mode 100644
index 0000000000..da9aa2828b
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/dataObject/Test.ts
@@ -0,0 +1,30 @@
+import globalChai from 'chai';
+
+export const Test =
+ (brunoTestResults: any, chai: typeof globalChai) =>
+ async (description: string, callback: () => Promise | void) => {
+ try {
+ await callback();
+ brunoTestResults.addResult({ description, status: 'pass' });
+ } catch (error) {
+ if (error instanceof chai.AssertionError) {
+ // @ts-expect-error Should work but actual & expected are not in the type
+ const { message, actual, expected } = error;
+ brunoTestResults.addResult({
+ description,
+ status: 'fail',
+ error: message,
+ actual,
+ expected
+ });
+ } else {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ brunoTestResults.addResult({
+ description,
+ status: 'fail',
+ error: errorMessage
+ });
+ }
+ console.log(`Error in your test: "${description}":`, error);
+ }
+ };
diff --git a/packages/bruno-core/src/request/runtime/dataObject/TestResults.ts b/packages/bruno-core/src/request/runtime/dataObject/TestResults.ts
new file mode 100644
index 0000000000..666837821b
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/dataObject/TestResults.ts
@@ -0,0 +1,25 @@
+import { nanoid } from 'nanoid';
+
+export type TestResult = {
+ uid: string;
+ description: string;
+ status: 'fail' | 'pass';
+ error?: string;
+ actual?: any;
+ expected?: any;
+};
+
+export class TestResults {
+ private results: TestResult[] = [];
+
+ addResult(result: Omit) {
+ this.results.push({
+ ...result,
+ uid: nanoid()
+ });
+ }
+
+ getResults() {
+ return this.results;
+ }
+}
diff --git a/packages/bruno-core/src/request/runtime/dataObject/UserScriptError.ts b/packages/bruno-core/src/request/runtime/dataObject/UserScriptError.ts
new file mode 100644
index 0000000000..1156e5914c
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/dataObject/UserScriptError.ts
@@ -0,0 +1,19 @@
+export class UserScriptError extends Error {
+ constructor(originalError: unknown, script: string) {
+ const formattedError = originalError instanceof Error ? originalError.stack : String(originalError);
+
+ const fullMessage = `
+UserScriptError: This error occurred inside your script!
+
+=== Begin of orignal error ===
+${formattedError}
+=== End of orignal error ===
+
+=== Begin of user script ===
+${script.trim()}
+=== End of user script ===
+ `.trim();
+
+ super(fullMessage);
+ }
+}
diff --git a/packages/bruno-core/src/request/runtime/script-runner.ts b/packages/bruno-core/src/request/runtime/script-runner.ts
new file mode 100644
index 0000000000..f581fdf760
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/script-runner.ts
@@ -0,0 +1,219 @@
+import vm from 'node:vm';
+import { Bru } from './dataObject/Bru';
+import { BrunoRequest } from './dataObject/BrunoRequest';
+import lodash from 'lodash';
+import path from 'node:path';
+import { cleanJson } from './utils';
+import chai from 'chai';
+import { BrunoResponse } from './dataObject/BrunoResponse';
+import { TestResults } from './dataObject/TestResults';
+import { Test } from './dataObject/Test';
+import { BrunoConfig, RequestContext, RequestItem, Response } from '../types';
+import { UserScriptError } from './dataObject/UserScriptError';
+
+// Save the original require inside an "alias" variable so the "vite-plugin-commonjs" does not complain about the
+// intentional dynamic require
+const dynamicRequire = require;
+
+export async function runScript(
+ script: string,
+ request: RequestItem,
+ response: Response | null,
+ responseBody: Response | null,
+ variables: RequestContext['variables'],
+ useTests: boolean,
+ collectionPath: string,
+ scriptingConfig: BrunoConfig['scripts'],
+ onConsoleLog?: (type: string, payload: any) => void
+) {
+ const scriptContext = buildScriptContext(
+ request,
+ response,
+ responseBody,
+ variables,
+ useTests,
+ collectionPath,
+ scriptingConfig,
+ onConsoleLog
+ );
+
+ if (script.trim().length !== 0) {
+ try {
+ await vm.runInThisContext(`
+ (async ({ require, console, req, res, bru, expect, assert, test }) => {
+ ${script}
+ });
+ `)(scriptContext);
+ } catch (error) {
+ throw new UserScriptError(error, script);
+ }
+ }
+
+ return {
+ envVariables: cleanJson(scriptContext.bru.envVariables),
+ collectionVariables: cleanJson(scriptContext.bru.collectionVariables),
+ nextRequestName: scriptContext.bru._nextRequest,
+ results: scriptContext.brunoTestResults ? cleanJson(scriptContext.brunoTestResults.getResults()) : null
+ };
+}
+
+function buildScriptContext(
+ request: RequestItem,
+ response: Response | null,
+ responseBody: any | null,
+ variables: RequestContext['variables'],
+ useTests: boolean,
+ collectionPath: string,
+ scriptingConfig: BrunoConfig['scripts'],
+ onConsoleLog?: (type: string, payload: any) => void
+) {
+ const context: {
+ require: (module: string) => unknown;
+ console: ReturnType;
+ req: BrunoRequest;
+ res: BrunoResponse | null;
+ bru: Bru;
+ expect: typeof chai.expect | null;
+ assert: typeof chai.assert | null;
+ brunoTestResults: TestResults | null;
+ test: ReturnType | null;
+ } = {
+ require: createCustomRequire(scriptingConfig, collectionPath),
+ console: createCustomConsole(onConsoleLog),
+ req: new BrunoRequest(request, response !== null),
+ res: null,
+ bru: new Bru(variables.environment, variables.collection, variables.process, collectionPath, ''),
+ expect: null,
+ assert: null,
+ brunoTestResults: null,
+ test: null
+ };
+
+ if (response) {
+ context.res = new BrunoResponse(response, responseBody);
+ }
+
+ if (useTests) {
+ Object.assign(context, createTestContext());
+ }
+
+ return context;
+}
+
+const defaultModuleWhiteList = [
+ // Node libs
+ 'path',
+ 'stream',
+ 'util',
+ 'url',
+ 'http',
+ 'https',
+ 'punycode',
+ 'zlib',
+ // Pre-installed 3rd libs
+ 'ajv',
+ 'ajv-formats',
+ 'atob',
+ 'btoa',
+ 'lodash',
+ 'moment',
+ 'uuid',
+ 'nanoid',
+ 'axios',
+ 'chai',
+ 'crypto-js',
+ 'node-vault'
+];
+
+function createCustomRequire(scriptingConfig: BrunoConfig['scripts'], collectionPath: string) {
+ const customWhitelistedModules = lodash.get(scriptingConfig, 'moduleWhitelist', []);
+
+ const whitelistedModules = [...defaultModuleWhiteList, ...customWhitelistedModules];
+
+ const allowScriptFilesystemAccess = lodash.get(scriptingConfig, 'filesystemAccess.allow', false);
+ if (allowScriptFilesystemAccess) {
+ // TODO: Allow other modules like os, child_process etc too?
+ whitelistedModules.push('fs');
+ }
+
+ const additionalContextRoots = lodash.get(scriptingConfig, 'additionalContextRoots', []);
+ const additionalContextRootsAbsolute = lodash
+ .chain(additionalContextRoots)
+ .map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr)))
+ .value();
+ additionalContextRootsAbsolute.push(collectionPath);
+
+ return (moduleName: string) => {
+ // First check If we want to require a native node module or
+ // Remove the "node:" prefix, to make sure "node:fs" and "fs" can be required, and we only need to whitelist one
+ if (whitelistedModules.includes(moduleName.replace(/^node:/, ''))) {
+ try {
+ return dynamicRequire(moduleName);
+ } catch {
+ // This can happen, if it s module installed by the user under additionalContextRoots
+ // So now we check if the user installed it themselves
+ let modulePath;
+ try {
+ modulePath = require.resolve(moduleName, { paths: additionalContextRootsAbsolute });
+ return dynamicRequire(modulePath);
+ } catch (error) {
+ throw new Error(`Could not resolve module "${moduleName}": ${error}
+ This most likely means you did not install the module under "additionalContextRoots" using a package manger like npm.
+
+ These are your current "additionalContextRoots":
+ - ${additionalContextRootsAbsolute.join('- ') || 'No "additionalContextRoots" defined'}
+ `);
+ }
+ }
+ }
+
+ const triedPaths = [];
+ for (const contextRoot of additionalContextRootsAbsolute) {
+ const fullScriptPath = path.join(contextRoot, moduleName);
+ try {
+ return dynamicRequire(fullScriptPath);
+ } catch (error) {
+ triedPaths.push({ fullScriptPath, error });
+ }
+ }
+
+ const triedPathsFormatted = triedPaths.map((i) => `- "${i.fullScriptPath}": ${i.error}\n`);
+ throw new Error(`Failed to require "${moduleName}"!
+
+If you tried to require a internal node module / external package, make sure its whitelisted in the "bruno.json" under "scriptConfig".
+If you wanted to require an external script make sure the path is correct or added to "additionalContextRoots" in your "bruno.json".
+
+${
+ triedPathsFormatted.length === 0
+ ? 'No additional context roots where defined'
+ : 'We searched the following paths for your script:'
+}
+${triedPathsFormatted}`);
+ };
+}
+
+function createCustomConsole(onConsoleLog?: (type: string, payload: any) => void) {
+ const customLogger = (type: string) => {
+ return (...args: any[]) => {
+ onConsoleLog && onConsoleLog(type, cleanJson(args));
+ };
+ };
+ return {
+ log: customLogger('log'),
+ info: customLogger('info'),
+ warn: customLogger('warn'),
+ error: customLogger('error')
+ };
+}
+
+function createTestContext() {
+ const brunoTestResults = new TestResults();
+ const test = Test(brunoTestResults, chai);
+
+ return {
+ test,
+ brunoTestResults,
+ expect: chai.expect,
+ assert: chai.assert
+ };
+}
diff --git a/packages/bruno-core/src/request/runtime/utils.ts b/packages/bruno-core/src/request/runtime/utils.ts
new file mode 100644
index 0000000000..229a246094
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/utils.ts
@@ -0,0 +1,170 @@
+import { get } from '@usebruno/query';
+import { stringify, parse, LosslessNumber } from 'lossless-json';
+import jsonQuery from 'json-query';
+import { Response } from '../types';
+import { readFile } from 'node:fs/promises';
+
+const JS_KEYWORDS = `
+ break case catch class const continue debugger default delete do
+ else export extends false finally for function if import in instanceof
+ new null return super switch this throw true try typeof var void while with
+ undefined let static yield arguments of
+`
+ .split(/\s+/)
+ .filter((word) => word.length > 0);
+
+/**
+ * Creates a function from a JavaScript expression
+ *
+ * When the function is called, the variables used in this expression are picked up from the context
+ *
+ * ```js
+ * res.data.pets.map(pet => pet.name.toUpperCase())
+ *
+ * function(context) {
+ * const { res, pet } = context;
+ * return res.data.pets.map(pet => pet.name.toUpperCase())
+ * }
+ * ```
+ */
+export const compileJsExpression = (expr: string) => {
+ // get all dotted identifiers (foo, bar.baz, .baz)
+ const matches = expr.match(/([\w\.$]+)/g) ?? [];
+
+ // get valid js identifiers (foo, bar)
+ const vars = new Set(
+ matches
+ .filter((match) => /^[a-zA-Z$_]/.test(match)) // starts with valid js identifier (foo.bar)
+ .map((match) => match.split('.')[0]) // top level identifier (foo)
+ .filter((name) => !JS_KEYWORDS.includes(name)) // exclude js keywords
+ );
+
+ // globals such as Math
+ const globals = [...vars].filter((name) => name in globalThis);
+
+ const code = {
+ vars: [...vars].join(', '),
+ // pick global from context or globalThis
+ globals: globals.map((name) => ` ${name} = ${name} ?? globalThis.${name};`).join('')
+ };
+
+ const body = `let { ${code.vars} } = context; ${code.globals}; return ${expr}`;
+
+ return new Function('context', body);
+};
+
+const internalExpressionCache = new Map();
+
+export const evaluateJsExpression = (expression: string, context: any) => {
+ let fn = internalExpressionCache.get(expression);
+ if (fn == null) {
+ internalExpressionCache.set(expression, (fn = compileJsExpression(expression)));
+ }
+ return fn(context);
+};
+
+export const evaluateJsTemplateLiteral = (templateLiteral: string, context: any) => {
+ if (!templateLiteral || !templateLiteral.length || typeof templateLiteral !== 'string') {
+ return templateLiteral;
+ }
+
+ templateLiteral = templateLiteral.trim();
+
+ if (templateLiteral === 'true') {
+ return true;
+ }
+
+ if (templateLiteral === 'false') {
+ return false;
+ }
+
+ if (templateLiteral === 'null') {
+ return null;
+ }
+
+ if (templateLiteral === 'undefined') {
+ return undefined;
+ }
+
+ if (templateLiteral.startsWith('"') && templateLiteral.endsWith('"')) {
+ return templateLiteral.slice(1, -1);
+ }
+
+ if (templateLiteral.startsWith("'") && templateLiteral.endsWith("'")) {
+ return templateLiteral.slice(1, -1);
+ }
+
+ if (!isNaN(Number(templateLiteral))) {
+ const number = Number(templateLiteral);
+ // Check if the number is too high. Too high number might get altered, see #1000
+ if (number > Number.MAX_SAFE_INTEGER) {
+ return templateLiteral;
+ }
+ return number;
+ }
+
+ templateLiteral = '`' + templateLiteral + '`';
+
+ return evaluateJsExpression(templateLiteral, context);
+};
+
+type ResponseParser = ((expr: string, ...fns: any) => any) & {
+ body: any;
+ status: number;
+ headers: Record;
+ responseTime: number;
+ jq: (expr: string) => unknown;
+};
+
+export const createResponseParser = (response: Response, responseBody: any) => {
+ const res: ResponseParser = (expr: string, ...fns: any[]) => {
+ return get(responseBody, expr, ...fns);
+ };
+
+ res.body = responseBody;
+ res.status = response.statusCode;
+ res.headers = response.headers;
+ res.responseTime = response.responseTime;
+
+ res.jq = (expr: string) => {
+ const output = jsonQuery(expr, { data: res.body });
+ return output ? output.value : null;
+ };
+
+ return res;
+};
+
+/**
+ * Objects that are created inside vm execution context result in an serialization error when sent to the renderer process
+ * Error sending from webFrameMain: Error: Failed to serialize arguments
+ * at s.send (node:electron/js2c/browser_init:169:631)
+ * at g.send (node:electron/js2c/browser_init:165:2156)
+ * How to reproduce
+ * Remove the cleanJson fix and execute the below post response script
+ * bru.setVar("a", {b:3});
+ */
+export const cleanJson = (data: any) => {
+ try {
+ return parse(stringify(data)!, null, (value) => {
+ // By default, this will return the LosslessNumber object, but because it's passed into ipc we
+ // need to convert it into a number because LosslessNumber is converted into a weird object
+ return new LosslessNumber(value).valueOf();
+ });
+ } catch (e) {
+ return data;
+ }
+};
+
+// Read the in a seperate worker thread, so it does not block the main thread during json parse
+export async function readResponseBodyAsync(path: string): Promise {
+ // TODO: Move this into a seperate thread
+ let bodyData: any = await readFile(path, { encoding: 'utf8' });
+ try {
+ bodyData = parse(bodyData, null, (value) => {
+ // Convert the Lossless number into whatever fits best
+ return new LosslessNumber(value).valueOf();
+ });
+ } catch {}
+
+ return bodyData;
+}
diff --git a/packages/bruno-core/src/request/runtime/vars-runtime.ts b/packages/bruno-core/src/request/runtime/vars-runtime.ts
new file mode 100644
index 0000000000..7c8a5f6515
--- /dev/null
+++ b/packages/bruno-core/src/request/runtime/vars-runtime.ts
@@ -0,0 +1,78 @@
+import _ from 'lodash';
+import { Bru } from './dataObject/Bru';
+import { BrunoRequest } from './dataObject/BrunoRequest';
+import { evaluateJsTemplateLiteral, evaluateJsExpression, createResponseParser } from './utils';
+import { RequestItem, RequestVariable, Response } from '../types';
+
+export class VarsRuntime {
+ runPreRequestVars(
+ vars: RequestVariable[],
+ request: RequestItem,
+ envVariables: Record,
+ collectionVariables: Record,
+ collectionPath: string,
+ processEnvVars: Record
+ ) {
+ const enabledVars = _.filter(vars, (v) => v.enabled);
+ if (!enabledVars.length) {
+ return;
+ }
+
+ const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath, 'the-env');
+ const req = new BrunoRequest(request, true);
+
+ const combinedVariables = {
+ ...envVariables,
+ ...collectionVariables,
+ bru,
+ req
+ };
+
+ _.each(enabledVars, (v) => {
+ const value = evaluateJsTemplateLiteral(v.value, combinedVariables);
+ bru.setVar(v.name, value);
+ });
+
+ return {
+ collectionVariables
+ };
+ }
+
+ runPostResponseVars(
+ vars: RequestVariable[],
+ request: RequestItem,
+ response: Response,
+ responseBody: any,
+ envVariables: Record,
+ collectionVariables: Record,
+ collectionPath: string,
+ processEnvVars: Record
+ ) {
+ const enabledVars = _.filter(vars, (v) => v.enabled);
+ if (!enabledVars.length) {
+ return;
+ }
+
+ const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath, 'the-env');
+ const req = new BrunoRequest(request, true);
+ const res = createResponseParser(response, responseBody);
+
+ const context = {
+ ...envVariables,
+ ...collectionVariables,
+ ...processEnvVars,
+ bru,
+ req,
+ res
+ };
+
+ _.each(enabledVars, (v) => {
+ const value = evaluateJsExpression(v.value, context);
+ bru.setVar(v.name, value);
+ });
+
+ return {
+ collectionVariables
+ };
+ }
+}
diff --git a/packages/bruno-core/src/request/types.ts b/packages/bruno-core/src/request/types.ts
new file mode 100644
index 0000000000..3383a8886c
--- /dev/null
+++ b/packages/bruno-core/src/request/types.ts
@@ -0,0 +1,371 @@
+import { Timings } from './Timings';
+import { DebugLogger } from './DebugLogger';
+import { Timeline } from './Timeline';
+import { Callbacks } from './Callbacks';
+import { RequestOptions } from 'node:http';
+import { TlsOptions } from 'node:tls';
+import { CookieJar } from 'tough-cookie';
+
+export type RequestType = 'http-request' | 'graphql-request';
+
+export type RequestVariable = {
+ name: string;
+ value: string;
+ enabled: boolean;
+};
+
+export type AuthMode =
+ | {
+ mode: 'none';
+ }
+ | {
+ mode: 'inherit';
+ }
+ | {
+ mode: 'basic';
+ basic: {
+ username: string;
+ password: string;
+ };
+ }
+ | {
+ mode: 'bearer';
+ bearer: {
+ token: string;
+ };
+ }
+ | {
+ mode: 'digest';
+ digest: {
+ username: string;
+ password: string;
+ };
+ }
+ | {
+ mode: 'awsv4';
+ awsv4: {
+ accessKeyId: string;
+ secretAccessKey: string;
+ sessionToken: string;
+ service: string;
+ region: string;
+ profileName: string;
+ };
+ }
+ | {
+ mode: 'oauth2';
+ grantType: 'authorization_code';
+ callbackUrl: string;
+ authorizationUrl: string;
+ accessTokenUrl: string;
+ clientId: string;
+ clientSecret: string;
+ scope: string;
+ pkce: boolean;
+ }
+ | {
+ mode: 'oauth2';
+ grantType: 'client_credentials';
+ accessTokenUrl: string;
+ clientId: string;
+ clientSecret: string;
+ scope: string;
+ }
+ | {
+ mode: 'oauth2';
+ grantType: 'password';
+ accessTokenUrl: string;
+ username: string;
+ password: string;
+ scope: string;
+ };
+
+export type RequestBody =
+ | {
+ mode: 'none';
+ }
+ | {
+ mode: 'json';
+ json: string | Record;
+ }
+ | {
+ mode: 'text';
+ text: string;
+ }
+ | {
+ mode: 'multipartForm';
+ multipartForm: (
+ | {
+ name: string;
+ value: string;
+ enabled: boolean;
+ type: 'text';
+ uid: string;
+ }
+ | {
+ name: string;
+ value: string[];
+ enabled: boolean;
+ type: 'file';
+ description: string;
+ uid: string;
+ }
+ )[];
+ }
+ | {
+ mode: 'formUrlEncoded';
+ formUrlEncoded: {
+ name: string;
+ value: string;
+ enabled: boolean;
+ uid: string;
+ }[];
+ }
+ | {
+ mode: 'xml';
+ xml: string;
+ }
+ | {
+ mode: 'sparql';
+ sparql: string;
+ }
+ | {
+ mode: 'graphql';
+ graphql: {
+ query: string;
+ variables: string;
+ };
+ };
+
+// This is the request Item from the App/.bru file
+export type RequestItem = {
+ uid: string;
+ name: string;
+ type: RequestType;
+ seq: number;
+ request: {
+ method: string;
+ url: string;
+ params: {
+ name: string;
+ value: string;
+ enabled: boolean;
+ type: 'path' | 'query';
+ }[];
+ headers: {
+ name: string;
+ value: string;
+ enabled: boolean;
+ }[];
+ auth: AuthMode;
+ body: RequestBody;
+ script: {
+ req?: string;
+ res?: string;
+ };
+ vars: {
+ req?: RequestVariable[];
+ res?: RequestVariable[];
+ };
+ assertions: {
+ enabled: boolean;
+ name: string;
+ value: string;
+ }[];
+ tests: string;
+ docs: string;
+ maxRedirects: number;
+ timeout: number;
+ };
+ // e.g `my-requests.bru`
+ filename: string;
+ // e.g `/path/to/collection/and/my-requests.bru`
+ pathname: string;
+ draft: null | RequestItem;
+ depth: number;
+};
+
+export type Response = {
+ // Last/Final response headers
+ headers: Record;
+ statusCode: number;
+ responseTime: number;
+ // Absolute path to response file
+ path: string;
+ size: number;
+};
+
+export type FolderItem = {
+ uid: string;
+ name: string;
+ // Absolute path to folder
+ pathname: string;
+ collapsed: boolean;
+ type: 'folder';
+ items: (RequestItem | FolderItem)[];
+ depth: number;
+};
+
+export type CollectionVariables = Record;
+
+export type EnvironmentVariable = {
+ name: string;
+ uid: string;
+ value: unknown;
+ enabled: boolean;
+ secret: boolean;
+ // TODO: Are there more types
+ type: 'text';
+};
+
+export type CollectionEnvironment = {
+ name: string;
+ uid: string;
+ variables: EnvironmentVariable[];
+};
+
+export type Collection = {
+ // e.g. '1'
+ version: string;
+ uid: string;
+ name: string;
+ // Full path to collection folder
+ pathname: string;
+ items: (RequestItem | FolderItem)[];
+ collectionVariables: CollectionVariables;
+ // Config json
+ brunoConfig: BrunoConfig;
+ settingsSelectedTab: string;
+ // Unix timestamp in milliseconds
+ importedAt: number;
+ // TODO: Check what this does
+ lastAction: null | any;
+ collapsed: boolean;
+ environments: CollectionEnvironment[];
+ root?: {
+ request?: {
+ auth?: AuthMode;
+ headers: {
+ name: string;
+ value: string;
+ enabled: boolean;
+ }[];
+ script?: {
+ req?: string;
+ res?: string;
+ };
+ tests?: string;
+ };
+ docs: string;
+ };
+};
+
+// This should always be equal to `preferences.js` in bruno-electron
+export type Preferences = {
+ request: {
+ sslVerification: boolean;
+ customCaCertificate: {
+ enabled: boolean;
+ filePath: string | null;
+ };
+ keepDefaultCaCertificates: {
+ enabled: boolean;
+ };
+ storeCookies: boolean;
+ sendCookies: boolean;
+ timeout: number;
+ };
+ font: {
+ codeFont: string | null;
+ };
+ proxy: {
+ enabled: boolean;
+ protocol: 'http' | 'https' | 'socks4' | 'socks5';
+ hostname: string;
+ port: number | null;
+ auth?: {
+ enabled: boolean;
+ username: string;
+ password: string;
+ };
+ bypassProxy?: string;
+ };
+};
+
+export type BrunoConfig = {
+ version: '1';
+ name: string;
+ type: 'collection';
+ ignore: string[];
+ proxy?: {
+ enabled: 'global' | true | false;
+ protocol: 'https' | 'http' | 'socks4' | 'socks5';
+ hostname: string;
+ port: number | null;
+ auth?: {
+ enabled: boolean;
+ username: string;
+ password: string;
+ };
+ bypassProxy?: string;
+ };
+ clientCertificates?: {
+ certs: {
+ domain: string;
+ certFilePath: string;
+ keyFilePath: string;
+ passphrase: string;
+ }[];
+ };
+ scripts: {
+ additionalContextRoots: string[];
+ moduleWhitelist: string[];
+ filesystemAccess: {
+ allow: boolean;
+ };
+ };
+};
+
+// Method, protocol, hostname and path are always set
+export type BrunoRequestOptions = Omit & {
+ method: string;
+ protocol: string;
+ hostname: string;
+ path: string;
+} & TlsOptions;
+
+export type RequestContext = {
+ uid: string;
+ dataDir: string;
+ nextRequestName?: string;
+ abortController?: AbortController;
+ cancelToken: string;
+
+ requestItem: RequestItem;
+ collection: Collection;
+ preferences: Preferences;
+ cookieJar: CookieJar;
+ variables: {
+ collection: Record;
+ environment: Record;
+ process: {
+ process: {
+ env: Record;
+ };
+ };
+ };
+
+ callback: Callbacks;
+ timings: Timings;
+ debug: DebugLogger;
+ timeline?: Timeline;
+
+ httpRequest?: {
+ options: BrunoRequestOptions;
+ body?: string | Buffer;
+ redirectDepth: number;
+ };
+
+ response?: Response;
+ error?: Error;
+};
diff --git a/packages/bruno-core/tsconfig.json b/packages/bruno-core/tsconfig.json
new file mode 100644
index 0000000000..17908a67de
--- /dev/null
+++ b/packages/bruno-core/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "module": "CommonJS"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/bruno-electron/.gitignore b/packages/bruno-electron/.gitignore
index 9faefe7828..5d0a6de0e1 100644
--- a/packages/bruno-electron/.gitignore
+++ b/packages/bruno-electron/.gitignore
@@ -2,6 +2,7 @@ node_modules
web
out
dist
+.vite
.env
// certs
diff --git a/packages/bruno-electron/electron-builder-config.js b/packages/bruno-electron/electron-builder-config.js
index 1b75e4d13d..b8625ec44d 100644
--- a/packages/bruno-electron/electron-builder-config.js
+++ b/packages/bruno-electron/electron-builder-config.js
@@ -1,17 +1,17 @@
require('dotenv').config({ path: process.env.DOTENV_PATH });
const config = {
- appId: 'com.usebruno.app',
- productName: 'Bruno',
- electronVersion: '21.1.1',
+ appId: 'com.usebruno-lazer.app',
+ productName: 'Bruno lazer',
directories: {
buildResources: 'resources',
output: 'out'
},
files: ['**/*'],
afterSign: 'notarize.js',
+ afterPack: './linux-sandbox-fix.js',
mac: {
- artifactName: '${name}_${version}_${arch}_${os}.${ext}',
+ artifactName: 'bruno-lazer_nightly_${arch}_${os}.${ext}',
category: 'public.app-category.developer-tools',
target: [
{
@@ -30,16 +30,17 @@ const config = {
entitlementsInherit: 'resources/entitlements.mac.plist'
},
linux: {
- artifactName: '${name}_${version}_${arch}_linux.${ext}',
+ artifactName: 'bruno-lazer_nightly_${arch}_linux.${ext}',
icon: 'resources/icons/png',
+ executableName: 'bruno-lazer',
target: ['AppImage', 'deb', 'snap', 'rpm']
},
win: {
- artifactName: '${name}_${version}_${arch}_win.${ext}',
- icon: 'resources/icons/png',
- certificateFile: `${process.env.WIN_CERT_FILEPATH}`,
- certificatePassword: `${process.env.WIN_CERT_PASSWORD}`
- }
+ target: ['msi', 'portable'],
+ artifactName: 'bruno-lazer_nightly_${arch}_win.${ext}',
+ icon: 'resources/icons/png'
+ },
+ publish: []
};
module.exports = config;
diff --git a/packages/bruno-electron/index.html b/packages/bruno-electron/index.html
new file mode 100644
index 0000000000..87522af47e
--- /dev/null
+++ b/packages/bruno-electron/index.html
@@ -0,0 +1,3 @@
+
+ TODO: This is currently not in use
+
diff --git a/packages/bruno-electron/linux-sandbox-fix.js b/packages/bruno-electron/linux-sandbox-fix.js
new file mode 100644
index 0000000000..969ef6ffae
--- /dev/null
+++ b/packages/bruno-electron/linux-sandbox-fix.js
@@ -0,0 +1,28 @@
+const fs = require('fs').promises;
+const path = require('path');
+
+/**
+ * Workaround for https://github.com/electron-userland/electron-builder/issues/5371
+ * from https://github.com/jitsi/jitsi-meet-electron/commit/4cc851dc75ec15fdb054aa46e4132d8fbfa3a9e5
+ * use as "afterPack": "./linux-sandbox-fix.js" in build section of package.json
+ */
+async function afterPack({ appOutDir, electronPlatformName }) {
+ if (electronPlatformName !== 'linux') {
+ return;
+ }
+
+ const appName = 'bruno-lazer';
+ const script = `#!/bin/bash
+if [[ -f "/opt/Bruno lazer/bruno-lazer.bin" ]]; then
+ "/opt/Bruno lazer/bruno-lazer.bin" --no-sandbox "$@"
+else
+ "\${BASH_SOURCE%/*}"/bruno-lazer.bin --no-sandbox "$@"
+fi`;
+ const scriptPath = path.join(appOutDir, appName);
+
+ await fs.rename(scriptPath, `${scriptPath}.bin`);
+ await fs.writeFile(scriptPath, script);
+ await fs.chmod(scriptPath, 0o755);
+}
+
+module.exports = afterPack;
diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json
index 4898d4a220..e2e672a1d9 100644
--- a/packages/bruno-electron/package.json
+++ b/packages/bruno-electron/package.json
@@ -1,29 +1,28 @@
{
+ "name": "bruno-lazer",
"version": "v1.18.0",
- "name": "bruno",
"description": "Opensource API Client for Exploring and Testing APIs",
"homepage": "https://www.usebruno.com",
"private": true,
- "main": "src/index.js",
+ "main": ".vite/build/index.js",
"author": "Anoop M D (https://helloanoop.com/)",
"scripts": {
- "clean": "rimraf dist",
- "dev": "electron .",
- "dist:mac": "electron-builder --mac --config electron-builder-config.js",
- "dist:win": "electron-builder --win --config electron-builder-config.js",
- "dist:linux": "electron-builder --linux AppImage --config electron-builder-config.js",
- "dist:deb": "electron-builder --linux deb --config electron-builder-config.js",
- "dist:rpm": "electron-builder --linux rpm --config electron-builder-config.js",
- "dist:snap": "electron-builder --linux snap --config electron-builder-config.js",
- "pack": "electron-builder --dir",
+ "build": "vite build -c vite.main.config.ts && vite build -c vite.preload.config.ts",
+ "predev": "pnpm run build",
+ "dev": "vite build -c vite.main.config.ts --watch",
+ "preelectron": "pnpm run build",
+ "electron": "electron --inspect .",
+ "dist": "electron-builder --config electron-builder-config.js",
+ "clean": "rimraf out web .vite",
"test": "jest"
},
"dependencies": {
"@aws-sdk/credential-providers": "3.525.0",
- "@usebruno/common": "0.1.0",
- "@usebruno/js": "0.12.0",
- "@usebruno/lang": "0.12.0",
- "@usebruno/schema": "0.7.0",
+ "@usebruno/common": "workspace:*",
+ "@usebruno/core": "workspace:*",
+ "@usebruno/js": "workspace:*",
+ "@usebruno/lang": "workspace:*",
+ "@usebruno/schema": "workspace:*",
"about-window": "^1.15.2",
"aws4-axios": "^3.3.0",
"axios": "^1.5.1",
@@ -32,7 +31,6 @@
"content-disposition": "^0.5.4",
"decomment": "^0.9.5",
"dotenv": "^16.0.3",
- "electron-is-dev": "^2.0.0",
"electron-notarize": "^1.2.2",
"electron-store": "^8.1.0",
"electron-util": "^0.17.2",
@@ -47,22 +45,28 @@
"json-bigint": "^1.0.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
- "mustache": "^4.2.0",
"nanoid": "3.3.4",
+ "node-fetch": "2.*",
"node-machine-id": "^1.1.12",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^4.1.3",
"uuid": "^9.0.0",
- "vm2": "^3.9.13",
"yup": "^0.32.11"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
},
"devDependencies": {
- "electron": "21.1.1",
+ "@typescript-eslint/eslint-plugin": "^7.1.1",
+ "@typescript-eslint/parser": "^7.1.1",
+ "electron": "30.0.2",
"electron-builder": "23.0.2",
- "electron-icon-maker": "^0.0.5"
+ "electron-icon-maker": "^0.0.5",
+ "rimraf": "^5.0.5",
+ "ts-node": "^10.0.0",
+ "typescript": "^5.5",
+ "vite": "^5.1.5",
+ "vite-plugin-commonjs": "^0.10.1"
}
}
diff --git a/packages/bruno-electron/src/app/menu-template.js b/packages/bruno-electron/src/app/menu-template.js
index e662336aeb..034b1c5b2c 100644
--- a/packages/bruno-electron/src/app/menu-template.js
+++ b/packages/bruno-electron/src/app/menu-template.js
@@ -1,7 +1,8 @@
const { ipcMain } = require('electron');
const os = require('os');
-const openAboutWindow = require('about-window').default;
const { join } = require('path');
+// prettier-ignore
+const openAboutWindow = (require('about-window')).default;
const template = [
{
@@ -80,8 +81,8 @@ const template = [
click: () =>
openAboutWindow({
product_name: 'Bruno',
- icon_path: join(__dirname, '../about/256x256.png'),
- css_path: join(__dirname, '../about/about.css'),
+ icon_path: join(__dirname, './about/256x256.png'),
+ css_path: join(__dirname, './about/about.css'),
homepage: 'https://www.usebruno.com/',
package_json_dir: join(__dirname, '../..')
})
diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js
index 441bba3b2f..d27ceba2f4 100644
--- a/packages/bruno-electron/src/app/watcher.js
+++ b/packages/bruno-electron/src/app/watcher.js
@@ -40,10 +40,16 @@ const isBruEnvironmentConfig = (pathname, collectionPath) => {
const isCollectionRootBruFile = (pathname, collectionPath) => {
const dirname = path.dirname(pathname);
const basename = path.basename(pathname);
-
return dirname === collectionPath && basename === 'collection.bru';
};
+const isFolderRootBruFile = (pathname, collectionPath) => {
+ const dirname = path.dirname(pathname);
+ const basename = path.basename(pathname);
+ return dirname !== collectionPath && basename === 'folder.json';
+ // return dirname !== collectionPath && basename === 'folder.bru';
+};
+
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
@@ -100,7 +106,10 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
- file.data.name = basename.substring(0, basename.length - 4);
+ // Older env files do not have a meta block
+ if (!file.data.name) {
+ file.data.name = basename.substring(0, basename.length - 4);
+ }
file.data.uid = getRequestUid(pathname);
_.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid()));
@@ -108,10 +117,16 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
// hydrate environment variables with secrets
if (envHasSecrets(file.data)) {
const envSecrets = environmentSecretsStore.getEnvSecrets(collectionPath, file.data);
- _.each(envSecrets, (secret) => {
- const variable = _.find(file.data.variables, (v) => v.name === secret.name);
- if (variable && secret.value) {
- variable.value = decryptString(secret.value);
+ _.each(file.data.variables, (variable) => {
+ if (!variable.secret) {
+ return;
+ }
+ const secret = _.find(envSecrets, (secret) => variable.name === secret.name);
+ try {
+ variable.value = secret ? decryptString(secret.value) : '';
+ // Decrypting the value will throw an error if the value is not encrypted
+ } catch {
+ variable.value = '';
}
});
}
@@ -135,7 +150,10 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat
const bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
- file.data.name = basename.substring(0, basename.length - 4);
+ // Older env files do not have a meta block
+ if (!file.data.name) {
+ file.data.name = basename.substring(0, basename.length - 4);
+ }
file.data.uid = getRequestUid(pathname);
_.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid()));
@@ -180,8 +198,6 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => {
};
const add = async (win, pathname, collectionUid, collectionPath) => {
- console.log(`watcher add: ${pathname}`);
-
if (isBrunoConfigFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
@@ -225,12 +241,10 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
collectionRoot: true
}
};
-
try {
- let bruContent = fs.readFileSync(pathname, 'utf8');
+ const bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
-
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
return;
@@ -240,6 +254,25 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
}
}
+ if (isFolderRootBruFile(pathname, collectionPath)) {
+ const bruContent = fs.readFileSync(pathname, 'utf8');
+ if (bruContent) {
+ const folder = {
+ meta: {
+ collectionUid,
+ pathname: path.dirname(pathname),
+ name: path.basename(pathname),
+ folderRoot: true
+ }
+ };
+ // folder.data = collectionBruToJson(bruContent);
+ folder.data = JSON.parse(bruContent);
+ hydrateBruCollectionFileWithUuid(folder.data);
+ win.webContents.send('main:collection-tree-updated', 'addFileDir', folder);
+ }
+ return;
+ }
+
if (hasBruExtension(pathname)) {
const file = {
meta: {
@@ -334,7 +367,6 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
-
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
return;
@@ -344,6 +376,25 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
}
}
+ if (isFolderRootBruFile(pathname, collectionPath)) {
+ const bruContent = fs.readFileSync(pathname, 'utf8');
+ if (bruContent) {
+ const folder = {
+ meta: {
+ collectionUid,
+ pathname: path.dirname(pathname),
+ name: path.basename(pathname),
+ folderRoot: true
+ }
+ };
+ // folder.data = collectionBruToJson(bruContent);
+ folder.data = JSON.parse(bruContent);
+ hydrateBruCollectionFileWithUuid(folder.data);
+ win.webContents.send('main:collection-tree-updated', 'addFileDir', folder);
+ }
+ return;
+ }
+
if (hasBruExtension(pathname)) {
try {
const file = {
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 7f4e58422a..5c8be85365 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -1,7 +1,6 @@
const path = require('path');
-const isDev = require('electron-is-dev');
const { format } = require('url');
-const { BrowserWindow, app, Menu, ipcMain } = require('electron');
+const { BrowserWindow, app, Menu, ipcMain, shell } = require('electron');
const { setContentSecurityPolicy } = require('electron-util');
const menuTemplate = require('./app/menu-template');
@@ -13,6 +12,7 @@ const registerPreferencesIpc = require('./ipc/preferences');
const Watcher = require('./app/watcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const registerNotificationsIpc = require('./ipc/notifications');
+const registerEnvironmentsIpc = require('./ipc/environments');
const lastOpenedCollections = new LastOpenedCollections();
@@ -20,6 +20,7 @@ const lastOpenedCollections = new LastOpenedCollections();
const contentSecurityPolicy = [
"default-src 'self'",
"script-src * 'unsafe-inline' 'unsafe-eval'",
+ 'worker-src blob:',
"connect-src * 'unsafe-inline'",
"font-src 'self' https:",
// this has been commented out to make oauth2 work
@@ -30,13 +31,13 @@ const contentSecurityPolicy = [
"media-src 'self' blob: data: https:",
"style-src 'self' 'unsafe-inline' https:"
];
-
setContentSecurityPolicy(contentSecurityPolicy.join(';') + ';');
const menu = Menu.buildFromTemplate(menuTemplate);
let mainWindow;
let watcher;
+let launchFailed = false;
// Prepare the renderer once the app is ready
app.on('ready', async () => {
@@ -52,44 +53,51 @@ app.on('ready', async () => {
minHeight: 640,
webPreferences: {
nodeIntegration: true,
- contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
webviewTag: true
},
- title: 'Bruno',
- icon: path.join(__dirname, 'about/256x256.png')
+ title: 'Bruno lazer',
+ icon: path.join(__dirname, 'about/256x256.png'),
// we will bring this back
// see https://github.com/usebruno/bruno/issues/440
// autoHideMenuBar: true
+ show: false
+ });
+ mainWindow.on('ready-to-show', () => {
+ mainWindow.show();
});
if (maximized) {
mainWindow.maximize();
}
- const url = isDev
- ? 'http://localhost:3000'
- : format({
- pathname: path.join(__dirname, '../web/index.html'),
- protocol: 'file:',
- slashes: true
- });
-
- mainWindow.loadURL(url).catch((reason) => {
- console.error(`Error: Failed to load URL: "${url}" (Electron shows a blank screen because of this).`);
- console.error('Original message:', reason);
- if (isDev) {
- console.error(
- 'Could not connect to Next.Js dev server, is it running?' +
- ' Start the dev server using "npm run dev:web" and restart electron'
- );
- } else {
+ if (app.isPackaged) {
+ const url = path.join(__dirname, '../../web/index.html');
+ mainWindow.loadFile(url).catch((reason) => {
+ console.error(`Error: Failed to load URL: "${url}" (Electron shows a blank screen because of this).`);
+ console.error('Original message:', reason);
console.error(
'If you are using an official production build: the above error is most likely a bug! ' +
' Please report this under: https://github.com/usebruno/bruno/issues'
);
- }
- });
+ mainWindow.loadURL(`data:text/html;charset=utf,Failed to load: ${reason}`);
+ launchFailed = true;
+ });
+ } else {
+ mainWindow.loadURL('http://localhost:3000').catch((reason) => {
+ console.error(
+ `Error: Failed to load URL: "http://localhost:3000" (Electron shows a blank screen because of this).`
+ );
+ console.error('Original message:', reason);
+ console.error(
+ 'Could not connect to Next.Js dev server, is it running?' +
+ ' Start the dev server using "npm run dev:web" and restart electron'
+ );
+ mainWindow.loadURL(`data:text/html;charset=utf,Failed to load: ${reason}`);
+ launchFailed = true;
+ });
+ }
+
watcher = new Watcher();
const handleBoundsChange = () => {
@@ -104,19 +112,15 @@ app.on('ready', async () => {
mainWindow.on('maximize', () => saveMaximized(true));
mainWindow.on('unmaximize', () => saveMaximized(false));
mainWindow.on('close', (e) => {
+ if (launchFailed) {
+ return;
+ }
e.preventDefault();
ipcMain.emit('main:start-quit-flow');
});
- mainWindow.webContents.on('will-redirect', (event, url) => {
- event.preventDefault();
- if (/^(http:\/\/|https:\/\/)/.test(url)) {
- require('electron').shell.openExternal(url);
- }
- });
-
mainWindow.webContents.setWindowOpenHandler((details) => {
- require('electron').shell.openExternal(details.url);
+ shell.openExternal(details.url);
return { action: 'deny' };
});
@@ -125,6 +129,7 @@ app.on('ready', async () => {
registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections);
registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections);
registerNotificationsIpc(mainWindow, watcher);
+ registerEnvironmentsIpc(mainWindow, watcher);
});
// Quit the app once all windows are closed
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 5f8b63c3bc..edc510dde1 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -2,7 +2,7 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron');
-const { envJsonToBru, bruToJson, jsonToBru, jsonToCollectionBru } = require('../bru');
+const { envJsonToBru, bruToEnvJson, bruToJson, jsonToBru, jsonToCollectionBru } = require('../bru');
const {
isValidPathname,
@@ -13,7 +13,9 @@ const {
browseFiles,
createDirectory,
searchForBruFiles,
- sanitizeDirectoryName
+ sanitizeDirectoryName,
+ sanitizeFilename,
+ canRenameFile
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -60,10 +62,6 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`collection: ${dirPath} already exists`);
}
- if (!isValidPathname(dirPath)) {
- throw new Error(`collection: invalid pathname - ${dir}`);
- }
-
await createDirectory(dirPath);
const uid = generateUidBasedOnHash(dirPath);
@@ -152,6 +150,18 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
+ ipcMain.handle('renderer:save-folder-root', async (event, folderPathname, folderRoot) => {
+ try {
+ // const folderBruFilePath = path.join(folderPathname, 'folder.bru');
+ // const content = jsonToBru(folderRoot);
+ const folderBruFilePath = path.join(folderPathname, 'folder.json');
+ const content = JSON.stringify(folderRoot);
+
+ await writeFile(folderBruFilePath, content);
+ } catch (error) {
+ return Promise.reject(error);
+ }
+ });
ipcMain.handle('renderer:save-collection-root', async (event, collectionPathname, collectionRoot) => {
try {
const collectionBruFilePath = path.join(collectionPathname, 'collection.bru');
@@ -166,12 +176,16 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// new request
ipcMain.handle('renderer:new-request', async (event, pathname, request) => {
try {
- if (fs.existsSync(pathname)) {
- throw new Error(`path: ${pathname} already exists`);
+ const sanitizedPathname = path.join(pathname, sanitizeFilename(request.name) + '.bru');
+
+ if (fs.existsSync(sanitizedPathname)) {
+ throw new Error(`path: ${sanitizedPathname} already exists`);
}
const content = jsonToBru(request);
- await writeFile(pathname, content);
+ await writeFile(sanitizedPathname, content);
+
+ return sanitizedPathname;
} catch (error) {
return Promise.reject(error);
}
@@ -218,7 +232,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
await createDirectory(envDirPath);
}
- const envFilePath = path.join(envDirPath, `${name}.bru`);
+ const filenameSanitized = sanitizeFilename(`${name}.bru`);
+ const envFilePath = path.join(envDirPath, filenameSanitized);
if (fs.existsSync(envFilePath)) {
throw new Error(`environment: ${envFilePath} already exists`);
}
@@ -248,9 +263,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
await createDirectory(envDirPath);
}
- const envFilePath = path.join(envDirPath, `${environment.name}.bru`);
+ let envFilePath = path.join(envDirPath, `${sanitizeFilename(environment.name)}.bru`);
if (!fs.existsSync(envFilePath)) {
- throw new Error(`environment: ${envFilePath} does not exist`);
+ // Fallback to unsanitized filename for old envs
+ envFilePath = path.join(envDirPath, `${environment.name}.bru`);
+ if (!fs.existsSync(envFilePath)) {
+ throw new Error(`environment: ${envFilePath} does not exist`);
+ }
}
if (envHasSecrets(environment)) {
@@ -268,16 +287,26 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:rename-environment', async (event, collectionPathname, environmentName, newName) => {
try {
const envDirPath = path.join(collectionPathname, 'environments');
- const envFilePath = path.join(envDirPath, `${environmentName}.bru`);
+ let envFilePath = path.join(envDirPath, `${sanitizeFilename(environmentName)}.bru`);
if (!fs.existsSync(envFilePath)) {
- throw new Error(`environment: ${envFilePath} does not exist`);
+ // Fallback to unsanitized env name
+ envFilePath = path.join(envDirPath, `${environmentName}.bru`);
+ if (!fs.existsSync(envFilePath)) {
+ throw new Error(`environment: ${envFilePath} does not exist`);
+ }
}
- const newEnvFilePath = path.join(envDirPath, `${newName}.bru`);
- if (fs.existsSync(newEnvFilePath)) {
+ const newEnvFilePath = path.join(envDirPath, `${sanitizeFilename(newName)}.bru`);
+ if (!canRenameFile(newEnvFilePath, envFilePath)) {
throw new Error(`environment: ${newEnvFilePath} already exists`);
}
+ // Update the name in the environment meta
+ const bruContent = fs.readFileSync(envFilePath, 'utf8');
+ const content = bruToEnvJson(bruContent);
+ content.name = newName;
+ await writeFile(envFilePath, envJsonToBru(content));
+
fs.renameSync(envFilePath, newEnvFilePath);
environmentSecretsStore.renameEnvironment(collectionPathname, environmentName, newName);
@@ -290,9 +319,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:delete-environment', async (event, collectionPathname, environmentName) => {
try {
const envDirPath = path.join(collectionPathname, 'environments');
- const envFilePath = path.join(envDirPath, `${environmentName}.bru`);
+ let envFilePath = path.join(envDirPath, `${sanitizeFilename(environmentName)}.bru`);
if (!fs.existsSync(envFilePath)) {
- throw new Error(`environment: ${envFilePath} does not exist`);
+ // Fallback to unsanitized env name
+ envFilePath = path.join(envDirPath, `${environmentName}.bru`);
+ if (!fs.existsSync(envFilePath)) {
+ throw new Error(`environment: ${envFilePath} does not exist`);
+ }
}
fs.unlinkSync(envFilePath);
@@ -304,42 +337,53 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
});
// rename item
- ipcMain.handle('renderer:rename-item', async (event, oldPath, newPath, newName) => {
+ ipcMain.handle('renderer:rename-item', async (event, oldPathFull, newPath, newName) => {
try {
- if (!fs.existsSync(oldPath)) {
- throw new Error(`path: ${oldPath} does not exist`);
- }
- if (fs.existsSync(newPath)) {
- throw new Error(`path: ${oldPath} already exists`);
+ if (!fs.existsSync(oldPathFull)) {
+ throw new Error(`Old file "${oldPathFull}" does not exist`);
}
// if its directory, rename and return
- if (isDirectory(oldPath)) {
- const bruFilesAtSource = await searchForBruFiles(oldPath);
+ if (isDirectory(oldPathFull)) {
+ const bruFilesAtSource = searchForBruFiles(oldPathFull);
+
+ const newPathFull = path.join(newPath, newName);
+ if (fs.existsSync(newPathFull)) {
+ throw new Error(`Directory "${newPathFull}" already exists`);
+ }
for (let bruFile of bruFilesAtSource) {
- const newBruFilePath = bruFile.replace(oldPath, newPath);
+ const newBruFilePath = bruFile.replace(oldPathFull, newPathFull);
moveRequestUid(bruFile, newBruFilePath);
}
- return fs.renameSync(oldPath, newPath);
+ return fs.renameSync(oldPathFull, newPathFull);
}
- const isBru = hasBruExtension(oldPath);
+ const isBru = hasBruExtension(oldPathFull);
if (!isBru) {
- throw new Error(`path: ${oldPath} is not a bru file`);
+ throw new Error(`"${oldPathFull}" is not a bru file`);
+ }
+
+ const newSanitizedPath = path.join(newPath, sanitizeFilename(newName) + '.bru');
+ if (!canRenameFile(newSanitizedPath, oldPathFull)) {
+ throw new Error(`File "${newSanitizedPath}" already exists`);
}
// update name in file and save new copy, then delete old copy
- const data = fs.readFileSync(oldPath, 'utf8');
+ const data = fs.readFileSync(oldPathFull, 'utf8');
const jsonData = bruToJson(data);
jsonData.name = newName;
- moveRequestUid(oldPath, newPath);
+ moveRequestUid(oldPathFull, newSanitizedPath);
const content = jsonToBru(jsonData);
- await writeFile(newPath, content);
- await fs.unlinkSync(oldPath);
+
+ // Because of sanitization the name can change but the path stays the same
+ if (newSanitizedPath !== oldPathFull) {
+ fs.unlinkSync(oldPathFull);
+ }
+ await writeFile(newSanitizedPath, content);
} catch (error) {
return Promise.reject(error);
}
@@ -417,11 +461,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
items.forEach((item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = jsonToBru(item);
- const filePath = path.join(currentPath, `${item.name}.bru`);
+ const sanitizedFilename = sanitizeFilename(item.name);
+ const filePath = path.join(currentPath, `${sanitizedFilename}.bru`);
fs.writeFileSync(filePath, content);
}
if (item.type === 'folder') {
- const folderPath = path.join(currentPath, item.name);
+ const sanitizedFolderName = sanitizeDirectoryName(item.name);
+ const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
if (item.items && item.items.length) {
@@ -523,13 +569,29 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => {
try {
for (let item of itemsToResequence) {
- const bru = fs.readFileSync(item.pathname, 'utf8');
- const jsonData = bruToJson(bru);
+ if (fs.lstatSync(item.pathname).isFile()) {
+ const bru = fs.readFileSync(item.pathname, 'utf8');
+ const jsonData = bruToJson(bru);
+
+ if (jsonData.seq !== item.seq) {
+ jsonData.seq = item.seq;
+ const content = jsonToBru(jsonData);
+ await writeFile(item.pathname, content);
+ }
+ } else {
+ const metadataPath = path.join(item.pathname, 'folder.json');
+
+ // folder.json is only created when needed, so it might not exist
+ let contents = { seq: -1, root: {} };
+ if (fs.existsSync(metadataPath)) {
+ const raw = fs.readFileSync(metadataPath, 'utf8');
+ contents = JSON.parse(raw);
+ }
- if (jsonData.seq !== item.seq) {
- jsonData.seq = item.seq;
- const content = jsonToBru(jsonData);
- await writeFile(item.pathname, content);
+ if (contents.seq !== item.seq) {
+ contents.seq = item.seq;
+ await writeFile(metadataPath, JSON.stringify(contents));
+ }
}
}
} catch (error) {
@@ -577,6 +639,22 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
});
+ ipcMain.handle('renderer:shell-open', async (event, itemPath, isCollection, edit) => {
+ if (isCollection) {
+ itemPath = path.join(itemPath, 'bruno.json');
+ }
+
+ if (fs.existsSync(itemPath)) {
+ const isDir = fs.statSync(itemPath).isDirectory();
+
+ if (edit && !isDir) {
+ shell.openPath(itemPath);
+ } else {
+ shell.showItemInFolder(itemPath);
+ }
+ }
+ });
+
ipcMain.handle('renderer:update-bruno-config', async (event, brunoConfig, collectionPath, collectionUid) => {
try {
const brunoConfigPath = path.join(collectionPath, 'bruno.json');
diff --git a/packages/bruno-electron/src/ipc/environments.js b/packages/bruno-electron/src/ipc/environments.js
new file mode 100644
index 0000000000..8ecfb6d03b
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/environments.js
@@ -0,0 +1,14 @@
+const { ipcMain } = require('electron');
+const { getLastSelectedEnvironment, updateLastSelectedEnvironment } = require('../store/last-selected-environments');
+
+const registerEnvironmentsIpc = (_mainWindow, _watcher) => {
+ ipcMain.handle('renderer:get-last-selected-environment', async (_event, collectionUid) => {
+ return getLastSelectedEnvironment(collectionUid);
+ });
+
+ ipcMain.handle('renderer:update-last-selected-environment', async (_event, collectionUid, environmentName) => {
+ updateLastSelectedEnvironment(collectionUid, environmentName);
+ });
+};
+
+module.exports = registerEnvironmentsIpc;
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index 4d4e8d8e4c..b57ead2d7b 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -1,17 +1,17 @@
const os = require('os');
const fs = require('fs');
+const fsPromise = require('fs/promises');
const qs = require('qs');
const https = require('https');
const tls = require('tls');
const axios = require('axios');
const path = require('path');
const decomment = require('decomment');
-const Mustache = require('mustache');
const contentDispositionParser = require('content-disposition');
const mime = require('mime-types');
-const { ipcMain } = require('electron');
+const { ipcMain, app } = require('electron');
+const { VarsRuntime, AssertRuntime, runScript } = require('@usebruno/js');
const { isUndefined, isNull, each, get, compact, cloneDeep } = require('lodash');
-const { VarsRuntime, AssertRuntime, ScriptRuntime, TestRuntime } = require('@usebruno/js');
const prepareRequest = require('./prepare-request');
const prepareCollectionRequest = require('./prepare-collection-request');
const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-request');
@@ -20,7 +20,7 @@ const { uuid } = require('../../utils/common');
const interpolateVars = require('./interpolate-vars');
const { interpolateString } = require('./interpolate-string');
const { sortFolder, getAllRequestsInFolderRecursively } = require('./helper');
-const { preferencesUtil } = require('../../store/preferences');
+const { preferencesUtil, getPreferences } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const { HttpProxyAgent } = require('http-proxy-agent');
@@ -30,20 +30,16 @@ const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-he
const { addDigestInterceptor } = require('./digestauth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../../utils/proxy-util');
const { chooseFileToSave, writeBinaryFile } = require('../../utils/filesystem');
-const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies } = require('../../utils/cookies');
+const { getCookieStringForUrl, addCookieToJar, getDomainsWithCookies, cookieJar } = require('../../utils/cookies');
const {
resolveOAuth2AuthorizationCodeAccessToken,
transformClientCredentialsRequest,
transformPasswordCredentialsRequest
} = require('./oauth2-helper');
const Oauth2Store = require('../../store/oauth2');
+const { request: newRequest } = require('@usebruno/core');
const iconv = require('iconv-lite');
-// override the default escape function to prevent escaping
-Mustache.escape = function (value) {
- return value;
-};
-
const safeStringifyJSON = (data) => {
try {
return JSON.stringify(data);
@@ -71,7 +67,7 @@ const getEnvVars = (environment = {}) => {
const envVars = {};
each(variables, (variable) => {
if (variable.enabled) {
- envVars[variable.name] = Mustache.escape(variable.value);
+ envVars[variable.name] = variable.value;
}
});
@@ -282,9 +278,6 @@ const parseDataFromResponse = (response) => {
}
// Try to parse response to JSON, this can quietly fail
try {
- // Filter out ZWNBSP character
- // https://gist.github.com/antic183/619f42b559b78028d1fe9e7ae8a1352d
- data = data.replace(/^\uFEFF/, '');
data = JSON.parse(data);
} catch {}
@@ -292,13 +285,17 @@ const parseDataFromResponse = (response) => {
};
const registerNetworkIpc = (mainWindow) => {
- const onConsoleLog = (type, args) => {
+ const onConsoleLog = async (type, args) => {
console[type](...args);
- mainWindow.webContents.send('main:console-log', {
- type,
- args
- });
+ try {
+ await mainWindow.webContents.send('main:console-log', {
+ type,
+ args
+ });
+ } catch (e) {
+ console.error(`Could not send the above console.log to the BrowserWindow: "${e}"`);
+ }
};
const runPreRequest = async (
@@ -336,28 +333,28 @@ const registerNetworkIpc = (mainWindow) => {
}
// run pre-request script
- let scriptResult;
const requestScript = compact([get(collectionRoot, 'request.script.req'), get(request, 'script.req')]).join(os.EOL);
- if (requestScript?.length) {
- const scriptRuntime = new ScriptRuntime();
- scriptResult = await scriptRuntime.runRequestScript(
- decomment(requestScript),
- request,
- envVars,
+ const scriptResult = await runScript(
+ decomment(requestScript),
+ request,
+ null,
+ {
+ envVariables: envVars,
collectionVariables,
- collectionPath,
- onConsoleLog,
- processEnvVars,
- scriptingConfig
- );
+ processEnvVars
+ },
+ false,
+ collectionPath,
+ scriptingConfig,
+ onConsoleLog
+ );
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: scriptResult.envVariables,
- collectionVariables: scriptResult.collectionVariables,
- requestUid,
- collectionUid
- });
- }
+ mainWindow.webContents.send('main:script-environment-update', {
+ envVariables: scriptResult.envVariables,
+ collectionVariables: scriptResult.collectionVariables,
+ requestUid,
+ collectionUid
+ });
// interpolate variables inside request
interpolateVars(request, envVars, collectionVariables, processEnvVars);
@@ -417,207 +414,409 @@ const registerNetworkIpc = (mainWindow) => {
}
// run post-response script
- let scriptResult;
const responseScript = compact([get(collectionRoot, 'request.script.res'), get(request, 'script.res')]).join(
os.EOL
);
- if (responseScript?.length) {
- const scriptRuntime = new ScriptRuntime();
- scriptResult = await scriptRuntime.runResponseScript(
- decomment(responseScript),
- request,
- response,
- envVars,
+
+ const scriptResult = await runScript(
+ decomment(responseScript),
+ request,
+ response,
+ {
+ envVariables: envVars,
collectionVariables,
- collectionPath,
- onConsoleLog,
- processEnvVars,
- scriptingConfig
- );
+ processEnvVars
+ },
+ false,
+ collectionPath,
+ scriptingConfig,
+ onConsoleLog
+ );
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: scriptResult.envVariables,
- collectionVariables: scriptResult.collectionVariables,
- requestUid,
- collectionUid
- });
- }
+ mainWindow.webContents.send('main:script-environment-update', {
+ envVariables: scriptResult.envVariables,
+ collectionVariables: scriptResult.collectionVariables,
+ requestUid,
+ collectionUid
+ });
return scriptResult;
};
- // handler for sending http request
- ipcMain.handle('send-http-request', async (event, item, collection, environment, collectionVariables) => {
- const collectionUid = collection.uid;
- const collectionPath = collection.pathname;
- const cancelTokenUid = uuid();
- const requestUid = uuid();
-
- mainWindow.webContents.send('main:run-request-event', {
- type: 'request-queued',
- requestUid,
- collectionUid,
- itemUid: item.uid,
- cancelTokenUid
+ async function executeNewFolder(folder, collection, environment, recursive) {
+ const folderUid = folder ? folder.uid : null;
+ const dataDir = path.join(app.getPath('userData'), 'responseCache');
+ const cancelToken = uuid();
+ const abortController = new AbortController();
+ saveCancelToken(cancelToken, abortController);
+
+ // TODO: Refactor this, getAllRequestInForlderRecursive and sortFolder to use items instead of the folder obj
+ if (!folder) {
+ folder = collection;
+ }
+
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'testrun-started',
+ isRecursive: recursive,
+ collectionUid: collection.uid,
+ folderUid,
+ cancelTokenUid: cancelToken
});
- const collectionRoot = get(collection, 'root', {});
- const _request = item.draft ? item.draft.request : item.request;
- const request = prepareRequest(_request, collectionRoot, collectionPath);
- const envVars = getEnvVars(environment);
- const processEnvVars = getProcessEnvVars(collectionUid);
- const brunoConfig = getBrunoConfig(collectionUid);
- const scriptingConfig = get(brunoConfig, 'scripts', {});
+ const folderRequests = [];
+ if (recursive) {
+ folderRequests.push(...getAllRequestsInFolderRecursively(sortFolder(folder)));
+ } else {
+ each(folder.items, (item) => {
+ if (item.request) {
+ folderRequests.push(item);
+ }
+ });
- try {
- const controller = new AbortController();
- request.signal = controller.signal;
- saveCancelToken(cancelTokenUid, controller);
+ // sort requests by seq property
+ folderRequests.sort((a, b) => {
+ return a.seq - b.seq;
+ });
+ }
- await runPreRequest(
- request,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
+ let currentRequestIndex = 0;
+ let nJumps = 0; // count the number of jumps to avoid infinite loops
+ while (currentRequestIndex < folderRequests.length) {
+ if (abortController.signal.aborted) {
+ deleteCancelToken(cancelToken);
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'testrun-ended',
+ collectionUid: collection.uid,
+ folderUid,
+ error: 'Request runner cancelled'
+ });
+ return;
+ }
- const axiosInstance = await configureRequest(
- collectionUid,
- request,
- envVars,
- collectionVariables,
- processEnvVars,
- collectionPath
+ const item = folderRequests[currentRequestIndex];
+
+ const res = await newRequest(
+ item,
+ collection,
+ getPreferences(),
+ cookieJar,
+ dataDir,
+ cancelToken,
+ abortController,
+ environment,
+ {
+ runFolderEvent: (payload) => {
+ mainWindow.webContents.send('main:run-folder-event', {
+ ...payload,
+ folderUid
+ });
+ },
+ updateScriptEnvironment: (payload) => {
+ mainWindow.webContents.send('main:script-environment-update', payload);
+ },
+ cookieUpdated: (payload) => {
+ mainWindow.webContents.send('main:cookies-update', payload);
+ },
+ consoleLog: (payload) => {
+ console.log('=== start console.log from your script ===');
+ console.log(payload.args);
+ console.log('=== end console.log from your script ===');
+ try {
+ mainWindow.webContents.send('main:console-log', payload);
+ } catch (e) {
+ console.error(`The above console.log could not be sent to electron: ${e}`);
+ mainWindow.webContents.send('main:console-log', {
+ type: 'error',
+ args: [`console.${payload.type} could not be sent to electron: ${e}`, safeStringifyJSON(payload)]
+ });
+ }
+ }
+ }
);
+ if (abortController.signal.aborted) {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'error',
+ error: 'Aborted',
+ responseReceived: {},
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ folderUid
+ });
+ continue;
+ }
- mainWindow.webContents.send('main:run-request-event', {
- type: 'request-sent',
- requestSent: {
- url: request.url,
- method: request.method,
- headers: request.headers,
- data: safeParseJSON(safeStringifyJSON(request.data)),
- timestamp: Date.now()
+ if (res.error) {
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'error',
+ error: String(res.error) || 'An unknown error occurred while running the request',
+ responseReceived: {},
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ folderUid
+ });
+ }
+
+ if (typeof res.nextRequestName !== 'string') {
+ currentRequestIndex++;
+ continue;
+ }
+ nJumps++;
+ if (nJumps > 100) {
+ throw new Error('Too many jumps, possible infinite loop');
+ }
+ const nextRequestIdx = folderRequests.findIndex((request) => request.name === res.nextRequestName);
+ if (nextRequestIdx >= 0) {
+ currentRequestIndex = nextRequestIdx;
+ } else {
+ console.error("Could not find request with name '" + res.nextRequestName + "'");
+ currentRequestIndex++;
+ }
+ }
+
+ deleteCancelToken(cancelToken);
+
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'testrun-ended',
+ collectionUid: collection.uid,
+ folderUid,
+ error: null
+ });
+ }
+
+ async function executeNewRequest(event, item, collection, environment) {
+ const dataDir = path.join(app.getPath('userData'), 'responseCache');
+ const cancelToken = uuid();
+ const abortController = new AbortController();
+ saveCancelToken(cancelToken, abortController);
+
+ const res = await newRequest(
+ item,
+ collection,
+ getPreferences(),
+ cookieJar,
+ dataDir,
+ cancelToken,
+ abortController,
+ environment,
+ {
+ updateScriptEnvironment: (payload) => {
+ mainWindow.webContents.send('main:script-environment-update', payload);
},
+ cookieUpdated: (payload) => {
+ mainWindow.webContents.send('main:cookies-update', payload);
+ },
+ requestEvent: (payload) => {
+ mainWindow.webContents.send('main:run-request-event', payload);
+ },
+ consoleLog: (payload) => {
+ mainWindow.webContents.send('main:console-log', payload);
+ }
+ }
+ );
+ if (res.error) {
+ console.error(res.error);
+ }
+
+ deleteCancelToken(cancelToken);
+
+ if (abortController.signal.aborted) {
+ throw new Error('Request aborted');
+ }
+
+ return {
+ status: res.response?.statusCode,
+ headers: res.response?.headers,
+ size: res.response?.size ?? 0,
+ duration: res.response?.responseTime ?? 0,
+ isNew: true,
+ timeline: res.timeline,
+ debug: res.debug.getClean(),
+ timings: res.timings.getClean(),
+ error: res.error ? String(res.error) : undefined
+ };
+ }
+
+ // handler for sending http request
+ ipcMain.handle(
+ 'send-http-request',
+ async (event, item, collection, environment, collectionVariables, useNewRequest) => {
+ if (useNewRequest) {
+ return await executeNewRequest(event, item, collection, environment);
+ }
+
+ const collectionUid = collection.uid;
+ const collectionPath = collection.pathname;
+ const cancelTokenUid = uuid();
+ const requestUid = uuid();
+
+ mainWindow.webContents.send('main:run-request-event', {
+ type: 'request-queued',
+ requestUid,
collectionUid,
itemUid: item.uid,
- requestUid,
cancelTokenUid
});
- let response, responseTime;
+ const collectionRoot = get(collection, 'root', {});
+ const _request = item.draft ? item.draft.request : item.request;
+ const request = prepareRequest(_request, collectionRoot, collectionPath);
+ const envVars = getEnvVars(environment);
+ const processEnvVars = getProcessEnvVars(collectionUid);
+ const brunoConfig = getBrunoConfig(collectionUid);
+ const scriptingConfig = get(brunoConfig, 'scripts', {});
+
try {
- /** @type {import('axios').AxiosResponse} */
- response = await axiosInstance(request);
+ const controller = new AbortController();
+ request.signal = controller.signal;
+ saveCancelToken(cancelTokenUid, controller);
- // Prevents the duration on leaking to the actual result
- responseTime = response.headers.get('request-duration');
- response.headers.delete('request-duration');
- } catch (error) {
- deleteCancelToken(cancelTokenUid);
+ await runPreRequest(
+ request,
+ requestUid,
+ envVars,
+ collectionPath,
+ collectionRoot,
+ collectionUid,
+ collectionVariables,
+ processEnvVars,
+ scriptingConfig
+ );
- // if it's a cancel request, don't continue
- if (axios.isCancel(error)) {
- let error = new Error('Request cancelled');
- error.isCancel = true;
- return Promise.reject(error);
- }
+ mainWindow.webContents.send('main:run-request-event', {
+ type: 'request-sent',
+ requestSent: {
+ url: request.url,
+ method: request.method,
+ headers: request.headers,
+ data: safeParseJSON(safeStringifyJSON(request.data)),
+ timestamp: Date.now()
+ },
+ collectionUid,
+ itemUid: item.uid,
+ requestUid,
+ cancelTokenUid
+ });
- if (error?.response) {
- response = error.response;
+ const axiosInstance = await configureRequest(
+ collectionUid,
+ request,
+ envVars,
+ collectionVariables,
+ processEnvVars,
+ collectionPath
+ );
+
+ let response, responseTime;
+ try {
+ /** @type {import('axios').AxiosResponse} */
+ response = await axiosInstance(request);
// Prevents the duration on leaking to the actual result
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
- } else {
- // if it's not a network error, don't continue
- return Promise.reject(error);
+ } catch (error) {
+ deleteCancelToken(cancelTokenUid);
+
+ // if it's a cancel request, don't continue
+ if (axios.isCancel(error)) {
+ let error = new Error('Request cancelled');
+ error.isCancel = true;
+ return Promise.reject(error);
+ }
+
+ if (error?.response) {
+ response = error.response;
+
+ // Prevents the duration on leaking to the actual result
+ responseTime = response.headers.get('request-duration');
+ response.headers.delete('request-duration');
+ } else {
+ // if it's not a network error, don't continue
+ return Promise.reject(error);
+ }
}
- }
- // Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
+ // Continue with the rest of the request lifecycle - post response vars, script, assertions, tests
- const { data, dataBuffer } = parseDataFromResponse(response);
- response.data = data;
+ const { data, dataBuffer } = parseDataFromResponse(response);
+ response.data = data;
- response.responseTime = responseTime;
-
- // save cookies
- if (preferencesUtil.shouldStoreCookies()) {
- let setCookieHeaders = [];
- if (response.headers['set-cookie']) {
- setCookieHeaders = Array.isArray(response.headers['set-cookie'])
- ? response.headers['set-cookie']
- : [response.headers['set-cookie']];
- for (let setCookieHeader of setCookieHeaders) {
- if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
- addCookieToJar(setCookieHeader, request.url);
+ const responsePath = path.join(app.getPath('userData'), 'responseCache', item.uid);
+ await fsPromise.writeFile(responsePath, dataBuffer);
+
+ response.responseTime = responseTime;
+
+ // save cookies
+ if (preferencesUtil.shouldStoreCookies()) {
+ let setCookieHeaders = [];
+ if (response.headers['set-cookie']) {
+ setCookieHeaders = Array.isArray(response.headers['set-cookie'])
+ ? response.headers['set-cookie']
+ : [response.headers['set-cookie']];
+ for (let setCookieHeader of setCookieHeaders) {
+ if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
+ addCookieToJar(setCookieHeader, request.url);
+ }
}
}
}
- }
- // send domain cookies to renderer
- const domainsWithCookies = await getDomainsWithCookies();
+ // send domain cookies to renderer
+ const domainsWithCookies = await getDomainsWithCookies();
- mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
-
- await runPostResponse(
- request,
- response,
- requestUid,
- envVars,
- collectionPath,
- collectionRoot,
- collectionUid,
- collectionVariables,
- processEnvVars,
- scriptingConfig
- );
+ mainWindow.webContents.send('main:cookies-update', safeParseJSON(safeStringifyJSON(domainsWithCookies)));
- // run assertions
- const assertions = get(request, 'assertions');
- if (assertions) {
- const assertRuntime = new AssertRuntime();
- const results = assertRuntime.runAssertions(
- assertions,
+ await runPostResponse(
request,
response,
+ requestUid,
envVars,
+ collectionPath,
+ collectionRoot,
+ collectionUid,
collectionVariables,
processEnvVars
);
- mainWindow.webContents.send('main:run-request-event', {
- type: 'assertion-results',
- results: results,
- itemUid: item.uid,
- requestUid,
- collectionUid
- });
- }
+ // run assertions
+ const assertions = get(request, 'assertions');
+ if (assertions) {
+ const assertRuntime = new AssertRuntime();
+ const results = assertRuntime.runAssertions(
+ assertions,
+ request,
+ response,
+ envVars,
+ collectionVariables,
+ collectionPath
+ );
+
+ mainWindow.webContents.send('main:run-request-event', {
+ type: 'assertion-results',
+ results: results,
+ itemUid: item.uid,
+ requestUid,
+ collectionUid
+ });
+ }
- // run tests
- const testFile = compact([
- get(collectionRoot, 'request.tests'),
- item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests')
- ]).join(os.EOL);
- if (typeof testFile === 'string') {
- const testRuntime = new TestRuntime();
- const testResults = await testRuntime.runTests(
+ // run tests
+ const testFile = compact([
+ get(collectionRoot, 'request.tests'),
+ item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests')
+ ]).join(os.EOL);
+ const testResults = await runScript(
decomment(testFile),
request,
response,
- envVars,
- collectionVariables,
+ {
+ envVariables: envVars,
+ collectionVariables,
+ processEnvVars
+ },
+ true,
collectionPath,
- onConsoleLog,
- processEnvVars,
- scriptingConfig
+ scriptingConfig,
+ onConsoleLog
);
mainWindow.webContents.send('main:run-request-event', {
@@ -634,23 +833,21 @@ const registerNetworkIpc = (mainWindow) => {
requestUid,
collectionUid
});
- }
- return {
- status: response.status,
- statusText: response.statusText,
- headers: response.headers,
- data: response.data,
- dataBuffer: dataBuffer.toString('base64'),
- size: Buffer.byteLength(dataBuffer),
- duration: responseTime ?? 0
- };
- } catch (error) {
- deleteCancelToken(cancelTokenUid);
+ return {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers,
+ size: Buffer.byteLength(dataBuffer),
+ duration: responseTime ?? 0
+ };
+ } catch (error) {
+ deleteCancelToken(cancelTokenUid);
- return Promise.reject(error);
+ return Promise.reject(error);
+ }
}
- });
+ );
ipcMain.handle('send-collection-oauth2-request', async (event, collection, environment, collectionVariables) => {
try {
@@ -829,7 +1026,11 @@ const registerNetworkIpc = (mainWindow) => {
ipcMain.handle(
'renderer:run-collection-folder',
- async (event, folder, collection, environment, collectionVariables, recursive) => {
+ async (event, folder, collection, environment, collectionVariables, recursive, newRequestMethod) => {
+ if (newRequestMethod) {
+ return await executeNewFolder(folder, collection, environment, recursive);
+ }
+
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null;
@@ -957,6 +1158,12 @@ const registerNetworkIpc = (mainWindow) => {
response.data = data;
response.responseTime = response.headers.get('request-duration');
+ try {
+ await fsPromise.mkdir(path.join(app.getPath('userData'), 'responseCache'));
+ } catch {}
+ const responsePath = path.join(app.getPath('userData'), 'responseCache', item.uid);
+ await fsPromise.writeFile(responsePath, dataBuffer);
+
mainWindow.webContents.send('main:run-folder-event', {
type: 'response-received',
responseReceived: {
@@ -964,9 +1171,7 @@ const registerNetworkIpc = (mainWindow) => {
statusText: response.statusText,
headers: response.headers,
duration: timeEnd - timeStart,
- dataBuffer: dataBuffer.toString('base64'),
size: Buffer.byteLength(dataBuffer),
- data: response.data,
responseTime: response.headers.get('request-duration')
},
...eventData
@@ -976,15 +1181,16 @@ const registerNetworkIpc = (mainWindow) => {
const { data, dataBuffer } = parseDataFromResponse(error.response);
error.response.data = data;
+ const responsePath = path.join(app.getPath('userData'), 'responseCache', item.uid);
+ await fsPromise.writeFile(responsePath, dataBuffer);
+
timeEnd = Date.now();
response = {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
duration: timeEnd - timeStart,
- dataBuffer: dataBuffer.toString('base64'),
size: Buffer.byteLength(dataBuffer),
- data: error.response.data,
responseTime: error.response.headers.get('request-duration')
};
@@ -1044,32 +1250,31 @@ const registerNetworkIpc = (mainWindow) => {
get(collectionRoot, 'request.tests'),
item.draft ? get(item.draft, 'request.tests') : get(item, 'request.tests')
]).join(os.EOL);
- if (typeof testFile === 'string') {
- const testRuntime = new TestRuntime();
- const testResults = await testRuntime.runTests(
- decomment(testFile),
- request,
- response,
- envVars,
+ const testResults = await runScript(
+ decomment(testFile),
+ request,
+ response,
+ {
+ envVariables: envVars,
collectionVariables,
- collectionPath,
- onConsoleLog,
- processEnvVars,
- scriptingConfig
- );
-
- mainWindow.webContents.send('main:run-folder-event', {
- type: 'test-results',
- testResults: testResults.results,
- ...eventData
- });
+ processEnvVars
+ },
+ true,
+ collectionPath,
+ scriptingConfig,
+ null
+ );
+ mainWindow.webContents.send('main:run-folder-event', {
+ type: 'test-results',
+ testResults: testResults.results,
+ ...eventData
+ });
- mainWindow.webContents.send('main:script-environment-update', {
- envVariables: testResults.envVariables,
- collectionVariables: testResults.collectionVariables,
- collectionUid
- });
- }
+ mainWindow.webContents.send('main:script-environment-update', {
+ envVariables: testResults.envVariables,
+ collectionVariables: testResults.collectionVariables,
+ collectionUid
+ });
} catch (error) {
mainWindow.webContents.send('main:run-folder-event', {
type: 'error',
@@ -1116,6 +1321,43 @@ const registerNetworkIpc = (mainWindow) => {
}
);
+ // Ensure the response dir directory exists
+ const responseCacheDir = path.join(app.getPath('userData'), 'responseCache');
+ try {
+ fs.mkdirSync(responseCacheDir);
+ } catch {}
+ // Delete old files
+ fs.readdir(responseCacheDir, (err, files) => {
+ if (err) {
+ throw err;
+ }
+
+ for (const file of files) {
+ fs.rmSync(path.join(responseCacheDir, file));
+ }
+ });
+
+ ipcMain.handle('renderer:get-response-body', async (_event, requestId) => {
+ const responsePath = path.join(app.getPath('userData'), 'responseCache', requestId);
+
+ let rawData;
+ try {
+ rawData = await fsPromise.readFile(responsePath);
+ } catch (e) {
+ return null;
+ }
+
+ let data = null;
+ try {
+ // TODO: Load encoding conditionally
+ data = JSON.parse(rawData.toString('utf-8'));
+ } catch {
+ data = rawData.toString('utf-8');
+ }
+
+ return { data, dataBuffer: rawData.toString('base64') };
+ });
+
// save response to file
ipcMain.handle('renderer:save-response-to-file', async (event, response, url) => {
try {
diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js
index 138083e1d8..9c238fe9d4 100644
--- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js
+++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js
@@ -1,5 +1,6 @@
+const FormData = require('form-data');
+const { each, forOwn, cloneDeep, extend } = require('lodash');
const { interpolate } = require('@usebruno/common');
-const { each, forOwn, cloneDeep, find } = require('lodash');
const getContentType = (headers = {}) => {
let contentType = '';
@@ -60,8 +61,9 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
if (typeof request.data === 'object') {
try {
let parsed = JSON.stringify(request.data);
- parsed = _interpolate(parsed);
- request.data = JSON.parse(parsed);
+ // Write the interpolated body into data, so one can see his values even if parsing fails
+ request.data = _interpolate(parsed);
+ request.data = JSON.parse(request.data);
} catch (err) {}
}
@@ -78,6 +80,16 @@ const interpolateVars = (request, envVars = {}, collectionVariables = {}, proces
request.data = JSON.parse(parsed);
} catch (err) {}
}
+ } else if (request.mode === 'multipartForm') {
+ // make axios work in node using form data
+ // reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
+ const form = new FormData();
+ forOwn(request.data, (value, name) => {
+ form.append(_interpolate(name), _interpolate(value));
+ });
+
+ extend(request.headers, form.getHeaders());
+ request.data = form;
} else {
request.data = _interpolate(request.data);
}
diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js
index 73b8bf71c8..e8339c0731 100644
--- a/packages/bruno-electron/src/ipc/network/prepare-request.js
+++ b/packages/bruno-electron/src/ipc/network/prepare-request.js
@@ -1,4 +1,4 @@
-const { get, each, filter, extend } = require('lodash');
+const { get, each, filter } = require('lodash');
const decomment = require('decomment');
var JSONbig = require('json-bigint');
const FormData = require('form-data');
@@ -216,9 +216,11 @@ const prepareRequest = (request, collectionRoot, collectionPath) => {
if (request.body.mode === 'multipartForm') {
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
- const form = parseFormData(enabledParams, collectionPath);
- extend(axiosRequest.headers, form.getHeaders());
- axiosRequest.data = form;
+
+ const params = {};
+ each(enabledParams, (p) => (params[p.name] = p.value));
+ axiosRequest.data = params;
+ axiosRequest.headers['content-type'] = 'multipart/form-data';
}
if (request.body.mode === 'graphql') {
diff --git a/packages/bruno-electron/src/store/last-selected-environments.js b/packages/bruno-electron/src/store/last-selected-environments.js
new file mode 100644
index 0000000000..f48ae09bcb
--- /dev/null
+++ b/packages/bruno-electron/src/store/last-selected-environments.js
@@ -0,0 +1,42 @@
+const _ = require('lodash');
+const Store = require('electron-store');
+
+class LastSelectedEnvironments {
+ constructor() {
+ this.store = new Store({
+ name: 'environments',
+ clearInvalidConfig: true
+ });
+ }
+
+ updateLastSelectedEnvironment(collectionUid, environmentName) {
+ const lastSelectedEnvironments = this.store.get('lastSelectedEnvironments') || {};
+
+ const updatedLastSelectedEnvironments = {
+ ...lastSelectedEnvironments,
+ [collectionUid]: environmentName
+ };
+
+ return this.store.set('lastSelectedEnvironments', updatedLastSelectedEnvironments);
+ }
+
+ getLastSelectedEnvironment(collectionUid) {
+ const lastSelectedEnvironments = this.store.get('lastSelectedEnvironments') || {};
+ return lastSelectedEnvironments[collectionUid];
+ }
+}
+
+const lastSelectedEnvironments = new LastSelectedEnvironments();
+
+const updateLastSelectedEnvironment = (collectionUid, environmentName) => {
+ return lastSelectedEnvironments.updateLastSelectedEnvironment(collectionUid, environmentName);
+};
+
+const getLastSelectedEnvironment = (collectionUid) => {
+ return lastSelectedEnvironments.getLastSelectedEnvironment(collectionUid);
+};
+
+module.exports = {
+ updateLastSelectedEnvironment,
+ getLastSelectedEnvironment
+};
diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js
index f9497abee4..fea80b54b9 100644
--- a/packages/bruno-electron/src/store/preferences.js
+++ b/packages/bruno-electron/src/store/preferences.js
@@ -36,6 +36,9 @@ const defaultPreferences = {
password: ''
},
bypassProxy: ''
+ },
+ editor: {
+ monaco: true
}
};
diff --git a/packages/bruno-electron/src/utils/cookies.js b/packages/bruno-electron/src/utils/cookies.js
index 5b4d7fc7cf..3ee21f5158 100644
--- a/packages/bruno-electron/src/utils/cookies.js
+++ b/packages/bruno-electron/src/utils/cookies.js
@@ -81,5 +81,6 @@ module.exports = {
getCookiesForUrl,
getCookieStringForUrl,
getDomainsWithCookies,
- deleteCookiesForDomain
+ deleteCookiesForDomain,
+ cookieJar
};
diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js
index 8216bd9c92..f3614cd286 100644
--- a/packages/bruno-electron/src/utils/filesystem.js
+++ b/packages/bruno-electron/src/utils/filesystem.js
@@ -147,6 +147,21 @@ const sanitizeDirectoryName = (name) => {
return name.replace(/[<>:"/\\|?*\x00-\x1F]+/g, '-');
};
+const sanitizeFilename = (name) => {
+ return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
+};
+
+const canRenameFile = (newFilePath, oldFilePath) => {
+ const newFileExists = fs.existsSync(newFilePath);
+ if (!newFileExists) {
+ // File does not exists, so wen can safely rename
+ return true;
+ }
+
+ // Try to resolve both paths, if they reference the same file. We are on a case-insentive filesystem like NTFS and are trying to rename a file
+ return fs.statSync(newFilePath).ino === fs.statSync(oldFilePath).ino;
+};
+
module.exports = {
isValidPathname,
exists,
@@ -164,5 +179,7 @@ module.exports = {
chooseFileToSave,
searchForFiles,
searchForBruFiles,
- sanitizeDirectoryName
+ sanitizeDirectoryName,
+ sanitizeFilename,
+ canRenameFile
};
diff --git a/packages/bruno-electron/tsconfig.json b/packages/bruno-electron/tsconfig.json
new file mode 100644
index 0000000000..787b978c49
--- /dev/null
+++ b/packages/bruno-electron/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "commonjs",
+ "noImplicitAny": false,
+ "sourceMap": true,
+ "baseUrl": ".",
+ "outDir": "dist",
+ "moduleResolution": "node",
+ "resolveJsonModule": true
+ },
+ "include": ["./*.config.ts"]
+}
diff --git a/packages/bruno-electron/vite.base.config.ts b/packages/bruno-electron/vite.base.config.ts
new file mode 100644
index 0000000000..89e08ac6ab
--- /dev/null
+++ b/packages/bruno-electron/vite.base.config.ts
@@ -0,0 +1,27 @@
+import { builtinModules } from 'node:module';
+import type { ConfigEnv, UserConfig } from 'vite';
+import pkg from './package.json';
+
+export const builtins = ['electron', ...builtinModules.map((m) => [m, `node:${m}`]).flat()];
+
+export const external = [
+ ...builtins,
+ ...Object.keys('dependencies' in pkg ? (pkg.dependencies as Record) : {})
+];
+
+export function getBuildConfig(env: ConfigEnv): UserConfig {
+ const { mode } = env;
+
+ return {
+ mode,
+ build: {
+ // Prevent multiple builds from interfering with each other.
+ emptyOutDir: false,
+ // 🚧 Multiple builds may conflict.
+ outDir: '.vite/build',
+ minify: false,
+ sourcemap: true
+ },
+ clearScreen: false
+ };
+}
diff --git a/packages/bruno-electron/vite.main.config.ts b/packages/bruno-electron/vite.main.config.ts
new file mode 100644
index 0000000000..c3a2ffc7d2
--- /dev/null
+++ b/packages/bruno-electron/vite.main.config.ts
@@ -0,0 +1,32 @@
+import type { UserConfig } from 'vite';
+import { defineConfig, mergeConfig } from 'vite';
+import { external, getBuildConfig } from './vite.base.config';
+import { resolve } from 'path';
+import commonjs from 'vite-plugin-commonjs';
+import { cpSync, mkdirSync } from 'node:fs';
+
+export default defineConfig((env) => {
+ // Copy the about files
+ mkdirSync(resolve(__dirname, '.vite/build/about'), { recursive: true });
+ cpSync(resolve(__dirname, 'src/about/'), resolve(__dirname, '.vite/build/about/'), { recursive: true });
+
+ const config: UserConfig = {
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'src/index.js'),
+ fileName: () => '[name].js',
+ formats: ['cjs']
+ },
+ rollupOptions: {
+ external
+ }
+ },
+ resolve: {
+ // Load the Node.js entry.
+ mainFields: ['module', 'jsnext:main', 'jsnext']
+ },
+ plugins: [commonjs()]
+ };
+
+ return mergeConfig(getBuildConfig(env), config);
+});
diff --git a/packages/bruno-electron/vite.preload.config.ts b/packages/bruno-electron/vite.preload.config.ts
new file mode 100644
index 0000000000..5f67f244a1
--- /dev/null
+++ b/packages/bruno-electron/vite.preload.config.ts
@@ -0,0 +1,26 @@
+import type { UserConfig } from 'vite';
+import { defineConfig, mergeConfig } from 'vite';
+import { getBuildConfig, external } from './vite.base.config';
+import { resolve } from 'path';
+
+export default defineConfig((env) => {
+ const config: UserConfig = {
+ build: {
+ rollupOptions: {
+ external,
+ // Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
+ input: resolve(__dirname, 'src/preload.js'),
+ output: {
+ format: 'cjs',
+ // It should not be split chunks.
+ inlineDynamicImports: true,
+ entryFileNames: '[name].js',
+ chunkFileNames: '[name].js',
+ assetFileNames: '[name].[ext]'
+ }
+ }
+ }
+ };
+
+ return mergeConfig(getBuildConfig(env), config);
+});
diff --git a/packages/bruno-graphql-docs/package.json b/packages/bruno-graphql-docs/package.json
index 393a3d7927..b65eed0677 100644
--- a/packages/bruno-graphql-docs/package.json
+++ b/packages/bruno-graphql-docs/package.json
@@ -1,39 +1,45 @@
{
"name": "@usebruno/graphql-docs",
"version": "0.1.0",
- "license" : "MIT",
- "main": "dist/cjs/index.js",
- "module": "dist/esm/index.js",
+ "license": "MIT",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "module": "dist/index.mjs",
"files": [
- "dist",
- "package.json"
+ "dist"
],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ },
+ "./dist/style.css": "./dist/style.css"
+ },
"scripts": {
- "build": "rollup -c"
+ "build": "vite build",
+ "dev": "vite build --watch",
+ "clean": "rimraf dist"
+ },
+ "dependencies": {
+ "graphql": "^16.6.0",
+ "markdown-it": "^13.0.1"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
},
"devDependencies": {
- "@rollup/plugin-commonjs": "^23.0.2",
- "@rollup/plugin-node-resolve": "^15.0.1",
- "@rollup/plugin-typescript": "^9.0.2",
"@types/markdown-it": "^12.2.3",
"@types/react": "^18.0.25",
"graphql": "^16.6.0",
"markdown-it": "^13.0.1",
- "postcss": "^8.4.18",
"react": "18.2.0",
"react-dom": "18.2.0",
- "rollup": "3.2.5",
- "rollup-plugin-dts": "^5.0.0",
- "rollup-plugin-peer-deps-external": "^2.2.4",
- "rollup-plugin-postcss": "^4.0.2",
- "rollup-plugin-terser": "^7.0.2",
- "typescript": "^4.8.4"
- },
- "peerDependencies": {
- "graphql": "^16.6.0",
- "markdown-it": "^13.0.1"
- },
- "overrides": {
- "rollup": "3.2.5"
+ "rimraf": "^5.0.5",
+ "vite": "^5.1.5",
+ "vite-plugin-dts": "^3.7.3",
+ "@vitejs/plugin-react": "^4.2.1"
}
}
diff --git a/packages/bruno-graphql-docs/rollup.config.js b/packages/bruno-graphql-docs/rollup.config.js
deleted file mode 100644
index d289e1df1f..0000000000
--- a/packages/bruno-graphql-docs/rollup.config.js
+++ /dev/null
@@ -1,48 +0,0 @@
-const { nodeResolve } = require('@rollup/plugin-node-resolve');
-const commonjs = require('@rollup/plugin-commonjs');
-const typescript = require('@rollup/plugin-typescript');
-const dts = require('rollup-plugin-dts');
-const postcss = require('rollup-plugin-postcss');
-const { terser } = require('rollup-plugin-terser');
-const peerDepsExternal = require('rollup-plugin-peer-deps-external');
-
-const packageJson = require('./package.json');
-
-module.exports = [
- {
- input: 'src/index.ts',
- output: [
- {
- file: packageJson.main,
- format: 'cjs',
- sourcemap: true
- },
- {
- file: packageJson.module,
- format: 'esm',
- sourcemap: true
- }
- ],
- plugins: [
- postcss({
- minimize: true,
- extensions: ['.css'],
- extract: true
- }),
- peerDepsExternal(),
- nodeResolve({
- extensions: ['.css']
- }),
- commonjs(),
- typescript({ tsconfig: './tsconfig.json' }),
- terser()
- ],
- external: ['react', 'react-dom', 'index.css']
- },
- {
- input: 'dist/esm/index.d.ts',
- external: [/\.css$/],
- output: [{ file: 'dist/index.d.ts', format: 'esm' }],
- plugins: [dts.default()]
- }
-];
diff --git a/packages/bruno-graphql-docs/tsconfig.json b/packages/bruno-graphql-docs/tsconfig.json
index 51e350d283..9e728b4336 100644
--- a/packages/bruno-graphql-docs/tsconfig.json
+++ b/packages/bruno-graphql-docs/tsconfig.json
@@ -1,22 +1,9 @@
{
+ "extends": "../../tsconfig.base.json",
"compilerOptions": {
- "esModuleInterop": true,
- "strict": true,
- "skipLibCheck": true,
- "jsx": "react",
- "module": "ESNext",
- "declaration": true,
- "declarationDir": "types",
- "sourceMap": true,
- "outDir": "dist",
- "moduleResolution": "node",
- "emitDeclarationOnly": true,
- "allowSyntheticDefaultImports": true,
- "forceConsistentCasingInFileNames": true
+ "outDir": "./dist",
+ "module": "CommonJS",
+ "jsx": "react"
},
- "exclude": [
- "dist",
- "node_modules",
- "src/**/*.test.tsx"
- ],
-}
\ No newline at end of file
+ "include": ["src/**/*"]
+}
diff --git a/packages/bruno-graphql-docs/vite.config.ts b/packages/bruno-graphql-docs/vite.config.ts
new file mode 100644
index 0000000000..2339099a30
--- /dev/null
+++ b/packages/bruno-graphql-docs/vite.config.ts
@@ -0,0 +1,21 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ plugins: [react(), dts()],
+ build: {
+ minify: false,
+ sourcemap: true,
+ lib: {
+ entry: resolve(__dirname, 'src/index.ts'),
+ formats: ['cjs', 'es'],
+ fileName: 'index'
+ },
+ rollupOptions: {
+ external: ['react', 'react-dom', 'graphql']
+ }
+ },
+ clearScreen: false
+});
diff --git a/packages/bruno-js/.gitignore b/packages/bruno-js/.gitignore
new file mode 100644
index 0000000000..b3a0259db4
--- /dev/null
+++ b/packages/bruno-js/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+web
+dist
+
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
diff --git a/packages/bruno-js/package.json b/packages/bruno-js/package.json
index 901ab00737..4bf74dad79 100644
--- a/packages/bruno-js/package.json
+++ b/packages/bruno-js/package.json
@@ -2,20 +2,23 @@
"name": "@usebruno/js",
"version": "0.12.0",
"license": "MIT",
- "main": "src/index.js",
+ "type": "commonjs",
"files": [
- "src",
- "package.json"
+ "dist"
],
- "peerDependencies": {
- "@n8n/vm2": "^3.9.23"
- },
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "module": "dist/index.mjs",
+ "private": true,
"scripts": {
+ "build": "vite build",
+ "dev": "vite build --watch",
+ "clean": "rimraf dist",
"test": "jest --testPathIgnorePatterns test.js"
},
"dependencies": {
- "@usebruno/common": "0.1.0",
- "@usebruno/query": "0.1.0",
+ "@usebruno/query": "workspace:*",
+ "@usebruno/common": "workspace:*",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
@@ -31,5 +34,13 @@
"node-fetch": "2.*",
"node-vault": "^0.10.2",
"uuid": "^9.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.11.25",
+ "rimraf": "^5.0.5",
+ "typescript": "^5.5",
+ "vite": "^5.1.5",
+ "vite-plugin-commonjs": "^0.10.1",
+ "vite-plugin-dts": "^3.7.3"
}
}
diff --git a/packages/bruno-js/src/bru.js b/packages/bruno-js/src/bru.js
index 53039fd063..c203688417 100644
--- a/packages/bruno-js/src/bru.js
+++ b/packages/bruno-js/src/bru.js
@@ -41,6 +41,10 @@ class Bru {
return this.processEnvVars[key];
}
+ hasEnvVar(key) {
+ return Object.hasOwn(this.envVariables, key);
+ }
+
getEnvVar(key) {
return this._interpolate(this.envVariables[key]);
}
@@ -53,6 +57,10 @@ class Bru {
this.envVariables[key] = value;
}
+ hasVar(key) {
+ return Object.hasOwn(this.collectionVariables, key);
+ }
+
setVar(key, value) {
if (!key) {
throw new Error('Creating a variable without specifying a name is not allowed.');
@@ -79,6 +87,10 @@ class Bru {
return this._interpolate(this.collectionVariables[key]);
}
+ deleteVar(key) {
+ delete this.collectionVariables[key];
+ }
+
setNextRequest(nextRequest) {
this.nextRequest = nextRequest;
}
diff --git a/packages/bruno-js/src/index.js b/packages/bruno-js/src/index.js
index fe6447cfb3..7870fc7225 100644
--- a/packages/bruno-js/src/index.js
+++ b/packages/bruno-js/src/index.js
@@ -1,11 +1,9 @@
-const ScriptRuntime = require('./runtime/script-runtime');
-const TestRuntime = require('./runtime/test-runtime');
const VarsRuntime = require('./runtime/vars-runtime');
const AssertRuntime = require('./runtime/assert-runtime');
+const { runScript } = require('./runtime/vm-helper');
module.exports = {
- ScriptRuntime,
- TestRuntime,
VarsRuntime,
- AssertRuntime
+ AssertRuntime,
+ runScript
};
diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js
deleted file mode 100644
index e1b7270bf6..0000000000
--- a/packages/bruno-js/src/runtime/script-runtime.js
+++ /dev/null
@@ -1,228 +0,0 @@
-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 fs = require('fs');
-const { get } = require('lodash');
-const Bru = require('../bru');
-const BrunoRequest = require('../bruno-request');
-const BrunoResponse = require('../bruno-response');
-const { cleanJson } = require('../utils');
-
-// Inbuilt Library Support
-const ajv = require('ajv');
-const addFormats = require('ajv-formats');
-const atob = require('atob');
-const btoa = require('btoa');
-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 chai = require('chai');
-const CryptoJS = require('crypto-js');
-const NodeVault = require('node-vault');
-
-class ScriptRuntime {
- constructor() {}
-
- // This approach is getting out of hand
- // Need to refactor this to use a single arg (object) instead of 7
- async runRequestScript(
- script,
- request,
- envVariables,
- collectionVariables,
- collectionPath,
- onConsoleLog,
- processEnvVars,
- scriptingConfig
- ) {
- const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
- const req = new BrunoRequest(request);
- const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
- const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
- const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
- const additionalContextRootsAbsolute = lodash
- .chain(additionalContextRoots)
- .map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr)))
- .value();
-
- const whitelistedModules = {};
-
- for (let module of moduleWhitelist) {
- try {
- whitelistedModules[module] = require(module);
- } catch (e) {
- // Ignore
- console.warn(e);
- }
- }
-
- const context = {
- bru,
- req
- };
-
- if (onConsoleLog && typeof onConsoleLog === 'function') {
- const customLogger = (type) => {
- return (...args) => {
- onConsoleLog(type, cleanJson(args));
- };
- };
- context.console = {
- log: customLogger('log'),
- debug: customLogger('debug'),
- info: customLogger('info'),
- warn: customLogger('warn'),
- error: customLogger('error')
- };
- }
-
- const vm = new NodeVM({
- sandbox: context,
- require: {
- context: 'sandbox',
- external: true,
- root: [collectionPath, ...additionalContextRootsAbsolute],
- mock: {
- // node libs
- path,
- stream,
- util,
- url,
- http,
- https,
- punycode,
- zlib,
- // 3rd party libs
- ajv,
- 'ajv-formats': addFormats,
- atob,
- btoa,
- lodash,
- moment,
- uuid,
- nanoid,
- axios,
- chai,
- 'node-fetch': fetch,
- 'crypto-js': CryptoJS,
- ...whitelistedModules,
- fs: allowScriptFilesystemAccess ? fs : undefined,
- 'node-vault': NodeVault
- }
- }
- });
- const asyncVM = vm.run(`module.exports = async () => { ${script} }`, path.join(collectionPath, 'vm.js'));
- await asyncVM();
- return {
- request,
- envVariables: cleanJson(envVariables),
- collectionVariables: cleanJson(collectionVariables),
- nextRequestName: bru.nextRequest
- };
- }
-
- async runResponseScript(
- script,
- request,
- response,
- envVariables,
- collectionVariables,
- collectionPath,
- onConsoleLog,
- processEnvVars,
- scriptingConfig
- ) {
- const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
- const req = new BrunoRequest(request);
- const res = new BrunoResponse(response);
- const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
- const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
-
- const whitelistedModules = {};
-
- for (let module of moduleWhitelist) {
- try {
- whitelistedModules[module] = require(module);
- } catch (e) {
- // Ignore
- console.warn(e);
- }
- }
-
- const context = {
- bru,
- req,
- res
- };
-
- if (onConsoleLog && typeof onConsoleLog === 'function') {
- const customLogger = (type) => {
- return (...args) => {
- onConsoleLog(type, cleanJson(args));
- };
- };
- context.console = {
- log: customLogger('log'),
- info: customLogger('info'),
- warn: customLogger('warn'),
- error: customLogger('error')
- };
- }
-
- const vm = new NodeVM({
- sandbox: context,
- require: {
- context: 'sandbox',
- external: true,
- root: [collectionPath],
- mock: {
- // node libs
- path,
- stream,
- util,
- url,
- http,
- https,
- punycode,
- zlib,
- // 3rd party libs
- ajv,
- 'ajv-formats': addFormats,
- atob,
- btoa,
- lodash,
- moment,
- uuid,
- nanoid,
- axios,
- 'node-fetch': fetch,
- 'crypto-js': CryptoJS,
- ...whitelistedModules,
- fs: allowScriptFilesystemAccess ? fs : undefined,
- 'node-vault': NodeVault
- }
- }
- });
-
- const asyncVM = vm.run(`module.exports = async () => { ${script} }`, path.join(collectionPath, 'vm.js'));
- await asyncVM();
-
- return {
- response,
- envVariables: cleanJson(envVariables),
- collectionVariables: cleanJson(collectionVariables),
- nextRequestName: bru.nextRequest
- };
- }
-}
-
-module.exports = ScriptRuntime;
diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js
deleted file mode 100644
index e67bb6bbc4..0000000000
--- a/packages/bruno-js/src/runtime/test-runtime.js
+++ /dev/null
@@ -1,154 +0,0 @@
-const { NodeVM } = require('vm2');
-const chai = require('chai');
-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 fs = require('fs');
-const { get } = require('lodash');
-const Bru = require('../bru');
-const BrunoRequest = require('../bruno-request');
-const BrunoResponse = require('../bruno-response');
-const Test = require('../test');
-const TestResults = require('../test-results');
-const { cleanJson } = require('../utils');
-
-// Inbuilt Library Support
-const ajv = require('ajv');
-const addFormats = require('ajv-formats');
-const atob = require('atob');
-const btoa = require('btoa');
-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');
-const NodeVault = require('node-vault');
-
-class TestRuntime {
- constructor() {}
-
- async runTests(
- testsFile,
- request,
- response,
- envVariables,
- collectionVariables,
- collectionPath,
- onConsoleLog,
- processEnvVars,
- scriptingConfig
- ) {
- const bru = new Bru(envVariables, collectionVariables, processEnvVars, collectionPath);
- const req = new BrunoRequest(request);
- const res = new BrunoResponse(response);
- const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
- const moduleWhitelist = get(scriptingConfig, 'moduleWhitelist', []);
- const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
- const additionalContextRootsAbsolute = lodash
- .chain(additionalContextRoots)
- .map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr)))
- .value();
-
- const whitelistedModules = {};
-
- for (let module of moduleWhitelist) {
- try {
- whitelistedModules[module] = require(module);
- } catch (e) {
- // Ignore
- console.warn(e);
- }
- }
-
- const __brunoTestResults = new TestResults();
- const test = Test(__brunoTestResults, chai);
-
- if (!testsFile || !testsFile.length) {
- return {
- request,
- envVariables,
- collectionVariables,
- results: __brunoTestResults.getResults()
- };
- }
-
- const context = {
- test,
- bru,
- req,
- res,
- expect: chai.expect,
- assert: chai.assert,
- __brunoTestResults: __brunoTestResults
- };
-
- if (onConsoleLog && typeof onConsoleLog === 'function') {
- const customLogger = (type) => {
- return (...args) => {
- onConsoleLog(type, cleanJson(args));
- };
- };
- context.console = {
- log: customLogger('log'),
- info: customLogger('info'),
- warn: customLogger('warn'),
- error: customLogger('error')
- };
- }
-
- const vm = new NodeVM({
- sandbox: context,
- require: {
- context: 'sandbox',
- external: true,
- root: [collectionPath, ...additionalContextRootsAbsolute],
- mock: {
- // node libs
- path,
- stream,
- util,
- url,
- http,
- https,
- punycode,
- zlib,
- // 3rd party libs
- ajv,
- 'ajv-formats': addFormats,
- btoa,
- atob,
- lodash,
- moment,
- uuid,
- nanoid,
- axios,
- chai,
- 'node-fetch': fetch,
- 'crypto-js': CryptoJS,
- ...whitelistedModules,
- fs: allowScriptFilesystemAccess ? fs : undefined,
- 'node-vault': NodeVault
- }
- }
- });
-
- const asyncVM = vm.run(`module.exports = async () => { ${testsFile}}`, path.join(collectionPath, 'vm.js'));
- await asyncVM();
-
- return {
- request,
- envVariables: cleanJson(envVariables),
- collectionVariables: cleanJson(collectionVariables),
- results: cleanJson(__brunoTestResults.getResults())
- };
- }
-}
-
-module.exports = TestRuntime;
diff --git a/packages/bruno-js/src/runtime/vm-helper.js b/packages/bruno-js/src/runtime/vm-helper.js
new file mode 100644
index 0000000000..422912cda6
--- /dev/null
+++ b/packages/bruno-js/src/runtime/vm-helper.js
@@ -0,0 +1,251 @@
+const vm = require('node:vm');
+const Bru = require('../bru');
+const BrunoRequest = require('../bruno-request');
+const { get } = require('lodash');
+const lodash = require('lodash');
+const path = require('path');
+const { cleanJson } = require('../utils');
+const chai = require('chai');
+const BrunoResponse = require('../bruno-response');
+const TestResults = require('../test-results');
+const Test = require('../test');
+
+// Save the original require inside an "alias" variable so the "vite-plugin-commonjs" does not complain about the
+// intentional dynamic require
+const dynamicRequire = require;
+
+/**
+ * @param {string} script
+ * @param {object} request
+ * @param {object|null} response
+ * @param {{
+ * envVariables: Record,
+ * collectionVariables: Record,
+ * processEnvVars: Record,
+ * }} variables
+ * @param {boolean} useTests
+ * @param {string} collectionPath
+ * @param {object} scriptingConfig
+ * @param {(type: string, context: any) => void} onConsoleLog
+ *
+ * @returns {Promise<{
+ * collectionVariables: Record,
+ * envVariables: Record,
+ * nextRequestName: string,
+ * results: array|null,
+ * }>}
+ */
+async function runScript(
+ script,
+ request,
+ response,
+ variables,
+ useTests,
+ collectionPath,
+ scriptingConfig,
+ onConsoleLog
+) {
+ const scriptContext = buildScriptContext(
+ request,
+ response,
+ variables,
+ useTests,
+ collectionPath,
+ scriptingConfig,
+ onConsoleLog
+ );
+
+ if (script.trim().length !== 0) {
+ await vm.runInThisContext(`
+ (async ({ require, console, req, res, bru, expect, assert, test }) => {
+ ${script}
+ });
+ `)(scriptContext);
+ }
+
+ return {
+ envVariables: cleanJson(scriptContext.bru.envVariables),
+ collectionVariables: cleanJson(scriptContext.bru.collectionVariables),
+ nextRequestName: scriptContext.bru.nextRequest,
+ results: scriptContext.__brunoTestResults ? cleanJson(scriptContext.__brunoTestResults.getResults()) : null
+ };
+}
+
+/**
+ * @typedef {{
+ * require: (module: string) => (*),
+ * console: {object},
+ * req: {BrunoRequest},
+ * res: {BrunoResponse},
+ * bru: {Bru},
+ * expect: {ExpectStatic},
+ * assert: {AssertStatic},
+ * __brunoTestResults: {object},
+ * test: {Test},
+ * }} ScriptContext
+ *
+ * @param {object} request
+ * @param {object|null} response
+ * @param {{
+ * envVariables: Record,
+ * collectionVariables: Record,
+ * processEnvVars: Record,
+ * }} variables
+ * @param {boolean} useTests
+ * @param {string} collectionPath
+ * @param {object} scriptingConfig
+ * @param {(type: string, context: any) => void} onConsoleLog
+ *
+ * @return {ScriptContext}
+ */
+function buildScriptContext(request, response, variables, useTests, collectionPath, scriptingConfig, onConsoleLog) {
+ const context = {
+ require: createCustomRequire(scriptingConfig, collectionPath),
+ console: createCustomConsole(onConsoleLog),
+ req: new BrunoRequest(request),
+ res: null,
+ bru: new Bru(variables.envVariables, variables.collectionVariables, variables.processEnvVars, collectionPath),
+ expect: null,
+ assert: null,
+ __brunoTestResults: null,
+ test: null
+ };
+
+ if (response) {
+ context.res = new BrunoResponse(response);
+ }
+
+ if (useTests) {
+ Object.assign(context, createTestContext());
+ }
+
+ return context;
+}
+
+const defaultModuleWhiteList = [
+ // Node libs
+ 'path',
+ 'stream',
+ 'util',
+ 'url',
+ 'http',
+ 'https',
+ 'punycode',
+ 'zlib',
+ // Pre-installed 3rd libs
+ 'ajv',
+ 'ajv-formats',
+ 'atob',
+ 'btoa',
+ 'lodash',
+ 'moment',
+ 'uuid',
+ 'nanoid',
+ 'axios',
+ 'chai',
+ 'crypto-js',
+ 'node-vault'
+];
+
+/**
+ * @param {object} scriptingConfig Config from collection's bruno.json
+ * @param {string} collectionPath
+ *
+ * @returns {(module: string) => (*)}
+ */
+function createCustomRequire(scriptingConfig, collectionPath) {
+ const customWhitelistedModules = get(scriptingConfig, 'moduleWhitelist', []);
+
+ const whitelistedModules = [...defaultModuleWhiteList, ...customWhitelistedModules];
+
+ const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
+ if (allowScriptFilesystemAccess) {
+ // TODO: Allow other modules like os, child_process etc too?
+ whitelistedModules.push('fs');
+ }
+
+ const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
+ const additionalContextRootsAbsolute = lodash
+ .chain(additionalContextRoots)
+ .map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr)))
+ .value();
+ additionalContextRootsAbsolute.push(collectionPath);
+
+ return (moduleName) => {
+ // First check If we want to require a native node module or
+ // Remove the "node:" prefix, to make sure "node:fs" and "fs" can be required, and we only need to whitelist one
+ if (whitelistedModules.includes(moduleName.replace(/^node:/, ''))) {
+ try {
+ return dynamicRequire(moduleName);
+ } catch {
+ // This can happen, if it s module installed by the user under additionalContextRoots
+ // So now we check if the user installed it themselves
+ let modulePath;
+ try {
+ modulePath = require.resolve(moduleName, { paths: additionalContextRootsAbsolute });
+ return dynamicRequire(modulePath);
+ } catch (error) {
+ throw new Error(`Could not resolve module "${moduleName}": ${error}
+ This most likely means you did not install the module under "additionalContextRoots" using a package manger like npm.
+
+ These are your current "additionalContextRoots":
+ - ${additionalContextRootsAbsolute.join('- ') || 'No "additionalContextRoots" defined'}
+ `);
+ }
+ }
+ }
+
+ const triedPaths = [];
+ for (const contextRoot of additionalContextRootsAbsolute) {
+ const fullScriptPath = path.join(contextRoot, moduleName);
+ try {
+ return dynamicRequire(fullScriptPath);
+ } catch (error) {
+ triedPaths.push({ fullScriptPath, error });
+ }
+ }
+
+ const triedPathsFormatted = triedPaths.map((i) => `- "${i.fullScriptPath}": ${i.error}\n`);
+ throw new Error(`Failed to require "${moduleName}"!
+
+If you tried to require a internal node module / external package, make sure its whitelisted in the "bruno.json" under "scriptConfig".
+If you wanted to require an external script make sure the path is correct or added to "additionalContextRoots" in your "bruno.json".
+
+${
+ triedPathsFormatted.length === 0
+ ? 'No additional context roots where defined'
+ : 'We searched the following paths for your script:'
+}
+${triedPathsFormatted}`);
+ };
+}
+
+function createCustomConsole(onConsoleLog) {
+ const customLogger = (type) => {
+ return (...args) => {
+ onConsoleLog(type, cleanJson(args));
+ };
+ };
+ return {
+ log: customLogger('log'),
+ info: customLogger('info'),
+ warn: customLogger('warn'),
+ error: customLogger('error')
+ };
+}
+
+function createTestContext() {
+ const __brunoTestResults = new TestResults();
+ const test = Test(__brunoTestResults, chai);
+
+ return {
+ test,
+ __brunoTestResults,
+ expect: chai.expect,
+ assert: chai.assert
+ };
+}
+
+module.exports = {
+ runScript
+};
diff --git a/packages/bruno-js/src/utils.js b/packages/bruno-js/src/utils.js
index e15ec09a7c..7216f8c2e9 100644
--- a/packages/bruno-js/src/utils.js
+++ b/packages/bruno-js/src/utils.js
@@ -125,7 +125,7 @@ const createResponseParser = (response = {}) => {
};
/**
- * Objects that are created inside vm2 execution context result in an serialization error when sent to the renderer process
+ * Objects that are created inside vm execution context result in an serialization error when sent to the renderer process
* Error sending from webFrameMain: Error: Failed to serialize arguments
* at s.send (node:electron/js2c/browser_init:169:631)
* at g.send (node:electron/js2c/browser_init:165:2156)
diff --git a/packages/bruno-js/tsconfig.json b/packages/bruno-js/tsconfig.json
new file mode 100644
index 0000000000..17908a67de
--- /dev/null
+++ b/packages/bruno-js/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "module": "CommonJS"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/bruno-js/vite.config.ts b/packages/bruno-js/vite.config.ts
new file mode 100644
index 0000000000..cc96c5b5f5
--- /dev/null
+++ b/packages/bruno-js/vite.config.ts
@@ -0,0 +1,21 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+import commonjs from 'vite-plugin-commonjs';
+
+export default defineConfig({
+ plugins: [commonjs(), dts()],
+ build: {
+ sourcemap: true,
+ minify: false,
+ lib: {
+ entry: resolve(__dirname, 'src/index.js'),
+ formats: ['cjs', 'es'],
+ fileName: 'index'
+ },
+ rollupOptions: {
+ external: 'node:vm'
+ }
+ },
+ clearScreen: false
+});
diff --git a/packages/bruno-lang/.gitignore b/packages/bruno-lang/.gitignore
index 024348ee56..b3a0259db4 100644
--- a/packages/bruno-lang/.gitignore
+++ b/packages/bruno-lang/.gitignore
@@ -1,6 +1,6 @@
node_modules
web
-out
+dist
pnpm-lock.yaml
package-lock.json
diff --git a/packages/bruno-lang/package.json b/packages/bruno-lang/package.json
index 23f39f9ec7..cbc247d79a 100644
--- a/packages/bruno-lang/package.json
+++ b/packages/bruno-lang/package.json
@@ -2,14 +2,17 @@
"name": "@usebruno/lang",
"version": "0.12.0",
"license": "MIT",
- "main": "src/index.js",
+ "private": true,
"files": [
- "src",
- "v1",
- "v2",
- "package.json"
+ "dist"
],
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "module": "dist/index.mjs",
"scripts": {
+ "build": "vite build",
+ "dev": "vite build --watch",
+ "clean": "rimraf dist",
"test": "jest"
},
"dependencies": {
@@ -17,5 +20,13 @@
"dotenv": "^16.3.1",
"lodash": "^4.17.21",
"ohm-js": "^16.6.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.11.25",
+ "rimraf": "^5.0.5",
+ "typescript": "^5.5",
+ "vite": "^5.1.5",
+ "vite-plugin-dts": "^3.7.3",
+ "vite-plugin-commonjs": "^0.10.1"
}
}
diff --git a/packages/bruno-lang/src/index.js b/packages/bruno-lang/src/index.js
index 55a9569d7f..19ec4542b4 100644
--- a/packages/bruno-lang/src/index.js
+++ b/packages/bruno-lang/src/index.js
@@ -1,11 +1,11 @@
-const bruToJsonV2 = require('../v2/src/bruToJson');
-const jsonToBruV2 = require('../v2/src/jsonToBru');
-const bruToEnvJsonV2 = require('../v2/src/envToJson');
-const envJsonToBruV2 = require('../v2/src/jsonToEnv');
-const dotenvToJson = require('../v2/src/dotenvToJson');
+const bruToJsonV2 = require('./v2/src/bruToJson');
+const jsonToBruV2 = require('./v2/src/jsonToBru.js');
+const bruToEnvJsonV2 = require('./v2/src/envToJson.js');
+const envJsonToBruV2 = require('./v2/src/jsonToEnv.js');
+const dotenvToJson = require('./v2/src/dotenvToJson.js');
-const collectionBruToJson = require('../v2/src/collectionBruToJson');
-const jsonToCollectionBru = require('../v2/src/jsonToCollectionBru');
+const collectionBruToJson = require('./v2/src/collectionBruToJson.js');
+const jsonToCollectionBru = require('./v2/src/jsonToCollectionBru.js');
// Todo: remove V2 suffixes
// Changes will have to be made to the CLI and GUI
diff --git a/packages/bruno-lang/v1/src/body-tag.js b/packages/bruno-lang/src/v1/src/body-tag.js
similarity index 100%
rename from packages/bruno-lang/v1/src/body-tag.js
rename to packages/bruno-lang/src/v1/src/body-tag.js
diff --git a/packages/bruno-lang/v1/src/env-vars-tag.js b/packages/bruno-lang/src/v1/src/env-vars-tag.js
similarity index 100%
rename from packages/bruno-lang/v1/src/env-vars-tag.js
rename to packages/bruno-lang/src/v1/src/env-vars-tag.js
diff --git a/packages/bruno-lang/v1/src/headers-tag.js b/packages/bruno-lang/src/v1/src/headers-tag.js
similarity index 100%
rename from packages/bruno-lang/v1/src/headers-tag.js
rename to packages/bruno-lang/src/v1/src/headers-tag.js
diff --git a/packages/bruno-lang/v1/src/index.js b/packages/bruno-lang/src/v1/src/index.js
similarity index 100%
rename from packages/bruno-lang/v1/src/index.js
rename to packages/bruno-lang/src/v1/src/index.js
diff --git a/packages/bruno-lang/v1/src/inline-tag.js b/packages/bruno-lang/src/v1/src/inline-tag.js
similarity index 100%
rename from packages/bruno-lang/v1/src/inline-tag.js
rename to packages/bruno-lang/src/v1/src/inline-tag.js
diff --git a/packages/bruno-lang/v1/src/key-val-lines.js b/packages/bruno-lang/src/v1/src/key-val-lines.js
similarity index 100%
rename from packages/bruno-lang/v1/src/key-val-lines.js
rename to packages/bruno-lang/src/v1/src/key-val-lines.js
diff --git a/packages/bruno-lang/v1/src/params-tag.js b/packages/bruno-lang/src/v1/src/params-tag.js
similarity index 100%
rename from packages/bruno-lang/v1/src/params-tag.js
rename to packages/bruno-lang/src/v1/src/params-tag.js
diff --git a/packages/bruno-lang/v1/src/script-tag.js b/packages/bruno-lang/src/v1/src/script-tag.js
similarity index 100%
rename from packages/bruno-lang/v1/src/script-tag.js
rename to packages/bruno-lang/src/v1/src/script-tag.js
diff --git a/packages/bruno-lang/v1/src/tests-tag.js b/packages/bruno-lang/src/v1/src/tests-tag.js
similarity index 100%
rename from packages/bruno-lang/v1/src/tests-tag.js
rename to packages/bruno-lang/src/v1/src/tests-tag.js
diff --git a/packages/bruno-lang/v1/src/utils.js b/packages/bruno-lang/src/v1/src/utils.js
similarity index 100%
rename from packages/bruno-lang/v1/src/utils.js
rename to packages/bruno-lang/src/v1/src/utils.js
diff --git a/packages/bruno-lang/v1/tests/body-tag.spec.js b/packages/bruno-lang/src/v1/tests/body-tag.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/body-tag.spec.js
rename to packages/bruno-lang/src/v1/tests/body-tag.spec.js
diff --git a/packages/bruno-lang/v1/tests/bru-to-env-json.spec.js b/packages/bruno-lang/src/v1/tests/bru-to-env-json.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/bru-to-env-json.spec.js
rename to packages/bruno-lang/src/v1/tests/bru-to-env-json.spec.js
diff --git a/packages/bruno-lang/v1/tests/bru-to-json.spec.js b/packages/bruno-lang/src/v1/tests/bru-to-json.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/bru-to-json.spec.js
rename to packages/bruno-lang/src/v1/tests/bru-to-json.spec.js
diff --git a/packages/bruno-lang/v1/tests/env-json-to-bru.spec.js b/packages/bruno-lang/src/v1/tests/env-json-to-bru.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/env-json-to-bru.spec.js
rename to packages/bruno-lang/src/v1/tests/env-json-to-bru.spec.js
diff --git a/packages/bruno-lang/v1/tests/fixtures/env.bru b/packages/bruno-lang/src/v1/tests/fixtures/env.bru
similarity index 100%
rename from packages/bruno-lang/v1/tests/fixtures/env.bru
rename to packages/bruno-lang/src/v1/tests/fixtures/env.bru
diff --git a/packages/bruno-lang/v1/tests/fixtures/request.bru b/packages/bruno-lang/src/v1/tests/fixtures/request.bru
similarity index 100%
rename from packages/bruno-lang/v1/tests/fixtures/request.bru
rename to packages/bruno-lang/src/v1/tests/fixtures/request.bru
diff --git a/packages/bruno-lang/v1/tests/inline-tag.spec.js b/packages/bruno-lang/src/v1/tests/inline-tag.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/inline-tag.spec.js
rename to packages/bruno-lang/src/v1/tests/inline-tag.spec.js
diff --git a/packages/bruno-lang/v1/tests/json-to-bru.spec.js b/packages/bruno-lang/src/v1/tests/json-to-bru.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/json-to-bru.spec.js
rename to packages/bruno-lang/src/v1/tests/json-to-bru.spec.js
diff --git a/packages/bruno-lang/v1/tests/key-val-lines.spec.js b/packages/bruno-lang/src/v1/tests/key-val-lines.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/key-val-lines.spec.js
rename to packages/bruno-lang/src/v1/tests/key-val-lines.spec.js
diff --git a/packages/bruno-lang/v1/tests/script-tag.spec.js b/packages/bruno-lang/src/v1/tests/script-tag.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/script-tag.spec.js
rename to packages/bruno-lang/src/v1/tests/script-tag.spec.js
diff --git a/packages/bruno-lang/v1/tests/tests-tag.spec.js b/packages/bruno-lang/src/v1/tests/tests-tag.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/tests-tag.spec.js
rename to packages/bruno-lang/src/v1/tests/tests-tag.spec.js
diff --git a/packages/bruno-lang/v1/tests/utils.spec.js b/packages/bruno-lang/src/v1/tests/utils.spec.js
similarity index 100%
rename from packages/bruno-lang/v1/tests/utils.spec.js
rename to packages/bruno-lang/src/v1/tests/utils.spec.js
diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/src/v2/src/bruToJson.js
similarity index 100%
rename from packages/bruno-lang/v2/src/bruToJson.js
rename to packages/bruno-lang/src/v2/src/bruToJson.js
diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/src/v2/src/collectionBruToJson.js
similarity index 100%
rename from packages/bruno-lang/v2/src/collectionBruToJson.js
rename to packages/bruno-lang/src/v2/src/collectionBruToJson.js
diff --git a/packages/bruno-lang/v2/src/dotenvToJson.js b/packages/bruno-lang/src/v2/src/dotenvToJson.js
similarity index 100%
rename from packages/bruno-lang/v2/src/dotenvToJson.js
rename to packages/bruno-lang/src/v2/src/dotenvToJson.js
diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/src/v2/src/envToJson.js
similarity index 90%
rename from packages/bruno-lang/v2/src/envToJson.js
rename to packages/bruno-lang/src/v2/src/envToJson.js
index eef4de375d..5eb69e9293 100644
--- a/packages/bruno-lang/v2/src/envToJson.js
+++ b/packages/bruno-lang/src/v2/src/envToJson.js
@@ -2,7 +2,7 @@ const ohm = require('ohm-js');
const _ = require('lodash');
const grammar = ohm.grammar(`Bru {
- BruEnvFile = (vars | secretvars)*
+ BruEnvFile = (meta | vars | secretvars)*
nl = "\\r"? "\\n"
st = " " | "\\t"
@@ -25,6 +25,8 @@ const grammar = ohm.grammar(`Bru {
arrayvalue = arrayvaluechar*
arrayvaluechar = ~(nl | st | "[" | "]" | ",") any
+ meta = "meta" dictionary
+
secretvars = "vars:secret" array
vars = "vars" dictionary
}`);
@@ -74,6 +76,14 @@ const mapArrayListToKeyValPairs = (arrayList = []) => {
});
};
+const mapPairListToKeyValPair = (pairList = []) => {
+ if (!pairList || !pairList.length) {
+ return {};
+ }
+
+ return _.merge({}, ...pairList[0]);
+};
+
const concatArrays = (objValue, srcValue) => {
if (_.isArray(objValue) && _.isArray(srcValue)) {
return objValue.concat(srcValue);
@@ -134,6 +144,13 @@ const sem = grammar.createSemantics().addAttribute('ast', {
_iter(...elements) {
return elements.map((e) => e.ast);
},
+ meta(_1, dictionary) {
+ let meta = mapPairListToKeyValPair(dictionary.ast);
+
+ return {
+ name: meta.name
+ };
+ },
vars(_1, dictionary) {
const vars = mapPairListToKeyValPairs(dictionary.ast);
_.each(vars, (v) => {
diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/src/v2/src/jsonToBru.js
similarity index 100%
rename from packages/bruno-lang/v2/src/jsonToBru.js
rename to packages/bruno-lang/src/v2/src/jsonToBru.js
diff --git a/packages/bruno-lang/v2/src/jsonToCollectionBru.js b/packages/bruno-lang/src/v2/src/jsonToCollectionBru.js
similarity index 100%
rename from packages/bruno-lang/v2/src/jsonToCollectionBru.js
rename to packages/bruno-lang/src/v2/src/jsonToCollectionBru.js
diff --git a/packages/bruno-lang/v2/src/jsonToEnv.js b/packages/bruno-lang/src/v2/src/jsonToEnv.js
similarity index 92%
rename from packages/bruno-lang/v2/src/jsonToEnv.js
rename to packages/bruno-lang/src/v2/src/jsonToEnv.js
index 42d0a4281d..6942d45f96 100644
--- a/packages/bruno-lang/v2/src/jsonToEnv.js
+++ b/packages/bruno-lang/src/v2/src/jsonToEnv.js
@@ -1,6 +1,10 @@
const _ = require('lodash');
const envToJson = (json) => {
+ // const meta = `meta {
+ // name: ${json.name}
+ // }\n\n`;
+
const variables = _.get(json, 'variables', []);
const vars = variables
.filter((variable) => !variable.secret)
diff --git a/packages/bruno-lang/v2/tests/assert.spec.js b/packages/bruno-lang/src/v2/tests/assert.spec.js
similarity index 100%
rename from packages/bruno-lang/v2/tests/assert.spec.js
rename to packages/bruno-lang/src/v2/tests/assert.spec.js
diff --git a/packages/bruno-lang/v2/tests/collection.spec.js b/packages/bruno-lang/src/v2/tests/collection.spec.js
similarity index 100%
rename from packages/bruno-lang/v2/tests/collection.spec.js
rename to packages/bruno-lang/src/v2/tests/collection.spec.js
diff --git a/packages/bruno-lang/v2/tests/defaults.spec.js b/packages/bruno-lang/src/v2/tests/defaults.spec.js
similarity index 100%
rename from packages/bruno-lang/v2/tests/defaults.spec.js
rename to packages/bruno-lang/src/v2/tests/defaults.spec.js
diff --git a/packages/bruno-lang/v2/tests/dictionary.spec.js b/packages/bruno-lang/src/v2/tests/dictionary.spec.js
similarity index 100%
rename from packages/bruno-lang/v2/tests/dictionary.spec.js
rename to packages/bruno-lang/src/v2/tests/dictionary.spec.js
diff --git a/packages/bruno-lang/v2/tests/dotenvToJson.spec.js b/packages/bruno-lang/src/v2/tests/dotenvToJson.spec.js
similarity index 100%
rename from packages/bruno-lang/v2/tests/dotenvToJson.spec.js
rename to packages/bruno-lang/src/v2/tests/dotenvToJson.spec.js
diff --git a/packages/bruno-lang/v2/tests/envToJson.spec.js b/packages/bruno-lang/src/v2/tests/envToJson.spec.js
similarity index 91%
rename from packages/bruno-lang/v2/tests/envToJson.spec.js
rename to packages/bruno-lang/src/v2/tests/envToJson.spec.js
index fbb74f2b95..e3dcebf0dc 100644
--- a/packages/bruno-lang/v2/tests/envToJson.spec.js
+++ b/packages/bruno-lang/src/v2/tests/envToJson.spec.js
@@ -14,8 +14,30 @@ vars {
expect(output).toEqual(expected);
});
+ it('should parse empty vars with meta', () => {
+ const input = `
+meta {
+ name: This is a test
+}
+
+vars {
+}`;
+
+ const output = parser(input);
+ const expected = {
+ variables: [],
+ name: 'This is a test'
+ };
+
+ expect(output).toEqual(expected);
+ });
+
it('should parse single var line', () => {
const input = `
+meta {
+ name: This is a test
+}
+
vars {
url: http://localhost:3000
}`;
@@ -29,7 +51,8 @@ vars {
enabled: true,
secret: false
}
- ]
+ ],
+ name: 'This is a test'
};
expect(output).toEqual(expected);
@@ -37,6 +60,10 @@ vars {
it('should parse multiple var lines', () => {
const input = `
+meta {
+ name: This is a test
+}
+
vars {
url: http://localhost:3000
port: 3000
@@ -45,6 +72,7 @@ vars {
const output = parser(input);
const expected = {
+ name: 'This is a test',
variables: [
{
name: 'url',
@@ -73,6 +101,11 @@ vars {
it('should gracefully handle empty lines and spaces', () => {
const input = `
+meta {
+ name: Yet another test
+ other-thing: ignore me
+}
+
vars {
url: http://localhost:3000
port: 3000
@@ -82,6 +115,7 @@ vars {
const output = parser(input);
const expected = {
+ name: 'Yet another test',
variables: [
{
name: 'url',
diff --git a/packages/bruno-lang/v2/tests/fixtures/collection.bru b/packages/bruno-lang/src/v2/tests/fixtures/collection.bru
similarity index 100%
rename from packages/bruno-lang/v2/tests/fixtures/collection.bru
rename to packages/bruno-lang/src/v2/tests/fixtures/collection.bru
diff --git a/packages/bruno-lang/v2/tests/fixtures/collection.json b/packages/bruno-lang/src/v2/tests/fixtures/collection.json
similarity index 100%
rename from packages/bruno-lang/v2/tests/fixtures/collection.json
rename to packages/bruno-lang/src/v2/tests/fixtures/collection.json
diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/src/v2/tests/fixtures/request.bru
similarity index 100%
rename from packages/bruno-lang/v2/tests/fixtures/request.bru
rename to packages/bruno-lang/src/v2/tests/fixtures/request.bru
diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/src/v2/tests/fixtures/request.json
similarity index 100%
rename from packages/bruno-lang/v2/tests/fixtures/request.json
rename to packages/bruno-lang/src/v2/tests/fixtures/request.json
diff --git a/packages/bruno-lang/v2/tests/index.spec.js b/packages/bruno-lang/src/v2/tests/index.spec.js
similarity index 100%
rename from packages/bruno-lang/v2/tests/index.spec.js
rename to packages/bruno-lang/src/v2/tests/index.spec.js
diff --git a/packages/bruno-lang/v2/tests/jsonToEnv.spec.js b/packages/bruno-lang/src/v2/tests/jsonToEnv.spec.js
similarity index 82%
rename from packages/bruno-lang/v2/tests/jsonToEnv.spec.js
rename to packages/bruno-lang/src/v2/tests/jsonToEnv.spec.js
index 62b7aa2697..9c32cdc112 100644
--- a/packages/bruno-lang/v2/tests/jsonToEnv.spec.js
+++ b/packages/bruno-lang/src/v2/tests/jsonToEnv.spec.js
@@ -3,11 +3,16 @@ const parser = require('../src/jsonToEnv');
describe('env parser', () => {
it('should parse empty vars', () => {
const input = {
- variables: []
+ variables: [],
+ name: 'test'
};
const output = parser(input);
- const expected = `vars {
+ const expected = `meta {
+ name: test
+}
+
+vars {
}
`;
@@ -22,11 +27,16 @@ describe('env parser', () => {
value: 'http://localhost:3000',
enabled: true
}
- ]
+ ],
+ name: 'test'
};
const output = parser(input);
- const expected = `vars {
+ const expected = `meta {
+ name: test
+}
+
+vars {
url: http://localhost:3000
}
`;
@@ -46,10 +56,15 @@ describe('env parser', () => {
value: '3000',
enabled: false
}
- ]
+ ],
+ name: 'test'
};
- const expected = `vars {
+ const expected = `meta {
+ name: test
+}
+
+vars {
url: http://localhost:3000
~port: 3000
}
@@ -72,11 +87,16 @@ describe('env parser', () => {
enabled: true,
secret: true
}
- ]
+ ],
+ name: 'test'
};
const output = parser(input);
- const expected = `vars {
+ const expected = `meta {
+ name: test
+}
+
+vars {
url: http://localhost:3000
}
vars:secret [
@@ -106,11 +126,16 @@ vars:secret [
enabled: false,
secret: true
}
- ]
+ ],
+ name: 'test'
};
const output = parser(input);
- const expected = `vars {
+ const expected = `meta {
+ name: test
+}
+
+vars {
url: http://localhost:3000
}
vars:secret [
@@ -130,11 +155,16 @@ vars:secret [
enabled: true,
secret: true
}
- ]
+ ],
+ name: 'test'
};
const output = parser(input);
- const expected = `vars:secret [
+ const expected = `meta {
+ name: test
+}
+
+vars:secret [
token
]
`;
diff --git a/packages/bruno-lang/v2/tests/script.spec.js b/packages/bruno-lang/src/v2/tests/script.spec.js
similarity index 100%
rename from packages/bruno-lang/v2/tests/script.spec.js
rename to packages/bruno-lang/src/v2/tests/script.spec.js
diff --git a/packages/bruno-lang/tsconfig.json b/packages/bruno-lang/tsconfig.json
new file mode 100644
index 0000000000..17908a67de
--- /dev/null
+++ b/packages/bruno-lang/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "module": "CommonJS"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/bruno-lang/vite.config.ts b/packages/bruno-lang/vite.config.ts
new file mode 100644
index 0000000000..1fb31f3c2d
--- /dev/null
+++ b/packages/bruno-lang/vite.config.ts
@@ -0,0 +1,17 @@
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+import dts from 'vite-plugin-dts';
+import commonjs from 'vite-plugin-commonjs';
+
+export default defineConfig({
+ plugins: [commonjs(), dts()],
+ build: {
+ sourcemap: true,
+ lib: {
+ entry: resolve(__dirname, 'src/index.js'),
+ formats: ['cjs', 'es'],
+ fileName: 'index'
+ }
+ },
+ clearScreen: false
+});
diff --git a/packages/bruno-query/package.json b/packages/bruno-query/package.json
index 140fdeafe7..4379cff6f9 100644
--- a/packages/bruno-query/package.json
+++ b/packages/bruno-query/package.json
@@ -1,33 +1,23 @@
{
"name": "@usebruno/query",
"version": "0.1.0",
- "license" : "MIT",
- "main": "dist/cjs/index.js",
- "module": "dist/esm/index.js",
- "types": "dist/index.d.ts",
+ "license": "MIT",
+ "type": "commonjs",
"files": [
- "dist",
- "src",
- "package.json"
+ "dist"
],
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "private": true,
"scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "check": "tsc --noEmit",
"clean": "rimraf dist",
- "test": "jest",
- "prebuild": "npm run clean",
- "build": "rollup -c",
- "prepack": "npm run test && npm run build"
+ "test": "jest"
},
"devDependencies": {
- "@rollup/plugin-commonjs": "^23.0.2",
- "@rollup/plugin-node-resolve": "^15.0.1",
- "@rollup/plugin-typescript": "^9.0.2",
- "rollup": "3.2.5",
- "rollup-plugin-dts": "^5.0.0",
- "rollup-plugin-peer-deps-external": "^2.2.4",
- "rollup-plugin-terser": "^7.0.2",
- "typescript": "^4.8.4"
- },
- "overrides": {
- "rollup": "3.2.5"
+ "rimraf": "^5.0.5",
+ "typescript": "^5.5"
}
-}
\ No newline at end of file
+}
diff --git a/packages/bruno-query/rollup.config.js b/packages/bruno-query/rollup.config.js
deleted file mode 100644
index 67dc6b1137..0000000000
--- a/packages/bruno-query/rollup.config.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const { nodeResolve } = require("@rollup/plugin-node-resolve");
-const commonjs = require("@rollup/plugin-commonjs");
-const typescript = require("@rollup/plugin-typescript");
-const dts = require("rollup-plugin-dts");
-const { terser } = require("rollup-plugin-terser");
-const peerDepsExternal = require('rollup-plugin-peer-deps-external');
-
-const packageJson = require("./package.json");
-
-module.exports = [
- {
- input: "src/index.ts",
- output: [
- {
- file: packageJson.main,
- format: "cjs",
- sourcemap: true,
- },
- {
- file: packageJson.module,
- format: "esm",
- sourcemap: true,
- },
- ],
- plugins: [
- peerDepsExternal(),
- nodeResolve({
- extensions: ['.css']
- }),
- commonjs(),
- typescript({ tsconfig: "./tsconfig.json" }),
- terser()
- ]
- },
- {
- input: "dist/esm/index.d.ts",
- output: [{ file: "dist/index.d.ts", format: "esm" }],
- plugins: [dts.default()],
- }
-];
\ No newline at end of file
diff --git a/packages/bruno-query/tsconfig.json b/packages/bruno-query/tsconfig.json
index 96998f7a16..17908a67de 100644
--- a/packages/bruno-query/tsconfig.json
+++ b/packages/bruno-query/tsconfig.json
@@ -1,23 +1,8 @@
{
+ "extends": "../../tsconfig.base.json",
"compilerOptions": {
- "target": "ES6",
- "esModuleInterop": true,
- "strict": true,
- "skipLibCheck": true,
- "jsx": "react",
- "module": "ESNext",
- "declaration": true,
- "declarationDir": "types",
- "sourceMap": true,
- "outDir": "dist",
- "moduleResolution": "node",
- "emitDeclarationOnly": true,
- "allowSyntheticDefaultImports": true,
- "forceConsistentCasingInFileNames": true
+ "outDir": "./dist",
+ "module": "CommonJS"
},
- "exclude": [
- "dist",
- "node_modules",
- "tests"
- ],
-}
\ No newline at end of file
+ "include": ["src/**/*"]
+}
diff --git a/packages/bruno-schema/.gitignore b/packages/bruno-schema/.gitignore
index 024348ee56..b3a0259db4 100644
--- a/packages/bruno-schema/.gitignore
+++ b/packages/bruno-schema/.gitignore
@@ -1,6 +1,6 @@
node_modules
web
-out
+dist
pnpm-lock.yaml
package-lock.json
diff --git a/packages/bruno-schema/package.json b/packages/bruno-schema/package.json
index 1e91a9a1a7..4758d62e1b 100644
--- a/packages/bruno-schema/package.json
+++ b/packages/bruno-schema/package.json
@@ -2,15 +2,24 @@
"name": "@usebruno/schema",
"version": "0.7.0",
"license": "MIT",
- "main": "src/index.js",
+ "private": true,
"files": [
- "src",
- "package.json"
+ "dist"
],
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
"scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "check": "tsc --noEmit",
+ "clean": "rimraf dist",
"test": "jest"
},
- "peerDependencies": {
- "yup": "^0.32.11"
+ "dependencies": {
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "rimraf": "^5.0.5",
+ "typescript": "^5.5"
}
}
diff --git a/packages/bruno-schema/src/collection/collection.ts b/packages/bruno-schema/src/collection/collection.ts
new file mode 100644
index 0000000000..fad21763f3
--- /dev/null
+++ b/packages/bruno-schema/src/collection/collection.ts
@@ -0,0 +1,72 @@
+import { z } from 'zod';
+import { httpRequestSchema, requestItemSchema } from './request';
+import { environmentSchema } from './environment';
+
+// This has a lot of defaults because the Config may be older and Bruno set any defaults
+export const brunoConfigSchema = z
+ .object({
+ version: z.literal('1'),
+ name: z.string(),
+ type: z.literal('collection'),
+ ignore: z.array(z.string()).default([]),
+ scripts: z
+ .object({
+ moduleWhitelist: z.array(z.string()).default([])
+ })
+ .default({ moduleWhitelist: [] }),
+ proxy: z
+ .object({
+ enabled: z.boolean().default(false),
+ protocol: z.enum(['http', 'https', 'socks4', 'socks5']),
+ hostname: z.string(),
+ port: z.number().or(z.string()),
+ auth: z.object({
+ enabled: z.boolean(),
+ username: z.string(),
+ password: z.string()
+ }),
+ bypassProxy: z.string().default('')
+ })
+ .default({
+ enabled: false,
+ protocol: 'http',
+ hostname: '',
+ port: 0,
+ auth: { enabled: false, username: '', password: '' },
+ bypassProxy: ''
+ }),
+ clientCertificates: z
+ .object({
+ enabled: z.boolean(),
+ certs: z.array(z.unknown())
+ })
+ .default({ enabled: false, certs: [] }),
+ presets: z
+ .object({
+ requestType: z.enum(['graphql', 'http']).default('http'),
+ requestUrl: z.string().default('')
+ })
+ .default({ requestType: 'http', requestUrl: '' })
+ })
+ .passthrough();
+export type BrunoConfigSchema = z.infer;
+
+export const collectionSchema = z.object({
+ version: z.literal('1'),
+ uid: z.string(),
+ name: z.string().min(1),
+ items: z.array(requestItemSchema),
+ activeEnvironmentUid: z.string().uuid().nullable(),
+ environments: z.array(environmentSchema),
+ pathname: z.string(),
+ collectionVariables: z.record(z.unknown()),
+ processEnvVariables: z.record(z.unknown()).optional(),
+ root: z
+ .object({
+ request: httpRequestSchema.optional()
+ })
+ .default({}),
+ brunoConfig: brunoConfigSchema,
+ collapsed: z.boolean().default(true)
+});
+export type CollectionSchema = z.infer;
diff --git a/packages/bruno-schema/src/collection/environment.ts b/packages/bruno-schema/src/collection/environment.ts
new file mode 100644
index 0000000000..99066255c6
--- /dev/null
+++ b/packages/bruno-schema/src/collection/environment.ts
@@ -0,0 +1,18 @@
+import { z } from 'zod';
+
+export const environmentVariableSchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ value: z.string(),
+ type: z.enum(['text']),
+ enabled: z.boolean(),
+ secret: z.boolean()
+});
+export type EnvironmentVariableSchema = z.infer;
+
+export const environmentSchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ variables: z.array(environmentVariableSchema)
+});
+export type EnvironmentSchema = z.infer;
diff --git a/packages/bruno-schema/src/collection/request.ts b/packages/bruno-schema/src/collection/request.ts
new file mode 100644
index 0000000000..7df862558f
--- /dev/null
+++ b/packages/bruno-schema/src/collection/request.ts
@@ -0,0 +1,89 @@
+import { z } from 'zod';
+import { requestAuthSchema } from './requestAuth';
+import { requestBodySchema } from './requestBody';
+
+// TODO: Remove some defaults
+
+export const headerSchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ value: z.string(),
+ description: z.string().default(''),
+ enabled: z.boolean()
+});
+export type HeaderSchema = z.infer;
+
+export const paramSchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ value: z.string(),
+ description: z.string().default(''),
+ type: z.enum(['query', 'path']),
+ enabled: z.boolean()
+});
+export type ParamSchema = z.infer;
+
+export const requestVarSchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ value: z.string(),
+ description: z.string().default(''),
+ enabled: z.boolean()
+});
+export type RequestVarSchema = z.infer;
+
+export const assertionSchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ value: z.string(),
+ description: z.string().default(''),
+ enabled: z.boolean()
+});
+export type AssertionSchema = z.infer;
+
+export const httpRequestSchema = z.object({
+ url: z.string().min(1),
+ method: z
+ .string()
+ .min(1)
+ .regex(/^[a-zA-Z]+$/)
+ .transform((base) => base.toUpperCase()),
+ headers: z.array(headerSchema),
+ params: z.array(paramSchema),
+ body: requestBodySchema,
+ auth: requestAuthSchema,
+
+ script: z.object({
+ req: z.string().default(''),
+ res: z.string().default('')
+ }),
+ vars: z.object({
+ req: z.array(requestVarSchema).default([]),
+ res: z.array(requestVarSchema).default([])
+ }),
+ assertions: z.array(assertionSchema),
+ tests: z.string(),
+ docs: z.string()
+});
+export type HttpRequestSchema = z.infer;
+
+const baseRequestItemSchema = z.object({
+ uid: z.string(),
+ type: z.enum(['http-request', 'graphql-request', 'folder', 'js']),
+ seq: z.number().min(1),
+ name: z.string().min(1),
+ request: httpRequestSchema,
+ fileContent: z.string().optional(),
+ filename: z.string().optional(),
+ pathname: z.string().optional(),
+ collapsed: z.boolean().default(true),
+ requestState: z.enum(['queued', 'sending', 'received']).optional()
+});
+export type RequestItemSchema = z.infer & {
+ items?: RequestItemSchema[];
+ draft?: RequestItemSchema;
+};
+export const requestItemSchema = baseRequestItemSchema.extend({
+ items: z.lazy(() => requestItemSchema.array()).optional(),
+ draft: z.lazy(() => requestItemSchema).optional()
+}) as z.ZodType;
diff --git a/packages/bruno-schema/src/collection/requestAuth.ts b/packages/bruno-schema/src/collection/requestAuth.ts
new file mode 100644
index 0000000000..c7988e0902
--- /dev/null
+++ b/packages/bruno-schema/src/collection/requestAuth.ts
@@ -0,0 +1,92 @@
+import { z } from 'zod';
+
+export const awsV4AuthSchema = z.object({
+ accessKeyId: z.string(),
+ secretAccessKey: z.string(),
+ sessionToken: z.string(),
+ service: z.string(),
+ region: z.string(),
+ profileName: z.string()
+});
+export type AwsV4AuthSchema = z.infer;
+
+export const basicAuthSchema = z.object({
+ username: z.string(),
+ password: z.string()
+});
+export type BasicAuthSchema = z.infer;
+
+export const bearerAuthSchema = z.object({
+ token: z.string()
+});
+export type BearerAuthSchema = z.infer;
+
+export const digestAuthSchema = z.object({
+ username: z.string(),
+ password: z.string()
+});
+export type DigestAuthSchema = z.infer;
+
+export const oauth2AuthSchema = z.discriminatedUnion('grantType', [
+ z.object({
+ grantType: z.literal('password'),
+ username: z.string(),
+ password: z.string(),
+ accessTokenUrl: z.string(),
+ clientId: z.string(),
+ clientSecret: z.string(),
+ scope: z.string()
+ }),
+ z.object({
+ grantType: z.literal('client_credentials'),
+ username: z.string(),
+ password: z.string(),
+ accessTokenUrl: z.string(),
+ clientId: z.string(),
+ clientSecret: z.string(),
+ scope: z.string()
+ }),
+ z.object({
+ grantType: z.literal('authorization_code'),
+ callbackUrl: z.string(),
+ authorizationUrl: z.string(),
+ accessTokenUrl: z.string(),
+ clientId: z.string(),
+ clientSecret: z.string(),
+ scope: z.string(),
+ state: z.string(),
+ pkce: z.boolean()
+ })
+]);
+
+export const requestAuthSchema = z
+ .discriminatedUnion('mode', [
+ z.object({
+ mode: z.literal('inherit')
+ }),
+ z.object({
+ mode: z.literal('none')
+ }),
+ z.object({
+ mode: z.literal('awsv4'),
+ awsv4: awsV4AuthSchema
+ }),
+ z.object({
+ mode: z.literal('basic'),
+ basic: basicAuthSchema
+ }),
+ z.object({
+ mode: z.literal('bearer'),
+ bearer: bearerAuthSchema
+ }),
+ z.object({
+ mode: z.literal('digest'),
+ digest: digestAuthSchema
+ }),
+ z.object({
+ mode: z.literal('oauth2'),
+ oauth2: oauth2AuthSchema
+ })
+ ])
+ .default({ mode: 'inherit' });
+export type RequestAuthSchema = z.infer;
diff --git a/packages/bruno-schema/src/collection/requestBody.ts b/packages/bruno-schema/src/collection/requestBody.ts
new file mode 100644
index 0000000000..6a989ea85a
--- /dev/null
+++ b/packages/bruno-schema/src/collection/requestBody.ts
@@ -0,0 +1,85 @@
+import { z } from 'zod';
+
+export const formUrlEncodedBodySchema = z.object({
+ uid: z.string(),
+ name: z.string(),
+ value: z.string(),
+ description: z.string().default(''),
+ enabled: z.boolean()
+});
+export type FormUrlEncodedBodySchema = z.infer;
+
+export const multipartFormBodySchema = z.discriminatedUnion('type', [
+ z.object({
+ uid: z.string(),
+ type: z.literal('text'),
+ value: z.string(),
+ description: z.string().default(''),
+ enabled: z.boolean()
+ }),
+ z.object({
+ uid: z.string(),
+ type: z.literal('file'),
+ value: z.array(z.string()),
+ description: z.string().default(''),
+ enabled: z.boolean()
+ })
+]);
+export type MultipartFormBodySchema = z.infer;
+
+export const graphqlBodySchema = z.object({
+ query: z.string(),
+ variables: z.string()
+});
+export type GraphqlBodySchema = z.infer;
+
+export const requestBodySchema = z.discriminatedUnion('mode', [
+ z
+ .object({
+ mode: z.literal('none')
+ })
+ .passthrough(),
+ z
+ .object({
+ mode: z.literal('json'),
+ json: z.string()
+ })
+ .passthrough(),
+ z
+ .object({
+ mode: z.literal('text'),
+ text: z.string()
+ })
+ .passthrough(),
+ z
+ .object({
+ mode: z.literal('xml'),
+ xml: z.string()
+ })
+ .passthrough(),
+ z
+ .object({
+ mode: z.literal('formUrlEncoded'),
+ formUrlEncoded: z.array(formUrlEncodedBodySchema)
+ })
+ .passthrough(),
+ z
+ .object({
+ mode: z.literal('multipartForm'),
+ multipartForm: z.array(multipartFormBodySchema)
+ })
+ .passthrough(),
+ z
+ .object({
+ mode: z.literal('graphql'),
+ graphql: graphqlBodySchema
+ })
+ .passthrough(),
+ z
+ .object({
+ mode: z.literal('sparql'),
+ sparql: z.string()
+ })
+ .passthrough()
+]);
+export type RequestBodySchema = z.infer;
diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js
deleted file mode 100644
index cc93ed671d..0000000000
--- a/packages/bruno-schema/src/collections/index.js
+++ /dev/null
@@ -1,284 +0,0 @@
-const Yup = require('yup');
-const { uidSchema } = require('../common');
-
-const environmentVariablesSchema = Yup.object({
- uid: uidSchema,
- name: Yup.string().nullable(),
- value: Yup.string().nullable(),
- type: Yup.string().oneOf(['text']).required('type is required'),
- enabled: Yup.boolean().defined(),
- secret: Yup.boolean()
-})
- .noUnknown(true)
- .strict();
-
-const environmentSchema = Yup.object({
- uid: uidSchema,
- name: Yup.string().min(1).required('name is required'),
- variables: Yup.array().of(environmentVariablesSchema).required('variables are required')
-})
- .noUnknown(true)
- .strict();
-
-const environmentsSchema = Yup.array().of(environmentSchema);
-
-const keyValueSchema = Yup.object({
- uid: uidSchema,
- name: Yup.string().nullable(),
- value: Yup.string().nullable(),
- description: Yup.string().nullable(),
- enabled: Yup.boolean()
-})
- .noUnknown(true)
- .strict();
-
-const varsSchema = Yup.object({
- uid: uidSchema,
- name: Yup.string().nullable(),
- value: Yup.string().nullable(),
- description: Yup.string().nullable(),
- enabled: Yup.boolean(),
-
- // todo
- // anoop(4 feb 2023) - nobody uses this, and it needs to be removed
- local: Yup.boolean()
-})
- .noUnknown(true)
- .strict();
-
-const requestUrlSchema = Yup.string().min(0).defined();
-const requestMethodSchema = Yup.string()
- .oneOf(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'])
- .required('method is required');
-
-const graphqlBodySchema = Yup.object({
- query: Yup.string().nullable(),
- variables: Yup.string().nullable()
-})
- .noUnknown(true)
- .strict();
-
-const multipartFormSchema = Yup.object({
- uid: uidSchema,
- type: Yup.string().oneOf(['file', 'text']).required('type is required'),
- name: Yup.string().nullable(),
- value: Yup.mixed().when('type', {
- is: 'file',
- then: Yup.array().of(Yup.string().nullable()).nullable(),
- otherwise: Yup.string().nullable()
- }),
- description: Yup.string().nullable(),
- enabled: Yup.boolean()
-})
- .noUnknown(true)
- .strict();
-
-const requestBodySchema = Yup.object({
- mode: Yup.string()
- .oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql'])
- .required('mode is required'),
- json: Yup.string().nullable(),
- text: Yup.string().nullable(),
- xml: Yup.string().nullable(),
- sparql: Yup.string().nullable(),
- formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
- multipartForm: Yup.array().of(multipartFormSchema).nullable(),
- graphql: graphqlBodySchema.nullable()
-})
- .noUnknown(true)
- .strict();
-
-const authAwsV4Schema = Yup.object({
- accessKeyId: Yup.string().nullable(),
- secretAccessKey: Yup.string().nullable(),
- sessionToken: Yup.string().nullable(),
- service: Yup.string().nullable(),
- region: Yup.string().nullable(),
- profileName: Yup.string().nullable()
-})
- .noUnknown(true)
- .strict();
-
-const authBasicSchema = Yup.object({
- username: Yup.string().nullable(),
- password: Yup.string().nullable()
-})
- .noUnknown(true)
- .strict();
-
-const authBearerSchema = Yup.object({
- token: Yup.string().nullable()
-})
- .noUnknown(true)
- .strict();
-
-const authDigestSchema = Yup.object({
- username: Yup.string().nullable(),
- password: Yup.string().nullable()
-})
- .noUnknown(true)
- .strict();
-
-const oauth2Schema = Yup.object({
- grantType: Yup.string()
- .oneOf(['client_credentials', 'password', 'authorization_code'])
- .required('grantType is required'),
- username: Yup.string().when('grantType', {
- is: (val) => ['client_credentials', 'password'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- password: Yup.string().when('grantType', {
- is: (val) => ['client_credentials', 'password'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- callbackUrl: Yup.string().when('grantType', {
- is: (val) => ['authorization_code'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- authorizationUrl: Yup.string().when('grantType', {
- is: (val) => ['authorization_code'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- accessTokenUrl: Yup.string().when('grantType', {
- is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- clientId: Yup.string().when('grantType', {
- is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- clientSecret: Yup.string().when('grantType', {
- is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- scope: Yup.string().when('grantType', {
- is: (val) => ['client_credentials', 'password', 'authorization_code'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- state: Yup.string().when('grantType', {
- is: (val) => ['authorization_code'].includes(val),
- then: Yup.string().nullable(),
- otherwise: Yup.string().nullable().strip()
- }),
- pkce: Yup.boolean().when('grantType', {
- is: (val) => ['authorization_code'].includes(val),
- then: Yup.boolean().default(false),
- otherwise: Yup.boolean()
- })
-})
- .noUnknown(true)
- .strict();
-
-const authSchema = Yup.object({
- mode: Yup.string()
- .oneOf(['inherit', 'none', 'awsv4', 'basic', 'bearer', 'digest', 'oauth2'])
- .required('mode is required'),
- awsv4: authAwsV4Schema.nullable(),
- basic: authBasicSchema.nullable(),
- bearer: authBearerSchema.nullable(),
- digest: authDigestSchema.nullable(),
- oauth2: oauth2Schema.nullable()
-})
- .noUnknown(true)
- .strict();
-
-const requestParamsSchema = Yup.object({
- uid: uidSchema,
- name: Yup.string().nullable(),
- value: Yup.string().nullable(),
- description: Yup.string().nullable(),
- type: Yup.string().oneOf(['query', 'path']).required('type is required'),
- enabled: Yup.boolean()
-})
- .noUnknown(true)
- .strict();
-
-// Right now, the request schema is very tightly coupled with http request
-// As we introduce more request types in the future, we will improve the definition to support
-// schema structure based on other request type
-const requestSchema = Yup.object({
- url: requestUrlSchema,
- method: requestMethodSchema,
- headers: Yup.array().of(keyValueSchema).required('headers are required'),
- params: Yup.array().of(requestParamsSchema).required('params are required'),
- auth: authSchema,
- body: requestBodySchema,
- script: Yup.object({
- req: Yup.string().nullable(),
- res: Yup.string().nullable()
- })
- .noUnknown(true)
- .strict(),
- vars: Yup.object({
- req: Yup.array().of(varsSchema).nullable(),
- res: Yup.array().of(varsSchema).nullable()
- })
- .noUnknown(true)
- .strict()
- .nullable(),
- assertions: Yup.array().of(keyValueSchema).nullable(),
- tests: Yup.string().nullable(),
- docs: Yup.string().nullable()
-})
- .noUnknown(true)
- .strict();
-
-const itemSchema = Yup.object({
- uid: uidSchema,
- type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js']).required('type is required'),
- seq: Yup.number().min(1),
- name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'),
- request: requestSchema.when('type', {
- is: (type) => ['http-request', 'graphql-request'].includes(type),
- then: (schema) => schema.required('request is required when item-type is request')
- }),
- fileContent: Yup.string().when('type', {
- // If the type is 'js', the fileContent field is expected to be a string.
- // This can include an empty string, indicating that the JS file may not have any content.
- is: 'js',
- then: Yup.string(),
- // For all other types, the fileContent field is not required and can be null.
- otherwise: Yup.string().nullable()
- }),
- items: Yup.lazy(() => Yup.array().of(itemSchema)),
- filename: Yup.string().nullable(),
- pathname: Yup.string().nullable()
-})
- .noUnknown(true)
- .strict();
-
-const collectionSchema = Yup.object({
- version: Yup.string().oneOf(['1']).required('version is required'),
- uid: uidSchema,
- name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'),
- items: Yup.array().of(itemSchema),
- activeEnvironmentUid: Yup.string()
- .length(21, 'activeEnvironmentUid must be 21 characters in length')
- .matches(/^[a-zA-Z0-9]*$/, 'uid must be alphanumeric')
- .nullable(),
- environments: environmentsSchema,
- pathname: Yup.string().nullable(),
- runnerResult: Yup.object({
- items: Yup.array()
- }),
- collectionVariables: Yup.object(),
- brunoConfig: Yup.object()
-})
- .noUnknown(true)
- .strict();
-
-module.exports = {
- requestSchema,
- itemSchema,
- environmentSchema,
- environmentsSchema,
- collectionSchema
-};
diff --git a/packages/bruno-schema/src/collections/index.spec.js b/packages/bruno-schema/src/collections/index.spec.js
deleted file mode 100644
index 16b683d081..0000000000
--- a/packages/bruno-schema/src/collections/index.spec.js
+++ /dev/null
@@ -1,137 +0,0 @@
-const { expect } = require('@jest/globals');
-const { uuid } = require('../utils/testUtils');
-const { collectionSchema } = require('./index');
-
-describe('Collection Schema Validation', () => {
- it('collection schema must validate successfully - simple collection, no items', async () => {
- const collection = {
- version: '1',
- uid: uuid(),
- name: 'My Collection'
- };
-
- const isValid = await collectionSchema.validate(collection);
- expect(isValid).toBeTruthy();
- });
-
- it('collection schema must validate successfully - simple collection, empty items', async () => {
- const collection = {
- version: '1',
- uid: uuid(),
- name: 'My Collection',
- items: []
- };
-
- const isValid = await collectionSchema.validate(collection);
- expect(isValid).toBeTruthy();
- });
-
- it('collection schema must validate successfully - simple collection, just a folder item', async () => {
- const collection = {
- version: '1',
- uid: uuid(),
- name: 'My Collection',
- items: [
- {
- uid: uuid(),
- name: 'A Folder',
- type: 'folder'
- }
- ]
- };
-
- const isValid = await collectionSchema.validate(collection);
- expect(isValid).toBeTruthy();
- });
-
- it('collection schema must validate successfully - simple collection, just a request item', async () => {
- const collection = {
- version: '1',
- uid: uuid(),
- name: 'My Collection',
- items: [
- {
- uid: uuid(),
- name: 'Get Countries',
- type: 'http-request',
- request: {
- url: 'https://restcountries.com/v2/alpha/in',
- method: 'GET',
- headers: [],
- params: [],
- body: {
- mode: 'none'
- }
- }
- }
- ]
- };
-
- const isValid = await collectionSchema.validate(collection);
- expect(isValid).toBeTruthy();
- });
-
- it('collection schema must validate successfully - simple collection, folder inside folder', async () => {
- const collection = {
- version: '1',
- uid: uuid(),
- name: 'My Collection',
- items: [
- {
- uid: uuid(),
- name: 'First Level Folder',
- type: 'folder',
- items: [
- {
- uid: uuid(),
- name: 'Second Level Folder',
- type: 'folder'
- }
- ]
- }
- ]
- };
-
- const isValid = await collectionSchema.validate(collection);
- expect(isValid).toBeTruthy();
- });
-
- it('collection schema must validate successfully - simple collection, [folder] [request + folder]', async () => {
- const collection = {
- version: '1',
- uid: uuid(),
- name: 'My Collection',
- items: [
- {
- uid: uuid(),
- name: 'First Level Folder',
- type: 'folder',
- items: [
- {
- uid: uuid(),
- name: 'Get Countries',
- type: 'http-request',
- request: {
- url: 'https://restcountries.com/v2/alpha/in',
- method: 'GET',
- headers: [],
- params: [],
- body: {
- mode: 'none'
- }
- }
- },
- {
- uid: uuid(),
- name: 'Second Level Folder',
- type: 'folder'
- }
- ]
- }
- ]
- };
-
- const isValid = await collectionSchema.validate(collection);
- expect(isValid).toBeTruthy();
- });
-});
diff --git a/packages/bruno-schema/src/collections/itemSchema.spec.js b/packages/bruno-schema/src/collections/itemSchema.spec.js
deleted file mode 100644
index 8c46bed2c5..0000000000
--- a/packages/bruno-schema/src/collections/itemSchema.spec.js
+++ /dev/null
@@ -1,69 +0,0 @@
-const { expect } = require('@jest/globals');
-const { uuid, validationErrorWithMessages } = require('../utils/testUtils');
-const { itemSchema } = require('./index');
-
-describe('Item Schema Validation', () => {
- it('item schema must validate successfully - simple items', async () => {
- const item = {
- uid: uuid(),
- name: 'A Folder',
- type: 'folder'
- };
-
- const isValid = await itemSchema.validate(item);
- expect(isValid).toBeTruthy();
- });
-
- it('item schema must throw an error if name is missing', async () => {
- const item = {
- uid: uuid(),
- type: 'folder'
- };
-
- return Promise.all([
- expect(itemSchema.validate(item)).rejects.toEqual(validationErrorWithMessages('name is required'))
- ]);
- });
-
- it('item schema must throw an error if name is empty', async () => {
- const item = {
- uid: uuid(),
- name: '',
- type: 'folder'
- };
-
- return Promise.all([
- expect(itemSchema.validate(item)).rejects.toEqual(
- validationErrorWithMessages('name must be at least 1 character')
- )
- ]);
- });
-
- it('item schema must throw an error if request is not present when item-type is http-request', async () => {
- const item = {
- uid: uuid(),
- name: 'Get Users',
- type: 'http-request'
- };
-
- return Promise.all([
- expect(itemSchema.validate(item)).rejects.toEqual(
- validationErrorWithMessages('request is required when item-type is request')
- )
- ]);
- });
-
- it('item schema must throw an error if request is not present when item-type is graphql-request', async () => {
- const item = {
- uid: uuid(),
- name: 'Get Users',
- type: 'graphql-request'
- };
-
- return Promise.all([
- expect(itemSchema.validate(item)).rejects.toEqual(
- validationErrorWithMessages('request is required when item-type is request')
- )
- ]);
- });
-});
diff --git a/packages/bruno-schema/src/collections/requestSchema.spec.js b/packages/bruno-schema/src/collections/requestSchema.spec.js
deleted file mode 100644
index 87399c6909..0000000000
--- a/packages/bruno-schema/src/collections/requestSchema.spec.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const { expect } = require('@jest/globals');
-const { uuid, validationErrorWithMessages } = require('../utils/testUtils');
-const { requestSchema } = require('./index');
-
-describe('Request Schema Validation', () => {
- it('request schema must validate successfully - simple request', async () => {
- const request = {
- url: 'https://restcountries.com/v2/alpha/in',
- method: 'GET',
- headers: [],
- params: [],
- body: {
- mode: 'none'
- }
- };
-
- const isValid = await requestSchema.validate(request);
- expect(isValid).toBeTruthy();
- });
-
- it('request schema must throw an error of method is invalid', async () => {
- const request = {
- url: 'https://restcountries.com/v2/alpha/in',
- method: 'GET-junk',
- headers: [],
- params: [],
- body: {
- mode: 'none'
- }
- };
-
- 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'
- )
- )
- ]);
- });
-});
diff --git a/packages/bruno-schema/src/common/index.js b/packages/bruno-schema/src/common/index.js
deleted file mode 100644
index 9f29df8ff5..0000000000
--- a/packages/bruno-schema/src/common/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-const Yup = require('yup');
-
-const uidSchema = Yup.string()
- .length(21, 'uid must be 21 characters in length')
- .matches(/^[a-zA-Z0-9]*$/, 'uid must be alphanumeric')
- .required('uid is required')
- .strict();
-
-module.exports = {
- uidSchema
-};
diff --git a/packages/bruno-schema/src/index.js b/packages/bruno-schema/src/index.js
deleted file mode 100644
index 7e84192704..0000000000
--- a/packages/bruno-schema/src/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-const { collectionSchema, itemSchema, environmentSchema, environmentsSchema } = require('./collections');
-
-module.exports = {
- itemSchema,
- environmentSchema,
- environmentsSchema,
- collectionSchema
-};
diff --git a/packages/bruno-schema/src/index.ts b/packages/bruno-schema/src/index.ts
new file mode 100644
index 0000000000..282ce3298e
--- /dev/null
+++ b/packages/bruno-schema/src/index.ts
@@ -0,0 +1,10 @@
+export * from './collection/collection';
+export type * from './collection/collection';
+export * from './collection/environment';
+export type * from './collection/environment';
+export * from './collection/request';
+export type * from './collection/request';
+export * from './collection/requestAuth';
+export type * from './collection/requestAuth';
+export * from './collection/requestBody';
+export type * from './collection/requestBody';
diff --git a/packages/bruno-schema/src/utils/testUtils.js b/packages/bruno-schema/src/utils/testUtils.js
deleted file mode 100644
index 1c3e08c7e1..0000000000
--- a/packages/bruno-schema/src/utils/testUtils.js
+++ /dev/null
@@ -1,22 +0,0 @@
-const { customAlphabet } = require('nanoid');
-const { expect } = require('@jest/globals');
-
-// a customized version of nanoid without using _ and -
-const uuid = () => {
- // https://github.com/ai/nanoid/blob/main/url-alphabet/index.js
- const urlAlphabet = 'useandom26T198340PX75pxJACKVERYMINDBUSHWOLFGQZbfghjklqvwyzrict';
- const customNanoId = customAlphabet(urlAlphabet, 21);
-
- return customNanoId();
-};
-
-const validationErrorWithMessages = (...errors) => {
- return expect.objectContaining({
- errors
- });
-};
-
-module.exports = {
- uuid,
- validationErrorWithMessages
-};
diff --git a/packages/bruno-schema/tsconfig.json b/packages/bruno-schema/tsconfig.json
new file mode 100644
index 0000000000..17908a67de
--- /dev/null
+++ b/packages/bruno-schema/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "module": "CommonJS"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000000..97a7f22045
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages:
+ - 'packages/electron'
+ - 'packages/**'
diff --git a/publishing.md b/publishing.md
deleted file mode 100644
index 632fe18cc2..0000000000
--- a/publishing.md
+++ /dev/null
@@ -1,18 +0,0 @@
-**English**
-| [Türkçe](docs/publishing/publishing_tr.md)
-| [Deutsch](docs/publishing/publishing_de.md)
-| [Français](docs/publishing/publishing_fr.md)
-| [Português (BR)](docs/publishing/publishing_pt_br.md)
-| [বাংলা](docs/publishing/publishing_bn.md)
-| [Română](docs/publishing/publishing_ro.md)
-| [Polski](docs/publishing/publishing_pl.md)
-| [简体中文](docs/publishing/publishing_cn.md)
-| [正體中文](docs/publishing/publishing_zhtw.md)
-| [日本語](docs/publishing/publishing_ja.md)
-
-### Publishing Bruno to a new package manager
-
-While our code is open source and available for everyone to use, we kindly request that you reach out to us before considering publication on new package managers. As the creator of Bruno, I hold the trademark `Bruno` for this project and would like to manage its distribution. If you'd like to see Bruno on a new package manager, please raise a GitHub issue.
-
-While the majority of our features are free and open source (which covers REST and GraphQL Apis),
-we strive to strike a harmonious balance between open-source principles and sustainability - https://github.com/usebruno/bruno/discussions/269
diff --git a/readme.md b/readme.md
index c55b2dd7ae..d6d977d0aa 100644
--- a/readme.md
+++ b/readme.md
@@ -1,32 +1,12 @@
-### Bruno - Opensource IDE for exploring and testing APIs.
-
-[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
-[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
-[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
-[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
-[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
-[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
-
-**English**
-| [Українська](docs/readme/readme_ua.md)
-| [Русский](docs/readme/readme_ru.md)
-| [Türkçe](docs/readme/readme_tr.md)
-| [Deutsch](docs/readme/readme_de.md)
-| [Français](docs/readme/readme_fr.md)
-| [Português (BR)](docs/readme/readme_pt_br.md)
-| [한국어](docs/readme/readme_kr.md)
-| [বাংলা](docs/readme/readme_bn.md)
-| [Español](docs/readme/readme_es.md)
-| [Italiano](docs/readme/readme_it.md)
-| [Română](docs/readme/readme_ro.md)
-| [Polski](docs/readme/readme_pl.md)
-| [简体中文](docs/readme/readme_cn.md)
-| [正體中文](docs/readme/readme_zhtw.md)
-| [العربية](docs/readme/readme_ar.md)
-| [日本語](docs/readme/readme_ja.md)
+### Bruno lazer - Opensource IDE for exploring and testing APIs.
+
+[![Commit Activity](https://img.shields.io/github/commit-activity/m/its-treason/bruno)](https://github.com/usebruno/bruno/pulse)
+[![Discord](https://img.shields.io/discord/1256004254309552258)](https://discord.gg/CT9VWUEwTv)
+
+Forked from [usebruno/bruno](https://github.com/usebruno/bruno).
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.
diff --git a/scripts/build-electron.js b/scripts/build-electron.js
deleted file mode 100644
index 9825c3a09b..0000000000
--- a/scripts/build-electron.js
+++ /dev/null
@@ -1,115 +0,0 @@
-const os = require('os');
-const fs = require('fs-extra');
-const util = require('util');
-const spawn = util.promisify(require('child_process').spawn);
-
-async function deleteFileIfExists(filePath) {
- try {
- const exists = await fs.pathExists(filePath);
- if (exists) {
- await fs.remove(filePath);
- console.log(`${filePath} has been successfully deleted.`);
- } else {
- console.log(`${filePath} does not exist.`);
- }
- } catch (err) {
- console.error(`Error while checking the existence of ${filePath}: ${err}`);
- }
-}
-
-async function copyFolderIfExists(srcPath, destPath) {
- try {
- const exists = await fs.pathExists(srcPath);
- if (exists) {
- await fs.copy(srcPath, destPath);
- console.log(`${srcPath} has been successfully copied.`);
- } else {
- console.log(`${srcPath} was not copied as it does not exist.`);
- }
- } catch (err) {
- console.error(`Error while checking the existence of ${srcPath}: ${err}`);
- }
-}
-
-async function removeSourceMapFiles(directory) {
- try {
- const files = await fs.readdir(directory);
- for (const file of files) {
- if (file.endsWith('.map')) {
- const filePath = path.join(directory, file);
- await fs.remove(filePath);
- console.log(`${filePath} has been successfully deleted.`);
- }
- }
- } catch (error) {
- console.error(`Error while deleting .map files: ${error}`);
- }
-}
-
-async function execCommandWithOutput(command) {
- return new Promise(async (resolve, reject) => {
- const childProcess = await spawn(command, {
- stdio: 'inherit',
- shell: true
- });
- childProcess.on('error', (error) => {
- reject(error);
- });
- childProcess.on('exit', (code) => {
- if (code === 0) {
- resolve();
- } else {
- reject(new Error(`Command exited with code ${code}.`));
- }
- });
- });
-}
-
-async function main() {
- try {
- // Remove out directory
- await deleteFileIfExists('packages/bruno-electron/out');
-
- // Remove web directory
- await deleteFileIfExists('packages/bruno-electron/web');
-
- // Create a new web directory
- await fs.ensureDir('packages/bruno-electron/web');
- console.log('The directory has been created successfully!');
-
- // Copy build
- await copyFolderIfExists('packages/bruno-app/out', 'packages/bruno-electron/web');
-
- // Change paths in next
- const files = await fs.readdir('packages/bruno-electron/web');
- for (const file of files) {
- if (file.endsWith('.html')) {
- let content = await fs.readFile(`packages/bruno-electron/web/${file}`, 'utf8');
- content = content.replace(/\/_next\//g, '_next/');
- await fs.writeFile(`packages/bruno-electron/web/${file}`, content);
- }
- }
-
- // Remove sourcemaps
- await removeSourceMapFiles('packages/bruno-electron/web');
-
- // Run npm dist command
- console.log('Building the Electron distribution');
-
- // Determine the OS and set the appropriate argument
- let osArg;
- if (os.platform() === 'win32') {
- osArg = 'win';
- } else if (os.platform() === 'darwin') {
- osArg = 'mac';
- } else {
- osArg = 'linux';
- }
-
- await execCommandWithOutput(`npm run dist:${osArg} --workspace=packages/bruno-electron`);
- } catch (error) {
- console.error('An error occurred:', error);
- }
-}
-
-main();
diff --git a/scripts/build-electron.sh b/scripts/build-electron.sh
deleted file mode 100755
index 7afb3c545e..0000000000
--- a/scripts/build-electron.sh
+++ /dev/null
@@ -1,42 +0,0 @@
-#!/bin/bash
-
-# Remove out directory
-rm -rf packages/bruno-electron/out
-
-# Remove web directory
-rm -rf packages/bruno-electron/web
-
-# Create a new web directory
-mkdir packages/bruno-electron/web
-
-# Copy build
-cp -r packages/bruno-app/out/* packages/bruno-electron/web
-
-
-# Change paths in next
-sed -i'' -e 's@/_next/@_next/@g' packages/bruno-electron/web/**.html
-
-# Remove sourcemaps
-find packages/bruno-electron/web -name '*.map' -type f -delete
-
-if [ "$1" == "snap" ]; then
- echo "Building snap distribution"
- npm run dist:snap --workspace=packages/bruno-electron
-elif [ "$1" == "mac" ]; then
- echo "Building mac distribution"
- npm run dist:mac --workspace=packages/bruno-electron
-elif [ "$1" == "win" ]; then
- echo "Building windows distribution"
- npm run dist:win --workspace=packages/bruno-electron
-elif [ "$1" == "deb" ]; then
- echo "Building debian distribution"
- npm run dist:deb --workspace=packages/bruno-electron
-elif [ "$1" == "rpm" ]; then
- echo "Building rpm distribution"
- npm run dist:rpm --workspace=packages/bruno-electron
-elif [ "$1" == "linux" ]; then
- echo "Building linux distribution"
- npm run dist:linux --workspace=packages/bruno-electron
-else
- echo "Please pass a build distribution type"
-fi
\ No newline at end of file
diff --git a/scripts/make.js b/scripts/make.js
new file mode 100644
index 0000000000..b5705aa514
--- /dev/null
+++ b/scripts/make.js
@@ -0,0 +1,131 @@
+const os = require('os');
+const fs = require('fs-extra');
+const spawn = require('child_process').spawn;
+
+/// Helper function
+const log = (...args) => console.log('-> ', ...args);
+const error = (...args) => console.log('!> ', ...args);
+
+async function deleteFileIfExists(filePath) {
+ try {
+ const exists = await fs.pathExists(filePath);
+ if (exists) {
+ await fs.remove(filePath);
+ log(`${filePath} has been successfully deleted.`);
+ } else {
+ log(`${filePath} does not exist.`);
+ }
+ } catch (err) {
+ error(`Error while checking the existence of ${filePath}: ${err}`);
+ }
+}
+
+async function copyFolderIfExists(srcPath, destPath) {
+ try {
+ const exists = await fs.pathExists(srcPath);
+ if (exists) {
+ await fs.copy(srcPath, destPath);
+ log(`${srcPath} has been successfully copied.`);
+ } else {
+ log(`${srcPath} was not copied as it does not exist.`);
+ }
+ } catch (err) {
+ error(`Error while checking the existence of ${srcPath}: ${err}`);
+ }
+}
+
+/**
+ * @param {String} command
+ * @param {String[]} args
+ * @returns {Promise}
+ */
+async function execCommandWithOutput(command, args) {
+ return new Promise(async (resolve, reject) => {
+ const childProcess = spawn(command, args, {
+ stdio: 'inherit',
+ shell: true
+ });
+ childProcess.on('error', (error) => {
+ reject(error);
+ });
+ childProcess.on('exit', (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`Command exited with code ${code}.`));
+ }
+ });
+ });
+}
+
+// This maps the os to electron-builder
+function determineOs() {
+ const platform = os.platform();
+ switch (platform) {
+ case 'win32':
+ return 'win';
+ case 'linux':
+ return 'linux';
+ case 'darwin':
+ return 'macos';
+ }
+
+ throw new Error(`Could not determine OS for your platform: "${platform}"!`);
+}
+
+// This maps the arch to electron-builder
+function determineArchitecture() {
+ const platform = os.arch();
+ switch (platform) {
+ case 'x64':
+ case 'ia32':
+ case 'arm64':
+ return platform;
+ case 'arm':
+ return 'armv71';
+ }
+
+ throw new Error(`Could not determine architecture for your architecture: "${platform}"!`);
+}
+
+/**
+ * @param {String[]} args
+ * @returns {Promise}
+ */
+async function main(args) {
+ log('Starting Bruno build');
+
+ const os = determineOs();
+ const arch = determineArchitecture();
+ log(`Building for operating system: "${os}" and architecture: "${arch}"`);
+
+ log('Clean up old build artifacts');
+ await execCommandWithOutput('pnpm', ['run', 'clean']);
+
+ log('Building packages');
+ await execCommandWithOutput('pnpm', ['run', 'build']);
+ // Copy the output of bruno-app into electron
+ await fs.ensureDir('packages/bruno-electron/web');
+ await copyFolderIfExists('packages/bruno-app/out', 'packages/bruno-electron/web');
+
+ // Change paths in next
+ const files = await fs.readdir('packages/bruno-electron/web');
+ for (const file of files) {
+ if (file.endsWith('.html')) {
+ let content = await fs.readFile(`packages/bruno-electron/web/${file}`, 'utf8');
+ content = content.replace(/\/_next\//g, '_next/');
+ await fs.writeFile(`packages/bruno-electron/web/${file}`, content);
+ }
+ }
+
+ // Run npm dist command
+ log(`Building the Electron app for: ${os}/${arch}`);
+ await execCommandWithOutput('pnpm', ['run', '--filter', 'bruno-lazer', 'dist', '--', `--${arch}`, `--${os}`]);
+ log('Build complete');
+
+ return 0;
+}
+
+main(process.argv)
+ .then((code) => process.exit(code))
+ .catch((e) => console.error('An error occurred during build', e) && process.exit(1));
diff --git a/tsconfig.base.json b/tsconfig.base.json
new file mode 100644
index 0000000000..8d250bafa2
--- /dev/null
+++ b/tsconfig.base.json
@@ -0,0 +1,22 @@
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "allowJs": true,
+ "strict": true,
+ "sourceMap": true,
+ "declaration": true,
+ "declarationMap": true,
+ "moduleResolution": "node",
+ "importHelpers": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "target": "ES2021",
+ "module": "esnext",
+ "lib": ["es2022"],
+ "skipLibCheck": true,
+ "skipDefaultLibCheck": true,
+ "baseUrl": ".",
+ "noPropertyAccessFromIndexSignature": false
+ },
+ "exclude": ["node_modules"]
+}