Merge branch 'main' of github.com:usebruno/bruno into feature/preview-response-html

This commit is contained in:
Its-treason 2023-10-02 14:34:54 +02:00
commit dce11d1bd5
23 changed files with 503 additions and 65 deletions

View File

@ -30,6 +30,8 @@
"graphiql": "^1.5.9", "graphiql": "^1.5.9",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"graphql-request": "^3.7.0", "graphql-request": "^3.7.0",
"handlebars": "^4.7.8",
"httpsnippet": "^3.0.1",
"idb": "^7.0.0", "idb": "^7.0.0",
"immer": "^9.0.15", "immer": "^9.0.15",
"know-your-http-well": "^0.5.0", "know-your-http-well": "^0.5.0",

View File

@ -119,7 +119,7 @@ export default class CodeEditor extends React.Component {
render() { render() {
return ( return (
<StyledWrapper <StyledWrapper
className="h-full" className="h-full w-full"
aria-label="Code Editor" aria-label="Code Editor"
ref={(node) => { ref={(node) => {
this._node = node; this._node = node;

View File

@ -13,13 +13,11 @@ const Placeholder = () => {
<div className="px-1 py-2">Send Request</div> <div className="px-1 py-2">Send Request</div>
<div className="px-1 py-2">New Request</div> <div className="px-1 py-2">New Request</div>
<div className="px-1 py-2">Edit Environments</div> <div className="px-1 py-2">Edit Environments</div>
<div className="px-1 py-2">Help</div>
</div> </div>
<div className="flex flex-1 flex-col px-1"> <div className="flex flex-1 flex-col px-1">
<div className="px-1 py-2">Cmd + Enter</div> <div className="px-1 py-2">Cmd + Enter</div>
<div className="px-1 py-2">Cmd + B</div> <div className="px-1 py-2">Cmd + B</div>
<div className="px-1 py-2">Cmd + E</div> <div className="px-1 py-2">Cmd + E</div>
<div className="px-1 py-2">Cmd + H</div>
</div> </div>
</div> </div>
</StyledWrapper> </StyledWrapper>

View File

@ -4,15 +4,56 @@ import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions'; import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import classnames from 'classnames'; import classnames from 'classnames';
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { useState } from 'react'; import { useState } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
const QueryResult = ({ item, collection, value, width, disableRunEventListener, mode }) => { const QueryResult = ({ item, collection, data, width, disableRunEventListener, headers }) => {
const { storedTheme } = useTheme(); const { storedTheme } = useTheme();
const [tab, setTab] = useState('raw'); const [tab, setTab] = useState('raw');
const dispatch = useDispatch(); const dispatch = useDispatch();
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType);
const formatResponse = (data, mode) => {
if (!data) {
return '';
}
if (mode.includes('json')) {
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 (['text', 'html'].includes(mode)) {
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data);
}
// final fallback
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data);
};
const value = formatResponse(data, mode);
const onRun = () => { const onRun = () => {
if (disableRunEventListener) { if (disableRunEventListener) {
@ -32,7 +73,7 @@ const QueryResult = ({ item, collection, value, width, disableRunEventListener,
Raw Raw
</div> </div>
)]; )];
if (mode.includes('text/html')) { if (mode.includes('html')) {
tabs.push( tabs.push(
<div className={getTabClassname('preview')} role="tab" onClick={() => setTab('preview')}> <div className={getTabClassname('preview')} role="tab" onClick={() => setTab('preview')}>
Preview Preview
@ -43,7 +84,7 @@ const QueryResult = ({ item, collection, value, width, disableRunEventListener,
const activeResult = useMemo(() => { const activeResult = useMemo(() => {
if (tab === 'preview') { if (tab === 'preview') {
// Add the Base tag to the head so content loads proparly. This also needs the correct CSP settings // Add the Base tag to the head so content loads proparly. This also needs the correct CSP settings
const webViewSrc = value.replace('<head>', `<head><base href="${item.requestSent.url}">`); const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent.url}">`);
return ( return (
<webview <webview
src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`} src={`data:text/html; charset=utf-8,${encodeURIComponent(webViewSrc)}`}
@ -58,7 +99,7 @@ const QueryResult = ({ item, collection, value, width, disableRunEventListener,
collection={collection} collection={collection}
theme={storedTheme} theme={storedTheme}
onRun={onRun} onRun={onRun}
value={value || ''} value={value}
mode={mode} mode={mode}
readOnly readOnly
/> />

View File

@ -2,7 +2,6 @@ import React from 'react';
import find from 'lodash/find'; import find from 'lodash/find';
import classnames from 'classnames'; import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { getContentType, formatResponse } from 'utils/common';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import QueryResult from './QueryResult'; import QueryResult from './QueryResult';
import Overlay from './Overlay'; import Overlay from './Overlay';
@ -41,8 +40,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
item={item} item={item}
collection={collection} collection={collection}
width={rightPaneWidth} width={rightPaneWidth}
value={response.data ? formatResponse(response) : ''} data={response.data}
mode={getContentType(response.headers)} headers={response.headers}
/> />
); );
} }

View File

@ -33,7 +33,8 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
collection={collection} collection={collection}
width={rightPaneWidth} width={rightPaneWidth}
disableRunEventListener={true} disableRunEventListener={true}
value={responseReceived && responseReceived.data ? safeStringifyJSON(responseReceived.data, true) : ''} data={responseReceived.data}
headers={responseReceived.headers}
/> />
); );
} }

View File

@ -0,0 +1,21 @@
import CodeEditor from 'components/CodeEditor/index';
import { HTTPSnippet } from 'httpsnippet';
import { useTheme } from 'providers/Theme/index';
import { buildHarRequest } from 'utils/codegenerator/har';
const CodeView = ({ language, item }) => {
const { storedTheme } = useTheme();
const { target, client, language: lang } = language;
let snippet = '';
try {
snippet = new HTTPSnippet(buildHarRequest(item.request)).convert(target, client);
} catch (e) {
console.error(e);
snippet = 'Error generating code snippet';
}
return <CodeEditor readOnly value={snippet} theme={storedTheme} mode={lang} />;
};
export default CodeView;

View File

@ -0,0 +1,38 @@
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;
}
.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;

View File

@ -0,0 +1,145 @@
import Modal from 'components/Modal/index';
import { useState } from 'react';
import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url/index';
import get from 'lodash/get';
import handlebars from 'handlebars';
import { findEnvironmentInCollection } from 'utils/collections';
const interpolateUrl = ({ url, envVars, collectionVariables, processEnvVars }) => {
if (!url || !url.length || typeof url !== 'string') {
return str;
}
const template = handlebars.compile(url, { noEscape: true });
return template({
...envVars,
...collectionVariables,
process: {
env: {
...processEnvVars
}
}
});
};
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 url = get(item, 'request.url') || '';
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 interpolatedUrl = interpolateUrl({
url,
envVars,
collectionVariables: collection.collectionVariables,
processEnvVars: collection.processEnvVariables
});
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
<StyledWrapper>
<div className="flex w-full">
<div>
<div className="generate-code-sidebar">
{languages &&
languages.length &&
languages.map((language) => (
<div
key={language.name}
className={
language.name === selectedLanguage.name ? 'generate-code-item active' : 'generate-code-item'
}
onClick={() => setSelectedLanguage(language)}
>
<span className="capitalize">{language.name}</span>
</div>
))}
</div>
</div>
<div className="flex-grow p-4">
{isValidUrl(interpolatedUrl) ? (
<CodeView
language={selectedLanguage}
item={{
...item,
request: {
...item.request,
url: interpolatedUrl
}
}}
/>
) : (
<div className="flex flex-col justify-center items-center w-full">
<div className="text-center">
<h1 className="text-2xl font-bold">Invalid URL: {interpolatedUrl}</h1>
<p className="text-gray-500">Please check the URL and try again</p>
</div>
</div>
)}
</div>
</div>
</StyledWrapper>
</Modal>
);
};
export default GenerateCodeItem;

View File

@ -16,6 +16,7 @@ import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem'; import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem'; import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem'; import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs'; import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections'; import { getDefaultRequestPaneTab } from 'utils/collections';
@ -32,6 +33,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false); const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false); const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false); const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
@ -166,6 +168,9 @@ const CollectionItem = ({ item, collection, searchText }) => {
{runCollectionModalOpen && ( {runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} /> <RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)} )}
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
<div className={itemRowClassName} ref={(node) => drag(drop(node))}> <div className={itemRowClassName} ref={(node) => drag(drop(node))}>
<div className="flex items-center h-full w-full"> <div className="flex items-center h-full w-full">
{indents && indents.length {indents && indents.length
@ -264,6 +269,18 @@ const CollectionItem = ({ item, collection, searchText }) => {
Clone Clone
</div> </div>
)} )}
{!isFolder && item.type === 'http-request' && (
<div
className="dropdown-item"
onClick={(e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
setGenerateCodeItemModalOpen(true);
}}
>
Generate Code
</div>
)}
<div <div
className="dropdown-item delete-item" className="dropdown-item delete-item"
onClick={(e) => { onClick={(e) => {

View File

@ -1,6 +1,12 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { IconSearch, IconFolders, IconSortAZ } from '@tabler/icons'; import {
IconSearch,
IconFolders,
IconArrowsSort,
IconSortAscendingLetters,
IconSortDescendingLetters
} from '@tabler/icons';
import Collection from '../Collections/Collection'; import Collection from '../Collections/Collection';
import CreateCollection from '../CreateCollection'; import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -9,20 +15,47 @@ import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend'; import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions'; import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// 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 CollectionsBadge = () => {
const dispatch = useDispatch() const dispatch = useDispatch();
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
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 ( return (
<div className="items-center mt-2 relative"> <div className="items-center mt-2 relative">
<div className='collections-badge flex items-center justify-between px-2' > <div className="collections-badge flex items-center justify-between px-2">
<div className="flex items-center py-1 select-none"> <div className="flex items-center py-1 select-none">
<span className="mr-2"> <span className="mr-2">
<IconFolders size={18} strokeWidth={1.5} /> <IconFolders size={18} strokeWidth={1.5} />
</span> </span>
<span>Collections</span> <span>Collections</span>
</div> </div>
<button onClick={() => dispatch(sortCollections())} > {collections.length >= 1 && (
<IconSortAZ size={18} strokeWidth={1.5} /> <button onClick={() => sortCollectionOrder()}>
{collectionSortOrder == 'default' ? (
<IconArrowsSort size={18} strokeWidth={1.5} />
) : collectionSortOrder == 'alphabetical' ? (
<IconSortAscendingLetters size={18} strokeWidth={1.5} />
) : (
<IconSortDescendingLetters size={18} strokeWidth={1.5} />
)}
</button> </button>
)}
</div> </div>
</div> </div>
); );

View File

@ -116,7 +116,7 @@ const Sidebar = () => {
</GitHubButton> </GitHubButton>
)} )}
</div> </div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v0.16.5</div> <div className="flex flex-grow items-center justify-end text-xs mr-2">v0.16.6</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,6 @@ import SaveRequest from 'components/RequestPane/SaveRequest';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings'; import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError'; import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest'; import NewRequest from 'components/Sidebar/NewRequest';
import BrunoSupport from 'components/BrunoSupport';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections'; import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs'; import { closeTabs } from 'providers/ReduxStore/slices/tabs';
@ -22,7 +21,6 @@ export const HotkeysProvider = (props) => {
const [showSaveRequestModal, setShowSaveRequestModal] = useState(false); const [showSaveRequestModal, setShowSaveRequestModal] = useState(false);
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false); const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false); const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showBrunoSupportModal, setShowBrunoSupportModal] = useState(false);
const getCurrentCollectionItems = () => { const getCurrentCollectionItems = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid); const activeTab = find(tabs, (t) => t.uid === activeTabUid);
@ -133,18 +131,6 @@ export const HotkeysProvider = (props) => {
}; };
}, [activeTabUid, tabs, collections, setShowNewRequestModal]); }, [activeTabUid, tabs, collections, setShowNewRequestModal]);
// help (ctrl/cmd + h)
useEffect(() => {
Mousetrap.bind(['command+h', 'ctrl+h'], (e) => {
setShowBrunoSupportModal(true);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind(['command+h', 'ctrl+h']);
};
}, [setShowNewRequestModal]);
// close tab hotkey // close tab hotkey
useEffect(() => { useEffect(() => {
Mousetrap.bind(['command+w', 'ctrl+w'], (e) => { Mousetrap.bind(['command+w', 'ctrl+w'], (e) => {
@ -164,7 +150,6 @@ export const HotkeysProvider = (props) => {
return ( return (
<HotkeysContext.Provider {...props} value="hotkey"> <HotkeysContext.Provider {...props} value="hotkey">
{showBrunoSupportModal && <BrunoSupport onClose={() => setShowBrunoSupportModal(false)} />}
{showSaveRequestModal && ( {showSaveRequestModal && (
<SaveRequest items={getCurrentCollectionItems()} onClose={() => setShowSaveRequestModal(false)} /> <SaveRequest items={getCurrentCollectionItems()} onClose={() => setShowSaveRequestModal(false)} />
)} )}

View File

@ -22,7 +22,7 @@ import {
} from 'utils/collections'; } from 'utils/collections';
import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema'; import { collectionSchema, itemSchema, environmentSchema, environmentsSchema } from '@usebruno/schema';
import { waitForNextTick } from 'utils/common'; import { waitForNextTick } from 'utils/common';
import { getDirectoryName } from 'utils/common/platform'; import { getDirectoryName, isWindowsOS } from 'utils/common/platform';
import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network'; import { sendNetworkRequest, cancelNetworkRequest } from 'utils/network';
import { import {
@ -34,12 +34,12 @@ import {
renameItem as _renameItem, renameItem as _renameItem,
cloneItem as _cloneItem, cloneItem as _cloneItem,
deleteItem as _deleteItem, deleteItem as _deleteItem,
sortCollections as _sortCollections,
saveRequest as _saveRequest, saveRequest as _saveRequest,
selectEnvironment as _selectEnvironment, selectEnvironment as _selectEnvironment,
createCollection as _createCollection, createCollection as _createCollection,
renameCollection as _renameCollection, renameCollection as _renameCollection,
removeCollection as _removeCollection, removeCollection as _removeCollection,
sortCollections as _sortCollections,
collectionAddEnvFileEvent as _collectionAddEnvFileEvent collectionAddEnvFileEvent as _collectionAddEnvFileEvent
} from './index'; } from './index';
@ -146,6 +146,11 @@ export const cancelRequest = (cancelTokenUid, item, collection) => (dispatch) =>
.catch((err) => console.log(err)); .catch((err) => console.log(err));
}; };
// todo: this can be directly put inside the collections/index.js file
// the coding convention is to put only actions that need ipc in this file
export const sortCollections = (order) => (dispatch) => {
dispatch(_sortCollections(order));
};
export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => { export const runCollectionFolder = (collectionUid, folderUid, recursive) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);
@ -263,7 +268,19 @@ export const renameItem = (newName, itemUid, collectionUid) => (dispatch, getSta
} }
const { ipcRenderer } = window; const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-item', item.pathname, newPathname, newName).then(resolve).catch(reject); ipcRenderer
.invoke('renderer:rename-item', item.pathname, newPathname, newName)
.then(() => {
// In case of Mac and Linux, we get the unlinkDir and addDir IPC events from electron which takes care of updating the state
// But in windows we don't get those events, so we need to update the state manually
// This looks like an issue in our watcher library chokidar
// GH: https://github.com/usebruno/bruno/issues/251
if (isWindowsOS()) {
dispatch(_renameItem({ newName, itemUid, collectionUid }));
}
resolve();
})
.catch(reject);
}); });
}; };
@ -347,16 +364,22 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
ipcRenderer ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type) .invoke('renderer:delete-item', item.pathname, item.type)
.then(() => resolve()) .then(() => {
// In case of Mac and Linux, we get the unlinkDir IPC event from electron which takes care of updating the state
// But in windows we don't get those events, so we need to update the state manually
// This looks like an issue in our watcher library chokidar
// GH: https://github.com/usebruno/bruno/issues/265
if (isWindowsOS()) {
dispatch(_deleteItem({ itemUid, collectionUid }));
}
resolve();
})
.catch((error) => reject(error)); .catch((error) => reject(error));
} }
return; return;
}); });
}; };
export const sortCollections = () => (dispatch) => {
dispatch(_sortCollections())
}
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => { export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
const state = getState(); const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid); const collection = findCollectionByUid(state.collections.collections, collectionUid);

View File

@ -28,7 +28,8 @@ import { getSubdirectoriesFromRoot, getDirectoryName } from 'utils/common/platfo
const PATH_SEPARATOR = path.sep; const PATH_SEPARATOR = path.sep;
const initialState = { const initialState = {
collections: [] collections: [],
collectionSortOrder: 'default'
}; };
export const collectionsSlice = createSlice({ export const collectionsSlice = createSlice({
@ -38,12 +39,12 @@ export const collectionsSlice = createSlice({
createCollection: (state, action) => { createCollection: (state, action) => {
const collectionUids = map(state.collections, (c) => c.uid); const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload; const collection = action.payload;
// last action is used to track the last action performed on the collection // last action is used to track the last action performed on the collection
// this is optional // this is optional
// this is used in scenarios where we want to know the last action performed on the collection // this is used in scenarios where we want to know the last action performed on the collection
// and take some extra action based on that // and take some extra action based on that
// for example, when a env is created, we want to auto select it the env modal // for example, when a env is created, we want to auto select it the env modal
collection.importedAt = new Date().getTime();
collection.lastAction = null; collection.lastAction = null;
collapseCollection(collection); collapseCollection(collection);
@ -70,8 +71,19 @@ export const collectionsSlice = createSlice({
removeCollection: (state, action) => { removeCollection: (state, action) => {
state.collections = filter(state.collections, (c) => c.uid !== action.payload.collectionUid); state.collections = filter(state.collections, (c) => c.uid !== action.payload.collectionUid);
}, },
sortCollections: (state) => { sortCollections: (state, action) => {
state.collections = state.collections.sort((a, b) => a.name.localeCompare(b.name)) state.collectionSortOrder = action.payload.order;
switch (action.payload.order) {
case 'default':
state.collections = state.collections.sort((a, b) => a.importedAt - b.importedAt);
break;
case 'alphabetical':
state.collections = state.collections.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'reverseAlphabetical':
state.collections = state.collections.sort((a, b) => b.name.localeCompare(a.name));
break;
}
}, },
updateLastAction: (state, action) => { updateLastAction: (state, action) => {
const { collectionUid, lastAction } = action.payload; const { collectionUid, lastAction } = action.payload;

View File

@ -0,0 +1,71 @@
const createContentType = (mode) => {
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 'application/json';
}
};
const createHeaders = (headers, mode) => {
const contentType = createContentType(mode);
const headersArray = headers
.filter((header) => header.enabled)
.map((header) => {
return {
name: header.name,
value: header.value
};
});
const headerNames = headersArray.map((header) => header.name);
if (!headerNames.includes('Content-Type')) {
return [...headersArray, { name: 'Content-Type', value: contentType }];
}
return headersArray;
};
const createQuery = (queryParams = []) => {
return queryParams.map((param) => {
return {
name: param.name,
value: param.value
};
});
};
const createPostData = (body) => {
const contentType = createContentType(body.mode);
if (body.mode === 'formUrlEncoded' || body.mode === 'multipartForm') {
return {
mimeType: contentType,
params: body[body.mode]
.filter((param) => param.enabled)
.map((param) => ({ name: param.name, value: param.value }))
};
} else {
return {
mimeType: contentType,
text: body[body.mode]
};
}
};
export const buildHarRequest = (request) => {
return {
method: request.method,
url: request.url,
httpVersion: 'HTTP/1.1',
cookies: [],
headers: createHeaders(request.headers, request.body.mode),
queryString: createQuery(request.params),
postData: createPostData(request.body),
headersSize: 0,
bodySize: 0
};
};

View File

@ -129,9 +129,11 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid); let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) { if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid); draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename); draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else { } else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid); collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
} }
@ -143,10 +145,12 @@ export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid); let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) { if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid); let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem); targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename); draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else { } else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid); let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem); collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename); draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);

View File

@ -42,3 +42,25 @@ export const defineCodeMirrorBrunoVariablesMode = (variables, mode) => {
return CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay); return CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
}); });
}; };
export const getCodeMirrorModeBasedOnContentType = (contentType) => {
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 (mimeType.includes('yaml')) {
return 'application/yaml';
} else {
return 'application/text';
}
};

View File

@ -51,6 +51,17 @@ export const safeStringifyJSON = (obj, indent = false) => {
} }
}; };
export const safeParseXML = (str) => {
if (!str || !str.length || typeof str !== 'string') {
return str;
}
try {
return xmlFormat(str);
} catch (e) {
return str;
}
};
// Remove any characters that are not alphanumeric, spaces, hyphens, or underscores // Remove any characters that are not alphanumeric, spaces, hyphens, or underscores
export const normalizeFileName = (name) => { export const normalizeFileName = (name) => {
if (!name) { if (!name) {
@ -80,16 +91,6 @@ export const getContentType = (headers) => {
return contentType[0]; return contentType[0];
} }
} }
return ''; return '';
}; };
export const formatResponse = (response) => {
let type = getContentType(response.headers);
if (type.includes('json')) {
return safeStringifyJSON(response.data, true);
}
if (type.includes('xml')) {
return xmlFormat(response.data, { collapseContent: true });
}
return response.data;
};

View File

@ -1,6 +1,7 @@
import trim from 'lodash/trim'; import trim from 'lodash/trim';
import path from 'path'; import path from 'path';
import slash from './slash'; import slash from './slash';
import platform from 'platform';
export const isElectron = () => { export const isElectron = () => {
if (!window) { if (!window) {
@ -33,3 +34,10 @@ export const getDirectoryName = (pathname) => {
return path.dirname(pathname); return path.dirname(pathname);
}; };
export const isWindowsOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
return osFamily.includes('windows');
};

View File

@ -53,3 +53,12 @@ export const splitOnFirst = (str, char) => {
return [str.slice(0, index), str.slice(index + 1)]; return [str.slice(0, index), str.slice(index + 1)];
}; };
export const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch (err) {
return false;
}
};

View File

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

View File

@ -10,36 +10,42 @@
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com) [![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) [![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there. Bruno is a new and innovative API client, aimed at revolutionizing the status quo represented by Postman and similar tools out there.
Bruno stores your collections directly in a folder on your filesystem. We use a plain text markup language, Bru, to save information about API requests. Bruno stores your collections directly in a folder on your filesystem. We use a plain text markup language, Bru, to save information about API requests.
You can use git or any version control of your choice to collaborate over your API collections. You can use git or any version control of your choice to collaborate over your API collections.
Bruno is offline-only. There are no plans to add cloud-sync to Bruno, ever. We value your data privacy and believe it should stay on your device. Read our long-term vision [here](https://github.com/usebruno/bruno/discussions/269)
![bruno](assets/images/landing-2.png) <br /><br /> ![bruno](assets/images/landing-2.png) <br /><br />
### Run across multiple platforms 🖥️ ### Run across multiple platforms 🖥️
![bruno](assets/images/run-anywhere.png) <br /><br /> ![bruno](assets/images/run-anywhere.png) <br /><br />
### Collaborate via Git 👩‍💻🧑‍💻 ### Collaborate via Git 👩‍💻🧑‍💻
Or any version control system of your choice Or any version control system of your choice
![bruno](assets/images/version-control.png) <br /><br /> ![bruno](assets/images/version-control.png) <br /><br />
### Website 📄 ### Website 📄
Please visit [here](https://www.usebruno.com) to checkout our website and download the app Please visit [here](https://www.usebruno.com) to checkout our website and download the app
### Documentation 📄 ### Documentation 📄
Please visit [here](https://docs.usebruno.com) for documentation Please visit [here](https://docs.usebruno.com) for documentation
### Contribute 👩‍💻🧑‍💻 ### Contribute 👩‍💻🧑‍💻
I am happy that you are looking to improve bruno. Please checkout the [contributing guide](contributing.md) I am happy that you are looking to improve bruno. Please checkout the [contributing guide](contributing.md)
Even if you are not able to make contributions via code, please don't hesitate to file bugs and feature requests that needs to be implemented to solve your use case. Even if you are not able to make contributions via code, please don't hesitate to file bugs and feature requests that needs to be implemented to solve your use case.
### Support ❤️ ### Support ❤️
Woof! If you like project, hit that ⭐ button !! Woof! If you like project, hit that ⭐ button !!
### Authors ### Authors
@ -51,9 +57,11 @@ Woof! If you like project, hit that ⭐ button !!
</div> </div>
### Stay in touch 🌐 ### Stay in touch 🌐
[Twitter](https://twitter.com/use_bruno) <br /> [Twitter](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com) <br /> [Website](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq) [Discord](https://discord.com/invite/KgcZUncpjq)
### License 📄 ### License 📄
[MIT](license.md) [MIT](license.md)