diff --git a/packages/bruno-app/jsconfig.json b/packages/bruno-app/jsconfig.json
index a71bc3138..257b99e10 100644
--- a/packages/bruno-app/jsconfig.json
+++ b/packages/bruno-app/jsconfig.json
@@ -13,7 +13,8 @@
"api/*": ["src/api/*"],
"pageComponents/*": ["src/pageComponents/*"],
"providers/*": ["src/providers/*"],
- "utils/*": ["src/utils/*"]
+ "utils/*": ["src/utils/*"],
+ "store/*": ["src/store/*"]
}
},
"exclude": ["node_modules", "dist"]
diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js
index 888149f78..99051b5f4 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js
+++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js
@@ -1,7 +1,6 @@
-import React from 'react';
+import React, { useMemo } from 'react';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
-import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ShareCollection from 'components/ShareCollection/index';
@@ -11,10 +10,13 @@ import StyledWrapper from './StyledWrapper';
const Info = ({ collection }) => {
const dispatch = useDispatch();
- const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
- const isCollectionLoading = areItemsLoading(collection);
- const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
+ const isCollectionLoading = collection.isLoading;
+
+ const totalRequestsInCollection = useMemo(
+ () => getTotalRequestCountInCollection(collection),
+ [collection.items]
+ );
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
@@ -95,7 +97,9 @@ const Info = ({ collection }) => {
Requests
{
- isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
+ isCollectionLoading
+ ? 'Loading requests...'
+ : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
}
diff --git a/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js
new file mode 100644
index 000000000..81dd38e70
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Cache/StyledWrapper.js
@@ -0,0 +1,67 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ color: ${(props) => props.theme.text};
+
+ .cache-stats {
+ padding: 1rem;
+ border-radius: ${(props) => props.theme.border.radius.md};
+ background-color: ${(props) => props.theme.input.bg};
+ border: 1px solid ${(props) => props.theme.input.border};
+ margin-bottom: 1rem;
+ }
+
+ .stat-item {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid ${(props) => props.theme.input.border};
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ .stat-label {
+ font-size: ${(props) => props.theme.font.size.sm};
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .stat-value {
+ font-size: ${(props) => props.theme.font.size.sm};
+ font-weight: 500;
+ }
+
+ .purge-button {
+ padding: 0.5rem 1rem;
+ border-radius: ${(props) => props.theme.border.radius.sm};
+ font-size: ${(props) => props.theme.font.size.sm};
+ cursor: pointer;
+ background-color: ${(props) => props.theme.input.bg};
+ border: 1px solid ${(props) => props.theme.input.border};
+ color: ${(props) => props.theme.text};
+
+ &:hover:not(:disabled) {
+ border-color: ${(props) => props.theme.input.focusBorder};
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
+
+ .description {
+ font-size: ${(props) => props.theme.font.size.sm};
+ color: ${(props) => props.theme.colors.text.muted};
+ margin-top: 0.5rem;
+ }
+
+ .section-title {
+ font-weight: 600;
+ font-size: 0.875rem;
+ margin-bottom: 0.75rem;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Preferences/Cache/index.js b/packages/bruno-app/src/components/Preferences/Cache/index.js
new file mode 100644
index 000000000..a810a2a45
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Cache/index.js
@@ -0,0 +1,89 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import toast from 'react-hot-toast';
+import StyledWrapper from './StyledWrapper';
+
+const Cache = () => {
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [purging, setPurging] = useState(false);
+
+ const fetchStats = useCallback(async () => {
+ try {
+ const cacheStats = await window.ipcRenderer.invoke('renderer:get-cache-stats');
+ setStats(cacheStats);
+ } catch (error) {
+ console.error('Error fetching cache stats:', error);
+ setStats({ error: error.message });
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchStats();
+ }, [fetchStats]);
+
+ const handlePurgeCache = async () => {
+ setPurging(true);
+ try {
+ const result = await window.ipcRenderer.invoke('renderer:purge-cache');
+ if (result.success) {
+ toast.success('Cache purged successfully');
+ await fetchStats();
+ } else {
+ toast.error(result.error || 'Failed to purge cache');
+ }
+ } catch (error) {
+ console.error('Error purging cache:', error);
+ toast.error('Failed to purge cache');
+ } finally {
+ setPurging(false);
+ }
+ };
+
+ return (
+
+ Collection Cache
+
+ Bruno caches parsed collection files to improve loading performance. Clearing the cache will cause collections to be fully re-parsed on next load.
+
+
+
+ {loading ? (
+
+ Loading...
+
+ ) : stats?.error ? (
+
+ Error: {stats.error}
+
+ ) : (
+ <>
+
+ Cached Collections
+ {stats?.totalCollections ?? 0}
+
+
+ Cached Files
+ {stats?.totalFiles ?? 0}
+
+
+ Cache Version
+ {stats?.version ?? 'N/A'}
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+export default Cache;
diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js
index 547ffd09a..ea7c188bc 100644
--- a/packages/bruno-app/src/components/Preferences/index.js
+++ b/packages/bruno-app/src/components/Preferences/index.js
@@ -9,7 +9,8 @@ import {
IconUserCircle,
IconKeyboard,
IconZoomQuestion,
- IconSquareLetterB
+ IconSquareLetterB,
+ IconDatabase
} from '@tabler/icons';
import Support from './Support';
@@ -19,6 +20,7 @@ import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
+import Cache from './Cache';
import StyledWrapper from './StyledWrapper';
@@ -62,6 +64,10 @@ const Preferences = () => {
return ;
}
+ case 'cache': {
+ return ;
+ }
+
case 'support': {
return ;
}
@@ -92,6 +98,10 @@ const Preferences = () => {
Keybindings
+ setTab('cache')}>
+
+ Cache
+
setTab('support')}>
Support
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 90b830137..358d01131 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
@@ -38,7 +38,6 @@ import { isTabForItemActive } from 'src/selectors/tab';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
-import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from './GenerateDocumentation';
@@ -62,7 +61,7 @@ const Collection = ({ collection, searchText }) => {
const [dropType, setDropType] = useState(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const dispatch = useDispatch();
- const isLoading = areItemsLoading(collection);
+ const isLoading = collection.isLoading;
const collectionRef = useRef(null);
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js
index 1a2d9a925..6304997eb 100644
--- a/packages/bruno-app/src/providers/App/index.js
+++ b/packages/bruno-app/src/providers/App/index.js
@@ -5,6 +5,7 @@ import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import ConfirmAppClose from './ConfirmAppClose';
import useIpcEvents from './useIpcEvents';
import useTelemetry from './useTelemetry';
+import useParsedFileCacheIpc from './useParsedFileCacheIpc';
import StyledWrapper from './StyledWrapper';
import { version } from '../../../package.json';
@@ -13,6 +14,7 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => {
useTelemetry({ version });
useIpcEvents();
+ useParsedFileCacheIpc();
const dispatch = useDispatch();
useEffect(() => {
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index 84b95e1a6..1f8e6fbe5 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -11,6 +11,7 @@ import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
collectionAddFileEvent,
+ collectionBatchAddItems,
collectionChangeFileEvent,
collectionRenamedEvent,
collectionUnlinkDirectoryEvent,
@@ -101,6 +102,50 @@ const useIpcEvents = () => {
}
};
+ // Batch handler for collection tree updates (performance optimization)
+ // Uses a single Redux dispatch to process all items, avoiding multiple re-renders
+ const _collectionTreeBatchUpdated = (batch) => {
+ if (!batch || !Array.isArray(batch) || batch.length === 0) {
+ return;
+ }
+
+ if (window.__IS_DEV__) {
+ console.log('Batch update received:', batch.length, 'items');
+ }
+
+ // Separate batch items into those that can be bulk-processed vs those that need individual handling
+ const bulkItems = []; // addFile, addDir - can be processed in single reducer
+ const individualItems = []; // change, unlink, etc - need individual dispatches
+
+ batch.forEach(({ eventType, payload }) => {
+ if (eventType === 'addDir' || eventType === 'addFile') {
+ bulkItems.push({ eventType, payload });
+ } else {
+ individualItems.push({ eventType, payload });
+ }
+ });
+
+ // Process bulk items in a single dispatch (addFile and addDir)
+ if (bulkItems.length > 0) {
+ dispatch(collectionBatchAddItems({ items: bulkItems }));
+ }
+
+ // Process remaining items individually (these are typically rare during mount)
+ individualItems.forEach(({ eventType, payload }) => {
+ if (eventType === 'change') {
+ dispatch(collectionChangeFileEvent({ file: payload }));
+ } else if (eventType === 'unlink') {
+ dispatch(collectionUnlinkFileEvent({ file: payload }));
+ } else if (eventType === 'unlinkDir') {
+ dispatch(collectionUnlinkDirectoryEvent({ directory: payload }));
+ } else if (eventType === 'addEnvironmentFile') {
+ dispatch(collectionAddEnvFileEvent(payload));
+ } else if (eventType === 'unlinkEnvironmentFile') {
+ dispatch(collectionUnlinkEnvFileEvent(payload));
+ }
+ });
+ };
+
const _apiSpecTreeUpdated = (type, val) => {
if (window.__IS_DEV__) {
console.log('API Spec update:', type);
@@ -118,6 +163,8 @@ const useIpcEvents = () => {
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
+ const removeCollectionTreeBatchUpdateListener = ipcRenderer.on('main:collection-tree-batch-updated', _collectionTreeBatchUpdated);
+
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
@@ -340,6 +387,7 @@ const useIpcEvents = () => {
return () => {
removeCollectionTreeUpdateListener();
+ removeCollectionTreeBatchUpdateListener();
removeApiSpecTreeUpdateListener();
removeOpenCollectionListener();
removeOpenWorkspaceListener();
diff --git a/packages/bruno-app/src/providers/App/useParsedFileCacheIpc.js b/packages/bruno-app/src/providers/App/useParsedFileCacheIpc.js
new file mode 100644
index 000000000..426bf8c14
--- /dev/null
+++ b/packages/bruno-app/src/providers/App/useParsedFileCacheIpc.js
@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+import { isElectron } from 'utils/common/platform';
+import { parsedFileCacheStore } from 'store/parsedFileCache';
+
+const useParsedFileCacheIpc = () => {
+ useEffect(() => {
+ if (!isElectron()) {
+ return () => {};
+ }
+
+ const { ipcRenderer } = window;
+
+ const handleCacheRequest = async (operation, requestId, ...args) => {
+ try {
+ let result = null;
+ switch (operation) {
+ case 'getEntry':
+ result = await parsedFileCacheStore.getEntry(...args);
+ break;
+ case 'setEntry':
+ await parsedFileCacheStore.setEntry(...args);
+ break;
+ case 'invalidate':
+ await parsedFileCacheStore.invalidate(...args);
+ break;
+ case 'invalidateCollection':
+ await parsedFileCacheStore.invalidateCollection(...args);
+ break;
+ case 'invalidateDirectory':
+ await parsedFileCacheStore.invalidateDirectory(...args);
+ break;
+ case 'getStats':
+ result = await parsedFileCacheStore.getStats();
+ break;
+ case 'clear':
+ await parsedFileCacheStore.clear();
+ break;
+ default:
+ throw new Error(`Unknown cache operation: ${operation}`);
+ }
+ ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: true, data: result });
+ } catch (error) {
+ ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: false, error: error.message });
+ }
+ };
+
+ const removeListener = ipcRenderer.on('main:parsed-file-cache-request', handleCacheRequest);
+
+ // Prune old cache entries on startup
+ parsedFileCacheStore.prune().catch((err) => {
+ console.error('ParsedFileCacheStore: Error during startup prune:', err);
+ });
+
+ return () => {
+ removeListener();
+ };
+ }, []);
+};
+
+export default useParsedFileCacheIpc;
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
index 3d0730e42..969dd8d62 100644
--- a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
@@ -4,7 +4,7 @@ import filter from 'lodash/filter';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
-import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
+import { collectionAddFileEvent, collectionChangeFileEvent, collectionBatchAddItems } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
import { taskTypes } from './utils';
@@ -51,6 +51,57 @@ taskMiddleware.startListening({
}
});
+/*
+ * When files are added via batch processing (e.g., during collection mount or when new files are created),
+ * we need to check if any of the added files match pending OPEN_REQUEST tasks.
+ * This handles the case where file additions go through the batch reducer instead of individual events.
+ */
+taskMiddleware.startListening({
+ actionCreator: collectionBatchAddItems,
+ effect: (action, listenerApi) => {
+ const state = listenerApi.getState();
+ const items = action.payload?.items || [];
+
+ // Extract all addFile events from the batch
+ const addFileItems = items.filter((item) => item.eventType === 'addFile');
+ if (addFileItems.length === 0) return;
+
+ const openRequestTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_REQUEST });
+ if (openRequestTasks.length === 0) return;
+
+ each(addFileItems, ({ payload: file }) => {
+ const collectionUid = file?.meta?.collectionUid;
+ if (!collectionUid) return;
+
+ each(openRequestTasks, (task) => {
+ if (collectionUid === task.collectionUid && file?.meta?.pathname === task.itemPathname) {
+ const collection = findCollectionByUid(state.collections.collections, collectionUid);
+ if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
+ const item = findItemInCollectionByPathname(collection, task.itemPathname);
+ const isTransient = item?.isTransient ?? false;
+ if (item) {
+ listenerApi.dispatch(
+ addTab({
+ uid: item.uid,
+ collectionUid: collection.uid,
+ requestPaneTab: getDefaultRequestPaneTab(item),
+ preview: !isTransient
+ })
+ );
+ }
+ }
+
+ listenerApi.dispatch(
+ removeTaskFromQueue({
+ taskUid: task.uid
+ })
+ );
+ }
+ });
+ });
+ }
+});
+
/*
* When an example is created or cloned, a task to open the example is added to the queue.
* We wait for the File IO to complete, after which the "collectionChangeFileEvent" gets dispatched.
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 dc7220c74..3f7317b36 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -162,6 +162,7 @@ export const collectionsSlice = createSlice({
collection.settingsSelectedTab = 'overview';
collection.folderLevelSettingsSelectedTab = {};
collection.allTags = []; // Initialize collection-level tags
+ collection.isLoading = false;
// Collection mount status is used to track the mount status of the collection
// values can be 'unmounted', 'mounting', 'mounted'
@@ -2769,6 +2770,159 @@ export const collectionsSlice = createSlice({
addDepth(collection.items);
}
},
+ // Batch reducer for adding multiple files/directories in a single state update
+ // This is a performance optimization to avoid multiple re-renders during collection mount
+ collectionBatchAddItems: (state, action) => {
+ const { items } = action.payload;
+ if (!items || !Array.isArray(items) || items.length === 0) {
+ return;
+ }
+
+ // Group items by collection to minimize lookups
+ const itemsByCollection = new Map();
+ for (const item of items) {
+ const collectionUid = item.payload?.meta?.collectionUid || item.payload?.collectionUid;
+ if (!collectionUid) continue;
+
+ if (!itemsByCollection.has(collectionUid)) {
+ itemsByCollection.set(collectionUid, []);
+ }
+ itemsByCollection.get(collectionUid).push(item);
+ }
+
+ // Process each collection's items
+ for (const [collectionUid, collectionItems] of itemsByCollection) {
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) continue;
+ const tempDirectory = state.tempDirectories?.[collectionUid];
+
+ // Process directories first to ensure folder structure exists
+ const directories = collectionItems.filter((i) => i.eventType === 'addDir');
+ const files = collectionItems.filter((i) => i.eventType === 'addFile');
+
+ // Add directories
+ for (const { payload: dir } of directories) {
+ const isTransientDir = tempDirectory && dir.meta.pathname.startsWith(tempDirectory);
+ const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname);
+ let currentPath = collection.pathname;
+ let currentSubItems = collection.items;
+ for (const directoryName of subDirectories) {
+ let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
+ currentPath = path.join(currentPath, directoryName);
+ if (!childItem) {
+ childItem = {
+ uid: dir?.meta?.uid || uuid(),
+ pathname: currentPath,
+ name: dir?.meta?.name || directoryName,
+ seq: dir?.meta?.seq,
+ filename: directoryName,
+ collapsed: true,
+ type: 'folder',
+ items: [],
+ isTransient: isTransientDir
+ };
+ currentSubItems.push(childItem);
+ } else if (isTransientDir && !childItem.isTransient) {
+ childItem.isTransient = true;
+ }
+ currentSubItems = childItem.items;
+ }
+ }
+
+ // Add files
+ for (const { payload: file } of files) {
+ const isCollectionRoot = file.meta.collectionRoot ? true : false;
+ const isFolderRoot = file.meta.folderRoot ? true : false;
+ const isTransientFile = tempDirectory && file.meta.pathname.startsWith(tempDirectory);
+ if (isCollectionRoot) {
+ collection.root = mergeRootWithPreservedUids(collection.root, file.data);
+ continue;
+ }
+
+ if (isFolderRoot) {
+ const folderPath = path.dirname(file.meta.pathname);
+ const folderItem = findItemInCollectionByPathname(collection, folderPath);
+ if (folderItem) {
+ if (file?.data?.meta?.name) {
+ folderItem.name = file?.data?.meta?.name;
+ }
+ folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data);
+ if (file?.data?.meta?.seq) {
+ folderItem.seq = file.data?.meta?.seq;
+ }
+ }
+ continue;
+ }
+
+ const dirname = path.dirname(file.meta.pathname);
+ const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
+ let currentPath = collection.pathname;
+ let currentSubItems = collection.items;
+ for (const directoryName of subDirectories) {
+ let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
+ currentPath = path.join(currentPath, directoryName);
+ if (!childItem) {
+ childItem = {
+ uid: uuid(),
+ pathname: currentPath,
+ name: directoryName,
+ collapsed: true,
+ type: 'folder',
+ items: [],
+ isTransient: isTransientFile
+ };
+ currentSubItems.push(childItem);
+ } else if (isTransientFile && !childItem.isTransient) {
+ childItem.isTransient = true;
+ }
+ currentSubItems = childItem.items;
+ }
+
+ if (file.meta.name !== 'folder.bru' && !currentSubItems.find((f) => f.name === file.meta.name)) {
+ const currentItem = find(currentSubItems, (i) => i.uid === file.data.uid);
+ if (currentItem) {
+ currentItem.name = file.data.name;
+ currentItem.type = file.data.type;
+ currentItem.seq = file.data.seq;
+ currentItem.tags = file.data.tags;
+ currentItem.request = mergeRequestWithPreservedUids(currentItem.request, file.data.request);
+ currentItem.filename = file.meta.name;
+ currentItem.pathname = file.meta.pathname;
+ currentItem.settings = file.data.settings;
+ currentItem.examples = file.data.examples;
+ currentItem.draft = null;
+ currentItem.partial = file.partial;
+ currentItem.loading = file.loading;
+ currentItem.size = file.size;
+ currentItem.error = file.error;
+ currentItem.isTransient = isTransientFile;
+ } else {
+ currentSubItems.push({
+ uid: file.data.uid,
+ name: file.data.name,
+ type: file.data.type,
+ seq: file.data.seq,
+ tags: file.data.tags,
+ request: file.data.request,
+ settings: file.data.settings,
+ examples: file.data.examples,
+ filename: file.meta.name,
+ pathname: file.meta.pathname,
+ draft: null,
+ partial: file.partial,
+ loading: file.loading,
+ size: file.size,
+ error: file.error,
+ isTransient: isTransientFile
+ });
+ }
+ }
+ }
+
+ // Call addDepth once per collection after all items are added
+ addDepth(collection.items);
+ }
+ },
collectionChangeFileEvent: (state, action) => {
const { file } = action.payload;
const isCollectionRoot = file.meta.collectionRoot ? true : false;
@@ -3662,6 +3816,7 @@ export const {
updateCollectionProtobuf,
collectionAddFileEvent,
collectionAddDirectoryEvent,
+ collectionBatchAddItems,
collectionChangeFileEvent,
collectionUnlinkFileEvent,
collectionUnlinkDirectoryEvent,
diff --git a/packages/bruno-app/src/store/parsedFileCache.js b/packages/bruno-app/src/store/parsedFileCache.js
new file mode 100644
index 000000000..416e3577c
--- /dev/null
+++ b/packages/bruno-app/src/store/parsedFileCache.js
@@ -0,0 +1,249 @@
+import { openDB } from 'idb';
+import path from 'utils/common/path';
+
+const DB_NAME = 'bruno-parsed-file-cache';
+const STORE_NAME = 'parsedFiles';
+const DB_VERSION = 1;
+const CACHE_VERSION = '1.0.0';
+const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
+
+let dbPromise = null;
+
+const getDB = () => {
+ if (!dbPromise) {
+ dbPromise = openDB(DB_NAME, DB_VERSION, {
+ upgrade(db) {
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
+ store.createIndex('collectionPath', 'collectionPath');
+ store.createIndex('parsedAt', 'parsedAt');
+ }
+ }
+ }).catch((err) => {
+ dbPromise = null;
+ throw err;
+ });
+ }
+ return dbPromise;
+};
+
+const generateKey = (collectionPath, filePath) => {
+ return `${collectionPath}↝${filePath}`;
+};
+
+export const parsedFileCacheStore = {
+ async getEntry(collectionPath, filePath) {
+ try {
+ const db = await getDB();
+ const key = generateKey(collectionPath, filePath);
+ const entry = await db.get(STORE_NAME, key);
+
+ if (entry && typeof entry.mtimeMs === 'number' && entry.parsedData) {
+ return {
+ mtimeMs: entry.mtimeMs,
+ parsedData: entry.parsedData
+ };
+ }
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error reading cache entry:', error);
+ }
+ return null;
+ },
+
+ async setEntry(collectionPath, filePath, entry, retryAfterEviction = true) {
+ try {
+ const db = await getDB();
+ const key = generateKey(collectionPath, filePath);
+ const cacheEntry = {
+ key,
+ collectionPath,
+ filePath,
+ mtimeMs: entry.mtimeMs,
+ parsedData: entry.parsedData,
+ parsedAt: Date.now()
+ };
+ await db.put(STORE_NAME, cacheEntry);
+ } catch (error) {
+ // Handle QuotaExceededError by evicting old entries and retrying
+ const isQuotaError
+ = error.name === 'QuotaExceededError'
+ || error.code === 22 // Legacy Safari
+ || (error.code === 1014 && error.name === 'NS_ERROR_DOM_QUOTA_REACHED'); // Firefox
+
+ if (isQuotaError && retryAfterEviction) {
+ console.warn('ParsedFileCacheStore: Quota exceeded, evicting old entries...');
+ const evicted = await this.evictLRU();
+ if (evicted > 0) {
+ // Retry the write after eviction
+ return this.setEntry(collectionPath, filePath, entry, false);
+ }
+ console.warn('ParsedFileCacheStore: No entries to evict, cache write skipped');
+ } else {
+ console.error('ParsedFileCacheStore: Error writing cache entry:', error);
+ }
+ }
+ },
+
+ async invalidate(collectionPath, filePath) {
+ try {
+ const db = await getDB();
+ const key = generateKey(collectionPath, filePath);
+ await db.delete(STORE_NAME, key);
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error invalidating cache entry:', error);
+ }
+ },
+
+ async invalidateCollection(collectionPath) {
+ try {
+ const db = await getDB();
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const index = tx.store.index('collectionPath');
+
+ let cursor = await index.openCursor(IDBKeyRange.only(collectionPath));
+ while (cursor) {
+ await cursor.delete();
+ cursor = await cursor.continue();
+ }
+ await tx.done;
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error invalidating collection cache:', error);
+ }
+ },
+
+ async invalidateDirectory(collectionPath, dirPath) {
+ try {
+ const db = await getDB();
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const index = tx.store.index('collectionPath');
+ const normalizedDirPath = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`;
+
+ let cursor = await index.openCursor(IDBKeyRange.only(collectionPath));
+ while (cursor) {
+ if (cursor.value.filePath.startsWith(normalizedDirPath)) {
+ await cursor.delete();
+ }
+ cursor = await cursor.continue();
+ }
+ await tx.done;
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error invalidating directory cache:', error);
+ }
+ },
+
+ async moveEntry(collectionPath, oldFilePath, newFilePath) {
+ try {
+ const entry = await this.getEntry(collectionPath, oldFilePath);
+ if (entry) {
+ await this.invalidate(collectionPath, oldFilePath);
+ await this.setEntry(collectionPath, newFilePath, {
+ mtimeMs: entry.mtimeMs,
+ parsedData: entry.parsedData
+ });
+ }
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error moving cache entry:', error);
+ }
+ },
+
+ async prune(maxAgeMs = DEFAULT_MAX_AGE_MS) {
+ try {
+ const db = await getDB();
+ const cutoff = Date.now() - maxAgeMs;
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const index = tx.store.index('parsedAt');
+
+ let cursor = await index.openCursor(IDBKeyRange.upperBound(cutoff));
+ while (cursor) {
+ await cursor.delete();
+ cursor = await cursor.continue();
+ }
+ await tx.done;
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error pruning cache:', error);
+ }
+ },
+
+ /**
+ * Evict least recently used entries when quota is exceeded.
+ * Removes approximately 20% of the oldest entries to free up space.
+ * @returns {Promise} Number of entries evicted
+ */
+ async evictLRU(percentageToEvict = 0.2) {
+ try {
+ const db = await getDB();
+ const totalCount = await db.count(STORE_NAME);
+
+ if (totalCount === 0) {
+ return 0;
+ }
+
+ const countToEvict = Math.max(1, Math.floor(totalCount * percentageToEvict));
+
+ const tx = db.transaction(STORE_NAME, 'readwrite');
+ const index = tx.store.index('parsedAt');
+
+ let cursor = await index.openCursor();
+ let evicted = 0;
+
+ while (cursor && evicted < countToEvict) {
+ await cursor.delete();
+ evicted++;
+ cursor = await cursor.continue();
+ }
+
+ await tx.done;
+ console.log(`ParsedFileCacheStore: Evicted ${evicted} LRU entries to free up space`);
+ return evicted;
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error during LRU eviction:', error);
+ return 0;
+ }
+ },
+
+ async clear() {
+ try {
+ const db = await getDB();
+ await db.clear(STORE_NAME);
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error clearing cache:', error);
+ }
+ },
+
+ async getStats() {
+ try {
+ const db = await getDB();
+
+ // Use count() for O(1) total files count
+ const totalFiles = await db.count(STORE_NAME);
+
+ // Count unique collections using index with unique cursor
+ const tx = db.transaction(STORE_NAME, 'readonly');
+ const index = tx.store.index('collectionPath');
+ let totalCollections = 0;
+
+ // Use openKeyCursor with 'nextunique' to count unique collection paths
+ let cursor = await index.openKeyCursor(null, 'nextunique');
+ while (cursor) {
+ totalCollections++;
+ cursor = await cursor.continue();
+ }
+
+ return {
+ version: CACHE_VERSION,
+ totalCollections,
+ totalFiles
+ };
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error getting stats:', error);
+ return {
+ version: CACHE_VERSION,
+ totalCollections: 0,
+ totalFiles: 0,
+ error: error.message
+ };
+ }
+ }
+};
+
+export default parsedFileCacheStore;
diff --git a/packages/bruno-electron/src/app/collection-tree-batcher.js b/packages/bruno-electron/src/app/collection-tree-batcher.js
new file mode 100644
index 000000000..62bf1cbe3
--- /dev/null
+++ b/packages/bruno-electron/src/app/collection-tree-batcher.js
@@ -0,0 +1,196 @@
+/**
+ * CollectionTreeBatcher - Batches IPC events to reduce Redux dispatch overhead.
+ *
+ * Instead of sending individual 'main:collection-tree-updated' events for each file,
+ * this batcher collects events and sends them in batches, reducing the number of
+ * Redux updates and improving UI performance during collection mounting.
+ *
+ * Flush triggers:
+ * - Time-based: Every DISPATCH_INTERVAL_MS (200ms)
+ * - Size-based: When batch reaches MAX_BATCH_SIZE (300 items)
+ * - Manual: Call flush() directly (e.g., on watcher 'ready' event)
+ */
+
+const DISPATCH_INTERVAL_MS = 200;
+const MAX_BATCH_SIZE = 300;
+
+class CollectionTreeBatcher {
+ constructor(win, collectionUid) {
+ this.win = win;
+ this.queue = [];
+ this.timer = null;
+ this.isDestroyed = false;
+ // Bind methods
+ // We need to bind the methods because these are being called as callbacks to
+ // chokidar's add, addDir, change, unlink, unlinkDir events
+
+ this.add = this.add.bind(this);
+ this.flush = this.flush.bind(this);
+ this._scheduleFlush = this._scheduleFlush.bind(this);
+ }
+
+ /**
+ * Check if the window is still valid for sending events
+ */
+ _isWindowValid() {
+ return this.win && !this.win.isDestroyed() && !this.isDestroyed;
+ }
+
+ /**
+ * Schedule a flush after the dispatch interval
+ */
+ _scheduleFlush() {
+ if (this.timer || !this._isWindowValid()) {
+ return;
+ }
+
+ this.timer = setTimeout(() => {
+ this.timer = null;
+ this.flush();
+ }, DISPATCH_INTERVAL_MS);
+ }
+
+ /**
+ * Add an event to the batch queue
+ * @param {string} eventType - The event type ('addFile', 'addDir', 'change', 'unlink', 'unlinkDir')
+ * @param {object} payload - The event payload
+ */
+ add(eventType, payload) {
+ if (!this._isWindowValid()) {
+ return;
+ }
+
+ this.queue.push({
+ eventType,
+ payload
+ });
+
+ // Flush immediately if batch is full
+ if (this.queue.length >= MAX_BATCH_SIZE) {
+ this.flush();
+ } else {
+ this._scheduleFlush();
+ }
+ }
+
+ /**
+ * Flush the current batch to the renderer
+ */
+ flush() {
+ // Clear any pending timer
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+
+ if (this.queue.length === 0 || !this._isWindowValid()) {
+ return;
+ }
+
+ // Take all items from the queue
+ // This is a copy-type operation to avoid mutating the original
+ // Splice returns the deleted items
+ const batch = this.queue.splice(0);
+
+ try {
+ // Send the batch to the renderer
+ this.win.webContents.send('main:collection-tree-batch-updated', batch);
+ } catch (error) {
+ console.error('CollectionTreeBatcher: Error sending batch:', error);
+ }
+ }
+
+ /**
+ * Get the current queue size
+ * @returns {number} - The number of items in the queue
+ */
+ size() {
+ return this.queue.length;
+ }
+
+ /**
+ * Clear the queue without sending
+ */
+ clear() {
+ if (this.timer) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ this.queue = [];
+ }
+
+ /**
+ * Mark this batcher as destroyed (e.g., when window closes)
+ */
+ destroy() {
+ this.isDestroyed = true;
+ this.clear();
+ this.win = null;
+ }
+}
+
+// Store for managing batchers per collection
+const batchers = new Map();
+
+/**
+ * Get the batcher key for a window and collection UID
+ * @param {BrowserWindow} win - The Electron BrowserWindow
+ * @param {string} collectionUid - The collection UID
+ * @returns {string} - The batcher key
+ */
+const getBatcherKey = (win, collectionUid) => {
+ return `${win.id}-${collectionUid}`;
+};
+
+/**
+ * Get or create a CollectionTreeBatcher for a window
+ * @param {BrowserWindow} win - The Electron BrowserWindow
+ * @param {string} collectionUid - The collection UID
+ * @returns {CollectionTreeBatcher} - The batcher instance
+ */
+const getBatcher = (win, collectionUid) => {
+ const batcherKey = getBatcherKey(win, collectionUid);
+
+ if (!batchers.has(batcherKey)) {
+ const batcher = new CollectionTreeBatcher(win, collectionUid);
+
+ // Clean up when window is closed
+ win.once('closed', () => {
+ const b = batchers.get(batcherKey);
+ if (b) {
+ b.destroy();
+ batchers.delete(batcherKey);
+ }
+ });
+
+ batchers.set(batcherKey, batcher);
+ }
+
+ return batchers.get(batcherKey);
+};
+
+/**
+ * Remove a batcher for a window
+ * @param {BrowserWindow} win - The Electron BrowserWindow
+ * @param {string} collectionUid - The collection UID
+ */
+const removeBatcher = (win, collectionUid) => {
+ const batcherKey = getBatcherKey(win, collectionUid);
+ const batcher = batchers.get(batcherKey);
+
+ if (batcher) {
+ batcher.destroy();
+ batchers.delete(batcherKey);
+ }
+};
+
+// Export with backward-compatible aliases
+module.exports = {
+ CollectionTreeBatcher,
+ getBatcher,
+ removeBatcher,
+ // Backward-compatible aliases
+ BatchAggregator: CollectionTreeBatcher,
+ getAggregator: getBatcher,
+ removeAggregator: removeBatcher
+};
diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js
index 5f4c5fda2..d94c1e11d 100644
--- a/packages/bruno-electron/src/app/collection-watcher.js
+++ b/packages/bruno-electron/src/app/collection-watcher.js
@@ -1,5 +1,6 @@
const _ = require('lodash');
const fs = require('fs');
+const fsPromises = require('fs').promises;
const path = require('path');
const chokidar = require('chokidar');
const {
@@ -26,6 +27,8 @@ const UiStateSnapshot = require('../store/ui-state-snapshot');
const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
+const { parsedFileCacheStore } = require('../store/parsed-file-cache-idb');
+const { getBatcher } = require('./collection-tree-batcher');
const dotEnvWatcher = require('./dotenv-watcher');
const MAX_FILE_SIZE = 2.5 * 1024 * 1024;
@@ -309,61 +312,71 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
};
- const fileStats = fs.statSync(pathname);
- let content = fs.readFileSync(pathname, 'utf8');
+ const batcher = getBatcher(win, collectionUid);
- // If worker thread is not used, we can directly parse the file
- if (!useWorkerThread) {
- try {
+ try {
+ const fileStats = await fsPromises.stat(pathname);
+
+ const cachedEntry = await parsedFileCacheStore.getEntry(collectionPath, pathname);
+ if (cachedEntry && cachedEntry.mtimeMs === fileStats.mtimeMs) {
+ // Cache hit
+ file.data = cachedEntry.parsedData;
+ file.partial = false;
+ file.loading = false;
+ file.size = sizeInMB(fileStats?.size);
+ hydrateRequestWithUuid(file.data, pathname);
+ batcher.add('addFile', file);
+ watcher.markFileAsProcessed(win, collectionUid, pathname);
+ return;
+ }
+
+ // Cache miss
+ const content = await fsPromises.readFile(pathname, 'utf8');
+
+ if (!useWorkerThread) {
file.data = await parseRequest(content, { format });
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);
- } finally {
+ batcher.add('addFile', file);
+
+ await parsedFileCacheStore.setEntry(collectionPath, pathname, {
+ mtimeMs: fileStats.mtimeMs,
+ parsedData: file.data
+ });
watcher.markFileAsProcessed(win, collectionUid, pathname);
+ return;
}
- return;
- }
-
- try {
- // 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 = parseFileMeta(content, format);
- 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 parseRequestViaWorker(content, {
format,
filename: pathname
});
file.partial = false;
file.loading = false;
+ file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
- win.webContents.send('main:collection-tree-updated', 'addFile', file);
+ batcher.add('addFile', file);
+
+ await parsedFileCacheStore.setEntry(collectionPath, pathname, {
+ mtimeMs: fileStats.mtimeMs,
+ parsedData: file.data
+ });
+ } else {
+ const metaJson = parseFileMeta(content, format);
+ file.data = metaJson;
+ file.partial = true;
+ file.loading = false;
+ file.size = sizeInMB(fileStats?.size);
+ hydrateRequestWithUuid(file.data, pathname);
+ batcher.add('addFile', file);
}
+
+ watcher.markFileAsProcessed(win, collectionUid, pathname);
} catch (error) {
+ console.error(`Error processing file ${pathname}:`, error);
file.data = {
name: path.basename(pathname),
type: 'http-request'
@@ -373,10 +386,8 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
};
file.partial = true;
file.loading = false;
- file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
- win.webContents.send('main:collection-tree-updated', 'addFile', file);
- } finally {
+ batcher.add('addFile', file);
watcher.markFileAsProcessed(win, collectionUid, pathname);
}
}
@@ -396,15 +407,16 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const folderFilePath = path.join(pathname, `folder.${format}`);
try {
- if (fs.existsSync(folderFilePath)) {
- let folderFileContent = fs.readFileSync(folderFilePath, 'utf8');
- let folderData = await parseFolder(folderFileContent, { format });
- name = folderData?.meta?.name || name;
- seq = folderData?.meta?.seq;
- }
+ await fsPromises.access(folderFilePath);
+ const folderFileContent = await fsPromises.readFile(folderFilePath, 'utf8');
+ const folderData = await parseFolder(folderFileContent, { format });
+ name = folderData?.meta?.name || name;
+ seq = folderData?.meta?.seq;
} catch (error) {
- console.error(`Error occured while parsing folder.${format} file`);
- console.error(error);
+ if (error.code !== 'ENOENT') {
+ console.error(`Error occurred while parsing folder.${format} file`);
+ console.error(error);
+ }
}
const directory = {
@@ -417,7 +429,8 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
}
};
- win.webContents.send('main:collection-tree-updated', 'addDir', directory);
+ const batcher = getBatcher(win, collectionUid);
+ batcher.add('addDir', directory);
};
const change = async (win, pathname, collectionUid, collectionPath) => {
@@ -524,6 +537,9 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
const format = getCollectionFormat(collectionPath);
if (hasRequestExtension(pathname, format)) {
+ // Invalidate cache for this file since it changed
+ await parsedFileCacheStore.invalidate(collectionPath, pathname);
+
try {
const file = {
meta: {
@@ -544,6 +560,14 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
+
+ // Update cache with new parsed data
+ await parsedFileCacheStore.setEntry(collectionPath, pathname, {
+ mtimeMs: fileStats.mtimeMs,
+ parsedData: file.data
+ });
+
+ // Change events are not batched - they need immediate feedback
win.webContents.send('main:collection-tree-updated', 'change', file);
} catch (err) {
console.error(err);
@@ -551,7 +575,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
}
};
-const unlink = (win, pathname, collectionUid, collectionPath) => {
+const unlink = async (win, pathname, collectionUid, collectionPath) => {
console.log(`watcher unlink: ${pathname}`);
if (isEnvironmentsFolder(pathname, collectionPath)) {
@@ -560,6 +584,9 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
const format = getCollectionFormat(collectionPath);
if (hasRequestExtension(pathname, format)) {
+ // Invalidate cache for deleted file
+ await parsedFileCacheStore.invalidate(collectionPath, pathname);
+
const basename = path.basename(pathname);
const dirname = path.dirname(pathname);
@@ -585,6 +612,8 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
return;
}
+ await parsedFileCacheStore.invalidateDirectory(collectionPath, pathname);
+
const format = getCollectionFormat(collectionPath);
const folderFilePath = path.join(pathname, `folder.${format}`);
@@ -607,7 +636,9 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
};
const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
- // Mark discovery as complete
+ const batcher = getBatcher(win, collectionUid);
+ batcher.flush();
+
watcher.completeCollectionDiscovery(win, collectionUid);
const UiStateSnapshotStore = new UiStateSnapshot();
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index a3402a3e1..2c7129336 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -40,6 +40,7 @@ const registerNetworkIpc = require('./ipc/network');
const registerCollectionsIpc = require('./ipc/collection');
const registerFilesystemIpc = require('./ipc/filesystem');
const registerPreferencesIpc = require('./ipc/preferences');
+const { parsedFileCacheStore } = require('./store/parsed-file-cache-idb');
const registerSystemMonitorIpc = require('./ipc/system-monitor');
const registerWorkspaceIpc = require('./ipc/workspace');
const registerApiSpecIpc = require('./ipc/apiSpec');
@@ -440,6 +441,9 @@ app.on('ready', async () => {
});
});
+ // Initialize the parsed file cache IPC handlers
+ parsedFileCacheStore.initialize(mainWindow);
+
// register all ipc handlers
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);
diff --git a/packages/bruno-electron/src/ipc/preferences.js b/packages/bruno-electron/src/ipc/preferences.js
index f9d1b387f..1a2deb28c 100644
--- a/packages/bruno-electron/src/ipc/preferences.js
+++ b/packages/bruno-electron/src/ipc/preferences.js
@@ -2,6 +2,7 @@ const { ipcMain, nativeTheme } = require('electron');
const { getPreferences, savePreferences } = require('../store/preferences');
const { getGitVersion } = require('../utils/git');
const { globalEnvironmentsStore } = require('../store/global-environments');
+const { parsedFileCacheStore } = require('../store/parsed-file-cache-idb');
const { getCachedSystemProxy, refreshSystemProxy } = require('../store/system-proxy');
const registerPreferencesIpc = (mainWindow) => {
@@ -43,6 +44,25 @@ const registerPreferencesIpc = (mainWindow) => {
nativeTheme.themeSource = theme;
});
+ ipcMain.handle('renderer:get-cache-stats', async () => {
+ try {
+ return await parsedFileCacheStore.getStats();
+ } catch (error) {
+ console.error('Error getting cache stats:', error);
+ return { error: error.message };
+ }
+ });
+
+ ipcMain.handle('renderer:purge-cache', async () => {
+ try {
+ await parsedFileCacheStore.clear();
+ return { success: true };
+ } catch (error) {
+ console.error('Error purging cache:', error);
+ return { success: false, error: error.message };
+ }
+ });
+
ipcMain.handle('renderer:get-system-proxy-variables', async () => {
// Return cached value (initialized at app startup)
const cachedProxy = getCachedSystemProxy();
diff --git a/packages/bruno-electron/src/store/parsed-file-cache-idb.js b/packages/bruno-electron/src/store/parsed-file-cache-idb.js
new file mode 100644
index 000000000..6a65109b8
--- /dev/null
+++ b/packages/bruno-electron/src/store/parsed-file-cache-idb.js
@@ -0,0 +1,157 @@
+const { ipcMain } = require('electron');
+const { v4: uuidv4 } = require('uuid');
+
+// Pending requests waiting for renderer response
+const pendingRequests = new Map();
+
+// Timeout for IPC requests (5 seconds)
+const REQUEST_TIMEOUT = 5000;
+
+// Store reference to main window
+let mainWindow = null;
+
+// Initialize the IPC response handler
+const initializeCacheIpc = (win) => {
+ mainWindow = win;
+
+ ipcMain.on('renderer:parsed-file-cache-response', (event, response) => {
+ const { requestId, success, data, error } = response;
+ const pending = pendingRequests.get(requestId);
+
+ if (pending) {
+ pendingRequests.delete(requestId);
+ clearTimeout(pending.timeout);
+
+ if (success) {
+ pending.resolve(data);
+ } else {
+ pending.reject(new Error(error || 'Unknown error'));
+ }
+ }
+ });
+};
+
+// Send a request to the renderer and wait for response
+const sendCacheRequest = (operation, ...args) => {
+ return new Promise((resolve, reject) => {
+ if (!mainWindow || mainWindow.isDestroyed()) {
+ resolve(null);
+ return;
+ }
+
+ const requestId = uuidv4();
+
+ const timeout = setTimeout(() => {
+ pendingRequests.delete(requestId);
+ resolve(null);
+ }, REQUEST_TIMEOUT);
+
+ pendingRequests.set(requestId, { resolve, reject, timeout });
+
+ mainWindow.webContents.send('main:parsed-file-cache-request', operation, requestId, ...args);
+ });
+};
+
+class ParsedFileCacheStore {
+ constructor() {
+ this.initialized = false;
+ }
+
+ initialize(win) {
+ if (!this.initialized) {
+ initializeCacheIpc(win);
+ this.initialized = true;
+ }
+ }
+
+ async getEntry(collectionPath, filePath) {
+ try {
+ return await sendCacheRequest('getEntry', collectionPath, filePath);
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error reading cache entry:', error);
+ return null;
+ }
+ }
+
+ async setEntry(collectionPath, filePath, entry) {
+ try {
+ await sendCacheRequest('setEntry', collectionPath, filePath, entry);
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error writing cache entry:', error);
+ }
+ }
+
+ async invalidate(collectionPath, filePath) {
+ try {
+ await sendCacheRequest('invalidate', collectionPath, filePath);
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error invalidating cache entry:', error);
+ }
+ }
+
+ async invalidateCollection(collectionPath) {
+ try {
+ await sendCacheRequest('invalidateCollection', collectionPath);
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error invalidating collection cache:', error);
+ }
+ }
+
+ async invalidateDirectory(collectionPath, dirPath) {
+ try {
+ await sendCacheRequest('invalidateDirectory', collectionPath, dirPath);
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error invalidating directory cache:', error);
+ }
+ }
+
+ async moveEntry(collectionPath, oldFilePath, newFilePath) {
+ const entry = await this.getEntry(collectionPath, oldFilePath);
+ if (entry) {
+ await this.invalidate(collectionPath, oldFilePath);
+ await this.setEntry(collectionPath, newFilePath, {
+ mtimeMs: entry.mtimeMs,
+ parsedData: entry.parsedData
+ });
+ }
+ }
+
+ async getStats() {
+ try {
+ const stats = await sendCacheRequest('getStats');
+ return stats || {
+ version: '1.0.0',
+ totalCollections: 0,
+ totalFiles: 0
+ };
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error getting stats:', error);
+ return {
+ version: '1.0.0',
+ totalCollections: 0,
+ totalFiles: 0,
+ error: error.message
+ };
+ }
+ }
+
+ async clear() {
+ try {
+ await sendCacheRequest('clear');
+ } catch (error) {
+ console.error('ParsedFileCacheStore: Error clearing cache:', error);
+ }
+ }
+
+ async close() {
+ // No-op for IndexedDB version (managed by browser)
+ }
+}
+
+// Singleton instance
+const parsedFileCacheStore = new ParsedFileCacheStore();
+
+module.exports = {
+ parsedFileCacheStore,
+ ParsedFileCacheStore
+};
diff --git a/packages/bruno-electron/tests/app/collection-tree-batcher.spec.js b/packages/bruno-electron/tests/app/collection-tree-batcher.spec.js
new file mode 100644
index 000000000..4cbe66c11
--- /dev/null
+++ b/packages/bruno-electron/tests/app/collection-tree-batcher.spec.js
@@ -0,0 +1,312 @@
+const { CollectionTreeBatcher, getBatcher, removeBatcher } = require('../../src/app/collection-tree-batcher');
+
+// Mock BrowserWindow
+const createMockWindow = (id = 1) => {
+ const listeners = {};
+ return {
+ id,
+ isDestroyed: jest.fn(() => false),
+ once: jest.fn((event, callback) => {
+ listeners[event] = callback;
+ }),
+ emit: (event) => {
+ if (listeners[event]) {
+ listeners[event]();
+ }
+ },
+ webContents: {
+ send: jest.fn()
+ }
+ };
+};
+
+describe('CollectionTreeBatcher', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('constructor', () => {
+ it('should initialize with empty queue and no timer', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ expect(batcher.queue).toEqual([]);
+ expect(batcher.timer).toBeNull();
+ expect(batcher.isDestroyed).toBe(false);
+ });
+ });
+
+ describe('add()', () => {
+ it('should add events to the queue', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+
+ expect(batcher.queue).toHaveLength(1);
+ expect(batcher.queue[0]).toEqual({
+ eventType: 'addFile',
+ payload: { path: '/test/file.bru' }
+ });
+ });
+
+ it('should schedule a flush after adding an event', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+
+ expect(batcher.timer).not.toBeNull();
+ });
+
+ it('should not add events if window is destroyed', () => {
+ const win = createMockWindow();
+ win.isDestroyed.mockReturnValue(true);
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+
+ expect(batcher.queue).toHaveLength(0);
+ });
+
+ it('should not add events if batcher is destroyed', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+ batcher.destroy();
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+
+ expect(batcher.queue).toHaveLength(0);
+ });
+ });
+
+ describe('flush()', () => {
+ it('should send batch to renderer and clear queue', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file1.bru' });
+ batcher.add('addDir', { path: '/test/folder' });
+
+ batcher.flush();
+
+ expect(win.webContents.send).toHaveBeenCalledWith('main:collection-tree-batch-updated', [
+ { eventType: 'addFile', payload: { path: '/test/file1.bru' } },
+ { eventType: 'addDir', payload: { path: '/test/folder' } }
+ ]);
+ expect(batcher.queue).toHaveLength(0);
+ });
+
+ it('should not send if queue is empty', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.flush();
+
+ expect(win.webContents.send).not.toHaveBeenCalled();
+ });
+
+ it('should clear pending timer on flush', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+ expect(batcher.timer).not.toBeNull();
+
+ batcher.flush();
+ expect(batcher.timer).toBeNull();
+ });
+
+ it('should not send if window is destroyed', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+ win.isDestroyed.mockReturnValue(true);
+
+ batcher.flush();
+
+ expect(win.webContents.send).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('time-based flush', () => {
+ it('should auto-flush after DISPATCH_INTERVAL_MS (200ms)', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+
+ expect(win.webContents.send).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(200);
+
+ expect(win.webContents.send).toHaveBeenCalledWith('main:collection-tree-batch-updated', [
+ { eventType: 'addFile', payload: { path: '/test/file.bru' } }
+ ]);
+ });
+
+ it('should not schedule multiple timers', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file1.bru' });
+ const firstTimer = batcher.timer;
+
+ batcher.add('addFile', { path: '/test/file2.bru' });
+
+ expect(batcher.timer).toBe(firstTimer);
+ });
+ });
+
+ describe('size-based flush', () => {
+ it('should auto-flush when reaching MAX_BATCH_SIZE (300)', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ // Add 299 events - should not flush
+ for (let i = 0; i < 299; i++) {
+ batcher.add('addFile', { path: `/test/file${i}.bru` });
+ }
+ expect(win.webContents.send).not.toHaveBeenCalled();
+ expect(batcher.queue).toHaveLength(299);
+
+ // Add 300th event - should trigger flush
+ batcher.add('addFile', { path: '/test/file299.bru' });
+
+ expect(win.webContents.send).toHaveBeenCalledTimes(1);
+ expect(batcher.queue).toHaveLength(0);
+ });
+ });
+
+ describe('size()', () => {
+ it('should return current queue size', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ expect(batcher.size()).toBe(0);
+
+ batcher.add('addFile', { path: '/test/file1.bru' });
+ expect(batcher.size()).toBe(1);
+
+ batcher.add('addFile', { path: '/test/file2.bru' });
+ expect(batcher.size()).toBe(2);
+ });
+ });
+
+ describe('clear()', () => {
+ it('should clear the queue without sending', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+ batcher.clear();
+
+ expect(batcher.queue).toHaveLength(0);
+ expect(batcher.timer).toBeNull();
+ expect(win.webContents.send).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('destroy()', () => {
+ it('should mark batcher as destroyed and clear queue', () => {
+ const win = createMockWindow();
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+ batcher.destroy();
+
+ expect(batcher.isDestroyed).toBe(true);
+ expect(batcher.queue).toHaveLength(0);
+ expect(batcher.win).toBeNull();
+ });
+ });
+
+ describe('error handling', () => {
+ it('should handle send errors gracefully', () => {
+ const win = createMockWindow();
+ win.webContents.send.mockImplementation(() => {
+ throw new Error('Window closed');
+ });
+ const batcher = new CollectionTreeBatcher(win, 'collection-1');
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+ batcher.flush();
+
+ expect(consoleSpy).toHaveBeenCalledWith('CollectionTreeBatcher: Error sending batch:', expect.any(Error));
+ consoleSpy.mockRestore();
+ });
+ });
+});
+
+describe('getBatcher / removeBatcher', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should create and return a batcher for a window', () => {
+ const win = createMockWindow(100);
+ const batcher = getBatcher(win, 'collection-1');
+
+ expect(batcher).toBeInstanceOf(CollectionTreeBatcher);
+ });
+
+ it('should return the same batcher for the same window and collection', () => {
+ const win = createMockWindow(101);
+ const batcher1 = getBatcher(win, 'collection-1');
+ const batcher2 = getBatcher(win, 'collection-1');
+
+ expect(batcher1).toBe(batcher2);
+ });
+
+ it('should return different batchers for different collections', () => {
+ const win = createMockWindow(102);
+ const batcher1 = getBatcher(win, 'collection-1');
+ const batcher2 = getBatcher(win, 'collection-2');
+
+ expect(batcher1).not.toBe(batcher2);
+ });
+
+ it('should return different batchers for different windows', () => {
+ const win1 = createMockWindow(103);
+ const win2 = createMockWindow(104);
+ const batcher1 = getBatcher(win1, 'collection-1');
+ const batcher2 = getBatcher(win2, 'collection-1');
+
+ expect(batcher1).not.toBe(batcher2);
+ });
+
+ it('should clean up batcher when window is closed', () => {
+ const win = createMockWindow(105);
+ const batcher = getBatcher(win, 'collection-1');
+
+ batcher.add('addFile', { path: '/test/file.bru' });
+
+ // Simulate window close
+ win.emit('closed');
+
+ expect(batcher.isDestroyed).toBe(true);
+ });
+
+ it('should remove batcher with removeBatcher', () => {
+ const win = createMockWindow(106);
+ const batcher = getBatcher(win, 'collection-1');
+
+ removeBatcher(win, 'collection-1');
+
+ expect(batcher.isDestroyed).toBe(true);
+
+ // Getting batcher again should create a new one
+ const newBatcher = getBatcher(win, 'collection-1');
+ expect(newBatcher).not.toBe(batcher);
+ });
+});