diff --git a/eslint.config.js b/eslint.config.js
index 18a82aa8ce7..8c9398e4206 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -142,6 +142,49 @@ module.exports = runESMImports().then(() => defineConfig([
'react-hooks/exhaustive-deps': 'warn'
}
},
+ {
+ // Prevent coarse Redux subscriptions. `state.collections.collections` and
+ // `state.tabs.tabs` are replaced frequently, so subscribing to either causes
+ // unnecessary re-renders when unrelated items or tabs change.
+ //
+ // Prefer narrow selectors from src/selectors/ when the value affects rendering.
+ // If the value is only needed at event time and does not affect rendering,
+ // read it from the store inside the event handler.
+ //
+ // Kept at `warn` during migration; change to `error` once existing violations
+ // have been converted.
+ files: ['packages/bruno-app/src/{components,providers,hooks}/**/*.{js,jsx,ts,tsx}'],
+ ignores: ['**/*.spec.*', '**/*.test.*'],
+ rules: {
+ 'no-restricted-syntax': [
+ 'warn',
+ {
+ selector:
+ 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="collections"][object.type="MemberExpression"][object.property.name="collections"][object.object.type="Identifier"]',
+ message:
+ 'Do not subscribe to state.collections.collections. Use a narrow selector from src/selectors/collections (for example, selectCollectionByUid or selectItemByUid).'
+ },
+ {
+ selector:
+ 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="collections"][object.type="Identifier"]',
+ message:
+ 'Do not subscribe to the whole state.collections slice. Select only the data the component needs from src/selectors/collections.'
+ },
+ {
+ selector:
+ 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="tabs"][object.type="MemberExpression"][object.property.name="tabs"][object.object.type="Identifier"]',
+ message:
+ 'Do not subscribe to state.tabs.tabs. Use a narrow selector such as selectTabByUid, selectActiveTab, or makeSelectTabsForCollection.'
+ },
+ {
+ selector:
+ 'CallExpression[callee.name="useSelector"] > ArrowFunctionExpression > MemberExpression.body[property.name="tabs"][object.type="Identifier"]',
+ message:
+ 'Do not subscribe to the whole state.tabs slice. Select only the data the component needs from src/selectors/tab.'
+ }
+ ]
+ }
+ },
{
// It prevents lint errors when using CommonJS exports (module.exports) in Jest mocks.
files: ['packages/bruno-app/src/test-utils/mocks/codemirror.js'],
diff --git a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js
index 6983025d26f..3bc2c4a681d 100644
--- a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js
+++ b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js
@@ -1,14 +1,15 @@
import React, { useMemo, useRef } from 'react';
import { useSelector } from 'react-redux';
-import { produce } from 'immer';
-import find from 'lodash/find';
import get from 'lodash/get';
import {
+ findCollectionByUid,
findItemInCollection,
findItemInCollectionByPathname,
getGlobalEnvironmentVariables,
getGlobalEnvironmentVariablesMasked
} from 'utils/collections';
+import { selectCollections } from 'src/selectors/collections';
+import { selectTabs, selectActiveTabUid } from 'src/selectors/tab';
import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider';
import TabPanelErrorBoundary from 'components/RequestTabPanel/TabPanelErrorBoundary';
import AppView from 'components/AppView';
@@ -25,40 +26,38 @@ const APP_CAPABLE_TAB_TYPES = new Set([
]);
const AppPreviewKeepAlive = () => {
- const tabs = useSelector((state) => state.tabs.tabs);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
- const _collections = useSelector((state) => state.collections.collections);
+ const tabs = useSelector(selectTabs);
+ const activeTabUid = useSelector(selectActiveTabUid);
+ const collections = useSelector(selectCollections);
const globalEnvironments = useSelector((state) => state.globalEnvironments?.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector(
(state) => state.globalEnvironments?.activeGlobalEnvironmentUid
);
- const collections = useMemo(() => {
- const globalEnvironmentVariables = getGlobalEnvironmentVariables({
- globalEnvironments,
- activeGlobalEnvironmentUid
- });
- const globalEnvSecrets = getGlobalEnvironmentVariablesMasked({
- globalEnvironments,
- activeGlobalEnvironmentUid
- });
- return produce(_collections, (draft) => {
- for (const collection of draft) {
- collection.globalEnvironmentVariables = globalEnvironmentVariables;
- collection.globalEnvSecrets = globalEnvSecrets;
- collection.globalEnvironments = globalEnvironments;
- collection.activeGlobalEnvironmentUid = activeGlobalEnvironmentUid;
- }
- });
- }, [_collections, globalEnvironments, activeGlobalEnvironmentUid]);
-
const everActiveRef = useRef(new Set());
const appTabs = useMemo(() => {
const out = [];
+ let globals = null;
+ const mergedByUid = new Map();
+ const withGlobals = (collection) => {
+ if (!mergedByUid.has(collection.uid)) {
+ if (!globals) {
+ globals = {
+ globalEnvironmentVariables: getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }),
+ globalEnvSecrets: getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid }),
+ globalEnvironments,
+ activeGlobalEnvironmentUid
+ };
+ }
+ mergedByUid.set(collection.uid, { ...collection, ...globals });
+ }
+ return mergedByUid.get(collection.uid);
+ };
+
for (const tab of tabs) {
if (tab.type && !APP_CAPABLE_TAB_TYPES.has(tab.type)) continue;
- const collection = find(collections, (c) => c.uid === tab.collectionUid);
+ const collection = findCollectionByUid(collections, tab.collectionUid);
// File-mode collections render everything through FileEditor.
if (!collection || collection.fileMode) continue;
let item = findItemInCollection(collection, tab.uid);
@@ -68,7 +67,7 @@ const AppPreviewKeepAlive = () => {
if (!item || item.partial || item.loading) continue;
if (item.type === 'app') {
- out.push({ tabUid: tab.uid, collection, item, kind: 'standalone' });
+ out.push({ tabUid: tab.uid, collection: withGlobals(collection), item, kind: 'standalone' });
continue;
}
@@ -77,11 +76,11 @@ const AppPreviewKeepAlive = () => {
&& tab.appPreview !== false;
if (appEnabled) {
const code = get(itemSource, 'app.code', '');
- out.push({ tabUid: tab.uid, collection, item, kind: 'request', code });
+ out.push({ tabUid: tab.uid, collection: withGlobals(collection), item, kind: 'request', code });
}
}
return out;
- }, [tabs, collections]);
+ }, [tabs, collections, globalEnvironments, activeGlobalEnvironmentUid]);
const validUids = new Set(appTabs.map((t) => t.tabUid));
for (const uid of [...everActiveRef.current]) {
diff --git a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js
index 2cb92d8ba4c..147d9ef2858 100644
--- a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js
+++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js
@@ -221,6 +221,19 @@ const EnvVarValueCell = ({
);
};
+const ErrorMessage = React.memo(({ id, error }) => {
+ if (!error) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ );
+});
+
const EnvironmentVariablesTable = ({
environment,
inheritedEnvironmentVariables = [],
@@ -371,12 +384,12 @@ const EnvironmentVariablesTable = ({
);
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables;
// `_collection` flows into every row's MultiLineEditor as the variable-resolution
- // context. Without memoization, `cloneDeep(collection)` runs on every render —
- // and Formik triggers a re-render on every keystroke, so a single env edit
- // session can deep-clone the entire collection 100+ times. That's the
- // dominant cost behind the test-budget flake.
+ // context. The copy exists only so the three fields below can be attached without
+ // writing to Redux state, so a shallow spread is enough, every consumer
+ // (getAllVariables, mergeVars, brunoVarInfo) reads the nested structures and never
+ // mutates them
const _collection = useMemo(() => {
- const c = collection ? cloneDeep(collection) : {};
+ const c = collection ? { ...collection } : {};
c.globalEnvironmentVariables = globalEnvironmentVariables;
c.globalEnvSecrets = globalEnvSecrets;
c.globalEnvironments = globalEnvironments;
@@ -606,32 +619,6 @@ const EnvironmentVariablesTable = ({
const duplicateSecretNames = useMemo(() => getDuplicateSecretNames(formik.values), [formik.values]);
- const ErrorMessage = ({ name, index }) => {
- const meta = formik.getFieldMeta(name);
- const id = `error-${name}-${index}`;
-
- const isLastRow = index === formik.values.length - 1;
- const variable = formik.values[index];
- const isEmptyRow = !variable?.name || variable.name.trim() === '';
-
- if (isLastRow && isEmptyRow) {
- return null;
- }
-
- const isDuplicateSecret = variable?.secret && !isEmptyRow && duplicateSecretNames.has(variable.name.trim());
- const error = meta.error || (isDuplicateSecret ? DUPLICATE_SECRET_NAME_FIELD_ERROR : null);
-
- if (!error) {
- return null;
- }
- return (
-
-
-
-
- );
- };
-
const handleRemoveVar = useCallback(
(id) => {
const currentValues = formik.values;
@@ -1073,6 +1060,12 @@ const EnvironmentVariablesTable = ({
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
+ const isDuplicateSecret
+ = variable.secret && !isEmptyRow && duplicateSecretNames.has(variable.name.trim());
+ const rowError = isLastEmptyRow
+ ? null
+ : formik.getFieldMeta(`${actualIndex}.name`).error
+ || (isDuplicateSecret ? DUPLICATE_SECRET_NAME_FIELD_ERROR : null);
return (
<>
@@ -1121,7 +1114,10 @@ const EnvironmentVariablesTable = ({
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
-
+
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
index b7788b0713a..6eac26985ef 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
@@ -15,13 +15,16 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, inherite
const environmentsDraft = collection?.environmentsDraft;
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
- // Check for non-secret variables used in sensitive fields
+ const collectionItems = collection?.items;
+ const collectionRoot = collection?.root;
+ const environmentVariables = environment?.variables;
+
const nonSecretSensitiveVarUsageMap = useMemo(() => {
const result = {};
- if (!collection || !environment?.variables) {
+ if (!environmentVariables) {
return result;
}
- const nonSecretVars = environment.variables.filter((v) => v.enabled && !v.secret && v.name);
+ const nonSecretVars = environmentVariables.filter((v) => v.enabled && !v.secret && v.name);
if (!nonSecretVars.length) {
return result;
}
@@ -45,12 +48,12 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, inherite
return item.root;
};
- const collectionObj = getObjectToProcess(collection);
+ const collectionObj = collectionRoot;
sensitiveFields.forEach((fieldPath) => {
checkSensitiveField(collectionObj, fieldPath);
});
- const items = flattenItems(collection.items || []);
+ const items = flattenItems(collectionItems || []);
items.forEach((item) => {
const objToProcess = getObjectToProcess(item);
sensitiveFields.forEach((fieldPath) => {
@@ -58,7 +61,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, inherite
});
});
return result;
- }, [collection, environment]);
+ }, [collectionItems, collectionRoot, environmentVariables]);
const hasSensitiveUsage = useCallback((name) => !!nonSecretSensitiveVarUsageMap[name], [nonSecretSensitiveVarUsageMap]);
diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.js b/packages/bruno-app/src/components/GlobalSearchModal/index.js
index ff891cacf01..370f978f8a8 100644
--- a/packages/bruno-app/src/components/GlobalSearchModal/index.js
+++ b/packages/bruno-app/src/components/GlobalSearchModal/index.js
@@ -1,5 +1,7 @@
-import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
-import { useSelector, useDispatch } from 'react-redux';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { useSelector, useDispatch, useStore } from 'react-redux';
+import { selectCollections, selectActiveWorkspace } from 'src/selectors/collections';
+import { selectTabs } from 'src/selectors/tab';
import {
IconSearch,
IconX,
@@ -27,23 +29,21 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
const debounceTimeoutRef = useRef(null);
const dispatch = useDispatch();
- const allCollections = useSelector((state) => state.collections.collections);
- const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
- const tabs = useSelector((state) => state.tabs.tabs);
+ const store = useStore();
+ const activeWorkspace = useSelector(selectActiveWorkspace);
- const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
-
- const collections = useMemo(() => {
+ const getCollections = () => {
+ const allCollections = selectCollections(store.getState());
if (!activeWorkspace) return allCollections;
const workspacePaths = new Set(
activeWorkspace.collections?.map((wc) => normalizePath(wc.path)) || []
);
return allCollections.filter((c) => workspacePaths.has(normalizePath(c.pathname)));
- }, [activeWorkspace, allCollections, workspaces]);
+ };
const createCollectionResults = () => {
- const collectionResults = collections.map((collection) => ({
+ const collectionResults = getCollections().map((collection) => ({
type: SEARCH_TYPES.COLLECTION,
item: collection,
name: collection.name,
@@ -65,7 +65,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
results.push(DOCUMENTATION_RESULT);
}
- collections.forEach((collection) => {
+ getCollections().forEach((collection) => {
// Search collection name
if (searchTerms.every((term) => collection.name.toLowerCase().includes(term))) {
results.push({
@@ -170,10 +170,10 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
debounceTimeoutRef.current = setTimeout(() => {
performSearch(searchQuery);
}, SEARCH_CONFIG.DEBOUNCE_DELAY);
- }, [collections]); // Depend on collections to recreate when they change
+ }, [activeWorkspace]); // collections are read from the store when the search runs
const expandItemPath = (result) => {
- const collection = collections.find((c) => c.uid === result.collectionUid);
+ const collection = getCollections().find((c) => c.uid === result.collectionUid);
if (!collection) return;
ensureCollectionIsMounted(collection);
@@ -246,7 +246,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
};
const handleResultSelection = (result) => {
- const targetCollection = collections.find((c) => c.uid === result.collectionUid);
+ const targetCollection = getCollections().find((c) => c.uid === result.collectionUid);
ensureCollectionIsMounted(targetCollection);
if (result.type === SEARCH_TYPES.DOCUMENTATION) {
@@ -258,7 +258,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
expandItemPath(result);
if (result.type === SEARCH_TYPES.REQUEST) {
- const existingTab = tabs.find((tab) => tab.uid === result.item.uid);
+ const existingTab = selectTabs(store.getState()).find((tab) => tab.uid === result.item.uid);
if (existingTab) {
dispatch(focusTab({ uid: result.item.uid }));
diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js
index 0eb87fa7f55..65cf5fdffb1 100644
--- a/packages/bruno-app/src/components/MultiLineEditor/index.js
+++ b/packages/bruno-app/src/components/MultiLineEditor/index.js
@@ -264,20 +264,21 @@ class MultiLineEditor extends Component {
// event loop.
this.ignoreChangeEvent = true;
- let variables = getAllVariables(this.props.collection, this.props.item);
- if (!isEqual(variables, this.variables)) {
- if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
- this.editor.options.brunoVarInfo.variables = variables;
+ if (this.props.collection !== prevProps.collection || this.props.item !== prevProps.item) {
+ const variables = getAllVariables(this.props.collection, this.props.item);
+ if (!isEqual(variables, this.variables)) {
+ if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
+ this.editor.options.brunoVarInfo.variables = variables;
+ }
+ this.addOverlay(variables);
}
- this.addOverlay(variables);
}
- // Update collection and item when they change
if (this.props.enableBrunoVarInfo !== false && this.editor.options.brunoVarInfo) {
- if (!isEqual(this.props.collection, this.editor.options.brunoVarInfo.collection)) {
+ if (this.props.collection !== this.editor.options.brunoVarInfo.collection) {
this.editor.options.brunoVarInfo.collection = this.props.collection;
}
- if (!isEqual(this.props.item, this.editor.options.brunoVarInfo.item)) {
+ if (this.props.item !== this.editor.options.brunoVarInfo.item) {
this.editor.options.brunoVarInfo.item = this.props.item;
}
}
@@ -333,7 +334,9 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly || false);
}
if (this.props.mode !== prevProps.mode && this.editor) {
- this.addOverlay(variables);
+ // `this.variables` is kept in sync by addOverlay(), so it is always the current
+ // variable set — no need to re-derive it just to re-apply the mode.
+ this.addOverlay(this.variables);
}
if (this.props.placeholder !== prevProps.placeholder && this.editor) {
this.editor.setOption('placeholder', this.props.placeholder);
diff --git a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js
index 6bf215bff43..8ad60776ac0 100644
--- a/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js
+++ b/packages/bruno-app/src/components/RequestPane/WsBody/SingleWSMessage/index.js
@@ -7,9 +7,10 @@ import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import React, { useMemo, useState, useEffect, useCallback } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector, useStore } from 'react-redux';
import { queueWsMessage, ensureWsConnection } from 'utils/network/index';
-import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index';
+import { findEnvironmentInCollection } from 'utils/collections/index';
+import { selectCollectionByUid } from 'src/selectors/collections';
import toast from 'react-hot-toast';
import WSRequestBodyMode from '../BodyMode/index';
import StyledWrapper from './StyledWrapper';
@@ -47,7 +48,8 @@ export const SingleWSMessage = ({
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
- const collections = useSelector((state) => state.collections.collections);
+
+ const store = useStore();
const { name, content, type } = message;
const displayMode = typeToMode(type);
@@ -162,7 +164,7 @@ export const SingleWSMessage = ({
const onSendMessage = useCallback(async () => {
try {
- const col = findCollectionByUid(collections, collection.uid);
+ const col = selectCollectionByUid(store.getState(), collection.uid);
const environment = resolveEnvironmentInheritance({
environments: col?.environments,
targetEnvironment: findEnvironmentInCollection(col, col?.activeEnvironmentUid)
@@ -178,7 +180,7 @@ export const SingleWSMessage = ({
} catch (err) {
toast.error(err.message || 'Failed to send message');
}
- }, [collections]);
+ }, [store, item, collection.uid, index]);
return (
{
const dispatch = useDispatch();
- const tabs = useSelector((state) => state.tabs.tabs);
- const focusedTab = find(tabs, (t) => t.uid === tabUid);
- const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type);
+ const isClosable = useSelector((state) => {
+ const tab = selectTabByUid(state, tabUid);
+ return !tab || !NON_CLOSABLE_TAB_TYPES.includes(tab.type);
+ });
const { theme } = useTheme();
const handleClose = () => {
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index fdf8e2d6e26..d168d15e81c 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -1,5 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
-import find from 'lodash/find';
import get from 'lodash/get';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
@@ -23,8 +22,8 @@ import { DocExplorer } from '@usebruno/graphql-docs';
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 { makeSelectCollectionWithGlobals, selectCollectionByUid, selectActiveWorkspace } from 'src/selectors/collections';
+import { selectActiveTab, selectActiveTabUid } from 'src/selectors/tab';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
@@ -69,13 +68,10 @@ const aiAutoCollapsedTabs = new Set();
const RequestTabPanel = () => {
const dispatch = useDispatch();
- const tabs = useSelector((state) => state.tabs.tabs);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
- const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
- const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
- const _collections = useSelector((state) => state.collections.collections);
+ const activeTabUid = useSelector(selectActiveTabUid);
+ const focusedTab = useSelector(selectActiveTab);
const preferences = useSelector((state) => state.app.preferences);
- const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
+ const activeWorkspace = useSelector(selectActiveWorkspace);
const resolvedMockServerInstance = useSelector((state) => {
if (!focusedTab || (focusedTab.type !== 'mock-server' && focusedTab.type !== 'mock-response')) {
return null;
@@ -83,7 +79,6 @@ const RequestTabPanel = () => {
return resolveMockServerInstance(state, focusedTab);
});
- const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const isAiSidebarDocked = useSelector((state) => state.chat.isOpen && !state.chat.isPoppedOut);
@@ -102,25 +97,19 @@ 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);
+ const selectCollectionWithGlobals = useMemo(makeSelectCollectionWithGlobals, []);
+ const collection = useSelector((state) => selectCollectionWithGlobals(state, 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);
+ // Mock-server tabs may point at a collection other than the focused one.
+ const mockInstanceCollectionUid = resolvedMockServerInstance
+ ? (resolvedMockServerInstance.sourceType === 'collection'
+ ? resolvedMockServerInstance.collectionUid
+ : focusedTab?.collectionUid)
+ : undefined;
+ const mockInstanceCollectionRaw = useSelector((state) => selectCollectionByUid(state, mockInstanceCollectionUid));
+ const mockInstanceCollection = mockInstanceCollectionUid && mockInstanceCollectionUid === focusedTab?.collectionUid
+ ? collection
+ : mockInstanceCollectionRaw ?? null;
const isItemsLoading = useMemo(() => {
return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
@@ -458,11 +447,7 @@ const RequestTabPanel = () => {
);
}
- const instanceCollection = instance.sourceType === 'collection'
- ? find(collections, (c) => c.uid === instance.collectionUid)
- : (focusedTab.collectionUid ? find(collections, (c) => c.uid === focusedTab.collectionUid) : null);
-
- return ;
+ return ;
}
if (focusedTab.type === 'mock-response') {
@@ -475,14 +460,10 @@ const RequestTabPanel = () => {
);
}
- const instanceCollection = instance.sourceType === 'collection'
- ? find(collections, (c) => c.uid === instance.collectionUid)
- : (focusedTab.collectionUid ? find(collections, (c) => c.uid === focusedTab.collectionUid) : null);
-
return (
);
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js
index 535ec7c118e..7f516398f1c 100644
--- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector, useStore, shallowEqual } from 'react-redux';
import {
IconCategory,
IconBox,
@@ -29,8 +29,10 @@ import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collection
import { toggleAiSidebar } from 'providers/ReduxStore/slices/chat';
import { showMigrateToYmlModal } from 'providers/ReduxStore/slices/collection-migration';
import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections';
-import find from 'lodash/find';
import get from 'lodash/get';
+import isEqual from 'lodash/isEqual';
+import { selectCollections, selectActiveWorkspace } from 'src/selectors/collections';
+import { selectActiveTab, selectActiveTabUid, selectTabs } from 'src/selectors/tab';
import { addTab, focusTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
@@ -64,23 +66,42 @@ const readDismissedCollections = () => {
const CollectionHeader = ({ collection, isScratchCollection }) => {
const dispatch = useDispatch();
- const workspaces = useSelector((state) => state.workspaces.workspaces);
+ const store = useStore();
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
- const collections = useSelector((state) => state.collections.collections);
- const tabs = useSelector((state) => state.tabs.tabs);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+ const activeTabUid = useSelector(selectActiveTabUid);
+ const focusedTab = useSelector(selectActiveTab);
const preferences = useSelector((state) => state.app.preferences);
const isAiEnabled = get(preferences, 'ai.enabled', false);
const isAiSidebarOpen = useSelector((state) => state.chat.isOpen);
// Get the current active workspace
- const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
+ const currentWorkspace = useSelector(selectActiveWorkspace);
+
+ const mountedCollections = useSelector((state) => {
+ const { workspaces } = state.workspaces;
+ const workspaceCollectionPaths = (selectActiveWorkspace(state)?.collections || []).map((wc) => normalizePath(wc.path));
+ return selectCollections(state)
+ .filter((c) => {
+ if (c.mountStatus !== 'mounted') return false;
+ if (workspaces.some((w) => w.scratchCollectionUid === c.uid)) return false;
+ return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === wcPath);
+ })
+ .map((c) => ({ uid: c.uid, name: c.name }));
+ }, isEqual);
+
+ // Open-tab counts per collection, for the badges in the switcher.
+ const tabCountsByCollection = useSelector((state) => {
+ const counts = {};
+ for (const t of selectTabs(state)) {
+ counts[t.collectionUid] = (counts[t.collectionUid] || 0) + 1;
+ }
+ return counts;
+ }, shallowEqual);
const gitRootPath = collection?.git?.gitRootPath;
const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER);
const mockServerInstances = useSelector((state) => getMockServerInstances(state, activeWorkspaceUid));
// Active request (used by the Request / App / File view-mode toggle)
- const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const activeItem = focusedTab && collection
? (findItemInCollection(collection, activeTabUid)
|| (focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null))
@@ -206,22 +227,11 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
const hasOpenApiUpdates = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.hasUpdates;
const hasOpenApiError = hasOpenApiSyncConfigured && collectionUpdates[collection.uid]?.error;
- // Get mounted collections for the current workspace (excluding scratch collections)
- const mountedCollections = collections.filter((c) => {
- if (c.mountStatus !== 'mounted') return false;
-
- const isScratch = workspaces.some((w) => w.scratchCollectionUid === c.uid);
- if (isScratch) return false;
-
- const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || [];
- return workspaceCollectionPaths.some((wcPath) => normalizePath(c.pathname) === normalizePath(wcPath));
- });
-
// Count tabs for the current collection
- const tabCount = tabs.filter((t) => t.collectionUid === collection.uid).length;
+ const tabCount = tabCountsByCollection[collection.uid] || 0;
// Get tab count for a given collection uid
- const getTabCount = (collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid).length;
+ const getTabCount = (collectionUid) => tabCountsByCollection[collectionUid] || 0;
// Get tab count for workspace (scratch collection)
const workspaceTabCount = currentWorkspace?.scratchCollectionUid
@@ -247,7 +257,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
switcherRef.current?.hide();
if (!targetCollection?.uid) return;
- const existingTab = tabs.find((t) => t.collectionUid === targetCollection.uid);
+ const existingTab = selectTabs(store.getState()).find((t) => t.collectionUid === targetCollection.uid);
if (existingTab) {
dispatch(focusTab({ uid: existingTab.uid }));
} else {
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js
index 38e6bb13280..df6912668f9 100644
--- a/packages/bruno-app/src/components/RequestTabs/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/index.js
@@ -1,6 +1,4 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
-import find from 'lodash/find';
-import filter from 'lodash/filter';
import classnames from 'classnames';
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
@@ -12,6 +10,8 @@ import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
import CreateTransientRequest from 'components/CreateTransientRequest';
import ActionIcon from 'ui/ActionIcon/index';
+import { selectCollectionByUid } from 'src/selectors/collections';
+import { selectActiveTab, selectActiveTabUid, makeSelectTabsForCollection } from 'src/selectors/tab';
const RequestTabs = () => {
const dispatch = useDispatch();
@@ -21,13 +21,21 @@ const RequestTabs = () => {
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [tabOverflowStates, setTabOverflowStates] = useState({});
const [showChevrons, setShowChevrons] = useState(false);
- const tabs = useSelector((state) => state.tabs.tabs);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
- const collections = useSelector((state) => state.collections.collections);
+ const activeTabUid = useSelector(selectActiveTabUid);
+ const activeTab = useSelector(selectActiveTab);
+ // Only the active collection: an edit in any other collection must not
+ // re-render the tab strip.
+ const activeCollection = useSelector((state) => selectCollectionByUid(state, activeTab?.collectionUid));
+ // Memoized on the tabs reference, so the array is stable between tab actions.
+ const selectTabsForCollection = useMemo(makeSelectTabsForCollection, []);
+ const collectionRequestTabs = useSelector((state) => selectTabsForCollection(state, activeTab?.collectionUid));
+ const totalTabsCount = useSelector((state) => state.tabs.tabs.length);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
- const workspaces = useSelector((state) => state.workspaces.workspaces);
+ const isScratchCollection = useSelector((state) =>
+ activeCollection ? state.workspaces.workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false
+ );
const createSetHasOverflow = useCallback((tabUid) => {
return (hasOverflow) => {
@@ -43,14 +51,6 @@ const RequestTabs = () => {
};
}, []);
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
- const activeCollection = find(collections, (c) => c?.uid === activeTab?.collectionUid);
- const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
-
- const isScratchCollection = useMemo(() => {
- return activeCollection ? workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false;
- }, [workspaces, activeCollection]);
-
useEffect(() => {
if (!activeTabUid || !activeTab) return;
@@ -73,7 +73,7 @@ const RequestTabs = () => {
const getTabClassname = (tab, index) => {
return classnames('request-tab select-none', {
'active': tab.uid === activeTabUid,
- 'last-tab': tabs && tabs.length && index === tabs.length - 1,
+ 'last-tab': totalTabsCount && index === totalTabsCount - 1,
'has-overflow': tabOverflowStates[tab.uid]
});
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/StyledWrapper.js
similarity index 92%
rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/StyledWrapper.js
index 75608c802f9..b723d8762f1 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/StyledWrapper.js
@@ -162,19 +162,6 @@ const Wrapper = styled.div`
}
}
- .empty-folder-message {
- display: flex;
- align-items: center;
- height: 1.6rem;
- font-size: ${(props) => props.theme.font.size.sm};
- color: ${(props) => props.theme.sidebar.muted};
-
- .add-request-link {
- color: ${(props) => props.theme.textLink};
- cursor: pointer;
- }
- }
-
&.is-sidebar-dragging .collection-item-name {
cursor: inherit;
}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx
similarity index 81%
rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx
index 0d43356097e..2e2a8f13b30 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx
@@ -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';
@@ -22,40 +21,37 @@ import {
IconAppWindow,
IconEyeOff
} from '@tabler/icons';
-import { useSelector, useDispatch, useStore } from 'react-redux';
+import { useSelector, useDispatch, useStore, shallowEqual } 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 { sanitizeName } from 'utils/common/regex';
import { formatIpcError } from 'utils/common/error';
-import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections';
+import { toggleCollectionItem, toggleRequestExamples, addResponseExample } from 'providers/ReduxStore/slices/collections';
import { uuid } from 'utils/common';
import { copyRequest, setFocusedSidebarPath, insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import NewApp from 'components/Sidebar/NewApp';
-import RenameCollectionItem from './RenameCollectionItem';
-import CloneCollectionItem from './CloneCollectionItem';
-import DeleteCollectionItems from './DeleteCollectionItems';
-import IgnoreCollectionItem from './IgnoreCollectionItem';
-import RunCollectionItem from './RunCollectionItem';
-import GenerateCodeItem from './GenerateCodeItem';
+import RenameCollectionItem from '../RenameCollectionItem';
+import DeleteCollectionItems from '../DeleteCollectionItems';
+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 toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
-import CollectionItemInfo from './CollectionItemInfo/index';
-import CollectionItemIcon from './CollectionItemIcon';
-import ExampleItem from './ExampleItem';
+import CollectionItemInfo from '../CollectionItemInfo/index';
+import CollectionItemIcon from '../CollectionItemIcon';
import ExampleIcon from 'components/Icons/ExampleIcon';
import {
getTabUidForItem as getTabUidForItemSelector,
isTabForItemActive as isTabForItemActiveSelector,
isTabForItemPresent as isTabForItemPresentSelector
} from 'src/selectors/tab';
-import { isEqual } from 'lodash';
-import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
+import { selectCollectionByUid } from 'src/selectors/collections';
import {
canCollectionItemBeDropped,
determineCollectionItemDrop,
@@ -63,7 +59,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,25 +69,34 @@ 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 }) => {
+const CollectionItemRow = ({
+ item,
+ depth,
+ collectionUid,
+ collectionPathname,
+ searchText,
+ openBulkMenu,
+ children,
+ 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);
-
- const _tabUidForItemSelector = getTabUidForItemSelector(selectorInput);
- const tabUidForItem = useSelector(_tabUidForItemSelector, isEqual);
+ const { isTabForItemActive, isTabForItemPresent, tabUidForItem } = useSelector(
+ useMemo(() => {
+ const selectorInput = { itemUid: item.uid, itemPathname: item.pathname, collectionUid };
+ const selectActive = isTabForItemActiveSelector(selectorInput);
+ const selectPresent = isTabForItemPresentSelector(selectorInput);
+ const selectTabUid = getTabUidForItemSelector(selectorInput);
+ return (state) => ({
+ isTabForItemActive: selectActive(state),
+ isTabForItemPresent: selectPresent(state),
+ tabUidForItem: selectTabUid(state)
+ });
+ }, [item.uid, item.pathname, collectionUid]),
+ shallowEqual
+ );
const isSidebarDragging = useSelector((state) => state.app.isDragging);
- const collection = useSelector((state) => state.collections.collections.find((c) => c.uid === collectionUid));
const store = useStore();
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids);
@@ -122,7 +126,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
const [newAppModalOpen, setNewAppModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
- const [examplesExpanded, setExamplesExpanded] = useState(false);
+ const examplesExpanded = Boolean(item.examplesExpanded);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
@@ -178,17 +182,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
}
});
- // Auto-scroll to show this item when its tab becomes active
- useEffect(() => {
- if (isTabForItemActive && ref.current) {
- try {
- ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- } catch (err) {
- // ignore scroll errors (some environments may not support smooth scrolling)
- }
- }
- }, [isTabForItemActive]);
-
const resolveDropFromMonitor = (monitor) => {
return determineCollectionItemDrop({
item,
@@ -386,7 +379,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
const handleExamplesCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
- setExamplesExpanded(!examplesExpanded);
+ dispatch(toggleRequestExamples({ collectionUid, itemUid: item.uid }));
};
// prevent the parent's double-click handler from firing
@@ -408,7 +401,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
menuDropdownRef.current?.show();
};
- const indents = range(item.depth);
+ const indents = range(depth);
// Build menu items for MenuDropdown
const buildMenuItems = () => {
@@ -583,11 +576,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
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,14 +628,6 @@ 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 = () => {
if (
(item?.request?.url !== '')
@@ -694,6 +674,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
let targetFolderUid = item.uid;
if (!isFolder) {
+ const collection = selectCollectionByUid(store.getState(), collectionUid);
const parentFolder = findParentItemInCollection(collection, item.uid);
targetFolderUid = parentFolder ? parentFolder.uid : null;
}
@@ -800,7 +781,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
data-testid="folder-chevron"
/>
- ) : hasExamples ? (
+ ) : hasExamples && !hasSearchText ? (
- {!itemIsCollapsed ? (
-
- {folderItems && folderItems.length
- ? folderItems.map((i) => {
- return ;
- })
- : null}
- {appItems && appItems.length
- ? appItems.map((i) => {
- return ;
- })
- : null}
- {requestItems && requestItems.length
- ? requestItems.map((i) => {
- return ;
- })
- : null}
- {showEmptyFolderMessage ? (
-
- {range(item.depth + 1).map((i) => (
-
-
-
- ))}
-
-
-
-
-
-
- ) : null}
-
- ) : null}
-
- {/* Show examples when expanded (only for HTTP requests) */}
- {isItemARequest(item) && item.type === 'http-request' && examplesExpanded && hasExamples && (
-
- {(item.examples || []).map((example, index) => {
- return (
-
- );
- })}
-
- )}
+
+ {children}
);
};
-export default React.memo(CollectionItem);
+export default React.memo(CollectionItemRow);
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js
index d720d8a49fc..02d9189699d 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/StyledWrapper.js
@@ -2,7 +2,11 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
-
+
+ .indent-block {
+ border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
+ }
+
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
visibility: hidden;
@@ -17,7 +21,7 @@ const StyledWrapper = styled.div`
}
}
- .collection-item-name {
+ &.collection-item-name {
height: 1.6rem;
cursor: pointer;
user-select: none;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js
index 1f020d32d4c..f72758c3648 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js
@@ -13,7 +13,6 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
import range from 'lodash/range';
import classnames from 'classnames';
import MenuDropdown from 'ui/MenuDropdown';
-import ActionIcon from 'ui/ActionIcon';
import Modal from 'components/Modal';
import DeleteResponseExampleModal from './DeleteResponseExampleModal';
import GenerateCodeItem from '../GenerateCodeItem';
@@ -21,7 +20,7 @@ import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
-const ExampleItem = ({ example, item, collection }) => {
+const ExampleItem = ({ example, item, collection, depth }) => {
const { dropdownContainerRef } = useSidebarAccordion();
const dispatch = useDispatch();
const activeTabUid = useSelector((state) => state.tabs?.activeTabUid);
@@ -33,8 +32,9 @@ const ExampleItem = ({ example, item, collection }) => {
const exampleRef = useRef(null);
const menuDropdownRef = useRef(null);
- // Calculate indentation: item depth + 1 for examples
- const indents = range((item.depth || 0) + 1);
+ // Indentation comes from the flattener, which already emits example rows one level
+ // deeper than their parent request.
+ const indents = range(depth);
const handleExampleClick = () => {
const exampleIndex = item?.examples?.findIndex((ex) => ex.uid === example.uid);
@@ -64,16 +64,6 @@ const ExampleItem = ({ example, item, collection }) => {
setEditName(example.name);
}, [example.name]);
- useEffect(() => {
- if (isExampleActive && exampleRef.current) {
- try {
- exampleRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- } catch (err) {
- // ignore scroll errors
- }
- }
- }, [isExampleActive]);
-
const handleClone = async () => {
// Calculate the index where the cloned example will be saved
// It will be at the end of the examples array
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/StyledWrapper.js
similarity index 90%
rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/StyledWrapper.js
index 5f23cec174a..abe32315bb7 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/StyledWrapper.js
@@ -96,19 +96,6 @@ const Wrapper = styled.div`
.indent-block {
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
}
-
- .empty-collection-message {
- display: flex;
- align-items: center;
- height: 1.6rem;
- font-size: ${(props) => props.theme.font.size.sm};
- color: ${(props) => props.theme.sidebar.muted};
-
- .add-request-link {
- color: ${(props) => props.theme.textLink};
- cursor: pointer;
- }
- }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx
similarity index 79%
rename from packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx
index 3b3136b6be8..457792f6026 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx
@@ -1,7 +1,6 @@
-import React, { useState, useRef, useEffect } from 'react';
+import React, { useState, useRef, 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 {
@@ -28,45 +27,38 @@ import {
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { toggleCollection, collapseFullCollection, clearSidebarSelection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleMultipleCollectionItemsDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector, useStore } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import NewApp from 'components/Sidebar/NewApp';
-import CollectionItem from './CollectionItem';
-import RemoveCollections from './RemoveCollections';
-import MoveToWorkspace from './MoveToWorkspace';
+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 { getSortedDraggedItems } from 'utils/collections';
import { isTabForItemActive } from 'src/selectors/tab';
-import RenameCollection from './RenameCollection';
+import RenameCollection from '../RenameCollection';
import StyledWrapper from './StyledWrapper';
-import CloneCollection from './CloneCollection';
+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 GenerateDocumentation from '../GenerateDocumentation';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
import ActionIcon from 'ui/ActionIcon';
import MenuDropdown from 'ui/MenuDropdown';
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
-import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
import useKeybinding from 'hooks/useKeybinding';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
import StatusBadge from 'ui/StatusBadge';
import CreateMockServerModal from 'components/MockServer/CreateMockServerModal';
import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick';
-// Delay before showing empty collection state (ms)
-// 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 }) => {
+const CollectionRow = ({ collection, searchText, openBulkMenu, children, isMultiDragDisabled, multiDragCollections }) => {
const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER);
const { dropdownContainerRef } = useSidebarAccordion();
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -81,15 +73,12 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled,
const [showCreateMockServerModal, setShowCreateMockServerModal] = useState(false);
const [dropType, setDropType] = useState(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
- const [showEmptyState, setShowEmptyState] = useState(false);
const dispatch = useDispatch();
const isLoading = collection.isLoading;
const collectionRef = useRef(null);
- // Only count persisted requests and folders; transients and file items
- // (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 }));
+ const selectIsCollectionFocused = useMemo(() => isTabForItemActive({ itemUid: collection.uid }), [collection.uid]);
+ const isCollectionFocused = useSelector(selectIsCollectionFocused);
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids);
const isSelected = selectedSidebarUids.includes(collection.uid);
@@ -103,7 +92,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled,
);
const workspaces = useSelector((state) => state.workspaces.workspaces);
const collectionSortOrder = useSelector((state) => state.collections.collectionSortOrder);
- const allCollections = useSelector((state) => state.collections.collections);
+ const store = useStore();
const isMoveToWorkspaceVisible = isPathExternalToBasePath(activeWorkspace?.pathname, collection.pathname);
const isDragDisabled = isMultiSelected && isMultiDragDisabled;
@@ -327,7 +316,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled,
const draggedItems = getSortedDraggedItems({
draggedItem,
- allCollections,
+ allCollections: store.getState().collections.collections,
workspaces,
activeWorkspace,
collectionSortOrder,
@@ -346,7 +335,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled,
} else {
const draggedItems = getSortedDraggedItems({
draggedItem,
- allCollections,
+ allCollections: store.getState().collections.collections,
workspaces,
activeWorkspace,
collectionSortOrder,
@@ -377,31 +366,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled,
drag(drop(collectionRef));
dragPreview(getEmptyImage(), { captureDraggingState: true });
- useEffect(() => {
- if (isCollectionFocused && collectionRef.current) {
- try {
- collectionRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- } catch (err) {
- // ignore scroll errors
- }
- }
- }, [isCollectionFocused]);
-
- // Debounce showing empty state to prevent flicker
- // Race condition: isLoading can become false before items batch arrives from IPC
- useEffect(() => {
- const isMounted = collection.mountStatus === 'mounted';
- const hasItems = itemCount > 0;
-
- if (hasItems || isLoading || !isMounted) {
- setShowEmptyState(false);
- return;
- }
-
- const timer = setTimeout(() => setShowEmptyState(true), EMPTY_STATE_DELAY_MS);
- return () => clearTimeout(timer);
- }, [itemCount, isLoading, collection.mountStatus]);
-
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
@@ -420,18 +384,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 });
-
const menuItems = [
{
id: 'new-request',
@@ -582,7 +534,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled,
];
return (
-
+
{showNewRequestModal && setShowNewRequestModal(false)} />}
{showNewFolderModal && setShowNewFolderModal(false)} />}
{showNewAppModal && setShowNewAppModal(false)} />}
@@ -660,41 +612,9 @@ const Collection = ({ collection, searchText, openBulkMenu, isMultiDragDisabled,
)}
-
- {!collectionIsCollapsed ? (
-
- {folderItems?.map?.((i) => {
- return ;
- })}
- {appItems?.map?.((i) => {
- return ;
- })}
- {requestItems?.map?.((i) => {
- return ;
- })}
- {showEmptyCollectionMessage ? (
-
-
-
-
-
-
-
-
-
-
- ) : null}
-
- ) : null}
-
+ {children}
);
};
-export default Collection;
+export default CollectionRow;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js
index 525109b3709..ffc2ec36654 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/SelectCollection/index.js
@@ -3,9 +3,10 @@ import Modal from 'components/Modal/index';
import { IconFiles } from '@tabler/icons';
import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
+import { selectCollections } from 'src/selectors/collections';
const SelectCollection = ({ onClose, onSelect, title }) => {
- const { collections } = useSelector((state) => state.collections);
+ const collections = useSelector(selectCollections);
return (
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/StyledWrapper.js
new file mode 100644
index 00000000000..4ca8a931ce2
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/StyledWrapper.js
@@ -0,0 +1,22 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ .empty-cta-message {
+ display: flex;
+ align-items: center;
+ height: 1.6rem;
+ font-size: ${(props) => props.theme.font.size.sm};
+ color: ${(props) => props.theme.sidebar.muted};
+
+ .add-request-link {
+ color: ${(props) => props.theme.textLink};
+ cursor: pointer;
+ }
+ }
+
+ .indent-block {
+ border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/index.jsx
new file mode 100644
index 00000000000..d67a78b569d
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/index.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import range from 'lodash/range';
+import { useDispatch } from 'react-redux';
+import MenuDropdown from 'ui/MenuDropdown';
+import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
+import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
+import StyledWrapper from './StyledWrapper';
+
+const EmptyCtaRow = ({ collection, itemUid = null, depth = 1 }) => {
+ const { dropdownContainerRef } = useSidebarAccordion();
+ const dispatch = useDispatch();
+
+ if (!collection) return null;
+
+ const menuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid });
+ const testId = itemUid ? 'add-request-cta-folder' : 'add-request-cta';
+
+ return (
+
+
+ {range(depth).map((i) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+
+export default React.memo(EmptyCtaRow);
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx
new file mode 100644
index 00000000000..515b5946c80
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import CollectionRow from '../Collection/CollectionRow';
+import CollectionItemRow from '../Collection/CollectionItem/CollectionItemRow';
+import GitRemoteCollectionRow from '../GitRemoteCollectionRow';
+import ExampleItem from '../Collection/CollectionItem/ExampleItem';
+import EmptyCtaRow from './EmptyCtaRow';
+
+const resolveRowObject = ({ row, itemsByUid, collectionsByUid, ghostsByPath }) => {
+ switch (row.kind) {
+ case 'collection':
+ case 'empty-cta':
+ // collection header and collection-root empty-cta both key off collectionUid
+ return collectionsByUid.get(row.collectionUid);
+ case 'folder':
+ case 'app':
+ case 'request':
+ case 'example':
+ // example rows resolve to their parent request.
+ return itemsByUid.get(row.itemUid);
+ case 'ghost':
+ return ghostsByPath.get(row.collectionPathname);
+ default:
+ return undefined;
+ }
+};
+
+const renderRow = (props) => {
+ const { row, searchText, openBulkMenu, collectionsByUid, isMultiDragDisabled, multiDragCollections, multiDragItems } = props;
+ const resolved = resolveRowObject(props);
+
+ switch (row.kind) {
+ case 'collection': {
+ if (!resolved) return null;
+ return (
+
+ );
+ }
+ case 'folder':
+ case 'app':
+ case 'request': {
+ if (!resolved) return null;
+ return (
+
+ );
+ }
+ case 'empty-cta': {
+ return ;
+ }
+ case 'ghost': {
+ if (!resolved) return null;
+ return ;
+ }
+ case 'example': {
+ const item = resolved;
+ const collection = collectionsByUid.get(row.collectionUid);
+ const example = item?.examples?.[row.exampleIndex];
+ if (!item || !collection || !example) return null;
+ return ;
+ }
+ default:
+ return null;
+ }
+};
+
+const SidebarRow = (props) => {
+ const { row } = props;
+ const inner = renderRow(props);
+ if (inner === null) return null;
+ return (
+
+ {inner}
+
+ );
+};
+
+// Compare row values instead of object identity because flattening creates new row objects
+// on every rebuild.
+const areEqual = (prev, next) => {
+ const a = prev.row;
+ const b = next.row;
+ return (
+ a.kind === b.kind
+ && a.id === b.id
+ && a.depth === b.depth
+ && a.itemUid === b.itemUid
+ && a.collectionUid === b.collectionUid
+ && a.collectionId === b.collectionId
+ && a.parentName === b.parentName
+ && a.collectionPathname === b.collectionPathname
+ && a.exampleIndex === b.exampleIndex
+ && prev.searchText === next.searchText
+ && prev.isMultiDragDisabled === next.isMultiDragDisabled
+ && prev.multiDragCollections === next.multiDragCollections
+ && prev.multiDragItems === next.multiDragItems
+ && resolveRowObject(prev) === resolveRowObject(next)
+ );
+};
+
+export default React.memo(SidebarRow, areEqual);
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js
index 0235625c563..a8b9dcdc674 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -1,22 +1,30 @@
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
-import Collection from './Collection';
-import GitRemoteCollectionRow from './GitRemoteCollectionRow';
+import { Virtuoso } from 'react-virtuoso';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import CollectionSearch from './CollectionSearch/index';
import InlineCollectionCreator from './InlineCollectionCreator';
+import SidebarRow from './SidebarRow';
import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections';
import { buildSidebarEntries, getSelectionInfo } from 'utils/collections/index';
+import { flattenSidebarTree, buildIndexes } from 'utils/collections/flattenSidebarTree';
import { CollectionItemDragPreview } from './Collection/CollectionItem/CollectionItemDragPreview';
import useBulkActionsMenu from 'hooks/useBulkActionsMenu';
import BulkActionsMenu from 'components/Sidebar/Collections/BulkActionsMenu';
+import { selectCollections, selectCollectionSortOrder, selectSelectedSidebarUids } from 'src/selectors/collections';
+import { selectActiveTabUid } from 'src/selectors/tab';
const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => {
const [searchText, setSearchText] = useState('');
- const { collections, collectionSortOrder, selectedSidebarUids } = useSelector((state) => state.collections);
- const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
+ const collections = useSelector(selectCollections);
+ const collectionSortOrder = useSelector(selectCollectionSortOrder);
+ const selectedSidebarUids = useSelector(selectSelectedSidebarUids);
+ const workspaces = useSelector((state) => state.workspaces.workspaces);
+ const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
+ const activeTabUid = useSelector(selectActiveTabUid);
const dispatch = useDispatch();
+ const virtuosoRef = useRef(null);
const { openBulkMenu, menuProps } = useBulkActionsMenu();
@@ -31,6 +39,22 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
[activeWorkspace, collections, workspaces, collectionSortOrder]
);
+ // Flatten the tree into ordered rows. itemsByUid / collectionsByUid resolve a row's live object.
+ const { rows, itemsByUid, collectionsByUid } = useMemo(
+ () => flattenSidebarTree(sidebarEntries, { searchText }),
+ [sidebarEntries, searchText]
+ );
+
+ // Ghost rows carry only path/name. GitRemoteCollectionRow needs the full entry (for `remote`).
+ const ghostsByPath = useMemo(() => {
+ const map = new Map();
+ for (const entry of sidebarEntries) {
+ if (entry.kind === 'ghost' && entry.entry?.path) map.set(entry.entry.path, entry.entry);
+ }
+ return map;
+ }, [sidebarEntries]);
+
+ // Multi-select drag context, computed once for the whole list and threaded to rows via SidebarRow.
const selectionInfo = useMemo(
() => (selectedSidebarUids.length > 1 ? getSelectionInfo({ collections, selectedUids: selectedSidebarUids }) : null),
[collections, selectedSidebarUids]
@@ -48,10 +72,27 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
return selectionInfo.effectiveSelection.map((entry) => ({ ...entry.item, sourceCollectionUid: entry.collectionUid }));
}, [selectionInfo]);
+ const { rowIndexByItemUid, rowIndexByCollectionUid } = useMemo(() => buildIndexes(rows), [rows]);
+
+ // Resolve the active tab's row index (item rows first, then collection headers).
+ const rowIndex = rowIndexByItemUid.get(activeTabUid);
+ const activeRowIndex = activeTabUid !== null
+ ? (rowIndex ?? rowIndexByCollectionUid.get(activeTabUid) ?? null)
+ : null;
+
+ useEffect(() => {
+ if (activeRowIndex === null) return;
+ virtuosoRef.current?.scrollIntoView({ index: activeRowIndex, behavior: 'smooth' });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTabUid]);
+
+ // Clear multi-selection only when clicking the bare scroller background.
+ // The `contains` guard ignores events propagated from portaled menus/modals in .
+ // The `[data-sidebar-row]` check covers all row types and inline menus/modals rendered within a row.
const handleContainerClick = (e) => {
- if (e.currentTarget === e.target) {
- dispatch(clearSidebarSelection());
- }
+ if (!e.currentTarget.contains(e.target)) return;
+ if (e.target.closest('[data-sidebar-row]')) return;
+ dispatch(clearSidebarSelection());
};
if (!sidebarEntries.length) {
@@ -75,33 +116,40 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
)}
+ {isCreatingCollection && (
+
+ )}
+
- {isCreatingCollection && (
-
- )}
- {sidebarEntries.map((entry) => {
- if (entry.kind === 'loaded') {
- return (
-
- );
- }
- return ;
- })}
+ row.id}
+ defaultItemHeight={26}
+ increaseViewportBy={{ top: 400, bottom: 600 }}
+ itemContent={(_, row) => (
+
+ )}
+ />
diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
index 026e68a541b..d526c3682cb 100644
--- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js
@@ -1,7 +1,7 @@
-import { useState, useMemo } from 'react';
+import { useState } from 'react';
import toast from 'react-hot-toast';
import get from 'lodash/get';
-import { useDispatch, useSelector } from 'react-redux';
+import { useDispatch, useSelector, useStore } from 'react-redux';
import {
IconArrowsSort,
IconDotsVertical,
@@ -21,6 +21,7 @@ import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { savePreferences, setIsCreatingCollection, setIsOpeningCollection, toggleSidebarSearch } from 'providers/ReduxStore/slices/app';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
+import { selectCollections, selectCollectionSortOrder, selectActiveWorkspace } from 'src/selectors/collections';
import { sanitizeName } from 'utils/common/regex';
import filter from 'lodash/filter';
@@ -44,12 +45,11 @@ const CollectionsSection = () => {
const dispatch = useDispatch();
const showSearch = useSelector((state) => state.app.showSidebarSearch);
- const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
- const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
+ const activeWorkspace = useSelector(selectActiveWorkspace);
- const { collections } = useSelector((state) => state.collections);
- const { collectionSortOrder } = useSelector((state) => state.collections);
- const { isCreatingCollection } = useSelector((state) => state.app);
+ const store = useStore();
+ const collectionSortOrder = useSelector(selectCollectionSortOrder);
+ const isCreatingCollection = useSelector((state) => state.app.isCreatingCollection);
const preferences = useSelector((state) => state.app.preferences);
const [collectionsToClose, setCollectionsToClose] = useState([]);
@@ -88,16 +88,18 @@ const CollectionsSection = () => {
});
};
- const workspaceCollections = useMemo(() => {
+ const getWorkspaceCollections = () => {
if (!activeWorkspace) return [];
+ const state = store.getState();
+ const { workspaces } = state.workspaces;
- return collections.filter((c) => {
+ return selectCollections(state).filter((c) => {
if (isScratchCollection(c, workspaces)) {
return false;
}
return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
});
- }, [activeWorkspace, collections, workspaces]);
+ };
const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => {
setImportCollectionModalOpen(false);
@@ -176,7 +178,7 @@ const CollectionsSection = () => {
};
const selectAllCollectionsToClose = () => {
- setCollectionsToClose(workspaceCollections.map((c) => c.uid));
+ setCollectionsToClose(getWorkspaceCollections().map((c) => c.uid));
};
const clearCollectionsToClose = () => {
@@ -194,7 +196,7 @@ const CollectionsSection = () => {
return;
}
- const scratchCollection = collections.find((c) => c.uid === scratchCollectionUid);
+ const scratchCollection = selectCollections(store.getState()).find((c) => c.uid === scratchCollectionUid);
if (!scratchCollection) {
toast.error('Unable to create request');
return;
diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js
index 7a44ef1dbad..bda6db279ab 100644
--- a/packages/bruno-app/src/components/StatusBar/index.js
+++ b/packages/bruno-app/src/components/StatusBar/index.js
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
-import find from 'lodash/find';
+import { selectActiveTab } from 'src/selectors/tab';
import { IconSettings, IconCookie, IconTool, IconSearch, IconPalette, IconBrandGithub } from '@tabler/icons';
import Mousetrap from 'mousetrap';
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
@@ -18,12 +18,7 @@ const StatusBar = () => {
const dispatch = useDispatch();
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const workspaces = useSelector((state) => state.workspaces.workspaces);
- const showHomePage = useSelector((state) => state.app.showHomePage);
- const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
- const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
- const tabs = useSelector((state) => state.tabs.tabs);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = useSelector(selectActiveTab);
const logs = useSelector((state) => state.logs.logs);
const [cookiesOpen, setCookiesOpen] = useState(false);
const { version } = useApp();
diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js
index 64200e52ee4..8713d2fa18a 100644
--- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js
+++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js
@@ -1,5 +1,6 @@
import React, { useState, useMemo, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
+import { selectCollections } from 'src/selectors/collections';
import {
IconBox,
IconTrash,
@@ -30,7 +31,7 @@ import StyledWrapper from './StyledWrapper';
const CollectionsList = ({ workspace }) => {
const dispatch = useDispatch();
- const { collections } = useSelector((state) => state.collections);
+ const collections = useSelector(selectCollections);
const dropdownRefs = useRef({});
const [renameCollectionModalOpen, setRenameCollectionModalOpen] = useState(false);
diff --git a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js
index 030b23ec382..daf6cdedb5c 100644
--- a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js
+++ b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js
@@ -1,4 +1,3 @@
-import find from 'lodash/find';
import {
updateRequestPaneTabHeight,
updateRequestPaneTabWidth,
@@ -8,16 +7,16 @@ import {
expandResponsePane
} from 'providers/ReduxStore/slices/tabs';
import { useDispatch, useSelector } from 'react-redux';
+import { selectTabByUid } from 'src/selectors/tab';
const MIN_TOP_PANE_HEIGHT = 380;
export function useTabPaneBoundaries(activeTabUid) {
const DEFAULT_PANE_WIDTH_DIVISOR = 2.2;
- const tabs = useSelector((state) => state.tabs.tabs);
- const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
+ const focusedTab = useSelector((state) => selectTabByUid(state, activeTabUid));
const screenWidth = useSelector((state) => state.app.screenWidth);
- let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
+ const asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const isSidebarHidden = useSelector((state) => state.app.sidebarCollapsed);
const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR;
const top = focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT;
@@ -56,7 +55,7 @@ export function useTabPaneBoundaries(activeTabUid) {
dispatch(expandResponsePane({ uid: activeTabUid }));
},
reset() {
- let usableAsideWidth = isSidebarHidden ? 0 : asideWidth;
+ const usableAsideWidth = isSidebarHidden ? 0 : asideWidth;
dispatch(expandRequestPane({ uid: activeTabUid }));
dispatch(expandResponsePane({ uid: activeTabUid }));
dispatch(updateRequestPaneTabHeight({
diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js
index 5183253c4cf..c5396b28744 100644
--- a/packages/bruno-app/src/providers/Hotkeys/index.js
+++ b/packages/bruno-app/src/providers/Hotkeys/index.js
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import find from 'lodash/find';
import Mousetrap from 'mousetrap';
-import { useSelector, useDispatch } from 'react-redux';
+import { useSelector, useDispatch, useStore } from 'react-redux';
import NewRequest from 'components/Sidebar/NewRequest';
import GlobalSearchModal from 'components/GlobalSearchModal';
import SaveRequestsModal from 'providers/App/ConfirmAppClose/SaveRequestsModal';
@@ -16,14 +16,18 @@ import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
import { isEnvironmentValidationError } from 'utils/environments';
import toast from 'react-hot-toast';
import { getKeyBindingsForActionAllOS } from './keyMappings';
+import { selectCollections } from 'src/selectors/collections';
+import { selectTabs, selectActiveTabUid } from 'src/selectors/tab';
export const HotkeysContext = React.createContext();
export const HotkeysProvider = (props) => {
const dispatch = useDispatch();
- const tabs = useSelector((state) => state.tabs.tabs);
- const collections = useSelector((state) => state.collections.collections);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+ const store = useStore();
+ const activeTabUid = useSelector(selectActiveTabUid);
+
+ const getTabs = () => selectTabs(store.getState());
+ const getCollections = () => selectCollections(store.getState());
const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings);
const keybindingsEnabled = useSelector((state) => state.app.preferences?.keybindingsEnabled !== false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
@@ -34,9 +38,9 @@ export const HotkeysProvider = (props) => {
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const getCurrentCollection = () => {
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = find(getTabs(), (t) => t.uid === activeTabUid);
if (activeTab) {
- const collection = findCollectionByUid(collections, activeTab.collectionUid);
+ const collection = findCollectionByUid(getCollections(), activeTab.collectionUid);
return collection;
}
@@ -44,9 +48,9 @@ export const HotkeysProvider = (props) => {
// Get tabs scoped to the active tab's collection
const getCollectionTabs = () => {
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = find(getTabs(), (t) => t.uid === activeTabUid);
if (!activeTab) return [];
- return tabs.filter((t) => t.collectionUid === activeTab.collectionUid);
+ return getTabs().filter((t) => t.collectionUid === activeTab.collectionUid);
};
// Helper: get Mousetrap combos for an action, merged with user overrides
@@ -69,9 +73,9 @@ export const HotkeysProvider = (props) => {
// edit environments
useEffect(() => {
bindAction('editEnvironment', (e) => {
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = find(getTabs(), (t) => t.uid === activeTabUid);
if (activeTab) {
- const collection = findCollectionByUid(collections, activeTab.collectionUid);
+ const collection = findCollectionByUid(getCollections(), activeTab.collectionUid);
if (collection) {
dispatch(
@@ -90,7 +94,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('editEnvironment');
};
- }, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// global search
useEffect(() => {
@@ -119,7 +123,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('switchToPreviousTab');
};
- }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// Switch to the next tab (active-collection-tabs-only)
useEffect(() => {
@@ -135,7 +139,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('switchToNextTab');
};
- }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// Switch to tab at position (Cmd+1 through Cmd+8) and last tab (Cmd+9) — collection-scoped
useEffect(() => {
@@ -165,17 +169,17 @@ export const HotkeysProvider = (props) => {
}
unbindAction('switchToLastTab');
};
- }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// Close all tabs
useEffect(() => {
bindAction('closeAllTabs', (e) => {
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = find(getTabs(), (t) => t.uid === activeTabUid);
if (activeTab) {
- const collection = findCollectionByUid(collections, activeTab.collectionUid);
+ const collection = findCollectionByUid(getCollections(), activeTab.collectionUid);
if (collection) {
- const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
+ const tabUids = getTabs().filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
setTabUidsToClose(tabUids);
setShowSaveRequestsModal(true);
}
@@ -187,12 +191,12 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('closeAllTabs');
};
- }, [activeTabUid, tabs, collections, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, userKeyBindings, keybindingsEnabled]);
// Reopen last closed tab (active-collection-tabs-only)
useEffect(() => {
bindAction('reopenLastClosedTab', (e) => {
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = find(getTabs(), (t) => t.uid === activeTabUid);
if (activeTab?.collectionUid) {
dispatch(reopenClosedTab({ collectionUid: activeTab.collectionUid }));
} else {
@@ -204,7 +208,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('reopenLastClosedTab');
};
- }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// Save all tabs (active-collection-tabs-only)
useEffect(() => {
@@ -262,7 +266,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('saveAllTabs');
};
- }, [activeTabUid, tabs, collections, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// Collapse sidebar
useEffect(() => {
@@ -298,16 +302,16 @@ export const HotkeysProvider = (props) => {
}
// 2. No sidebar focus → check active tab type
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = find(getTabs(), (t) => t.uid === activeTabUid);
if (activeTab) {
if (activeTab.type === 'collection-settings' && activeTab.collectionUid) {
- const collection = findCollectionByUid(collections, activeTab.collectionUid);
+ const collection = findCollectionByUid(getCollections(), activeTab.collectionUid);
if (collection?.pathname) {
openDevtoolsAndSwitchToTerminal(dispatch, collection.pathname);
return false;
}
} else if (activeTab.type === 'folder-settings' && activeTab.collectionUid && activeTab.uid) {
- const collection = findCollectionByUid(collections, activeTab.collectionUid);
+ const collection = findCollectionByUid(getCollections(), activeTab.collectionUid);
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item?.pathname) {
@@ -328,7 +332,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('openTerminal');
};
- }, [focusedSidebarPath, activeTabUid, tabs, collections, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [focusedSidebarPath, activeTabUid, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]);
// Move tab left (active-collection-tabs-only)
useEffect(() => {
@@ -343,7 +347,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('moveTabLeft');
};
- }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// Move tab right (active-collection-tabs-only)
useEffect(() => {
@@ -358,12 +362,12 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('moveTabRight');
};
- }, [activeTabUid, tabs, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, dispatch, userKeyBindings, keybindingsEnabled]);
// Open preferences
useEffect(() => {
bindAction('openPreferences', (e) => {
- const activeTab = find(tabs, (t) => t.uid === activeTabUid);
+ const activeTab = find(getTabs(), (t) => t.uid === activeTabUid);
const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
dispatch(
@@ -379,7 +383,7 @@ export const HotkeysProvider = (props) => {
return () => {
unbindAction('openPreferences');
};
- }, [activeTabUid, tabs, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]);
+ }, [activeTabUid, activeWorkspace, dispatch, userKeyBindings, keybindingsEnabled]);
// Change layout orientation
useEffect(() => {
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..1ef8904d187 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -4,7 +4,6 @@ import { find, map, concat, filter, each, cloneDeep, get, set, pick, isEqual } f
import { createSlice } from '@reduxjs/toolkit';
import { hexy as hexdump } from 'hexy';
import {
- addDepth,
areItemsTheSameExceptSeqUpdate,
collapseAllItemsInCollection,
deleteItemInCollection,
@@ -270,7 +269,6 @@ export const collectionsSlice = createSlice({
collection.lastAction = null;
collapseAllItemsInCollection(collection);
- addDepth(collection.items);
if (!collectionUids.includes(collection.uid)) {
state.collections.push(collection);
}
@@ -493,7 +491,6 @@ export const collectionsSlice = createSlice({
item.items.push(action.payload.item);
}
}
- addDepth(collection.items);
}
},
deleteItem: (state, action) => {
@@ -751,7 +748,7 @@ export const collectionsSlice = createSlice({
// Get current response state or create initial state
const currentResponse = item.response || initiatedGrpcResponse;
const timestamp = item?.requestSent?.timestamp;
- let updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) };
+ const updatedResponse = { ...currentResponse, duration: Date.now() - (timestamp || Date.now()) };
// Process based on event type
switch (eventType) {
@@ -1149,6 +1146,17 @@ export const collectionsSlice = createSlice({
}
}
},
+ toggleRequestExamples: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
+
+ if (collection) {
+ const item = findItemInCollection(collection, action.payload.itemUid);
+
+ if (item && item.type === 'http-request') {
+ item.examplesExpanded = !item.examplesExpanded;
+ }
+ }
+ },
requestUrlChanged: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2656,7 +2664,7 @@ export const collectionsSlice = createSlice({
folder.draft = cloneDeep(folder.root);
}
if (type === 'request') {
- let vars = get(folder, 'draft.request.vars.req', []);
+ const vars = get(folder, 'draft.request.vars.req', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
@@ -2666,7 +2674,7 @@ export const collectionsSlice = createSlice({
}
set(folder, 'draft.request.vars.req', vars);
} else if (type === 'response') {
- let vars = get(folder, 'draft.request.vars.res', []);
+ const vars = get(folder, 'draft.request.vars.res', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
@@ -2920,7 +2928,7 @@ export const collectionsSlice = createSlice({
};
}
if (type === 'request') {
- let vars = get(collection, 'draft.root.request.vars.req', []);
+ const vars = get(collection, 'draft.root.request.vars.req', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
@@ -2930,7 +2938,7 @@ export const collectionsSlice = createSlice({
}
set(collection, 'draft.root.request.vars.req', vars);
} else if (type === 'response') {
- let vars = get(collection, 'draft.root.request.vars.res', []);
+ const vars = get(collection, 'draft.root.request.vars.res', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
@@ -3145,7 +3153,6 @@ export const collectionsSlice = createSlice({
});
}
}
- addDepth(collection.items);
}
},
collectionAddDirectoryEvent: (state, action) => {
@@ -3197,7 +3204,6 @@ export const collectionsSlice = createSlice({
}
currentSubItems = childItem.items;
});
- addDepth(collection.items);
}
},
collectionChangeFileEvent: (state, action) => {
@@ -3793,7 +3799,6 @@ export const collectionsSlice = createSlice({
};
annotateTransient(collection.items);
}
- addDepth(collection.items);
},
collectionAddOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo, executionMode } = action.payload;
@@ -3804,7 +3809,7 @@ export const collectionsSlice = createSlice({
if (!collection.oauth2Credentials) {
collection.oauth2Credentials = [];
}
- let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
+ const collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
// Remove existing credentials for the same combination
const filteredOauth2Credentials = filter(
@@ -3861,7 +3866,7 @@ export const collectionsSlice = createSlice({
if (!collection) return;
if (collection.oauth2Credentials) {
- let collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
+ const collectionOauth2Credentials = cloneDeep(collection.oauth2Credentials);
const filteredOauth2Credentials = filter(
collectionOauth2Credentials,
(creds) =>
@@ -4023,7 +4028,7 @@ export const collectionsSlice = createSlice({
// Get current response state or create initial state
const currentResponse = item.response || initiatedWsResponse;
const timestamp = item?.requestSent?.timestamp;
- let updatedResponse = {
+ const updatedResponse = {
...currentResponse,
isError: false,
error: '',
@@ -4268,6 +4273,7 @@ export const {
expandItem,
collapseItem,
toggleCollectionItem,
+ toggleRequestExamples,
requestUrlChanged,
updateItemSettings,
updateAuth,
diff --git a/packages/bruno-app/src/selectors/collections.js b/packages/bruno-app/src/selectors/collections.js
new file mode 100644
index 00000000000..2060bdd7ac5
--- /dev/null
+++ b/packages/bruno-app/src/selectors/collections.js
@@ -0,0 +1,82 @@
+import { createSelector } from '@reduxjs/toolkit';
+import {
+ findCollectionByUid,
+ findItemInCollection,
+ getGlobalEnvironmentVariables,
+ getGlobalEnvironmentVariablesMasked
+} from 'utils/collections/index';
+
+/**
+ * Narrow selectors for the collections slice.
+ *
+ * `state.collections.collections` is replaced whenever a collection or request
+ * changes, so subscribing to the whole array causes re-renders for unrelated
+ * edits. Prefer selecting the smallest existing reference a component needs.
+ *
+ * Rules:
+ * - Select a single field or existing reference directly; no equality function
+ * is needed.
+ * - Use `createSelector` for derived values. Use a `make…` factory when each
+ * mounted component needs its own memoization cache.
+ * - If data is only needed at event time, don't subscribe to it; read it with
+ * `useStore().getState()` inside the event handler.
+ */
+
+// The full collections array. Use only when rendering the collection list itself.
+// Other components should select a specific collection, item, or field instead.
+export const selectCollections = (state) => state.collections.collections;
+
+export const selectCollectionByUid = (state, collectionUid) =>
+ collectionUid ? findCollectionByUid(state.collections.collections, collectionUid) : undefined;
+
+export const selectCollectionName = (state, collectionUid) => selectCollectionByUid(state, collectionUid)?.name;
+
+export const selectCollectionPathname = (state, collectionUid) =>
+ selectCollectionByUid(state, collectionUid)?.pathname;
+
+export const selectCollectionMountStatus = (state, collectionUid) =>
+ selectCollectionByUid(state, collectionUid)?.mountStatus;
+
+export const selectItemByUid = (state, collectionUid, itemUid) => {
+ const collection = selectCollectionByUid(state, collectionUid);
+ return collection && itemUid ? findItemInCollection(collection, itemUid) : undefined;
+};
+
+export const selectCollectionSortOrder = (state) => state.collections.collectionSortOrder;
+
+export const selectSelectedSidebarUids = (state) => state.collections.selectedSidebarUids;
+
+export const selectActiveWorkspace = (state) => {
+ const { workspaces, activeWorkspaceUid } = state.workspaces;
+ return workspaces?.find((w) => w.uid === activeWorkspaceUid);
+};
+
+const selectGlobalEnvironments = (state) => state.globalEnvironments.globalEnvironments;
+const selectActiveGlobalEnvironmentUid = (state) => state.globalEnvironments.activeGlobalEnvironmentUid;
+
+/**
+ * The collection with the active global environment grafted on, the shape the
+ * request/response panes expect. Replaces the `produce()` call that used to
+ * run in RequestTabPanel's render body and handed the whole subtree a fresh
+ * collection object on every render.
+ *
+ * Per-instance factory: the merged object is recomputed only when the
+ * collection reference or the global environments change.
+ */
+export const makeSelectCollectionWithGlobals = () =>
+ createSelector(
+ [selectCollectionByUid, selectGlobalEnvironments, selectActiveGlobalEnvironmentUid],
+ (collection, globalEnvironments, activeGlobalEnvironmentUid) => {
+ if (!collection) {
+ return collection;
+ }
+
+ return {
+ ...collection,
+ globalEnvironmentVariables: getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }),
+ globalEnvSecrets: getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid }),
+ globalEnvironments,
+ activeGlobalEnvironmentUid
+ };
+ }
+ );
diff --git a/packages/bruno-app/src/selectors/collections.spec.js b/packages/bruno-app/src/selectors/collections.spec.js
new file mode 100644
index 00000000000..34b46502026
--- /dev/null
+++ b/packages/bruno-app/src/selectors/collections.spec.js
@@ -0,0 +1,103 @@
+import { buildTestState } from 'test-utils/buildTestState';
+import {
+ selectCollections,
+ selectCollectionByUid,
+ selectCollectionName,
+ selectItemByUid,
+ selectActiveWorkspace,
+ makeSelectCollectionWithGlobals
+} from './collections';
+
+const request = (uid, name) => ({ uid, name, type: 'http-request', request: { url: `/${name}` } });
+
+const buildState = () =>
+ buildTestState({
+ collections: {
+ collections: [
+ { uid: 'col-a', name: 'A', items: [{ uid: 'folder-1', type: 'folder', items: [request('req-1', 'one')] }] },
+ { uid: 'col-b', name: 'B', items: [request('req-2', 'two')] }
+ ]
+ },
+ globalEnvironments: {
+ globalEnvironments: [
+ { uid: 'genv-1', name: 'Global', variables: [{ name: 'host', value: 'https://x', enabled: true, secret: false }] }
+ ],
+ activeGlobalEnvironmentUid: 'genv-1'
+ },
+ workspaces: {
+ workspaces: [{ uid: 'ws-1', name: 'One' }, { uid: 'ws-2', name: 'Two' }],
+ activeWorkspaceUid: 'ws-2'
+ }
+ });
+
+describe('selectors/collections', () => {
+ it('selectCollectionByUid returns the stored reference, or undefined', () => {
+ const state = buildState();
+ expect(selectCollectionByUid(state, 'col-a')).toBe(state.collections.collections[0]);
+ expect(selectCollectionByUid(state, 'nope')).toBeUndefined();
+ expect(selectCollectionByUid(state, undefined)).toBeUndefined();
+ });
+
+ it('selectCollectionName reads one field', () => {
+ expect(selectCollectionName(buildState(), 'col-b')).toBe('B');
+ expect(selectCollectionName(buildState(), 'missing')).toBeUndefined();
+ });
+
+ it('selectItemByUid walks nested folders and returns the stored reference', () => {
+ const state = buildState();
+ const item = selectItemByUid(state, 'col-a', 'req-1');
+ expect(item).toBe(state.collections.collections[0].items[0].items[0]);
+ expect(selectItemByUid(state, 'col-a', 'req-2')).toBeUndefined();
+ expect(selectItemByUid(state, 'col-zzz', 'req-1')).toBeUndefined();
+ });
+
+ it('selectActiveWorkspace resolves the active uid', () => {
+ expect(selectActiveWorkspace(buildState())).toEqual({ uid: 'ws-2', name: 'Two' });
+ });
+
+ it('selectCollections is the raw array reference', () => {
+ const state = buildState();
+ expect(selectCollections(state)).toBe(state.collections.collections);
+ });
+
+ describe('makeSelectCollectionWithGlobals', () => {
+ it('grafts the active global environment onto the collection', () => {
+ const select = makeSelectCollectionWithGlobals();
+ const merged = select(buildState(), 'col-a');
+ expect(merged.uid).toBe('col-a');
+ expect(merged.globalEnvironmentVariables).toEqual({ host: 'https://x' });
+ expect(merged.activeGlobalEnvironmentUid).toBe('genv-1');
+ expect(merged.globalEnvironments).toHaveLength(1);
+ });
+
+ it('returns the same object while its inputs are unchanged', () => {
+ const select = makeSelectCollectionWithGlobals();
+ const state = buildState();
+ expect(select(state, 'col-a')).toBe(select(state, 'col-a'));
+ });
+
+ it('recomputes when the collection reference changes and not when an unrelated slice does', () => {
+ const select = makeSelectCollectionWithGlobals();
+ const state = buildState();
+ const first = select(state, 'col-a');
+
+ const unrelated = { ...state, app: { ...state.app, isDragging: true } };
+ expect(select(unrelated, 'col-a')).toBe(first);
+
+ const edited = {
+ ...state,
+ collections: {
+ ...state.collections,
+ collections: state.collections.collections.map((c) => (c.uid === 'col-a' ? { ...c, name: 'A2' } : c))
+ }
+ };
+ const second = select(edited, 'col-a');
+ expect(second).not.toBe(first);
+ expect(second.name).toBe('A2');
+ });
+
+ it('returns undefined for a missing collection', () => {
+ expect(makeSelectCollectionWithGlobals()(buildState(), 'missing')).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/bruno-app/src/selectors/tab.js b/packages/bruno-app/src/selectors/tab.js
index 55ff783087d..e1bdd5c6d61 100644
--- a/packages/bruno-app/src/selectors/tab.js
+++ b/packages/bruno-app/src/selectors/tab.js
@@ -1,5 +1,36 @@
import { createSelector } from '@reduxjs/toolkit';
+/**
+ * Narrow selectors for the tabs slice.
+ *
+ * `state.tabs.tabs` changes on every tab action, so subscribing to the whole
+ * array causes re-renders for unrelated tab changes. Prefer selecting the
+ * specific tab or value the component needs.
+ */
+
+// The full tab array. Use only when the component needs to render all tabs.
+export const selectTabs = (state) => state.tabs.tabs;
+
+export const selectActiveTabUid = (state) => state.tabs.activeTabUid;
+
+export const selectTabByUid = (state, tabUid) =>
+ tabUid ? state.tabs.tabs.find((t) => t.uid === tabUid) : undefined;
+
+export const selectActiveTab = (state) =>
+ selectTabByUid(state, state.tabs.activeTabUid);
+
+/**
+ * Creates a memoized selector for tabs belonging to a collection.
+ *
+ * The selector is created per component instance so each consumer maintains
+ * its own cache. Create it with `useMemo` when used inside a component.
+ */
+export const makeSelectTabsForCollection = () =>
+ createSelector(
+ [selectTabs, (_state, collectionUid) => collectionUid],
+ (tabs, collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid)
+ );
+
export const getTabUidForItem = ({ itemUid, itemPathname, collectionUid }) => createSelector([
(state) => state.tabs.tabs
], (tabs) => {
diff --git a/packages/bruno-app/src/selectors/tab.spec.js b/packages/bruno-app/src/selectors/tab.spec.js
index cb642c55d6a..e00a7357b41 100644
--- a/packages/bruno-app/src/selectors/tab.spec.js
+++ b/packages/bruno-app/src/selectors/tab.spec.js
@@ -1,4 +1,14 @@
-import { getTabUidForItem, isTabForItemActive, isTabForItemPresent } from './tab';
+import { buildTestState } from 'test-utils/buildTestState';
+import {
+ getTabUidForItem,
+ isTabForItemActive,
+ isTabForItemPresent,
+ selectTabs,
+ selectActiveTabUid,
+ selectTabByUid,
+ selectActiveTab,
+ makeSelectTabsForCollection
+} from './tab';
describe('tab selectors', () => {
const baseState = {
@@ -88,3 +98,64 @@ describe('tab selectors', () => {
expect(selector(state)).toBe('request-1');
});
});
+
+const tab = (uid, collectionUid, extra = {}) => ({ uid, collectionUid, type: 'request', ...extra });
+
+const buildState = () =>
+ buildTestState({
+ tabs: {
+ tabs: [tab('t1', 'col-a'), tab('t2', 'col-b'), tab('t3', 'col-a')],
+ activeTabUid: 't2'
+ }
+ });
+
+describe('tab field selectors', () => {
+ it('selectTabs and selectActiveTabUid are plain field reads', () => {
+ const state = buildState();
+ expect(selectTabs(state)).toBe(state.tabs.tabs);
+ expect(selectActiveTabUid(state)).toBe('t2');
+ });
+
+ it('selectTabByUid returns the stored reference, or undefined', () => {
+ const state = buildState();
+ expect(selectTabByUid(state, 't3')).toBe(state.tabs.tabs[2]);
+ expect(selectTabByUid(state, 'missing')).toBeUndefined();
+ expect(selectTabByUid(state, null)).toBeUndefined();
+ });
+
+ it('selectActiveTab resolves activeTabUid', () => {
+ const state = buildState();
+ expect(selectActiveTab(state)).toBe(state.tabs.tabs[1]);
+ expect(selectActiveTab(buildTestState())).toBeUndefined();
+ });
+
+ describe('makeSelectTabsForCollection', () => {
+ it('filters by collection uid', () => {
+ const result = makeSelectTabsForCollection()(buildState(), 'col-a');
+ expect(result.map((t) => t.uid)).toEqual(['t1', 't3']);
+ });
+
+ it('is memoized on the tabs reference', () => {
+ const selectTabsForCollection = makeSelectTabsForCollection();
+ const state = buildState();
+ const first = selectTabsForCollection(state, 'col-a');
+ expect(selectTabsForCollection(state, 'col-a')).toBe(first);
+
+ const unrelated = { ...state, app: { ...state.app, isDragging: true } };
+ expect(selectTabsForCollection(unrelated, 'col-a')).toBe(first);
+
+ const changed = { ...state, tabs: { ...state.tabs, tabs: [...state.tabs.tabs, tab('t4', 'col-a')] } };
+ expect(selectTabsForCollection(changed, 'col-a')).not.toBe(first);
+ expect(selectTabsForCollection(changed, 'col-a')).toHaveLength(3);
+ });
+
+ it('gives each instance its own cache', () => {
+ const state = buildState();
+ const a = makeSelectTabsForCollection();
+ const b = makeSelectTabsForCollection();
+ const firstA = a(state, 'col-a');
+ b(state, 'col-b');
+ expect(a(state, 'col-a')).toBe(firstA);
+ });
+ });
+});
diff --git a/packages/bruno-app/src/test-utils/buildTestState.js b/packages/bruno-app/src/test-utils/buildTestState.js
new file mode 100644
index 00000000000..626a0631a2b
--- /dev/null
+++ b/packages/bruno-app/src/test-utils/buildTestState.js
@@ -0,0 +1,59 @@
+/**
+ * Builds a complete Redux state for selector unit tests, with every slice
+ * initialized to the shape selectors expect. Tests only need to override the
+ * fields relevant to them.
+ *
+ * const state = buildTestState({
+ * tabs: { tabs: [tab], activeTabUid: tab.uid }
+ * });
+ *
+ * Keep the defaults aligned with each slice's `initialState` so selector tests
+ * continue to reflect the real store shape as slices evolve.
+ */
+const defaults = () => ({
+ collections: {
+ collections: [],
+ collectionSortOrder: 'default',
+ activeConnections: [],
+ selectedSidebarUids: [],
+ lastClickedSidebarUid: null,
+ tempDirectories: {},
+ saveTransientRequestModals: [],
+ mockResponseEditors: {}
+ },
+ tabs: {
+ tabs: [],
+ activeTabUid: null,
+ recentlyClosedTabs: []
+ },
+ workspaces: {
+ workspaces: [],
+ activeWorkspaceUid: null
+ },
+ globalEnvironments: {
+ globalEnvironments: [],
+ activeGlobalEnvironmentUid: null,
+ globalEnvironmentDraft: null,
+ _scriptGlobalEnvBaseline: null
+ },
+ app: {
+ isDragging: false,
+ clipboard: { hasCopiedItems: false },
+ preferences: {},
+ leftSidebarWidth: 222,
+ sidebarCollapsed: false,
+ screenWidth: 1280
+ },
+ logs: { isConsoleOpen: false },
+ chat: { isOpen: false, isPoppedOut: false }
+});
+
+export const buildTestState = (overrides = {}) => {
+ const state = defaults();
+ for (const [slice, value] of Object.entries(overrides)) {
+ state[slice] = { ...(state[slice] || {}), ...value };
+ }
+ return state;
+};
+
+export default buildTestState;
diff --git a/packages/bruno-app/src/utils/collections/collectionSlug.js b/packages/bruno-app/src/utils/collections/collectionSlug.js
new file mode 100644
index 00000000000..4362a6a7c8e
--- /dev/null
+++ b/packages/bruno-app/src/utils/collections/collectionSlug.js
@@ -0,0 +1,7 @@
+/**
+ * @param {string} name - collection display name
+ * @returns {string}
+ */
+export const collectionSlug = (name) => (name || '').replace(/\s+/g, '-').toLowerCase();
+
+export default collectionSlug;
diff --git a/packages/bruno-app/src/utils/collections/flattenSidebarTree.js b/packages/bruno-app/src/utils/collections/flattenSidebarTree.js
new file mode 100644
index 00000000000..dc5bb8a7666
--- /dev/null
+++ b/packages/bruno-app/src/utils/collections/flattenSidebarTree.js
@@ -0,0 +1,342 @@
+import { isItemAFolder, isItemARequest } from './index';
+import { collectionSlug } from './collectionSlug';
+import { sortByNameThenSequence } from 'utils/common/index';
+import {
+ doesRequestMatchSearchText,
+ doesFolderHaveItemsMatchSearchText,
+ doesCollectionHaveItemsMatchingSearchText
+} from './search';
+
+const groupCollectionItems = (collectionItems) => {
+ const folders = [];
+ const apps = [];
+ const requests = [];
+
+ const sortBySeq = (items) => [...items].sort((a, b) => a.seq - b.seq);
+
+ for (const item of collectionItems) {
+ if (!item || item.isTransient) continue;
+
+ if (isItemAFolder(item)) {
+ folders.push(item);
+ } else if (item.type === 'app') {
+ apps.push(item);
+ } else if (isItemARequest(item)) {
+ requests.push(item);
+ }
+ }
+
+ return {
+ folders: sortByNameThenSequence(folders),
+ apps: sortBySeq(apps),
+ requests: sortBySeq(requests)
+ };
+};
+
+/**
+ * Flattens the children of a collection or folder into sidebar rows.
+ * Returns the number of visible children to determine whether an empty-state
+ * CTA should be shown.
+ */
+const walkChildren = (
+ collectionContext,
+ { collectionItems = [], depth, parentName }
+) => {
+ const {
+ collectionUid,
+ collectionPathname,
+ collectionId,
+ hasSearch,
+ searchText,
+ appendRow,
+ addItemToIndex
+ } = collectionContext;
+
+ let visibleChildCount = 0;
+
+ const { folders, apps, requests } = groupCollectionItems(collectionItems);
+
+ for (const folder of folders) {
+ if (hasSearch && !doesFolderHaveItemsMatchSearchText(folder, searchText)) {
+ continue;
+ }
+
+ visibleChildCount++;
+
+ appendRow({
+ id: `${collectionUid}:${folder.uid}`,
+ kind: 'folder',
+ depth,
+ collectionUid,
+ collectionPathname,
+ collectionId,
+ parentName,
+ itemUid: folder.uid,
+ sortName: folder.name || null
+ });
+
+ addItemToIndex(folder.uid, folder);
+
+ // Search reveals matching descendants regardless of the collapsed state.
+ const isExpanded = hasSearch || !folder.collapsed;
+
+ if (!isExpanded) continue;
+
+ const childCount = walkChildren(collectionContext, {
+ collectionItems: folder.items,
+ depth: depth + 1,
+ parentName: folder.name || null
+ });
+
+ if (!hasSearch && childCount === 0) {
+ appendRow({
+ id: `${collectionUid}:${folder.uid}:cta`,
+ kind: 'empty-cta',
+ depth: depth + 1,
+ collectionUid,
+ collectionPathname,
+ collectionId,
+ parentName: folder.name || null,
+ itemUid: folder.uid,
+ sortName: null
+ });
+ }
+ }
+
+ if (!hasSearch) {
+ for (const app of apps) {
+ visibleChildCount++;
+
+ appendRow({
+ id: `${collectionUid}:${app.uid}`,
+ kind: 'app',
+ depth,
+ collectionUid,
+ collectionPathname,
+ collectionId,
+ parentName,
+ itemUid: app.uid,
+ sortName: app.name || null
+ });
+
+ addItemToIndex(app.uid, app);
+ }
+ }
+
+ for (const request of requests) {
+ if (hasSearch && !doesRequestMatchSearchText(request, searchText)) {
+ continue;
+ }
+
+ visibleChildCount++;
+
+ appendRow({
+ id: `${collectionUid}:${request.uid}`,
+ kind: 'request',
+ depth,
+ collectionUid,
+ collectionPathname,
+ collectionId,
+ parentName,
+ itemUid: request.uid,
+ sortName: request.name || null
+ });
+
+ addItemToIndex(request.uid, request);
+
+ const hasExamples
+ = request.type === 'http-request' && Array.isArray(request.examples);
+
+ if (!hasSearch && hasExamples && request.examplesExpanded) {
+ request.examples.forEach((example, index) => {
+ appendRow({
+ id: `${collectionUid}:${request.uid}:ex:${example.uid || index}`,
+ kind: 'example',
+ depth: depth + 1,
+ collectionUid,
+ collectionPathname,
+ collectionId,
+ parentName: request.name || null,
+ itemUid: request.uid,
+ sortName: example.name || null,
+ exampleIndex: index,
+ exampleUid: example.uid || null
+ });
+ });
+ }
+ }
+
+ return visibleChildCount;
+};
+
+/**
+ * Adds a collection and its visible children to the flat sidebar row list.
+ */
+const flattenCollection = ({
+ collection,
+ hasSearch,
+ searchText,
+ appendRow,
+ addItemToIndex,
+ addCollectionToIndex
+}) => {
+ if (
+ hasSearch
+ && !doesCollectionHaveItemsMatchingSearchText(collection, searchText)
+ ) {
+ return;
+ }
+
+ // Used for readable test selectors. collectionUid remains the unique identity.
+ const collectionId = collectionSlug(collection.name);
+
+ appendRow({
+ id: `col:${collection.uid}`,
+ kind: 'collection',
+ depth: 0,
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname || null,
+ collectionId,
+ parentName: null,
+ itemUid: null,
+ sortName: collection.name || null
+ });
+
+ addCollectionToIndex(collection.uid, collection);
+
+ // Search reveals matching descendants regardless of the collapsed state.
+ const isExpanded = hasSearch || !collection.collapsed;
+
+ if (!isExpanded) return;
+
+ const collectionContext = {
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname || null,
+ collectionId,
+ hasSearch,
+ searchText,
+ appendRow,
+ addItemToIndex
+ };
+
+ const visibleChildCount = walkChildren(collectionContext, {
+ collectionItems: collection.items,
+ depth: 1,
+ parentName: null
+ });
+
+ // append emtry row cta.
+ if (
+ !hasSearch
+ && visibleChildCount === 0
+ && collection.mountStatus === 'mounted'
+ && !collection.isLoading
+ ) {
+ appendRow({
+ id: `${collection.uid}:root:cta`,
+ kind: 'empty-cta',
+ depth: 1,
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname || null,
+ collectionId,
+ parentName: null,
+ itemUid: null,
+ sortName: null
+ });
+ }
+};
+
+/**
+ * convert sidebar entries into a flat, ordered array of layout rows.
+ * each row carry only structural data.
+ *
+ * @param {Array} sidebarEntries
+ * @param {{ searchText?: string }} options
+ * @returns {{
+ * rows: Array |