mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
* Revert "Performance/file parse and mount (#6975)" This reverts commitf76f487211. * fix: import duplication * Revert "fix(batch-events): fix order of directory file and folder events (#7300)" This reverts commitbf4af42a25. --------- Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com> Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
committed by
GitHub
parent
17c3dc0e2b
commit
0b7cd0e540
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user