Revert "Performance/file parse and mount (#6975)" (#7360)

* Revert "Performance/file parse and mount (#6975)"

This reverts commit f76f487211.

* fix: import duplication

* Revert "fix(batch-events): fix order of directory file and folder events (#7300)"

This reverts commit bf4af42a25.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
Chirag Chandrashekhar
2026-03-04 19:20:26 +05:30
committed by GitHub
parent 17c3dc0e2b
commit 0b7cd0e540
18 changed files with 62 additions and 1615 deletions

View File

@@ -13,8 +13,7 @@
"api/*": ["src/api/*"],
"pageComponents/*": ["src/pageComponents/*"],
"providers/*": ["src/providers/*"],
"utils/*": ["src/utils/*"],
"store/*": ["src/store/*"]
"utils/*": ["src/utils/*"]
}
},
"exclude": ["node_modules", "dist"]

View File

@@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import React 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';
@@ -10,13 +11,10 @@ import StyledWrapper from './StyledWrapper';
const Info = ({ collection }) => {
const dispatch = useDispatch();
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
const isCollectionLoading = collection.isLoading;
const totalRequestsInCollection = useMemo(
() => getTotalRequestCountInCollection(collection),
[collection.items]
);
const isCollectionLoading = areItemsLoading(collection);
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
@@ -97,9 +95,7 @@ const Info = ({ collection }) => {
<div className="font-medium">Requests</div>
<div className="mt-1 text-muted">
{
isCollectionLoading
? 'Loading requests...'
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
}
</div>
</div>

View File

@@ -1,67 +0,0 @@
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;

View File

@@ -1,89 +0,0 @@
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 (
<StyledWrapper className="w-full">
<div className="section-title">Collection Cache</div>
<p className="description mb-4">
Bruno caches parsed collection files to improve loading performance. Clearing the cache will cause collections to be fully re-parsed on next load.
</p>
<div className="cache-stats">
{loading ? (
<div className="stat-item">
<span className="stat-label">Loading...</span>
</div>
) : stats?.error ? (
<div className="stat-item">
<span className="stat-label">Error: {stats.error}</span>
</div>
) : (
<>
<div className="stat-item">
<span className="stat-label">Cached Collections</span>
<span className="stat-value">{stats?.totalCollections ?? 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Cached Files</span>
<span className="stat-value">{stats?.totalFiles ?? 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Cache Version</span>
<span className="stat-value">{stats?.version ?? 'N/A'}</span>
</div>
</>
)}
</div>
<button
className="purge-button"
onClick={handlePurgeCache}
disabled={purging || loading}
>
{purging ? 'Purging...' : 'Purge Cache'}
</button>
</StyledWrapper>
);
};
export default Cache;

View File

@@ -9,8 +9,7 @@ import {
IconUserCircle,
IconKeyboard,
IconZoomQuestion,
IconSquareLetterB,
IconDatabase
IconSquareLetterB
} from '@tabler/icons';
import Support from './Support';
@@ -20,7 +19,6 @@ import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
import Cache from './Cache';
import StyledWrapper from './StyledWrapper';
@@ -64,10 +62,6 @@ const Preferences = () => {
return <Beta />;
}
case 'cache': {
return <Cache />;
}
case 'support': {
return <Support />;
}
@@ -98,10 +92,6 @@ const Preferences = () => {
<IconKeyboard size={16} strokeWidth={1.5} />
Keybindings
</div>
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
<IconDatabase size={16} strokeWidth={1.5} />
Cache
</div>
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
<IconZoomQuestion size={16} strokeWidth={1.5} />
Support

View File

@@ -67,7 +67,7 @@ const Collection = ({ collection, searchText }) => {
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [showEmptyState, setShowEmptyState] = useState(false);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const isLoading = collection.isLoading;
const collectionRef = useRef(null);
const itemCount = collection.items?.length || 0;

View File

@@ -5,7 +5,6 @@ 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';
@@ -14,7 +13,6 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => {
useTelemetry({ version });
useIpcEvents();
useParsedFileCacheIpc();
const dispatch = useDispatch();
useEffect(() => {

View File

@@ -11,7 +11,6 @@ import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
collectionAddFileEvent,
collectionBatchAddItems,
collectionChangeFileEvent,
collectionRenamedEvent,
collectionUnlinkDirectoryEvent,
@@ -102,50 +101,6 @@ 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);
@@ -163,8 +118,6 @@ 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) => {
@@ -387,7 +340,6 @@ const useIpcEvents = () => {
return () => {
removeCollectionTreeUpdateListener();
removeCollectionTreeBatchUpdateListener();
removeApiSpecTreeUpdateListener();
removeOpenCollectionListener();
removeOpenWorkspaceListener();

View File

@@ -1,60 +0,0 @@
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;

View File

@@ -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, collectionBatchAddItems } from 'providers/ReduxStore/slices/collections';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
import { taskTypes } from './utils';
@@ -51,57 +51,6 @@ 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.

View File

@@ -162,7 +162,6 @@ 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'
@@ -2770,224 +2769,6 @@ 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];
const folderIndex = new Map();
const folderStack = [...collection.items];
while (folderStack.length) {
const item = folderStack.pop();
if (item?.type === 'folder' && item.pathname) {
folderIndex.set(item.pathname, item);
if (item.items && item.items.length) {
folderStack.push(...item.items);
}
}
}
for (const { eventType, payload } of collectionItems) {
if (eventType === 'addDir') {
const dir = payload;
const isTransientDir = tempDirectory && dir.meta.pathname.startsWith(tempDirectory);
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
let lastFolder = null;
for (const directoryName of subDirectories) {
currentPath = path.join(currentPath, directoryName);
let childItem = folderIndex.get(currentPath);
if (!childItem) {
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === 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);
folderIndex.set(currentPath, childItem);
} else if (isTransientDir && !childItem.isTransient) {
childItem.isTransient = true;
}
currentSubItems = childItem.items;
lastFolder = childItem;
}
if (lastFolder) {
if (dir?.meta?.name) {
lastFolder.name = dir.meta.name;
}
if (dir?.meta?.seq) {
lastFolder.seq = dir.meta.seq;
}
}
continue;
}
if (eventType === 'addFile') {
const file = payload;
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 subDirectories = getSubdirectoriesFromRoot(collection.pathname, folderPath);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
let folderItem = folderIndex.get(folderPath);
if (!folderItem) {
for (const directoryName of subDirectories) {
currentPath = path.join(currentPath, directoryName);
let childItem = folderIndex.get(currentPath);
if (!childItem) {
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
}
if (!childItem) {
childItem = {
uid: uuid(),
pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
items: [],
isTransient: isTransientFile
};
currentSubItems.push(childItem);
folderIndex.set(currentPath, childItem);
} else if (isTransientFile && !childItem.isTransient) {
childItem.isTransient = true;
}
currentSubItems = childItem.items;
if (currentPath === folderPath) {
folderItem = childItem;
}
}
}
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) {
currentPath = path.join(currentPath, directoryName);
let childItem = folderIndex.get(currentPath);
if (!childItem) {
childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
}
if (!childItem) {
childItem = {
uid: uuid(),
pathname: currentPath,
name: directoryName,
collapsed: true,
type: 'folder',
items: [],
isTransient: isTransientFile
};
currentSubItems.push(childItem);
folderIndex.set(currentPath, 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;
@@ -3002,33 +2783,7 @@ export const collectionsSlice = createSlice({
if (isFolderRoot) {
const folderPath = path.dirname(file.meta.pathname);
let folderItem = findItemInCollectionByPathname(collection, folderPath);
if (!folderItem && collection) {
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, folderPath);
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: []
};
currentSubItems.push(childItem);
}
currentSubItems = childItem.items;
if (currentPath === folderPath) {
folderItem = childItem;
}
}
}
const folderItem = findItemInCollectionByPathname(collection, folderPath);
if (folderItem) {
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
@@ -3907,7 +3662,6 @@ export const {
updateCollectionProtobuf,
collectionAddFileEvent,
collectionAddDirectoryEvent,
collectionBatchAddItems,
collectionChangeFileEvent,
collectionUnlinkFileEvent,
collectionUnlinkDirectoryEvent,

View File

@@ -1,249 +0,0 @@
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>} 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;

View File

@@ -1,201 +0,0 @@
/**
* 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 = 200;
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);
this.queue.push(...batch);
}
}
/**
* 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,
constants: {
MAX_BATCH_SIZE,
DISPATCH_INTERVAL_MS
}
};

View File

@@ -1,6 +1,5 @@
const _ = require('lodash');
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const chokidar = require('chokidar');
const {
@@ -27,8 +26,6 @@ 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;
@@ -226,8 +223,6 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
return addEnvironmentFile(win, pathname, collectionUid, collectionPath);
}
const batcher = getBatcher(win, collectionUid);
if (isCollectionRootFile(pathname, collectionPath)) {
const format = getCollectionFormat(collectionPath);
const file = {
@@ -294,8 +289,7 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
file.data = await parseFolder(content, { format });
hydrateCollectionRootWithUuid(file.data);
// win.webContents.send('main:collection-tree-updated', 'addFile', file);
batcher.add('addFile', file);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
return;
} catch (err) {
console.error(err);
@@ -315,69 +309,61 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
};
try {
const fileStats = await fsPromises.stat(pathname);
const fileStats = fs.statSync(pathname);
let content = fs.readFileSync(pathname, 'utf8');
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) {
// If worker thread is not used, we can directly parse the file
if (!useWorkerThread) {
try {
file.data = await parseRequest(content, { format });
file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
batcher.add('addFile', file);
await parsedFileCacheStore.setEntry(collectionPath, pathname, {
mtimeMs: fileStats.mtimeMs,
parsedData: file.data
});
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} catch (error) {
console.error(error);
} finally {
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);
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);
win.webContents.send('main:collection-tree-updated', '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'
@@ -387,8 +373,10 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
};
file.partial = true;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
hydrateRequestWithUuid(file.data, pathname);
batcher.add('addFile', file);
win.webContents.send('main:collection-tree-updated', 'addFile', file);
} finally {
watcher.markFileAsProcessed(win, collectionUid, pathname);
}
}
@@ -408,16 +396,15 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
const folderFilePath = path.join(pathname, `folder.${format}`);
try {
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) {
if (error.code !== 'ENOENT') {
console.error(`Error occurred while parsing folder.${format} file`);
console.error(error);
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;
}
} catch (error) {
console.error(`Error occured while parsing folder.${format} file`);
console.error(error);
}
const directory = {
@@ -430,8 +417,7 @@ const addDirectory = async (win, pathname, collectionUid, collectionPath) => {
}
};
const batcher = getBatcher(win, collectionUid);
batcher.add('addDir', directory);
win.webContents.send('main:collection-tree-updated', 'addDir', directory);
};
const change = async (win, pathname, collectionUid, collectionPath) => {
@@ -538,9 +524,6 @@ 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: {
@@ -561,14 +544,6 @@ 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);
@@ -576,7 +551,7 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
}
};
const unlink = async (win, pathname, collectionUid, collectionPath) => {
const unlink = (win, pathname, collectionUid, collectionPath) => {
console.log(`watcher unlink: ${pathname}`);
if (isEnvironmentsFolder(pathname, collectionPath)) {
@@ -585,9 +560,6 @@ const unlink = async (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);
@@ -613,8 +585,6 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
return;
}
await parsedFileCacheStore.invalidateDirectory(collectionPath, pathname);
const format = getCollectionFormat(collectionPath);
const folderFilePath = path.join(pathname, `folder.${format}`);
@@ -637,9 +607,7 @@ const unlinkDir = async (win, pathname, collectionUid, collectionPath) => {
};
const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
const batcher = getBatcher(win, collectionUid);
batcher.flush();
// Mark discovery as complete
watcher.completeCollectionDiscovery(win, collectionUid);
const UiStateSnapshotStore = new UiStateSnapshot();

View File

@@ -39,7 +39,6 @@ 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');
@@ -461,9 +460,6 @@ app.on('ready', async () => {
});
});
// Initialize the parsed file cache IPC handlers
parsedFileCacheStore.initialize(mainWindow);
// register all ipc handlers
registerNetworkIpc(mainWindow);
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);

View File

@@ -2,7 +2,6 @@ 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, fetchSystemProxy } = require('../store/system-proxy');
const { resolveDefaultLocation } = require('../utils/default-location');
const onboardUser = require('../app/onboarding');
@@ -61,25 +60,6 @@ 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 await getCachedSystemProxy();
});

View File

@@ -1,157 +0,0 @@
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
};

View File

@@ -1,312 +0,0 @@
const { CollectionTreeBatcher, getBatcher, removeBatcher, constants } = 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', () => {
const win = createMockWindow();
const batcher = new CollectionTreeBatcher(win, 'collection-1');
const eventCount = constants.MAX_BATCH_SIZE - 1;
// Add events - should not flush
for (let i = 0; i < eventCount; i++) {
batcher.add('addFile', { path: `/test/file${i}.bru` });
}
expect(win.webContents.send).not.toHaveBeenCalled();
expect(batcher.queue).toHaveLength(eventCount);
// 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);
});
});