diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js index e49220854..b7e4b56c7 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/StyledWrapper.js @@ -1,5 +1,7 @@ import styled from 'styled-components'; -const Wrapper = styled.div``; +const Wrapper = styled.div` + max-width: 800px; +`; export default Wrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js index 262f068e7..afe08bcba 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 23dbe9e70..2d869de65 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, IconX, IconFileText } from '@tabler/icons'; const Docs = ({ collection }) => { const dispatch = useDispatch(); @@ -29,35 +30,95 @@ 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)); + toggleViewMode(); + } return ( -
- {isEditing ? 'Preview' : 'Edit'} -
- - {isEditing ? ( -
- - +
+
+ + Documentation
+
+ {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/Headers/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Headers/StyledWrapper.js index 9f723cb81..c4d03c5ed 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Headers/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Headers/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const Wrapper = styled.div` + max-width: 800px; + table { width: 100%; border-collapse: collapse; diff --git a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js deleted file mode 100644 index 7fd98347c..000000000 --- a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js +++ /dev/null @@ -1,13 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - table { - td { - &:first-child { - width: 120px; - } - } - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Info/index.js deleted file mode 100644 index 3b0a1297b..000000000 --- a/packages/bruno-app/src/components/CollectionSettings/Info/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import StyledWrapper from './StyledWrapper'; -import { getTotalRequestCountInCollection } from 'utils/collections/'; - -const Info = ({ collection }) => { - const totalRequestsInCollection = getTotalRequestCountInCollection(collection); - - return ( - -
General information about the collection.
- - - - - - - - - - - - - - - - - - - - - - - -
Name :{collection.name}
Location :{collection.pathname}
Ignored files :{collection.brunoConfig?.ignore?.map((x) => `'${x}'`).join(', ')}
Environments :{collection.environments?.length || 0}
Requests :{totalRequestsInCollection}
-
- ); -}; - -export default Info; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js new file mode 100644 index 000000000..86bf2308f --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { getTotalRequestCountInCollection } from 'utils/collections/'; +import { IconFolder, IconFileOff, IconWorld, IconApi } from '@tabler/icons'; + +const Info = ({ collection }) => { + const totalRequestsInCollection = getTotalRequestCountInCollection(collection); + + return ( +
+
+
+ {/* Location Row */} +
+
+ +
+
+
Location
+
+ {collection.pathname} +
+
+
+ + {/* Environments Row */} +
+
+ +
+
+
Environments
+
+ {collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured +
+
+
+ + {/* Requests Row */} +
+
+ +
+
+
Requests
+
+ {totalRequestsInCollection} request{totalRequestsInCollection !== 1 ? 's' : ''} in collection +
+
+
+
+
+
+ ); +}; + +export default Info; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/StyledWrapper.js new file mode 100644 index 000000000..e9a9cd06f --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/StyledWrapper.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + &.card { + background-color: ${(props) => props.theme.requestTabPanel.card.bg}; + + .title { + border-top: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border}; + border-left: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border}; + border-right: 1px solid ${(props) => props.theme.requestTabPanel.cardTable.border}; + + border-top-left-radius: 3px; + border-top-right-radius: 3px; + } + + .table { + thead { + background-color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.bg}; + color: ${(props) => props.theme.requestTabPanel.cardTable.table.thead.color}; + } + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/index.js new file mode 100644 index 000000000..c15b36cd8 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/RequestsNotLoaded/index.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { flattenItems } from "utils/collections"; +import { IconAlertTriangle } from '@tabler/icons'; +import StyledWrapper from "./StyledWrapper"; + +const RequestsNotLoaded = ({ collection }) => { + const flattenedItems = flattenItems(collection.items); + const itemsFailedLoading = flattenedItems?.filter(item => item?.partial && !item?.loading); + + if (!itemsFailedLoading?.length) { + return null; + } + + return ( + +
+ + Following requests were not loaded +
+ + + + + + + + + {flattenedItems?.map((item, index) => ( + item?.partial && !item?.loading ? ( + + + + + ) : null + ))} + +
+ Pathname + + Size +
+ {item?.pathname?.split(`${collection?.pathname}/`)?.[1]} + + {item?.size?.toFixed?.(2)} MB +
+
+ ); +}; + +export default RequestsNotLoaded; 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 000000000..4d77f2600 --- /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 000000000..87b461e9c --- /dev/null +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/index.js @@ -0,0 +1,27 @@ +import StyledWrapper from "./StyledWrapper"; +import Docs from "../Docs"; +import Info from "./Info"; +import { IconBox } from '@tabler/icons'; +import RequestsNotLoaded from "./RequestsNotLoaded"; + +const Overview = ({ collection }) => { + return ( +
+
+
+
+ + {collection?.name} +
+ + +
+
+ +
+
+
+ ); +} + +export default Overview; \ No newline at end of file diff --git a/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js index 602851baa..db26e863b 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Presets/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + max-width: 800px; + .settings-label { width: 110px; } diff --git a/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js index 66ba1ed3d..03aed74aa 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Script/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + max-width: 800px; + div.CodeMirror { height: inherit; } diff --git a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js index b88a31e0d..90ab7fee5 100644 --- a/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js @@ -1,8 +1,6 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - max-width: 800px; - div.tabs { div.tab { padding: 6px 0px; diff --git a/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js index ec278887d..b9014ebd5 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Tests/StyledWrapper.js @@ -1,5 +1,7 @@ import styled from 'styled-components'; -const StyledWrapper = styled.div``; +const StyledWrapper = styled.div` + max-width: 800px; +`; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js index 44b01b464..26459a3c6 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Vars/StyledWrapper.js @@ -1,6 +1,8 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + max-width: 800px; + div.title { color: var(--color-tab-inactive); } diff --git a/packages/bruno-app/src/components/CollectionSettings/index.js b/packages/bruno-app/src/components/CollectionSettings/index.js index b849d6b18..7d5d60574 100644 --- a/packages/bruno-app/src/components/CollectionSettings/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/index.js @@ -12,12 +12,11 @@ import Headers from './Headers'; import Auth from './Auth'; import Script from './Script'; import Test from './Tests'; -import Docs from './Docs'; import Presets from './Presets'; -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 +96,9 @@ const CollectionSettings = ({ collection }) => { const getTabPanel = (tab) => { switch (tab) { + case 'overview': { + return ; + } case 'headers': { return ; } @@ -128,12 +130,6 @@ const CollectionSettings = ({ collection }) => { /> ); } - case 'docs': { - return ; - } - case 'info': { - return ; - } } }; @@ -146,6 +142,9 @@ const CollectionSettings = ({ collection }) => { return (
+
setTab('overview')}> + Overview +
setTab('headers')}> Headers {activeHeadersCount > 0 && {activeHeadersCount}} @@ -177,13 +176,6 @@ const CollectionSettings = ({ collection }) => { Client Certificates {clientCertConfig.length > 0 && }
-
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 f159d94dc..af80d4c08 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/MarkDown/StyledWrapper.js b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js index fa1269e14..0ac61b4e5 100644 --- a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js +++ b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js @@ -9,7 +9,6 @@ const StyledMarkdownBodyWrapper = styled.div` box-sizing: border-box; height: 100%; margin: 0 auto; - padding-top: 0.5rem; font-size: 0.875rem; h1 { @@ -80,12 +79,6 @@ const StyledMarkdownBodyWrapper = styled.div` } } } - - @media (max-width: 767px) { - .markdown-body { - padding: 15px; - } - } `; export default StyledMarkdownBodyWrapper; diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js new file mode 100644 index 000000000..ff6c48575 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + div.card { + background: ${(props) => props.theme.requestTabPanel.card.bg}; + border: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + + div.hr { + border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr}; + height: 1px; + } + + div.border-top { + border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + } + } +`; + +export default StyledWrapper; 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 000000000..9d2ff1346 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js @@ -0,0 +1,47 @@ +import { IconLoader2, IconFile } from '@tabler/icons'; +import StyledWrapper from './StyledWrapper'; + +const RequestIsLoading = ({ item }) => { + return +
+
+
+
+ + File Info +
+
+ +
+ Name: +
+ {item?.name} +
+
+ +
+ Path: +
+ {item?.pathname} +
+
+ +
+ Size: +
+ {item?.size?.toFixed?.(2)} MB +
+
+ +
+
+ + Loading... +
+
+
+
+ +} + +export default RequestIsLoading; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js new file mode 100644 index 000000000..ff6c48575 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + div.card { + background: ${(props) => props.theme.requestTabPanel.card.bg}; + border: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + + div.hr { + border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr}; + height: 1px; + } + + div.border-top { + border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border}; + } + } +`; + +export default StyledWrapper; 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 000000000..1a951b624 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js @@ -0,0 +1,89 @@ +import { IconLoader2, IconFile } from '@tabler/icons'; +import { loadRequest, loadRequestViaWorker } from 'providers/ReduxStore/slices/collections/actions'; +import { useDispatch } from 'react-redux'; +import StyledWrapper from './StyledWrapper'; + +const RequestNotLoaded = ({ collection, item }) => { + const dispatch = useDispatch(); + const handleLoadRequestViaWorker = () => { + !item?.loading && dispatch(loadRequestViaWorker({ collectionUid: collection?.uid, pathname: item?.pathname })); + } + + const handleLoadRequest = () => { + !item?.loading && dispatch(loadRequest({ collectionUid: collection?.uid, pathname: item?.pathname })); + } + + return +
+
+
+
+ + File Info +
+
+ +
+ Name: +
{item?.name}
+
+ +
+ Path: +
{item?.pathname}
+
+ +
+ Size: +
{item?.size?.toFixed?.(2)} MB
+
+ + {!item?.error && ( + <> +
+
+ Due to its large size, this request wasn't loaded automatically. +
+
+
+ + + May cause the app to freeze temporarily while it runs. + +
+
+ + + Runs in background. + +
+
+ + )} + + {item?.loading && ( + <> +
+
+ + Loading... +
+ + )} +
+
+
+ +} + +export default RequestNotLoaded; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 4bcfff1c3..d7690e08a 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 CollectionOverview from 'components/CollectionSettings/Overview'; +import RequestNotLoaded from './RequestNotLoaded'; +import RequestIsLoading from './RequestIsLoading'; 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 c5d09faa8..1cbb0aa05 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 e73313c13..2d74a4290 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 ( { // convert to unix style path @@ -106,6 +107,8 @@ export default function RunnerResults({ collection }) { return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail'; }); + let isCollectionLoading = areItemsLoading(collection); + if (!items || !items.length) { return ( @@ -116,7 +119,7 @@ export default function RunnerResults({ collection }) {
You have {totalRequestsInCollection} requests in this collection.
- + {isCollectionLoading ?
Requests in this collection are still loading.
: null}
props.theme.colors.text.yellow}; + } + .error { + color: ${(props) => props.theme.colors.text.danger}; + } +`; + +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 000000000..82d87aa7d --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js @@ -0,0 +1,21 @@ +import RequestMethod from "../RequestMethod"; +import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons'; +import StyledWrapper from "./StyledWrapper"; + +const CollectionItemIcon = ({ item }) => { + if (item?.error) { + return ; + } + + 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/RunCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js index 3b6e08f42..e7dd94d2f 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js @@ -4,6 +4,9 @@ const Wrapper = styled.div` .bruno-modal-content { padding-bottom: 1rem; } + .warning { + color: ${(props) => props.theme.colors.text.danger}; + } `; export default Wrapper; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js index 4a81f59af..cfd236f8c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -7,6 +7,7 @@ import { addTab } from 'providers/ReduxStore/slices/tabs'; import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions'; import { flattenItems } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; +import { areItemsLoading } from 'utils/collections'; const RunCollectionItem = ({ collection, item, onClose }) => { const dispatch = useDispatch(); @@ -32,6 +33,10 @@ const RunCollectionItem = ({ collection, item, onClose }) => { const flattenedItems = flattenItems(item ? item.items : collection.items); const recursiveRunLength = getRequestsCount(flattenedItems); + const isFolderLoading = areItemsLoading(item); + console.log(item); + console.log(isFolderLoading); + return ( @@ -44,13 +49,12 @@ const RunCollectionItem = ({ collection, item, onClose }) => { ({runLength} requests)
This will only run the requests in this folder.
-
Recursive Run ({recursiveRunLength} requests)
-
This will run all the requests in this folder and all its subfolders.
- +
This will run all the requests in this folder and all its subfolders.
+ {isFolderLoading ?
Requests in this folder are still loading.
: null}
- + {item.name} @@ -421,4 +414,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 3b814a7e5..1b16f4eea 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 { collapseCollection } from 'providers/ReduxStore/slices/collections'; +import { mountCollection, 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 CloneCollection from './CloneCollection'; +import { areItemsLoading } from 'utils/collections'; const Collection = ({ collection, searchText }) => { const [showNewFolderModal, setShowNewFolderModal] = useState(false); @@ -29,8 +29,8 @@ const Collection = ({ collection, searchText }) => { const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false); const [showExportCollectionModal, setShowExportCollectionModal] = useState(false); const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); - const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed); const dispatch = useDispatch(); + const isLoading = areItemsLoading(collection); const menuDropdownTippyRef = useRef(); const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref); @@ -52,32 +52,37 @@ const Collection = ({ collection, searchText }) => { ); }; - useEffect(() => { - if (searchText && searchText.length) { - setCollectionIsCollapsed(false); - } else { - setCollectionIsCollapsed(collection.collapsed); - } - }, [searchText, collection]); + const hasSearchText = searchText && searchText?.trim()?.length; + const collectionIsCollapsed = hasSearchText ? false : collection.collapsed; const iconClassName = classnames({ 'rotate-90': !collectionIsCollapsed }); const handleClick = (event) => { - dispatch(collectionClicked(collection.uid)); - }; + // Check if the click came from the chevron icon + const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon'); - const handleCollapseCollection = () => { - dispatch(collectionClicked(collection.uid)); - dispatch( - addTab({ - uid: uuid(), + if (collection.mountStatus === 'unmounted') { + dispatch(mountCollection({ collectionUid: collection.uid, - type: 'collection-settings' - }) - ); - } + collectionPathname: collection.pathname, + brunoConfig: collection.brunoConfig + })); + } + dispatch(collapseCollection(collection.uid)); + + // Only open collection settings if not clicking the chevron + if(!isChevronClick) { + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'collection-settings' + }) + ); + } + }; const handleRightClick = (event) => { const _menuDropdown = menuDropdownTippyRef.current; @@ -152,19 +157,19 @@ 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 4163ffc37..50e19c22e 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 7973e57ad..de9ad78e9 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -23,6 +23,7 @@ import { import { uuid, waitForNextTick } from 'utils/common'; import { PATH_SEPARATOR, getDirectoryName, isWindowsPath } from 'utils/common/platform'; import { cancelNetworkRequest, sendNetworkRequest } from 'utils/network'; +import { callIpc } from 'utils/common/ipc'; import { collectionAddEnvFileEvent as _collectionAddEnvFileEvent, @@ -30,6 +31,7 @@ import { removeCollection as _removeCollection, selectEnvironment as _selectEnvironment, sortCollections as _sortCollections, + updateCollectionMountStatus, requestCancelled, resetRunResults, responseReceived, @@ -42,7 +44,6 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs'; import { resolveRequestFilename } from 'utils/common/platform'; import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index'; import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index'; -import { name } from 'file-loader'; import slash from 'utils/common/slash'; import { getGlobalEnvironmentVariables } from 'utils/collections/index'; import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index'; @@ -161,7 +162,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 +170,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 +1191,31 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS reject(error); } }); - }; \ No newline at end of file + }; + +export const loadRequestViaWorker = ({ collectionUid, pathname }) => (dispatch, getState) => { + return new Promise(async (resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:load-request-via-worker', { collectionUid, pathname }).then(resolve).catch(reject); + }); +}; + +export const loadRequest = ({ collectionUid, pathname }) => (dispatch, getState) => { + return new Promise(async (resolve, reject) => { + const { ipcRenderer } = window; + ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject); + }); +}; + +export const mountCollection = ({ collectionUid, collectionPathname, brunoConfig }) => (dispatch, getState) => { + dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' })); + return new Promise(async (resolve, reject) => { + callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig }) + .then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }))) + .then(resolve) + .catch(() => { + dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' })); + reject(); + }); + }); +}; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 11f12026f..6a795171f 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -4,7 +4,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { addDepth, areItemsTheSameExceptSeqUpdate, - collapseCollection, + collapseAllItemsInCollection, deleteItemInCollection, deleteItemInCollectionByPathname, findCollectionByPathname, @@ -32,9 +32,13 @@ export const collectionsSlice = createSlice({ const collectionUids = map(state.collections, (c) => c.uid); const collection = action.payload; - collection.settingsSelectedTab = 'headers'; + collection.settingsSelectedTab = 'overview'; collection.folderLevelSettingsSelectedTab = {}; + // Collection mount status is used to track the mount status of the collection + // values can be 'unmounted', 'mounting', 'mounted' + collection.mountStatus = 'unmounted'; + // TODO: move this to use the nextAction approach // last action is used to track the last action performed on the collection // this is optional @@ -44,12 +48,18 @@ export const collectionsSlice = createSlice({ collection.importedAt = new Date().getTime(); collection.lastAction = null; - collapseCollection(collection); + collapseAllItemsInCollection(collection); addDepth(collection.items); if (!collectionUids.includes(collection.uid)) { state.collections.push(collection); } }, + updateCollectionMountStatus: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + if (collection) { + collection.mountStatus = action.payload.mountStatus; + } + }, setCollectionSecurityConfig: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { @@ -358,7 +368,7 @@ export const collectionsSlice = createSlice({ collection.items.push(item); } }, - collectionClicked: (state, action) => { + collapseCollection: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload); if (collection) { @@ -1582,7 +1592,7 @@ export const collectionsSlice = createSlice({ name: directoryName, collapsed: true, type: 'folder', - items: [] + items: [], }; currentSubItems.push(childItem); } @@ -1604,6 +1614,10 @@ 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; + currentItem.error = file.error; } else { currentSubItems.push({ uid: file.data.uid, @@ -1613,7 +1627,11 @@ 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, + error: file.error }); } } @@ -1890,6 +1908,7 @@ export const collectionsSlice = createSlice({ export const { createCollection, + updateCollectionMountStatus, setCollectionSecurityConfig, brunoConfigUpdateEvent, renameCollection, @@ -1913,7 +1932,7 @@ export const { saveRequest, deleteRequestDraft, newEphemeralHttpRequest, - collectionClicked, + collapseCollection, collectionFolderClicked, requestUrlChanged, updateAuth, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 935be6075..2dfa3d94a 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/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 9e8e923aa..a47abb8d2 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -114,7 +114,25 @@ const darkTheme = { responseStatus: '#ccc', responseOk: '#8cd656', responseError: '#f06f57', - responseOverlayBg: 'rgba(30, 30, 30, 0.6)' + responseOverlayBg: 'rgba(30, 30, 30, 0.6)', + + card: { + bg: '#252526', + border: 'transparent', + borderDark: '#8cd656', + hr: '#424242' + }, + + cardTable: { + border: '#333', + bg: '#252526', + table: { + thead: { + bg: '#3D3D3D', + color: '#ccc' + } + } + } }, collection: { diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index a25583136..9d3439895 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -114,7 +114,22 @@ const lightTheme = { responseStatus: 'rgb(117 117 117)', responseOk: '#047857', responseError: 'rgb(185, 28, 28)', - responseOverlayBg: 'rgba(255, 255, 255, 0.6)' + responseOverlayBg: 'rgba(255, 255, 255, 0.6)', + card: { + bg: '#fff', + border: '#f4f4f4', + hr: '#f4f4f4' + }, + cardTable: { + border: '#efefef', + bg: '#fff', + table: { + thead: { + bg: 'rgb(249, 250, 251)', + color: 'rgb(75 85 99)' + } + } + } }, collection: { diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index bc6c731f4..956616710 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -34,7 +34,7 @@ export const addDepth = (items = []) => { depth(items, 1); }; -export const collapseCollection = (collection) => { +export const collapseAllItemsInCollection = (collection) => { collection.collapsed = true; const collapseItem = (items) => { @@ -47,7 +47,7 @@ export const collapseCollection = (collection) => { }); }; - collapseItem(collection.items, 1); + collapseItem(collection.items); }; export const sortItems = (collection) => { @@ -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-app/src/utils/common/ipc.js b/packages/bruno-app/src/utils/common/ipc.js new file mode 100644 index 000000000..3559737f2 --- /dev/null +++ b/packages/bruno-app/src/utils/common/ipc.js @@ -0,0 +1,14 @@ +/** + * Wrapper for ipcRenderer.invoke that handles error cases + * @param {string} channel - The IPC channel name + * @param {...any} args - Arguments to pass to the channel + * @returns {Promise} - Resolves with the result or rejects with error + */ +export const callIpc = (channel, ...args) => { + const { ipcRenderer } = window; + if (!ipcRenderer) { + return Promise.reject(new Error('IPC Renderer not available')); + } + + return ipcRenderer.invoke(channel, ...args); +}; \ 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 5c9889e13..7bd74c43b 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, getCollectionStats } = 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) { @@ -70,6 +70,10 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => { brunoConfig.ignore = ['node_modules', '.git']; } + const { size, filesCount } = await getCollectionStats(collectionPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + win.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', win, collectionPath, uid, brunoConfig); } catch (err) { diff --git a/packages/bruno-electron/src/app/watcher.js b/packages/bruno-electron/src/app/watcher.js index 43d01153d..b2b60fd55 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, bruToJsonViaWorker ,collectionBruToJson } = 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 { parseBruFileMeta, 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,7 +160,7 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => { } }; -const add = async (win, pathname, collectionUid, collectionPath) => { +const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread) => { console.log(`watcher add: ${pathname}`); if (isBrunoConfigFile(pathname, collectionPath)) { @@ -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,15 +254,67 @@ const add = async (win, pathname, collectionUid, collectionPath) => { } }; + const fileStats = fs.statSync(pathname); + let bruContent = fs.readFileSync(pathname, 'utf8'); + // If worker thread is not used, we can directly parse the file + if (!useWorkerThread) { + try { + file.data = await bruToJson(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 (error) { + console.error(error); + } + return; + } + try { - let bruContent = fs.readFileSync(pathname, 'utf8'); - - file.data = bruToJson(bruContent); + // we need to send a partial file info to the UI + // so that the UI can display the file in the collection tree + file.data = { + name: path.basename(pathname), + type: 'http-request' + }; + const metaJson = await bruToJson(parseBruFileMeta(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) { + // This is to update the loading indicator in the UI + file.data = metaJson; + file.partial = false; + file.loading = true; + hydrateRequestWithUuid(file.data, pathname); + win.webContents.send('main:collection-tree-updated', 'addFile', file); + + // This is to update the file info in the UI + file.data = await bruToJsonViaWorker(bruContent); + file.partial = false; + file.loading = false; + hydrateRequestWithUuid(file.data, pathname); + win.webContents.send('main:collection-tree-updated', 'addFile', file); + } + } catch(error) { + file.data = { + name: path.basename(pathname), + type: 'http-request' + }; + file.error = { + message: error?.message + }; + file.partial = true; + 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); } } }; @@ -357,7 +389,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 +410,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); @@ -424,10 +456,10 @@ const unlinkDir = (win, pathname, collectionUid, collectionPath) => { win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory); }; -const onWatcherSetupComplete = (win, collectionPath) => { +const onWatcherSetupComplete = (win, watchPath) => { const UiStateSnapshotStore = new UiStateSnapshot(); const collectionsSnapshotState = UiStateSnapshotStore.getCollections(); - const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == collectionPath); + const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == watchPath); win.webContents.send('main:hydrate-app-with-ui-state-snapshot', collectionSnapshotState); }; @@ -436,7 +468,7 @@ class Watcher { this.watchers = {}; } - addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false) { + addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread) { if (this.watchers[watchPath]) { this.watchers[watchPath].close(); } @@ -467,7 +499,7 @@ class Watcher { let startedNewWatcher = false; watcher .on('ready', () => onWatcherSetupComplete(win, watchPath)) - .on('add', (pathname) => add(win, pathname, collectionUid, watchPath)) + .on('add', (pathname) => add(win, pathname, collectionUid, watchPath, useWorkerThread)) .on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath)) .on('change', (pathname) => change(win, pathname, collectionUid, watchPath)) .on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath)) @@ -488,7 +520,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, useWorkerThread); } 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 7fe43218a..a641a95a7 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -7,10 +7,13 @@ const { collectionBruToJson: _collectionBruToJson, jsonToCollectionBru: _jsonToCollectionBru } = require('@usebruno/lang'); +const BruParserWorker = require('./workers'); -const collectionBruToJson = (bru) => { +const bruParserWorker = new BruParserWorker(); + +const collectionBruToJson = async (data, parsed = false) => { try { - const json = _collectionBruToJson(bru); + const json = parsed ? data : _collectionBruToJson(data); const transformedJson = { request: { @@ -38,7 +41,7 @@ const collectionBruToJson = (bru) => { } }; -const jsonToCollectionBru = (json, isFolder) => { +const jsonToCollectionBru = async (json, isFolder) => { try { const collectionBruJson = { headers: _.get(json, 'request.headers', []), @@ -73,7 +76,7 @@ const jsonToCollectionBru = (json, isFolder) => { } }; -const bruToEnvJson = (bru) => { +const bruToEnvJson = async (bru) => { try { const json = bruToEnvJsonV2(bru); @@ -90,7 +93,7 @@ const bruToEnvJson = (bru) => { } }; -const envJsonToBru = (json) => { +const envJsonToBru = async (json) => { try { const bru = envJsonToBruV2(json); return bru; @@ -105,12 +108,12 @@ const envJsonToBru = (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. + * @param {string} data The BRU file content. * @returns {object} The JSON representation of the BRU file. */ -const bruToJson = (bru) => { +const bruToJson = (data, parsed = false) => { try { - const json = bruToJsonV2(bru); + const json = parsed ? data : bruToJsonV2(data); let requestType = _.get(json, 'meta.type'); if (requestType === 'http') { @@ -149,6 +152,16 @@ const bruToJson = (bru) => { return Promise.reject(e); } }; + +const bruToJsonViaWorker = async (data) => { + try { + const json = await bruParserWorker?.bruToJson(data); + return bruToJson(json, true); + } catch (e) { + return Promise.reject(e); + } +}; + /** * The transformer function for converting a JSON to BRU file. * @@ -158,7 +171,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,14 +208,59 @@ const jsonToBru = (json) => { docs: _.get(json, 'request.docs', '') }; - return jsonToBruV2(bruJson); + const bru = jsonToBruV2(bruJson); + return bru; }; +const jsonToBruViaWorker = async (json) => { + let type = _.get(json, 'type'); + if (type === 'http-request') { + type = 'http'; + } else if (type === 'graphql-request') { + type = 'graphql'; + } else { + type = 'http'; + } + + const sequence = _.get(json, 'seq'); + const bruJson = { + meta: { + name: _.get(json, 'name'), + type: type, + seq: !isNaN(sequence) ? Number(sequence) : 1 + }, + http: { + method: _.lowerCase(_.get(json, 'request.method')), + url: _.get(json, 'request.url'), + auth: _.get(json, 'request.auth.mode', 'none'), + body: _.get(json, 'request.body.mode', 'none') + }, + params: _.get(json, 'request.params', []), + headers: _.get(json, 'request.headers', []), + auth: _.get(json, 'request.auth', {}), + body: _.get(json, 'request.body', {}), + script: _.get(json, 'request.script', {}), + vars: { + req: _.get(json, 'request.vars.req', []), + res: _.get(json, 'request.vars.res', []) + }, + assertions: _.get(json, 'request.assertions', []), + tests: _.get(json, 'request.tests', ''), + docs: _.get(json, 'request.docs', '') + }; + + const bru = await bruParserWorker?.jsonToBru(bruJson) + return bru; +}; + + module.exports = { bruToJson, + bruToJsonViaWorker, jsonToBru, bruToEnvJson, envJsonToBru, collectionBruToJson, - jsonToCollectionBru + jsonToCollectionBru, + jsonToBruViaWorker }; 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 000000000..62c19f99d --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/index.js @@ -0,0 +1,57 @@ +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'); +} + +/** + * Lanes are used to determine which worker queue to use based on the size of the data. + * + * The first lane is for smaller files (<0.1MB), the second lane is for larger files (>=0.1MB). + * This helps with parsing performance. + */ +const LANES = [{ + maxSize: 0.1 +},{ + maxSize: 100 +}]; + +class BruParserWorker { + constructor() { + this.workerQueues = LANES?.map(lane => ({ + maxSize: lane?.maxSize, + workerQueue: new WorkerQueue() + })); + } + + getWorkerQueue(size) { + // Find the first queue that can handle the given size + // or fallback to the last queue for largest files + const queueForSize = this.workerQueues.find((queue) => + queue.maxSize >= size + ); + + return queueForSize?.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` }); + } +} + +module.exports = BruParserWorker; \ 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 000000000..c1bbb44e7 --- /dev/null +++ b/packages/bruno-electron/src/bru/workers/scripts/bru-to-json.js @@ -0,0 +1,14 @@ +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); + parentPort.postMessage({ error: error?.message }); +} \ 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 000000000..e08be60b9 --- /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); + parentPort.postMessage({ error: error?.message }); +} \ 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 898324892..b9061a227 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, jsonToBruViaWorker, jsonToCollectionBru, bruToJsonViaWorker } = require('../bru'); const { isValidPathname, @@ -24,6 +24,8 @@ const { isWindowsOS, isValidFilename, hasSubDirectories, + getCollectionStats, + sizeInMB } = require('../utils/filesystem'); const { openCollectionDialog } = require('../app/collections'); const { generateUidBasedOnHash, stringifyJson, safeParseJSON, safeStringifyJSON } = require('../utils/common'); @@ -32,11 +34,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 { parseBruFileMeta, 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); @@ -97,6 +105,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection const content = await stringifyJson(brunoConfig); await writeFile(path.join(dirPath, 'bruno.json'), content); + const { size, filesCount } = await getCollectionStats(dirPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid, brunoConfig); } catch (error) { @@ -126,9 +138,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 +159,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection fs.copyFileSync(sourceFilePath, newFilePath); } - mainWindow.webContents.send('main:collection-opened', dirPath, uid, json); + const { size, filesCount } = await getCollectionStats(dirPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + + mainWindow.webContents.send('main:collection-opened', dirPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, dirPath, uid); } ); @@ -184,7 +200,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection name: folderName }; - const content = jsonToCollectionBru( + const content = await jsonToCollectionBru( folderRoot, true // isFolder ); @@ -197,7 +213,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 +229,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 jsonToBruViaWorker(request); await writeFile(pathname, content); } catch (error) { return Promise.reject(error); @@ -227,7 +243,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(`path: ${pathname} does not exist`); } - const content = jsonToBru(request); + const content = await jsonToBruViaWorker(request); await writeFile(pathname, content); } catch (error) { return Promise.reject(error); @@ -245,7 +261,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(`path: ${pathname} does not exist`); } - const content = jsonToBru(request); + const content = await jsonToBruViaWorker(request); await writeFile(pathname, content); } } catch (error) { @@ -275,7 +291,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 +316,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 +428,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 bruToJsonViaWorker(data); jsonData.name = newName; moveRequestUid(oldPath, newPath); - const content = jsonToBru(jsonData); + const content = await jsonToBruViaWorker(jsonData); await fs.promises.unlink(oldPath); await writeFile(newPath, content); @@ -516,9 +532,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 jsonToBruViaWorker(item); const filePath = path.join(currentPath, `${item.name}.bru`); fs.writeFileSync(filePath, content); } @@ -529,7 +545,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 +570,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 +595,19 @@ 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); + const { size, filesCount } = await getCollectionStats(collectionPath); + brunoConfig.size = size; + brunoConfig.filesCount = filesCount; + mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig); ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig); @@ -609,9 +629,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 jsonToBruViaWorker(item); const filePath = path.join(currentPath, `${item.name}.bru`); fs.writeFileSync(filePath, content); } @@ -621,7 +641,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 +659,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 +675,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 bruToJsonViaWorker(bru); if (jsonData.seq !== item.seq) { jsonData.seq = item.seq; - const content = jsonToBru(jsonData); + const content = await jsonToBruViaWorker(jsonData); await writeFile(item.pathname, content); } } @@ -776,6 +796,119 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection throw new Error(error.message); } }); + + ipcMain.handle('renderer:load-request-via-worker', 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(parseBruFileMeta(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); + file.data = await bruToJsonViaWorker(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(parseBruFileMeta(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', 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(parseBruFileMeta(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); + file.data = 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(parseBruFileMeta(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:mount-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => { + const { + size, + filesCount, + maxFileSize + } = await getCollectionStats(collectionPathname); + + const shouldLoadCollectionAsync = + (size > MAX_COLLECTION_SIZE_IN_MB) || + (filesCount > MAX_COLLECTION_FILES_COUNT) || + (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB); + + watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync); + }); }; const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => { @@ -790,8 +923,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); }); diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 15d5574e2..d0ec68ab1 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 parseBruFileMeta = (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, + parseBruFileMeta, + 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 d2f74d10e..0ab6bbf0a 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -211,6 +211,50 @@ 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); +} + module.exports = { isValidPathname, exists, @@ -235,5 +279,7 @@ module.exports = { isWindowsOS, safeToRename, isValidFilename, - hasSubDirectories + hasSubDirectories, + getCollectionStats, + sizeInMB }; diff --git a/packages/bruno-electron/src/workers/index.js b/packages/bruno-electron/src/workers/index.js new file mode 100644 index 000000000..04836e9fc --- /dev/null +++ b/packages/bruno-electron/src/workers/index.js @@ -0,0 +1,60 @@ +const { Worker } = require('worker_threads'); + +class WorkerQueue { + constructor() { + this.queue = []; + this.isProcessing = false; + } + + async enqueue(task) { + const { priority, scriptPath, data } = task; + + 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) => { + if (data?.error) { + reject(new Error(data?.error)); + } + resolve(data); + worker.terminate(); + }); + worker.on('error', (error) => { + reject(error); + worker.terminate(); + }); + worker.on('exit', (code) => { + reject(new Error(`stopped with ${code} exit code`)); + worker.terminate(); + }); + }); + } +} + +module.exports = WorkerQueue; diff --git a/packages/bruno-electron/tests/utils/collection.spec.js b/packages/bruno-electron/tests/utils/collection.spec.js new file mode 100644 index 000000000..4efc9c002 --- /dev/null +++ b/packages/bruno-electron/tests/utils/collection.spec.js @@ -0,0 +1,121 @@ +const { parseBruFileMeta } = require("../../src/utils/collection"); + +describe('parseBruFileMeta', () => { + test('parses valid meta block correctly', () => { + const data = `meta { + name: 0.2_mb + type: http + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + name: '0.2_mb', + type: 'http', + seq: 1, + }, + }); + }); + + test('returns undefined for missing meta block', () => { + const data = `someOtherBlock { + key: value + }`; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); + + test('handles empty meta block gracefully', () => { + const data = `meta {}`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ meta: {} }); + }); + + test('ignores invalid lines in meta block', () => { + const data = `meta { + name: 0.2_mb + invalidLine + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + name: '0.2_mb', + seq: 1, + }, + }); + }); + + test('handles unexpected input gracefully', () => { + const data = null; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); + + test('handles missing colon gracefully', () => { + const data = `meta { + name 0.2_mb + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + seq: 1, + }, + }); + }); + + test('parses numeric values correctly', () => { + const data = `meta { + numValue: 1234 + floatValue: 12.34 + strValue: some_text + }`; + + const result = parseBruFileMeta(data); + + expect(result).toEqual({ + meta: { + numValue: 1234, + floatValue: 12.34, + strValue: 'some_text', + }, + }); + }); + + test('handles syntax error in meta block 1', () => { + const data = `meta + name: 0.2_mb + type: http + seq: 1 + }`; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); + + test('handles syntax error in meta block 2', () => { + const data = `meta { + name: 0.2_mb + type: http + seq: 1 + `; + + const result = parseBruFileMeta(data); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/bruno-tests/collection_oauth2/bruno.json b/packages/bruno-tests/collection_oauth2/bruno.json index 66949e685..82816b2b5 100644 --- a/packages/bruno-tests/collection_oauth2/bruno.json +++ b/packages/bruno-tests/collection_oauth2/bruno.json @@ -1,9 +1,11 @@ { "version": "1", - "name": "collection_oauth2", + "name": "OAuth2 Demo", "type": "collection", "scripts": { - "moduleWhitelist": ["crypto"], + "moduleWhitelist": [ + "crypto" + ], "filesystemAccess": { "allow": true } @@ -15,4 +17,4 @@ "presets": { "requestType": "http" } -} +} \ No newline at end of file