feat: async parser workers (#3834)

* feat: async parser workers (#3834)
---------
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
This commit is contained in:
lohit
2025-01-27 12:26:32 +05:30
committed by GitHub
parent fee631d496
commit 074c6be5f4
35 changed files with 962 additions and 152 deletions

View File

@@ -8,7 +8,6 @@ const StyledWrapper = styled.div`
}
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
}
`;

View File

@@ -8,6 +8,7 @@ import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/acti
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { IconEdit, IconTrash, IconFileText } from '@tabler/icons';
const Docs = ({ collection }) => {
const dispatch = useDispatch();
@@ -29,35 +30,89 @@ const Docs = ({ collection }) => {
);
};
const onSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleDiscardChanges = () => {
dispatch(
updateCollectionDocs({
collectionUid: collection.uid,
docs: docs
})
);
toggleViewMode();
}
const onSave = () => {
dispatch(saveCollectionRoot(collection.uid));
}
return (
<StyledWrapper className="mt-1 h-full w-full relative flex flex-col">
<div className="editing-mode flex justify-between items-center" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
{isEditing ? (
<div className="mt-2 flex-1 max-h-[70vh]">
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
<button type="submit" className="submit btn btn-sm btn-secondary my-6" onClick={onSave}>
Save
</button>
<div className='flex flex-row w-full justify-between items-center mb-4'>
<div className='text-lg font-medium flex items-center gap-2'>
<IconFileText size={20} strokeWidth={1.5} />
Documentation
</div>
<div className='flex flex-row gap-2 items-center justify-center'>
<div className="editing-mode" role="tab" onClick={handleDiscardChanges}>
{isEditing ? <IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} /> : <IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />}
</div>
{/* <div className="editing-mode" role="tab" onClick={toggleViewMode}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />
</div> */}
{/* <button type="submit" className="submit btn btn-sm btn-secondary" onClick={onSave}>
Save
</button> */}
</div>
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
<div className='h-full overflow-auto'>
<div className='h-[1px] min-h-[500px]'>
{
docs?.length > 0 ?
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
:
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={documentationPlaceholder} />
}
</div>
</div>
)}
</StyledWrapper>
);
};
export default Docs;
const documentationPlaceholder = `
Welcome to your collection documentation! This space is designed to help you document your API collection effectively.
## Overview
Use this section to provide a high-level overview of your collection. You can describe:
- The purpose of these API endpoints
- Key features and functionalities
- Target audience or users
## Best Practices
- Keep documentation up to date
- Include request/response examples
- Document error scenarios
- Add relevant links and references
## Markdown Support
This documentation supports Markdown formatting! You can use:
- **Bold** and *italic* text
- \`code blocks\` and syntax highlighting
- Tables and lists
- [Links](https://example.com)
- And more!
`;

View File

@@ -6,7 +6,7 @@ const Info = ({ collection }) => {
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<StyledWrapper className="w-full flex flex-col h-fit">
<div className="text-xs mb-4 text-muted">General information about the collection.</div>
<table className="w-full border-collapse">
<tbody>
@@ -30,6 +30,10 @@ const Info = ({ collection }) => {
<td className="py-2 px-2 text-right">Requests&nbsp;:</td>
<td className="py-2 px-2">{totalRequestsInCollection}</td>
</tr>
<tr className="">
<td className="py-2 px-2 text-right">Size&nbsp;:</td>
<td className="py-2 px-2">{collection?.brunoConfig?.size?.toFixed?.(3)} MB</td>
</tr>
</tbody>
</table>
</StyledWrapper>

View File

@@ -0,0 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.partial {
color: ${(props) => props.theme.colors.text.yellow};
opacity: 0.8;
}
.loading {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.completed {
color: ${(props) => props.theme.colors.text.green};
opacity: 0.8;
}
.failed {
color: ${(props) => props.theme.colors.text.danger};
opacity: 0.8;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,63 @@
import { flattenItems } from "utils/collections/index";
import StyledWrapper from "./StyledWrapper";
import Docs from "../Docs/index";
import Info from "../Info/index";
const Overview = ({ collection }) => {
const flattenedItems = flattenItems(collection.items);
const itemsFailedLoading = flattenedItems?.filter(item => item?.partial && !item?.loading);
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 gap-4">
<div className="flex flex-row grid grid-cols-5 w-full gap-8">
<div className="col-span-2 flex flex-col gap-12">
<Info collection={collection} />
{
itemsFailedLoading?.length ?
<div className="w-full border border-opacity-50 border-yellow-500 rounded-md">
<div className="my-2 mx-2 pb-2 font-medium">
Following requests were not loaded
</div>
<table className="w-full border-collapse mt-2">
<thead>
<tr>
<td>
<div className="ml-2">
Pathname
</div>
</td>
<td>
<div className="ml-2">
Size
</div>
</td>
</tr>
</thead>
<tbody>
{flattenedItems?.map(item => (
<>
{
item?.partial && !item?.loading ?
<tr className="">
<td className="py-2 px-2">{item?.pathname?.split(`${collection?.pathname}/`)?.[1]}</td>
<td className="py-2 px-2 text-left">{item?.size?.toFixed?.(2)}&nbsp;MB</td>
</tr>
: null
}
</>
))}
</tbody>
</table>
</div>
:
null
}
</div>
<div className="col-span-3">
<Docs collection={collection} />
</div>
</div>
</StyledWrapper>
);
}
export default Overview;

View File

@@ -1,7 +1,7 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
// max-width: 800px;
div.tabs {
div.tab {

View File

@@ -18,6 +18,7 @@ import Info from './Info';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import DotIcon from 'components/Icons/Dot';
import Overview from './Overview/index';
const ContentIndicator = () => {
return (
@@ -97,6 +98,9 @@ const CollectionSettings = ({ collection }) => {
const getTabPanel = (tab) => {
switch (tab) {
case 'overview': {
return <Overview collection={collection} />;
}
case 'headers': {
return <Headers collection={collection} />;
}
@@ -146,6 +150,9 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
@@ -177,13 +184,13 @@ const CollectionSettings = ({ collection }) => {
Client Certificates
{clientCertConfig.length > 0 && <ContentIndicator />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
{/* <div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
{hasDocs && <ContentIndicator />}
</div>
<div className={getTabClassname('info')} role="tab" onClick={() => setTab('info')}>
Info
</div>
</div> */}
</div>
<section className="mt-4 h-full">{getTabPanel(tab)}</section>
</StyledWrapper>

View File

@@ -3,7 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
}
`;

View File

@@ -0,0 +1,27 @@
import { IconLoader2 } from '@tabler/icons';
const RequestIsLoading = ({ item }) => {
return <>
<div className='flex flex-col gap-6 w-fit pt-4 pb-3 px-4'>
<div className='flex flex-col gap-1'>
<div className='flex flex-row gap-1'>
<div className='opacity-70 min-w-[50px]'>Name</div>
<div>{item?.name}</div>
</div>
<div className='flex flex-row gap-1'>
<div className='opacity-70 min-w-[50px]'>Size</div>
<div>{item?.size?.toFixed?.(2)} MB</div>
</div>
<div className='flex flex-row gap-1'>
<div className='opacity-70 min-w-[50px]'>Path</div>
<div>{item?.pathname}</div>
</div>
<div className='flex flex-col gap-6 w-fit justify-start'>
<IconLoader2 className="animate-spin" size={18} strokeWidth={1.5} />
</div>
</div>
</div>
</>
}
export default RequestIsLoading;

View File

@@ -0,0 +1,55 @@
import { IconLoader2 } from '@tabler/icons';
import { loadRequest, loadRequestSync } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
const RequestNotLoaded = ({ collection, item }) => {
const dispatch = useDispatch();
const handleLoadRequest = () => {
!item?.loading && dispatch(loadRequest({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
const handleLoadRequestSync = () => {
!item?.loading && dispatch(loadRequestSync({ collectionUid: collection?.uid, pathname: item?.pathname }));
}
return <>
<div className='flex flex-col gap-6 w-fit pt-4 pb-3 px-4'>
<div className='flex flex-col gap-1'>
<div className='flex flex-row gap-1'>
<div className='opacity-70 min-w-[50px]'>Name</div>
<div>{item?.name}</div>
</div>
<div className='flex flex-row gap-1'>
<div className='opacity-70 min-w-[50px]'>Size</div>
<div>{item?.size?.toFixed?.(2)} MB</div>
</div>
<div className='flex flex-row gap-1'>
<div className='opacity-70 min-w-[50px]'>Path</div>
<div>{item?.pathname}</div>
</div>
</div>
<div className='flex flex-col gap-6 w-fit justify-start'>
<div className='flex flex-col'>
<button className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`} onClick={handleLoadRequestSync}>
{item?.loading ? `Loading Request` : `Load Request`}
{item?.loading ? <IconLoader2 className="animate-spin" size={18} strokeWidth={1.5} /> : null}
</button>
<small className='text-muted mt-1'>
May cause the app to freeze temporarily while it runs.
</small>
</div>
<div className='flex flex-col'>
<button className={`submit btn btn-sm btn-secondary w-fit h-fit flex flex-row gap-2 ${item?.loading? 'opacity-50 cursor-blocked': ''}`} onClick={handleLoadRequest}>
{item?.loading ? `Loading Request` : `Load Request in Background`}
{item?.loading ? <IconLoader2 className="animate-spin" size={18} strokeWidth={1.5} /> : null}
</button>
<small className='text-muted mt-1'>
Runs in background.
</small>
</div>
</div>
</div>
</>
}
export default RequestNotLoaded;

View File

@@ -45,6 +45,10 @@ const StyledWrapper = styled.div`
display: flex;
}
}
.partial-request-overlay {
background: ${(props) => props.theme.bg};
}
`;
export default StyledWrapper;

View File

@@ -22,6 +22,9 @@ import SecuritySettings from 'components/SecuritySettings';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
import { produce } from 'immer';
import CollectionLoadStats from 'components/CollectionSettings/Overview/index';
import RequestNotLoaded from './RequestNotLoaded/index';
import RequestIsLoading from './RequestIsLoading/index';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -153,6 +156,11 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'collection-settings') {
return <CollectionSettings collection={collection} />;
}
if (focusedTab.type === 'collection-overview') {
return <CollectionLoadStats collection={collection} />;
}
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
return <FolderSettings collection={collection} folder={folder} />;
@@ -167,6 +175,14 @@ const RequestTabPanel = () => {
return <RequestNotFound itemUid={activeTabUid} />;
}
if (item?.partial) {
return <RequestNotLoaded item={item} collection={collection} />
}
if (item?.loading) {
return <RequestIsLoading item={item} />
}
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {

View File

@@ -13,6 +13,14 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
</>
);
}
case 'collection-overview': {
return (
<>
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
<span className="ml-1 leading-6">Collection</span>
</>
);
}
case 'security-settings': {
return (
<>

View File

@@ -70,7 +70,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-1"

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.partial {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -0,0 +1,17 @@
import RequestMethod from "../RequestMethod";
import { IconLoader2, IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from "./StyledWrapper";
const CollectionItemIcon = ({ item }) => {
if (item?.loading) {
return <IconLoader2 className="animate-spin w-fit mr-2" size={18} strokeWidth={1.5} />;
}
if (item?.partial) {
return <StyledWrapper><IconAlertTriangle size={18} className="w-fit mr-2 partial" strokeWidth={1.5} /></StyledWrapper>;
}
return <RequestMethod item={item} />;
};
export default CollectionItemIcon;

View File

@@ -11,7 +11,6 @@ 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';
@@ -24,7 +23,7 @@ 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 { uuid } from 'utils/common';
import CollectionItemIcon from './CollectionItemIcon/index';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
@@ -294,12 +293,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div>
<div
className="ml-1 flex items-center overflow-hidden flex-1"
className="ml-1 flex w-full h-full items-center overflow-hidden"
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<RequestMethod item={item} />
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
{item.name}
</span>
@@ -421,4 +420,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
export default CollectionItem;
export default CollectionItem;

View File

@@ -3,10 +3,10 @@ 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 { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collectionClicked } from 'providers/ReduxStore/slices/collections';
import { moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
import { loadCollection, 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';
@@ -15,12 +15,12 @@ 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 { areItemsLoading } from 'utils/collections/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -30,7 +30,9 @@ const Collection = ({ collection, searchText }) => {
const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed);
const [hasCollectionLoadingBeenTriggered, setHasCollectionLoadingBeenTriggered] = useState(false);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
@@ -70,6 +72,8 @@ const Collection = ({ collection, searchText }) => {
const handleCollapseCollection = () => {
dispatch(collectionClicked(collection.uid));
setHasCollectionLoadingBeenTriggered(true);
!hasCollectionLoadingBeenTriggered && dispatch(loadCollection({ collectionUid: collection?.uid, collectionPathname: collection?.pathname, brunoConfig: collection?.brunoConfig }));
dispatch(
addTab({
uid: uuid(),
@@ -165,6 +169,7 @@ const Collection = ({ collection, searchText }) => {
<div className="ml-1" id="sidebar-collection-name">
{collection.name}
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
</div>
<div className="collection-actions">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">

View File

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

View File

@@ -161,7 +161,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
if (!folder) {
return reject(new Error('Folder not found'));
}
console.log(collection);
const { ipcRenderer } = window;
@@ -170,7 +169,6 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
pathname: folder.pathname,
root: folder.root
};
console.log(folderData);
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
@@ -1192,4 +1190,27 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS
reject(error);
}
});
};
};
export const loadRequest = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request-init', { collectionUid, pathname }).then(resolve).catch(reject);
ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadRequestSync = ({ collectionUid, pathname }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-request-init', { collectionUid, pathname }).then(resolve).catch(reject);
ipcRenderer.invoke('renderer:load-request-sync', { collectionUid, pathname }).then(resolve).catch(reject);
});
};
export const loadCollection = ({ collectionUid, collectionPathname, brunoConfig }) => (dispatch, getState) => {
return new Promise(async (resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:load-collection', { collectionUid, collectionPathname, brunoConfig }).then(resolve).catch(reject);
});
};

View File

@@ -32,7 +32,7 @@ export const collectionsSlice = createSlice({
const collectionUids = map(state.collections, (c) => c.uid);
const collection = action.payload;
collection.settingsSelectedTab = 'headers';
collection.settingsSelectedTab = 'overview';
collection.folderLevelSettingsSelectedTab = {};
// TODO: move this to use the nextAction approach
@@ -1582,7 +1582,7 @@ export const collectionsSlice = createSlice({
name: directoryName,
collapsed: true,
type: 'folder',
items: []
items: [],
};
currentSubItems.push(childItem);
}
@@ -1604,6 +1604,9 @@ export const collectionsSlice = createSlice({
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
currentItem.draft = null;
currentItem.partial = file.partial;
currentItem.loading = file.loading;
currentItem.size = file.size;
} else {
currentSubItems.push({
uid: file.data.uid,
@@ -1613,7 +1616,10 @@ export const collectionsSlice = createSlice({
request: file.data.request,
filename: file.meta.name,
pathname: file.meta.pathname,
draft: null
draft: null,
partial: file.partial,
loading: file.loading,
size: file.size
});
}
}

View File

@@ -25,7 +25,7 @@ export const tabsSlice = createSlice({
}
if (
['variables', 'collection-settings', 'collection-runner', 'security-settings'].includes(action.payload.type)
['variables', 'collection-settings', 'collection-overview', 'collection-runner', 'security-settings'].includes(action.payload.type)
) {
const tab = tabTypeAlreadyExists(state.tabs, action.payload.collectionUid, action.payload.type);
if (tab) {

View File

@@ -136,6 +136,16 @@ export const findEnvironmentInCollectionByName = (collection, name) => {
return find(collection.environments, (e) => e.name === name);
};
export const areItemsLoading = (folder) => {
let flattenedItems = flattenItems(folder.items);
return flattenedItems?.reduce((isLoading, i) => {
if (i?.loading) {
isLoading = true;
}
return isLoading;
}, false);
}
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
@@ -991,4 +1001,4 @@ const mergeVars = (collection, requestTreePath = []) => {
folderVariables,
requestVariables
};
};
};

View File

@@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path');
const { dialog, ipcMain } = require('electron');
const Yup = require('yup');
const { isDirectory, normalizeAndResolvePath } = require('../utils/filesystem');
const { isDirectory, normalizeAndResolvePath, addCollectionStatsToBrunoConfig } = require('../utils/filesystem');
const { generateUidBasedOnHash } = require('../utils/common');
// todo: bruno.json config schema validation errors must be propagated to the UI
@@ -59,7 +59,7 @@ const openCollectionDialog = async (win, watcher) => {
const openCollection = async (win, watcher, collectionPath, options = {}) => {
if (!watcher.hasWatcher(collectionPath)) {
try {
const brunoConfig = await getCollectionConfigFile(collectionPath);
let brunoConfig = await getCollectionConfigFile(collectionPath);
const uid = generateUidBasedOnHash(collectionPath);
if (!brunoConfig.ignore || brunoConfig.ignore.length === 0) {
@@ -69,6 +69,7 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
// this is to maintain backwards compatibility with older collections
brunoConfig.ignore = ['node_modules', '.git'];
}
brunoConfig = await addCollectionStatsToBrunoConfig({ brunoConfig, collectionPath });
win.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', win, collectionPath, uid, brunoConfig);

View File

@@ -2,8 +2,8 @@ const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const chokidar = require('chokidar');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, collectionBruToJson } = require('../bru');
const { hasBruExtension, isWSLPath, normalizeAndResolvePath, normalizeWslPath, sizeInMB } = require('../utils/filesystem');
const { bruToEnvJson, bruToJson, collectionBruToJson, bruToJsonSync } = require('../bru');
const { dotenvToJson } = require('@usebruno/lang');
const { uuid } = require('../utils/common');
@@ -13,6 +13,9 @@ const { setDotEnvVars } = require('../store/process-env');
const { setBrunoConfig } = require('../store/bruno-config');
const EnvironmentSecretsStore = require('../store/env-secrets');
const UiStateSnapshot = require('../store/ui-state-snapshot');
const { getBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
const environmentSecretsStore = new EnvironmentSecretsStore();
@@ -44,28 +47,6 @@ const isCollectionRootBruFile = (pathname, collectionPath) => {
return dirname === collectionPath && basename === 'collection.bru';
};
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
const params = _.get(request, 'request.params', []);
const headers = _.get(request, 'request.headers', []);
const requestVars = _.get(request, 'request.vars.req', []);
const responseVars = _.get(request, 'request.vars.res', []);
const assertions = _.get(request, 'request.assertions', []);
const bodyFormUrlEncoded = _.get(request, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = _.get(request, 'request.body.multipartForm', []);
params.forEach((param) => (param.uid = uuid()));
headers.forEach((header) => (header.uid = uuid()));
requestVars.forEach((variable) => (variable.uid = uuid()));
responseVars.forEach((variable) => (variable.uid = uuid()));
assertions.forEach((assertion) => (assertion.uid = uuid()));
bodyFormUrlEncoded.forEach((param) => (param.uid = uuid()));
bodyMultipartForm.forEach((param) => (param.uid = uuid()));
return request;
};
const hydrateBruCollectionFileWithUuid = (collectionRoot) => {
const params = _.get(collectionRoot, 'request.params', []);
const headers = _.get(collectionRoot, 'request.headers', []);
@@ -99,7 +80,7 @@ const addEnvironmentFile = async (win, pathname, collectionUid, collectionPath)
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
file.data = await bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
@@ -134,7 +115,7 @@ const changeEnvironmentFile = async (win, pathname, collectionUid, collectionPat
};
const bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToEnvJson(bruContent);
file.data = await bruToEnvJson(bruContent);
file.data.name = basename.substring(0, basename.length - 4);
file.data.uid = getRequestUid(pathname);
_.each(_.get(file, 'data.variables', []), (variable) => (variable.uid = uuid()));
@@ -179,13 +160,13 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => {
}
};
const add = async (win, pathname, collectionUid, collectionPath) => {
const add = async ({ win, pathname, collectionUid, watchPath: collectionPath, shouldLoadAsync }) => {
console.log(`watcher add: ${pathname}`);
if (isBrunoConfigFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
const brunoConfig = JSON.parse(content);
let brunoConfig = JSON.parse(content);
setBrunoConfig(collectionUid, brunoConfig);
} catch (err) {
@@ -228,7 +209,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@@ -241,7 +222,6 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
// Is this a folder.bru file?
if (path.basename(pathname) === 'folder.bru') {
console.log('folder.bru file detected');
const file = {
meta: {
collectionUid,
@@ -254,7 +234,7 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
@@ -274,20 +254,63 @@ const add = async (win, pathname, collectionUid, collectionPath) => {
}
};
let fileStats;
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToJson(bruContent);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
if (shouldLoadAsync) {
try {
const fileStats = fs.statSync(pathname);
const metaJson = await bruToJson(getBruFileMeta(bruContent), true);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
if (fileStats.size < MAX_FILE_SIZE) {
file.data = metaJson;
file.partial = false;
file.loading = true;
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
file.data = await bruToJson(bruContent);
file.partial = false;
file.loading = false;
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
}
}
catch(error) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
file.data = {};
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
}
}
else {
file.data = bruToJsonSync(bruContent);
file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
}
} catch (err) {
console.error(err);
}
}
};
const addDirectory = (win, pathname, collectionUid, collectionPath) => {
const addDirectory = ({ win, pathname, collectionUid, watchPath: collectionPath }) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
@@ -304,7 +327,7 @@ const addDirectory = (win, pathname, collectionUid, collectionPath) => {
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
};
const change = async (win, pathname, collectionUid, collectionPath) => {
const change = async ({ win, pathname, collectionUid, watchPath: collectionPath }) => {
if (isBrunoConfigFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
@@ -357,7 +380,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
try {
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = collectionBruToJson(bruContent);
file.data = await collectionBruToJson(bruContent);
hydrateBruCollectionFileWithUuid(file.data);
win.webContents.send('main:collection-tree-updated', 'change', file);
return;
@@ -378,7 +401,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
};
const bru = fs.readFileSync(pathname, 'utf8');
file.data = bruToJson(bru);
file.data = await bruToJson(bru);
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'change', file);
@@ -388,7 +411,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
}
};
const unlink = (win, pathname, collectionUid, collectionPath) => {
const unlink = ({ win, pathname, collectionUid, watchPath: collectionPath }) => {
console.log(`watcher unlink: ${pathname}`);
if (isBruEnvironmentConfig(pathname, collectionPath)) {
@@ -407,7 +430,7 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
}
};
const unlinkDir = (win, pathname, collectionUid, collectionPath) => {
const unlinkDir = ({ win, pathname, collectionUid, watchPath: collectionPath }) => {
const envDirectory = path.join(collectionPath, 'environments');
if (pathname === envDirectory) {
@@ -424,7 +447,7 @@ const unlinkDir = (win, pathname, collectionUid, collectionPath) => {
win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory);
};
const onWatcherSetupComplete = (win, collectionPath) => {
const onWatcherSetupComplete = ({ win, watchPath: collectionPath }) => {
const UiStateSnapshotStore = new UiStateSnapshot();
const collectionsSnapshotState = UiStateSnapshotStore.getCollections();
const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == collectionPath);
@@ -436,7 +459,7 @@ class Watcher {
this.watchers = {};
}
addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false) {
addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, shouldLoadAsync) {
if (this.watchers[watchPath]) {
this.watchers[watchPath].close();
}
@@ -466,12 +489,12 @@ class Watcher {
let startedNewWatcher = false;
watcher
.on('ready', () => onWatcherSetupComplete(win, watchPath))
.on('add', (pathname) => add(win, pathname, collectionUid, watchPath))
.on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath))
.on('change', (pathname) => change(win, pathname, collectionUid, watchPath))
.on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath))
.on('unlinkDir', (pathname) => unlinkDir(win, pathname, collectionUid, watchPath))
.on('ready', () => onWatcherSetupComplete({ win, watchPath }))
.on('add', (pathname) => add({win, pathname, collectionUid, watchPath, shouldLoadAsync }))
.on('addDir', (pathname) => addDirectory({ win, pathname, collectionUid, watchPath, shouldLoadAsync }))
.on('change', (pathname) => change({ win, pathname, collectionUid, watchPath, shouldLoadAsync }))
.on('unlink', (pathname) => unlink({ win, pathname, collectionUid, watchPath, shouldLoadAsync }))
.on('unlinkDir', (pathname) => unlinkDir({ win, pathname, collectionUid, watchPath, shouldLoadAsync }))
.on('error', (error) => {
// `EMFILE` is an error code thrown when to many files are watched at the same time see: https://github.com/usebruno/bruno/issues/627
// `ENOSPC` stands for "Error No space" but is also thrown if the file watcher limit is reached.
@@ -488,7 +511,7 @@ class Watcher {
'Update you system config to allow more concurrently watched files with:',
'"echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p"'
);
this.addWatcher(win, watchPath, collectionUid, brunoConfig, true);
this.addWatcher(win, watchPath, collectionUid, brunoConfig, true, shouldLoadAsync);
} else {
console.error(`An error occurred in the watcher for: ${watchPath}`, error);
}

View File

@@ -1,16 +1,21 @@
const _ = require('lodash');
const {
bruToJsonV2,
jsonToBruV2,
bruToEnvJsonV2,
envJsonToBruV2,
collectionBruToJson: _collectionBruToJson,
jsonToCollectionBru: _jsonToCollectionBru
} = require('@usebruno/lang');
const { bruToJsonV2, bruToEnvJsonV2, envJsonToBruV2 } = require('@usebruno/lang');
const BruWorker = require('./workers');
const collectionBruToJson = (bru) => {
// collections can have bru files of varying sizes. we use two worker threads:
// - one thread handles smaller files (<0.1MB), so they get processed quickly and show up in the gui faster.
// - the other thread takes care of larger files (>=0.1MB). Splitting the processing like this helps with parsing performance.
const bruWorker = new BruWorker({
lanes: [{
maxSize: 0.1
},{
maxSize: 100
}]
});
const collectionBruToJson = async (bru) => {
try {
const json = _collectionBruToJson(bru);
const json = await bruWorker?.collectionBruToJson(bru);
const transformedJson = {
request: {
@@ -38,7 +43,7 @@ const collectionBruToJson = (bru) => {
}
};
const jsonToCollectionBru = (json, isFolder) => {
const jsonToCollectionBru = async (json, isFolder) => {
try {
const collectionBruJson = {
headers: _.get(json, 'request.headers', []),
@@ -67,13 +72,14 @@ const jsonToCollectionBru = (json, isFolder) => {
collectionBruJson.auth = _.get(json, 'request.auth', {});
}
return _jsonToCollectionBru(collectionBruJson);
const bru = await bruWorker?.jsonToCollectionBru(collectionBruJson);
return bru;
} catch (error) {
return Promise.reject(error);
}
};
const bruToEnvJson = (bru) => {
const bruToEnvJson = async (bru) => {
try {
const json = bruToEnvJsonV2(bru);
@@ -90,7 +96,7 @@ const bruToEnvJson = (bru) => {
}
};
const envJsonToBru = (json) => {
const envJsonToBru = async (json) => {
try {
const bru = envJsonToBruV2(json);
return bru;
@@ -108,9 +114,15 @@ const envJsonToBru = (json) => {
* @param {string} bru The BRU file content.
* @returns {object} The JSON representation of the BRU file.
*/
const bruToJson = (bru) => {
const bruToJson = async (data, parsed = false) => {
try {
const json = bruToJsonV2(bru);
let json;
if (parsed) {
json = data;
}
else {
json = await bruWorker?.bruToJson(data);
}
let requestType = _.get(json, 'meta.type');
if (requestType === 'http') {
@@ -149,6 +161,64 @@ const bruToJson = (bru) => {
return Promise.reject(e);
}
};
/**
* The transformer function for converting a BRU file to JSON.
*
* We map the json response from the bru lang and transform it into the DSL
* format that the app uses
*
* @param {string} bru The BRU file content.
* @returns {object} The JSON representation of the BRU file.
*/
const bruToJsonSync = (data, parsed = false) => {
try {
let json;
if (parsed) {
json = data;
}
else {
json = bruToJsonV2(data);
}
let requestType = _.get(json, 'meta.type');
if (requestType === 'http') {
requestType = 'http-request';
} else if (requestType === 'graphql') {
requestType = 'graphql-request';
} else {
requestType = 'http-request';
}
const sequence = _.get(json, 'meta.seq');
const transformedJson = {
type: requestType,
name: _.get(json, 'meta.name'),
seq: !isNaN(sequence) ? Number(sequence) : 1,
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
params: _.get(json, 'params', []),
headers: _.get(json, 'headers', []),
auth: _.get(json, 'auth', {}),
body: _.get(json, 'body', {}),
script: _.get(json, 'script', {}),
vars: _.get(json, 'vars', {}),
assertions: _.get(json, 'assertions', []),
tests: _.get(json, 'tests', ''),
docs: _.get(json, 'docs', '')
}
};
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
return transformedJson;
} catch (e) {
return Promise.reject(e);
}
};
/**
* The transformer function for converting a JSON to BRU file.
*
@@ -158,7 +228,7 @@ const bruToJson = (bru) => {
* @param {object} json The JSON representation of the BRU file.
* @returns {string} The BRU file content.
*/
const jsonToBru = (json) => {
const jsonToBru = async (json) => {
let type = _.get(json, 'type');
if (type === 'http-request') {
type = 'http';
@@ -195,11 +265,13 @@ const jsonToBru = (json) => {
docs: _.get(json, 'request.docs', '')
};
return jsonToBruV2(bruJson);
const bru = await bruWorker?.jsonToBru(bruJson)
return bru;
};
module.exports = {
bruToJson,
bruToJsonSync,
jsonToBru,
bruToEnvJson,
envJsonToBru,

View File

@@ -0,0 +1,43 @@
const WorkerQueue = require("../../workers");
const path = require("path");
const getSize = (data) => {
return typeof data === 'string' ? Buffer.byteLength(data, 'utf8') : Buffer.byteLength(JSON.stringify(data), 'utf8');
}
class BruWorker {
constructor({ lanes = [] }) {
this.workerQueues = lanes?.map(lane => ({
maxSize: lane?.maxSize,
workerQueue: new WorkerQueue()
}));
}
getWorkerQueue(size) {
return this.workerQueues.find((wq) => wq?.maxSize >= size)?.workerQueue || this.workerQueues.at(-1)?.workerQueue;
}
async enqueueTask({data, scriptFile }) {
const size = getSize(data);
const workerQueue = this.getWorkerQueue(size);
return workerQueue.enqueue({ data, priority: size, scriptPath: path.join(__dirname, `./scripts/${scriptFile}.js`) });
}
async bruToJson(data) {
return this.enqueueTask({ data, scriptFile: `bru-to-json` });
}
async jsonToBru(data) {
return this.enqueueTask({ data, scriptFile: `json-to-bru` });
}
async collectionBruToJson(data) {
return this.enqueueTask({ data, scriptFile: `collection-bru-to-json` });
}
async jsonToCollectionBru(data) {
return this.enqueueTask({ data, scriptFile: `json-to-collection-bru` });
}
}
module.exports = BruWorker;

View File

@@ -0,0 +1,13 @@
const { workerData, parentPort } = require('worker_threads');
const {
bruToJsonV2,
} = require('@usebruno/lang');
try {
const bru = workerData;
const json = bruToJsonV2(bru);
parentPort.postMessage(json);
}
catch(error) {
console.error(error);
}

View File

@@ -0,0 +1,13 @@
const { workerData, parentPort } = require('worker_threads');
const {
collectionBruToJson,
} = require('@usebruno/lang');
try {
const bru = workerData;
const json = collectionBruToJson(bru);
parentPort.postMessage(json);
}
catch(error) {
console.error(error);
}

View File

@@ -0,0 +1,13 @@
const { workerData, parentPort } = require('worker_threads');
const {
jsonToBruV2,
} = require('@usebruno/lang');
try {
const json = workerData;
const bru = jsonToBruV2(json);
parentPort.postMessage(bru);
}
catch(error) {
console.error(error);
}

View File

@@ -0,0 +1,13 @@
const { workerData, parentPort } = require('worker_threads');
const {
jsonToCollectionBru,
} = require('@usebruno/lang');
try {
const json = workerData;
const bru = jsonToCollectionBru(json);
parentPort.postMessage(bru);
}
catch(error) {
console.error(error);
}

View File

@@ -4,7 +4,7 @@ const fsExtra = require('fs-extra');
const os = require('os');
const path = require('path');
const { ipcMain, shell, dialog, app } = require('electron');
const { envJsonToBru, bruToJson, jsonToBru, jsonToCollectionBru } = require('../bru');
const { envJsonToBru, bruToJson, jsonToBru, jsonToCollectionBru, bruToJsonSync } = require('../bru');
const {
isValidPathname,
@@ -24,6 +24,9 @@ const {
isWindowsOS,
isValidFilename,
hasSubDirectories,
getCollectionStats,
sizeInMB,
addCollectionStatsToBrunoConfig
} = require('../utils/filesystem');
const { openCollectionDialog } = require('../app/collections');
const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common');
@@ -32,11 +35,17 @@ const { deleteCookiesForDomain, getDomainsWithCookies } = require('../utils/cook
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const UiStateSnapshotStore = require('../store/ui-state-snapshot');
const { getBruFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
const uiStateSnapshotStore = new UiStateSnapshotStore();
// size and file count limits to determine whether the bru files in the collection should be loaded asynchronously or not.
const MAX_COLLECTION_SIZE_IN_MB = 5;
const MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB = 2;
const MAX_COLLECTION_FILES_COUNT = 100;
const envHasSecrets = (environment = {}) => {
const secrets = _.filter(environment.variables, (v) => v.secret);
@@ -88,7 +97,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
}
const uid = generateUidBasedOnHash(dirPath);
const brunoConfig = {
let brunoConfig = {
version: '1',
name: collectionName,
type: 'collection',
@@ -97,6 +106,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const content = await stringifyJson(brunoConfig);
await writeFile(path.join(dirPath, 'bruno.json'), content);
brunoConfig = await addCollectionStatsToBrunoConfig({ brunoConfig, collectionPath: dirPath });
mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid, brunoConfig);
} catch (error) {
@@ -126,9 +137,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const brunoJsonFilePath = path.join(previousPath, 'bruno.json');
const content = fs.readFileSync(brunoJsonFilePath, 'utf8');
//Change new name of collection
let json = JSON.parse(content);
json.name = collectionName;
// Change new name of collection
let brunoConfig = JSON.parse(content);
brunoConfig.name = collectionName;
const cont = await stringifyJson(json);
// write the bruno.json to new dir
@@ -147,7 +158,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
fs.copyFileSync(sourceFilePath, newFilePath);
}
mainWindow.webContents.send('main:collection-opened', dirPath, uid, json);
brunoConfig = await addCollectionStatsToBrunoConfig({ brunoConfig, collectionPath: dirPath });
mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid);
}
);
@@ -184,7 +197,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
name: folderName
};
const content = jsonToCollectionBru(
const content = await jsonToCollectionBru(
folderRoot,
true // isFolder
);
@@ -197,7 +210,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
try {
const collectionBruFilePath = path.join(collectionPathname, 'collection.bru');
const content = jsonToCollectionBru(collectionRoot);
const content = await jsonToCollectionBru(collectionRoot);
await writeFile(collectionBruFilePath, content);
} catch (error) {
return Promise.reject(error);
@@ -213,7 +226,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (!isValidFilename(request.name)) {
throw new Error(`path: ${request.name}.bru is not a valid filename`);
}
const content = jsonToBru(request);
const content = await jsonToBru(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
@@ -227,7 +240,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${pathname} does not exist`);
}
const content = jsonToBru(request);
const content = await jsonToBru(request);
await writeFile(pathname, content);
} catch (error) {
return Promise.reject(error);
@@ -245,7 +258,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(`path: ${pathname} does not exist`);
}
const content = jsonToBru(request);
const content = await jsonToBru(request);
await writeFile(pathname, content);
}
} catch (error) {
@@ -275,7 +288,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environmentSecretsStore.storeEnvSecrets(collectionPathname, environment);
}
const content = envJsonToBru(environment);
const content = await envJsonToBru(environment);
await writeFile(envFilePath, content);
} catch (error) {
@@ -300,7 +313,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
environmentSecretsStore.storeEnvSecrets(collectionPathname, environment);
}
const content = envJsonToBru(environment);
const content = await envJsonToBru(environment);
await writeFile(envFilePath, content);
} catch (error) {
return Promise.reject(error);
@@ -412,11 +425,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// update name in file and save new copy, then delete old copy
const data = await fs.promises.readFile(oldPath, 'utf8'); // Use async read
const jsonData = bruToJson(data);
const jsonData = await bruToJson(data);
jsonData.name = newName;
moveRequestUid(oldPath, newPath);
const content = jsonToBru(jsonData);
const content = await jsonToBru(jsonData);
await fs.promises.unlink(oldPath);
await writeFile(newPath, content);
@@ -516,9 +529,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = (items = [], currentPath) => {
items.forEach((item) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = jsonToBru(item);
const content = await jsonToBru(item);
const filePath = path.join(currentPath, `${item.name}.bru`);
fs.writeFileSync(filePath, content);
}
@@ -529,7 +542,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
if (item?.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
const folderContent = jsonToCollectionBru(
const folderContent = await jsonToCollectionBru(
item.root,
true // isFolder
);
@@ -554,8 +567,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
fs.mkdirSync(envDirPath);
}
environments.forEach((env) => {
const content = envJsonToBru(env);
environments.forEach(async (env) => {
const content = await envJsonToBru(env);
const filePath = path.join(envDirPath, `${env.name}.bru`);
fs.writeFileSync(filePath, content);
});
@@ -579,15 +592,17 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
await createDirectory(collectionPath);
const uid = generateUidBasedOnHash(collectionPath);
const brunoConfig = getBrunoJsonConfig(collection);
let brunoConfig = getBrunoJsonConfig(collection);
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
// Write the Bruno configuration to a file
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
const collectionContent = jsonToCollectionBru(collection.root);
const collectionContent = await jsonToCollectionBru(collection.root);
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
brunoConfig = await addCollectionStatsToBrunoConfig({ brunoConfig, collectionPath });
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
@@ -609,9 +624,9 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// Recursive function to parse the folder and create files/folders
const parseCollectionItems = (items = [], currentPath) => {
items.forEach((item) => {
items.forEach(async (item) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
const content = jsonToBru(item);
const content = await jsonToBru(item);
const filePath = path.join(currentPath, `${item.name}.bru`);
fs.writeFileSync(filePath, content);
}
@@ -621,7 +636,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// If folder has a root element, then I should write its folder.bru file
if (item.root) {
const folderContent = jsonToCollectionBru(item.root, true);
const folderContent = await jsonToCollectionBru(item.root, true);
if (folderContent) {
const bruFolderPath = path.join(folderPath, `folder.bru`);
fs.writeFileSync(bruFolderPath, folderContent);
@@ -639,7 +654,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
// If initial folder has a root element, then I should write its folder.bru file
if (itemFolder.root) {
const folderContent = jsonToCollectionBru(itemFolder.root, true);
const folderContent = await jsonToCollectionBru(itemFolder.root, true);
if (folderContent) {
const bruFolderPath = path.join(collectionPath, `folder.bru`);
fs.writeFileSync(bruFolderPath, folderContent);
@@ -655,13 +670,13 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
ipcMain.handle('renderer:resequence-items', async (event, itemsToResequence) => {
try {
for (let item of itemsToResequence) {
for await (let item of itemsToResequence) {
const bru = fs.readFileSync(item.pathname, 'utf8');
const jsonData = bruToJson(bru);
const jsonData = await bruToJson(bru);
if (jsonData.seq !== item.seq) {
jsonData.seq = item.seq;
const content = jsonToBru(jsonData);
const content = await jsonToBru(jsonData);
await writeFile(item.pathname, content);
}
}
@@ -776,6 +791,116 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
throw new Error(error.message);
}
});
ipcMain.handle('renderer:load-request-init', async (event, { collectionUid, pathname }) => {
let fileStats;
try {
fileStats = fs.statSync(pathname);
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(getBruFileMeta(bruContent), true);
file.data = metaJson;
file.loading = true;
file.partial = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
} catch (error) {
return Promise.reject(error);
}
});
ipcMain.handle('renderer:load-request', async (event, { collectionUid, pathname }) => {
let fileStats;
try {
fileStats = fs.statSync(pathname);
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = await bruToJson(bruContent);
file.partial = false;
file.loading = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
} catch (error) {
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(getBruFileMeta(bruContent), true);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
return Promise.reject(error);
}
});
ipcMain.handle('renderer:load-request-sync', async (event, { collectionUid, pathname }) => {
let fileStats;
try {
fileStats = fs.statSync(pathname);
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
file.data = bruToJsonSync(bruContent);
file.partial = false;
file.loading = true;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
} catch (error) {
if (hasBruExtension(pathname)) {
const file = {
meta: {
collectionUid,
pathname,
name: path.basename(pathname)
}
};
let bruContent = fs.readFileSync(pathname, 'utf8');
const metaJson = await bruToJson(getBruFileMeta(bruContent), true);
file.data = metaJson;
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
}
return Promise.reject(error);
}
});
};
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
@@ -790,8 +915,7 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) =
shell.openExternal(docsURL);
});
ipcMain.on('main:collection-opened', (win, pathname, uid, brunoConfig) => {
watcher.addWatcher(win, pathname, uid, brunoConfig);
ipcMain.on('main:collection-opened', async (win, pathname, uid, brunoConfig) => {
lastOpenedCollections.add(pathname);
app.addRecentDocument(pathname);
});
@@ -801,6 +925,12 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) =
mainWindow.webContents.send('main:start-quit-flow');
});
ipcMain.handle('renderer:load-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => {
const { size: collectionSize, filesCount: collectionBruFilesCount, maxFileSize: maxSingleBruFileSize } = await getCollectionStats(collectionPathname);
const shouldLoadCollectionAsync = (collectionSize > MAX_COLLECTION_SIZE_IN_MB) || (collectionBruFilesCount > MAX_COLLECTION_FILES_COUNT) || (maxSingleBruFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB);
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync);
});
ipcMain.handle('main:complete-quit-flow', () => {
mainWindow.destroy();
});

View File

@@ -1,3 +1,7 @@
const fs = require('fs');
const { getRequestUid } = require('../cache/requestUids');
const { uuid } = require('./common');
const { get, each, find, compact } = require('lodash');
const os = require('os');
@@ -203,6 +207,51 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
return path;
};
const getBruFileMeta = (data) => {
try {
const metaRegex = /meta\s*{\s*([\s\S]*?)\s*}/;
const match = data.match(metaRegex);
if (match) {
const metaContent = match[1].trim();
const lines = metaContent.replace(/\r\n/g, '\n').split('\n');
const metaJson = {};
lines.forEach(line => {
const [key, value] = line.split(':').map(str => str.trim());
if (key && value) {
metaJson[key] = isNaN(value) ? value : Number(value);
}
});
return { meta: metaJson };
} else {
console.log('No "meta" block found in the file.');
}
} catch (err) {
console.error('Error reading file:', err);
}
}
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
const params = get(request, 'request.params', []);
const headers = get(request, 'request.headers', []);
const requestVars = get(request, 'request.vars.req', []);
const responseVars = get(request, 'request.vars.res', []);
const assertions = get(request, 'request.assertions', []);
const bodyFormUrlEncoded = get(request, 'request.body.formUrlEncoded', []);
const bodyMultipartForm = get(request, 'request.body.multipartForm', []);
params.forEach((param) => (param.uid = uuid()));
headers.forEach((header) => (header.uid = uuid()));
requestVars.forEach((variable) => (variable.uid = uuid()));
responseVars.forEach((variable) => (variable.uid = uuid()));
assertions.forEach((assertion) => (assertion.uid = uuid()));
bodyFormUrlEncoded.forEach((param) => (param.uid = uuid()));
bodyMultipartForm.forEach((param) => (param.uid = uuid()));
return request;
};
const slash = (path) => {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
if (isExtendedLengthPath) {
@@ -221,13 +270,18 @@ const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
module.exports = {
mergeHeaders,
mergeVars,
mergeScripts,
getTreePathFromCollectionToItem,
flattenItems,
findItem,
findItemInCollection,
slash,
findItemByPathname,
findItemInCollectionByPathname
}
findItemInCollectionByPathname,
findParentItemInCollection,
getBruFileMeta,
hydrateRequestWithUuid
};

View File

@@ -211,6 +211,57 @@ const safeToRename = (oldPath, newPath) => {
}
};
const getCollectionStats = async (directoryPath) => {
let size = 0;
let filesCount = 0;
let maxFileSize = 0;
async function calculateStats(directory) {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const tasks = entries.map(async (entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (['node_modules', '.git'].includes(entry.name)) {
return;
}
await calculateStats(fullPath);
}
if (path.extname(fullPath) === '.bru') {
const stats = await fsPromises.stat(fullPath);
size += stats?.size;
if (maxFileSize < stats?.size) {
maxFileSize = stats?.size;
}
filesCount += 1;
}
});
await Promise.all(tasks);
}
await calculateStats(directoryPath);
size = sizeInMB(size);
maxFileSize = sizeInMB(maxFileSize);
return { size, filesCount, maxFileSize };
}
const sizeInMB = (size) => {
return size / (1024 * 1024);
}
const addCollectionStatsToBrunoConfig = async ({ brunoConfig, collectionPath }) => {
const { size: collectionSize, filesCount: collectionBruFilesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = collectionSize;
brunoConfig.filesCount = collectionBruFilesCount;
return brunoConfig;
}
module.exports = {
isValidPathname,
exists,
@@ -235,5 +286,8 @@ module.exports = {
isWindowsOS,
safeToRename,
isValidFilename,
hasSubDirectories
hasSubDirectories,
getCollectionStats,
sizeInMB,
addCollectionStatsToBrunoConfig
};

View File

@@ -0,0 +1,52 @@
const { Worker } = require('worker_threads');
class WorkerQueue {
constructor() {
this.queue = [];
this.isProcessing = false;
}
async enqueue({ priority, scriptPath, data }) {
return new Promise((resolve, reject) => {
this.queue.push({ priority, scriptPath, data, resolve, reject });
this.queue?.sort((taskX, taskY) => taskX?.priority - taskY?.priority);
this.processQueue();
});
}
async processQueue() {
if (this.isProcessing || this.queue.length === 0) return;
this.isProcessing = true;
const { scriptPath, data, resolve, reject } = this.queue.shift();
try {
const result = await this.runWorker({ scriptPath, data });
resolve(result);
} catch (error) {
reject(error);
} finally {
this.isProcessing = false;
this.processQueue();
}
}
async runWorker({ scriptPath, data }) {
return new Promise((resolve, reject) => {
const worker = new Worker(scriptPath, { workerData: data });
worker.on('message', (data) => {
resolve(data);
worker.terminate();
});
worker.on('error', (error) => {
reject(error);
worker.terminate();
});
worker.on('exit', (code) => {
// if (code !== 0)
reject(new Error(`stopped with ${code} exit code`));
worker.terminate();
});
});
}
}
module.exports = WorkerQueue;