diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/StyledWrapper.js index 761cedf9236..8f51999a9c2 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/StyledWrapper.js @@ -43,6 +43,15 @@ const StyledWrapper = styled.div` } } + &.load-time { + background-color: ${(props) => rgba(props.theme.colors.text.green, 0.08)}; + border: 1px solid ${(props) => rgba(props.theme.colors.text.green, 0.09)}; + + svg { + color: ${(props) => props.theme.colors.text.green}; + } + } + &.share { background-color: ${(props) => rgba(props.theme.textLink, 0.08)}; border: 1px solid ${(props) => rgba(props.theme.textLink, 0.09)}; diff --git a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js index 8d4157de025..08de61f619a 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Overview/Info/index.js @@ -1,20 +1,23 @@ -import React from 'react'; -import { getTotalRequestCountInCollection } from 'utils/collections/'; -import { IconFolder, IconWorld, IconApi, IconShare, IconBook, IconTag } from '@tabler/icons'; -import { areItemsLoading, getItemsLoadStats, getCollectionVersion } from 'utils/collections/index'; -import { useRef, useState } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { IconApi, IconBook, IconClock, IconFolder, IconShare, IconTag, IconWorld } from '@tabler/icons'; import ShareCollection from 'components/ShareCollection/index'; -import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation'; import ChangeCollectionVersion from 'components/Sidebar/Collections/Collection/ChangeCollectionVersion'; +import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation'; import ToolHint from 'components/ToolHint'; import { addTab } from 'providers/ReduxStore/slices/tabs'; -import StyledWrapper from './StyledWrapper'; +import React, { useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getTotalRequestCountInCollection } from 'utils/collections/'; +import { areItemsLoading, getCollectionVersion, getItemsLoadStats } from 'utils/collections/index'; import Migration from '../Migration'; +import StyledWrapper from './StyledWrapper'; + +// Sub-second timings still read as seconds so the phases stay directly comparable. +const formatSeconds = (ms) => (typeof ms === 'number' ? `${(ms / 1000).toFixed(2)}s` : '—'); const Info = ({ collection }) => { const dispatch = useDispatch(); const totalRequestsInCollection = getTotalRequestCountInCollection(collection); + const loadStats = collection.loadStats; const isCollectionLoading = areItemsLoading(collection); const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection); @@ -149,6 +152,27 @@ const Info = ({ collection }) => { + {loadStats ? ( +
+
+ +
+
+
Load time
+
+ {formatSeconds(loadStats.mountMs ?? loadStats.scanMs)} + {loadStats.fileCount !== undefined ? ` · ${loadStats.fileCount} files parsed` : ''} +
+ {loadStats.scanMs !== undefined ? ( +
+ {`scan ${formatSeconds(loadStats.scanMs)} — walk ${formatSeconds(loadStats.walkMs)}, `} + {`parse ${formatSeconds(loadStats.parseMs)}, tree ${formatSeconds(loadStats.buildMs)}`} +
+ ) : null} +
+
+ ) : null} +
diff --git a/packages/bruno-app/src/components/FileEditor/index.js b/packages/bruno-app/src/components/FileEditor/index.js index 7544a1a0830..684a53f8c62 100644 --- a/packages/bruno-app/src/components/FileEditor/index.js +++ b/packages/bruno-app/src/components/FileEditor/index.js @@ -1,8 +1,9 @@ +import { useEffect } from 'react'; import get from 'lodash/get'; import { useTheme } from 'providers/Theme'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from './CodeEditor/index'; -import { saveFile } from 'providers/ReduxStore/slices/collections/actions'; +import { saveFile, fetchItemRaw } from 'providers/ReduxStore/slices/collections/actions'; import { IconDeviceFloppy } from '@tabler/icons'; import { toggleCollectionFileMode, updateFileContent } from 'providers/ReduxStore/slices/collections'; import { usePersistedState } from 'hooks/usePersistedState'; @@ -13,6 +14,13 @@ const FileEditor = ({ item, collection }) => { const preferences = useSelector((state) => state.app.preferences); const [scroll, setScroll] = usePersistedState({ key: `file-mode-scroll-${item.uid}`, default: 0 }); + // `raw` isn't carried by the mount tree — it's fetched here, for the one item File Mode is + // actually showing, rather than kept resident for every item in the collection. + useEffect(() => { + if (item.draft || item.raw != null) return; + dispatch(fetchItemRaw({ collectionUid: collection.uid, itemUid: item.uid, pathname: item.pathname })); + }, [dispatch, collection.uid, item.uid, item.pathname, item.draft, item.raw]); + const content = item.draft ? item.draft.raw : item.raw || ''; const onEdit = (value) => { diff --git a/packages/bruno-app/src/components/GlobalSearchModal/StyledWrapper.js b/packages/bruno-app/src/components/GlobalSearchModal/StyledWrapper.js index d1a61e80c17..76655b29049 100644 --- a/packages/bruno-app/src/components/GlobalSearchModal/StyledWrapper.js +++ b/packages/bruno-app/src/components/GlobalSearchModal/StyledWrapper.js @@ -110,11 +110,11 @@ const StyledWrapper = styled.div` } .command-k-results { flex: 1; - overflow-y: auto; - max-height: 400px; + /* Virtuoso owns the scrolling now, and sizes itself from the result count, so this must not + introduce a second scroll container or an independent height cap. */ + overflow: hidden; scrollbar-width: thin; padding: 6px 0; - scroll-behavior: smooth; /* Webkit scrollbar styling */ &::-webkit-scrollbar { width: 8px; @@ -134,8 +134,12 @@ const StyledWrapper = styled.div` .result-item { display: flex; align-items: center; - padding: 10px 12px; - margin: 2px 8px; + /* Fixed height, and no vertical margin for it to collapse against: the list is virtualised and + this must match RESULT_ROW_HEIGHT, which is passed to Virtuoso as fixedItemHeight. */ + height: 52px; + box-sizing: border-box; + padding: 0 12px; + margin: 0 8px; gap: 10px; cursor: pointer; border-radius: ${(props) => props.theme.border.radius.base}; diff --git a/packages/bruno-app/src/components/GlobalSearchModal/constants/index.js b/packages/bruno-app/src/components/GlobalSearchModal/constants/index.js index f7e174c5232..6a707af34bc 100644 --- a/packages/bruno-app/src/components/GlobalSearchModal/constants/index.js +++ b/packages/bruno-app/src/components/GlobalSearchModal/constants/index.js @@ -15,11 +15,9 @@ export const MATCH_TYPES = { }; export const SEARCH_CONFIG = { - MAX_DEPTH: 20, FOCUS_DELAY: 100, SCROLL_BEHAVIOR: 'smooth', - SCROLL_BLOCK: 'nearest', - DEBOUNCE_DELAY: 300 + DEBOUNCE_DELAY: 350 }; export const DOCUMENTATION_RESULT = { diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.js b/packages/bruno-app/src/components/GlobalSearchModal/index.js index ff891cacf01..995c6680c49 100644 --- a/packages/bruno-app/src/components/GlobalSearchModal/index.js +++ b/packages/bruno-app/src/components/GlobalSearchModal/index.js @@ -1,4 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; import { useSelector, useDispatch } from 'react-redux'; import { IconSearch, @@ -8,22 +9,32 @@ import { IconFileText, IconBook } from '@tabler/icons'; -import { flattenItems, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections'; import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections'; import { mountCollection } from 'providers/ReduxStore/slices/collections/actions'; -import { getDefaultRequestPaneTab } from 'utils/collections'; +import { getDefaultRequestPaneTab, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections'; import { normalizePath } from 'utils/common/path'; -import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils'; +import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, flattenItemsWithPaths } from './utils/searchUtils'; import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants'; +import IndeterminateProgressBar from 'ui/IndeterminateProgressBar'; import StyledWrapper from './StyledWrapper'; +// Fixed row height (px). MUST stay in sync with `.result-item` in StyledWrapper.js, since it is +// passed to Virtuoso as `fixedItemHeight`. +const RESULT_ROW_HEIGHT = 52; + +// The list scrolls beyond this; it caps how tall the modal grows, not how many results exist. +const MAX_VISIBLE_RESULTS = 8; + const GlobalSearchModal = ({ isOpen, onClose }) => { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const [results, setResults] = useState([]); + // True from the keystroke until its results land, so the progress bar covers the debounce window + // and the search itself rather than leaving stale results looking current. + const [isSearching, setIsSearching] = useState(false); const inputRef = useRef(null); - const resultsRef = useRef(null); + const virtuosoRef = useRef(null); const debounceTimeoutRef = useRef(null); const dispatch = useDispatch(); @@ -78,11 +89,10 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { }); } - // Search collection items - const flattenedItems = flattenItems(collection.items); - flattenedItems.forEach((item) => { - const itemPath = getItemPath(item, collection, findParentItemInCollection); - const itemPathLower = itemPath.toLowerCase(); + // Paths for the whole collection in one walk. Deriving each item's path on its own meant + // re-flattening the collection once per ancestor, for every item, on every keystroke. + flattenItemsWithPaths(collection).forEach(({ item, path: itemPath }) => { + const itemPathLower = enablePathMatch ? itemPath.toLowerCase() : ''; if (isItemARequest(item)) { // add an optional check for the item name to prevent a crash if it doesn’t exist. @@ -133,7 +143,44 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { return results; }; - const performSearch = (searchQuery) => { + const searchUnmountedCollections = async (searchTerms) => { + const unmounted = collections.filter((c) => c.mountStatus !== 'mounted'); + if (!unmounted.length) return []; + + try { + const { ipcRenderer } = window; + const rows = await ipcRenderer.invoke('renderer:search-index-query', { + collections: unmounted.map((c) => ({ + uid: c.uid, + pathname: c.pathname, + name: c.name, + ignore: c.brunoConfig?.ignore + })), + terms: searchTerms, + limit: 50 + }); + + return rows.map((row) => ({ + type: SEARCH_TYPES.REQUEST, + item: { + uid: row.uid, + type: 'http-request', + pathname: row.pathname, + name: row.name, + request: { method: row.method, url: row.url } + }, + name: row.name, + path: [row.collectionName, row.folderPath, row.name].filter(Boolean).join('/'), + matchType: MATCH_TYPES.REQUEST, + method: row.method || '', + collectionUid: row.collectionUid + })); + } catch (err) { + return []; + } + }; + + const performSearch = async (searchQuery) => { const normalizedQuery = normalizeQuery(searchQuery); if (!normalizedQuery) { @@ -154,21 +201,25 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { const enablePathMatch = normalizedQuery.includes('/'); const searchResults = searchInCollections(searchTerms, enablePathMatch); - const sortedResults = sortResults(searchResults); - setResults(sortedResults); + setResults(sortResults(searchResults)); setSelectedIndex(0); + + const indexResults = await searchUnmountedCollections(searchTerms); + if (indexResults.length) { + setResults((prev) => sortResults([...prev, ...indexResults])); + } }; const debouncedSearch = useCallback((searchQuery) => { - // Clear existing timeout if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); } - // Set new timeout - debounceTimeoutRef.current = setTimeout(() => { - performSearch(searchQuery); + setIsSearching(true); + debounceTimeoutRef.current = setTimeout(async () => { + await performSearch(searchQuery); + setIsSearching(false); }, SEARCH_CONFIG.DEBOUNCE_DELAY); }, [collections]); // Depend on collections to recreate when they change @@ -195,8 +246,8 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { }; const ensureCollectionIsMounted = (collection) => { - if (!collection || collection.mountStatus === 'mounted') return; - dispatch(mountCollection({ + if (!collection || collection.mountStatus === 'mounted') return Promise.resolve(); + return dispatch(mountCollection({ collectionUid: collection.uid, collectionPathname: collection.pathname, brunoConfig: collection.brunoConfig @@ -247,7 +298,6 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { const handleResultSelection = (result) => { const targetCollection = collections.find((c) => c.uid === result.collectionUid); - ensureCollectionIsMounted(targetCollection); if (result.type === SEARCH_TYPES.DOCUMENTATION) { window.open('https://docs.usebruno.com/', '_blank'); @@ -261,17 +311,23 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { const existingTab = tabs.find((tab) => tab.uid === result.item.uid); if (existingTab) { + ensureCollectionIsMounted(targetCollection); dispatch(focusTab({ uid: result.item.uid })); } else { - dispatch(addTab({ - uid: result.item.uid, - collectionUid: result.collectionUid, - requestPaneTab: getDefaultRequestPaneTab(result.item), - type: result.item.type, - pathname: result.item.pathname - })); + // The item may only exist in the search index, not yet in the store — wait for the + // collection to be fully mounted before opening a tab for it. + Promise.resolve(ensureCollectionIsMounted(targetCollection)).then(() => { + dispatch(addTab({ + uid: result.item.uid, + collectionUid: result.collectionUid, + requestPaneTab: getDefaultRequestPaneTab(result.item), + type: result.item.type, + pathname: result.item.pathname + })); + }); } } else if (result.type === SEARCH_TYPES.FOLDER) { + ensureCollectionIsMounted(targetCollection); dispatch(addTab({ uid: result.item.uid, collectionUid: result.collectionUid, @@ -279,6 +335,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { pathname: result.item.pathname })); } else if (result.type === SEARCH_TYPES.COLLECTION) { + ensureCollectionIsMounted(targetCollection); dispatch(addTab({ uid: result.item.uid, collectionUid: result.collectionUid, @@ -328,14 +385,11 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { } }, [isOpen]); - // Auto-scroll selected item into view + // Keyboard navigation asks the list to scroll, rather than reaching for a DOM node: the selected + // row may not be rendered at all while the list is virtualised. useEffect(() => { - if (resultsRef.current && results.length > 0) { - const selectedElement = resultsRef.current.children[selectedIndex]; - selectedElement?.scrollIntoView({ - behavior: SEARCH_CONFIG.SCROLL_BEHAVIOR, - block: SEARCH_CONFIG.SCROLL_BLOCK - }); + if (results.length > 0) { + virtuosoRef.current?.scrollIntoView({ index: selectedIndex, behavior: SEARCH_CONFIG.SCROLL_BEHAVIOR }); } }, [selectedIndex, results]); @@ -419,9 +473,10 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
+ +
{

) : ( - results.map((result, index) => { - const isSelected = index === selectedIndex; - const typeLabel = getTypeLabel(result.type); - - return ( -
handleResultSelection(result)} - data-selected={isSelected} - data-type={result.type} - role="option" - aria-selected={isSelected} - aria-label={`${result.name}, ${typeLabel || result.type}${result.method ? `, ${result.method}` : ''}`} - tabIndex={-1} - > -
- {getResultIcon(result.type)} -
-
-
-
- {highlightText(result.name, query)} -
-
- {result.type === SEARCH_TYPES.DOCUMENTATION - ? result.description - : result.type === SEARCH_TYPES.REQUEST - ? highlightText(result.item.request?.url || '', query) - : highlightText(result.path, query)} -
+ `${result.type}-${result.item.id || result.item.uid}-${index}`} + itemContent={(index, result) => { + const isSelected = index === selectedIndex; + const typeLabel = getTypeLabel(result.type); + + return ( +
handleResultSelection(result)} + data-selected={isSelected} + data-type={result.type} + role="option" + aria-selected={isSelected} + aria-label={`${result.name}, ${typeLabel || result.type}${result.method ? `, ${result.method}` : ''}`} + tabIndex={-1} + > +
+ {getResultIcon(result.type)}
-
- {result.type === SEARCH_TYPES.REQUEST && result.method && ( - - {result.method.toUpperCase().replace(/-/g, ' ')} - - )} - {typeLabel && ( -
- {typeLabel} +
+
+
+ {highlightText(result.name, query)} +
+
+ {result.type === SEARCH_TYPES.DOCUMENTATION + ? result.description + : result.type === SEARCH_TYPES.REQUEST + ? highlightText(result.item.request?.url || '', query) + : highlightText(result.path, query)}
- )} +
+
+ {result.type === SEARCH_TYPES.REQUEST && result.method && ( + + {result.method.toUpperCase().replace(/-/g, ' ')} + + )} + {typeLabel && ( +
+ {typeLabel} +
+ )} +
-
- ); - }) + ); + }} + /> )}
diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.spec.js b/packages/bruno-app/src/components/GlobalSearchModal/index.spec.js new file mode 100644 index 00000000000..3a5ed286f51 --- /dev/null +++ b/packages/bruno-app/src/components/GlobalSearchModal/index.spec.js @@ -0,0 +1,148 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore, createSlice } from '@reduxjs/toolkit'; +import { ThemeProvider } from 'styled-components'; +import themes from 'themes/index'; +import GlobalSearchModal from './index'; + +jest.mock('react-virtuoso', () => { + const mockReact = require('react'); + return { + Virtuoso: ({ data, itemContent }) => mockReact.createElement( + 'div', + null, + data.map((item, index) => mockReact.createElement(mockReact.Fragment, { key: index }, itemContent(index, item))) + ) + }; +}); + +const mockInvoke = jest.fn(); +window.ipcRenderer = { invoke: (...args) => mockInvoke(...args) }; + +const mountedCollection = { + uid: 'col-mounted', + name: 'Mounted Collection', + pathname: '/mounted', + mountStatus: 'mounted', + collapsed: true, + items: [{ + uid: 'req-mounted', + type: 'http-request', + name: 'Get Mounted Users', + pathname: '/mounted/get.bru', + request: { method: 'GET', url: 'https://x.test' } + }] +}; + +const unmountedCollection = { + uid: 'col-unmounted', + name: 'Unmounted Collection', + pathname: '/unmounted', + mountStatus: 'unmounted', + collapsed: true, + items: [] +}; + +const makeStore = (collections) => { + const slice = createSlice({ name: 'root', initialState: {}, reducers: {} }); + return configureStore({ + reducer: { + collections: () => ({ collections }), + workspaces: () => ({ workspaces: [], activeWorkspaceUid: null }), + tabs: () => ({ tabs: [] }), + app: slice.reducer + } + }); +}; + +const renderModal = (collections) => render( + + + + + +); + +beforeEach(() => { + jest.useFakeTimers(); + mockInvoke.mockReset(); + mockInvoke.mockResolvedValue([]); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +const type = async (value) => { + const input = screen.getByTestId('global-search-input'); + fireEvent.change(input, { target: { value } }); + await act(async () => { + jest.advanceTimersByTime(400); + }); +}; + +const findResultByName = (name) => screen.findByText( + (_, element) => element?.className === 'result-name' && element.textContent === name +); + +describe('GlobalSearchModal', () => { + it('shows results from a mounted collection without touching the index', async () => { + renderModal([mountedCollection]); + + await type('users'); + + expect(await findResultByName('Get Mounted Users')).toBeInTheDocument(); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('queries the search index for collections that are not mounted', async () => { + mockInvoke.mockResolvedValue([{ + uid: 'req-unmounted', + name: 'Get Unmounted Orders', + method: 'GET', + url: 'https://x.test/orders', + pathname: '/unmounted/get.bru', + folderPath: '', + collectionUid: 'col-unmounted', + collectionName: 'Unmounted Collection' + }]); + renderModal([unmountedCollection]); + + await type('orders'); + + expect(mockInvoke).toHaveBeenCalledWith('renderer:search-index-query', expect.objectContaining({ + collections: [expect.objectContaining({ uid: 'col-unmounted', pathname: '/unmounted' })], + terms: ['orders'] + })); + expect(await findResultByName('Get Unmounted Orders')).toBeInTheDocument(); + }); + + it('merges mounted and index results together', async () => { + mockInvoke.mockResolvedValue([{ + uid: 'req-unmounted', + name: 'Get More Users', + method: 'GET', + url: 'https://x.test/more-users', + pathname: '/unmounted/get.bru', + folderPath: '', + collectionUid: 'col-unmounted', + collectionName: 'Unmounted Collection' + }]); + renderModal([mountedCollection, unmountedCollection]); + + await type('users'); + + expect(await findResultByName('Get Mounted Users')).toBeInTheDocument(); + expect(await findResultByName('Get More Users')).toBeInTheDocument(); + }); + + it('does not crash the search when the index query rejects', async () => { + mockInvoke.mockRejectedValue(new Error('main process unavailable')); + renderModal([mountedCollection, unmountedCollection]); + + await type('users'); + + expect(await findResultByName('Get Mounted Users')).toBeInTheDocument(); + }); +}); diff --git a/packages/bruno-app/src/components/GlobalSearchModal/utils/searchUtils.js b/packages/bruno-app/src/components/GlobalSearchModal/utils/searchUtils.js index 9d30e997903..0f8acf157e8 100644 --- a/packages/bruno-app/src/components/GlobalSearchModal/utils/searchUtils.js +++ b/packages/bruno-app/src/components/GlobalSearchModal/utils/searchUtils.js @@ -1,5 +1,5 @@ import React from 'react'; -import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG } from '../constants'; +import { SEARCH_TYPES, MATCH_TYPES } from '../constants'; export const normalizeQuery = (searchQuery) => { return searchQuery.trim().replace(/\/+/g, '/'); @@ -72,23 +72,28 @@ export const getTypeLabel = (type) => { return baseLabels[type] || ''; }; -export const getItemPath = (item, collection, findParentItemInCollection) => { - const pathParts = []; - let currentItem = item; - let depth = 0; - const maxDepth = SEARCH_CONFIG.MAX_DEPTH; +/** + * Every item in a collection paired with its display path, built in one walk. + * + * Replaces a per-item `getItemPath` that climbed to the root calling + * `findParentItemInCollection` at each level — and that helper flattens the whole collection on + * every call. Computing it for each item made searching a collection quadratic in its size + * (items x depth x items), which a workspace-wide search multiplied by the number of collections. + * Here each item is visited once and its path is its parent's path plus its own name. + * + * @returns {Array<{ item: Object, path: string }>} in the tree's own order + */ +export const flattenItemsWithPaths = (collection) => { + const entries = []; - while (currentItem && depth < maxDepth) { - pathParts.unshift(currentItem.name); - const parent = findParentItemInCollection(collection, currentItem.uid); - if (parent) { - currentItem = parent; - depth++; - } else { - break; + const visit = (items = [], parentPath) => { + for (const item of items) { + const path = `${parentPath}/${item.name}`; + entries.push({ item, path }); + if (item.items?.length) visit(item.items, path); } - } + }; - pathParts.unshift(collection.name); - return pathParts.join('/'); + visit(collection.items, collection.name); + return entries; }; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index fdf8e2d6e26..72f7ddae1f9 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -24,7 +24,6 @@ import FileEditor from 'components/FileEditor'; import StyledWrapper from './StyledWrapper'; import FolderSettings from 'components/FolderSettings'; import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index'; -import { produce } from 'immer'; import CollectionOverview from 'components/CollectionSettings/Overview'; import RequestNotLoaded from './RequestNotLoaded'; import RequestIsLoading from './RequestIsLoading'; @@ -102,29 +101,28 @@ const RequestTabPanel = () => { isVerticalLayoutRef.current = isVerticalLayout; }, [isVerticalLayout]); - // merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object - const collections = produce(_collections, (draft) => { - const collection = find(draft, (c) => c.uid === focusedTab?.collectionUid); - - if (collection) { - // add selected global env variables to the collection object - const globalEnvironmentVariables = getGlobalEnvironmentVariables({ - globalEnvironments, - activeGlobalEnvironmentUid - }); - const globalEnvSecrets = getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid }); - collection.globalEnvironmentVariables = globalEnvironmentVariables; - collection.globalEnvSecrets = globalEnvSecrets; - collection.globalEnvironments = globalEnvironments; - collection.activeGlobalEnvironmentUid = activeGlobalEnvironmentUid; - } - }); - - const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid); + const globalEnvFields = useMemo(() => ({ + globalEnvironmentVariables: getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }), + globalEnvSecrets: getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid }), + globalEnvironments, + activeGlobalEnvironmentUid + }), [globalEnvironments, activeGlobalEnvironmentUid]); + + // The panes read the selected global environment off the collection object. Only the focused + // collection needs it, so it is merged onto that one — grafting it across the whole array costs + // a full Immer pass per render, and this component re-renders on every collection change. + const collection = useMemo(() => { + const focusedCollection = find(_collections, (c) => c.uid === focusedTab?.collectionUid); + return focusedCollection ? { ...focusedCollection, ...globalEnvFields } : focusedCollection; + }, [_collections, focusedTab?.collectionUid, globalEnvFields]); + + // Preserves the previous behaviour, where only the focused collection carried the global env. + const collectionByUid = (uid) => + (uid === focusedTab?.collectionUid ? collection : find(_collections, (c) => c.uid === uid)); const isItemsLoading = useMemo(() => { return collection?.mountStatus === 'mounting' || areItemsLoading(collection); - }, [collection?.mountStatus, collection]); + }, [collection]); const [dragging, setDragging] = useState(false); const draggingRef = useRef(false); @@ -459,8 +457,8 @@ const RequestTabPanel = () => { } const instanceCollection = instance.sourceType === 'collection' - ? find(collections, (c) => c.uid === instance.collectionUid) - : (focusedTab.collectionUid ? find(collections, (c) => c.uid === focusedTab.collectionUid) : null); + ? collectionByUid(instance.collectionUid) + : (focusedTab.collectionUid ? collectionByUid(focusedTab.collectionUid) : null); return ; } @@ -476,8 +474,8 @@ const RequestTabPanel = () => { } const instanceCollection = instance.sourceType === 'collection' - ? find(collections, (c) => c.uid === instance.collectionUid) - : (focusedTab.collectionUid ? find(collections, (c) => c.uid === focusedTab.collectionUid) : null); + ? collectionByUid(instance.collectionUid) + : (focusedTab.collectionUid ? collectionByUid(focusedTab.collectionUid) : null); return ( { + const dispatch = useDispatch(); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const isCollectionLoading = areItemsLoading(collection); const [selectedFormat, setSelectedFormat] = useState(EXPORT_FORMATS.ZIP); @@ -58,8 +60,8 @@ const ShareCollection = ({ onClose, collectionUid }) => { } }; - const handleExportYaml = () => { - const collectionCopy = cloneDeep(collection); + const handleExportYaml = async () => { + const collectionCopy = await dispatch(resolveJsItemsRaw(cloneDeep(collection))); exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); }; @@ -83,7 +85,7 @@ const ShareCollection = ({ onClose, collectionUid }) => { await handleExportZip(); break; case EXPORT_FORMATS.YAML: - handleExportYaml(); + await handleExportYaml(); break; } onClose(); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index 11b76a9244e..d25fefbd31b 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import range from 'lodash/range'; import classnames from 'classnames'; import { useDrag, useDrop } from 'react-dnd'; @@ -51,7 +51,6 @@ import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; -import { isEqual } from 'lodash'; import { canCollectionItemBeDropped, determineCollectionItemDrop, @@ -84,20 +83,25 @@ const CollectionItemRow = ({ multiDragItems: multiDragItemsForSelection }) => { const { dropdownContainerRef } = useSidebarAccordion(); - const selectorInput = { - itemUid: item.uid, - itemPathname: item.pathname, - collectionUid - }; - - const _isTabForItemActiveSelector = isTabForItemActiveSelector(selectorInput); - const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual); - const _isTabForItemPresentSelector = isTabForItemPresentSelector(selectorInput); - const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual); + // Each of these builds a createSelector, and createSelector's memo lives on the instance it + // returns. Built inline they would be new instances on every render, so the memo could never + // hold — every row would rescan the tab list on every render, and react-redux would tear down + // and re-create three store subscriptions per row along with it. + const { activeSelector, presentSelector, tabUidSelector } = useMemo(() => { + const selectorInput = { itemUid: item.uid, itemPathname: item.pathname, collectionUid }; + return { + activeSelector: isTabForItemActiveSelector(selectorInput), + presentSelector: isTabForItemPresentSelector(selectorInput), + tabUidSelector: getTabUidForItemSelector(selectorInput) + }; + }, [item.uid, item.pathname, collectionUid]); - const _tabUidForItemSelector = getTabUidForItemSelector(selectorInput); - const tabUidForItem = useSelector(_tabUidForItemSelector, isEqual); + // All three resolve to a boolean or a uid, so reference equality is both correct and cheaper + // than a deep compare. + const isTabForItemActive = useSelector(activeSelector); + const isTabForItemPresent = useSelector(presentSelector); + const tabUidForItem = useSelector(tabUidSelector); const isSidebarDragging = useSelector((state) => state.app.isDragging); const collection = useSelector((state) => state.collections.collections.find((c) => c.uid === collectionUid)); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx index ff476462703..f8e47b2f245 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import classnames from 'classnames'; import { uuid } from 'utils/common'; import { useDrop, useDrag } from 'react-dnd'; @@ -6,7 +6,6 @@ import { getEmptyImage } from 'react-dnd-html5-backend'; import { IconChevronRight, IconDots, - IconLoader2, IconFilePlus, IconFolderPlus, IconCopy, @@ -75,10 +74,13 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children, isColle const [dropType, setDropType] = useState(null); const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const dispatch = useDispatch(); - const isLoading = collection.isLoading; const collectionRef = useRef(null); - const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid })); + // Held across renders: `isTabForItemActive` builds a createSelector, whose memo is bound to the + // instance. Building a new one each render means it recomputes every time and react-redux tears + // down and re-creates the store subscription with it. + const isCollectionFocusedSelector = useMemo(() => isTabForItemActive({ itemUid: collection.uid }), [collection.uid]); + const isCollectionFocused = useSelector(isCollectionFocusedSelector); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); const isSelected = selectedSidebarUids.includes(collection.uid); @@ -593,7 +595,6 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children, isColle - {isLoading ? : null}
{!isDragging && !isMultiSelected && (
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 };