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/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js
index 74cd2989d03..f9235dbf96c 100644
--- a/packages/bruno-app/src/components/MultiLineEditor/index.js
+++ b/packages/bruno-app/src/components/MultiLineEditor/index.js
@@ -260,20 +260,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;
}
}
@@ -315,7 +316,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/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 83%
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 552d0df1310..2bfa839040e 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 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';
@@ -27,27 +26,25 @@ import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/
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, expandItem, collapseItem } 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,
@@ -55,7 +52,6 @@ import {
isTabForItemPresent as isTabForItemPresentSelector
} from 'src/selectors/tab';
import { isEqual } from 'lodash';
-import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
import {
canCollectionItemBeDropped,
determineCollectionItemDrop,
@@ -64,7 +60,6 @@ import {
getSortedDraggedItems,
isCollectionItemCollapsed
} 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';
@@ -76,7 +71,18 @@ import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick';
import { startBlockedDragTracking } from 'utils/dragBlockedCursor';
import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections/index';
-const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu, isItemMultiDragDisabled, multiDragCollections, multiDragItems: multiDragItemsForSelection }) => {
+const CollectionItemRow = ({
+ item,
+ depth,
+ collectionUid,
+ collectionPathname,
+ searchText,
+ openBulkMenu,
+ children,
+ isItemMultiDragDisabled,
+ multiDragCollections,
+ multiDragItems: multiDragItemsForSelection
+}) => {
const { dropdownContainerRef } = useSidebarAccordion();
const selectorInput = {
itemUid: item.uid,
@@ -125,6 +131,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 = Boolean(item.examplesExpanded);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : isCollectionItemCollapsed(item);
@@ -183,17 +190,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,
@@ -387,12 +383,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o
const handleExamplesCollapse = (e) => {
e.stopPropagation();
e.preventDefault();
- dispatch(
- (isCollectionItemCollapsed(item) ? expandItem : collapseItem)({
- itemUid: item.uid,
- collectionUid: collectionUid
- })
- );
+ dispatch(toggleRequestExamples({ collectionUid, itemUid: item.uid }));
};
// prevent the parent's double-click handler from firing
@@ -414,7 +405,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 = () => {
@@ -589,11 +580,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);
@@ -646,14 +632,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 !== '')
@@ -807,7 +785,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' && !itemIsCollapsed && 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 8b1a4d25e0d..31f4bdb2cb4 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;
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 fffa22a7d94..f9144d73fc1 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
@@ -15,7 +15,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';
@@ -25,7 +24,7 @@ import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'
import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick';
import { startBlockedDragTracking } from 'utils/dragBlockedCursor';
-const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isParentDragDisabled, parentMultiDragItems }) => {
+const ExampleItem = ({ example, item, collection, depth, searchText, openBulkMenu, isItemMultiDragDisabled, multiDragCollections, multiDragItems }) => {
const { dropdownContainerRef } = useSidebarAccordion();
const dispatch = useDispatch();
const activeTabUid = useSelector((state) => state.tabs?.activeTabUid);
@@ -42,6 +41,15 @@ const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isPa
const isMultiSelected = isSelected && selectedSidebarUids.length > 1;
const handleSelectionClick = useSidebarSelectionClick({ uid: example.uid, searchText });
+ // In the flat/virtualized sidebar an example row is a sibling of its parent request,
+ // not a child, so we derive the parent request's multi-drag state here rather than
+ // receiving it as props from the parent (as the nested tree did on main).
+ const parentIsSelected = selectedSidebarUids.includes(item.uid);
+ const parentIsMultiSelected = parentIsSelected && selectedSidebarUids.length > 1;
+ const parentMultiDragItems = parentIsMultiSelected ? multiDragItems : null;
+ const isParentRedirectedToCollectionDrag = parentIsMultiSelected && multiDragCollections?.length > 0;
+ const isParentDragDisabled = parentIsMultiSelected && isItemMultiDragDisabled && !isParentRedirectedToCollectionDrag;
+
const isRedirectedToRequestDrag = isMultiSelected && selectedSidebarUids.includes(item.uid) && !isParentDragDisabled;
const [, drag, dragPreview] = useDrag({
@@ -60,8 +68,9 @@ const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isPa
drag(exampleRef);
dragPreview(getEmptyImage(), { captureDraggingState: true });
- // 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);
@@ -97,16 +106,6 @@ const ExampleItem = ({ example, item, collection, searchText, openBulkMenu, isPa
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 0a27517e6d5..ff476462703 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 } 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 {
@@ -35,27 +34,24 @@ 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';
@@ -63,11 +59,7 @@ import CreateMockServerModal from 'components/MockServer/CreateMockServerModal';
import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick';
import { startBlockedDragTracking } from 'utils/dragBlockedCursor';
-// 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, isCollectionMultiDragDisabled, isItemMultiDragDisabled, multiDragCollections, multiDragItems: multiDragItemsForSelection }) => {
+const CollectionRow = ({ collection, searchText, openBulkMenu, children, isCollectionMultiDragDisabled, multiDragCollections }) => {
const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER);
const { dropdownContainerRef } = useSidebarAccordion();
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -82,13 +74,9 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra
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 { hasCopiedItems } = useSelector((state) => state.app.clipboard);
@@ -379,31 +367,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra
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;
@@ -422,18 +385,6 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra
}
);
- // 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',
@@ -584,7 +535,7 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra
];
return (
-
+
{showNewRequestModal && setShowNewRequestModal(false)} />}
{showNewFolderModal && setShowNewFolderModal(false)} />}
{showNewAppModal && setShowNewAppModal(false)} />}
@@ -663,41 +614,9 @@ const Collection = ({ collection, searchText, openBulkMenu, isCollectionMultiDra
)}
-
- {!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/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..1a1b4537dcc
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx
@@ -0,0 +1,133 @@
+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, isCollectionMultiDragDisabled, isItemMultiDragDisabled, 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.isCollectionMultiDragDisabled === next.isCollectionMultiDragDisabled
+ && prev.isItemMultiDragDisabled === next.isItemMultiDragDisabled
+ && 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 b0e0ec2f31c..11fdbd5fe6d 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -1,13 +1,14 @@
-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';
@@ -16,7 +17,9 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
const [searchText, setSearchText] = useState('');
const { collections, collectionSortOrder, selectedSidebarUids } = useSelector((state) => state.collections);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
+ const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const dispatch = useDispatch();
+ const virtuosoRef = useRef(null);
const { openBulkMenu, menuProps } = useBulkActionsMenu();
@@ -31,6 +34,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]
@@ -58,10 +77,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) {
@@ -85,34 +121,41 @@ 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/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index ea7901ac677..9ec25fb0a62 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/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 |