From adb20e108a34a9735ecd339ae66fbe8a2692bc16 Mon Sep 17 00:00:00 2001 From: mr-weed Date: Sun, 20 Sep 2026 16:27:59 +0530 Subject: [PATCH 1/4] feat: add deferred-parsing collection mount pipeline with additive SQLite search index --- .../Overview/Info/StyledWrapper.js | 9 + .../CollectionSettings/Overview/Info/index.js | 40 ++- .../GlobalSearchModal/StyledWrapper.js | 14 +- .../GlobalSearchModal/constants/index.js | 4 +- .../src/components/GlobalSearchModal/index.js | 201 +++++++++------ .../GlobalSearchModal/utils/searchUtils.js | 39 +-- .../RequestTabPanel/DeferredRequest/index.js | 25 ++ .../src/components/RequestTabPanel/index.js | 58 +++-- .../src/components/ShareCollection/index.js | 11 +- .../Collection/CollectionItem/index.js | 91 ++++--- .../ExportCollection/ExportToPostman/index.js | 5 +- .../Collection/GenerateDocumentation/index.js | 11 +- .../Sidebar/Collections/Collection/index.js | 47 ++-- .../components/Sidebar/Collections/index.js | 91 ++++++- .../src/hooks/useVisibleSidebarItems/index.js | 31 +++ .../ReduxStore/slices/collections/actions.js | 234 ++++++++++++++++-- .../ReduxStore/slices/collections/index.js | 156 +++++++++++- .../ReduxStore/slices/workspaces/actions.js | 10 +- .../IndeterminateProgressBar/StyledWrapper.js | 45 ++++ .../src/ui/IndeterminateProgressBar/index.js | 21 ++ packages/bruno-app/src/ui/index.js | 1 + .../bruno-app/src/utils/collections/index.js | 89 ++++--- .../bruno-app/src/utils/collections/search.js | 72 ++++-- .../bruno-electron/electron-builder-config.js | 48 ++-- .../src/app/collection-watcher.js | 58 ++--- packages/bruno-electron/src/index.js | 5 +- packages/bruno-electron/src/ipc/collection.js | 138 ++++++----- .../bruno-electron/src/ipc/network/index.js | 12 +- .../bruno-electron/src/ipc/search-index.js | 59 +++++ .../src/services/mount/file-index.js | 45 +--- .../bruno-electron/src/services/mount/scan.js | 126 ++++++++++ .../src/services/mount/tree-builder.js | 42 +++- .../bruno-electron/src/services/pool/index.js | 95 ++++++- .../src/services/pool/jobs/parse-file.js | 19 +- .../src/services/search-index/index.js | 128 ++++++++++ .../src/services/search-index/indexer.js | 60 +++++ .../src/services/search-index/watcher.js | 75 ++++++ .../bruno-electron/src/utils/collection.js | 95 +++---- .../bruno-electron/src/utils/filesystem.js | 12 +- packages/bruno-electron/src/utils/mount.js | 83 ++++++- .../bruno-filestore/src/formats/bru/index.ts | 4 + packages/bruno-filestore/src/index.ts | 16 ++ .../bruno-filestore/src/types/bruno-lang.d.ts | 1 + packages/bruno-lang/src/index.js | 2 + packages/bruno-lang/v2/src/bruToJson.js | 23 +- packages/bruno-lang/v2/src/bruToTreeFields.js | 184 ++++++++++++++ .../bruno-lang/v2/src/collectionBruToJson.js | 23 +- packages/bruno-lang/v2/src/envToJson.js | 23 +- .../bruno-lang/v2/src/example/bruToJson.js | 26 +- .../v2/src/example/request/bruToJson.js | 26 +- .../v2/src/example/response/bruToJson.js | 26 +- 51 files changed, 2190 insertions(+), 569 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestTabPanel/DeferredRequest/index.js create mode 100644 packages/bruno-app/src/hooks/useVisibleSidebarItems/index.js create mode 100644 packages/bruno-app/src/ui/IndeterminateProgressBar/StyledWrapper.js create mode 100644 packages/bruno-app/src/ui/IndeterminateProgressBar/index.js create mode 100644 packages/bruno-electron/src/ipc/search-index.js create mode 100644 packages/bruno-electron/src/services/mount/scan.js create mode 100644 packages/bruno-electron/src/services/search-index/index.js create mode 100644 packages/bruno-electron/src/services/search-index/indexer.js create mode 100644 packages/bruno-electron/src/services/search-index/watcher.js create mode 100644 packages/bruno-lang/v2/src/bruToTreeFields.js 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/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..a8d62f1d78c 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,45 @@ 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 }, + deferred: true + }, + 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 +202,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 @@ -328,14 +380,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 +468,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/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/DeferredRequest/index.js b/packages/bruno-app/src/components/RequestTabPanel/DeferredRequest/index.js new file mode 100644 index 00000000000..d7af9331fa8 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/DeferredRequest/index.js @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import { loadRequest } from 'providers/ReduxStore/slices/collections/actions'; +import RequestIsLoading from '../RequestIsLoading'; + +// Requests are mounted as deferred tree nodes carrying only what the sidebar and the searches +// read. Opening one parses it in full; the parsed item arrives over main:collection-tree-updated, +// which clears `deferred` and replaces this view. +// +// A parse failure is surfaced as data — the main process sends the item back with `error` and +// `partial` set — so the rejected invoke needs no separate handling here. +const DeferredRequest = ({ item, collection }) => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(loadRequest({ + collectionUid: collection?.uid, + pathname: item?.pathname + })).catch(() => {}); + }, [dispatch, collection?.uid, item?.pathname]); + + return ; +}; + +export default DeferredRequest; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index fdf8e2d6e26..33350ed07a9 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -24,9 +24,9 @@ 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 DeferredRequest from './DeferredRequest'; import RequestIsLoading from './RequestIsLoading'; import RequestTabPanelLoading from './RequestTabPanelLoading'; import FolderNotFound from './FolderNotFound'; @@ -102,29 +102,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 +458,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 +475,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 ( { item = findItemInCollectionByPathname(collection, focusedTab.pathname); } + // A deferred item carries example names only, not their request/response bodies. + if (item?.deferred) { + return ; + } + let example = null; if (item?.examples) { example = item.examples.find((ex) => ex.uid === focusedTab.uid); @@ -594,6 +598,10 @@ const RequestTabPanel = () => { : ; } + if (item.deferred) { + return ; + } + if (item.partial) { return ; } diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 1128eae37e1..79091699274 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -5,9 +5,9 @@ import { IconCheck, IconAlertTriangle, IconFileExport } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; import ExportToPostman from 'components/Sidebar/Collections/Collection/ExportCollection/ExportToPostman'; import exportOpenCollection from 'utils/exporters/opencollection'; -import { cloneDeep } from 'lodash'; import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index'; -import { useSelector } from 'react-redux'; +import { resolveDeferredCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { useDispatch, useSelector } from 'react-redux'; import { findCollectionByUid, areItemsLoading } from 'utils/collections/index'; import toast from 'react-hot-toast'; @@ -18,6 +18,7 @@ const EXPORT_FORMATS = { }; const ShareCollection = ({ onClose, collectionUid }) => { + 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 +59,8 @@ const ShareCollection = ({ onClose, collectionUid }) => { } }; - const handleExportYaml = () => { - const collectionCopy = cloneDeep(collection); + const handleExportYaml = async () => { + const collectionCopy = await dispatch(resolveDeferredCollection(collection.uid)); exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); }; @@ -83,7 +84,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/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 0d43356097e..6aa8f7176e8 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -1,6 +1,5 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import range from 'lodash/range'; -import filter from 'lodash/filter'; import classnames from 'classnames'; import { useDrag, useDrop } from 'react-dnd'; import { getEmptyImage } from 'react-dnd-html5-backend'; @@ -24,7 +23,7 @@ import { } from '@tabler/icons'; import { useSelector, useDispatch, useStore } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; -import { handleMultipleCollectionItemsDrop, sendRequest, showInFolder, pasteItem, saveRequest, cloneItem } from 'providers/ReduxStore/slices/collections/actions'; +import { handleMultipleCollectionItemsDrop, sendRequest, showInFolder, pasteItem, saveRequest, cloneItem, resolveDeferredItem } from 'providers/ReduxStore/slices/collections/actions'; import { sanitizeName } from 'utils/common/regex'; import { formatIpcError } from 'utils/common/error'; import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections'; @@ -40,8 +39,8 @@ import IgnoreCollectionItem from './IgnoreCollectionItem'; import RunCollectionItem from './RunCollectionItem'; import GenerateCodeItem from './GenerateCodeItem'; import { isItemARequest, isItemAFolder, scrollToTheActiveTab } from 'utils/tabs'; -import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; import { getDefaultRequestPaneTab, getItemTypeLabel } from 'utils/collections'; +import useVisibleSidebarItems from 'hooks/useVisibleSidebarItems'; import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; import NetworkError from 'components/ResponsePane/NetworkError/index'; @@ -54,7 +53,6 @@ import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; -import { isEqual } from 'lodash'; import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; import { canCollectionItemBeDropped, @@ -63,7 +61,6 @@ import { findParentItemInCollection, getSortedDraggedItems } from 'utils/collections/index'; -import { sortByNameThenSequence } from 'utils/common/index'; import { getRevealInFolderLabel } from 'utils/common/platform'; import CreateExampleModal from 'components/ResponseExample/CreateExampleModal'; import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; @@ -74,22 +71,36 @@ import useKeybinding from 'hooks/useKeybinding'; import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections/index'; -const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu, isMultiDragDisabled, multiDragItems: multiDragItemsForSelection }) => { +/** + * `flat` renders the row on its own, without its subtree: the virtualised search list already holds + * every descendant as a row of its own, and rendering them here as well would nest the whole tree + * inside one virtual row. Indentation comes from `item.depth` either way, so the row looks the same. + * + * Drag and drop is off in flat mode. Only a fraction of the rows exist while virtualised, so + * dropping onto something scrolled out of view has nothing to land on — and reordering a filtered + * tree, where an item's real neighbours are hidden, is not a meaningful gesture anyway. + */ +const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, searchIndex, flat = false, openBulkMenu, isMultiDragDisabled, 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)); @@ -106,7 +117,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o const dispatch = useDispatch(); const multiDragItems = isMultiSelected ? multiDragItemsForSelection : null; - const isDragDisabled = isMultiSelected && isMultiDragDisabled; + const isDragDisabled = flat || (isMultiSelected && isMultiDragDisabled); // We use a single ref for drag and drop. const ref = useRef(null); @@ -272,6 +283,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o setDropType(null); }, canDrop: (draggedItem, monitor) => { + if (flat) return false; if (draggedItem.uid === item.uid) return false; const dropType = resolveDropFromMonitor(monitor); @@ -567,27 +579,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o 'is-sidebar-dragging': isSidebarDragging }); - if (searchText && searchText.length) { - if (isItemARequest(item)) { - if (!doesRequestMatchSearchText(item, searchText)) { - return null; - } - } else { - if (!doesFolderHaveItemsMatchSearchText(item, searchText)) { - return null; - } - } - } + const { folderItems, appItems, requestItems } = useVisibleSidebarItems(item.items, { hasSearchText, searchIndex, skip: flat }); const handleDoubleClick = (event) => { dispatch(makeTabPermanent({ uid: tabUidForItem || item.uid })); }; - // Sort items by their "seq" property. - const sortItemsBySequence = (items = []) => { - return items.sort((a, b) => a.seq - b.seq); - }; - const handleShowInFolder = () => { dispatch(showInFolder(item.pathname)).catch((error) => { console.error('Error opening the folder', error); @@ -640,18 +637,20 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o setCreateExampleModalOpen(false); }; - const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient)); - const appItems = sortItemsBySequence(filter(item.items, (i) => i.type === 'app' && !i.isTransient)); - const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient)); const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !appItems?.length && !requestItems?.length; const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid }); - const handleGenerateCode = () => { + const handleGenerateCode = async () => { + // The snippet is built in the renderer from the item's headers, body and auth, so a deferred + // node has to be parsed in full first — unlike sending, which the main process resolves itself. + // The parse also lands in the store, so the modal below renders against the loaded item. + const resolvedItem = await dispatch(resolveDeferredItem(item, collectionUid)).catch(() => item); + if ( - (item?.request?.url !== '') - || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '') + (resolvedItem?.request?.url !== '') + || (resolvedItem?.draft?.request?.url !== undefined && resolvedItem?.draft?.request?.url !== '') ) { setGenerateCodeItemModalOpen(true); } else { @@ -839,21 +838,21 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o )}
- {!itemIsCollapsed ? ( + {!flat && !itemIsCollapsed ? (
{folderItems && folderItems.length ? folderItems.map((i) => { - return ; + return ; }) : null} {appItems && appItems.length ? appItems.map((i) => { - return ; + return ; }) : null} {requestItems && requestItems.length ? requestItems.map((i) => { - return ; + return ; }) : null} {showEmptyFolderMessage ? ( 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..55d961c91b6 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 @@ -2,14 +2,13 @@ import React, { useState, useRef, useEffect, forwardRef } from 'react'; import { useDispatch } from 'react-redux'; 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'; import Portal from 'components/Portal'; import Modal from 'components/Modal'; import Dropdown from 'components/Dropdown'; -import { browseDirectory, exportCollectionToPostman } from 'providers/ReduxStore/slices/collections/actions'; +import { browseDirectory, exportCollectionToPostman, resolveDeferredCollection } from 'providers/ReduxStore/slices/collections/actions'; import { exportPostmanCollection } from 'utils/exporters/postman-collection'; import StyledWrapper from './StyledWrapper'; @@ -54,7 +53,7 @@ const ExportToPostman = ({ onClose, onExported, collection }) => { setIsExporting(true); try { - const content = exportPostmanCollection(cloneDeep(collection), { preserveScripts }); + const content = exportPostmanCollection(await dispatch(resolveDeferredCollection(collection.uid)), { preserveScripts }); await dispatch(exportCollectionToPostman(values.location, `${values.fileName.trim()}.json`, content, overwrite)); toast.success('Collection exported successfully'); onExported(); 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 0cff48447dd..180948dbcf2 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,6 +1,5 @@ import React, { useCallback, useMemo, useState, Fragment } from 'react'; -import { useSelector } from 'react-redux'; -import { cloneDeep } from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; import * as FileSaver from 'file-saver'; import jsyaml from 'js-yaml'; import jsesc from 'jsesc'; @@ -18,6 +17,7 @@ import useCollectionGitRemoteUrl from 'hooks/useCollectionGitRemoteUrl'; import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading, sortItemsBySidebarOrder, getCollectionItemCounts, getCollectionVersion, getUniqueTagsFromItems } from 'utils/collections/index'; import { brunoToOpenCollection } from '@usebruno/converters'; import { generateApiDocsHtml, getApiDocsFileName } from '@usebruno/common'; +import { resolveDeferredCollection } from 'providers/ReduxStore/slices/collections/actions'; const FEATURES = [ 'Standalone HTML file - no server required', @@ -40,6 +40,7 @@ const CollectionNotFound = ({ onClose }) => ( const GenerateDocumentation = ({ onClose, collectionUid }) => { const { version } = useApp(); + const dispatch = useDispatch(); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid) ); @@ -92,9 +93,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(resolveDeferredCollection(collection.uid)); // 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. @@ -125,7 +126,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => { console.error('Error generating documentation:', error); toast.error('Failed to generate documentation'); } - }, [collection, version, onClose, currentVersion, selectedEnvUidsSet, filterByTags, docTags, includeGitLink, gitCollectionUrl]); + }, [dispatch, collection, version, onClose, currentVersion, selectedEnvUidsSet, filterByTags, docTags, includeGitLink, gitCollectionUrl]); if (!collection) { return ; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 3b3136b6be8..10d1dc7554e 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -1,13 +1,11 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import classnames from 'classnames'; import { uuid } from 'utils/common'; -import filter from 'lodash/filter'; import { useDrop, useDrag } from 'react-dnd'; import { getEmptyImage } from 'react-dnd-html5-backend'; import { IconChevronRight, IconDots, - IconLoader2, IconFilePlus, IconFolderPlus, IconCopy, @@ -39,8 +37,8 @@ import CollectionItem from './CollectionItem'; import RemoveCollections from './RemoveCollections'; import MoveToWorkspace from './MoveToWorkspace'; import { isPathExternalToBasePath } from 'utils/common/path'; -import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search'; import { isItemAFolder, isItemARequest, getSortedDraggedItems } from 'utils/collections'; +import useVisibleSidebarItems from 'hooks/useVisibleSidebarItems'; import { isTabForItemActive } from 'src/selectors/tab'; import RenameCollection from './RenameCollection'; @@ -49,7 +47,6 @@ import CloneCollection from './CloneCollection'; import { scrollToTheActiveTab } from 'utils/tabs'; import ShareCollection from 'components/ShareCollection/index'; import GenerateDocumentation from './GenerateDocumentation'; -import { sortByNameThenSequence } from 'utils/common/index'; import { getRevealInFolderLabel } from 'utils/common/platform'; import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; import ActionIcon from 'ui/ActionIcon'; @@ -66,7 +63,11 @@ import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; // This prevents flicker from race condition between loading state and item batch updates const EMPTY_STATE_DELAY_MS = 300; -const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, multiDragCollections, multiDragItems: multiDragItemsForSelection }) => { +/** + * `flat` renders the collection's own header row only — the virtualised search list carries its + * items as rows of their own. See CollectionItem for why drag and drop is off in that mode. + */ +const Collection = ({ collection, searchText, searchIndex, flat = false, openBulkMenu, isMultiDragDisabled, multiDragCollections, multiDragItems: multiDragItemsForSelection }) => { const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER); const { dropdownContainerRef } = useSidebarAccordion(); const [showNewFolderModal, setShowNewFolderModal] = useState(false); @@ -89,7 +90,11 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, // (bruno.json, .js scripts) don't affect empty state const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i) || i.type === 'app')).length || 0; - 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); @@ -106,7 +111,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, const allCollections = useSelector((state) => state.collections.collections); const isMoveToWorkspaceVisible = isPathExternalToBasePath(activeWorkspace?.pathname, collection.pathname); - const isDragDisabled = isMultiSelected && isMultiDragDisabled; + const isDragDisabled = flat || (isMultiSelected && isMultiDragDisabled); const multiDragItems = isMultiSelected ? multiDragCollections : null; // Open the OpenAPI Sync tab @@ -366,6 +371,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, setDropType(null); }, canDrop: (draggedItem) => { + if (flat) return false; if (draggedItem.uid === collection.uid) return false; return !draggedItem.multiSelectedItems?.some((i) => i.uid === collection.uid); }, @@ -402,10 +408,10 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, return () => clearTimeout(timer); }, [itemCount, isLoading, collection.mountStatus]); - if (searchText && searchText.length) { - if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) { - return null; - } + const { folderItems, appItems, requestItems } = useVisibleSidebarItems(collection.items, { hasSearchText, searchIndex, skip: flat }); + + if (hasSearchText && !searchIndex.has(collection.uid)) { + return null; } const collectionRowClassName = classnames( @@ -420,14 +426,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, } ); - // we need to sort request items by seq property - const sortItemsBySequence = (items = []) => { - return items.sort((a, b) => a.seq - b.seq); - }; - - const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient)); - const appItems = sortItemsBySequence(filter(collection.items, (i) => i.type === 'app' && !i.isTransient)); - const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient)); const showEmptyCollectionMessage = showEmptyState && !hasSearchText; const emptyStateMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: null }); @@ -639,7 +637,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, - {isLoading ? : null}
{!isDragging && !isMultiSelected && (
@@ -661,16 +658,16 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled, )}
- {!collectionIsCollapsed ? ( + {!flat && !collectionIsCollapsed ? (
{folderItems?.map?.((i) => { - return ; + return ; })} {appItems?.map?.((i) => { - return ; + return ; })} {requestItems?.map?.((i) => { - return ; + return ; })} {showEmptyCollectionMessage ? (
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index 0235625c563..818927a1baa 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -1,20 +1,38 @@ import React, { useState, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { Virtuoso } from 'react-virtuoso'; import Collection from './Collection'; +import CollectionItem from './Collection/CollectionItem'; import GitRemoteCollectionRow from './GitRemoteCollectionRow'; import StyledWrapper from './StyledWrapper'; import CreateOrOpenCollection from './CreateOrOpenCollection'; import CollectionSearch from './CollectionSearch/index'; import InlineCollectionCreator from './InlineCollectionCreator'; import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections'; -import { buildSidebarEntries, getSelectionInfo } from 'utils/collections/index'; +import { buildSidebarEntries, getSelectionInfo, getVisibleSidebarRows } from 'utils/collections/index'; +import { buildSidebarSearchIndex } from 'utils/collections/search'; 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 index and re-renders + // the tree once rather than per character. const [searchText, setSearchText] = useState(''); - const { collections, collectionSortOrder, selectedSidebarUids } = useSelector((state) => state.collections); + const debouncedSearchText = useDebounce(searchText, SEARCH_DEBOUNCE_MS); + // Subscribed field by field: selecting the whole collections slice re-renders the entire + // sidebar whenever any unrelated part of it changes (active connections, runner state, + // last-clicked uid, transient directories). + const collections = useSelector((state) => state.collections.collections); + const collectionSortOrder = useSelector((state) => state.collections.collectionSortOrder); + const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); const dispatch = useDispatch(); @@ -31,6 +49,29 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis [activeWorkspace, collections, workspaces, collectionSortOrder] ); + // Which rows a search leaves visible, resolved once per term for the whole sidebar. Each row then + // checks membership instead of searching its own subtree while it renders. + const searchIndex = useMemo( + () => buildSidebarSearchIndex(collections, debouncedSearchText), + [collections, 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; + const hasSearchText = Boolean(debouncedSearchText.trim().length); + + // Only while searching. A search opens every folder that contains a match, so the tree it renders + // is unbounded — that is the case worth virtualising, and it is also the one where drag and drop + // is meaningless, since an item's real neighbours are filtered out of view. + const searchRows = useMemo( + () => (hasSearchText ? getVisibleSidebarRows({ sidebarEntries, searchText: debouncedSearchText }) : []), + [hasSearchText, sidebarEntries, debouncedSearchText] + ); + const selectionInfo = useMemo( () => (selectedSidebarUids.length > 1 ? getSelectionInfo({ collections, selectedUids: selectedSidebarUids }) : null), [collections, selectedSidebarUids] @@ -75,8 +116,15 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis )} + +
{isCreatingCollection && ( @@ -86,11 +134,44 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis onOpenAdvanced={onOpenAdvancedCreate} /> )} - {sidebarEntries.map((entry) => { + {hasSearchText ? ( + row.uid} + itemContent={(index, row) => (row.kind === 'collection' ? ( + + ) : ( + + ))} + /> + ) : sidebarEntries.map((entry) => { if (entry.kind === 'loaded') { return ( } options.searchIndex - uids a search leaves visible, from buildSidebarSearchIndex + */ +const NO_ITEMS = []; + +const useVisibleSidebarItems = (items, { hasSearchText, searchIndex, skip = false }) => { + const visibleItems = useMemo(() => { + // `skip` is the virtualised search list, where every descendant is already a row of its own. + // Without it a folder row would filter and sort its entire subtree only to render none of it. + if (skip) return NO_ITEMS; + return hasSearchText ? (items || []).filter((item) => searchIndex.has(item.uid)) : items; + }, [items, hasSearchText, searchIndex, skip]); + + return useMemo(() => groupItemsBySidebarOrder(visibleItems), [visibleItems]); +}; + +export default useVisibleSidebarItems; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 69cad0c2565..a66ec9d1ddf 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -15,9 +15,11 @@ import IpcErrorModal from 'components/Errors/IpcErrorModal/index'; import SaveFileErrorModal from 'components/Errors/SaveFileErrorModal/index'; import { findCollectionByUid, + findCollectionByItemUid, findEnvironmentInCollection, findItemInCollection, findParentItemInCollection, + flattenItems, isItemAFolder, refreshUidsInItem, isItemARequest, @@ -37,12 +39,14 @@ import brunoClipboard from 'utils/bruno-clipboard'; import { collectionAddEnvFileEvent as _collectionAddEnvFileEvent, + evictClosedItems as _evictClosedItems, createCollection as _createCollection, removeCollection as _removeCollection, selectEnvironment as _selectEnvironment, applyDefaultEnvironment as _applyDefaultEnvironment, sortCollections as _sortCollections, updateCollectionMountStatus, + updateCollectionLoadStats, moveCollection, deleteItem as _deleteItemFromState, brunoConfigUpdateEvent as _brunoConfigUpdateEvent, @@ -922,14 +926,23 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (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')); + } + + // A request the user never opened is a deferred node, and cloning serializes it. + // Folder children are resolved inside renderer:clone-folder as it walks them. + let item; + try { + item = await dispatch(resolveDeferredItem(treeItem, collectionUid)); + } catch (error) { + return reject(error); } if (isItemAFolder(item)) { @@ -1063,7 +1076,13 @@ export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatc const newFilename = sanitizeName(newName); const filename = resolveRequestFilename(newFilename, targetCollection.format); - const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(copiedItem)); + // The clipboard holds whatever the tree held when the item was copied, so a request the + // user never opened is still a deferred node. It is resolved against the collection it + // came from, which is not necessarily the one being pasted into. Folders take the same + // trip inside renderer:clone-folder, which resolves their children as it walks them. + const sourceCollectionUid = findCollectionByItemUid(state.collections.collections, copiedItem.uid)?.uid; + const resolvedItem = await dispatch(resolveDeferredItem(copiedItem, sourceCollectionUid)); + const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(resolvedItem)); set(itemToSave, 'name', trim(newName)); set(itemToSave, 'filename', trim(filename)); @@ -3230,21 +3249,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,14 +3382,65 @@ export const loadRequestViaWorker }); }; -// todo: could be removed +// Requests are mounted as deferred tree nodes carrying only what the sidebar and searches read. +// Opening one parses it in full; the parsed item arrives back over main:collection-tree-updated, +// and is also returned here for callers that need it before that lands in the store. 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); + () => { + const { ipcRenderer } = window; + return ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }); + }; + +/** + * A request the user has never opened is a deferred tree node: it carries name, method and url but + * none of the headers, body, auth, scripts or tests. Anything that *serializes* an item — clone, + * paste, export, documentation — has to fill it in first, or it writes an empty request. + * + * Execution paths do not need this: the main process resolves deferred items itself when it runs + * them (`resolveDeferredItem` in bruno-electron), where the file is already at hand. + */ +export const resolveDeferredItem + = (item, collectionUid) => + async (dispatch) => { + if (!item?.deferred || !item?.pathname) return item; + + const data = await dispatch(loadRequest({ collectionUid, pathname: item.pathname })); + + return { ...item, ...data, uid: item.uid, deferred: false }; + }; + +/** + * The same fill-in as `resolveDeferredItem`, for consumers that serialize a whole collection at + * once — export, documentation. Returns a copy with every deferred request parsed, in one IPC + * round trip rather than one per request. + * + * The copy is deliberately not written back to the store: nothing here is open, and keeping a + * fully parsed collection around after a one-shot export is what deferred nodes exist to avoid. + * Requests that fail to parse are left as they are, so an export reports them rather than aborting. + */ +export const resolveDeferredCollection + = (collectionUid) => + async (dispatch, getState) => { + const collection = cloneDeep(findCollectionByUid(getState().collections.collections, collectionUid)); + if (!collection) return collection; + + const deferredItems = flattenItems(collection.items).filter((item) => item.deferred && item.pathname); + if (!deferredItems.length) return collection; + + const { ipcRenderer } = window; + const loaded = await ipcRenderer.invoke('renderer:load-requests', { + pathnames: deferredItems.map((item) => item.pathname) }); + + const dataByPathname = new Map(loaded.map(({ pathname, data }) => [pathname, data])); + deferredItems.forEach((item) => { + const data = dataByPathname.get(item.pathname); + if (!data) return; + Object.assign(item, data, { uid: item.uid, deferred: false }); + }); + + return collection; }; export const loadLargeRequest @@ -3387,9 +3458,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 +3495,97 @@ 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. + * + * It is affordable now and was not before: a mounted collection holds deferred tree nodes rather + * than full requests, and items are released again when their last tab closes, so mounting + * everything costs the tree and nothing else. + * + * 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); + } + }; + export const showInFolder = (collectionPath) => () => { return new Promise((resolve, reject) => { const { ipcRenderer } = window; @@ -3648,6 +3817,41 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => { console.error('Failed to delete transient request files:', err); } } + + dispatch(evictItemsWithNoOpenTabs({ closedTabUids: tabUids })); +}; + +/** + * Releases every item whose last tab just closed back to a deferred tree node, so the store holds + * what is open rather than everything ever opened. Reopening re-parses from disk. + * + * An item can be referenced by more than one tab — an example tab addresses its parent request by + * `pathname` rather than by uid — so a request is only released once no remaining tab points at it + * either way. The reducer applies its own safety checks (drafts, transient items, live streams). + */ +const evictItemsWithNoOpenTabs = ({ closedTabUids }) => (dispatch, getState) => { + const state = getState(); + const openTabs = state.tabs?.tabs || []; + const openTabUids = new Set(openTabs.map((tab) => tab.uid)); + const openTabPathnames = new Set(openTabs.map((tab) => tab.pathname).filter(Boolean)); + + const evictionsByCollection = {}; + each(closedTabUids, (tabUid) => { + if (openTabUids.has(tabUid)) return; + + for (const collection of state.collections.collections) { + const item = findItemInCollection(collection, tabUid); + if (!item) continue; + if (!openTabPathnames.has(item.pathname)) { + (evictionsByCollection[collection.uid] ||= []).push(tabUid); + } + break; + } + }); + + Object.entries(evictionsByCollection).forEach(([collectionUid, itemUids]) => { + dispatch(_evictClosedItems({ collectionUid, itemUids })); + }); }; /** 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 dd1f727810e..04789f0a7a2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -14,6 +14,7 @@ import { findEnvironmentInCollection, findItemInCollection, findItemInCollectionByPathname, + findParentItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections'; @@ -47,6 +48,88 @@ const FILE_DERIVED_REQUEST_FIELDS = [ 'isTransient' ]; +// The subset a deferred tree node can be trusted for. Everything omitted here (`request`, +// `settings`, `examples`, `raw`) only exists on an item once it has been parsed in full. +const TREE_DERIVED_REQUEST_FIELDS = [ + 'name', + 'type', + 'seq', + 'tags', + 'filename', + 'pathname', + 'size', + 'isTransient' +]; + +/** + * Everything an item keeps when it is evicted back to a deferred tree node. Mirrors + * `buildDeferredRequestNode` in bruno-electron's tree-builder — the shape mount produces — plus the + * fields the slice itself owns (`depth`, `isTransient`). Anything not listed is re-read from disk + * the next time the request is opened. + */ +const evictItemToTreeNode = (item) => { + const { uid, name, type, seq, tags, filename, pathname, size, app, depth, isTransient } = item; + + return { + uid, + name, + type, + seq, + tags, + filename, + pathname, + size, + app: app ?? null, + depth, + isTransient, + request: { method: item.request?.method, url: item.request?.url }, + examples: (item.examples || []).map((example) => ({ uid: example.uid, name: example.name })), + draft: null, + deferred: true, + partial: false, + loading: false + }; +}; + +/** + * An item is safe to evict only when nothing still depends on what is about to be dropped. + * + * `draft` is the one that would lose user data: the close flow gates on the Unsaved Changes modal, + * so a draft should already be saved or discarded by now, but eviction must never be the thing that + * discards it. A transient item has no file to re-read, and a `partial` one could not be parsed in + * the first place, so neither can be rebuilt. An in-flight request or a live stream is still writing + * into `item.response`, which closing a tab does not stop. + */ +/** + * The response an evicted item lost, read back off the collection timeline. `responseReceived` + * writes the same payload to both `item.response` and a timeline entry, so this is a lookup rather + * than a second copy. Best-effort: the user can clear the timeline, in which case the pane stays + * empty until the request is sent again. + */ +const findLastTimelineResponse = (collection, itemUid) => { + const timeline = collection.timeline; + if (!Array.isArray(timeline)) return null; + + for (let i = timeline.length - 1; i >= 0; i--) { + const entry = timeline[i]; + if (entry.type === 'request' && entry.itemUid === itemUid && entry.data?.response) { + return entry.data.response; + } + } + + return null; +}; + +const canEvictItem = (item) => + isItemARequest(item) + && !item.deferred + && !item.draft + && !item.isTransient + && !item.partial + && !item.response?.stream?.running + && item.requestState !== 'sending' + && item.requestState !== 'queued'; + const FILE_DERIVED_FOLDER_FIELDS = [ 'name', 'filename', @@ -92,7 +175,13 @@ const mergeTreeItems = (existingItems, newItems) => { return merged; } - const merged = { ...existing, ...pick(newItem, FILE_DERIVED_REQUEST_FIELDS) }; + // A deferred node carries only tree metadata, so it must not overwrite an item that has + // already been parsed in full — that would strip request/settings/examples while leaving the + // item looking loaded. + const fields = newItem.deferred && !existing.deferred + ? TREE_DERIVED_REQUEST_FIELDS + : FILE_DERIVED_REQUEST_FIELDS; + const merged = { ...existing, ...pick(newItem, fields) }; // only drop the draft if it matches what's on disk — user may still be typing const draftMatchesFile = existing.draft && areItemsTheSameExceptSeqUpdate(existing.draft, newItem); merged.draft = draftMatchesFile ? null : (existing.draft || null); @@ -296,6 +385,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) { @@ -3043,6 +3140,30 @@ export const collectionsSlice = createSlice({ delete collection._scriptEnvBaseline; delete collection._scriptCollVarBaseline; }, + /** + * Releases items whose last tab just closed, back to the deferred tree node mount produced. + * Without this the store keeps every request the user has ever opened fully parsed for the rest + * of the session — the resident set grows with requests-ever-opened rather than tabs-open. + * + * Reopening re-parses from disk through the same path a fresh mount uses, and the response + * pane is restored from `collection.timeline`, which is collection-level and unaffected here. + */ + evictClosedItems: (state, action) => { + const { collectionUid, itemUids } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + + itemUids.forEach((itemUid) => { + const item = findItemInCollection(collection, itemUid); + if (!item || !canEvictItem(item)) return; + + const parent = findParentItemInCollection(collection, itemUid) || collection; + const index = parent.items.findIndex((i) => i.uid === itemUid); + if (index !== -1) { + parent.items[index] = evictItemToTreeNode(item); + } + }); + }, collectionAddFileEvent: (state, action) => { const file = action.payload.file; const isCollectionRoot = file.meta.collectionRoot ? true : false; @@ -3079,7 +3200,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) { @@ -3090,6 +3211,7 @@ export const collectionsSlice = createSlice({ collapsed: true, type: 'folder', isTransient: isTransientFile, + depth: idx + 1, items: [] }; currentSubItems.push(childItem); @@ -3098,7 +3220,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 @@ -3122,6 +3245,19 @@ export const collectionsSlice = createSlice({ currentItem.size = file.size; currentItem.error = file.error; currentItem.isTransient = isTransientFile; + currentItem.depth = itemDepth; + // Reopening an evicted item loses the response pane's contents, but the same response + // is still on the collection timeline, which is not evicted. Restore from there rather + // than storing a second copy of it anywhere. + if (currentItem.deferred && !currentItem.response) { + const lastResponse = findLastTimelineResponse(collection, currentItem.uid); + if (lastResponse) { + currentItem.response = lastResponse; + currentItem.requestState = 'received'; + } + } + // A watcher event always carries a full parse, so the item is no longer deferred. + currentItem.deferred = false; } else { currentSubItems.push({ uid: file.data.uid, @@ -3141,11 +3277,12 @@ export const collectionsSlice = createSlice({ loading: file.loading, size: file.size, error: file.error, - isTransient: isTransientFile + isTransient: isTransientFile, + depth: itemDepth, + deferred: false }); } } - addDepth(collection.items); } }, collectionAddDirectoryEvent: (state, action) => { @@ -3188,6 +3325,7 @@ export const collectionsSlice = createSlice({ collapsed: true, type: 'folder', isTransient: isTransientDir, + depth: idx + 1, items: [] }; currentSubItems.push(childItem); @@ -3197,7 +3335,6 @@ export const collectionsSlice = createSlice({ } currentSubItems = childItem.items; }); - addDepth(collection.items); } }, collectionChangeFileEvent: (state, action) => { @@ -3260,6 +3397,7 @@ export const collectionsSlice = createSlice({ item.pathname = file.meta.pathname; item.raw = file.data.raw; item.size = file.size; + item.deferred = false; // Only clear draft if it matches the file content // This preserves characters typed during autosave // The raw comparison is guarded so an undefined === undefined match @@ -3282,6 +3420,7 @@ export const collectionsSlice = createSlice({ item.pathname = file.meta.pathname; item.raw = file.data.raw; item.size = file.size; + item.deferred = false; if (!item.draft || item.draft.raw === file.data.raw) { item.draft = null; } @@ -3770,6 +3909,9 @@ export const collectionsSlice = createSlice({ 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) { @@ -4218,6 +4360,7 @@ export const { createCollection, updateCollectionMountStatus, updateCollectionLoadingState, + updateCollectionLoadStats, collectionLoadedFromTree, setCollectionSecurityConfig, updateCollectionVersion, @@ -4351,6 +4494,7 @@ export const { updateCollectionPresets, updateCollectionProtobuf, collectionAddFileEvent, + evictClosedItems, collectionAddDirectoryEvent, collectionChangeFileEvent, collectionUnlinkFileEvent, 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/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 efb3eba2310..d7c02cf2bf8 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -5,11 +5,7 @@ import path, { normalizePath } from 'utils/common/path'; import { isWindowsOS } from 'utils/common/platform'; import { isRequestTagsIncluded } from '@usebruno/common'; import { VARIABLE_ADD_SCOPES } from 'utils/common/constants'; -import { - doesRequestMatchSearchText, - doesFolderHaveItemsMatchSearchText, - doesCollectionHaveItemsMatchingSearchText -} from 'utils/collections/search'; +import { buildSidebarSearchIndex } from 'utils/collections/search'; import { resolveEnvironmentInheritance, toVariablesMap } from '@usebruno/common/utils'; const replaceTabsWithSpaces = (str, numSpaces = 2) => { @@ -945,18 +941,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 @@ -1934,53 +1937,73 @@ export const buildSidebarEntries = ({ collections = [], workspaces = [], activeW }; /** - * Returns an ordered list of all currently visible sidebar item UIDs, reflecting collapse state and search filters. + * Every row the sidebar currently shows, in render order, flattened. + * + * This is the single source of ordering for the sidebar: the virtualised search list renders from + * it and keyboard navigation walks it, so the two cannot drift apart. Nesting is carried as + * `depth` rather than by structure, which is also how a row indents itself. * * @param {Object} options - * @param {Array} options.sidebarEntries + * @param {Array} options.sidebarEntries - from buildSidebarEntries * @param {string} options.searchText - * @returns {string[]} + * @returns {Array<{ uid: string, kind: 'collection'|'item', collection: Object, item?: Object }>} */ -export const getVisibleSidebarUidsInOrder = ({ sidebarEntries = [], searchText = '' }) => { +export const getVisibleSidebarRows = ({ sidebarEntries = [], searchText = '' }) => { const hasSearchText = Boolean(searchText && searchText.trim().length); - const uids = []; + const rows = []; + + // The same index the rendered rows are filtered by. + const searchIndex = hasSearchText + ? buildSidebarSearchIndex(sidebarEntries.filter((e) => e.kind === 'loaded').map((e) => e.collection), searchText) + : null; - const visitItems = (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 visitItems = (collection, items = []) => { + const { folderItems, appItems, requestItems } = groupItemsBySidebarOrder(items); folderItems.forEach((folder) => { - if (hasSearchText && !doesFolderHaveItemsMatchSearchText(folder, searchText)) return; - uids.push(folder.uid); + if (hasSearchText && !searchIndex.has(folder.uid)) return; + rows.push({ uid: folder.uid, kind: 'item', collection, item: folder }); + // A search shows every match in context, so folders are walked regardless of collapse state. if (hasSearchText || !folder.collapsed) { - visitItems(folder.items); + visitItems(collection, folder.items); } }); + // Apps have nothing to match on, so a search hides them entirely. if (!hasSearchText) { - appItems.forEach((app) => uids.push(app.uid)); + appItems.forEach((app) => rows.push({ uid: app.uid, kind: 'item', collection, item: app })); } requestItems.forEach((request) => { - if (hasSearchText && !doesRequestMatchSearchText(request, searchText)) return; - uids.push(request.uid); + if (hasSearchText && !searchIndex.has(request.uid)) return; + rows.push({ uid: request.uid, kind: 'item', collection, item: request }); }); }; sidebarEntries.forEach((entry) => { if (entry.kind !== 'loaded') return; const { collection } = entry; - if (hasSearchText && !doesCollectionHaveItemsMatchingSearchText(collection, searchText)) return; - uids.push(collection.uid); + if (hasSearchText && !searchIndex.has(collection.uid)) return; + rows.push({ uid: collection.uid, kind: 'collection', collection }); if (hasSearchText || !collection.collapsed) { - visitItems(collection.items); + visitItems(collection, collection.items); } }); - return uids; + return rows; }; +/** + * The uids of {@link getVisibleSidebarRows}, for callers that only need identity and order — + * range selection, for one. Derived from the same walk so ordering can never disagree. + * + * @param {Object} options + * @param {Array} options.sidebarEntries + * @param {string} options.searchText + * @returns {string[]} + */ +export const getVisibleSidebarUidsInOrder = (options) => getVisibleSidebarRows(options).map((row) => row.uid); + const isPathnameDescendantOf = (pathname, ancestorPathname) => { if (!pathname || !ancestorPathname || pathname === ancestorPathname) return false; diff --git a/packages/bruno-app/src/utils/collections/search.js b/packages/bruno-app/src/utils/collections/search.js index 2919216e066..105743867d0 100644 --- a/packages/bruno-app/src/utils/collections/search.js +++ b/packages/bruno-app/src/utils/collections/search.js @@ -1,21 +1,65 @@ -import { flattenItems, isItemARequest } from './index'; -import filter from 'lodash/filter'; -import find from 'lodash/find'; +import { isItemAFolder, isItemARequest } from './index'; -export const doesRequestMatchSearchText = (request, searchText = '') => { - return request?.name?.toLowerCase().includes(searchText.toLowerCase()); +/** + * Whitespace-separated terms, all of which must match. Same rule the global search modal applies, + * so typing the same thing in either place selects the same requests. + */ +const toSearchTerms = (searchText = '') => searchText.toLowerCase().split(/\s+/).filter(Boolean); + +const includesAllTerms = (value, terms) => { + const haystack = (value || '').toLowerCase(); + return terms.every((term) => haystack.includes(term)); }; -export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => { - let flattenedItems = flattenItems(item.items); - let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient); +/** + * Matches a request on its name or its url. Url matters because the endpoint is often the only part + * a user remembers, and it is on the tree node even for a request that has never been opened. + */ +const matchesTerms = (request, terms) => + includesAllTerms(request?.name, terms) || includesAllTerms(request?.request?.url, terms); - return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText)); -}; +/** + * The uids the sidebar shows for a search term: every request that matches, plus the folders and + * the collection above it. + * + * Built once per search term rather than per row. Visibility used to be decided during render — + * each folder called `flattenItems` over its own subtree on every render, so a nested tree walked + * the same nodes once per level of depth, on every keystroke. Here one pass marks everything and a + * row's visibility becomes a Set lookup. + * + * An empty set means "no search is active"; callers check `searchText` before consulting it. + */ +export const buildSidebarSearchIndex = (collections = [], searchText = '') => { + const visibleUids = new Set(); + const terms = toSearchTerms(searchText); + if (!terms.length) return visibleUids; + + // Ancestors of the node being visited. Kept as one array pushed and popped through the walk so a + // match can mark its whole path without rebuilding it. + const ancestorUids = []; + + const visit = (items = []) => { + for (const item of items) { + if (item.isTransient) continue; + + if (isItemAFolder(item)) { + ancestorUids.push(item.uid); + visit(item.items); + ancestorUids.pop(); + } else if (isItemARequest(item) && matchesTerms(item, terms)) { + // Marking the whole path is what makes a folder visible: it is in the index exactly when + // something beneath it matched. + ancestorUids.forEach((uid) => visibleUids.add(uid)); + visibleUids.add(item.uid); + } + } + }; -export const doesCollectionHaveItemsMatchingSearchText = (collection, searchText = '') => { - let flattenedItems = flattenItems(collection.items); - let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient); + for (const collection of collections) { + ancestorUids.push(collection.uid); + visit(collection.items); + ancestorUids.pop(); + } - return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText)); + return visibleUids; }; 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..ac94fe751c5 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), @@ -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..771f787b67f 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -50,6 +50,7 @@ const { isWindowsOS, hasRequestExtension, getCollectionFormat, + getRequestFormat, searchForRequestFiles, validateName, getCollectionStats, @@ -76,9 +77,10 @@ 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'); +const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, resolveDeferredItem, transformRequestToSaveToFilesystem } = require('../utils/collection'); const { getProcessEnvVars } = require('../store/process-env'); const { setBrunoConfig } = require('../store/bruno-config'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, refreshOauth2Token } = require('../utils/oauth2'); @@ -1614,7 +1616,9 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { const parseCollectionItems = async (items = [], currentPath) => { for (const item of items) { if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) { - const content = await stringifyRequestViaWorker(item, { format }); + // Children the user never opened are deferred nodes, so the source files are read here + // rather than taken from the renderer's copy — otherwise the clone comes out empty. + const content = await stringifyRequestViaWorker(await resolveDeferredItem(item), { format }); // Use the correct file extension based on target format const baseName = path.parse(item.filename).name; @@ -1715,7 +1719,9 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { await writeFile(folderRootPath, content); } else if (REQUEST_TYPES.includes(item?.type)) { if (fs.existsSync(item.pathname)) { - const itemToSave = transformRequestToSaveToFilesystem(item); + // Reordering rewrites the whole file, and the renderer only holds a deferred node for + // a request the user has never opened — so read it back before serializing it. + const itemToSave = transformRequestToSaveToFilesystem(await resolveDeferredItem(item)); const content = await stringifyRequestViaWorker(itemToSave, { format }); await writeFile(item.pathname, content); } @@ -2282,56 +2288,68 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); - // todo: could be removed + // Parses one request that was mounted as a deferred tree node, pushes the full item into the + // tree, and returns it so a caller that needs the request immediately does not have to wait for + // the tree update to land in the store. Distinct from renderer:load-large-request, which handles + // files too big to parse on mount at all and applies bru text-block redaction. 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); + const format = getRequestFormat(pathname); + const file = { + meta: { + collectionUid, + pathname, + name: path.basename(pathname) } + }; + + try { + const [fileStats, content] = await Promise.all([ + fs.promises.stat(pathname), + fs.promises.readFile(pathname, 'utf8') + ]); + + // Parsed off the main thread. A deferred request is only read when it is opened, so this is + // on the path between the click and the request appearing — and running the `.bru` grammar + // here stalls everything else in main for the duration, which scales with the file's size. + file.data = await parseRequestViaWorker(content, { format, filename: pathname }); + file.partial = false; + file.loading = false; + file.size = sizeInMB(fileStats?.size); + file.data.raw = content; + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); + return file.data; } 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); - } + file.data = { + name: path.basename(pathname), + type: 'http-request' + }; + file.error = { message: error?.message }; + file.partial = true; + file.loading = false; + hydrateRequestWithUuid(file.data, pathname); + mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); return Promise.reject(error); } }); + // Parses a batch of deferred requests and returns them without touching the tree. Whole-collection + // consumers — export, documentation — need every request's body in one go, and resolving them one + // at a time would be an IPC round trip per request. They are not put into the tree because none of + // them is open: the store would then hold a fully parsed collection for a one-shot export. + ipcMain.handle('renderer:load-requests', async (event, { pathnames }) => { + return Promise.all((pathnames || []).map(async (pathname) => { + try { + const content = await fs.promises.readFile(pathname, 'utf8'); + const data = await parseRequestViaWorker(content, { format: getRequestFormat(pathname), filename: pathname }); + hydrateRequestWithUuid(data, pathname); + return { pathname, data }; + } catch (error) { + return { pathname, error: { message: error?.message } }; + } + })); + }); + ipcMain.handle('renderer:load-large-request', async (event, { collectionUid, pathname }) => { let fileStats; if (!hasBruExtension(pathname)) { @@ -2398,18 +2416,26 @@ 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. + // Request nodes are deferred (see tree-builder): the sidebar and searches get what they + // need, and a request is parsed in full only when it is opened. + 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 f6c222b17e3..9cbcb4f5b3b 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -28,7 +28,7 @@ const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseData const { chooseFileToSave, writeFile, getCollectionFormat, hasRequestExtension } = require('../../utils/filesystem'); const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies'); const { createFormData } = require('../../utils/form-data'); -const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection'); +const { findItemInCollectionByPathname, resolveDeferredItem, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, mergeVars, sortByNameThenSequence } = require('../../utils/collection'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials, clearOauth2CredentialsByCredentialsId } = require('../../utils/oauth2'); const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); @@ -763,7 +763,7 @@ const registerNetworkIpc = (mainWindow) => { if (itemPathname && !hasRequestExtension(itemPathname, format)) { itemPathname = `${itemPathname}.${format}`; } - const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); + const _item = cloneDeep(await resolveDeferredItem(findItemInCollectionByPathname(collection, itemPathname))); if (_item) { // WS/gRPC items live on separate IPC channels and can't be driven via // the HTTP runRequest. Record a Skipped row so the user sees feedback. @@ -1350,8 +1350,10 @@ 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; + // Sending from the sidebar never opens the request, so it can still be a deferred node. + const item = await resolveDeferredItem(_item); const collectionUid = collection.uid; const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collectionUid); @@ -1454,7 +1456,7 @@ const registerNetworkIpc = (mainWindow) => { if (itemPathname && !hasRequestExtension(itemPathname, format)) { itemPathname = `${itemPathname}.${format}`; } - const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); + const _item = cloneDeep(await resolveDeferredItem(findItemInCollectionByPathname(collection, itemPathname))); if (_item) { // WS/gRPC items live on separate IPC channels and can't be driven via // the HTTP runRequest. Record a Skipped row so the user sees feedback. @@ -1616,7 +1618,7 @@ const registerNetworkIpc = (mainWindow) => { stopRunnerExecution = false; - const item = cloneDeep(folderRequests[currentRequestIndex]); + const item = cloneDeep(await resolveDeferredItem(folderRequests[currentRequestIndex])); let nextRequestName; const itemUid = item.uid; const eventData = { 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..672abcebf84 --- /dev/null +++ b/packages/bruno-electron/src/ipc/search-index.js @@ -0,0 +1,59 @@ +const { ipcMain } = require('electron'); +const { indexCollection, getSearchIndex } = require('../services/search-index/indexer'); +const { ensureWatching, closeAll: closeAllSearchIndexWatchers } = require('../services/search-index/watcher'); +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); +}; + +const registerSearchIndexIpc = () => { + ipcMain.handle('renderer:search-index-query', searchIndex); + ipcMain.handle('renderer:search-index-warm', warmSearchIndex); +}; + +module.exports = { registerSearchIndexIpc, searchIndex, warmSearchIndex, indexedCollections, closeAllSearchIndexWatchers }; 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..36e998c0d27 --- /dev/null +++ b/packages/bruno-electron/src/services/mount/scan.js @@ -0,0 +1,126 @@ +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, parse +// every file across the worker pool, and build a single tree. Request nodes carry only what the +// sidebar and the searches read; collection and folder roots are parsed in full because folder +// names and `seq` drive sidebar ordering and requests inherit from them. +// +// 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, + // Requests become deferred nodes, so only their tree fields are needed. Collection and + // folder roots are parsed in full — the sidebar orders folders by their `seq` and + // requests inherit headers/auth/scripts from them. + treeFieldsOnly: true + })); + } 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, deferRequests: true }); + 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..4c20d87b593 100644 --- a/packages/bruno-electron/src/services/mount/tree-builder.js +++ b/packages/bruno-electron/src/services/mount/tree-builder.js @@ -142,6 +142,44 @@ const buildRequestNode = (absolutePath, basename, entry, uidOverrides, uidFor) = }; }; +// Carries only what the sidebar and the searches read. `method`/`url` stay nested under `request` +// so the sidebar's method badge and global search keep reading the same paths, and everything +// heavy — headers, body, auth, scripts, settings, example bodies, raw text — is left on disk until +// the request is opened. +// +// `deferred` marks "not parsed yet, on purpose". It is deliberately not `partial`, which means +// "could not be fully parsed" and renders a warning triangle in the sidebar. +const buildDeferredRequestNode = (absolutePath, basename, entry, uidOverrides, uidFor) => { + const uid = uidOverrides?.get(absolutePath) || uidFor(absolutePath); + const data = entry.data || {}; + return { + uid, + name: data.name || stripExt(basename), + type: data.type || 'http-request', + seq: data.seq, + tags: data.tags, + request: { + method: data.request?.method, + url: data.request?.url + }, + examples: (data.examples || []).map((example, i) => ({ + uid: example.uid || uidForSeed(`${posixifyPath(absolutePath)}#example#${i}`), + name: example.name + })), + app: data.app ?? null, + // The mount path reports byteSize instead of returning the file's text; `raw` covers the + // cache path, which still carries it. + size: sizeInMB(entry.byteSize ?? (entry.raw ? Buffer.byteLength(entry.raw, 'utf8') : 0)), + filename: basename, + pathname: absolutePath, + draft: null, + deferred: true, + partial: false, + loading: false, + ...(entry.error ? { error: entry.error, partial: true, deferred: false } : {}) + }; +}; + const buildEnvironmentNode = (collectionPath, relativePath, entry, uidFor) => { const basename = path.basename(relativePath); const absolutePath = path.join(collectionPath, relativePath); @@ -159,6 +197,9 @@ const buildTree = (collectionPath, parserResults, options = {}) => { const uidOverrides = options.uidOverrides; const uidFor = options.uidFor || idForAbsolutePath; const transientEntries = options.transientEntries || []; + // Collection and folder roots are always built in full — the sidebar needs folder names and + // `seq` to order the tree, and requests inherit headers/auth/scripts from them. + const buildNode = options.deferRequests ? buildDeferredRequestNode : buildRequestNode; const tree = { pathname: collectionPath, @@ -204,7 +245,6 @@ 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( path.join(collectionPath, relativePath), path.basename(relativePath), 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..10eec825705 100644 --- a/packages/bruno-electron/src/services/pool/jobs/parse-file.js +++ b/packages/bruno-electron/src/services/pool/jobs/parse-file.js @@ -65,19 +65,28 @@ const parseContent = (content, format, type, byteSize) => { } }; -const parseFile = ({ collectionPath, relativePath, format, type }) => { +// `treeFieldsOnly` is the mount path for requests: it parses just the fields a tree node needs and +// returns `byteSize` in place of `raw`, so neither the full parse nor the file's text is paid for. +// Returning `raw` for every file would push the whole collection's bytes back across the worker +// boundary; the cache path still asks for it because it stages `raw`. +const parseFile = ({ collectionPath, relativePath, format, type, treeFieldsOnly = false }) => { const absolutePath = path.join(collectionPath, relativePath); 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 treeFieldsRequest = treeFieldsOnly && type === 'request'; + const hash = treeFieldsRequest ? '' : sha256(buf); + const payload = treeFieldsRequest ? { byteSize: buf.length } : { raw: content }; + try { - const data = parseContent(content, format, type, buf.length); - return { relativePath, mtime, hash, data, format, type, raw: content }; + const data = treeFieldsRequest + ? filestore.parseRequestTreeFields(content, { format }) + : parseContent(content, format, type, buf.length); + 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/index.js b/packages/bruno-electron/src/services/search-index/index.js new file mode 100644 index 00000000000..8e4345950e5 --- /dev/null +++ b/packages/bruno-electron/src/services/search-index/index.js @@ -0,0 +1,128 @@ +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); + ` + } +]; + +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 ${termClauses} AND collection_path IN (${collectionPlaceholders}) + ORDER BY name COLLATE NOCASE + LIMIT ?`, + ...params + ); + } + + #upsert(root, entry) { + const { relativePath, absolutePath, name, method, url, collectionUid, collectionName, mtime, hash } = entry; + const id = idForAbsolutePath(absolutePath); + const dirname = path.dirname(relativePath); + const folderPath = dirname === '.' ? '' : dirname; + + this.#db.run( + `INSERT INTO search_index_items + (id, collection_path, relative_path, name, method, url, request_path, folder_path, collection_uid, collection_name, mtime, hash, 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, + updated_at = excluded.updated_at`, + id, root, relativePath, name, method || null, url || null, + absolutePath, folderPath, collectionUid, collectionName, mtime, hash + ); + } + + #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/indexer.js b/packages/bruno-electron/src/services/search-index/indexer.js new file mode 100644 index 00000000000..79d352da416 --- /dev/null +++ b/packages/bruno-electron/src/services/search-index/indexer.js @@ -0,0 +1,60 @@ +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) => { + const cls = defaultClassify(entry.relativePath); + let name = path.basename(entry.relativePath); + let method = null; + let url = null; + + try { + const result = await getPool().run(JobType.ParseFile, { + collectionPath: root, + relativePath: entry.relativePath, + format: cls.format, + type: cls.type, + treeFieldsOnly: true + }); + 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, + name, + method, + url, + 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 toParse = [...added, ...updated].filter((entry) => defaultClassify(entry.relativePath)?.type === 'request'); + const parsed = await Promise.all(toParse.map((entry) => parseForIndex(root, entry))); + + const upsert = parsed.map((entry) => ({ ...entry, collectionUid, collectionName })); + const removeIds = removed + .filter((entry) => defaultClassify(entry.relativePath)?.type === 'request') + .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/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/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index eee11143f0b..9621f8aea0d 100644 --- a/packages/bruno-electron/src/utils/collection.js +++ b/packages/bruno-electron/src/utils/collection.js @@ -2,11 +2,11 @@ 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 { posixifyPath, getRequestFormat } = require('./filesystem'); const os = require('os'); const { preferencesUtil } = require('../store/preferences'); const path = require('path'); -const { DEFAULT_COLLECTION_FORMAT } = require('@usebruno/filestore'); +const { parseRequestViaWorker } = require('@usebruno/filestore'); const { parseValueByDataType } = require('@usebruno/common/utils'); const { GRPC_SCRIPT_KEYS } = require('@usebruno/common'); @@ -566,67 +566,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-'); @@ -682,6 +621,34 @@ const findItemInCollectionByPathname = (collection, pathname) => { return findItemByPathname(flattenedItems, pathname); }; +/** + * A mounted collection carries requests as deferred nodes — tree metadata plus method and url, but + * no headers, body, auth, scripts, assertions or tests. The renderer fills a request in when the + * user opens it, so anything here that is about to *execute* or *serialize* a request has to read + * the file itself rather than trust what the renderer sent. Serializing a deferred node throws on + * its missing `request.body`, except in `renderer:clone-folder`, which stringifies the node + * directly and so writes a valid but gutted file. + * + * Read and parsed off the main thread: the collection runner calls this once per request, so doing + * it synchronously stalls the main process between every request in a run. The file is re-read at + * the moment it is needed rather than batched up front, so an edit part-way through a run is picked + * up by the requests that follow it. + * + * A deferred item cannot have unsaved changes — editing one requires opening it, which loads it in + * full and clears the flag — so there is no draft to preserve here. + */ +const resolveDeferredItem = async (item) => { + if (!item?.deferred || !item.pathname) return item; + + const content = await fs.promises.readFile(item.pathname, 'utf8'); + const data = await parseRequestViaWorker(content, { format: getRequestFormat(item.pathname) }); + hydrateRequestWithUuid(data, item.pathname); + + // uid is derived from the pathname on both sides, but keep the tree node's copy authoritative so + // response and timeline events keep routing to the row the user is looking at. + return { ...item, ...data, uid: item.uid, deferred: false }; +}; + const replaceTabsWithSpaces = (str, numSpaces = 2) => { if (!str || !str.length || !isString(str)) { return ''; @@ -991,10 +958,10 @@ module.exports = { findItemInCollection, findItemByPathname, findItemInCollectionByPathname, + resolveDeferredItem, 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 }; diff --git a/packages/bruno-filestore/src/formats/bru/index.ts b/packages/bruno-filestore/src/formats/bru/index.ts index 190534b52f5..2e0d27c6860 100644 --- a/packages/bruno-filestore/src/formats/bru/index.ts +++ b/packages/bruno-filestore/src/formats/bru/index.ts @@ -1,6 +1,7 @@ import * as _ from 'lodash'; import { bruToJsonV2, + bruToTreeFields, jsonToBruV2, bruToEnvJsonV2, envJsonToBruV2, @@ -9,6 +10,9 @@ import { } from '@usebruno/lang'; import { getOauth2AdditionalParameters } from './utils/oauth2-additional-params'; +// Fields a sidebar/search tree node needs, read without running the grammar. See bruToTreeFields. +export const parseBruRequestTreeFields = (data: string): any => bruToTreeFields(data); + export const parseBruRequest = (data: string | any, parsed: boolean = false): any => { try { const json = parsed ? data : bruToJsonV2(data); diff --git a/packages/bruno-filestore/src/index.ts b/packages/bruno-filestore/src/index.ts index fa0486e6dc9..60ea9fa613c 100644 --- a/packages/bruno-filestore/src/index.ts +++ b/packages/bruno-filestore/src/index.ts @@ -2,6 +2,7 @@ import type { BrunoCollection, BrunoItem, BrunoEnvironment } from '@usebruno/sch import { parseBruRequest, + parseBruRequestTreeFields, parseBruCollection, parseBruEnvironment, stringifyBruRequest, @@ -41,6 +42,21 @@ export const parseRequest = (content: string, options: ParseOptions = { format: throw new Error(`Unsupported format: ${options.format}`); }; +/** + * Parses only the fields a sidebar/search tree node needs. For `.bru` this skips the ohm grammar + * (~1 MB/s) in favour of a block scanner, which is what makes mounting a large `.bru` collection + * viable. `.yml` is already fast enough that it uses the real parser and simply returns more than + * asked for; callers keep only the tree fields either way. + */ +export const parseRequestTreeFields = (content: string, options: ParseOptions = { format: DEFAULT_COLLECTION_FORMAT }): any => { + if (options.format === 'bru') { + return parseBruRequestTreeFields(content); + } else if (options.format === 'yml') { + return parseYmlItem(content); + } + throw new Error(`Unsupported format: ${options.format}`); +}; + export const parseRequestAndRedactBody = (content: string, options: ParseOptions = { format: 'bru' }): any => { if (options.format === 'bru') { return bruRequestParseAndRedactBodyData(content); diff --git a/packages/bruno-filestore/src/types/bruno-lang.d.ts b/packages/bruno-filestore/src/types/bruno-lang.d.ts index cf9e9cdc880..958c7b6ea20 100644 --- a/packages/bruno-filestore/src/types/bruno-lang.d.ts +++ b/packages/bruno-filestore/src/types/bruno-lang.d.ts @@ -1,5 +1,6 @@ declare module '@usebruno/lang' { export function bruToJsonV2(bruContent: string): any; + export function bruToTreeFields(bruContent: string): any; export function jsonToBruV2(jsonData: any): string; export function bruToEnvJsonV2(bruContent: string): any; export function envJsonToBruV2(jsonData: any): string; diff --git a/packages/bruno-lang/src/index.js b/packages/bruno-lang/src/index.js index 4a8eafab2c6..2a66eeb9315 100644 --- a/packages/bruno-lang/src/index.js +++ b/packages/bruno-lang/src/index.js @@ -1,4 +1,5 @@ const bruToJsonV2 = require('../v2/src/bruToJson'); +const bruToTreeFields = require('../v2/src/bruToTreeFields'); const jsonToBruV2 = require('../v2/src/jsonToBru'); const bruToEnvJsonV2 = require('../v2/src/envToJson'); const envJsonToBruV2 = require('../v2/src/jsonToEnv'); @@ -13,6 +14,7 @@ const { outdentString } = require('../v2/src/utils'); module.exports = { bruToJsonV2, + bruToTreeFields, jsonToBruV2, bruToEnvJsonV2, envJsonToBruV2, diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index a230288c5b4..2261f73af1b 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -40,7 +40,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); * ] * */ -const grammar = ohm.grammar(`Bru { +const GRAMMAR_SOURCE = `Bru { BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | app | settings | docs | example)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws @@ -195,7 +195,7 @@ const grammar = ohm.grammar(`Bru { scriptaftercallend = "script:grpc:after-call-end" st* "{" nl* textblock tagend tests = "tests" st* "{" nl* textblock tagend docs = "docs" st* "{" nl* textblock tagend -}`); +}`; const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTypes = false) => { if (!pairList.length) { @@ -376,7 +376,7 @@ const parseExampleContent = (content) => { } }; -const sem = grammar.createSemantics().addAttribute('ast', { +const SEMANTIC_ACTIONS = { BruFile(tags) { if (!tags || !tags.ast || !tags.ast.length) { return {}; @@ -1302,9 +1302,24 @@ const sem = grammar.createSemantics().addAttribute('ast', { examplecontent(chars) { return outdentString(chars.sourceString); } -}); +}; + +// ohm builds the grammar and its semantics eagerly, and both are large: constructing them costs +// around 78MB of heap across this package's three grammars, paid at import even by a consumer that +// never parses a `.bru` file. The mount path is exactly that consumer — it scans tree fields — and +// each parser worker is a thread whose heap counts against the main process. Built on first parse +// instead, and memoised, so the cost lands only where a real parse happens. +let compiled = null; +const compileGrammar = () => { + if (!compiled) { + const grammar = ohm.grammar(GRAMMAR_SOURCE); + compiled = { grammar, sem: grammar.createSemantics().addAttribute('ast', SEMANTIC_ACTIONS) }; + } + return compiled; +}; const parser = (input) => { + const { grammar, sem } = compileGrammar(); const match = grammar.match(input); if (match.succeeded()) { diff --git a/packages/bruno-lang/v2/src/bruToTreeFields.js b/packages/bruno-lang/v2/src/bruToTreeFields.js new file mode 100644 index 00000000000..7867f85293d --- /dev/null +++ b/packages/bruno-lang/v2/src/bruToTreeFields.js @@ -0,0 +1,184 @@ +/** + * Extracts just the fields a sidebar/search tree node needs from a .bru file, without running the + * ohm grammar. The full grammar sustains ~1 MB/s, which makes mounting a large collection slow; + * this reads the same files at ~4.5 GB/s. + * + * It is a deliberate subset, not a parser: the result is only ever used for tree nodes, and opening + * a request replaces it with `bruToJsonV2` output. Field shapes therefore mirror `bruToJsonV2` so + * the two are interchangeable for these fields. + * + * Correctness rests on the grammar's own block delimiter, `tagend = nl "}"` — a block ends at a + * newline followed by `}`, not by brace counting. So this jumps from a block's header line to the + * next `\n}` and never reads inside a block, which is why braces in bodies and scripts cannot + * confuse it. Block contents are written indented, so a `}` belonging to the content never sits at + * column 0; where one did, the grammar would end the block there too. + */ + +// `get`/`post`/... name the method in the block header; `http`/`grpc`/`ws` carry it as a `method:` +// pair instead (grammar: `httpcustom = "http" dictionary`, `grpc = "grpc" dictionary`, `ws = ...`). +const METHOD_HEADER_BLOCKS = new Set(['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'connect', 'trace']); +const METHOD_PAIR_BLOCKS = new Set(['http', 'grpc', 'ws']); + +const TYPE_MAP = { + http: 'http-request', + graphql: 'graphql-request', + grpc: 'grpc-request', + ws: 'ws-request' +}; + +const MULTILINE_DELIMITER = '\'\'\''; + +// Keys may be quoted, and prefixed with `~` when disabled (grammar: `quoted_key`, `disable_char`). +const normalizeKey = (rawKey) => rawKey.trim().replace(/^~/, '').replace(/^"(.*)"$/, '$1'); + +// Finds where a block that starts on `headerLineEnd`'s line ends. Mirrors `tagend = nl "}"`. +const findBlockEnd = (content, headerLineEnd) => { + const end = content.indexOf('\n}', headerLineEnd); + return end === -1 ? content.length : end; +}; + +/** + * Reads `key: value` pairs from one block body, keeping only the wanted keys. Handles the two value + * forms that can span lines: `'''…'''` text blocks and `[ … ]` lists. + */ +const readPairs = (body, wantedKeys) => { + const pairs = {}; + let cursor = 0; + + while (cursor < body.length) { + let lineEnd = body.indexOf('\n', cursor); + if (lineEnd === -1) lineEnd = body.length; + + const line = body.slice(cursor, lineEnd); + const colon = line.indexOf(':'); + + if (colon !== -1) { + const key = normalizeKey(line.slice(0, colon)); + if (wantedKeys.has(key)) { + const rest = line.slice(colon + 1); + const trimmed = rest.trim(); + + if (trimmed.startsWith(MULTILINE_DELIMITER)) { + const valueStart = cursor + colon + 1 + rest.indexOf(MULTILINE_DELIMITER) + MULTILINE_DELIMITER.length; + const close = body.indexOf(MULTILINE_DELIMITER, valueStart); + if (close === -1) { + pairs[key] = trimmed; + } else { + pairs[key] = body.slice(valueStart, close); + lineEnd = body.indexOf('\n', close); + if (lineEnd === -1) lineEnd = body.length; + } + } else if (trimmed === '[' || trimmed.startsWith('[')) { + // grammar: `list = st* "[" nl+ listitems? st* nl+ st* "]"` — items sit on their own lines + const close = body.indexOf(']', cursor + colon); + if (close === -1) { + pairs[key] = trimmed; + } else { + pairs[key] = body.slice(cursor + colon + 1, close).replace(/^\s*\[/, ''); + lineEnd = body.indexOf('\n', close); + if (lineEnd === -1) lineEnd = body.length; + } + } else { + pairs[key] = trimmed; + } + } + } + + cursor = lineEnd + 1; + } + + return pairs; +}; + +const META_KEYS = new Set(['name', 'type', 'seq', 'tags']); +const URL_KEYS = new Set(['url']); +const URL_AND_METHOD_KEYS = new Set(['url', 'method']); + +/** + * An example's body is opaque to the grammar (`examplecontent = (~tagend any)*`) and holds nested + * `request: {` / `response: {` blocks whose contents can contain a literal `"name":` — inside a + * JSON body, for instance. The example's own scalar pairs always precede those nested blocks, so + * reading stops at the first one rather than scanning the whole body. + */ +const readExampleName = (body) => { + let cursor = 0; + + while (cursor < body.length) { + let lineEnd = body.indexOf('\n', cursor); + if (lineEnd === -1) lineEnd = body.length; + + const line = body.slice(cursor, lineEnd).trim(); + if (line.endsWith('{')) return undefined; + + const colon = line.indexOf(':'); + if (colon !== -1 && normalizeKey(line.slice(0, colon)) === 'name') { + return line.slice(colon + 1).trim(); + } + + cursor = lineEnd + 1; + } + + return undefined; +}; + +const toList = (value) => + value + .split(/[,\n]/) + .map((entry) => entry.trim()) + .filter(Boolean); + +const bruToTreeFields = (content) => { + if (typeof content !== 'string') return {}; + + const treeFields = { request: {} }; + const examples = []; + let cursor = 0; + + while (cursor < content.length) { + let lineEnd = content.indexOf('\n', cursor); + if (lineEnd === -1) lineEnd = content.length; + + const brace = content.slice(cursor, lineEnd).indexOf('{'); + if (brace === -1) { + cursor = lineEnd + 1; + continue; + } + + const header = content.slice(cursor, cursor + brace).trim(); + const blockEnd = findBlockEnd(content, lineEnd); + const body = content.slice(lineEnd + 1, blockEnd); + + if (header === 'meta') { + const pairs = readPairs(body, META_KEYS); + if (pairs.name !== undefined) treeFields.name = pairs.name; + if (pairs.type !== undefined) treeFields.type = TYPE_MAP[pairs.type] || 'http-request'; + if (pairs.seq !== undefined) { + const seq = Number(pairs.seq); + treeFields.seq = Number.isNaN(seq) ? pairs.seq : seq; + } + if (pairs.tags !== undefined) treeFields.tags = toList(pairs.tags); + } else if (METHOD_HEADER_BLOCKS.has(header)) { + treeFields.request.method = header.toUpperCase(); + const pairs = readPairs(body, URL_KEYS); + if (pairs.url !== undefined) treeFields.request.url = pairs.url; + } else if (METHOD_PAIR_BLOCKS.has(header)) { + const pairs = readPairs(body, URL_AND_METHOD_KEYS); + // A grpc method is a case-sensitive path (`/pkg.Service/GetUser`), and `parseBruRequest` + // passes it through untouched; only http methods are upper-cased. + if (pairs.method !== undefined) { + treeFields.request.method = header === 'grpc' ? pairs.method : pairs.method.toUpperCase(); + } + if (pairs.url !== undefined) treeFields.request.url = pairs.url; + } else if (header === 'example') { + // The sidebar lists example names; their request/response bodies stay on disk. + examples.push({ name: readExampleName(body) }); + } + + cursor = blockEnd + 2; + } + + if (examples.length > 0) treeFields.examples = examples; + return treeFields; +}; + +module.exports = bruToTreeFields; diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index 8ae6a66deda..750f69a1936 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -13,7 +13,7 @@ const { // the key is hidden and not added into the json automatically const ANNOTATIONS_KEY = Symbol('annotations'); -const grammar = ohm.grammar(`Bru { +const GRAMMAR_SOURCE = `Bru { BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs @@ -110,7 +110,7 @@ const grammar = ohm.grammar(`Bru { scriptres = "script:post-response" st* "{" nl* textblock tagend tests = "tests" st* "{" nl* textblock tagend docs = "docs" st* "{" nl* textblock tagend -}`); +}`; const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTypes = false) => { if (!pairList.length) { @@ -158,7 +158,7 @@ const mapPairListToKeyValPair = (pairList = []) => { return _.merge({}, ...pairList[0]); }; -const sem = grammar.createSemantics().addAttribute('ast', { +const SEMANTIC_ACTIONS = { BruFile(tags) { if (!tags || !tags.ast || !tags.ast.length) { return {}; @@ -715,9 +715,24 @@ const sem = grammar.createSemantics().addAttribute('ast', { docs: outdentString(textblock.sourceString) }; } -}); +}; + +// ohm builds the grammar and its semantics eagerly, and both are large: constructing them costs +// around 78MB of heap across this package's three grammars, paid at import even by a consumer that +// never parses a `.bru` file. The mount path is exactly that consumer — it scans tree fields — and +// each parser worker is a thread whose heap counts against the main process. Built on first parse +// instead, and memoised, so the cost lands only where a real parse happens. +let compiled = null; +const compileGrammar = () => { + if (!compiled) { + const grammar = ohm.grammar(GRAMMAR_SOURCE); + compiled = { grammar, sem: grammar.createSemantics().addAttribute('ast', SEMANTIC_ACTIONS) }; + } + return compiled; +}; const parser = (input) => { + const { grammar, sem } = compileGrammar(); const match = grammar.match(input); if (match.succeeded()) { diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js index 9e3ad37cb3b..d85225e066d 100644 --- a/packages/bruno-lang/v2/src/envToJson.js +++ b/packages/bruno-lang/v2/src/envToJson.js @@ -22,7 +22,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); // ''' // } const indentLevel = 4; -const grammar = ohm.grammar(`Bru { +const GRAMMAR_SOURCE = `Bru { BruEnvFile = (vars | secretvars | externalsecrets | extends | color)* nl = "\\r"? "\\n" @@ -92,7 +92,7 @@ const grammar = ohm.grammar(`Bru { unquotedextendsvaluechar = ~(nl | "[" | "]" | ",") any extendsname = ":" singlelinechar* color = "color:" singlelinechar* -}`); +}`; const mapPairListToKeyValPairs = (pairList = []) => { if (!pairList.length) { @@ -183,7 +183,7 @@ const concatArrays = (objValue, srcValue) => { } }; -const sem = grammar.createSemantics().addAttribute('ast', { +const SEMANTIC_ACTIONS = { BruEnvFile(tags) { if (!tags || !tags.ast || !tags.ast.length) { return { @@ -373,9 +373,24 @@ const sem = grammar.createSemantics().addAttribute('ast', { color: anystring.sourceString.trim() }; } -}); +}; + +// ohm builds the grammar and its semantics eagerly, and both are large: constructing them costs +// around 78MB of heap across this package's three grammars, paid at import even by a consumer that +// never parses a `.bru` file. The mount path is exactly that consumer — it scans tree fields — and +// each parser worker is a thread whose heap counts against the main process. Built on first parse +// instead, and memoised, so the cost lands only where a real parse happens. +let compiled = null; +const compileGrammar = () => { + if (!compiled) { + const grammar = ohm.grammar(GRAMMAR_SOURCE); + compiled = { grammar, sem: grammar.createSemantics().addAttribute('ast', SEMANTIC_ACTIONS) }; + } + return compiled; +}; const parser = (input) => { + const { grammar, sem } = compileGrammar(); const match = grammar.match(input); if (match.succeeded()) { diff --git a/packages/bruno-lang/v2/src/example/bruToJson.js b/packages/bruno-lang/v2/src/example/bruToJson.js index 4f0cc841144..728fb79f8ed 100644 --- a/packages/bruno-lang/v2/src/example/bruToJson.js +++ b/packages/bruno-lang/v2/src/example/bruToJson.js @@ -13,7 +13,7 @@ const astBaseAttribute = require('../common/attributes'); * Supports all body types from request side but response body stays as simple text. */ -const exampleGrammar = ohm.grammar(`Example { +const GRAMMAR_SOURCE = `Example { ExampleFile = (name | description | request | response)* nl = "\\r"? "\\n" @@ -65,7 +65,7 @@ const exampleGrammar = ohm.grammar(`Example { // Response block response = "response" st* ":" st* "{" nl* responsecontent nl* "}" nl* responsecontent = (~tagend any)+ -}`); +}`; const astExampleAttribute = { ExampleFile(tags) { @@ -127,14 +127,28 @@ const astExampleAttribute = { } }; -const grammarSemantics = exampleGrammar.createSemantics(); -grammarSemantics.addAttribute('ast', { ...astBaseAttribute, ...astExampleAttribute }); +const ATTRIBUTES = { ...astBaseAttribute, ...astExampleAttribute }; + +// Built on first parse rather than at import: ohm constructs the grammar and its semantics +// eagerly, and a consumer that never parses an example — the mount path, which only scans tree +// fields — would otherwise pay for them anyway. See the same pattern in v2/src/bruToJson.js. +let compiled = null; +const compileGrammar = () => { + if (!compiled) { + const grammar = ohm.grammar(GRAMMAR_SOURCE); + const semantics = grammar.createSemantics(); + semantics.addAttribute('ast', ATTRIBUTES); + compiled = { grammar, semantics }; + } + return compiled; +}; const parseExample = (input) => { - const match = exampleGrammar.match(input); + const { grammar, semantics } = compileGrammar(); + const match = grammar.match(input); if (match.succeeded()) { - let ast = grammarSemantics(match).ast; + let ast = semantics(match).ast; return ast; } else { console.log('match failed', match); diff --git a/packages/bruno-lang/v2/src/example/request/bruToJson.js b/packages/bruno-lang/v2/src/example/request/bruToJson.js index 162e5f98daf..655f51658ab 100644 --- a/packages/bruno-lang/v2/src/example/request/bruToJson.js +++ b/packages/bruno-lang/v2/src/example/request/bruToJson.js @@ -16,7 +16,7 @@ const { * Handles parsing of request blocks within example files. * Supports all body types: json, text, xml, sparql, graphql, form-urlencoded, multipart-form, file */ -const requestGrammar = ohm.grammar(`Request { +const GRAMMAR_SOURCE = `Request { RequestFile = requestcontent* nl = "\\r"? "\\n" @@ -76,7 +76,7 @@ const requestGrammar = ohm.grammar(`Request { bodyformurlencoded = "body:form-urlencoded" st* ":" st* dictionary bodymultipart = "body:multipart-form" st* ":" st* dictionary bodyfile = "body:file" st* ":" st* dictionary -}`); +}`; const astRequestAttribute = { RequestFile(tags) { @@ -204,14 +204,28 @@ const astRequestAttribute = { } }; -const grammarSemantics = requestGrammar.createSemantics(); -grammarSemantics.addAttribute('ast', { ...astBaseAttribute, ...astRequestAttribute }); +const ATTRIBUTES = { ...astBaseAttribute, ...astRequestAttribute }; + +// Built on first parse rather than at import: ohm constructs the grammar and its semantics +// eagerly, and a consumer that never parses an example — the mount path, which only scans tree +// fields — would otherwise pay for them anyway. See the same pattern in v2/src/bruToJson.js. +let compiled = null; +const compileGrammar = () => { + if (!compiled) { + const grammar = ohm.grammar(GRAMMAR_SOURCE); + const semantics = grammar.createSemantics(); + semantics.addAttribute('ast', ATTRIBUTES); + compiled = { grammar, semantics }; + } + return compiled; +}; const parseRequest = (input) => { - const match = requestGrammar.match(input); + const { grammar, semantics } = compileGrammar(); + const match = grammar.match(input); if (match.succeeded()) { - let ast = grammarSemantics(match).ast; + let ast = semantics(match).ast; return ast; } else { console.log('match failed', match); diff --git a/packages/bruno-lang/v2/src/example/response/bruToJson.js b/packages/bruno-lang/v2/src/example/response/bruToJson.js index 2a7390144b7..2baa53314ea 100644 --- a/packages/bruno-lang/v2/src/example/response/bruToJson.js +++ b/packages/bruno-lang/v2/src/example/response/bruToJson.js @@ -10,7 +10,7 @@ const { mapPairListToKeyValPairs } = require('../../common/semantic-utils'); * Handles parsing of response blocks within example files. * Supports headers, status, and body parsing. */ -const responseGrammar = ohm.grammar(`Response { +const GRAMMAR_SOURCE = `Response { ResponseFile = responsecontent* nl = "\\r"? "\\n" @@ -58,7 +58,7 @@ const responseGrammar = ohm.grammar(`Response { responsebodyfields = (responsebodytype | responsebodycontentvalue)* responsebodytype = st* "type" st* ":" st* valuechar* nl* responsebodycontentvalue = st* "content" st* ":" st* multilinetextblock -}`); +}`; const astResponseAttribute = { ResponseFile(tags) { @@ -120,14 +120,28 @@ const astResponseAttribute = { } }; -const grammarSemantics = responseGrammar.createSemantics(); -grammarSemantics.addAttribute('ast', { ...astBaseAttribute, ...astResponseAttribute }); +const ATTRIBUTES = { ...astBaseAttribute, ...astResponseAttribute }; + +// Built on first parse rather than at import: ohm constructs the grammar and its semantics +// eagerly, and a consumer that never parses an example — the mount path, which only scans tree +// fields — would otherwise pay for them anyway. See the same pattern in v2/src/bruToJson.js. +let compiled = null; +const compileGrammar = () => { + if (!compiled) { + const grammar = ohm.grammar(GRAMMAR_SOURCE); + const semantics = grammar.createSemantics(); + semantics.addAttribute('ast', ATTRIBUTES); + compiled = { grammar, semantics }; + } + return compiled; +}; const parseResponse = (input) => { - const match = responseGrammar.match(input); + const { grammar, semantics } = compileGrammar(); + const match = grammar.match(input); if (match.succeeded()) { - let ast = grammarSemantics(match).ast; + let ast = semantics(match).ast; return ast; } else { console.log('match failed', match); From f67453d2952a15fa109cc5ff54c01350a649f8e9 Mon Sep 17 00:00:00 2001 From: mr-weed Date: Sun, 20 Sep 2026 19:06:10 +0530 Subject: [PATCH 2/4] revert: deferred parsing --- .../src/components/GlobalSearchModal/index.js | 29 +-- .../RequestTabPanel/DeferredRequest/index.js | 25 --- .../src/components/RequestTabPanel/index.js | 10 - .../src/components/ShareCollection/index.js | 7 +- .../CollectionItemRow/index.jsx | 13 +- .../ExportCollection/ExportToPostman/index.js | 5 +- .../Collection/GenerateDocumentation/index.js | 11 +- .../GenerateDocumentation/index.spec.js | 13 +- .../ReduxStore/slices/collections/actions.js | 120 +----------- .../ReduxStore/slices/collections/index.js | 133 +------------ packages/bruno-electron/src/ipc/collection.js | 75 +------ .../bruno-electron/src/ipc/network/index.js | 11 +- .../bruno-electron/src/services/mount/scan.js | 14 +- .../src/services/mount/tree-builder.js | 43 +--- .../src/services/pool/jobs/parse-file.js | 15 +- .../src/services/search-index/indexer.js | 3 +- .../src/services/search-index/indexer.spec.js | 8 - .../bruno-electron/src/utils/collection.js | 33 +--- .../bruno-filestore/src/formats/bru/index.ts | 4 - packages/bruno-filestore/src/index.ts | 16 -- .../bruno-filestore/src/types/bruno-lang.d.ts | 1 - packages/bruno-lang/src/index.js | 2 - packages/bruno-lang/v2/src/bruToTreeFields.js | 184 ------------------ 23 files changed, 60 insertions(+), 715 deletions(-) delete mode 100644 packages/bruno-app/src/components/RequestTabPanel/DeferredRequest/index.js delete mode 100644 packages/bruno-lang/v2/src/bruToTreeFields.js diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.js b/packages/bruno-app/src/components/GlobalSearchModal/index.js index a8d62f1d78c..995c6680c49 100644 --- a/packages/bruno-app/src/components/GlobalSearchModal/index.js +++ b/packages/bruno-app/src/components/GlobalSearchModal/index.js @@ -167,8 +167,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => { type: 'http-request', pathname: row.pathname, name: row.name, - request: { method: row.method, url: row.url }, - deferred: true + request: { method: row.method, url: row.url } }, name: row.name, path: [row.collectionName, row.folderPath, row.name].filter(Boolean).join('/'), @@ -247,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 @@ -299,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'); @@ -313,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, @@ -331,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, diff --git a/packages/bruno-app/src/components/RequestTabPanel/DeferredRequest/index.js b/packages/bruno-app/src/components/RequestTabPanel/DeferredRequest/index.js deleted file mode 100644 index d7af9331fa8..00000000000 --- a/packages/bruno-app/src/components/RequestTabPanel/DeferredRequest/index.js +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { loadRequest } from 'providers/ReduxStore/slices/collections/actions'; -import RequestIsLoading from '../RequestIsLoading'; - -// Requests are mounted as deferred tree nodes carrying only what the sidebar and the searches -// read. Opening one parses it in full; the parsed item arrives over main:collection-tree-updated, -// which clears `deferred` and replaces this view. -// -// A parse failure is surfaced as data — the main process sends the item back with `error` and -// `partial` set — so the rejected invoke needs no separate handling here. -const DeferredRequest = ({ item, collection }) => { - const dispatch = useDispatch(); - - useEffect(() => { - dispatch(loadRequest({ - collectionUid: collection?.uid, - pathname: item?.pathname - })).catch(() => {}); - }, [dispatch, collection?.uid, item?.pathname]); - - return ; -}; - -export default DeferredRequest; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 33350ed07a9..72f7ddae1f9 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -26,7 +26,6 @@ import FolderSettings from 'components/FolderSettings'; import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index'; import CollectionOverview from 'components/CollectionSettings/Overview'; import RequestNotLoaded from './RequestNotLoaded'; -import DeferredRequest from './DeferredRequest'; import RequestIsLoading from './RequestIsLoading'; import RequestTabPanelLoading from './RequestTabPanelLoading'; import FolderNotFound from './FolderNotFound'; @@ -501,11 +500,6 @@ const RequestTabPanel = () => { item = findItemInCollectionByPathname(collection, focusedTab.pathname); } - // A deferred item carries example names only, not their request/response bodies. - if (item?.deferred) { - return ; - } - let example = null; if (item?.examples) { example = item.examples.find((ex) => ex.uid === focusedTab.uid); @@ -598,10 +592,6 @@ const RequestTabPanel = () => { : ; } - if (item.deferred) { - return ; - } - if (item.partial) { return ; } diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 79091699274..6398fb3d71c 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -1,4 +1,5 @@ import React, { useState, useMemo } from 'react'; +import { cloneDeep } from 'lodash'; import Modal from 'components/Modal'; import Button from 'ui/Button'; import { IconCheck, IconAlertTriangle, IconFileExport } from '@tabler/icons'; @@ -6,8 +7,7 @@ import StyledWrapper from './StyledWrapper'; import ExportToPostman from 'components/Sidebar/Collections/Collection/ExportCollection/ExportToPostman'; import exportOpenCollection from 'utils/exporters/opencollection'; import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index'; -import { resolveDeferredCollection } from 'providers/ReduxStore/slices/collections/actions'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { findCollectionByUid, areItemsLoading } from 'utils/collections/index'; import toast from 'react-hot-toast'; @@ -18,7 +18,6 @@ const EXPORT_FORMATS = { }; const ShareCollection = ({ onClose, collectionUid }) => { - const dispatch = useDispatch(); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const isCollectionLoading = areItemsLoading(collection); const [selectedFormat, setSelectedFormat] = useState(EXPORT_FORMATS.ZIP); @@ -60,7 +59,7 @@ const ShareCollection = ({ onClose, collectionUid }) => { }; const handleExportYaml = async () => { - const collectionCopy = await dispatch(resolveDeferredCollection(collection.uid)); + const collectionCopy = cloneDeep(collection); exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); }; 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 b111225729e..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 @@ -23,7 +23,7 @@ import { } from '@tabler/icons'; import { useSelector, useDispatch, useStore } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; -import { handleMultipleCollectionItemsDrop, sendRequest, showInFolder, pasteItem, saveRequest, cloneItem, resolveDeferredItem } from 'providers/ReduxStore/slices/collections/actions'; +import { handleMultipleCollectionItemsDrop, sendRequest, showInFolder, pasteItem, saveRequest, cloneItem } from 'providers/ReduxStore/slices/collections/actions'; import { sanitizeName } from 'utils/common/regex'; import { formatIpcError } from 'utils/common/error'; import { toggleCollectionItem, expandItem, collapseItem, addResponseExample } from 'providers/ReduxStore/slices/collections'; @@ -640,15 +640,10 @@ const CollectionItemRow = ({ setCreateExampleModalOpen(false); }; - const handleGenerateCode = async () => { - // The snippet is built in the renderer from the item's headers, body and auth, so a deferred - // node has to be parsed in full first — unlike sending, which the main process resolves itself. - // The parse also lands in the store, so the modal below renders against the loaded item. - const resolvedItem = await dispatch(resolveDeferredItem(item, collectionUid)).catch(() => item); - + const handleGenerateCode = () => { if ( - (resolvedItem?.request?.url !== '') - || (resolvedItem?.draft?.request?.url !== undefined && resolvedItem?.draft?.request?.url !== '') + (item?.request?.url !== '') + || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '') ) { setGenerateCodeItemModalOpen(true); } else { 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 55d961c91b6..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,5 +1,6 @@ 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 { IconCaretDown } from '@tabler/icons'; @@ -8,7 +9,7 @@ import { sanitizeName, validateName, validateNameError } from 'utils/common/rege import Portal from 'components/Portal'; import Modal from 'components/Modal'; import Dropdown from 'components/Dropdown'; -import { browseDirectory, exportCollectionToPostman, resolveDeferredCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { browseDirectory, exportCollectionToPostman } from 'providers/ReduxStore/slices/collections/actions'; import { exportPostmanCollection } from 'utils/exporters/postman-collection'; import StyledWrapper from './StyledWrapper'; @@ -53,7 +54,7 @@ const ExportToPostman = ({ onClose, onExported, collection }) => { setIsExporting(true); try { - const content = exportPostmanCollection(await dispatch(resolveDeferredCollection(collection.uid)), { preserveScripts }); + const content = exportPostmanCollection(cloneDeep(collection), { preserveScripts }); await dispatch(exportCollectionToPostman(values.location, `${values.fileName.trim()}.json`, content, overwrite)); toast.success('Collection exported successfully'); onExported(); 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 0d41348555e..c3a8aa33ff0 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,6 @@ import React, { useCallback, useMemo, useState, Fragment } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { cloneDeep } from 'lodash'; import * as FileSaver from 'file-saver'; import jsyaml from 'js-yaml'; import jsesc from 'jsesc'; @@ -17,7 +18,6 @@ import useCollectionGitRemoteUrl from 'hooks/useCollectionGitRemoteUrl'; import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading, sortItemsBySidebarOrder, getCollectionItemCounts, getCollectionVersion, getUniqueTagsFromItems } from 'utils/collections/index'; import { brunoToOpenCollection } from '@usebruno/converters'; import { generateApiDocsHtml, getApiDocsFileName, filterRequestItemsByTags } from '@usebruno/common'; -import { resolveDeferredCollection } from 'providers/ReduxStore/slices/collections/actions'; const FEATURES = [ 'Standalone HTML file - no server required', @@ -40,7 +40,6 @@ const CollectionNotFound = ({ onClose }) => ( const GenerateDocumentation = ({ onClose, collectionUid }) => { const { version } = useApp(); - const dispatch = useDispatch(); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid) ); @@ -101,9 +100,9 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => { const { gitCollectionUrl, isResolved: gitUrlLoaded } = useCollectionGitRemoteUrl(collection?.pathname); const hasGitUrl = gitUrlLoaded && Boolean(gitCollectionUrl); - const handleGenerate = useCallback(async () => { + const handleGenerate = useCallback(() => { try { - const collectionCopy = await dispatch(resolveDeferredCollection(collection.uid)); + const collectionCopy = 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. @@ -134,7 +133,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => { console.error('Error generating documentation:', error); toast.error('Failed to generate documentation'); } - }, [dispatch, collection, version, onClose, currentVersion, selectedEnvUidsSet, activeTags, includeGitLink, gitCollectionUrl]); + }, [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 81ff39220a2..f503496f637 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, waitFor, within } from '@testing-library/react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; import { Provider } from 'react-redux'; import { configureStore, createSlice } from '@reduxjs/toolkit'; import { ThemeProvider } from 'styled-components'; @@ -147,14 +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', async () => { + it('generates docs with the resolved git url, the shared filename, and the format-aware version', () => { mockGitRemote = { gitCollectionUrl: 'https://github.com/org/repo.git', isResolved: true }; const { onClose } = renderModal(buildCollection({ name: 'My Collection' })); fireEvent.click(screen.getByTestId('generate-btn')); - // Generating first fills in any request the user never opened, so the handler is async. - await waitFor(() => expect(generateApiDocsHtml).toHaveBeenCalledTimes(1)); + 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'); @@ -163,7 +162,7 @@ describe('GenerateDocumentation', () => { expect(onClose).toHaveBeenCalled(); }); - it('omits the git url when the include-git-link toggle is turned off', async () => { + it('omits the git url when the include-git-link toggle is turned off', () => { mockGitRemote = { gitCollectionUrl: 'https://github.com/org/repo.git', isResolved: true }; renderModal(buildCollection()); @@ -172,7 +171,6 @@ describe('GenerateDocumentation', () => { fireEvent.click(screen.getByTestId('generate-btn')); - await waitFor(() => expect(generateApiDocsHtml).toHaveBeenCalled()); const [, options] = generateApiDocsHtml.mock.calls[0]; expect(options.gitCollectionUrl).toBeUndefined(); }); @@ -226,7 +224,7 @@ describe('GenerateDocumentation', () => { expectSummary('2 Folders', '5 requests'); }); - it('generates the docs with the same tags the counts were based on', async () => { + it('generates the docs with the same tags the counts were based on', () => { renderModal(buildTaggedCollection()); switchToTagFilter(); addTag('Include tags', 'smoke'); @@ -235,7 +233,6 @@ 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/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index a66ec9d1ddf..5a786f4bb76 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -15,11 +15,9 @@ import IpcErrorModal from 'components/Errors/IpcErrorModal/index'; import SaveFileErrorModal from 'components/Errors/SaveFileErrorModal/index'; import { findCollectionByUid, - findCollectionByItemUid, findEnvironmentInCollection, findItemInCollection, findParentItemInCollection, - flattenItems, isItemAFolder, refreshUidsInItem, isItemARequest, @@ -39,7 +37,6 @@ import brunoClipboard from 'utils/bruno-clipboard'; import { collectionAddEnvFileEvent as _collectionAddEnvFileEvent, - evictClosedItems as _evictClosedItems, createCollection as _createCollection, removeCollection as _removeCollection, selectEnvironment as _selectEnvironment, @@ -936,14 +933,7 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp return reject(new Error('Unable to locate item')); } - // A request the user never opened is a deferred node, and cloning serializes it. - // Folder children are resolved inside renderer:clone-folder as it walks them. - let item; - try { - item = await dispatch(resolveDeferredItem(treeItem, collectionUid)); - } catch (error) { - return reject(error); - } + const item = treeItem; if (isItemAFolder(item)) { const parentFolder = findParentItemInCollection(collection, item.uid) || collection; @@ -1076,13 +1066,7 @@ export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatc const newFilename = sanitizeName(newName); const filename = resolveRequestFilename(newFilename, targetCollection.format); - // The clipboard holds whatever the tree held when the item was copied, so a request the - // user never opened is still a deferred node. It is resolved against the collection it - // came from, which is not necessarily the one being pasted into. Folders take the same - // trip inside renderer:clone-folder, which resolves their children as it walks them. - const sourceCollectionUid = findCollectionByItemUid(state.collections.collections, copiedItem.uid)?.uid; - const resolvedItem = await dispatch(resolveDeferredItem(copiedItem, sourceCollectionUid)); - const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(resolvedItem)); + const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(copiedItem)); set(itemToSave, 'name', trim(newName)); set(itemToSave, 'filename', trim(filename)); @@ -3382,67 +3366,6 @@ export const loadRequestViaWorker }); }; -// Requests are mounted as deferred tree nodes carrying only what the sidebar and searches read. -// Opening one parses it in full; the parsed item arrives back over main:collection-tree-updated, -// and is also returned here for callers that need it before that lands in the store. -export const loadRequest - = ({ collectionUid, pathname }) => - () => { - const { ipcRenderer } = window; - return ipcRenderer.invoke('renderer:load-request', { collectionUid, pathname }); - }; - -/** - * A request the user has never opened is a deferred tree node: it carries name, method and url but - * none of the headers, body, auth, scripts or tests. Anything that *serializes* an item — clone, - * paste, export, documentation — has to fill it in first, or it writes an empty request. - * - * Execution paths do not need this: the main process resolves deferred items itself when it runs - * them (`resolveDeferredItem` in bruno-electron), where the file is already at hand. - */ -export const resolveDeferredItem - = (item, collectionUid) => - async (dispatch) => { - if (!item?.deferred || !item?.pathname) return item; - - const data = await dispatch(loadRequest({ collectionUid, pathname: item.pathname })); - - return { ...item, ...data, uid: item.uid, deferred: false }; - }; - -/** - * The same fill-in as `resolveDeferredItem`, for consumers that serialize a whole collection at - * once — export, documentation. Returns a copy with every deferred request parsed, in one IPC - * round trip rather than one per request. - * - * The copy is deliberately not written back to the store: nothing here is open, and keeping a - * fully parsed collection around after a one-shot export is what deferred nodes exist to avoid. - * Requests that fail to parse are left as they are, so an export reports them rather than aborting. - */ -export const resolveDeferredCollection - = (collectionUid) => - async (dispatch, getState) => { - const collection = cloneDeep(findCollectionByUid(getState().collections.collections, collectionUid)); - if (!collection) return collection; - - const deferredItems = flattenItems(collection.items).filter((item) => item.deferred && item.pathname); - if (!deferredItems.length) return collection; - - const { ipcRenderer } = window; - const loaded = await ipcRenderer.invoke('renderer:load-requests', { - pathnames: deferredItems.map((item) => item.pathname) - }); - - const dataByPathname = new Map(loaded.map(({ pathname, data }) => [pathname, data])); - deferredItems.forEach((item) => { - const data = dataByPathname.get(item.pathname); - if (!data) return; - Object.assign(item, data, { uid: item.uid, deferred: false }); - }); - - return collection; - }; - export const loadLargeRequest = ({ collectionUid, pathname }) => (dispatch, getState) => { @@ -3503,10 +3426,6 @@ export const mountCollection * 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. * - * It is affordable now and was not before: a mounted collection holds deferred tree nodes rather - * than full requests, and items are released again when their last tab closes, so mounting - * everything costs the tree and nothing else. - * * 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. @@ -3817,41 +3736,6 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => { console.error('Failed to delete transient request files:', err); } } - - dispatch(evictItemsWithNoOpenTabs({ closedTabUids: tabUids })); -}; - -/** - * Releases every item whose last tab just closed back to a deferred tree node, so the store holds - * what is open rather than everything ever opened. Reopening re-parses from disk. - * - * An item can be referenced by more than one tab — an example tab addresses its parent request by - * `pathname` rather than by uid — so a request is only released once no remaining tab points at it - * either way. The reducer applies its own safety checks (drafts, transient items, live streams). - */ -const evictItemsWithNoOpenTabs = ({ closedTabUids }) => (dispatch, getState) => { - const state = getState(); - const openTabs = state.tabs?.tabs || []; - const openTabUids = new Set(openTabs.map((tab) => tab.uid)); - const openTabPathnames = new Set(openTabs.map((tab) => tab.pathname).filter(Boolean)); - - const evictionsByCollection = {}; - each(closedTabUids, (tabUid) => { - if (openTabUids.has(tabUid)) return; - - for (const collection of state.collections.collections) { - const item = findItemInCollection(collection, tabUid); - if (!item) continue; - if (!openTabPathnames.has(item.pathname)) { - (evictionsByCollection[collection.uid] ||= []).push(tabUid); - } - break; - } - }); - - Object.entries(evictionsByCollection).forEach(([collectionUid, itemUids]) => { - dispatch(_evictClosedItems({ collectionUid, itemUids })); - }); }; /** 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 44b17d4367c..5b7b28bd9b7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -13,7 +13,6 @@ import { findEnvironmentInCollection, findItemInCollection, findItemInCollectionByPathname, - findParentItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections'; @@ -47,88 +46,6 @@ const FILE_DERIVED_REQUEST_FIELDS = [ 'isTransient' ]; -// The subset a deferred tree node can be trusted for. Everything omitted here (`request`, -// `settings`, `examples`, `raw`) only exists on an item once it has been parsed in full. -const TREE_DERIVED_REQUEST_FIELDS = [ - 'name', - 'type', - 'seq', - 'tags', - 'filename', - 'pathname', - 'size', - 'isTransient' -]; - -/** - * Everything an item keeps when it is evicted back to a deferred tree node. Mirrors - * `buildDeferredRequestNode` in bruno-electron's tree-builder — the shape mount produces — plus the - * fields the slice itself owns (`depth`, `isTransient`). Anything not listed is re-read from disk - * the next time the request is opened. - */ -const evictItemToTreeNode = (item) => { - const { uid, name, type, seq, tags, filename, pathname, size, app, depth, isTransient } = item; - - return { - uid, - name, - type, - seq, - tags, - filename, - pathname, - size, - app: app ?? null, - depth, - isTransient, - request: { method: item.request?.method, url: item.request?.url }, - examples: (item.examples || []).map((example) => ({ uid: example.uid, name: example.name })), - draft: null, - deferred: true, - partial: false, - loading: false - }; -}; - -/** - * An item is safe to evict only when nothing still depends on what is about to be dropped. - * - * `draft` is the one that would lose user data: the close flow gates on the Unsaved Changes modal, - * so a draft should already be saved or discarded by now, but eviction must never be the thing that - * discards it. A transient item has no file to re-read, and a `partial` one could not be parsed in - * the first place, so neither can be rebuilt. An in-flight request or a live stream is still writing - * into `item.response`, which closing a tab does not stop. - */ -/** - * The response an evicted item lost, read back off the collection timeline. `responseReceived` - * writes the same payload to both `item.response` and a timeline entry, so this is a lookup rather - * than a second copy. Best-effort: the user can clear the timeline, in which case the pane stays - * empty until the request is sent again. - */ -const findLastTimelineResponse = (collection, itemUid) => { - const timeline = collection.timeline; - if (!Array.isArray(timeline)) return null; - - for (let i = timeline.length - 1; i >= 0; i--) { - const entry = timeline[i]; - if (entry.type === 'request' && entry.itemUid === itemUid && entry.data?.response) { - return entry.data.response; - } - } - - return null; -}; - -const canEvictItem = (item) => - isItemARequest(item) - && !item.deferred - && !item.draft - && !item.isTransient - && !item.partial - && !item.response?.stream?.running - && item.requestState !== 'sending' - && item.requestState !== 'queued'; - const FILE_DERIVED_FOLDER_FIELDS = [ 'name', 'filename', @@ -174,13 +91,7 @@ const mergeTreeItems = (existingItems, newItems) => { return merged; } - // A deferred node carries only tree metadata, so it must not overwrite an item that has - // already been parsed in full — that would strip request/settings/examples while leaving the - // item looking loaded. - const fields = newItem.deferred && !existing.deferred - ? TREE_DERIVED_REQUEST_FIELDS - : FILE_DERIVED_REQUEST_FIELDS; - const merged = { ...existing, ...pick(newItem, fields) }; + const merged = { ...existing, ...pick(newItem, FILE_DERIVED_REQUEST_FIELDS) }; // only drop the draft if it matches what's on disk — user may still be typing const draftMatchesFile = existing.draft && areItemsTheSameExceptSeqUpdate(existing.draft, newItem); merged.draft = draftMatchesFile ? null : (existing.draft || null); @@ -3137,30 +3048,6 @@ export const collectionsSlice = createSlice({ delete collection._scriptEnvBaseline; delete collection._scriptCollVarBaseline; }, - /** - * Releases items whose last tab just closed, back to the deferred tree node mount produced. - * Without this the store keeps every request the user has ever opened fully parsed for the rest - * of the session — the resident set grows with requests-ever-opened rather than tabs-open. - * - * Reopening re-parses from disk through the same path a fresh mount uses, and the response - * pane is restored from `collection.timeline`, which is collection-level and unaffected here. - */ - evictClosedItems: (state, action) => { - const { collectionUid, itemUids } = action.payload; - const collection = findCollectionByUid(state.collections, collectionUid); - if (!collection) return; - - itemUids.forEach((itemUid) => { - const item = findItemInCollection(collection, itemUid); - if (!item || !canEvictItem(item)) return; - - const parent = findParentItemInCollection(collection, itemUid) || collection; - const index = parent.items.findIndex((i) => i.uid === itemUid); - if (index !== -1) { - parent.items[index] = evictItemToTreeNode(item); - } - }); - }, collectionAddFileEvent: (state, action) => { const file = action.payload.file; const isCollectionRoot = file.meta.collectionRoot ? true : false; @@ -3243,18 +3130,6 @@ export const collectionsSlice = createSlice({ currentItem.error = file.error; currentItem.isTransient = isTransientFile; currentItem.depth = itemDepth; - // Reopening an evicted item loses the response pane's contents, but the same response - // is still on the collection timeline, which is not evicted. Restore from there rather - // than storing a second copy of it anywhere. - if (currentItem.deferred && !currentItem.response) { - const lastResponse = findLastTimelineResponse(collection, currentItem.uid); - if (lastResponse) { - currentItem.response = lastResponse; - currentItem.requestState = 'received'; - } - } - // A watcher event always carries a full parse, so the item is no longer deferred. - currentItem.deferred = false; } else { currentSubItems.push({ uid: file.data.uid, @@ -3275,8 +3150,7 @@ export const collectionsSlice = createSlice({ size: file.size, error: file.error, isTransient: isTransientFile, - depth: itemDepth, - deferred: false + depth: itemDepth }); } } @@ -3394,7 +3268,6 @@ export const collectionsSlice = createSlice({ item.pathname = file.meta.pathname; item.raw = file.data.raw; item.size = file.size; - item.deferred = false; // Only clear draft if it matches the file content // This preserves characters typed during autosave // The raw comparison is guarded so an undefined === undefined match @@ -3417,7 +3290,6 @@ export const collectionsSlice = createSlice({ item.pathname = file.meta.pathname; item.raw = file.data.raw; item.size = file.size; - item.deferred = false; if (!item.draft || item.draft.raw === file.data.raw) { item.draft = null; } @@ -4529,7 +4401,6 @@ export const { updateCollectionPresets, updateCollectionProtobuf, collectionAddFileEvent, - evictClosedItems, collectionAddDirectoryEvent, collectionChangeFileEvent, collectionUnlinkFileEvent, diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 771f787b67f..e4e993f9f17 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -50,7 +50,6 @@ const { isWindowsOS, hasRequestExtension, getCollectionFormat, - getRequestFormat, searchForRequestFiles, validateName, getCollectionStats, @@ -80,7 +79,7 @@ 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, resolveDeferredItem, transformRequestToSaveToFilesystem } = require('../utils/collection'); +const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection'); const { getProcessEnvVars } = require('../store/process-env'); const { setBrunoConfig } = require('../store/bruno-config'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, refreshOauth2Token } = require('../utils/oauth2'); @@ -1616,9 +1615,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { const parseCollectionItems = async (items = [], currentPath) => { for (const item of items) { if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) { - // Children the user never opened are deferred nodes, so the source files are read here - // rather than taken from the renderer's copy — otherwise the clone comes out empty. - const content = await stringifyRequestViaWorker(await resolveDeferredItem(item), { format }); + const content = await stringifyRequestViaWorker(item, { format }); // Use the correct file extension based on target format const baseName = path.parse(item.filename).name; @@ -1719,9 +1716,7 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { await writeFile(folderRootPath, content); } else if (REQUEST_TYPES.includes(item?.type)) { if (fs.existsSync(item.pathname)) { - // Reordering rewrites the whole file, and the renderer only holds a deferred node for - // a request the user has never opened — so read it back before serializing it. - const itemToSave = transformRequestToSaveToFilesystem(await resolveDeferredItem(item)); + const itemToSave = transformRequestToSaveToFilesystem(item); const content = await stringifyRequestViaWorker(itemToSave, { format }); await writeFile(item.pathname, content); } @@ -2288,68 +2283,6 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); - // Parses one request that was mounted as a deferred tree node, pushes the full item into the - // tree, and returns it so a caller that needs the request immediately does not have to wait for - // the tree update to land in the store. Distinct from renderer:load-large-request, which handles - // files too big to parse on mount at all and applies bru text-block redaction. - ipcMain.handle('renderer:load-request', async (event, { collectionUid, pathname }) => { - const format = getRequestFormat(pathname); - const file = { - meta: { - collectionUid, - pathname, - name: path.basename(pathname) - } - }; - - try { - const [fileStats, content] = await Promise.all([ - fs.promises.stat(pathname), - fs.promises.readFile(pathname, 'utf8') - ]); - - // Parsed off the main thread. A deferred request is only read when it is opened, so this is - // on the path between the click and the request appearing — and running the `.bru` grammar - // here stalls everything else in main for the duration, which scales with the file's size. - file.data = await parseRequestViaWorker(content, { format, filename: pathname }); - file.partial = false; - file.loading = false; - file.size = sizeInMB(fileStats?.size); - file.data.raw = content; - hydrateRequestWithUuid(file.data, pathname); - mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); - return file.data; - } catch (error) { - file.data = { - name: path.basename(pathname), - type: 'http-request' - }; - file.error = { message: error?.message }; - file.partial = true; - file.loading = false; - hydrateRequestWithUuid(file.data, pathname); - mainWindow.webContents.send('main:collection-tree-updated', 'addFile', file); - return Promise.reject(error); - } - }); - - // Parses a batch of deferred requests and returns them without touching the tree. Whole-collection - // consumers — export, documentation — need every request's body in one go, and resolving them one - // at a time would be an IPC round trip per request. They are not put into the tree because none of - // them is open: the store would then hold a fully parsed collection for a one-shot export. - ipcMain.handle('renderer:load-requests', async (event, { pathnames }) => { - return Promise.all((pathnames || []).map(async (pathname) => { - try { - const content = await fs.promises.readFile(pathname, 'utf8'); - const data = await parseRequestViaWorker(content, { format: getRequestFormat(pathname), filename: pathname }); - hydrateRequestWithUuid(data, pathname); - return { pathname, data }; - } catch (error) { - return { pathname, error: { message: error?.message } }; - } - })); - }); - ipcMain.handle('renderer:load-large-request', async (event, { collectionUid, pathname }) => { let fileStats; if (!hasBruExtension(pathname)) { @@ -2417,8 +2350,6 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { throw error; } // Scan and parse the whole collection across the worker pool, then emit it as one tree. - // Request nodes are deferred (see tree-builder): the sidebar and searches get what they - // need, and a request is parsed in full only when it is opened. mainWindow.webContents.send('main:collection-loading-state-updated', { collectionUid, isLoading: true }); const tree = await scanCollection({ diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index b42037ba1c5..36cd9c9ecf1 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -28,7 +28,7 @@ const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseData const { chooseFileToSave, writeFile, getCollectionFormat, hasRequestExtension } = require('../../utils/filesystem'); const { addCookieToJar, getDomainsWithCookies, getCookieStringForUrl } = require('../../utils/cookies'); const { createFormData } = require('../../utils/form-data'); -const { findItemInCollectionByPathname, resolveDeferredItem, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, getEffectiveTagsByUid, mergeVars, sortByNameThenSequence } = require('../../utils/collection'); +const { findItemInCollectionByPathname, sortFolder, getAllRequestsInFolderRecursively, getEnvVars, getTreePathFromCollectionToItem, getEffectiveTagsByUid, mergeVars, sortByNameThenSequence } = require('../../utils/collection'); const { getOAuth2TokenUsingAuthorizationCode, getOAuth2TokenUsingClientCredentials, getOAuth2TokenUsingPasswordCredentials, getOAuth2TokenUsingImplicitGrant, updateCollectionOauth2Credentials, clearOauth2CredentialsByCredentialsId } = require('../../utils/oauth2'); const { preferencesUtil } = require('../../store/preferences'); const { getProcessEnvVars } = require('../../store/process-env'); @@ -763,7 +763,7 @@ const registerNetworkIpc = (mainWindow) => { if (itemPathname && !hasRequestExtension(itemPathname, format)) { itemPathname = `${itemPathname}.${format}`; } - const _item = cloneDeep(await resolveDeferredItem(findItemInCollectionByPathname(collection, itemPathname))); + const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); if (_item) { // WS/gRPC items live on separate IPC channels and can't be driven via // the HTTP runRequest. Record a Skipped row so the user sees feedback. @@ -1352,8 +1352,7 @@ const registerNetworkIpc = (mainWindow) => { // handler for sending http request ipcMain.handle('send-http-request', async (event, _item, collection, environment, runtimeVariables) => { let seq = 0; - // Sending from the sidebar never opens the request, so it can still be a deferred node. - const item = await resolveDeferredItem(_item); + const item = _item; const collectionUid = collection.uid; const envVars = getEnvVars(environment); const processEnvVars = getProcessEnvVars(collectionUid); @@ -1456,7 +1455,7 @@ const registerNetworkIpc = (mainWindow) => { if (itemPathname && !hasRequestExtension(itemPathname, format)) { itemPathname = `${itemPathname}.${format}`; } - const _item = cloneDeep(await resolveDeferredItem(findItemInCollectionByPathname(collection, itemPathname))); + const _item = cloneDeep(findItemInCollectionByPathname(collection, itemPathname)); if (_item) { // WS/gRPC items live on separate IPC channels and can't be driven via // the HTTP runRequest. Record a Skipped row so the user sees feedback. @@ -1618,7 +1617,7 @@ const registerNetworkIpc = (mainWindow) => { stopRunnerExecution = false; - const item = cloneDeep(await resolveDeferredItem(folderRequests[currentRequestIndex])); + const item = cloneDeep(folderRequests[currentRequestIndex]); let nextRequestName; const itemUid = item.uid; const eventData = { diff --git a/packages/bruno-electron/src/services/mount/scan.js b/packages/bruno-electron/src/services/mount/scan.js index 36e998c0d27..4c12e93820a 100644 --- a/packages/bruno-electron/src/services/mount/scan.js +++ b/packages/bruno-electron/src/services/mount/scan.js @@ -43,10 +43,8 @@ const hydrateEnvironments = (collectionPath, environments = []) => { } }; -// Cold-start scan for the default mount path (no file cache): walk the collection once, parse -// every file across the worker pool, and build a single tree. Request nodes carry only what the -// sidebar and the searches read; collection and folder roots are parsed in full because folder -// names and `seq` drive sidebar ordering and requests inherit from them. +// 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. @@ -73,11 +71,7 @@ const scanCollection = async ({ collectionPath, collectionUid, denylist }) => { collectionPath, relativePath: entry.relativePath, format: entry.format, - type: entry.type, - // Requests become deferred nodes, so only their tree fields are needed. Collection and - // folder roots are parsed in full — the sidebar orders folders by their `seq` and - // requests inherit headers/auth/scripts from them. - treeFieldsOnly: true + type: entry.type })); } catch (err) { entries.set(entry.relativePath, { @@ -92,7 +86,7 @@ const scanCollection = async ({ collectionPath, collectionUid, denylist }) => { const parseMs = performance.now() - parseStartedAt; const buildStartedAt = performance.now(); - const tree = buildTree(collectionPath, entries, { uidFor: getRequestUid, deferRequests: true }); + 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 diff --git a/packages/bruno-electron/src/services/mount/tree-builder.js b/packages/bruno-electron/src/services/mount/tree-builder.js index 4c20d87b593..c824773e3a9 100644 --- a/packages/bruno-electron/src/services/mount/tree-builder.js +++ b/packages/bruno-electron/src/services/mount/tree-builder.js @@ -142,44 +142,6 @@ const buildRequestNode = (absolutePath, basename, entry, uidOverrides, uidFor) = }; }; -// Carries only what the sidebar and the searches read. `method`/`url` stay nested under `request` -// so the sidebar's method badge and global search keep reading the same paths, and everything -// heavy — headers, body, auth, scripts, settings, example bodies, raw text — is left on disk until -// the request is opened. -// -// `deferred` marks "not parsed yet, on purpose". It is deliberately not `partial`, which means -// "could not be fully parsed" and renders a warning triangle in the sidebar. -const buildDeferredRequestNode = (absolutePath, basename, entry, uidOverrides, uidFor) => { - const uid = uidOverrides?.get(absolutePath) || uidFor(absolutePath); - const data = entry.data || {}; - return { - uid, - name: data.name || stripExt(basename), - type: data.type || 'http-request', - seq: data.seq, - tags: data.tags, - request: { - method: data.request?.method, - url: data.request?.url - }, - examples: (data.examples || []).map((example, i) => ({ - uid: example.uid || uidForSeed(`${posixifyPath(absolutePath)}#example#${i}`), - name: example.name - })), - app: data.app ?? null, - // The mount path reports byteSize instead of returning the file's text; `raw` covers the - // cache path, which still carries it. - size: sizeInMB(entry.byteSize ?? (entry.raw ? Buffer.byteLength(entry.raw, 'utf8') : 0)), - filename: basename, - pathname: absolutePath, - draft: null, - deferred: true, - partial: false, - loading: false, - ...(entry.error ? { error: entry.error, partial: true, deferred: false } : {}) - }; -}; - const buildEnvironmentNode = (collectionPath, relativePath, entry, uidFor) => { const basename = path.basename(relativePath); const absolutePath = path.join(collectionPath, relativePath); @@ -197,9 +159,6 @@ const buildTree = (collectionPath, parserResults, options = {}) => { const uidOverrides = options.uidOverrides; const uidFor = options.uidFor || idForAbsolutePath; const transientEntries = options.transientEntries || []; - // Collection and folder roots are always built in full — the sidebar needs folder names and - // `seq` to order the tree, and requests inherit headers/auth/scripts from them. - const buildNode = options.deferRequests ? buildDeferredRequestNode : buildRequestNode; const tree = { pathname: collectionPath, @@ -245,7 +204,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); - cursor.push(buildNode( + cursor.push(buildRequestNode( path.join(collectionPath, relativePath), path.basename(relativePath), entry, 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 10eec825705..51c42b9b3c0 100644 --- a/packages/bruno-electron/src/services/pool/jobs/parse-file.js +++ b/packages/bruno-electron/src/services/pool/jobs/parse-file.js @@ -65,24 +65,17 @@ const parseContent = (content, format, type, byteSize) => { } }; -// `treeFieldsOnly` is the mount path for requests: it parses just the fields a tree node needs and -// returns `byteSize` in place of `raw`, so neither the full parse nor the file's text is paid for. -// Returning `raw` for every file would push the whole collection's bytes back across the worker -// boundary; the cache path still asks for it because it stages `raw`. -const parseFile = ({ collectionPath, relativePath, format, type, treeFieldsOnly = false }) => { +const parseFile = ({ collectionPath, relativePath, format, type }) => { const absolutePath = path.join(collectionPath, relativePath); const buf = fs.readFileSync(absolutePath); const stat = fs.statSync(absolutePath, { bigint: true }); const mtime = stat.mtimeNs; const content = buf.toString('utf8'); - const treeFieldsRequest = treeFieldsOnly && type === 'request'; - const hash = treeFieldsRequest ? '' : sha256(buf); - const payload = treeFieldsRequest ? { byteSize: buf.length } : { raw: content }; + const hash = sha256(buf); + const payload = { raw: content }; try { - const data = treeFieldsRequest - ? filestore.parseRequestTreeFields(content, { format }) - : parseContent(content, format, type, buf.length); + const data = parseContent(content, format, type, buf.length); return { relativePath, mtime, hash, data, format, type, ...payload }; } catch (err) { const data = format === 'bru' && type === 'request' ? extractBruMeta(content) : {}; diff --git a/packages/bruno-electron/src/services/search-index/indexer.js b/packages/bruno-electron/src/services/search-index/indexer.js index 79d352da416..910dd3fa781 100644 --- a/packages/bruno-electron/src/services/search-index/indexer.js +++ b/packages/bruno-electron/src/services/search-index/indexer.js @@ -20,8 +20,7 @@ const parseForIndex = async (root, entry) => { collectionPath: root, relativePath: entry.relativePath, format: cls.format, - type: cls.type, - treeFieldsOnly: true + type: cls.type }); if (result.data?.name) name = result.data.name; method = result.data?.request?.method || null; diff --git a/packages/bruno-electron/src/services/search-index/indexer.spec.js b/packages/bruno-electron/src/services/search-index/indexer.spec.js index 33d79e16c44..4ac7d289bc6 100644 --- a/packages/bruno-electron/src/services/search-index/indexer.spec.js +++ b/packages/bruno-electron/src/services/search-index/indexer.spec.js @@ -53,14 +53,6 @@ describe('indexCollection', () => { expect(mockRun).toHaveBeenCalledWith('parse-file', expect.objectContaining({ relativePath: path.join('users', 'get.bru') })); }); - it('parses with treeFieldsOnly so the full grammar never runs at index time', async () => { - const collectionPath = makeCollection(); - - await indexCollection({ collectionPath, collectionUid: 'col-1', collectionName: 'My Collection' }); - - expect(mockRun).toHaveBeenCalledWith('parse-file', expect.objectContaining({ treeFieldsOnly: true })); - }); - 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' }); diff --git a/packages/bruno-electron/src/utils/collection.js b/packages/bruno-electron/src/utils/collection.js index 52cd8e0216b..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, getRequestFormat } = require('./filesystem'); +const { posixifyPath } = require('./filesystem'); const os = require('os'); const { preferencesUtil } = require('../store/preferences'); const path = require('path'); -const { parseRequestViaWorker } = require('@usebruno/filestore'); const { parseValueByDataType } = require('@usebruno/common/utils'); const { GRPC_SCRIPT_KEYS, getEffectiveTags, getFolderTags, getOwnTags } = require('@usebruno/common'); @@ -621,34 +619,6 @@ const findItemInCollectionByPathname = (collection, pathname) => { return findItemByPathname(flattenedItems, pathname); }; -/** - * A mounted collection carries requests as deferred nodes — tree metadata plus method and url, but - * no headers, body, auth, scripts, assertions or tests. The renderer fills a request in when the - * user opens it, so anything here that is about to *execute* or *serialize* a request has to read - * the file itself rather than trust what the renderer sent. Serializing a deferred node throws on - * its missing `request.body`, except in `renderer:clone-folder`, which stringifies the node - * directly and so writes a valid but gutted file. - * - * Read and parsed off the main thread: the collection runner calls this once per request, so doing - * it synchronously stalls the main process between every request in a run. The file is re-read at - * the moment it is needed rather than batched up front, so an edit part-way through a run is picked - * up by the requests that follow it. - * - * A deferred item cannot have unsaved changes — editing one requires opening it, which loads it in - * full and clears the flag — so there is no draft to preserve here. - */ -const resolveDeferredItem = async (item) => { - if (!item?.deferred || !item.pathname) return item; - - const content = await fs.promises.readFile(item.pathname, 'utf8'); - const data = await parseRequestViaWorker(content, { format: getRequestFormat(item.pathname) }); - hydrateRequestWithUuid(data, item.pathname); - - // uid is derived from the pathname on both sides, but keep the tree node's copy authoritative so - // response and timeline events keep routing to the row the user is looking at. - return { ...item, ...data, uid: item.uid, deferred: false }; -}; - const replaceTabsWithSpaces = (str, numSpaces = 2) => { if (!str || !str.length || !isString(str)) { return ''; @@ -984,7 +954,6 @@ module.exports = { findItemInCollection, findItemByPathname, findItemInCollectionByPathname, - resolveDeferredItem, findParentItemInCollection, findParentItemInCollectionByPathname, parseBruFileMeta, diff --git a/packages/bruno-filestore/src/formats/bru/index.ts b/packages/bruno-filestore/src/formats/bru/index.ts index 329c1da8db9..150571adb66 100644 --- a/packages/bruno-filestore/src/formats/bru/index.ts +++ b/packages/bruno-filestore/src/formats/bru/index.ts @@ -1,7 +1,6 @@ import * as _ from 'lodash'; import { bruToJsonV2, - bruToTreeFields, jsonToBruV2, bruToEnvJsonV2, envJsonToBruV2, @@ -11,9 +10,6 @@ import { import { normalizeTags } from '@usebruno/common'; import { getOauth2AdditionalParameters } from './utils/oauth2-additional-params'; -// Fields a sidebar/search tree node needs, read without running the grammar. See bruToTreeFields. -export const parseBruRequestTreeFields = (data: string): any => bruToTreeFields(data); - export const parseBruRequest = (data: string | any, parsed: boolean = false): any => { try { const json = parsed ? data : bruToJsonV2(data); diff --git a/packages/bruno-filestore/src/index.ts b/packages/bruno-filestore/src/index.ts index 60ea9fa613c..fa0486e6dc9 100644 --- a/packages/bruno-filestore/src/index.ts +++ b/packages/bruno-filestore/src/index.ts @@ -2,7 +2,6 @@ import type { BrunoCollection, BrunoItem, BrunoEnvironment } from '@usebruno/sch import { parseBruRequest, - parseBruRequestTreeFields, parseBruCollection, parseBruEnvironment, stringifyBruRequest, @@ -42,21 +41,6 @@ export const parseRequest = (content: string, options: ParseOptions = { format: throw new Error(`Unsupported format: ${options.format}`); }; -/** - * Parses only the fields a sidebar/search tree node needs. For `.bru` this skips the ohm grammar - * (~1 MB/s) in favour of a block scanner, which is what makes mounting a large `.bru` collection - * viable. `.yml` is already fast enough that it uses the real parser and simply returns more than - * asked for; callers keep only the tree fields either way. - */ -export const parseRequestTreeFields = (content: string, options: ParseOptions = { format: DEFAULT_COLLECTION_FORMAT }): any => { - if (options.format === 'bru') { - return parseBruRequestTreeFields(content); - } else if (options.format === 'yml') { - return parseYmlItem(content); - } - throw new Error(`Unsupported format: ${options.format}`); -}; - export const parseRequestAndRedactBody = (content: string, options: ParseOptions = { format: 'bru' }): any => { if (options.format === 'bru') { return bruRequestParseAndRedactBodyData(content); diff --git a/packages/bruno-filestore/src/types/bruno-lang.d.ts b/packages/bruno-filestore/src/types/bruno-lang.d.ts index 958c7b6ea20..cf9e9cdc880 100644 --- a/packages/bruno-filestore/src/types/bruno-lang.d.ts +++ b/packages/bruno-filestore/src/types/bruno-lang.d.ts @@ -1,6 +1,5 @@ declare module '@usebruno/lang' { export function bruToJsonV2(bruContent: string): any; - export function bruToTreeFields(bruContent: string): any; export function jsonToBruV2(jsonData: any): string; export function bruToEnvJsonV2(bruContent: string): any; export function envJsonToBruV2(jsonData: any): string; diff --git a/packages/bruno-lang/src/index.js b/packages/bruno-lang/src/index.js index 2a66eeb9315..4a8eafab2c6 100644 --- a/packages/bruno-lang/src/index.js +++ b/packages/bruno-lang/src/index.js @@ -1,5 +1,4 @@ const bruToJsonV2 = require('../v2/src/bruToJson'); -const bruToTreeFields = require('../v2/src/bruToTreeFields'); const jsonToBruV2 = require('../v2/src/jsonToBru'); const bruToEnvJsonV2 = require('../v2/src/envToJson'); const envJsonToBruV2 = require('../v2/src/jsonToEnv'); @@ -14,7 +13,6 @@ const { outdentString } = require('../v2/src/utils'); module.exports = { bruToJsonV2, - bruToTreeFields, jsonToBruV2, bruToEnvJsonV2, envJsonToBruV2, diff --git a/packages/bruno-lang/v2/src/bruToTreeFields.js b/packages/bruno-lang/v2/src/bruToTreeFields.js deleted file mode 100644 index 7867f85293d..00000000000 --- a/packages/bruno-lang/v2/src/bruToTreeFields.js +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Extracts just the fields a sidebar/search tree node needs from a .bru file, without running the - * ohm grammar. The full grammar sustains ~1 MB/s, which makes mounting a large collection slow; - * this reads the same files at ~4.5 GB/s. - * - * It is a deliberate subset, not a parser: the result is only ever used for tree nodes, and opening - * a request replaces it with `bruToJsonV2` output. Field shapes therefore mirror `bruToJsonV2` so - * the two are interchangeable for these fields. - * - * Correctness rests on the grammar's own block delimiter, `tagend = nl "}"` — a block ends at a - * newline followed by `}`, not by brace counting. So this jumps from a block's header line to the - * next `\n}` and never reads inside a block, which is why braces in bodies and scripts cannot - * confuse it. Block contents are written indented, so a `}` belonging to the content never sits at - * column 0; where one did, the grammar would end the block there too. - */ - -// `get`/`post`/... name the method in the block header; `http`/`grpc`/`ws` carry it as a `method:` -// pair instead (grammar: `httpcustom = "http" dictionary`, `grpc = "grpc" dictionary`, `ws = ...`). -const METHOD_HEADER_BLOCKS = new Set(['get', 'post', 'put', 'delete', 'patch', 'options', 'head', 'connect', 'trace']); -const METHOD_PAIR_BLOCKS = new Set(['http', 'grpc', 'ws']); - -const TYPE_MAP = { - http: 'http-request', - graphql: 'graphql-request', - grpc: 'grpc-request', - ws: 'ws-request' -}; - -const MULTILINE_DELIMITER = '\'\'\''; - -// Keys may be quoted, and prefixed with `~` when disabled (grammar: `quoted_key`, `disable_char`). -const normalizeKey = (rawKey) => rawKey.trim().replace(/^~/, '').replace(/^"(.*)"$/, '$1'); - -// Finds where a block that starts on `headerLineEnd`'s line ends. Mirrors `tagend = nl "}"`. -const findBlockEnd = (content, headerLineEnd) => { - const end = content.indexOf('\n}', headerLineEnd); - return end === -1 ? content.length : end; -}; - -/** - * Reads `key: value` pairs from one block body, keeping only the wanted keys. Handles the two value - * forms that can span lines: `'''…'''` text blocks and `[ … ]` lists. - */ -const readPairs = (body, wantedKeys) => { - const pairs = {}; - let cursor = 0; - - while (cursor < body.length) { - let lineEnd = body.indexOf('\n', cursor); - if (lineEnd === -1) lineEnd = body.length; - - const line = body.slice(cursor, lineEnd); - const colon = line.indexOf(':'); - - if (colon !== -1) { - const key = normalizeKey(line.slice(0, colon)); - if (wantedKeys.has(key)) { - const rest = line.slice(colon + 1); - const trimmed = rest.trim(); - - if (trimmed.startsWith(MULTILINE_DELIMITER)) { - const valueStart = cursor + colon + 1 + rest.indexOf(MULTILINE_DELIMITER) + MULTILINE_DELIMITER.length; - const close = body.indexOf(MULTILINE_DELIMITER, valueStart); - if (close === -1) { - pairs[key] = trimmed; - } else { - pairs[key] = body.slice(valueStart, close); - lineEnd = body.indexOf('\n', close); - if (lineEnd === -1) lineEnd = body.length; - } - } else if (trimmed === '[' || trimmed.startsWith('[')) { - // grammar: `list = st* "[" nl+ listitems? st* nl+ st* "]"` — items sit on their own lines - const close = body.indexOf(']', cursor + colon); - if (close === -1) { - pairs[key] = trimmed; - } else { - pairs[key] = body.slice(cursor + colon + 1, close).replace(/^\s*\[/, ''); - lineEnd = body.indexOf('\n', close); - if (lineEnd === -1) lineEnd = body.length; - } - } else { - pairs[key] = trimmed; - } - } - } - - cursor = lineEnd + 1; - } - - return pairs; -}; - -const META_KEYS = new Set(['name', 'type', 'seq', 'tags']); -const URL_KEYS = new Set(['url']); -const URL_AND_METHOD_KEYS = new Set(['url', 'method']); - -/** - * An example's body is opaque to the grammar (`examplecontent = (~tagend any)*`) and holds nested - * `request: {` / `response: {` blocks whose contents can contain a literal `"name":` — inside a - * JSON body, for instance. The example's own scalar pairs always precede those nested blocks, so - * reading stops at the first one rather than scanning the whole body. - */ -const readExampleName = (body) => { - let cursor = 0; - - while (cursor < body.length) { - let lineEnd = body.indexOf('\n', cursor); - if (lineEnd === -1) lineEnd = body.length; - - const line = body.slice(cursor, lineEnd).trim(); - if (line.endsWith('{')) return undefined; - - const colon = line.indexOf(':'); - if (colon !== -1 && normalizeKey(line.slice(0, colon)) === 'name') { - return line.slice(colon + 1).trim(); - } - - cursor = lineEnd + 1; - } - - return undefined; -}; - -const toList = (value) => - value - .split(/[,\n]/) - .map((entry) => entry.trim()) - .filter(Boolean); - -const bruToTreeFields = (content) => { - if (typeof content !== 'string') return {}; - - const treeFields = { request: {} }; - const examples = []; - let cursor = 0; - - while (cursor < content.length) { - let lineEnd = content.indexOf('\n', cursor); - if (lineEnd === -1) lineEnd = content.length; - - const brace = content.slice(cursor, lineEnd).indexOf('{'); - if (brace === -1) { - cursor = lineEnd + 1; - continue; - } - - const header = content.slice(cursor, cursor + brace).trim(); - const blockEnd = findBlockEnd(content, lineEnd); - const body = content.slice(lineEnd + 1, blockEnd); - - if (header === 'meta') { - const pairs = readPairs(body, META_KEYS); - if (pairs.name !== undefined) treeFields.name = pairs.name; - if (pairs.type !== undefined) treeFields.type = TYPE_MAP[pairs.type] || 'http-request'; - if (pairs.seq !== undefined) { - const seq = Number(pairs.seq); - treeFields.seq = Number.isNaN(seq) ? pairs.seq : seq; - } - if (pairs.tags !== undefined) treeFields.tags = toList(pairs.tags); - } else if (METHOD_HEADER_BLOCKS.has(header)) { - treeFields.request.method = header.toUpperCase(); - const pairs = readPairs(body, URL_KEYS); - if (pairs.url !== undefined) treeFields.request.url = pairs.url; - } else if (METHOD_PAIR_BLOCKS.has(header)) { - const pairs = readPairs(body, URL_AND_METHOD_KEYS); - // A grpc method is a case-sensitive path (`/pkg.Service/GetUser`), and `parseBruRequest` - // passes it through untouched; only http methods are upper-cased. - if (pairs.method !== undefined) { - treeFields.request.method = header === 'grpc' ? pairs.method : pairs.method.toUpperCase(); - } - if (pairs.url !== undefined) treeFields.request.url = pairs.url; - } else if (header === 'example') { - // The sidebar lists example names; their request/response bodies stay on disk. - examples.push({ name: readExampleName(body) }); - } - - cursor = blockEnd + 2; - } - - if (examples.length > 0) treeFields.examples = examples; - return treeFields; -}; - -module.exports = bruToTreeFields; From 9fbc92df19ea8837e4f58fc9099fb4f82adefd8a Mon Sep 17 00:00:00 2001 From: mr-weed Date: Mon, 21 Sep 2026 13:28:43 +0530 Subject: [PATCH 3/4] revert: bruno-lang changes --- packages/bruno-lang/v2/src/bruToJson.js | 23 +++------------- .../bruno-lang/v2/src/collectionBruToJson.js | 23 +++------------- packages/bruno-lang/v2/src/envToJson.js | 23 +++------------- .../bruno-lang/v2/src/example/bruToJson.js | 26 +++++-------------- .../v2/src/example/request/bruToJson.js | 26 +++++-------------- .../v2/src/example/response/bruToJson.js | 26 +++++-------------- 6 files changed, 30 insertions(+), 117 deletions(-) diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index 2261f73af1b..a230288c5b4 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -40,7 +40,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); * ] * */ -const GRAMMAR_SOURCE = `Bru { +const grammar = ohm.grammar(`Bru { BruFile = (meta | http | grpc | ws | query | params | headers | metadata | auths | bodies | varsandassert | script | tests | app | settings | docs | example)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs bodies = bodyjson | bodytext | bodyxml | bodysparql | bodygraphql | bodygraphqlvars | bodyforms | body | bodygrpc | bodyws @@ -195,7 +195,7 @@ const GRAMMAR_SOURCE = `Bru { scriptaftercallend = "script:grpc:after-call-end" st* "{" nl* textblock tagend tests = "tests" st* "{" nl* textblock tagend docs = "docs" st* "{" nl* textblock tagend -}`; +}`); const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTypes = false) => { if (!pairList.length) { @@ -376,7 +376,7 @@ const parseExampleContent = (content) => { } }; -const SEMANTIC_ACTIONS = { +const sem = grammar.createSemantics().addAttribute('ast', { BruFile(tags) { if (!tags || !tags.ast || !tags.ast.length) { return {}; @@ -1302,24 +1302,9 @@ const SEMANTIC_ACTIONS = { examplecontent(chars) { return outdentString(chars.sourceString); } -}; - -// ohm builds the grammar and its semantics eagerly, and both are large: constructing them costs -// around 78MB of heap across this package's three grammars, paid at import even by a consumer that -// never parses a `.bru` file. The mount path is exactly that consumer — it scans tree fields — and -// each parser worker is a thread whose heap counts against the main process. Built on first parse -// instead, and memoised, so the cost lands only where a real parse happens. -let compiled = null; -const compileGrammar = () => { - if (!compiled) { - const grammar = ohm.grammar(GRAMMAR_SOURCE); - compiled = { grammar, sem: grammar.createSemantics().addAttribute('ast', SEMANTIC_ACTIONS) }; - } - return compiled; -}; +}); const parser = (input) => { - const { grammar, sem } = compileGrammar(); const match = grammar.match(input); if (match.succeeded()) { diff --git a/packages/bruno-lang/v2/src/collectionBruToJson.js b/packages/bruno-lang/v2/src/collectionBruToJson.js index 7feccf884e8..5d04092da92 100644 --- a/packages/bruno-lang/v2/src/collectionBruToJson.js +++ b/packages/bruno-lang/v2/src/collectionBruToJson.js @@ -13,7 +13,7 @@ const { // the key is hidden and not added into the json automatically const ANNOTATIONS_KEY = Symbol('annotations'); -const GRAMMAR_SOURCE = `Bru { +const grammar = ohm.grammar(`Bru { BruFile = (meta | query | headers | auth | auths | vars | script | tests | docs)* auths = authawsv4 | authbasic | authbearer | authdigest | authNTLM | authOAuth1 | authOAuth2 | authwsse | authapikey | authedgegrid | authOauth2Configs @@ -115,7 +115,7 @@ const GRAMMAR_SOURCE = `Bru { scriptres = "script:post-response" st* "{" nl* textblock tagend tests = "tests" st* "{" nl* textblock tagend docs = "docs" st* "{" nl* textblock tagend -}`; +}`); const mapPairListToKeyValPairs = (pairList = [], parseEnabled = true, extractTypes = false) => { if (!pairList.length) { @@ -163,7 +163,7 @@ const mapPairListToKeyValPair = (pairList = []) => { return _.merge({}, ...pairList[0]); }; -const SEMANTIC_ACTIONS = { +const sem = grammar.createSemantics().addAttribute('ast', { BruFile(tags) { if (!tags || !tags.ast || !tags.ast.length) { return {}; @@ -733,24 +733,9 @@ const SEMANTIC_ACTIONS = { docs: outdentString(textblock.sourceString) }; } -}; - -// ohm builds the grammar and its semantics eagerly, and both are large: constructing them costs -// around 78MB of heap across this package's three grammars, paid at import even by a consumer that -// never parses a `.bru` file. The mount path is exactly that consumer — it scans tree fields — and -// each parser worker is a thread whose heap counts against the main process. Built on first parse -// instead, and memoised, so the cost lands only where a real parse happens. -let compiled = null; -const compileGrammar = () => { - if (!compiled) { - const grammar = ohm.grammar(GRAMMAR_SOURCE); - compiled = { grammar, sem: grammar.createSemantics().addAttribute('ast', SEMANTIC_ACTIONS) }; - } - return compiled; -}; +}); const parser = (input) => { - const { grammar, sem } = compileGrammar(); const match = grammar.match(input); if (match.succeeded()) { diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js index d85225e066d..9e3ad37cb3b 100644 --- a/packages/bruno-lang/v2/src/envToJson.js +++ b/packages/bruno-lang/v2/src/envToJson.js @@ -22,7 +22,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); // ''' // } const indentLevel = 4; -const GRAMMAR_SOURCE = `Bru { +const grammar = ohm.grammar(`Bru { BruEnvFile = (vars | secretvars | externalsecrets | extends | color)* nl = "\\r"? "\\n" @@ -92,7 +92,7 @@ const GRAMMAR_SOURCE = `Bru { unquotedextendsvaluechar = ~(nl | "[" | "]" | ",") any extendsname = ":" singlelinechar* color = "color:" singlelinechar* -}`; +}`); const mapPairListToKeyValPairs = (pairList = []) => { if (!pairList.length) { @@ -183,7 +183,7 @@ const concatArrays = (objValue, srcValue) => { } }; -const SEMANTIC_ACTIONS = { +const sem = grammar.createSemantics().addAttribute('ast', { BruEnvFile(tags) { if (!tags || !tags.ast || !tags.ast.length) { return { @@ -373,24 +373,9 @@ const SEMANTIC_ACTIONS = { color: anystring.sourceString.trim() }; } -}; - -// ohm builds the grammar and its semantics eagerly, and both are large: constructing them costs -// around 78MB of heap across this package's three grammars, paid at import even by a consumer that -// never parses a `.bru` file. The mount path is exactly that consumer — it scans tree fields — and -// each parser worker is a thread whose heap counts against the main process. Built on first parse -// instead, and memoised, so the cost lands only where a real parse happens. -let compiled = null; -const compileGrammar = () => { - if (!compiled) { - const grammar = ohm.grammar(GRAMMAR_SOURCE); - compiled = { grammar, sem: grammar.createSemantics().addAttribute('ast', SEMANTIC_ACTIONS) }; - } - return compiled; -}; +}); const parser = (input) => { - const { grammar, sem } = compileGrammar(); const match = grammar.match(input); if (match.succeeded()) { diff --git a/packages/bruno-lang/v2/src/example/bruToJson.js b/packages/bruno-lang/v2/src/example/bruToJson.js index 728fb79f8ed..4f0cc841144 100644 --- a/packages/bruno-lang/v2/src/example/bruToJson.js +++ b/packages/bruno-lang/v2/src/example/bruToJson.js @@ -13,7 +13,7 @@ const astBaseAttribute = require('../common/attributes'); * Supports all body types from request side but response body stays as simple text. */ -const GRAMMAR_SOURCE = `Example { +const exampleGrammar = ohm.grammar(`Example { ExampleFile = (name | description | request | response)* nl = "\\r"? "\\n" @@ -65,7 +65,7 @@ const GRAMMAR_SOURCE = `Example { // Response block response = "response" st* ":" st* "{" nl* responsecontent nl* "}" nl* responsecontent = (~tagend any)+ -}`; +}`); const astExampleAttribute = { ExampleFile(tags) { @@ -127,28 +127,14 @@ const astExampleAttribute = { } }; -const ATTRIBUTES = { ...astBaseAttribute, ...astExampleAttribute }; - -// Built on first parse rather than at import: ohm constructs the grammar and its semantics -// eagerly, and a consumer that never parses an example — the mount path, which only scans tree -// fields — would otherwise pay for them anyway. See the same pattern in v2/src/bruToJson.js. -let compiled = null; -const compileGrammar = () => { - if (!compiled) { - const grammar = ohm.grammar(GRAMMAR_SOURCE); - const semantics = grammar.createSemantics(); - semantics.addAttribute('ast', ATTRIBUTES); - compiled = { grammar, semantics }; - } - return compiled; -}; +const grammarSemantics = exampleGrammar.createSemantics(); +grammarSemantics.addAttribute('ast', { ...astBaseAttribute, ...astExampleAttribute }); const parseExample = (input) => { - const { grammar, semantics } = compileGrammar(); - const match = grammar.match(input); + const match = exampleGrammar.match(input); if (match.succeeded()) { - let ast = semantics(match).ast; + let ast = grammarSemantics(match).ast; return ast; } else { console.log('match failed', match); diff --git a/packages/bruno-lang/v2/src/example/request/bruToJson.js b/packages/bruno-lang/v2/src/example/request/bruToJson.js index 655f51658ab..162e5f98daf 100644 --- a/packages/bruno-lang/v2/src/example/request/bruToJson.js +++ b/packages/bruno-lang/v2/src/example/request/bruToJson.js @@ -16,7 +16,7 @@ const { * Handles parsing of request blocks within example files. * Supports all body types: json, text, xml, sparql, graphql, form-urlencoded, multipart-form, file */ -const GRAMMAR_SOURCE = `Request { +const requestGrammar = ohm.grammar(`Request { RequestFile = requestcontent* nl = "\\r"? "\\n" @@ -76,7 +76,7 @@ const GRAMMAR_SOURCE = `Request { bodyformurlencoded = "body:form-urlencoded" st* ":" st* dictionary bodymultipart = "body:multipart-form" st* ":" st* dictionary bodyfile = "body:file" st* ":" st* dictionary -}`; +}`); const astRequestAttribute = { RequestFile(tags) { @@ -204,28 +204,14 @@ const astRequestAttribute = { } }; -const ATTRIBUTES = { ...astBaseAttribute, ...astRequestAttribute }; - -// Built on first parse rather than at import: ohm constructs the grammar and its semantics -// eagerly, and a consumer that never parses an example — the mount path, which only scans tree -// fields — would otherwise pay for them anyway. See the same pattern in v2/src/bruToJson.js. -let compiled = null; -const compileGrammar = () => { - if (!compiled) { - const grammar = ohm.grammar(GRAMMAR_SOURCE); - const semantics = grammar.createSemantics(); - semantics.addAttribute('ast', ATTRIBUTES); - compiled = { grammar, semantics }; - } - return compiled; -}; +const grammarSemantics = requestGrammar.createSemantics(); +grammarSemantics.addAttribute('ast', { ...astBaseAttribute, ...astRequestAttribute }); const parseRequest = (input) => { - const { grammar, semantics } = compileGrammar(); - const match = grammar.match(input); + const match = requestGrammar.match(input); if (match.succeeded()) { - let ast = semantics(match).ast; + let ast = grammarSemantics(match).ast; return ast; } else { console.log('match failed', match); diff --git a/packages/bruno-lang/v2/src/example/response/bruToJson.js b/packages/bruno-lang/v2/src/example/response/bruToJson.js index 2baa53314ea..2a7390144b7 100644 --- a/packages/bruno-lang/v2/src/example/response/bruToJson.js +++ b/packages/bruno-lang/v2/src/example/response/bruToJson.js @@ -10,7 +10,7 @@ const { mapPairListToKeyValPairs } = require('../../common/semantic-utils'); * Handles parsing of response blocks within example files. * Supports headers, status, and body parsing. */ -const GRAMMAR_SOURCE = `Response { +const responseGrammar = ohm.grammar(`Response { ResponseFile = responsecontent* nl = "\\r"? "\\n" @@ -58,7 +58,7 @@ const GRAMMAR_SOURCE = `Response { responsebodyfields = (responsebodytype | responsebodycontentvalue)* responsebodytype = st* "type" st* ":" st* valuechar* nl* responsebodycontentvalue = st* "content" st* ":" st* multilinetextblock -}`; +}`); const astResponseAttribute = { ResponseFile(tags) { @@ -120,28 +120,14 @@ const astResponseAttribute = { } }; -const ATTRIBUTES = { ...astBaseAttribute, ...astResponseAttribute }; - -// Built on first parse rather than at import: ohm constructs the grammar and its semantics -// eagerly, and a consumer that never parses an example — the mount path, which only scans tree -// fields — would otherwise pay for them anyway. See the same pattern in v2/src/bruToJson.js. -let compiled = null; -const compileGrammar = () => { - if (!compiled) { - const grammar = ohm.grammar(GRAMMAR_SOURCE); - const semantics = grammar.createSemantics(); - semantics.addAttribute('ast', ATTRIBUTES); - compiled = { grammar, semantics }; - } - return compiled; -}; +const grammarSemantics = responseGrammar.createSemantics(); +grammarSemantics.addAttribute('ast', { ...astBaseAttribute, ...astResponseAttribute }); const parseResponse = (input) => { - const { grammar, semantics } = compileGrammar(); - const match = grammar.match(input); + const match = responseGrammar.match(input); if (match.succeeded()) { - let ast = semantics(match).ast; + let ast = grammarSemantics(match).ast; return ast; } else { console.log('match failed', match); From 7c106ca45d83fc2965c60462251ae0210c486561 Mon Sep 17 00:00:00 2001 From: mr-weed Date: Mon, 21 Sep 2026 14:36:21 +0530 Subject: [PATCH 4/4] poc: search index --- .../src/components/FileEditor/index.js | 10 +- .../src/components/ShareCollection/index.js | 6 +- .../Collection/GenerateDocumentation/index.js | 10 +- .../GenerateDocumentation/index.spec.js | 12 +- .../components/Sidebar/Collections/index.js | 45 +++++++- .../ReduxStore/slices/collections/actions.js | 49 ++++++++- .../slices/collections/actions.spec.js | 76 ++++++++++++- .../slices/collections/file-mode.spec.js | 34 +++++- .../ReduxStore/slices/collections/index.js | 15 ++- .../src/app/collection-watcher.js | 2 +- packages/bruno-electron/src/ipc/collection.js | 8 ++ .../bruno-electron/src/ipc/search-index.js | 22 +++- .../src/ipc/search-index.spec.js | 33 +++++- .../src/services/mount/tree-builder.js | 4 +- .../src/services/search-index/build-tree.js | 66 +++++++++++ .../services/search-index/build-tree.spec.js | 103 ++++++++++++++++++ .../src/services/search-index/index.js | 50 +++++++-- .../src/services/search-index/index.spec.js | 35 ++++++ .../src/services/search-index/indexer.js | 25 ++++- .../src/services/search-index/indexer.spec.js | 13 ++- 20 files changed, 579 insertions(+), 39 deletions(-) create mode 100644 packages/bruno-electron/src/services/search-index/build-tree.js create mode 100644 packages/bruno-electron/src/services/search-index/build-tree.spec.js 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/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js index 6398fb3d71c..38ba09b2653 100644 --- a/packages/bruno-app/src/components/ShareCollection/index.js +++ b/packages/bruno-app/src/components/ShareCollection/index.js @@ -7,7 +7,8 @@ import StyledWrapper from './StyledWrapper'; import ExportToPostman from 'components/Sidebar/Collections/Collection/ExportCollection/ExportToPostman'; import exportOpenCollection from 'utils/exporters/opencollection'; import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index'; -import { useSelector } from 'react-redux'; +import { resolveJsItemsRaw } from 'providers/ReduxStore/slices/collections/actions'; +import { useDispatch, useSelector } from 'react-redux'; import { findCollectionByUid, areItemsLoading } from 'utils/collections/index'; import toast from 'react-hot-toast'; @@ -18,6 +19,7 @@ const EXPORT_FORMATS = { }; const ShareCollection = ({ onClose, collectionUid }) => { + const dispatch = useDispatch(); const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const isCollectionLoading = areItemsLoading(collection); const [selectedFormat, setSelectedFormat] = useState(EXPORT_FORMATS.ZIP); @@ -59,7 +61,7 @@ const ShareCollection = ({ onClose, collectionUid }) => { }; const handleExportYaml = async () => { - const collectionCopy = cloneDeep(collection); + const collectionCopy = await dispatch(resolveJsItemsRaw(cloneDeep(collection))); exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy)); }; 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 1a5b32c88a3..64627386515 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -7,6 +7,7 @@ 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'; @@ -45,10 +46,50 @@ 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: debouncedSearchText }), - [sidebarEntries, debouncedSearchText] + () => flattenSidebarTree(renderedSidebarEntries, { searchText: debouncedSearchText }), + [renderedSidebarEntries, debouncedSearchText] ); // Shown while the workspace is still being indexed, and while a search is settling — the two diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 5a786f4bb76..da06f41329e 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -18,6 +18,7 @@ import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection, + flattenItems, isItemAFolder, refreshUidsInItem, isItemARequest, @@ -78,7 +79,8 @@ import { addSaveTransientRequestModal, updatePathParam, toggleCollection, - setSidebarSelection + setSidebarSelection, + setItemRaw } from './index'; import { each } from 'lodash'; @@ -3505,6 +3507,51 @@ export const warmSearchIndex } }; +/** + * 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 4c912bfcf26..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, warmSearchIndex } from './actions'; +import { newHttpRequest, warmSearchIndex, fetchItemRaw, resolveJsItemsRaw } from './actions'; const mockUuid = jest.fn(); @@ -173,4 +173,78 @@ describe('collection actions', () => { 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 5b7b28bd9b7..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', @@ -3773,6 +3775,16 @@ 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); @@ -4418,6 +4430,7 @@ export const { updateFolderDocs, toggleCollectionFileMode, updateFileContent, + setItemRaw, updateAppCode, toggleAppMode, appSetRuntimeVariable, diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js index ac94fe751c5..fc3c6e3b7ee 100644 --- a/packages/bruno-electron/src/app/collection-watcher.js +++ b/packages/bruno-electron/src/app/collection-watcher.js @@ -586,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) { diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index e4e993f9f17..47bc290a59c 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -2283,6 +2283,14 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); + // 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 }) => { let fileStats; if (!hasBruExtension(pathname)) { diff --git a/packages/bruno-electron/src/ipc/search-index.js b/packages/bruno-electron/src/ipc/search-index.js index 672abcebf84..ca969afe9f9 100644 --- a/packages/bruno-electron/src/ipc/search-index.js +++ b/packages/bruno-electron/src/ipc/search-index.js @@ -1,6 +1,7 @@ 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(); @@ -51,9 +52,28 @@ 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, indexedCollections, closeAllSearchIndexWatchers }; +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 index a8cde0274fb..316804ba78e 100644 --- a/packages/bruno-electron/src/ipc/search-index.spec.js +++ b/packages/bruno-electron/src/ipc/search-index.spec.js @@ -12,9 +12,10 @@ const mockSearch = jest.fn(() => [{ 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) }) + getSearchIndex: () => ({ search: (...args) => mockSearch(...args), getFolderTree: (...args) => mockGetFolderTree(...args) }) })); jest.mock('../cache/requestUids', () => ({ getRequestUid: (pathname) => `uid-for-${pathname}` @@ -25,7 +26,12 @@ jest.mock('../services/search-index/watcher', () => ({ ensureWatching: (...args) => mockEnsureWatching(...args) })); -const { searchIndex, warmSearchIndex, indexedCollections } = require('./search-index'); +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', @@ -43,6 +49,8 @@ beforeEach(() => { mockIndexCollection.mockClear(); mockSearch.mockClear(); mockEnsureWatching.mockClear(); + mockGetFolderTree.mockClear(); + mockBuildFolderTree.mockClear(); }); describe('searchIndex handler', () => { @@ -124,3 +132,24 @@ describe('warmSearchIndex handler', () => { 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/tree-builder.js b/packages/bruno-electron/src/services/mount/tree-builder.js index c824773e3a9..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, 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 index 8e4345950e5..329bae1e080 100644 --- a/packages/bruno-electron/src/services/search-index/index.js +++ b/packages/bruno-electron/src/services/search-index/index.js @@ -24,6 +24,15 @@ const MIGRATIONS = [ 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; + ` } ]; @@ -84,23 +93,48 @@ class SearchIndex { return this.#db.all( `SELECT id, name, method, url, request_path, folder_path, collection_uid, collection_path, collection_name FROM search_index_items - WHERE ${termClauses} AND collection_path IN (${collectionPlaceholders}) + 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, name, method, url, collectionUid, collectionName, mtime, hash } = 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(relativePath); + const dirname = path.dirname(itemPath); const folderPath = dirname === '.' ? '' : dirname; this.#db.run( `INSERT INTO search_index_items - (id, collection_path, relative_path, name, method, url, request_path, folder_path, collection_uid, collection_name, mtime, hash, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch()) + (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, @@ -108,9 +142,11 @@ class SearchIndex { 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, name, method || null, url || null, - absolutePath, folderPath, collectionUid, collectionName, mtime, hash + id, root, relativePath, itemPath, name, method || null, url || null, + absolutePath, folderPath, collectionUid, collectionName, mtime, hash, type, seq ); } diff --git a/packages/bruno-electron/src/services/search-index/index.spec.js b/packages/bruno-electron/src/services/search-index/index.spec.js index 074ed7c622a..0a2030c81f2 100644 --- a/packages/bruno-electron/src/services/search-index/index.spec.js +++ b/packages/bruno-electron/src/services/search-index/index.spec.js @@ -117,6 +117,41 @@ describe('SearchIndex', () => { 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')); diff --git a/packages/bruno-electron/src/services/search-index/indexer.js b/packages/bruno-electron/src/services/search-index/indexer.js index 910dd3fa781..054294146a7 100644 --- a/packages/bruno-electron/src/services/search-index/indexer.js +++ b/packages/bruno-electron/src/services/search-index/indexer.js @@ -9,11 +9,15 @@ const getSearchIndex = () => { return sharedIndex; }; -const parseForIndex = async (root, entry) => { +const parseForIndex = async (root, entry, type) => { const cls = defaultClassify(entry.relativePath); - let name = path.basename(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, { @@ -22,7 +26,12 @@ const parseForIndex = async (root, entry) => { format: cls.format, type: cls.type }); - if (result.data?.name) name = result.data.name; + 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) {} @@ -30,9 +39,12 @@ const parseForIndex = async (root, entry) => { return { relativePath: entry.relativePath, absolutePath: entry.absolutePath, + itemPath, name, method, url, + type, + seq, mtime: entry.mtime, hash: entry.hash }; @@ -43,12 +55,13 @@ const indexCollection = async ({ collectionPath, collectionUid, collectionName, const index = getSearchIndex(); const { added, updated, removed } = await index.status(root, { denylist }); - const toParse = [...added, ...updated].filter((entry) => defaultClassify(entry.relativePath)?.type === 'request'); - const parsed = await Promise.all(toParse.map((entry) => parseForIndex(root, entry))); + 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) => defaultClassify(entry.relativePath)?.type === 'request') + .filter((entry) => ['request', 'folder'].includes(classifyType(entry))) .map((entry) => entry.id); index.apply(root, { upsert, removeIds }); diff --git a/packages/bruno-electron/src/services/search-index/indexer.spec.js b/packages/bruno-electron/src/services/search-index/indexer.spec.js index 4ac7d289bc6..26e157cac8d 100644 --- a/packages/bruno-electron/src/services/search-index/indexer.spec.js +++ b/packages/bruno-electron/src/services/search-index/indexer.spec.js @@ -11,6 +11,9 @@ 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', () => ({ @@ -44,13 +47,17 @@ describe('indexCollection', () => { expect(results[0].method).toBe('GET'); }); - it('does not index folder.bru as a searchable request', async () => { + 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(1); - expect(mockRun).toHaveBeenCalledWith('parse-file', expect.objectContaining({ relativePath: path.join('users', 'get.bru') })); + 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 () => {