diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/ExportToPostman/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/ExportToPostman/index.js
index 94f70c6f758..217d3c95905 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/ExportToPostman/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/ExportToPostman/index.js
@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect, forwardRef } from 'react';
import { useDispatch } from 'react-redux';
+import { cloneDeep } from 'lodash';
import { useFormik } from 'formik';
import * as Yup from 'yup';
-import { cloneDeep } from 'lodash';
import { IconCaretDown } from '@tabler/icons';
import toast from 'react-hot-toast';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js
index c3a8aa33ff0..6a5a38fdede 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.js
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState, Fragment } from 'react';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { cloneDeep } from 'lodash';
import * as FileSaver from 'file-saver';
import jsyaml from 'js-yaml';
@@ -16,6 +16,7 @@ import Advanced from './Advanced';
import { useApp } from 'providers/App';
import useCollectionGitRemoteUrl from 'hooks/useCollectionGitRemoteUrl';
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading, sortItemsBySidebarOrder, getCollectionItemCounts, getCollectionVersion, getUniqueTagsFromItems } from 'utils/collections/index';
+import { resolveJsItemsRaw } from 'providers/ReduxStore/slices/collections/actions';
import { brunoToOpenCollection } from '@usebruno/converters';
import { generateApiDocsHtml, getApiDocsFileName, filterRequestItemsByTags } from '@usebruno/common';
@@ -40,6 +41,7 @@ const CollectionNotFound = ({ onClose }) => (
const GenerateDocumentation = ({ onClose, collectionUid }) => {
const { version } = useApp();
+ const dispatch = useDispatch();
const collection = useSelector((state) =>
findCollectionByUid(state.collections.collections, collectionUid)
);
@@ -100,9 +102,9 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
const { gitCollectionUrl, isResolved: gitUrlLoaded } = useCollectionGitRemoteUrl(collection?.pathname);
const hasGitUrl = gitUrlLoaded && Boolean(gitCollectionUrl);
- const handleGenerate = useCallback(() => {
+ const handleGenerate = useCallback(async () => {
try {
- const collectionCopy = cloneDeep(collection);
+ const collectionCopy = await dispatch(resolveJsItemsRaw(cloneDeep(collection)));
// Match the sidebar's ordering (folders then requests, by seq, at every depth)
// so the generated docs read in the same order as the collection tree.
@@ -133,7 +135,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
console.error('Error generating documentation:', error);
toast.error('Failed to generate documentation');
}
- }, [collection, version, onClose, currentVersion, selectedEnvUidsSet, activeTags, includeGitLink, gitCollectionUrl]);
+ }, [dispatch, collection, version, onClose, currentVersion, selectedEnvUidsSet, activeTags, includeGitLink, gitCollectionUrl]);
if (!collection) {
return
;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.spec.js
index f503496f637..e4c9726666a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.spec.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/GenerateDocumentation/index.spec.js
@@ -1,6 +1,6 @@
import '@testing-library/jest-dom';
import React from 'react';
-import { render, screen, fireEvent, within } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { ThemeProvider } from 'styled-components';
@@ -147,13 +147,13 @@ describe('GenerateDocumentation', () => {
expect(screen.getByTestId('generate-btn')).toBeEnabled();
});
- it('generates docs with the resolved git url, the shared filename, and the format-aware version', () => {
+ it('generates docs with the resolved git url, the shared filename, and the format-aware version', async () => {
mockGitRemote = { gitCollectionUrl: 'https://github.com/org/repo.git', isResolved: true };
const { onClose } = renderModal(buildCollection({ name: 'My Collection' }));
fireEvent.click(screen.getByTestId('generate-btn'));
- expect(generateApiDocsHtml).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(generateApiDocsHtml).toHaveBeenCalledTimes(1));
const [, options] = generateApiDocsHtml.mock.calls[0];
expect(options.gitCollectionUrl).toBe('https://github.com/org/repo.git');
expect(options.collectionVersion).toBe('2.0');
@@ -162,7 +162,7 @@ describe('GenerateDocumentation', () => {
expect(onClose).toHaveBeenCalled();
});
- it('omits the git url when the include-git-link toggle is turned off', () => {
+ it('omits the git url when the include-git-link toggle is turned off', async () => {
mockGitRemote = { gitCollectionUrl: 'https://github.com/org/repo.git', isResolved: true };
renderModal(buildCollection());
@@ -171,6 +171,7 @@ describe('GenerateDocumentation', () => {
fireEvent.click(screen.getByTestId('generate-btn'));
+ await waitFor(() => expect(generateApiDocsHtml).toHaveBeenCalled());
const [, options] = generateApiDocsHtml.mock.calls[0];
expect(options.gitCollectionUrl).toBeUndefined();
});
@@ -224,7 +225,7 @@ describe('GenerateDocumentation', () => {
expectSummary('2 Folders', '5 requests');
});
- it('generates the docs with the same tags the counts were based on', () => {
+ it('generates the docs with the same tags the counts were based on', async () => {
renderModal(buildTaggedCollection());
switchToTagFilter();
addTag('Include tags', 'smoke');
@@ -233,6 +234,7 @@ describe('GenerateDocumentation', () => {
fireEvent.click(screen.getByTestId('generate-btn'));
+ await waitFor(() => expect(generateApiDocsHtml).toHaveBeenCalled());
const [, options] = generateApiDocsHtml.mock.calls[0];
expect(options.tags).toEqual({ include: ['smoke'], exclude: ['wip'] });
});
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js
index 44af2f3ecc2..64627386515 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -7,14 +7,25 @@ import CollectionSearch from './CollectionSearch/index';
import InlineCollectionCreator from './InlineCollectionCreator';
import SidebarRow from './SidebarRow';
import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections';
+import { fetchCollectionTreeFromIndex } from 'providers/ReduxStore/slices/collections/actions';
import { buildSidebarEntries, getSelectionInfo } from 'utils/collections/index';
import { flattenSidebarTree, buildIndexes } from 'utils/collections/flattenSidebarTree';
import { CollectionItemDragPreview } from './Collection/CollectionItem/CollectionItemDragPreview';
import useBulkActionsMenu from 'hooks/useBulkActionsMenu';
+import useDebounce from 'hooks/useDebounce';
+import IndeterminateProgressBar from 'ui/IndeterminateProgressBar';
import BulkActionsMenu from 'components/Sidebar/Collections/BulkActionsMenu';
+// Long enough that a typed word resolves in one pass rather than once per character, short enough
+// that the results still feel attached to the keystroke.
+const SEARCH_DEBOUNCE_MS = 350;
+
const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => {
+ // The input renders from `searchText` so typing stays instant; everything that has to walk the
+ // tree reads `debouncedSearchText`, so a burst of keystrokes rebuilds the rows and re-renders
+ // the tree once rather than per character.
const [searchText, setSearchText] = useState('');
+ const debouncedSearchText = useDebounce(searchText, SEARCH_DEBOUNCE_MS);
const { collections, collectionSortOrder, selectedSidebarUids } = useSelector((state) => state.collections);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -35,12 +46,59 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
[activeWorkspace, collections, workspaces, collectionSortOrder]
);
+ // A collection that isn't mounted yet has no `collection.items` — its structure lives only in
+ // the search index until a real mount runs. Fetched on expand, keyed by uid, and merged into the
+ // entry below rather than written to Redux: it's a read-only stand-in, not collection state.
+ const [indexTreesByUid, setIndexTreesByUid] = useState({});
+
+ useEffect(() => {
+ const toFetch = sidebarEntries.filter((entry) =>
+ entry.kind === 'loaded'
+ && entry.collection.mountStatus !== 'mounted'
+ && !entry.collection.collapsed
+ && !(entry.collection.uid in indexTreesByUid));
+
+ if (!toFetch.length) return;
+
+ toFetch.forEach((entry) => {
+ const { collection } = entry;
+ dispatch(fetchCollectionTreeFromIndex({
+ uid: collection.uid,
+ pathname: collection.pathname,
+ name: collection.name,
+ ignore: collection.brunoConfig?.ignore
+ }))
+ .then(({ items }) => {
+ setIndexTreesByUid((prev) => ({ ...prev, [collection.uid]: items }));
+ })
+ .catch(() => {
+ setIndexTreesByUid((prev) => ({ ...prev, [collection.uid]: [] }));
+ });
+ });
+ }, [sidebarEntries, indexTreesByUid, dispatch]);
+
+ // Substitute the index-read tree for a not-yet-mounted collection's (empty) `items`, so
+ // flattenSidebarTree walks real structure instead of nothing.
+ const renderedSidebarEntries = useMemo(() => sidebarEntries.map((entry) => {
+ if (entry.kind !== 'loaded' || entry.collection.mountStatus === 'mounted') return entry;
+ const indexItems = indexTreesByUid[entry.collection.uid];
+ if (!indexItems) return entry;
+ return { ...entry, collection: { ...entry.collection, items: indexItems } };
+ }), [sidebarEntries, indexTreesByUid]);
+
// Flatten the tree into ordered rows. itemsByUid / collectionsByUid resolve a row's live object.
const { rows, itemsByUid, collectionsByUid } = useMemo(
- () => flattenSidebarTree(sidebarEntries, { searchText }),
- [sidebarEntries, searchText]
+ () => flattenSidebarTree(renderedSidebarEntries, { searchText: debouncedSearchText }),
+ [renderedSidebarEntries, debouncedSearchText]
);
+ // Shown while the workspace is still being indexed, and while a search is settling — the two
+ // moments the tree on screen is not yet the answer to what the user asked for.
+ const isIndexing = sidebarEntries.some(
+ (entry) => entry.kind === 'loaded' && entry.collection.mountStatus === 'mounting'
+ );
+ const isSearchPending = searchText !== debouncedSearchText;
+
// Ghost rows carry only path/name. GitRemoteCollectionRow needs the full entry (for `remote`).
const ghostsByPath = useMemo(() => {
const map = new Map();
@@ -123,6 +181,11 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
)}
+
+
{isCreatingCollection && (
(
(disp
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
- return new Promise((resolve, reject) => {
+ return new Promise(async (resolve, reject) => {
if (!collection) {
- throw new Error('Collection not found');
+ return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
- const item = findItemInCollection(collectionCopy, itemUid);
- if (!item) {
- throw new Error('Unable to locate item');
+ const treeItem = findItemInCollection(collectionCopy, itemUid);
+ if (!treeItem) {
+ return reject(new Error('Unable to locate item'));
}
+ const item = treeItem;
+
if (isItemAFolder(item)) {
const parentFolder = findParentItemInCollection(collection, item.uid) || collection;
@@ -3230,21 +3235,22 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS
return;
}
const { pathname } = collectionSnapshotData;
+ // Read-only below, and this runs once per mounted collection — cloning here meant deep-copying
+ // a whole collection tree to look up a uid and an environment.
const collection = findCollectionByPathname(state.collections.collections, pathname);
- const collectionCopy = cloneDeep(collection);
- const collectionUid = collectionCopy?.uid;
+ const collectionUid = collection?.uid;
// update selected environment
// Precedence:
// 1. The environment saved in the ui-state-snapshot always wins.
// 2. The collection's configured default environment (brunoConfig.presets.defaultEnvironment)
// is applied ONLY the first time a collection is opened/imported.
- const environment = findCollectionEnvironmentFromSnapshot(collectionCopy, collectionSnapshotData);
+ const environment = findCollectionEnvironmentFromSnapshot(collection, collectionSnapshotData);
if (environment) {
dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
} else if (collectionSnapshotData?.hasSnapshotEntry === false) {
- const defaultEnvironmentName = collectionCopy?.brunoConfig?.presets?.defaultEnvironment;
+ const defaultEnvironmentName = collection?.brunoConfig?.presets?.defaultEnvironment;
if (defaultEnvironmentName && collectionUid) {
// Apply the default now if its environment file is already loaded; otherwise mark
// it pending so it's applied as soon as the file arrives (collectionAddEnvFileEvent).
@@ -3362,16 +3368,6 @@ export const loadRequestViaWorker
});
};
-// todo: could be removed
-export const loadRequest
- = ({ collectionUid, pathname }) =>
- (dispatch, getState) => {
- return new Promise(async (resolve, reject) => {
- const { ipcRenderer } = window;
- ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }).then(resolve).catch(reject);
- });
- };
-
export const loadLargeRequest
= ({ collectionUid, pathname }) =>
(dispatch, getState) => {
@@ -3387,9 +3383,16 @@ export const mountCollection
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' }));
const fileCacheEnabled = getState().app?.preferences?.cache?.file?.enabled;
const channel = fileCacheEnabled ? 'renderer:mount-collection-v2' : 'renderer:mount-collection';
+ // End-to-end mount cost as the user experiences it: the main-process scan plus IPC transport.
+ // The per-phase breakdown rides in on the tree message — see scanCollection.
+ const mountStartedAt = performance.now();
return new Promise(async (resolve, reject) => {
callIpc(channel, { collectionUid, collectionPathname, brunoConfig, workspacePathname })
.then(async (transientDirPath) => {
+ dispatch(updateCollectionLoadStats({
+ collectionUid,
+ loadStats: { mountMs: Math.round(performance.now() - mountStartedAt) }
+ }));
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }));
dispatch(addTransientDirectory({ collectionUid, pathname: transientDirPath }));
@@ -3417,6 +3420,138 @@ export const mountCollection
});
};
+/**
+ * Mounts every collection in the active workspace that is not mounted yet, without expanding any of
+ * them in the sidebar.
+ *
+ * Search is the reason this exists. Both searches read `collection.items`, and an unmounted
+ * collection has none — so before this, global search silently found nothing in any collection the
+ * user had not clicked this session, with no indication that whole collections were missing.
+ *
+ * Sequential on purpose. The main-process directory walk is synchronous, so mounting in parallel
+ * would not overlap the walks anyway — it would only bunch them together and stall IPC for
+ * everything else. One at a time keeps the app responsive while this runs in the background.
+ * Idempotent, so the callers that signal "workspace settled" can each fire it without coordinating.
+ */
+export const mountWorkspaceCollections
+ = ({ workspacePathname = null } = {}) =>
+ async (dispatch, getState) => {
+ const state = getState();
+ const { workspaces, activeWorkspaceUid } = state.workspaces;
+ const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid);
+ if (!activeWorkspace) return;
+
+ // The same list the sidebar renders, so this mounts exactly what the user can see, in the
+ // order they see it. It also carries the two rules this would otherwise have to repeat:
+ // scratch collections are excluded, and paths are matched case-insensitively on Windows.
+ const pending = buildSidebarEntries({
+ collections: state.collections.collections,
+ workspaces,
+ activeWorkspace,
+ collectionSortOrder: state.collections.collectionSortOrder
+ })
+ .filter((entry) => entry.kind === 'loaded')
+ .map((entry) => entry.collection)
+ .filter((collection) => collection.mountStatus !== 'mounted' && collection.mountStatus !== 'mounting');
+
+ for (const collection of pending) {
+ // Re-read: an earlier iteration takes time, and the user may have clicked this collection
+ // in the meantime, which mounts it through the same thunk.
+ const current = findCollectionByUid(getState().collections.collections, collection.uid);
+ if (!current || current.mountStatus === 'mounted' || current.mountStatus === 'mounting') continue;
+
+ await dispatch(mountCollection({
+ collectionUid: current.uid,
+ collectionPathname: current.pathname,
+ brunoConfig: current.brunoConfig,
+ // Restoring tabs for every collection in the workspace would open tabs the user never
+ // asked for; the active collection's tabs are restored by the flow that mounts it.
+ skipTabRestore: true,
+ workspacePathname: workspacePathname || activeWorkspace.pathname || null
+ })).catch((err) => console.error(`Failed to background-mount ${current.pathname}:`, err));
+ }
+ };
+
+export const warmSearchIndex
+ = () =>
+ async (dispatch, getState) => {
+ const state = getState();
+ const { workspaces, activeWorkspaceUid } = state.workspaces;
+ const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid);
+ if (!activeWorkspace) return;
+
+ const collections = buildSidebarEntries({
+ collections: state.collections.collections,
+ workspaces,
+ activeWorkspace,
+ collectionSortOrder: state.collections.collectionSortOrder
+ })
+ .filter((entry) => entry.kind === 'loaded')
+ .map((entry) => entry.collection)
+ .filter((collection) => collection.mountStatus !== 'mounted');
+
+ if (!collections.length) return;
+
+ const { ipcRenderer } = window;
+ try {
+ await ipcRenderer.invoke('renderer:search-index-warm', {
+ collections: collections.map((collection) => ({
+ uid: collection.uid,
+ pathname: collection.pathname,
+ name: collection.name,
+ ignore: collection.brunoConfig?.ignore
+ }))
+ });
+ } catch (err) {
+ console.error('Failed to warm search index:', err);
+ }
+ };
+
+/**
+ * The folder/request structure of a collection that isn't mounted yet, read from the search index
+ * instead of `collection.items` (empty until a real mount runs). Not written back to Redux — the
+ * sidebar merges it into the row it renders and discards it once the collection actually mounts.
+ */
+export const fetchCollectionTreeFromIndex
+ = ({ uid, pathname, name, ignore }) =>
+ async () => {
+ const { ipcRenderer } = window;
+ return ipcRenderer.invoke('renderer:search-index-tree', {
+ collection: { uid, pathname, name, ignore }
+ });
+ };
+
+/**
+ * The raw file text behind one item, fetched on demand and written to `item.raw`. Not carried by
+ * the mount tree at all — see `setItemRaw`. Safe to call for an item that already has it; the
+ * fetch and dispatch just repeat.
+ */
+export const fetchItemRaw
+ = ({ collectionUid, itemUid, pathname }) =>
+ async (dispatch) => {
+ const { ipcRenderer } = window;
+ const raw = await ipcRenderer.invoke('renderer:get-item-raw', { pathname });
+ dispatch(setItemRaw({ collectionUid, itemUid, raw }));
+ return raw;
+ };
+
+/**
+ * Fills in `raw` for every `js`-type item in a collection copy — `.js` files export as their raw
+ * text (`transformCollectionToSaveToExportAsFile`), and `raw` isn't carried by the tree at all.
+ * `js` items are collection-level scripts, not per-request, so this is a handful of items at most
+ * regardless of collection size. Mutates and returns the copy passed in.
+ */
+export const resolveJsItemsRaw
+ = (collectionCopy) =>
+ async (dispatch) => {
+ const jsItems = flattenItems(collectionCopy.items).filter((item) => item.type === 'js' && item.raw == null);
+ await Promise.all(jsItems.map((item) =>
+ dispatch(fetchItemRaw({ collectionUid: collectionCopy.uid, itemUid: item.uid, pathname: item.pathname }))
+ .then((raw) => { item.raw = raw; })
+ ));
+ return collectionCopy;
+ };
+
export const showInFolder = (collectionPath) => () => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.spec.js
index 846b77ff4fe..f6a15eb99c0 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.spec.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.spec.js
@@ -1,4 +1,4 @@
-import { newHttpRequest } from './actions';
+import { newHttpRequest, warmSearchIndex, fetchItemRaw, resolveJsItemsRaw } from './actions';
const mockUuid = jest.fn();
@@ -98,4 +98,153 @@ describe('collection actions', () => {
});
});
});
+
+ describe('warmSearchIndex', () => {
+ const activeWorkspace = {
+ uid: 'ws-1',
+ collections: [{ path: '/c1' }, { path: '/c2' }]
+ };
+
+ const getState = (collections) => () => ({
+ workspaces: { workspaces: [activeWorkspace], activeWorkspaceUid: 'ws-1' },
+ collections: { collections, collectionSortOrder: 'default' }
+ });
+
+ it('warms every not-mounted collection in the active workspace', async () => {
+ const dispatch = jest.fn();
+ const collections = [
+ { uid: 'c1', pathname: '/c1', name: 'One', mountStatus: 'unmounted', brunoConfig: { ignore: ['dist'] } },
+ { uid: 'c2', pathname: '/c2', name: 'Two', mountStatus: 'mounting' }
+ ];
+
+ await warmSearchIndex()(dispatch, getState(collections));
+
+ expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:search-index-warm', {
+ collections: [
+ { uid: 'c1', pathname: '/c1', name: 'One', ignore: ['dist'] },
+ { uid: 'c2', pathname: '/c2', name: 'Two', ignore: undefined }
+ ]
+ });
+ });
+
+ it('excludes a collection that is already mounted', async () => {
+ const dispatch = jest.fn();
+ const collections = [
+ { uid: 'c1', pathname: '/c1', name: 'One', mountStatus: 'mounted' },
+ { uid: 'c2', pathname: '/c2', name: 'Two', mountStatus: 'unmounted' }
+ ];
+
+ await warmSearchIndex()(dispatch, getState(collections));
+
+ expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:search-index-warm', {
+ collections: [{ uid: 'c2', pathname: '/c2', name: 'Two', ignore: undefined }]
+ });
+ });
+
+ it('does nothing when every collection in the workspace is already mounted', async () => {
+ const dispatch = jest.fn();
+ const collections = [
+ { uid: 'c1', pathname: '/c1', name: 'One', mountStatus: 'mounted' },
+ { uid: 'c2', pathname: '/c2', name: 'Two', mountStatus: 'mounted' }
+ ];
+
+ await warmSearchIndex()(dispatch, getState(collections));
+
+ expect(window.ipcRenderer.invoke).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when there is no active workspace', async () => {
+ const dispatch = jest.fn();
+ const state = () => ({
+ workspaces: { workspaces: [], activeWorkspaceUid: null },
+ collections: { collections: [], collectionSortOrder: 'default' }
+ });
+
+ await warmSearchIndex()(dispatch, state);
+
+ expect(window.ipcRenderer.invoke).not.toHaveBeenCalled();
+ });
+
+ it('does not throw when the main process call rejects', async () => {
+ const dispatch = jest.fn();
+ window.ipcRenderer.invoke.mockRejectedValueOnce(new Error('boom'));
+ const collections = [{ uid: 'c1', pathname: '/c1', name: 'One', mountStatus: 'unmounted' }];
+
+ await expect(warmSearchIndex()(dispatch, getState(collections))).resolves.toBeUndefined();
+ });
+ });
+
+ describe('fetchItemRaw', () => {
+ it('fetches raw content over IPC and dispatches setItemRaw with it', async () => {
+ const dispatch = jest.fn();
+ window.ipcRenderer.invoke.mockResolvedValueOnce('meta {\n name: Ping\n}');
+
+ const raw = await fetchItemRaw({ collectionUid: 'col-1', itemUid: 'item-1', pathname: '/coll/ping.bru' })(dispatch);
+
+ expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:get-item-raw', { pathname: '/coll/ping.bru' });
+ expect(dispatch).toHaveBeenCalledWith({
+ type: 'collections/setItemRaw',
+ payload: { collectionUid: 'col-1', itemUid: 'item-1', raw: 'meta {\n name: Ping\n}' }
+ });
+ expect(raw).toBe('meta {\n name: Ping\n}');
+ });
+ });
+
+ describe('resolveJsItemsRaw', () => {
+ // A real store's dispatch also runs thunks it's handed (redux-thunk); this stands in for
+ // that so fetchItemRaw's dispatch(fetchItemRaw(...)) call inside resolveJsItemsRaw resolves.
+ const makeThunkDispatch = () => {
+ const dispatch = jest.fn((action) => (typeof action === 'function' ? action(dispatch) : action));
+ return dispatch;
+ };
+
+ it('fetches raw for every js-type item and leaves everything else untouched', async () => {
+ const dispatch = makeThunkDispatch();
+ window.ipcRenderer.invoke.mockResolvedValue('console.log("hi")');
+ const collectionCopy = {
+ uid: 'col-1',
+ items: [
+ { uid: 'js-1', type: 'js', pathname: '/coll/util.js', raw: null },
+ { uid: 'req-1', type: 'http-request', pathname: '/coll/ping.bru' }
+ ]
+ };
+
+ const result = await resolveJsItemsRaw(collectionCopy)(dispatch);
+
+ expect(window.ipcRenderer.invoke).toHaveBeenCalledTimes(1);
+ expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:get-item-raw', { pathname: '/coll/util.js' });
+ expect(result.items[0].raw).toBe('console.log("hi")');
+ expect(result.items[1].raw).toBeUndefined();
+ expect(result).toBe(collectionCopy);
+ });
+
+ it('skips a js item that already has raw', async () => {
+ const dispatch = makeThunkDispatch();
+ const collectionCopy = {
+ uid: 'col-1',
+ items: [{ uid: 'js-1', type: 'js', pathname: '/coll/util.js', raw: 'already here' }]
+ };
+
+ await resolveJsItemsRaw(collectionCopy)(dispatch);
+
+ expect(window.ipcRenderer.invoke).not.toHaveBeenCalled();
+ });
+
+ it('resolves raw for js items nested inside folders', async () => {
+ const dispatch = makeThunkDispatch();
+ window.ipcRenderer.invoke.mockResolvedValue('nested content');
+ const collectionCopy = {
+ uid: 'col-1',
+ items: [{
+ uid: 'folder-1',
+ type: 'folder',
+ items: [{ uid: 'js-1', type: 'js', pathname: '/coll/api/util.js', raw: undefined }]
+ }]
+ };
+
+ await resolveJsItemsRaw(collectionCopy)(dispatch);
+
+ expect(collectionCopy.items[0].items[0].raw).toBe('nested content');
+ });
+ });
});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/file-mode.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/file-mode.spec.js
index cc6bda110f6..8c35712bee7 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/file-mode.spec.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/file-mode.spec.js
@@ -2,7 +2,8 @@ import reducer, {
createCollection,
toggleCollectionFileMode,
updateFileContent,
- collectionChangeFileEvent
+ collectionChangeFileEvent,
+ setItemRaw
} from 'providers/ReduxStore/slices/collections';
const COLLECTION_UID = 'col-1';
@@ -359,3 +360,34 @@ describe('collectionChangeFileEvent — failed parse', () => {
expect(item.raw).toBe(fixedRaw);
});
});
+
+describe('setItemRaw', () => {
+ test('sets raw on the item fetched on demand', () => {
+ const state = reducer(
+ makeInitialState({ item: { raw: undefined } }),
+ setItemRaw({ collectionUid: COLLECTION_UID, itemUid: ITEM_UID, raw: 'meta {\n name: user_info\n}' })
+ );
+
+ expect(state.collections[0].items[0].raw).toBe('meta {\n name: user_info\n}');
+ });
+
+ test('does nothing for an unknown collection', () => {
+ const initialState = makeInitialState();
+ const state = reducer(
+ initialState,
+ setItemRaw({ collectionUid: 'unknown', itemUid: ITEM_UID, raw: 'edited' })
+ );
+
+ expect(state).toEqual(initialState);
+ });
+
+ test('does nothing for an unknown item', () => {
+ const initialState = makeInitialState();
+ const state = reducer(
+ initialState,
+ setItemRaw({ collectionUid: COLLECTION_UID, itemUid: 'unknown', raw: 'edited' })
+ );
+
+ expect(state.collections[0].items[0].raw).toBe(initialState.collections[0].items[0].raw);
+ });
+});
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index 8289658fe9c..7a1211893e9 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -27,6 +27,9 @@ import { DEFAULT_HTTP_ITEM_SETTINGS, GRPC_SCRIPT_KEYS, SCRIPT_TYPES } from '@use
import * as exampleReducers from './exampleReducers';
import * as mockResponseEditorReducers from './mockResponseEditorReducers';
+// `raw` is deliberately not here — it is fetched on demand (see `setItemRaw`), not carried by the
+// tree. Picking it from a fresh tree on every reload would either always be empty or clobber a
+// value that was just fetched for the one item that is actually open.
const FILE_DERIVED_REQUEST_FIELDS = [
'name',
'type',
@@ -36,7 +39,6 @@ const FILE_DERIVED_REQUEST_FIELDS = [
'settings',
'examples',
'app',
- 'raw',
'filename',
'pathname',
'partial',
@@ -294,6 +296,14 @@ export const collectionsSlice = createSlice({
collection.isLoading = action.payload.isLoading;
}
},
+ // Merged rather than replaced: the main-process phase timings ride in on the tree message
+ // while the end-to-end total comes from the mount thunk, and the two can arrive in any order.
+ updateCollectionLoadStats: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+ if (collection) {
+ collection.loadStats = { ...collection.loadStats, ...action.payload.loadStats };
+ }
+ },
setCollectionSecurityConfig: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
@@ -3076,7 +3086,7 @@ export const collectionsSlice = createSlice({
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
let currentPath = collection.pathname;
let currentSubItems = collection.items;
- for (const directoryName of subDirectories) {
+ subDirectories.forEach((directoryName, idx) => {
let childItem = currentSubItems.find((f) => f.type === 'folder' && f.filename === directoryName);
currentPath = path.join(currentPath, directoryName);
if (!childItem) {
@@ -3087,6 +3097,7 @@ export const collectionsSlice = createSlice({
collapsed: true,
type: 'folder',
isTransient: isTransientFile,
+ depth: idx + 1,
items: []
};
currentSubItems.push(childItem);
@@ -3095,7 +3106,8 @@ export const collectionsSlice = createSlice({
childItem.isTransient = true;
}
currentSubItems = childItem.items;
- }
+ });
+ const itemDepth = subDirectories.length + 1;
if (file.meta.name != 'folder.bru' && !currentSubItems.find((f) => f.name === file.meta.name)) {
// this happens when you rename a file
@@ -3119,6 +3131,7 @@ export const collectionsSlice = createSlice({
currentItem.size = file.size;
currentItem.error = file.error;
currentItem.isTransient = isTransientFile;
+ currentItem.depth = itemDepth;
} else {
currentSubItems.push({
uid: file.data.uid,
@@ -3138,7 +3151,8 @@ export const collectionsSlice = createSlice({
loading: file.loading,
size: file.size,
error: file.error,
- isTransient: isTransientFile
+ isTransient: isTransientFile,
+ depth: itemDepth
});
}
}
@@ -3184,6 +3198,7 @@ export const collectionsSlice = createSlice({
collapsed: true,
type: 'folder',
isTransient: isTransientDir,
+ depth: idx + 1,
items: []
};
currentSubItems.push(childItem);
@@ -3760,11 +3775,24 @@ export const collectionsSlice = createSlice({
}
}
},
+ // The only writer of `item.raw` — fetched on demand for the one item that needs it (see
+ // `fetchItemRaw`), never carried by the tree itself.
+ setItemRaw: (state, action) => {
+ const { collectionUid, itemUid, raw } = action.payload;
+ const collection = findCollectionByUid(state.collections, collectionUid);
+ if (!collection) return;
+
+ const item = findItemInCollection(collection, itemUid);
+ if (item) item.raw = raw;
+ },
collectionLoadedFromTree: (state, action) => {
const { collectionUid, tree } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
+ if (tree?.loadStats) {
+ collection.loadStats = { ...collection.loadStats, ...tree.loadStats };
+ }
collection.items = mergeTreeItems(collection.items, tree?.items || []);
collection.environments = tree?.environments || [];
if (tree?.root !== undefined) {
@@ -4251,6 +4279,7 @@ export const {
createCollection,
updateCollectionMountStatus,
updateCollectionLoadingState,
+ updateCollectionLoadStats,
collectionLoadedFromTree,
setCollectionSecurityConfig,
updateCollectionVersion,
@@ -4401,6 +4430,7 @@ export const {
updateFolderDocs,
toggleCollectionFileMode,
updateFileContent,
+ setItemRaw,
updateAppCode,
toggleAppMode,
appSetRuntimeVariable,
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
index 53ca9bd9036..47c6c5ebc16 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
@@ -8,7 +8,7 @@ import {
updateWorkspaceLoadingState,
setWorkspaceScratchCollection
} from '../workspaces';
-import { createCollection, openMultipleCollections, openScratchCollectionEvent, mountCollection, hydrateCollectionWithUiStateSnapshot } from '../collections/actions';
+import { createCollection, openMultipleCollections, openScratchCollectionEvent, mountCollection, mountWorkspaceCollections, warmSearchIndex, hydrateCollectionWithUiStateSnapshot } from '../collections/actions';
import { removeCollection, addTransientDirectory, updateCollectionMountStatus, expandCollection, sortCollections } from '../collections';
import { sanitizeName } from 'utils/common/regex';
import { clearCollectionState } from '../openapi-sync';
@@ -448,6 +448,8 @@ const maybeCompleteSnapshotHydrationSession = (dispatch, getState) => {
clearSnapshotHydrationTimeout();
dispatch(setSnapshotReady(true));
dispatch(clearSnapshotHydrationSession());
+ dispatch(mountWorkspaceCollections());
+ dispatch(warmSearchIndex());
return true;
};
@@ -473,6 +475,8 @@ const scheduleSnapshotHydrationTimeout = (dispatch, getState, workspaceUid) => {
dispatch(setSnapshotReady(true));
dispatch(clearSnapshotHydrationSession());
clearSnapshotHydrationTimeout();
+ dispatch(mountWorkspaceCollections());
+ dispatch(warmSearchIndex());
}, SNAPSHOT_HYDRATION_LONG_STOP_GUARD_MS);
};
@@ -748,6 +752,10 @@ export const switchWorkspace = (workspaceUid) => {
if (!state.app.snapshotReady && !hasHydrationSession) {
dispatch(setSnapshotReady(true));
}
+ if (!hasHydrationSession) {
+ dispatch(mountWorkspaceCollections());
+ dispatch(warmSearchIndex());
+ }
}
};
};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.spec.js
index 2d94d43713f..90d22da82a9 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.spec.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.spec.js
@@ -14,6 +14,8 @@ jest.mock('../collections/actions', () => ({
openMultipleCollections: jest.fn(() => () => Promise.resolve({ opened: [], failed: [], invalid: [] })),
openScratchCollectionEvent: jest.fn(() => () => Promise.resolve()),
mountCollection: jest.fn(() => () => Promise.resolve()),
+ mountWorkspaceCollections: jest.fn(() => () => Promise.resolve()),
+ warmSearchIndex: jest.fn(() => () => Promise.resolve()),
hydrateCollectionWithUiStateSnapshot: jest.fn(() => () => Promise.resolve())
}));
diff --git a/packages/bruno-app/src/ui/IndeterminateProgressBar/StyledWrapper.js b/packages/bruno-app/src/ui/IndeterminateProgressBar/StyledWrapper.js
new file mode 100644
index 00000000000..c5e17569658
--- /dev/null
+++ b/packages/bruno-app/src/ui/IndeterminateProgressBar/StyledWrapper.js
@@ -0,0 +1,45 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ position: relative;
+ height: 2px;
+ width: 100%;
+ overflow: hidden;
+ flex-shrink: 0;
+
+ .bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ /* Travel is expressed relative to the bar's own width, so the two must stay in step:
+ at 30% wide the track is 3.33x the bar, and -100% -> 333% carries it fully across. */
+ width: 30%;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ ${(props) => props.theme.textLink},
+ transparent
+ );
+ animation: sweep 1.15s ease-in-out infinite;
+ }
+
+ @keyframes sweep {
+ from {
+ transform: translateX(-100%);
+ }
+ to {
+ transform: translateX(333%);
+ }
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .bar {
+ width: 100%;
+ animation: none;
+ opacity: 0.35;
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/ui/IndeterminateProgressBar/index.js b/packages/bruno-app/src/ui/IndeterminateProgressBar/index.js
new file mode 100644
index 00000000000..ef2a3c0fdc4
--- /dev/null
+++ b/packages/bruno-app/src/ui/IndeterminateProgressBar/index.js
@@ -0,0 +1,21 @@
+import StyledWrapper from './StyledWrapper';
+
+/**
+ * A thin sweeping bar for work whose duration is not known ahead of time — indexing a workspace,
+ * resolving a search. Spans the full width of whatever contains it.
+ *
+ * The track keeps its height whether or not it is active, so showing and hiding it never shifts the
+ * content below.
+ */
+const IndeterminateProgressBar = ({ active, className, 'data-testid': dataTestId }) => (
+
+ {active ? : null}
+
+);
+
+export default IndeterminateProgressBar;
diff --git a/packages/bruno-app/src/ui/index.js b/packages/bruno-app/src/ui/index.js
index c8af1b8b18d..e1e0aa0c97b 100644
--- a/packages/bruno-app/src/ui/index.js
+++ b/packages/bruno-app/src/ui/index.js
@@ -7,6 +7,7 @@ export { default as Checkbox } from './Checkbox';
export { default as CountBadge } from './CountBadge';
export { default as ErrorBanner } from './ErrorBanner';
export { default as HeightBoundContainer } from './HeightBoundContainer';
+export { default as IndeterminateProgressBar } from './IndeterminateProgressBar';
export { default as MenuDropdown } from './MenuDropdown';
export { default as MethodBadge } from './MethodBadge';
export { default as ResponsiveTabs } from './ResponsiveTabs';
diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js
index f9bba93245d..c4ea115394c 100644
--- a/packages/bruno-app/src/utils/collections/index.js
+++ b/packages/bruno-app/src/utils/collections/index.js
@@ -944,18 +944,25 @@ export const getCollectionItemCounts = (items = []) => {
};
/**
- * Orders a list of collection items exactly the way the Sidebar tree renders them:
- * folders first (via `sortByNameThenSequence`), then standalone apps by `seq`, then
- * requests by `seq`. The same ordering is applied recursively to every nested folder
- * so an exported/serialized tree matches the sidebar at all depths.
+ * Splits one level of collection items into the three groups the Sidebar renders, in order:
+ * folders (via `sortByNameThenSequence`), then standalone apps by `seq`, then requests by `seq`.
*
- * Items that are none of folder/app/request (e.g. `js` script files) are excluded,
- * mirroring the sidebar. Transient items are excluded too.
+ * Items that are none of folder/app/request (e.g. `js` script files) are excluded, as are
+ * transient items. Returns the original item references — callers rely on that for memoization —
+ * and does not descend into nested folders.
+ */
+export const groupItemsBySidebarOrder = (items = []) => ({
+ folderItems: sortByNameThenSequence(filter(items, (i) => isItemAFolder(i) && !i.isTransient)),
+ appItems: filter(items, (i) => i.type === 'app' && !i.isTransient).sort((a, b) => a.seq - b.seq),
+ requestItems: filter(items, (i) => isItemARequest(i) && !i.isTransient).sort((a, b) => a.seq - b.seq)
+});
+
+/**
+ * Flattens `groupItemsBySidebarOrder` into a single ordered list, applied recursively to every
+ * nested folder so an exported/serialized tree matches the sidebar at all depths.
*/
export const sortItemsBySidebarOrder = (items = []) => {
- const folderItems = sortByNameThenSequence(filter(items, (i) => isItemAFolder(i) && !i.isTransient));
- const appItems = filter(items, (i) => i.type === 'app' && !i.isTransient).sort((a, b) => a.seq - b.seq);
- const requestItems = filter(items, (i) => isItemARequest(i) && !i.isTransient).sort((a, b) => a.seq - b.seq);
+ const { folderItems, appItems, requestItems } = groupItemsBySidebarOrder(items);
return [...folderItems, ...appItems, ...requestItems].map((item) =>
Array.isArray(item.items) ? { ...item, items: sortItemsBySidebarOrder(item.items) } : item
diff --git a/packages/bruno-electron/electron-builder-config.js b/packages/bruno-electron/electron-builder-config.js
index 7b7ed1b3557..f1b9f5df31d 100644
--- a/packages/bruno-electron/electron-builder-config.js
+++ b/packages/bruno-electron/electron-builder-config.js
@@ -20,18 +20,18 @@ const config = {
artifactName: '${name}_${version}_${arch}_${os}.${ext}',
category: 'public.app-category.developer-tools',
target: [
- {
- target: 'pkg',
- arch: ['x64', 'arm64']
- },
+ // {
+ // target: 'pkg',
+ // arch: ['x64', 'arm64']
+ // },
{
target: 'dmg',
arch: ['x64', 'arm64']
- },
- {
- target: 'zip',
- arch: ['x64', 'arm64']
}
+ // {
+ // target: 'zip',
+ // arch: ['x64', 'arm64']
+ // }
],
icon: 'resources/icons/mac/icon.icns',
hardenedRuntime: true,
@@ -53,18 +53,18 @@ const config = {
artifactName: '${name}_${version}_${arch}_${os}.${ext}',
icon: 'resources/icons/png',
target: [
- {
- target: 'AppImage',
- arch: ['x64', 'arm64']
- },
- {
- target: 'deb',
- arch: ['x64', 'arm64']
- },
- {
- target: 'rpm',
- arch: ['x64', 'arm64']
- }
+ // {
+ // target: 'AppImage',
+ // arch: ['x64', 'arm64']
+ // },
+ // {
+ // target: 'deb',
+ // arch: ['x64', 'arm64']
+ // },
+ // {
+ // target: 'rpm',
+ // arch: ['x64', 'arm64']
+ // }
],
protocols: [
{
@@ -96,10 +96,10 @@ const config = {
artifactName: '${name}_${version}_${arch}_win.${ext}',
icon: 'resources/icons/win/icon.ico',
target: [
- {
- target: 'nsis',
- arch: ['x64', 'arm64']
- }
+ // {
+ // target: 'nsis',
+ // arch: ['x64', 'arm64']
+ // }
],
sign: null,
publisherName: 'Bruno Software Inc'
diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js
index 35a68ac91a3..fc3c6e3b7ee 100644
--- a/packages/bruno-electron/src/app/collection-watcher.js
+++ b/packages/bruno-electron/src/app/collection-watcher.js
@@ -24,7 +24,7 @@ const { decryptStringSafe } = require('../utils/encryption');
const { setBrunoConfig, getBrunoConfig } = require('../store/bruno-config');
const EnvironmentSecretsStore = require('../store/env-secrets');
const snapshotManager = require('../services/snapshot');
-const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
+const { hydrateRequestWithUuid } = require('../utils/collection');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
const dotEnvWatcher = require('./dotenv-watcher');
@@ -220,8 +220,6 @@ const unlinkEnvironmentFile = async (win, pathname, collectionUid) => {
};
const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread, watcher) => {
- console.log(`watcher add: ${pathname}`);
-
if (isBrunoConfigFile(pathname, collectionPath)) {
try {
const content = fs.readFileSync(pathname, 'utf8');
@@ -372,41 +370,34 @@ const add = async (win, pathname, collectionUid, collectionPath, useWorkerThread
}
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'
- };
+ // Files this large are not parsed on mount at all. The sidebar gets a placeholder named
+ // after the file, and `renderer:load-large-request` parses it if the user opens it.
+ // The name is derived from the filename rather than the file's `meta`/`info` block so no
+ // parse is needed here — the same fallback the v2 tree builder uses.
+ if (fileStats.size >= MAX_FILE_SIZE) {
+ file.data = {
+ name: path.basename(pathname, path.extname(pathname)),
+ type: 'http-request'
+ };
+ file.partial = true;
+ file.loading = false;
+ file.size = sizeInMB(fileStats?.size);
+ hydrateRequestWithUuid(file.data, pathname);
+ win.webContents.send('main:collection-tree-updated', 'addFile', file);
+ return;
+ }
- const metaJson = parseFileMeta(content, format);
- file.data = metaJson;
- file.partial = true;
+ file.data = await parseRequestViaWorker(content, {
+ format,
+ filename: pathname
+ });
+ stageToCache(collectionPath, pathname, file.data);
+ file.partial = false;
file.loading = false;
file.size = sizeInMB(fileStats?.size);
+ file.data.raw = content;
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
- });
- stageToCache(collectionPath, pathname, file.data);
- file.partial = false;
- file.loading = false;
- file.data.raw = content;
- hydrateRequestWithUuid(file.data, pathname);
- win.webContents.send('main:collection-tree-updated', 'addFile', file);
- }
} catch (error) {
file.data = {
name: path.basename(pathname),
@@ -595,8 +586,8 @@ const change = async (win, pathname, collectionUid, collectionPath) => {
stageToCache(collectionPath, pathname, file.data);
}
- file.data.raw = content;
file.size = sizeInMB(fileStats?.size);
+ file.data.raw = content;
hydrateRequestWithUuid(file.data, pathname);
win.webContents.send('main:collection-tree-updated', 'change', file);
} catch (err) {
@@ -622,7 +613,6 @@ const unlink = (win, pathname, collectionUid, collectionPath) => {
if (!fs.existsSync(collectionPath)) {
return;
}
- console.log(`watcher unlink: ${pathname}`);
// drop the file from the snapshot regardless of type (request/env/config/folder root)
unstageFromCache(collectionPath, pathname);
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 3e0777ed75c..050c3fb5406 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -51,6 +51,7 @@ const registerAiIpc = require('./ipc/ai');
const registerAiAutocompleteIpc = require('./ipc/ai/autocomplete');
const { registerMountIpc } = require('./ipc/mount');
const { registerSqliteIpc } = require('./ipc/sqlite');
+const { registerSearchIndexIpc, closeAllSearchIndexWatchers } = require('./ipc/search-index');
const collectionWatcher = require('./app/collection-watcher');
const WorkspaceWatcher = require('./app/workspace-watcher');
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
@@ -132,7 +133,8 @@ const focusMainWindow = () => {
const closeAllWatchers = () => Promise.allSettled([
collectionWatcher.closeAllWatchers(),
workspaceWatcher.closeAllWatchers(),
- apiSpecWatcher.closeAllWatchers()
+ apiSpecWatcher.closeAllWatchers(),
+ closeAllSearchIndexWatchers()
]);
// Parse protocol URL from command line arguments (if any)
@@ -528,6 +530,7 @@ app.on('ready', async () => {
registerAiAutocompleteIpc(mainWindow);
registerMountIpc();
registerSqliteIpc(mainWindow);
+ registerSearchIndexIpc();
// Internal delegator
ipcMain.handle('main:cache-clear', async () => {
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index ba4b7974217..47bc290a59c 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -76,6 +76,7 @@ const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modif
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
const snapshotManager = require('../services/snapshot');
+const { scanCollection } = require('../services/mount/scan');
const interpolateVars = require('./network/interpolate-vars');
const { interpolateString } = require('./network/interpolate-string');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
@@ -2282,54 +2283,12 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
});
- // todo: could be removed
- ipcMain.handle('renderer:load-request', async (event, { collectionUid, pathname }) => {
- let fileStats;
- try {
- fileStats = fs.statSync(pathname);
- if (hasRequestExtension(pathname)) {
- const file = {
- meta: {
- collectionUid,
- pathname,
- name: path.basename(pathname)
- }
- };
- const bruContent = fs.readFileSync(pathname, 'utf8');
- const metaJson = parseBruFileMeta(bruContent);
- file.data = metaJson;
- file.loading = true;
- file.partial = true;
- file.size = sizeInMB(fileStats?.size);
- hydrateRequestWithUuid(file.data, pathname);
- mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
- file.data = parseRequest(bruContent);
- file.partial = false;
- file.loading = true;
- file.size = sizeInMB(fileStats?.size);
- hydrateRequestWithUuid(file.data, pathname);
- mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
- }
- } catch (error) {
- if (hasRequestExtension(pathname)) {
- const file = {
- meta: {
- collectionUid,
- pathname,
- name: path.basename(pathname)
- }
- };
- const bruContent = fs.readFileSync(pathname, 'utf8');
- const metaJson = parseBruFileMeta(bruContent);
- file.data = metaJson;
- file.partial = true;
- file.loading = false;
- file.size = sizeInMB(fileStats?.size);
- hydrateRequestWithUuid(file.data, pathname);
- mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file);
- }
- return Promise.reject(error);
- }
+ // The raw file text behind one item, read fresh from disk. Not carried in the mount tree — it
+ // duplicates every other field as a single string, for every item, whether or not it is ever
+ // opened — so File Mode and the export flows that need it (for `js`-type items) ask for it here.
+ ipcMain.handle('renderer:get-item-raw', async (event, { pathname }) => {
+ validatePathIsInsideCollection(pathname);
+ return fs.promises.readFile(pathname, 'utf8');
});
ipcMain.handle('renderer:load-large-request', async (event, { collectionUid, pathname }) => {
@@ -2398,18 +2357,24 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
} catch (error) {
throw error;
}
- const {
- size,
- filesCount,
- maxFileSize
- } = await getCollectionStats(collectionPathname);
+ // Scan and parse the whole collection across the worker pool, then emit it as one tree.
+ mainWindow.webContents.send('main:collection-loading-state-updated', { collectionUid, isLoading: true });
- const shouldLoadCollectionAsync
- = (size > MAX_COLLECTION_SIZE_IN_MB)
- || (filesCount > MAX_COLLECTION_FILES_COUNT)
- || (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB);
+ const tree = await scanCollection({
+ collectionPath: collectionPathname,
+ collectionUid,
+ denylist: brunoConfig?.ignore
+ });
+ mainWindow.webContents.send('main:collection-tree-loaded', { collectionUid, tree });
+ if (tree.brunoConfig) {
+ mainWindow.webContents.send('main:bruno-config-update', { collectionUid, brunoConfig: tree.brunoConfig });
+ }
- watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync, { workspacePathname: workspacePathname || null });
+ // The tree above already covers everything on disk, so the watcher only reports live changes.
+ watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, false, {
+ workspacePathname: workspacePathname || null,
+ ignoreInitial: true
+ });
// Add watcher for transient directory
watcher.addTempDirectoryWatcher(mainWindow, tempDirectoryPath, collectionUid, collectionPathname);
diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js
index 77a860a6855..36cd9c9ecf1 100644
--- a/packages/bruno-electron/src/ipc/network/index.js
+++ b/packages/bruno-electron/src/ipc/network/index.js
@@ -1350,8 +1350,9 @@ const registerNetworkIpc = (mainWindow) => {
};
// handler for sending http request
- ipcMain.handle('send-http-request', async (event, item, collection, environment, runtimeVariables) => {
+ ipcMain.handle('send-http-request', async (event, _item, collection, environment, runtimeVariables) => {
let seq = 0;
+ const item = _item;
const collectionUid = collection.uid;
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
diff --git a/packages/bruno-electron/src/ipc/search-index.js b/packages/bruno-electron/src/ipc/search-index.js
new file mode 100644
index 00000000000..ca969afe9f9
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/search-index.js
@@ -0,0 +1,79 @@
+const { ipcMain } = require('electron');
+const { indexCollection, getSearchIndex } = require('../services/search-index/indexer');
+const { ensureWatching, closeAll: closeAllSearchIndexWatchers } = require('../services/search-index/watcher');
+const { buildFolderTree } = require('../services/search-index/build-tree');
+const { getRequestUid } = require('../cache/requestUids');
+
+const indexedCollections = new Set();
+
+const ensureIndexed = async (collections) => {
+ const toIndex = collections.filter((c) => c.pathname && !indexedCollections.has(c.pathname));
+ await Promise.all(toIndex.map(async (c) => {
+ try {
+ await indexCollection({
+ collectionPath: c.pathname,
+ collectionUid: c.uid,
+ collectionName: c.name,
+ denylist: c.ignore
+ });
+ indexedCollections.add(c.pathname);
+ ensureWatching({
+ collectionPath: c.pathname,
+ collectionUid: c.uid,
+ collectionName: c.name,
+ denylist: c.ignore
+ });
+ } catch (err) {
+ console.error(`[search-index] failed to index ${c.pathname}`, err);
+ }
+ }));
+};
+
+const searchIndex = async (event, { collections = [], terms = [], limit } = {}) => {
+ await ensureIndexed(collections);
+ const rows = getSearchIndex().search({
+ terms,
+ collectionPaths: collections.map((c) => c.pathname).filter(Boolean),
+ limit
+ });
+ return rows.map((row) => ({
+ uid: getRequestUid(row.request_path),
+ name: row.name,
+ method: row.method,
+ url: row.url,
+ pathname: row.request_path,
+ folderPath: row.folder_path,
+ collectionUid: row.collection_uid,
+ collectionName: row.collection_name
+ }));
+};
+
+const warmSearchIndex = async (event, { collections = [] } = {}) => {
+ await ensureIndexed(collections);
+};
+
+// The sidebar's tree shape for a collection that isn't mounted yet — folders and requests, read
+// from the index instead of the (empty) in-memory tree. Not put into the real collection: the
+// caller decides whether and where to render it.
+const getCollectionTree = async (event, { collection } = {}) => {
+ if (!collection?.pathname) return { items: [] };
+
+ await ensureIndexed([collection]);
+ const rows = getSearchIndex().getFolderTree(collection.pathname);
+ return { items: buildFolderTree(collection.pathname, rows) };
+};
+
+const registerSearchIndexIpc = () => {
+ ipcMain.handle('renderer:search-index-query', searchIndex);
+ ipcMain.handle('renderer:search-index-warm', warmSearchIndex);
+ ipcMain.handle('renderer:search-index-tree', getCollectionTree);
+};
+
+module.exports = {
+ registerSearchIndexIpc,
+ searchIndex,
+ warmSearchIndex,
+ getCollectionTree,
+ indexedCollections,
+ closeAllSearchIndexWatchers
+};
diff --git a/packages/bruno-electron/src/ipc/search-index.spec.js b/packages/bruno-electron/src/ipc/search-index.spec.js
new file mode 100644
index 00000000000..316804ba78e
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/search-index.spec.js
@@ -0,0 +1,155 @@
+jest.mock('electron', () => ({ ipcMain: { handle: jest.fn() } }));
+
+const mockIndexCollection = jest.fn(async () => {});
+const mockSearch = jest.fn(() => [{
+ id: 'r1',
+ name: 'Get Users',
+ method: 'GET',
+ url: 'https://api.test/users',
+ request_path: '/c1/users/get.bru',
+ folder_path: 'users',
+ collection_uid: 'c1',
+ collection_path: '/c1',
+ collection_name: 'One'
+}]);
+const mockGetFolderTree = jest.fn(() => []);
+jest.mock('../services/search-index/indexer', () => ({
+ indexCollection: (...args) => mockIndexCollection(...args),
+ getSearchIndex: () => ({ search: (...args) => mockSearch(...args), getFolderTree: (...args) => mockGetFolderTree(...args) })
+}));
+jest.mock('../cache/requestUids', () => ({
+ getRequestUid: (pathname) => `uid-for-${pathname}`
+}));
+
+const mockEnsureWatching = jest.fn();
+jest.mock('../services/search-index/watcher', () => ({
+ ensureWatching: (...args) => mockEnsureWatching(...args)
+}));
+
+const mockBuildFolderTree = jest.fn(() => [{ uid: 'req-1', name: 'Get Users', type: 'http-request' }]);
+jest.mock('../services/search-index/build-tree', () => ({
+ buildFolderTree: (...args) => mockBuildFolderTree(...args)
+}));
+
+const { searchIndex, warmSearchIndex, getCollectionTree, indexedCollections } = require('./search-index');
+
+const expectedResult = [{
+ uid: 'uid-for-/c1/users/get.bru',
+ name: 'Get Users',
+ method: 'GET',
+ url: 'https://api.test/users',
+ pathname: '/c1/users/get.bru',
+ folderPath: 'users',
+ collectionUid: 'c1',
+ collectionName: 'One'
+}];
+
+beforeEach(() => {
+ indexedCollections.clear();
+ mockIndexCollection.mockClear();
+ mockSearch.mockClear();
+ mockEnsureWatching.mockClear();
+ mockGetFolderTree.mockClear();
+ mockBuildFolderTree.mockClear();
+});
+
+describe('searchIndex handler', () => {
+ it('indexes each collection once and then searches across all of them', async () => {
+ const collections = [
+ { uid: 'c1', pathname: '/c1', name: 'One' },
+ { uid: 'c2', pathname: '/c2', name: 'Two' }
+ ];
+
+ const results = await searchIndex(null, { collections, terms: ['users'], limit: 10 });
+
+ expect(mockIndexCollection).toHaveBeenCalledTimes(2);
+ expect(mockSearch).toHaveBeenCalledWith({ terms: ['users'], collectionPaths: ['/c1', '/c2'], limit: 10 });
+ expect(results).toEqual(expectedResult);
+ });
+
+ it('does not re-index a collection already indexed this session', async () => {
+ const collections = [{ uid: 'c1', pathname: '/c1', name: 'One' }];
+ await searchIndex(null, { collections, terms: ['a'] });
+
+ await searchIndex(null, { collections, terms: ['b'] });
+
+ expect(mockIndexCollection).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not let one collection failing to index block searching the rest', async () => {
+ mockIndexCollection.mockImplementationOnce(async () => { throw new Error('boom'); });
+ const collections = [
+ { uid: 'c1', pathname: '/c1', name: 'One' },
+ { uid: 'c2', pathname: '/c2', name: 'Two' }
+ ];
+
+ const results = await searchIndex(null, { collections, terms: ['users'] });
+
+ expect(results).toEqual(expectedResult);
+ expect(indexedCollections.has('/c2')).toBe(true);
+ expect(indexedCollections.has('/c1')).toBe(false);
+ });
+
+ it('skips collections with no pathname', async () => {
+ const results = await searchIndex(null, { collections: [{ uid: 'c1' }], terms: ['x'] });
+
+ expect(mockIndexCollection).not.toHaveBeenCalled();
+ expect(mockSearch).toHaveBeenCalledWith({ terms: ['x'], collectionPaths: [], limit: undefined });
+ expect(results).toEqual(expectedResult);
+ });
+
+ it('starts watching each collection once it is indexed', async () => {
+ await searchIndex(null, { collections: [{ uid: 'c1', pathname: '/c1', name: 'One', ignore: ['dist'] }], terms: ['x'] });
+
+ expect(mockEnsureWatching).toHaveBeenCalledWith({
+ collectionPath: '/c1',
+ collectionUid: 'c1',
+ collectionName: 'One',
+ denylist: ['dist']
+ });
+ });
+});
+
+describe('warmSearchIndex handler', () => {
+ it('indexes every collection without running a search', async () => {
+ const collections = [
+ { uid: 'c1', pathname: '/c1', name: 'One' },
+ { uid: 'c2', pathname: '/c2', name: 'Two' }
+ ];
+
+ await warmSearchIndex(null, { collections });
+
+ expect(mockIndexCollection).toHaveBeenCalledTimes(2);
+ expect(mockSearch).not.toHaveBeenCalled();
+ });
+
+ it('does not re-index a collection the search handler already indexed this session', async () => {
+ await searchIndex(null, { collections: [{ uid: 'c1', pathname: '/c1', name: 'One' }], terms: ['a'] });
+ mockIndexCollection.mockClear();
+
+ await warmSearchIndex(null, { collections: [{ uid: 'c1', pathname: '/c1', name: 'One' }] });
+
+ expect(mockIndexCollection).not.toHaveBeenCalled();
+ });
+});
+
+describe('getCollectionTree handler', () => {
+ it('indexes the collection, then builds its tree from the index rows', async () => {
+ const collection = { uid: 'c1', pathname: '/c1', name: 'One', ignore: ['dist'] };
+
+ const result = await getCollectionTree(null, { collection });
+
+ expect(mockIndexCollection).toHaveBeenCalledWith(expect.objectContaining({ collectionPath: '/c1' }));
+ expect(mockGetFolderTree).toHaveBeenCalledWith('/c1');
+ expect(mockBuildFolderTree).toHaveBeenCalledWith('/c1', []);
+ expect(result).toEqual({ items: [{ uid: 'req-1', name: 'Get Users', type: 'http-request' }] });
+ });
+
+ it('returns an empty tree without touching the index when the collection has no pathname', async () => {
+ const result = await getCollectionTree(null, { collection: { uid: 'c1' } });
+
+ expect(mockIndexCollection).not.toHaveBeenCalled();
+ expect(mockBuildFolderTree).not.toHaveBeenCalled();
+ expect(result).toEqual({ items: [] });
+ });
+});
diff --git a/packages/bruno-electron/src/services/mount/file-index.js b/packages/bruno-electron/src/services/mount/file-index.js
index 1d12476216b..9c48521afe4 100644
--- a/packages/bruno-electron/src/services/mount/file-index.js
+++ b/packages/bruno-electron/src/services/mount/file-index.js
@@ -3,13 +3,11 @@ const path = require('node:path');
const { Database } = require('../storage');
const {
hashFile,
- hashFileAsync,
normalize,
posixifyPath,
idForAbsolutePath,
resolveDenylist,
- isDenied,
- walk
+ diffFiles
} = require('../../utils/mount');
const MIGRATIONS = [
@@ -59,46 +57,7 @@ class FileIndex {
const root = normalize(collectionPath);
const stored = this.#loadStored(root);
const denylist = resolveDenylist(options.denylist);
- const added = [];
- const updated = [];
- const removed = [];
- const seen = new Set();
-
- const files = walk(root, denylist);
- const results = await Promise.all(files.map(async ({ relativePath, absolutePath }) => {
- const stat = await fs.promises.stat(absolutePath, { bigint: true });
- const mtime = stat.mtimeNs;
- const prior = stored.get(relativePath);
-
- if (!prior) {
- const hash = await hashFileAsync(absolutePath);
- return { kind: 'added', entry: { relativePath, absolutePath, mtime, hash } };
- }
- if (prior.mtime === mtime) return { kind: 'unchanged', relativePath };
- const hash = await hashFileAsync(absolutePath);
- if (hash === prior.hash) return { kind: 'unchanged', relativePath };
- return { kind: 'updated', entry: { relativePath, absolutePath, mtime, hash, prevHash: prior.hash } };
- }));
-
- for (const r of results) {
- if (r.kind === 'added') {
- added.push(r.entry);
- seen.add(r.entry.relativePath);
- } else if (r.kind === 'updated') {
- updated.push(r.entry);
- seen.add(r.entry.relativePath);
- } else {
- seen.add(r.relativePath);
- }
- }
-
- for (const [relativePath, row] of stored) {
- if (seen.has(relativePath)) continue;
- if (isDenied(posixifyPath(relativePath), denylist)) continue;
- removed.push({ relativePath, id: row.id, hash: row.hash });
- }
-
- return { added, updated, removed };
+ return diffFiles(root, stored, denylist);
}
clear() {
diff --git a/packages/bruno-electron/src/services/mount/scan.js b/packages/bruno-electron/src/services/mount/scan.js
new file mode 100644
index 00000000000..4c12e93820a
--- /dev/null
+++ b/packages/bruno-electron/src/services/mount/scan.js
@@ -0,0 +1,120 @@
+const { JobType, getPool } = require('../pool');
+const { buildTree } = require('./tree-builder');
+const { defaultClassify, walk, resolveDenylist } = require('../../utils/mount');
+const { getRequestUid } = require('../../cache/requestUids');
+const { uuid } = require('../../utils/common');
+const { parseValueByDataType } = require('@usebruno/common/utils');
+const { decryptStringSafe } = require('../../utils/encryption');
+const { transformBrunoConfigAfterRead } = require('../../utils/transformBrunoConfig');
+const { setBrunoConfig } = require('../../store/bruno-config');
+const EnvironmentSecretsStore = require('../../store/env-secrets');
+
+let environmentSecretsStore = null;
+const getEnvironmentSecretsStore = () => {
+ if (!environmentSecretsStore) environmentSecretsStore = new EnvironmentSecretsStore();
+ return environmentSecretsStore;
+};
+
+const envHasSecrets = (environment) =>
+ Array.isArray(environment?.variables) && environment.variables.some((variable) => variable.secret);
+
+// Mirrors addEnvironmentFile in app/collection-watcher.js, which is what runs for environments
+// discovered after mount. Both must stay in step: variables get fresh uids, and secret values are
+// decrypted and then coerced through the variable's dataType.
+const hydrateEnvironments = (collectionPath, environments = []) => {
+ for (const environment of environments) {
+ for (const variable of environment.variables || []) {
+ variable.uid = uuid();
+ }
+
+ if (!envHasSecrets(environment)) continue;
+
+ try {
+ const envSecrets = getEnvironmentSecretsStore().getEnvSecrets(collectionPath, environment) || [];
+ for (const secret of envSecrets) {
+ const variable = environment.variables.find((v) => v.name === secret.name && v.secret);
+ if (!variable || !secret.value) continue;
+ const decrypted = decryptStringSafe(secret.value);
+ variable.value = parseValueByDataType(decrypted.value, variable.dataType);
+ }
+ } catch (err) {
+ console.error(`[mount] environment secret hydration failed for ${environment.name}`, err);
+ }
+ }
+};
+
+// Cold-start scan for the default mount path (no file cache): walk the collection once and parse
+// every file in full across the worker pool.
+//
+// Unlike the cache-backed path this keeps no state — nothing is persisted or reconciled, so the
+// tree is always derived from what is on disk right now.
+const scanCollection = async ({ collectionPath, collectionUid, denylist }) => {
+ const resolvedDenylist = resolveDenylist(denylist);
+
+ const walkStartedAt = performance.now();
+ const toParse = [];
+ for (const { relativePath } of await walk(collectionPath, resolvedDenylist)) {
+ const classified = defaultClassify(relativePath);
+ if (!classified) continue;
+ toParse.push({ relativePath, format: classified.format, type: classified.type });
+ }
+ const walkMs = performance.now() - walkStartedAt;
+
+ const parseStartedAt = performance.now();
+ const entries = new Map();
+ if (toParse.length > 0) {
+ const pool = getPool();
+ await Promise.allSettled(
+ toParse.map(async (entry) => {
+ try {
+ entries.set(entry.relativePath, await pool.run(JobType.ParseFile, {
+ collectionPath,
+ relativePath: entry.relativePath,
+ format: entry.format,
+ type: entry.type
+ }));
+ } catch (err) {
+ entries.set(entry.relativePath, {
+ relativePath: entry.relativePath,
+ error: { message: err.message, stack: err.stack }
+ });
+ }
+ })
+ );
+ }
+
+ const parseMs = performance.now() - parseStartedAt;
+
+ const buildStartedAt = performance.now();
+ const tree = buildTree(collectionPath, entries, { uidFor: getRequestUid });
+ const buildMs = performance.now() - buildStartedAt;
+
+ // The watcher runs with ignoreInitial, so nothing else populates these at mount: the bruno
+ // config has to reach the main-process store (the watcher's ignore predicate and the network
+ // layer read it from there) and environment secrets have to be decrypted.
+ if (tree.brunoConfig) {
+ try {
+ tree.brunoConfig = await transformBrunoConfigAfterRead(tree.brunoConfig, collectionPath);
+ setBrunoConfig(collectionUid, tree.brunoConfig);
+ } catch (err) {
+ console.error(`[mount:${collectionUid}] brunoConfig transform failed`, err);
+ }
+ }
+
+ hydrateEnvironments(collectionPath, tree.environments);
+
+ // Surfaced in Collection Overview so a slow mount can be attributed to a phase instead of
+ // guessed at. `parseMs` is wall-clock across the worker pool, not summed CPU time, so on a
+ // machine with N cores it is roughly the total parse cost divided by N.
+ tree.loadStats = {
+ fileCount: toParse.length,
+ walkMs: Math.round(walkMs),
+ parseMs: Math.round(parseMs),
+ buildMs: Math.round(buildMs),
+ scanMs: Math.round(performance.now() - walkStartedAt)
+ };
+
+ return tree;
+};
+
+module.exports = { scanCollection };
diff --git a/packages/bruno-electron/src/services/mount/tree-builder.js b/packages/bruno-electron/src/services/mount/tree-builder.js
index 8101b60e8b2..d355e62e61c 100644
--- a/packages/bruno-electron/src/services/mount/tree-builder.js
+++ b/packages/bruno-electron/src/services/mount/tree-builder.js
@@ -131,7 +131,9 @@ const buildRequestNode = (absolutePath, basename, entry, uidOverrides, uidFor) =
settings: data.settings,
examples: data.examples,
app: data.app ?? null,
- raw: entry.raw ?? null,
+ // `raw` is deliberately not carried into the tree — it duplicates every other field as one
+ // string, for every item, whether or not it is ever opened. `renderer:get-item-raw` reads it
+ // on demand for the one item that needs it (File Mode, or an export walking `js`-type items).
size: sizeInMB(entry.raw ? Buffer.byteLength(entry.raw, 'utf8') : 0),
filename: basename,
pathname: absolutePath,
@@ -204,8 +206,7 @@ const buildTree = (collectionPath, parserResults, options = {}) => {
for (const { relativePath, entry } of requests) {
const segments = path.dirname(relativePath).split(path.sep).filter((s) => s && s !== '.');
const { cursor } = ensureFolder(collectionPath, tree.items, segments, uidFor);
- const buildNode = buildRequestNode;
- cursor.push(buildNode(
+ cursor.push(buildRequestNode(
path.join(collectionPath, relativePath),
path.basename(relativePath),
entry,
diff --git a/packages/bruno-electron/src/services/pool/index.js b/packages/bruno-electron/src/services/pool/index.js
index c80aa5b0b02..e236d4c2728 100644
--- a/packages/bruno-electron/src/services/pool/index.js
+++ b/packages/bruno-electron/src/services/pool/index.js
@@ -8,24 +8,99 @@ const JobType = Object.freeze({
const WORKER_FILE = path.join(__dirname, 'worker.js');
+/**
+ * How long the pool may sit idle before its workers are released.
+ *
+ * Workers are threads, so their heaps count against the main process. Each one loads the `.bru`
+ * grammars, which ohm compiles at module load rather than on first parse — around 78MB per worker
+ * before it has read a single file. A machine reporting ten cores therefore holds the better part
+ * of a gigabyte for a pool that is only used while a collection mounts.
+ *
+ * Long enough that a workspace mounting its collections one after another reuses the same workers
+ * throughout, rather than paying startup between each.
+ */
+const IDLE_TEARDOWN_MS = 30_000;
+
+/**
+ * Ceiling on the default pool size, independent of core count.
+ *
+ * A mount's wall-clock is dominated by reading files and dispatching them, not by CPU: the measured
+ * speed-up topped out around 4.5x even on a `.bru` collection parsed with the full grammar, and the
+ * tree scanner made the per-file work cheaper still. Workers past that point each cost a thread with
+ * its own V8 isolate and win nothing, which on a 10- or 16-core machine is pure overhead.
+ *
+ * An explicitly requested size is honoured; only the core-count default is capped.
+ */
+const MAX_DEFAULT_WORKERS = 6;
+
class Pool {
- #pool;
+ #pool = null;
+ #size;
+ #inFlight = 0;
+ #idleTimer = null;
constructor({ size } = {}) {
- const workers = Math.max(1, size ?? os.availableParallelism());
- this.#pool = workerpool.pool(WORKER_FILE, {
- maxWorkers: workers,
- workerType: 'thread',
- workerThreadOpts: { resourceLimits: { maxOldGenerationSizeMb: 512 } }
- });
+ this.#size = Math.max(1, size ?? Math.min(os.availableParallelism(), MAX_DEFAULT_WORKERS));
+ }
+
+ // Workers are spun up on demand and released again once idle, so this may be creating the
+ // underlying pool for the first time or re-creating it after a quiet period.
+ #ensurePool() {
+ if (!this.#pool) {
+ this.#pool = workerpool.pool(WORKER_FILE, {
+ maxWorkers: this.#size,
+ workerType: 'thread',
+ workerThreadOpts: { resourceLimits: { maxOldGenerationSizeMb: 512 } }
+ });
+ }
+ return this.#pool;
+ }
+
+ async run(type, args) {
+ this.#clearIdleTimer();
+ const pool = this.#ensurePool();
+ this.#inFlight += 1;
+
+ try {
+ return await pool.exec(type, [args]);
+ } finally {
+ this.#inFlight -= 1;
+ if (this.#inFlight === 0) this.#scheduleIdleRelease();
+ }
+ }
+
+ #clearIdleTimer() {
+ if (this.#idleTimer) {
+ clearTimeout(this.#idleTimer);
+ this.#idleTimer = null;
+ }
+ }
+
+ #scheduleIdleRelease() {
+ this.#clearIdleTimer();
+ this.#idleTimer = setTimeout(() => {
+ this.#idleTimer = null;
+ // Re-check: a job may have started between the timer firing and this running.
+ if (this.#inFlight === 0) this.#releaseWorkers();
+ }, IDLE_TEARDOWN_MS);
+ // Must not hold the process open on its own.
+ this.#idleTimer.unref?.();
}
- run(type, args) {
- return this.#pool.exec(type, [args]);
+ // Releases the workers but keeps this Pool usable: callers hold on to the instance returned by
+ // getPool(), so the instance has to outlive its workers. The next run() spins them up again.
+ #releaseWorkers() {
+ const pool = this.#pool;
+ if (!pool) return;
+ this.#pool = null;
+ pool.terminate().catch(() => {});
}
async destroy() {
- await this.#pool.terminate();
+ this.#clearIdleTimer();
+ const pool = this.#pool;
+ this.#pool = null;
+ if (pool) await pool.terminate();
}
}
diff --git a/packages/bruno-electron/src/services/pool/jobs/parse-file.js b/packages/bruno-electron/src/services/pool/jobs/parse-file.js
index 38662756451..51c42b9b3c0 100644
--- a/packages/bruno-electron/src/services/pool/jobs/parse-file.js
+++ b/packages/bruno-electron/src/services/pool/jobs/parse-file.js
@@ -70,14 +70,16 @@ const parseFile = ({ collectionPath, relativePath, format, type }) => {
const buf = fs.readFileSync(absolutePath);
const stat = fs.statSync(absolutePath, { bigint: true });
const mtime = stat.mtimeNs;
- const hash = sha256(buf);
const content = buf.toString('utf8');
+ const hash = sha256(buf);
+ const payload = { raw: content };
+
try {
const data = parseContent(content, format, type, buf.length);
- return { relativePath, mtime, hash, data, format, type, raw: content };
+ return { relativePath, mtime, hash, data, format, type, ...payload };
} catch (err) {
const data = format === 'bru' && type === 'request' ? extractBruMeta(content) : {};
- return { relativePath, mtime, hash, data, format, type, raw: content, partial: true, error: { message: err.message, stack: err.stack } };
+ return { relativePath, mtime, hash, data, format, type, ...payload, partial: true, error: { message: err.message, stack: err.stack } };
}
};
diff --git a/packages/bruno-electron/src/services/search-index/build-tree.js b/packages/bruno-electron/src/services/search-index/build-tree.js
new file mode 100644
index 00000000000..bc87fb19b36
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/build-tree.js
@@ -0,0 +1,66 @@
+const path = require('node:path');
+const { getRequestUid } = require('../../cache/requestUids');
+
+// Not every folder has its own folder.bru/folder.yml — an intermediate directory can exist purely
+// as a path segment. This walks every row's own path up to the root, creating an implicit folder
+// node for each segment that doesn't already have one, the same way the real mount's
+// tree-builder does. `getRequestUid` keeps uids stable across a later real mount of the same path.
+const buildFolderTree = (collectionPath, rows) => {
+ const folderConfigByItemPath = new Map();
+ for (const row of rows) {
+ if (row.type === 'folder') folderConfigByItemPath.set(row.itemPath, row);
+ }
+
+ const root = { items: [] };
+ const foldersByItemPath = new Map([['', root]]);
+
+ const parentOf = (itemPath) => {
+ const dirname = path.dirname(itemPath);
+ return dirname === '.' ? '' : dirname;
+ };
+
+ const ensureFolder = (itemPath) => {
+ const existing = foldersByItemPath.get(itemPath);
+ if (existing) return existing;
+
+ const parent = ensureFolder(parentOf(itemPath));
+ const config = folderConfigByItemPath.get(itemPath);
+ const absolutePath = path.join(collectionPath, itemPath);
+
+ const folder = {
+ uid: getRequestUid(absolutePath),
+ name: config?.name || path.basename(itemPath),
+ type: 'folder',
+ seq: config?.seq ?? undefined,
+ filename: path.basename(itemPath),
+ pathname: absolutePath,
+ collapsed: true,
+ items: []
+ };
+ parent.items.push(folder);
+ foldersByItemPath.set(itemPath, folder);
+ return folder;
+ };
+
+ for (const row of rows) {
+ if (row.type === 'folder') {
+ ensureFolder(row.itemPath);
+ continue;
+ }
+
+ const parent = ensureFolder(row.folderPath || '');
+ const absolutePath = path.join(collectionPath, row.itemPath);
+ parent.items.push({
+ uid: getRequestUid(absolutePath),
+ name: row.name,
+ type: 'http-request',
+ filename: path.basename(row.itemPath),
+ pathname: absolutePath,
+ request: { method: row.method, url: row.url }
+ });
+ }
+
+ return root.items;
+};
+
+module.exports = { buildFolderTree };
diff --git a/packages/bruno-electron/src/services/search-index/build-tree.spec.js b/packages/bruno-electron/src/services/search-index/build-tree.spec.js
new file mode 100644
index 00000000000..913ad4551ab
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/build-tree.spec.js
@@ -0,0 +1,103 @@
+const path = require('node:path');
+const { buildFolderTree } = require('./build-tree');
+const { getRequestUid } = require('../../cache/requestUids');
+
+const COLLECTION_PATH = path.join(path.sep, 'collection');
+
+const requestRow = (itemPath, { name, method = 'GET', url = 'https://x.test' } = {}) => ({
+ type: 'request',
+ itemPath,
+ folderPath: path.dirname(itemPath) === '.' ? '' : path.dirname(itemPath),
+ name: name || path.basename(itemPath),
+ method,
+ url,
+ seq: null
+});
+
+const folderRow = (itemPath, { name, seq = null } = {}) => ({
+ type: 'folder',
+ itemPath,
+ folderPath: path.dirname(itemPath) === '.' ? '' : path.dirname(itemPath),
+ name: name || path.basename(itemPath),
+ method: null,
+ url: null,
+ seq
+});
+
+const findByName = (items, name) => items.find((item) => item.name === name);
+
+describe('buildFolderTree', () => {
+ it('places a request at the collection root', () => {
+ const items = buildFolderTree(COLLECTION_PATH, [requestRow('ping.bru', { name: 'Ping' })]);
+
+ expect(items).toHaveLength(1);
+ expect(items[0].name).toBe('Ping');
+ expect(items[0].type).toBe('http-request');
+ expect(items[0].request).toEqual({ method: 'GET', url: 'https://x.test' });
+ });
+
+ it('creates a folder node from its own folder.bru, applying the configured name and seq', () => {
+ const items = buildFolderTree(COLLECTION_PATH, [
+ folderRow('users', { name: 'Users', seq: 2 }),
+ requestRow(path.join('users', 'get.bru'), { name: 'Get Users' })
+ ]);
+
+ const usersFolder = findByName(items, 'Users');
+ expect(usersFolder.type).toBe('folder');
+ expect(usersFolder.seq).toBe(2);
+ expect(usersFolder.items).toHaveLength(1);
+ expect(usersFolder.items[0].name).toBe('Get Users');
+ });
+
+ it('creates sibling folders independently, each with their own children', () => {
+ const items = buildFolderTree(COLLECTION_PATH, [
+ folderRow('users', { name: 'Users' }),
+ folderRow('orders', { name: 'Orders' }),
+ requestRow(path.join('users', 'get.bru'), { name: 'Get Users' }),
+ requestRow(path.join('orders', 'get.bru'), { name: 'Get Orders' })
+ ]);
+
+ expect(items).toHaveLength(2);
+ expect(findByName(items, 'Users').items.map((i) => i.name)).toEqual(['Get Users']);
+ expect(findByName(items, 'Orders').items.map((i) => i.name)).toEqual(['Get Orders']);
+ });
+
+ it('builds an implicit folder for a directory that has no folder.bru of its own', () => {
+ // `api` has no folder.bru — it exists only because `api/v2` has a request in it.
+ const items = buildFolderTree(COLLECTION_PATH, [
+ folderRow(path.join('api', 'v2'), { name: 'v2' }),
+ requestRow(path.join('api', 'v2', 'get.bru'), { name: 'Get V2' })
+ ]);
+
+ expect(items).toHaveLength(1);
+ const api = items[0];
+ expect(api.name).toBe('api');
+ expect(api.type).toBe('folder');
+
+ const v2 = findByName(api.items, 'v2');
+ expect(v2.items.map((i) => i.name)).toEqual(['Get V2']);
+ });
+
+ it('nests a folder inside its parent folder rather than flattening the tree', () => {
+ const items = buildFolderTree(COLLECTION_PATH, [
+ folderRow('api', { name: 'api' }),
+ folderRow(path.join('api', 'v2'), { name: 'v2' }),
+ requestRow(path.join('api', 'v2', 'get.bru'), { name: 'Get V2' })
+ ]);
+
+ expect(items).toHaveLength(1);
+ const api = items[0];
+ expect(api.items).toHaveLength(1);
+ expect(api.items[0].name).toBe('v2');
+ expect(api.items[0].items[0].name).toBe('Get V2');
+ });
+
+ it('assigns the same uid a real mount would, for the same path', () => {
+ const absolutePath = path.join(COLLECTION_PATH, 'ping.bru');
+ const expectedUid = getRequestUid(absolutePath);
+
+ const items = buildFolderTree(COLLECTION_PATH, [requestRow('ping.bru', { name: 'Ping' })]);
+
+ expect(items[0].uid).toBe(expectedUid);
+ });
+});
diff --git a/packages/bruno-electron/src/services/search-index/index.js b/packages/bruno-electron/src/services/search-index/index.js
new file mode 100644
index 00000000000..329bae1e080
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/index.js
@@ -0,0 +1,164 @@
+const path = require('node:path');
+const { Database } = require('../storage');
+const { normalize, idForAbsolutePath, resolveDenylist, diffFiles } = require('../../utils/mount');
+
+const MIGRATIONS = [
+ {
+ version: 1,
+ up: `
+ CREATE TABLE IF NOT EXISTS search_index_items (
+ id TEXT PRIMARY KEY,
+ collection_path TEXT NOT NULL,
+ relative_path TEXT NOT NULL,
+ name TEXT NOT NULL,
+ method TEXT,
+ url TEXT,
+ request_path TEXT NOT NULL,
+ folder_path TEXT NOT NULL,
+ collection_uid TEXT NOT NULL,
+ collection_name TEXT NOT NULL,
+ mtime INTEGER NOT NULL,
+ hash TEXT NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_search_index_collection_path ON search_index_items(collection_path);
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_search_index_collection_relpath ON search_index_items(collection_path, relative_path);
+ `
+ },
+ {
+ version: 2,
+ up: `
+ ALTER TABLE search_index_items ADD COLUMN type TEXT NOT NULL DEFAULT 'request';
+ ALTER TABLE search_index_items ADD COLUMN seq INTEGER;
+ ALTER TABLE search_index_items ADD COLUMN item_path TEXT;
+ UPDATE search_index_items SET item_path = relative_path;
+ `
+ }
+];
+
+const escapeLike = (value) => value.replace(/[\\%_]/g, (c) => `\\${c}`);
+
+class SearchIndex {
+ #db;
+ #dbPath;
+
+ constructor({ dbPath } = {}) {
+ this.#dbPath = dbPath || path.join(require('electron').app.getPath('userData'), 'search-index.db');
+ this.#db = new Database({ path: this.#dbPath, migrations: MIGRATIONS, readBigInts: true });
+ }
+
+ close() {
+ this.#db.close();
+ }
+
+ get dbPath() {
+ return this.#dbPath;
+ }
+
+ async status(collectionPath, options = {}) {
+ const root = normalize(collectionPath);
+ const stored = this.#loadStored(root);
+ const denylist = resolveDenylist(options.denylist);
+ return diffFiles(root, stored, denylist);
+ }
+
+ apply(collectionPath, { upsert = [], removeIds = [] } = {}) {
+ const root = normalize(collectionPath);
+ this.#db.transaction(() => {
+ for (const entry of upsert) this.#upsert(root, entry);
+ for (const id of removeIds) this.#db.run('DELETE FROM search_index_items WHERE id = ?', id);
+ });
+ }
+
+ clearCollection(collectionPath) {
+ const root = normalize(collectionPath);
+ this.#db.run('DELETE FROM search_index_items WHERE collection_path = ?', root);
+ }
+
+ search({ terms, collectionPaths, limit = 50 }) {
+ if (!terms.length || !collectionPaths.length) return [];
+
+ const termClauses = terms
+ .map(() => `(name LIKE ? ESCAPE '\\' OR url LIKE ? ESCAPE '\\' OR folder_path LIKE ? ESCAPE '\\' OR collection_name LIKE ? ESCAPE '\\')`)
+ .join(' AND ');
+ const collectionPlaceholders = collectionPaths.map(() => '?').join(',');
+
+ const params = [];
+ for (const term of terms) {
+ const pattern = `%${escapeLike(term)}%`;
+ params.push(pattern, pattern, pattern, pattern);
+ }
+ params.push(...collectionPaths, limit);
+
+ return this.#db.all(
+ `SELECT id, name, method, url, request_path, folder_path, collection_uid, collection_path, collection_name
+ FROM search_index_items
+ WHERE type = 'request' AND ${termClauses} AND collection_path IN (${collectionPlaceholders})
+ ORDER BY name COLLATE NOCASE
+ LIMIT ?`,
+ ...params
+ );
+ }
+
+ // Sourced from the sidebar-tree read path, which needs every folder and request under a
+ // collection, not just ones matching a term. `itemPath` is the thing a row *describes* (a
+ // folder's own directory, or a request's own file) — distinct from `relativePath`, which is
+ // always the real file on disk that `status()` diffs against.
+ getFolderTree(collectionPath) {
+ const root = normalize(collectionPath);
+ const rows = this.#db.all(
+ `SELECT item_path AS itemPath, name, type, seq, method, url,
+ folder_path AS folderPath, request_path AS absolutePath
+ FROM search_index_items
+ WHERE collection_path = ?
+ ORDER BY item_path`,
+ root
+ );
+ // This connection reads every INTEGER column as a BigInt (mtime needs that precision
+ // elsewhere), but a folder's seq is always a small ordering number — sortByNameThenSequence
+ // (@usebruno/common) checks it with Number.isFinite/isInteger, which are false for a BigInt,
+ // so an unconverted seq is silently treated as absent and the folder falls back to alphabetical order.
+ return rows.map((row) => (row.seq == null ? row : { ...row, seq: Number(row.seq) }));
+ }
+
+ #upsert(root, entry) {
+ const {
+ relativePath, absolutePath, itemPath = relativePath, name, method, url,
+ collectionUid, collectionName, mtime, hash,
+ type = 'request', seq = null
+ } = entry;
+ const id = idForAbsolutePath(absolutePath);
+ const dirname = path.dirname(itemPath);
+ const folderPath = dirname === '.' ? '' : dirname;
+
+ this.#db.run(
+ `INSERT INTO search_index_items
+ (id, collection_path, relative_path, item_path, name, method, url, request_path, folder_path, collection_uid, collection_name, mtime, hash, type, seq, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name,
+ method = excluded.method,
+ url = excluded.url,
+ folder_path = excluded.folder_path,
+ mtime = excluded.mtime,
+ hash = excluded.hash,
+ type = excluded.type,
+ seq = excluded.seq,
+ updated_at = excluded.updated_at`,
+ id, root, relativePath, itemPath, name, method || null, url || null,
+ absolutePath, folderPath, collectionUid, collectionName, mtime, hash, type, seq
+ );
+ }
+
+ #loadStored(collectionPath) {
+ const rows = this.#db.all(
+ 'SELECT relative_path AS relativePath, id, mtime, hash FROM search_index_items WHERE collection_path = ?',
+ collectionPath
+ );
+ const map = new Map();
+ for (const row of rows) map.set(row.relativePath, row);
+ return map;
+ }
+}
+
+module.exports = { SearchIndex };
diff --git a/packages/bruno-electron/src/services/search-index/index.spec.js b/packages/bruno-electron/src/services/search-index/index.spec.js
new file mode 100644
index 00000000000..0a2030c81f2
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/index.spec.js
@@ -0,0 +1,194 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const { SearchIndex } = require('./index');
+
+const makeIndex = () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-search-index-'));
+ return new SearchIndex({ dbPath: path.join(dir, 'search-index.db') });
+};
+
+const row = (overrides = {}) => ({
+ relativePath: 'users/get.bru',
+ absolutePath: '/c/users/get.bru',
+ name: 'Get Users',
+ method: 'GET',
+ url: 'https://api.test/users',
+ collectionUid: 'col-1',
+ collectionName: 'My Collection',
+ mtime: 1n,
+ hash: 'h1',
+ ...overrides
+});
+
+describe('SearchIndex', () => {
+ it('finds a request by a substring of its name', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [row()] });
+
+ const results = index.search({ terms: ['user'], collectionPaths: ['/c'] });
+
+ expect(results).toHaveLength(1);
+ expect(results[0].name).toBe('Get Users');
+ index.close();
+ });
+
+ it('matches camelCase-style substrings the way .includes() does', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [row({ relativePath: 'up.bru', absolutePath: '/c/up.bru', name: 'UserProfile' })] });
+
+ const results = index.search({ terms: ['profile'], collectionPaths: ['/c'] });
+
+ expect(results).toHaveLength(1);
+ index.close();
+ });
+
+ it('requires every term to match, possibly in different fields', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [row()] });
+
+ expect(index.search({ terms: ['get', 'users'], collectionPaths: ['/c'] })).toHaveLength(1);
+ expect(index.search({ terms: ['get', 'orders'], collectionPaths: ['/c'] })).toHaveLength(0);
+ index.close();
+ });
+
+ it('matches on url as well as name', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [row()] });
+
+ const results = index.search({ terms: ['api.test'], collectionPaths: ['/c'] });
+
+ expect(results).toHaveLength(1);
+ index.close();
+ });
+
+ it('treats % and _ in the query as literal characters, not wildcards', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [row({ name: '100% Done' })] });
+
+ expect(index.search({ terms: ['100% done'], collectionPaths: ['/c'] })).toHaveLength(1);
+ expect(index.search({ terms: ['100x done'], collectionPaths: ['/c'] })).toHaveLength(0);
+ index.close();
+ });
+
+ it('scopes results to the requested collections only', () => {
+ const index = makeIndex();
+ index.apply('/c1', { upsert: [row({ absolutePath: '/c1/users/get.bru' })] });
+ index.apply('/c2', { upsert: [row({ absolutePath: '/c2/users/get.bru' })] });
+
+ expect(index.search({ terms: ['users'], collectionPaths: ['/c1'] })).toHaveLength(1);
+ expect(index.search({ terms: ['users'], collectionPaths: ['/c1', '/c2'] })).toHaveLength(2);
+ index.close();
+ });
+
+ it('bounds the result count', () => {
+ const index = makeIndex();
+ const rows = Array.from({ length: 10 }, (_, i) => row({
+ relativePath: `r${i}.bru`,
+ absolutePath: `/c/r${i}.bru`,
+ name: `Request ${i}`
+ }));
+ index.apply('/c', { upsert: rows });
+
+ expect(index.search({ terms: ['request'], collectionPaths: ['/c'], limit: 3 })).toHaveLength(3);
+ index.close();
+ });
+
+ it('updates an existing row on re-index rather than duplicating it', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [row()] });
+ index.apply('/c', { upsert: [row({ name: 'Fetch Users V2', mtime: 2n, hash: 'h2' })] });
+
+ const results = index.search({ terms: ['users'], collectionPaths: ['/c'] });
+
+ expect(results).toHaveLength(1);
+ expect(results[0].name).toBe('Fetch Users V2');
+ index.close();
+ });
+
+ it('removes a row by id', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [row()] });
+ const [{ id }] = index.search({ terms: ['users'], collectionPaths: ['/c'] });
+
+ index.apply('/c', { removeIds: [id] });
+
+ expect(index.search({ terms: ['users'], collectionPaths: ['/c'] })).toHaveLength(0);
+ index.close();
+ });
+
+ describe('getFolderTree', () => {
+ const folderRow = (overrides = {}) => row({
+ relativePath: 'users/folder.bru',
+ absolutePath: '/c/users/folder.bru',
+ itemPath: 'users',
+ type: 'folder',
+ seq: 2,
+ name: 'Users',
+ method: null,
+ url: null,
+ ...overrides
+ });
+
+ it('returns seq as a plain Number, not the BigInt the underlying connection reads by default', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [folderRow()] });
+
+ const [folder] = index.getFolderTree('/c');
+
+ expect(folder.seq).toBe(2);
+ expect(typeof folder.seq).toBe('number');
+ index.close();
+ });
+
+ it('leaves seq as null for a folder/request that never set one', () => {
+ const index = makeIndex();
+ index.apply('/c', { upsert: [folderRow({ seq: null })] });
+
+ const [folder] = index.getFolderTree('/c');
+
+ expect(folder.seq).toBeNull();
+ index.close();
+ });
+ });
+
+ it('status reports every file as added against an empty index', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-search-index-collection-'));
+ fs.mkdirSync(path.join(dir, 'users'));
+ fs.writeFileSync(path.join(dir, 'users', 'get.bru'), 'meta { name: Get }');
+
+ const index = makeIndex();
+ const { added, updated, removed } = await index.status(dir);
+
+ expect(added).toHaveLength(1);
+ expect(added[0].relativePath).toBe(path.join('users', 'get.bru'));
+ expect(updated).toHaveLength(0);
+ expect(removed).toHaveLength(0);
+ index.close();
+ });
+
+ it('status reports nothing changed once the file is indexed and untouched', async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-search-index-collection-'));
+ fs.writeFileSync(path.join(dir, 'get.bru'), 'meta { name: Get }');
+
+ const index = makeIndex();
+ const first = await index.status(dir);
+ index.apply(dir, {
+ upsert: first.added.map((entry) => ({
+ ...entry,
+ name: 'Get',
+ method: 'GET',
+ url: '',
+ collectionUid: 'col-1',
+ collectionName: 'C'
+ }))
+ });
+
+ const second = await index.status(dir);
+
+ expect(second.added).toHaveLength(0);
+ expect(second.updated).toHaveLength(0);
+ expect(second.removed).toHaveLength(0);
+ index.close();
+ });
+});
diff --git a/packages/bruno-electron/src/services/search-index/indexer.js b/packages/bruno-electron/src/services/search-index/indexer.js
new file mode 100644
index 00000000000..054294146a7
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/indexer.js
@@ -0,0 +1,72 @@
+const path = require('node:path');
+const { JobType, getPool } = require('../pool');
+const { defaultClassify, normalize } = require('../../utils/mount');
+const { SearchIndex } = require('./index');
+
+let sharedIndex = null;
+const getSearchIndex = () => {
+ if (!sharedIndex) sharedIndex = new SearchIndex({});
+ return sharedIndex;
+};
+
+const parseForIndex = async (root, entry, type) => {
+ const cls = defaultClassify(entry.relativePath);
+ // A folder's own identity is its directory, not the folder.bru/folder.yml file describing it —
+ // that file stays the thing `relativePath`/`absolutePath` track for diffing and removal.
+ const itemPath = type === 'folder' ? path.dirname(entry.relativePath) : entry.relativePath;
+ let name = path.basename(itemPath);
+ let method = null;
+ let url = null;
+ let seq = null;
+
+ try {
+ const result = await getPool().run(JobType.ParseFile, {
+ collectionPath: root,
+ relativePath: entry.relativePath,
+ format: cls.format,
+ type: cls.type
+ });
+ if (type === 'folder') {
+ if (result.data?.meta?.name) name = result.data.meta.name;
+ seq = Number.isFinite(result.data?.meta?.seq) ? result.data.meta.seq : null;
+ } else if (result.data?.name) {
+ name = result.data.name;
+ }
+ method = result.data?.request?.method || null;
+ url = result.data?.request?.url || null;
+ } catch (err) {}
+
+ return {
+ relativePath: entry.relativePath,
+ absolutePath: entry.absolutePath,
+ itemPath,
+ name,
+ method,
+ url,
+ type,
+ seq,
+ mtime: entry.mtime,
+ hash: entry.hash
+ };
+};
+
+const indexCollection = async ({ collectionPath, collectionUid, collectionName, denylist }) => {
+ const root = normalize(collectionPath);
+ const index = getSearchIndex();
+ const { added, updated, removed } = await index.status(root, { denylist });
+
+ const classifyType = (entry) => defaultClassify(entry.relativePath)?.type;
+ const toParse = [...added, ...updated].filter((entry) => ['request', 'folder'].includes(classifyType(entry)));
+ const parsed = await Promise.all(toParse.map((entry) => parseForIndex(root, entry, classifyType(entry))));
+
+ const upsert = parsed.map((entry) => ({ ...entry, collectionUid, collectionName }));
+ const removeIds = removed
+ .filter((entry) => ['request', 'folder'].includes(classifyType(entry)))
+ .map((entry) => entry.id);
+
+ index.apply(root, { upsert, removeIds });
+
+ return { indexed: upsert.length, removed: removeIds.length };
+};
+
+module.exports = { indexCollection, getSearchIndex, parseForIndex };
diff --git a/packages/bruno-electron/src/services/search-index/indexer.spec.js b/packages/bruno-electron/src/services/search-index/indexer.spec.js
new file mode 100644
index 00000000000..26e157cac8d
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/indexer.spec.js
@@ -0,0 +1,102 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+
+const mockUserDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-search-index-userdata-'));
+jest.mock('electron', () => ({
+ app: { getPath: jest.fn(() => mockUserDataDir) }
+}));
+
+const mockRun = jest.fn(async (type, args) => {
+ if (args.relativePath.endsWith('get.bru')) {
+ return { data: { name: 'Get Users', request: { method: 'GET', url: 'https://api.test/users' } } };
+ }
+ if (args.relativePath.endsWith('folder.bru')) {
+ return { data: { meta: { name: 'Users', seq: 1 } } };
+ }
+ return { data: { name: path.basename(args.relativePath, '.bru') } };
+});
+jest.mock('../pool', () => ({
+ JobType: { ParseFile: 'parse-file' },
+ getPool: () => ({ run: mockRun })
+}));
+
+const { indexCollection, getSearchIndex } = require('./indexer');
+
+const makeCollection = () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-search-index-collection-'));
+ fs.mkdirSync(path.join(dir, 'users'));
+ fs.writeFileSync(path.join(dir, 'users', 'get.bru'), 'meta {\n name: Get Users\n type: http\n seq: 1\n}\n\nget {\n url: https://api.test/users\n}\n');
+ fs.writeFileSync(path.join(dir, 'users', 'folder.bru'), 'meta {\n name: Users\n seq: 1\n}\n');
+ return dir;
+};
+
+afterEach(() => {
+ mockRun.mockClear();
+});
+
+describe('indexCollection', () => {
+ it('indexes request files and makes them searchable', async () => {
+ const collectionPath = makeCollection();
+
+ await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+
+ const results = getSearchIndex().search({ terms: ['users'], collectionPaths: [collectionPath] });
+ expect(results).toHaveLength(1);
+ expect(results[0].name).toBe('Get Users');
+ expect(results[0].method).toBe('GET');
+ });
+
+ it('indexes folder.bru for the sidebar tree, but not as a searchable request', async () => {
+ const collectionPath = makeCollection();
+
+ await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+
+ expect(mockRun).toHaveBeenCalledTimes(2);
+ expect(mockRun).toHaveBeenCalledWith('parse-file', expect.objectContaining({ relativePath: path.join('users', 'folder.bru') }));
+
+ const results = getSearchIndex().search({ terms: ['users'], collectionPaths: [collectionPath] });
+ expect(results).toHaveLength(1);
+ expect(results[0].name).toBe('Get Users');
+ });
+
+ it('does not re-index or re-parse a file that has not changed', async () => {
+ const collectionPath = makeCollection();
+ await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+ mockRun.mockClear();
+
+ const result = await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+
+ expect(result).toEqual({ indexed: 0, removed: 0 });
+ expect(mockRun).not.toHaveBeenCalled();
+ });
+
+ it('removes a row once its file is deleted from disk', async () => {
+ const collectionPath = makeCollection();
+ await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+ fs.unlinkSync(path.join(collectionPath, 'users', 'get.bru'));
+
+ await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+
+ expect(getSearchIndex().search({ terms: ['users'], collectionPaths: [collectionPath] })).toHaveLength(0);
+ });
+
+ it('re-indexes a file after its content changes', async () => {
+ const collectionPath = makeCollection();
+ await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+
+ const getBruPath = path.join(collectionPath, 'users', 'get.bru');
+ fs.writeFileSync(getBruPath, 'meta {\n name: Fetch Users V2\n type: http\n seq: 1\n}\n\nget {\n url: https://api.test/users\n}\n');
+ const bumped = new Date(fs.statSync(getBruPath).mtime.getTime() + 1000);
+ fs.utimesSync(getBruPath, bumped, bumped);
+ mockRun.mockImplementationOnce(async () => ({
+ data: { name: 'Fetch Users V2', request: { method: 'GET', url: 'https://api.test/users' } }
+ }));
+
+ await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' });
+
+ const results = getSearchIndex().search({ terms: ['fetch'], collectionPaths: [collectionPath] });
+ expect(results).toHaveLength(1);
+ expect(results[0].name).toBe('Fetch Users V2');
+ });
+});
diff --git a/packages/bruno-electron/src/services/search-index/watcher.js b/packages/bruno-electron/src/services/search-index/watcher.js
new file mode 100644
index 00000000000..02e24bf57dc
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/watcher.js
@@ -0,0 +1,75 @@
+const chokidar = require('chokidar');
+const path = require('node:path');
+const fs = require('node:fs');
+const {
+ DENY_DIRS,
+ defaultClassify,
+ normalize,
+ posixifyPath,
+ isDenied,
+ resolveDenylist,
+ hashFileAsync,
+ idForAbsolutePath
+} = require('../../utils/mount');
+const { getSearchIndex, parseForIndex } = require('./indexer');
+
+const watchers = new Map();
+
+const isPathIgnored = (root, absolutePath, denylist) => {
+ const relativePath = path.relative(root, absolutePath);
+ if (!relativePath) return false;
+ const segments = relativePath.split(path.sep);
+ if (segments.some((segment) => DENY_DIRS.has(segment))) return true;
+ return isDenied(posixifyPath(relativePath), denylist);
+};
+
+const ensureWatching = ({ collectionPath, collectionUid, collectionName, denylist }) => {
+ const root = normalize(collectionPath);
+ if (watchers.has(root)) return watchers.get(root);
+
+ const resolvedDenylist = resolveDenylist(denylist);
+
+ const upsertOne = async (absolutePath) => {
+ const relativePath = path.relative(root, absolutePath);
+ if (defaultClassify(relativePath)?.type !== 'request') return;
+
+ try {
+ const stat = await fs.promises.stat(absolutePath, { bigint: true });
+ const hash = await hashFileAsync(absolutePath);
+ const entry = await parseForIndex(root, { relativePath, absolutePath, mtime: stat.mtimeNs, hash });
+ getSearchIndex().apply(root, { upsert: [{ ...entry, collectionUid, collectionName }] });
+ } catch (err) {
+ console.error(`[search-index] failed to index ${absolutePath}`, err);
+ }
+ };
+
+ const removeOne = (absolutePath) => {
+ const relativePath = path.relative(root, absolutePath);
+ if (defaultClassify(relativePath)?.type !== 'request') return;
+ getSearchIndex().apply(root, { removeIds: [idForAbsolutePath(absolutePath)] });
+ };
+
+ const watcher = chokidar.watch(root, {
+ ignoreInitial: true,
+ depth: 20,
+ awaitWriteFinish: { stabilityThreshold: 80, pollInterval: 10 },
+ ignored: (filepath) => isPathIgnored(root, filepath, resolvedDenylist)
+ });
+
+ watcher
+ .on('add', upsertOne)
+ .on('change', upsertOne)
+ .on('unlink', removeOne)
+ .on('error', (err) => console.error(`[search-index] watcher error for ${root}`, err));
+
+ watchers.set(root, watcher);
+ return watcher;
+};
+
+const closeAll = async () => {
+ const all = Array.from(watchers.values());
+ watchers.clear();
+ await Promise.allSettled(all.map((watcher) => watcher.close()));
+};
+
+module.exports = { ensureWatching, closeAll };
diff --git a/packages/bruno-electron/src/services/search-index/watcher.spec.js b/packages/bruno-electron/src/services/search-index/watcher.spec.js
new file mode 100644
index 00000000000..901f3b82c7d
--- /dev/null
+++ b/packages/bruno-electron/src/services/search-index/watcher.spec.js
@@ -0,0 +1,117 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+
+const mockUserDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-search-index-watch-userdata-'));
+jest.mock('electron', () => ({
+ app: { getPath: jest.fn(() => mockUserDataDir) }
+}));
+
+const mockRun = jest.fn(async (type, args) => ({
+ data: { name: path.basename(args.relativePath, '.bru'), request: { method: 'GET', url: 'https://x.test' } }
+}));
+jest.mock('../pool', () => ({
+ JobType: { ParseFile: 'parse-file' },
+ getPool: () => ({ run: mockRun })
+}));
+
+const { ensureWatching, closeAll } = require('./watcher');
+const { getSearchIndex } = require('./indexer');
+
+const makeCollection = () => fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-search-index-watch-collection-'));
+
+const waitFor = async (assertion, { timeout = 2000, interval = 20 } = {}) => {
+ const start = Date.now();
+ let lastError;
+ while (Date.now() - start < timeout) {
+ try {
+ assertion();
+ return;
+ } catch (err) {
+ lastError = err;
+ await new Promise((resolve) => setTimeout(resolve, interval));
+ }
+ }
+ throw lastError;
+};
+
+afterEach(async () => {
+ await closeAll();
+});
+
+const startWatching = async (options) => {
+ const watcher = ensureWatching(options);
+ await new Promise((resolve) => watcher.once('ready', resolve));
+ return watcher;
+};
+
+describe('search-index watcher', () => {
+ it('adds a row when a request file is created after watching starts', async () => {
+ const collectionPath = makeCollection();
+ await startWatching({ collectionPath, collectionUid: 'col-1', collectionName: 'One' });
+
+ fs.writeFileSync(path.join(collectionPath, 'get.bru'), 'meta {\n name: Get\n type: http\n seq: 1\n}\n\nget {\n url: https://x.test\n}\n');
+
+ await waitFor(() => {
+ const results = getSearchIndex().search({ terms: ['get'], collectionPaths: [collectionPath] });
+ expect(results).toHaveLength(1);
+ });
+ });
+
+ it('does not index a folder.bru file', async () => {
+ const collectionPath = makeCollection();
+ await startWatching({ collectionPath, collectionUid: 'col-1', collectionName: 'One' });
+
+ fs.writeFileSync(path.join(collectionPath, 'folder.bru'), 'meta {\n name: Folder\n seq: 1\n}\n');
+ await new Promise((resolve) => setTimeout(resolve, 300));
+
+ expect(getSearchIndex().search({ terms: ['folder'], collectionPaths: [collectionPath] })).toHaveLength(0);
+ });
+
+ it('updates the row when the file changes', async () => {
+ const collectionPath = makeCollection();
+ const filePath = path.join(collectionPath, 'get.bru');
+ await startWatching({ collectionPath, collectionUid: 'col-1', collectionName: 'One' });
+ fs.writeFileSync(filePath, 'meta {\n name: Get\n type: http\n seq: 1\n}\n\nget {\n url: https://x.test\n}\n');
+ await waitFor(() => {
+ expect(getSearchIndex().search({ terms: ['get'], collectionPaths: [collectionPath] })).toHaveLength(1);
+ });
+
+ mockRun.mockResolvedValueOnce({ data: { name: 'Fetch V2', request: { method: 'GET', url: 'https://x.test' } } });
+ fs.writeFileSync(filePath, 'meta {\n name: Fetch V2\n type: http\n seq: 1\n}\n\nget {\n url: https://x.test\n}\n');
+
+ await waitFor(() => {
+ const results = getSearchIndex().search({ terms: ['fetch'], collectionPaths: [collectionPath] });
+ expect(results).toHaveLength(1);
+ expect(results[0].name).toBe('Fetch V2');
+ });
+ });
+
+ it('removes the row when the file is deleted', async () => {
+ const collectionPath = makeCollection();
+ const filePath = path.join(collectionPath, 'get.bru');
+ await startWatching({ collectionPath, collectionUid: 'col-1', collectionName: 'One' });
+ fs.writeFileSync(filePath, 'meta {\n name: Get\n type: http\n seq: 1\n}\n\nget {\n url: https://x.test\n}\n');
+ await waitFor(() => {
+ expect(getSearchIndex().search({ terms: ['get'], collectionPaths: [collectionPath] })).toHaveLength(1);
+ });
+
+ fs.unlinkSync(filePath);
+
+ await waitFor(() => {
+ expect(getSearchIndex().search({ terms: ['get'], collectionPaths: [collectionPath] })).toHaveLength(0);
+ });
+ });
+
+ it('does not start a second watcher for the same collection', () => {
+ const collectionPath = makeCollection();
+ ensureWatching({ collectionPath, collectionUid: 'col-1', collectionName: 'One' });
+ const chokidar = require('chokidar');
+ const watchSpy = jest.spyOn(chokidar, 'watch');
+
+ ensureWatching({ collectionPath, collectionUid: 'col-1', collectionName: 'One' });
+
+ expect(watchSpy).not.toHaveBeenCalled();
+ watchSpy.mockRestore();
+ });
+});
diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js
index 8f5e97ed6ca..59e229c51d6 100644
--- a/packages/bruno-electron/src/utils/collection.js
+++ b/packages/bruno-electron/src/utils/collection.js
@@ -1,12 +1,10 @@
const { get, each, find, isString, filter } = require('lodash');
-const fs = require('fs');
const { getRequestUid, getExampleUid } = require('../cache/requestUids');
const { uuid } = require('./common');
const { posixifyPath } = require('./filesystem');
const os = require('os');
const { preferencesUtil } = require('../store/preferences');
const path = require('path');
-const { DEFAULT_COLLECTION_FORMAT } = require('@usebruno/filestore');
const { parseValueByDataType } = require('@usebruno/common/utils');
const { GRPC_SCRIPT_KEYS, getEffectiveTags, getFolderTags, getOwnTags } = require('@usebruno/common');
@@ -566,67 +564,6 @@ const parseBruFileMeta = (data) => {
}
};
-// Parse YML file meta information
-const parseYmlFileMeta = (data) => {
- try {
- const yaml = require('js-yaml');
- const parsed = yaml.load(data);
-
- if (!parsed || !parsed.meta) {
- console.log('No "meta" section found in YAML file.');
- return null;
- }
-
- const metaJson = parsed.meta;
-
- // Transform to the format expected by bruno-app
- let requestType = metaJson.type;
- const typeMap = {
- http: 'http-request',
- graphql: 'graphql-request',
- grpc: 'grpc-request',
- ws: 'ws-request'
- };
- requestType = typeMap[requestType] || 'http-request';
-
- const sequence = metaJson.seq;
- const transformedJson = {
- type: requestType,
- name: metaJson.name,
- seq: !isNaN(sequence) ? Number(sequence) : 1,
- settings: {},
- tags: metaJson.tags || [],
- request: {
- method: '',
- url: '',
- params: [],
- headers: [],
- auth: { mode: 'none' },
- body: { mode: 'none' },
- script: {},
- vars: {},
- assertions: [],
- tests: '',
- docs: ''
- }
- };
-
- return transformedJson;
- } catch (err) {
- console.error('Error parsing YAML file meta:', err);
- return null;
- }
-};
-
-// Format-aware meta parsing function
-const parseFileMeta = (data, format = DEFAULT_COLLECTION_FORMAT) => {
- if (format === 'yml') {
- return parseYmlFileMeta(data);
- } else {
- return parseBruFileMeta(data);
- }
-};
-
const hydrateRequestWithUuid = (request, pathname) => {
request.uid = getRequestUid(pathname);
const prefix = path.join(os.tmpdir(), 'bruno-');
@@ -1020,7 +957,6 @@ module.exports = {
findParentItemInCollection,
findParentItemInCollectionByPathname,
parseBruFileMeta,
- parseFileMeta,
hydrateRequestWithUuid,
transformRequestToSaveToFilesystem,
sortCollection,
diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js
index 8e66b8ebcc2..d996ec44ca3 100644
--- a/packages/bruno-electron/src/utils/filesystem.js
+++ b/packages/bruno-electron/src/utils/filesystem.js
@@ -255,6 +255,13 @@ const hasRequestExtension = (filename, format = null) => {
return ['bru', 'yml'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
};
+/**
+ * The format of a single request file, taken from the file itself rather than from the collection
+ * it is being read *for*. Those differ when an item crosses collections — pasting a `.bru` request
+ * into a `.yml` collection — where the source has to be parsed as what it is on disk.
+ */
+const getRequestFormat = (pathname) => (String(pathname).toLowerCase().endsWith('.yml') ? 'yml' : 'bru');
+
const createDirectory = async (dir) => {
if (!dir) {
throw new Error(`directory: path is null`);
@@ -551,7 +558,9 @@ const getCollectionStats = async (directoryPath) => {
await calculateStats(fullPath);
}
- if (path.extname(fullPath) === '.bru') {
+ // Counts both formats. Counting only `.bru` made every `.yml` collection report 0 files
+ // and 0 bytes, so it never crossed the async thresholds and always parsed on the main thread.
+ if (hasRequestExtension(fullPath)) {
const stats = await fsPromises.stat(fullPath);
size += stats?.size;
if (maxFileSize < stats?.size) {
@@ -789,6 +798,7 @@ module.exports = {
validateName,
hasSubDirectories,
getCollectionStats,
+ getRequestFormat,
sizeInMB,
safeWriteFile,
safeWriteFileSync,
diff --git a/packages/bruno-electron/src/utils/mount.js b/packages/bruno-electron/src/utils/mount.js
index 3ead6b91935..9b33ecf99ef 100644
--- a/packages/bruno-electron/src/utils/mount.js
+++ b/packages/bruno-electron/src/utils/mount.js
@@ -22,20 +22,41 @@ const isDenied = (relativePathPosix, patterns) => {
return false;
};
-const walk = (root, denylist) => {
+/**
+ * Every file under `root`, minus denied paths, following symlinks once.
+ *
+ * Asynchronous because this runs on the main process ahead of the parse: a synchronous walk blocks
+ * it for the whole traversal, and mounting a workspace runs one per collection back to back, so the
+ * app is unresponsive before any of the pooled parsing starts.
+ *
+ * Sibling directories are traversed together rather than one after another — awaiting each in turn
+ * would trade blocking for wall-clock. Concurrency is bounded by the directory count, which is small
+ * next to the file count. The cycle guard stays correct under that: the check and the `add` sit in
+ * the same synchronous step after `realpath` resolves, so no other branch can interleave between
+ * them.
+ */
+const walk = async (root, denylist) => {
const out = [];
const visited = new Set();
- const visit = (absDir, relDir) => {
+
+ const visit = async (absDir, relDir) => {
let canonicalDir;
try {
- canonicalDir = fs.realpathSync(absDir);
+ canonicalDir = await fs.promises.realpath(absDir);
} catch (err) {
return;
}
if (visited.has(canonicalDir)) return;
visited.add(canonicalDir);
- const entries = fs.readdirSync(absDir, { withFileTypes: true });
+ let entries;
+ try {
+ entries = await fs.promises.readdir(absDir, { withFileTypes: true });
+ } catch (err) {
+ return;
+ }
+
+ const subdirectories = [];
for (const entry of entries) {
const childAbs = path.join(absDir, entry.name);
const childRel = relDir ? path.join(relDir, entry.name) : entry.name;
@@ -45,7 +66,7 @@ const walk = (root, denylist) => {
if (entry.isSymbolicLink()) {
try {
- const stat = fs.statSync(childAbs);
+ const stat = await fs.promises.stat(childAbs);
isDir = stat.isDirectory();
isFile = stat.isFile();
} catch (err) {
@@ -55,14 +76,17 @@ const walk = (root, denylist) => {
if (isDir) {
if (DENY_DIRS.has(entry.name)) continue;
- visit(childAbs, childRel);
+ subdirectories.push([childAbs, childRel]);
} else if (isFile) {
if (isDenied(posixifyPath(childRel), denylist)) continue;
out.push({ relativePath: childRel, absolutePath: childAbs });
}
}
+
+ await Promise.all(subdirectories.map(([childAbs, childRel]) => visit(childAbs, childRel)));
};
- visit(root, '');
+
+ await visit(root, '');
return out;
};
@@ -98,7 +122,51 @@ const defaultClassify = (relativePath) => {
return { format, type: 'request' };
};
+const diffFiles = async (root, stored, denylist) => {
+ const added = [];
+ const updated = [];
+ const removed = [];
+ const seen = new Set();
+
+ const files = await walk(root, denylist);
+ const results = await Promise.all(files.map(async ({ relativePath, absolutePath }) => {
+ const stat = await fs.promises.stat(absolutePath, { bigint: true });
+ const mtime = stat.mtimeNs;
+ const prior = stored.get(relativePath);
+
+ if (!prior) {
+ const hash = await hashFileAsync(absolutePath);
+ return { kind: 'added', entry: { relativePath, absolutePath, mtime, hash } };
+ }
+ if (prior.mtime === mtime) return { kind: 'unchanged', relativePath };
+ const hash = await hashFileAsync(absolutePath);
+ if (hash === prior.hash) return { kind: 'unchanged', relativePath };
+ return { kind: 'updated', entry: { relativePath, absolutePath, mtime, hash, prevHash: prior.hash } };
+ }));
+
+ for (const r of results) {
+ if (r.kind === 'added') {
+ added.push(r.entry);
+ seen.add(r.entry.relativePath);
+ } else if (r.kind === 'updated') {
+ updated.push(r.entry);
+ seen.add(r.entry.relativePath);
+ } else {
+ seen.add(r.relativePath);
+ }
+ }
+
+ for (const [relativePath, row] of stored) {
+ if (seen.has(relativePath)) continue;
+ if (isDenied(posixifyPath(relativePath), denylist)) continue;
+ removed.push({ relativePath, id: row.id, hash: row.hash });
+ }
+
+ return { added, updated, removed };
+};
+
module.exports = {
+ DENY_DIRS,
COLLECTION_ROOT_BASENAMES,
FOLDER_ROOT_BASENAMES,
BRUNO_CONFIG_BASENAME,
@@ -112,5 +180,6 @@ module.exports = {
resolveDenylist,
isDenied,
walk,
+ diffFiles,
defaultClassify
};