From 62151330f2b1f5a52fa6d77998d977ed8e4842ad Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Tue, 29 Jul 2025 17:15:30 +0530 Subject: [PATCH] Fix: Loading state while collection mount (#5138) --- .../src/components/RunnerResults/index.jsx | 38 ++++++-- .../src/components/ShareCollection/index.js | 44 +++++++-- .../Sidebar/Collections/Collection/index.js | 15 +-- .../src/providers/App/useIpcEvents.js | 7 +- .../ReduxStore/slices/collections/index.js | 7 ++ .../bruno-app/src/utils/collections/index.js | 4 + .../src/app/collection-watcher.js | 97 ++++++++++++++++++- packages/bruno-electron/src/ipc/collection.js | 4 +- 8 files changed, 185 insertions(+), 31 deletions(-) diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index 736589f9c..a5dc83a1a 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -2,10 +2,10 @@ import React, { useState, useRef, useEffect } from 'react'; import path from 'utils/common/path'; import { useDispatch } from 'react-redux'; import { get, cloneDeep } from 'lodash'; -import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions'; +import { runCollectionFolder, cancelRunnerExecution, mountCollection } from 'providers/ReduxStore/slices/collections/actions'; import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections'; import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections'; -import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun } from '@tabler/icons'; +import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconLoader2 } from '@tabler/icons'; import ResponsePane from './ResponsePane'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; @@ -103,11 +103,24 @@ export default function RunnerResults({ collection }) { }) .filter(Boolean); + const ensureCollectionIsMounted = () => { + if(collection.mountStatus === 'mounted'){ + return; + } + dispatch(mountCollection({ + collectionUid: collection.uid, + collectionPathname: collection.pathname, + brunoConfig: collection.brunoConfig + })); + }; + const runCollection = () => { + ensureCollectionIsMounted(); dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags)); }; const runAgain = () => { + ensureCollectionIsMounted(); dispatch( runCollectionFolder( collection.uid, @@ -149,8 +162,12 @@ export default function RunnerResults({ collection }) {
You have {totalRequestsInCollection} requests in this collection. + {isCollectionLoading && ( + + (Loading...) + + )}
- {isCollectionLoading ?
Requests in this collection are still loading.
: null}
- +
+ - + +
); } diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index d0db00905..6b18ae837 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -1,6 +1,6 @@ import React from 'react'; import Modal from 'components/Modal'; -import { IconDownload } from '@tabler/icons'; +import { IconDownload, IconLoader2 } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; import Bruno from 'components/Bruno'; import exportBrunoCollection from 'utils/collections/export'; @@ -8,10 +8,12 @@ import exportPostmanCollection from 'utils/exporters/postman-collection'; import { cloneDeep } from 'lodash'; import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index'; import { useSelector } from 'react-redux'; -import { findCollectionByUid } from 'utils/collections/index'; +import { findCollectionByUid, areItemsLoading } from 'utils/collections/index'; const ShareCollection = ({ onClose, collectionUid }) => { const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid)); + const isCollectionLoading = areItemsLoading(collection); + const handleExportBrunoCollection = () => { const collectionCopy = cloneDeep(collection); exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); @@ -35,23 +37,49 @@ const ShareCollection = ({ onClose, collectionUid }) => { >
-
+
- + {isCollectionLoading ? ( + + ) : ( + + )}
Bruno Collection
-
Export in Bruno format
+
+ {isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'} +
-
+
- + {isCollectionLoading ? ( + + ) : ( + + )}
Postman Collection
-
Export in Postman format
+
+ {isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'} +
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 4b787fc3d..0f44b467a 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -61,13 +61,14 @@ const Collection = ({ collection, searchText }) => { }; const ensureCollectionIsMounted = () => { - if (collection.mountStatus === 'unmounted') { - dispatch(mountCollection({ - collectionUid: collection.uid, - collectionPathname: collection.pathname, - brunoConfig: collection.brunoConfig - })); + if(collection.mountStatus === 'mounted'){ + return; } + dispatch(mountCollection({ + collectionUid: collection.uid, + collectionPathname: collection.pathname, + brunoConfig: collection.brunoConfig + })); } const hasSearchText = searchText && searchText?.trim()?.length; @@ -269,6 +270,7 @@ const Collection = ({ collection, searchText }) => { className="dropdown-item" onClick={(e) => { menuDropdownTippyRef.current.hide(); + ensureCollectionIsMounted(); handleRun(); }} > @@ -287,6 +289,7 @@ const Collection = ({ collection, searchText }) => { className="dropdown-item" onClick={(e) => { menuDropdownTippyRef.current.hide(); + ensureCollectionIsMounted(); setShowShareCollectionModal(true); }} > diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 583e31725..34b3e3d5d 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -24,7 +24,7 @@ import toast from 'react-hot-toast'; import { useDispatch } from 'react-redux'; import { isElectron } from 'utils/common/platform'; import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments'; -import { collectionAddOauth2CredentialsByUrl } from 'providers/ReduxStore/slices/collections/index'; +import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index'; import { addLog } from 'providers/ReduxStore/slices/logs'; const useIpcEvents = () => { @@ -179,6 +179,10 @@ const useIpcEvents = () => { dispatch(collectionAddOauth2CredentialsByUrl(payload)); }); + const removeCollectionLoadingStateListener = ipcRenderer.on('main:collection-loading-state-updated', (val) => { + dispatch(updateCollectionLoadingState(val)); + }); + return () => { removeCollectionTreeUpdateListener(); removeOpenCollectionListener(); @@ -199,6 +203,7 @@ const useIpcEvents = () => { removeGlobalEnvironmentsUpdatesListener(); removeSnapshotHydrationListener(); removeCollectionOauth2CredentialsUpdatesListener(); + removeCollectionLoadingStateListener(); }; }, [isElectron]); }; 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 44db21df4..6805fa32b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -67,6 +67,12 @@ export const collectionsSlice = createSlice({ } } }, + updateCollectionLoadingState: (state, action) => { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + if (collection) { + collection.isLoading = action.payload.isLoading; + } + }, setCollectionSecurityConfig: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); if (collection) { @@ -2413,6 +2419,7 @@ export const collectionsSlice = createSlice({ export const { createCollection, updateCollectionMountStatus, + updateCollectionLoadingState, setCollectionSecurityConfig, brunoConfigUpdateEvent, renameCollection, diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index c5ef12b04..3ea85aa61 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -137,6 +137,10 @@ export const findEnvironmentInCollectionByName = (collection, name) => { }; export const areItemsLoading = (folder) => { + if (!folder || folder.isLoading) { + return true; + } + let flattenedItems = flattenItems(folder.items); return flattenedItems?.reduce((isLoading, i) => { if (i?.loading) { diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index 908e04ccf..e5f4503ac 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -166,7 +166,7 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => { } }; -const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread) => { +const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread, watcher) => { console.log(`watcher add: ${pathname}`); if (isBrunoConfigFile(pathname, collectionPath)) { @@ -251,6 +251,8 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread } if (hasBruExtension(pathname)) { + watcher.addFileToProcessing(collectionUid, pathname); + const file = { meta: { collectionUid, @@ -270,8 +272,11 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread file.size = sizeInMB(fileStats?.size); hydrateRequestWithUuid(file.data, pathname); win.webContents.send('main:collection-tree-updated', 'addFile', file); + } catch (error) { console.error(error); + } finally { + watcher.markFileAsProcessed(win, collectionUid, pathname); } return; } @@ -320,6 +325,8 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread file.size = sizeInMB(fileStats?.size); hydrateRequestWithUuid(file.data, pathname); win.webContents.send('main:collection-tree-updated', 'addFile', file); + } finally { + watcher.markFileAsProcessed(win, collectionUid, pathname); } } }; @@ -510,7 +517,10 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => { win.webContents.send('main:collection-tree-updated', 'unlinkDir', directory); }; -const onWatcherSetupComplete = (win, watchPath) => { +const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => { + // Mark discovery as complete + watcher.completeCollectionDiscovery(win, collectionUid); + const UiStateSnapshotStore = new UiStateSnapshot(); const collectionsSnapshotState = UiStateSnapshotStore.getCollections(); const collectionSnapshotState = collectionsSnapshotState?.find(c => c?.pathname == watchPath); @@ -520,6 +530,75 @@ const onWatcherSetupComplete = (win, watchPath) => { class CollectionWatcher { constructor() { this.watchers = {}; + this.loadingStates = {}; + } + + // Initialize loading state tracking for a collection + initializeLoadingState(collectionUid) { + if (!this.loadingStates[collectionUid]) { + this.loadingStates[collectionUid] = { + isDiscovering: false, // Initial discovery phase + isProcessing: false, // Processing discovered files + pendingFiles: new Set(), // Files that need processing + }; + } + } + + startCollectionDiscovery(win, collectionUid) { + this.initializeLoadingState(collectionUid); + const state = this.loadingStates[collectionUid]; + + state.isDiscovering = true; + state.pendingFiles.clear(); + + win.webContents.send('main:collection-loading-state-updated', { + collectionUid, + isLoading: true + }); + } + + addFileToProcessing(collectionUid, filepath) { + this.initializeLoadingState(collectionUid); + const state = this.loadingStates[collectionUid]; + state.pendingFiles.add(filepath); + } + + markFileAsProcessed(win, collectionUid, filepath) { + if (!this.loadingStates[collectionUid]) return; + + const state = this.loadingStates[collectionUid]; + state.pendingFiles.delete(filepath); + + // If discovery is complete and no pending files, mark as not loading + if (!state.isDiscovering && state.pendingFiles.size === 0 && state.isProcessing) { + state.isProcessing = false; + win.webContents.send('main:collection-loading-state-updated', { + collectionUid, + isLoading: false + }); + } + } + + completeCollectionDiscovery(win, collectionUid) { + if (!this.loadingStates[collectionUid]) return; + + const state = this.loadingStates[collectionUid]; + state.isDiscovering = false; + + // If there are pending files, start processing phase + if (state.pendingFiles.size > 0) { + state.isProcessing = true; + } else { + // No pending files, collection is fully loaded + win.webContents.send('main:collection-loading-state-updated', { + collectionUid, + isLoading: false + }); + } + } + + cleanupLoadingState(collectionUid) { + delete this.loadingStates[collectionUid]; } addWatcher(win, watchPath, collectionUid, brunoConfig, forcePolling = false, useWorkerThread) { @@ -527,6 +606,10 @@ class CollectionWatcher { this.watchers[watchPath].close(); } + this.initializeLoadingState(collectionUid); + + this.startCollectionDiscovery(win, collectionUid); + const ignores = brunoConfig?.ignore || []; setTimeout(() => { const watcher = chokidar.watch(watchPath, { @@ -552,8 +635,8 @@ class CollectionWatcher { let startedNewWatcher = false; watcher - .on('ready', () => onWatcherSetupComplete(win, watchPath)) - .on('add', (pathname) => add(win, pathname, collectionUid, watchPath, useWorkerThread)) + .on('ready', () => onWatcherSetupComplete(win, watchPath, collectionUid, this)) + .on('add', (pathname) => add(win, pathname, collectionUid, watchPath, useWorkerThread, this)) .on('addDir', (pathname) => addDirectory(win, pathname, collectionUid, watchPath)) .on('change', (pathname) => change(win, pathname, collectionUid, watchPath)) .on('unlink', (pathname) => unlink(win, pathname, collectionUid, watchPath)) @@ -588,11 +671,15 @@ class CollectionWatcher { return this.watchers[watchPath]; } - removeWatcher(watchPath, win) { + removeWatcher(watchPath, win, collectionUid) { if (this.watchers[watchPath]) { this.watchers[watchPath].close(); this.watchers[watchPath] = null; } + + if (collectionUid) { + this.cleanupLoadingState(collectionUid); + } } getWatcherByItemPath(itemPath) { diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 089507334..8ca5ac2f9 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -593,10 +593,10 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection } }); - ipcMain.handle('renderer:remove-collection', async (event, collectionPath) => { + ipcMain.handle('renderer:remove-collection', async (event, collectionPath, collectionUid) => { if (watcher && mainWindow) { console.log(`watcher stopWatching: ${collectionPath}`); - watcher.removeWatcher(collectionPath, mainWindow); + watcher.removeWatcher(collectionPath, mainWindow, collectionUid); lastOpenedCollections.remove(collectionPath); } });