diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js index 262f068e78..afe08bcba3 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js @@ -8,7 +8,6 @@ const StyledWrapper = styled.div` } .editing-mode { cursor: pointer; - color: ${(props) => props.theme.colors.text.yellow}; } `; diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js index 23dbe9e701..e96b1c6e43 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Docs/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Docs/index.js @@ -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 ( -
- {isEditing ? 'Preview' : 'Edit'} -
- - {isEditing ? ( -
- - + */}
+ + {isEditing ? ( + ) : ( - +
+
+ { + docs?.length > 0 ? + + : + + } +
+
)}
); }; 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! +`; diff --git a/packages/bruno-app/src/components/CollectionSettings/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Info/index.js index 3b0a1297be..b15ba036f9 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Info/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Info/index.js @@ -6,7 +6,7 @@ const Info = ({ collection }) => { const totalRequestsInCollection = getTotalRequestCountInCollection(collection); return ( - +
General information about the collection.
@@ -30,6 +30,10 @@ const Info = ({ collection }) => { + + + +
Requests : {totalRequestsInCollection}
Size :{collection?.brunoConfig?.size?.toFixed?.(3)} MB
diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/StyledWrapper.js new file mode 100644 index 0000000000..4d77f26001 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/index.js new file mode 100644 index 0000000000..f6a29bc713 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/index.js @@ -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 ( + +
+
+ + { + itemsFailedLoading?.length ? +
+
+ Following requests were not loaded +
+ + + + + + + + + {flattenedItems?.map(item => ( + <> + { + item?.partial && !item?.loading ? + + + + + : null + } + + ))} + +
+
+ Pathname +
+
+
+ Size +
+
{item?.pathname?.split(`${collection?.pathname}/`)?.[1]}{item?.size?.toFixed?.(2)} MB
+
+ : + null + } +
+
+ +
+
+
+ ); +} + +export default Overview; \ No newline at end of file diff --git a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js index b88a31e0db..5b2b8b7815 100644 --- a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js @@ -1,7 +1,7 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - max-width: 800px; + // max-width: 800px; div.tabs { div.tab { diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js index b849d6b185..25c5dba1f3 100644 --- a/packages/bruno-app/src/components/CollectionSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/index.js @@ -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 ; + } case 'headers': { return ; } @@ -146,6 +150,9 @@ const CollectionSettings = ({ collection }) => { return (
+
setTab('overview')}> + Overview +
setTab('headers')}> Headers {activeHeadersCount > 0 && {activeHeadersCount}} @@ -177,13 +184,13 @@ const CollectionSettings = ({ collection }) => { Client Certificates {clientCertConfig.length > 0 && }
-
setTab('docs')}> + {/*
setTab('docs')}> Docs {hasDocs && }
setTab('info')}> Info -
+
*/}
{getTabPanel(tab)}
diff --git a/packages/bruno-app/src/components/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/Documentation/StyledWrapper.js index f159d94dcd..af80d4c085 100644 --- a/packages/bruno-app/src/components/Documentation/StyledWrapper.js +++ b/packages/bruno-app/src/components/Documentation/StyledWrapper.js @@ -3,7 +3,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` .editing-mode { cursor: pointer; - color: ${(props) => props.theme.colors.text.yellow}; } `; diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js new file mode 100644 index 0000000000..e2d65d1238 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js @@ -0,0 +1,27 @@ +import { IconLoader2 } from '@tabler/icons'; + +const RequestIsLoading = ({ item }) => { + return <> +
+
+
+
Name
+
{item?.name}
+
+
+
Size
+
{item?.size?.toFixed?.(2)} MB
+
+
+
Path
+
{item?.pathname}
+
+
+ +
+
+
+ +} + +export default RequestIsLoading; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js new file mode 100644 index 0000000000..e7afb1f602 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js @@ -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 <> +
+
+
+
Name
+
{item?.name}
+
+
+
Size
+
{item?.size?.toFixed?.(2)} MB
+
+
+
Path
+
{item?.pathname}
+
+
+
+
+ + + May cause the app to freeze temporarily while it runs. + +
+
+ + + Runs in background. + +
+
+
+ +} + +export default RequestNotLoaded; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js index ec0a032171..112c3ff798 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js @@ -45,6 +45,10 @@ const StyledWrapper = styled.div` display: flex; } } + + .partial-request-overlay { + background: ${(props) => props.theme.bg}; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 4bcfff1c3a..36f6d1cd56 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -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 ; } + + if (focusedTab.type === 'collection-overview') { + return ; + } + if (focusedTab.type === 'folder-settings') { const folder = findItemInCollection(collection, focusedTab.folderUid); return ; @@ -167,6 +175,14 @@ const RequestTabPanel = () => { return ; } + if (item?.partial) { + return + } + + if (item?.loading) { + return + } + const handleRun = async () => { dispatch(sendRequest(item, collection.uid)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js index c5d09faa8d..1cbb0aa051 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js @@ -13,6 +13,14 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => { ); } + case 'collection-overview': { + return ( + <> + + Collection + + ); + } case 'security-settings': { return ( <> diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index e73313c139..2d74a4290d 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -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 ( props.theme.colors.text.yellow}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js new file mode 100644 index 0000000000..14de1eb2f2 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js @@ -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 ; + } + + if (item?.partial) { + return ; + } + + return ; +}; + +export default CollectionItemIcon; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index ed402825d4..20d7607544 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -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 }) => {
- + {item.name} @@ -421,4 +420,4 @@ const CollectionItem = ({ item, collection, searchText }) => { ); }; -export default CollectionItem; +export default CollectionItem; \ No newline at end of file diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 3b814a7e5e..a39d7a3324 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -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 }) => { + {isLoading ? : null}
} placement="bottom-start"> diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js index 4163ffc374..50e19c22e1 100644 --- a/packages/bruno-app/src/components/Sidebar/index.js +++ b/packages/bruno-app/src/components/Sidebar/index.js @@ -184,7 +184,7 @@ const Sidebar = () => { Star */}
-
v1.36.0
+
v1.36.1
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 75c6f2cb90..753783b5fd 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -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); } }); - }; \ No newline at end of file + }; + +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); + }); +}; \ No newline at end of file diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 11f12026f7..da461ba5c6 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -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 }); } } diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 935be60752..2dfa3d94a6 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -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) { diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index bc6c731f4d..edc54f39ac 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -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 }; -}; +}; \ No newline at end of file diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index 5c9889e137..2b94e1c7f8 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -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); diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index 43d01153d1..ede91f963e 100644 --- a/packages/bruno-electron/src/app/watcher.js +++ b/packages/bruno-electron/src/app/watcher.js @@ -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); } diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index 7fe43218a5..62217681cf 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -1,16 +1,21 @@ const _ = require('lodash'); -const { - bruToJsonV2, - jsonToBruV2, - bruToEnvJsonV2, - envJsonToBruV2, - collectionBruToJson: _collectionBruToJson, - jsonToCollectionBru: _jsonToCollectionBru -} = require('@usebruno/lang'); - -const collectionBruToJson = (bru) => { +const { bruToJsonV2, bruToEnvJsonV2, envJsonToBruV2 } = require('@usebruno/lang'); +const BruWorker = require('./workers'); + +// 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, diff --git a/packages/bruno-electron/src/bru/workers/index.js b/packages/bruno-electron/src/bru/workers/index.js new file mode 100644 index 0000000000..e4ed057628 --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/index.js @@ -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; \ No newline at end of file diff --git a/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js b/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js new file mode 100644 index 0000000000..ac19bfdced --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js @@ -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); +} \ No newline at end of file diff --git a/packages/bruno-electron/src/bru/workers/scripts/collection-bru-to-json.js b/packages/bruno-electron/src/bru/workers/scripts/collection-bru-to-json.js new file mode 100644 index 0000000000..9ef7a6e0c5 --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/scripts/collection-bru-to-json.js @@ -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); +} \ No newline at end of file diff --git a/packages/bruno-electron/src/bru/workers/scripts/json-to-bru.js b/packages/bruno-electron/src/bru/workers/scripts/json-to-bru.js new file mode 100644 index 0000000000..796d8e246b --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/scripts/json-to-bru.js @@ -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); +} \ No newline at end of file diff --git a/packages/bruno-electron/src/bru/workers/scripts/json-to-collection-bru.js b/packages/bruno-electron/src/bru/workers/scripts/json-to-collection-bru.js new file mode 100644 index 0000000000..fbecfd47e1 --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/scripts/json-to-collection-bru.js @@ -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); +} \ No newline at end of file diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 8983248922..148fde50e9 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -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(); }); diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 15d5574e22..900b139363 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -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 -} \ No newline at end of file + findItemInCollectionByPathname, + findParentItemInCollection, + getBruFileMeta, + hydrateRequestWithUuid +}; \ No newline at end of file diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index d2f74d10ec..2b928ce34b 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -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 }; diff --git a/packages/bruno-electron/src/workers/index.js b/packages/bruno-electron/src/workers/index.js new file mode 100644 index 0000000000..03d28c718c --- /dev/null +++ b/packages/bruno-electron/src/workers/index.js @@ -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;