From 2a6692dcf36cd75c5096f1e42cbc56fdbb9a2ce0 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Fri, 4 Sep 2026 11:34:30 +0530 Subject: [PATCH 01/17] added flat sidebar tree into flat array with specs --- .../utils/collections/flattenSidebarTree.js | 344 ++++++++++++++++++ .../collections/flattenSidebarTree.spec.js | 179 +++++++++ .../src/utils/collections/search.spec.js | 61 ++++ 3 files changed, 584 insertions(+) create mode 100644 packages/bruno-app/src/utils/collections/flattenSidebarTree.js create mode 100644 packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js create mode 100644 packages/bruno-app/src/utils/collections/search.spec.js 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..42732a20355 --- /dev/null +++ b/packages/bruno-app/src/utils/collections/flattenSidebarTree.js @@ -0,0 +1,344 @@ +import { isItemAFolder, isItemARequest } from './index'; +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 slugifyCollectionName = (name) => + (name || '').replace(/\s+/g, '-').toLowerCase(); + + const collectionId = slugifyCollectionName(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, + * itemsByUid: Map, + * collectionsByUid: Map + * }} + */ +export const flattenSidebarTree = (sidebarEntries = [], options = {}) => { + const { searchText = '' } = options; + const hasSearch = Boolean(searchText.trim()); + + const rows = []; + const itemsByUid = new Map(); + const collectionsByUid = new Map(); + + const appendRow = (row) => rows.push(row); + const addItemToIndex = (uid, item) => itemsByUid.set(uid, item); + const addCollectionToIndex = (uid, collection) => + collectionsByUid.set(uid, collection); + + for (const entry of sidebarEntries) { + if (!entry) continue; + + // A ghost represents a missing Git-backed collection and is never expanded. + if (entry.kind === 'ghost') { + const ghost = entry.entry || {}; + + appendRow({ + id: `ghost:${ghost.path}`, + kind: 'ghost', + depth: 0, + collectionUid: null, + collectionPathname: ghost.path || null, + itemUid: null, + sortName: ghost.name || null + }); + + continue; + } + + if (!entry.collection) continue; + + flattenCollection({ + collection: entry.collection, + hasSearch, + searchText, + appendRow, + addItemToIndex, + addCollectionToIndex + }); + } + + return { + rows, + itemsByUid, + collectionsByUid + }; +}; + +/** + * lookups from item/collection UIDs to their row positions. + * needed for active tab to scroll into view in sidebar + */ +export const buildIndexes = (rows = []) => { + const rowIndexByItemUid = new Map(); + const rowIndexByCollectionUid = new Map(); + + rows.forEach((row, index) => { + if (row.kind === 'collection' && row.collectionUid) { + rowIndexByCollectionUid.set(row.collectionUid, index); + } + + if ( + ['folder', 'app', 'request'].includes(row.kind) + && row.itemUid + ) { + rowIndexByItemUid.set(row.itemUid, index); + } + + if (row.kind === 'example' && row.exampleUid) { + rowIndexByItemUid.set(row.exampleUid, index); + } + }); + + return { + rowIndexByItemUid, + rowIndexByCollectionUid + }; +}; diff --git a/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js b/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js new file mode 100644 index 00000000000..54655fcd389 --- /dev/null +++ b/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js @@ -0,0 +1,179 @@ +import { flattenSidebarTree, buildIndexes } from './flattenSidebarTree'; + +let uid = 0; +const nextUid = (p) => `${p}-${++uid}`; +const request = (name, props = {}) => ({ uid: props.uid || nextUid('req'), name, type: 'http-request', seq: props.seq, request: {}, ...props }); +const folder = (name, items = [], props = {}) => ({ uid: props.uid || nextUid('fol'), name, type: 'folder', seq: props.seq, items, ...props }); +const app = (name, props = {}) => ({ uid: props.uid || nextUid('app'), name, type: 'app', seq: props.seq, ...props }); +const collection = (name, items = [], props = {}) => ({ uid: props.uid || nextUid('col'), name, pathname: `/c/${name}`, mountStatus: 'mounted', isLoading: false, collapsed: false, items, ...props }); +const loaded = (c) => ({ kind: 'loaded', collection: c }); +const flatten = (entries, options) => flattenSidebarTree(entries, options).rows; +const kinds = (rows) => rows.map((r) => r.kind); +const names = (rows) => rows.map((r) => r.sortName); + +beforeEach(() => { uid = 0; }); + +describe('flattenSidebarTree', () => { + describe('ordering and depth', () => { + it('emits collection header then folders -> apps -> requests', () => { + const c = collection('C', [request('r1', { seq: 1 }), app('a1', { seq: 1 }), folder('f1', [], { seq: 1, collapsed: true })]); + expect(kinds(flatten([loaded(c)]))).toEqual(['collection', 'folder', 'app', 'request']); + }); + it('sorts requests/apps by seq, folders alphabetically', () => { + const c = collection('C', [request('rB', { seq: 2 }), request('rA', { seq: 1 }), folder('zeta'), folder('alpha')]); + const rows = flatten([loaded(c)]); + expect(names(rows.filter((r) => r.kind === 'request'))).toEqual(['rA', 'rB']); + expect(names(rows.filter((r) => r.kind === 'folder'))).toEqual(['alpha', 'zeta']); + }); + it('stamps depth: header 0, top-level 1, nested 2', () => { + const byName = Object.fromEntries(flatten([loaded(collection('C', [folder('f1', [request('r1')])]))]).map((r) => [r.sortName, r.depth])); + expect(byName.C).toBe(0); expect(byName.f1).toBe(1); expect(byName.r1).toBe(2); + }); + }); + + it('drops transient items', () => { + const c = collection('C', [request('real', { seq: 1 }), request('draft', { seq: 2, isTransient: true })]); + expect(names(flatten([loaded(c)]).filter((r) => r.kind === 'request'))).toEqual(['real']); + }); + + describe('collapse', () => { + it('collapsed collection = header only', () => { + expect(kinds(flatten([loaded(collection('C', [request('r1')], { collapsed: true }))]))).toEqual(['collection']); + }); + it('collapsed folder = row without subtree', () => { + const rows = flatten([loaded(collection('C', [folder('f1', [request('hidden')], { collapsed: true })]))]); + expect(kinds(rows)).toEqual(['collection', 'folder']); + expect(names(rows)).not.toContain('hidden'); + }); + }); + + describe('search', () => { + it('includes only matching requests, force-expanded', () => { + const c = collection('C', [folder('f1', [request('login'), request('logout')], { collapsed: true }), request('health')]); + const r = names(flatten([loaded(c)], { searchText: 'log' }).filter((x) => x.kind === 'request')); + expect(r).toEqual(expect.arrayContaining(['login', 'logout'])); + expect(r).not.toContain('health'); + }); + it('drops a collection with no matching request', () => { + expect(flatten([loaded(collection('C', [request('health')]))], { searchText: 'zzz' })).toHaveLength(0); + }); + it('includes a folder only if it has a matching descendant', () => { + const c = collection('C', [folder('match', [request('login')]), folder('nomatch', [request('health')])]); + expect(names(flatten([loaded(c)], { searchText: 'login' }).filter((x) => x.kind === 'folder'))).toEqual(['match']); + }); + it('hides apps and empty-cta while searching', () => { + const k = kinds(flatten([loaded(collection('C', [app('a'), request('login')]))], { searchText: 'login' })); + expect(k).not.toContain('app'); + expect(k).not.toContain('empty-cta'); + }); + }); + + describe('empty-cta', () => { + it('collection cta when mounted, empty, expanded', () => { + const rows = flatten([loaded(collection('C', []))]); + expect(kinds(rows)).toEqual(['collection', 'empty-cta']); + expect(rows[1].depth).toBe(1); + expect(rows[1].itemUid).toBeNull(); + }); + it('suppressed while loading or unmounted', () => { + expect(kinds(flatten([loaded(collection('C', [], { isLoading: true }))]))).toEqual(['collection']); + expect(kinds(flatten([loaded(collection('D', [], { mountStatus: 'unmounted' }))]))).toEqual(['collection']); + }); + it('folder cta at depth+1 for an empty expanded folder', () => { + const rows = flatten([loaded(collection('C', [folder('empty', [])]))]); + const cta = rows.find((r) => r.kind === 'empty-cta'); + expect(cta.depth).toBe(2); + expect(cta.itemUid).toBe(rows.find((r) => r.kind === 'folder').itemUid); + }); + }); + + it('emits a ghost row', () => { + const rows = flatten([{ kind: 'ghost', entry: { path: '/repo/x', name: 'X' } }]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ kind: 'ghost', collectionPathname: '/repo/x', sortName: 'X', depth: 0 }); + }); + + describe('examples', () => { + it('emits example rows when expanded at request depth + 1', () => { + const c = collection('C', [request('r1', { examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }, { uid: 'ex2', name: 'err' }] })]); + const rows = flatten([loaded(c)]); + const ex = rows.filter((r) => r.kind === 'example'); + expect(names(ex)).toEqual(['ok', 'err']); + const reqDepth = rows.find((r) => r.kind === 'request').depth; + expect(ex.every((r) => r.depth === reqDepth + 1)).toBe(true); + }); + it('omits example rows when not expanded', () => { + const c = collection('C', [request('r1', { examples: [{ uid: 'ex1', name: 'ok' }] })]); + expect(kinds(flatten([loaded(c)]))).not.toContain('example'); + }); + }); +}); + +describe('ancestry attributes', () => { + it('stamps collectionId (slug) on every row of the collection', () => { + const rows = flatten([loaded(collection('My Coll', [folder('f1', [request('r1')])]))]); + expect(rows.every((r) => r.collectionId === 'my-coll')).toBe(true); + }); + it('stamps parentName: folder name for a folder child, null at collection root', () => { + const rows = flatten([loaded(collection('C', [request('top'), folder('f1', [request('nested')])]))]); + expect(rows.find((r) => r.sortName === 'top').parentName).toBeNull(); + expect(rows.find((r) => r.sortName === 'f1').parentName).toBeNull(); + expect(rows.find((r) => r.sortName === 'nested').parentName).toBe('f1'); + }); + it('stamps collectionId + parentName (request name) on example rows', () => { + const c = collection('My Coll', [request('r1', { examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }] })]); + const ex = flatten([loaded(c)]).find((r) => r.kind === 'example'); + expect(ex.collectionId).toBe('my-coll'); + expect(ex.parentName).toBe('r1'); + }); + it('stamps collectionId + parentName on empty-cta rows', () => { + const rootCta = flatten([loaded(collection('Empty', []))]).find((r) => r.kind === 'empty-cta'); + expect(rootCta.collectionId).toBe('empty'); + expect(rootCta.parentName).toBeNull(); + const folderCta = flatten([loaded(collection('C', [folder('f1', [])]))]).find((r) => r.kind === 'empty-cta'); + expect(folderCta.parentName).toBe('f1'); + }); +}); + +describe('object maps', () => { + it('itemsByUid resolves folders, apps and requests to their live objects', () => { + const r = request('r1', { uid: 'req-x' }); + const a = app('a1', { uid: 'app-x' }); + const f = folder('f1', [r], { uid: 'fol-x' }); + const { itemsByUid } = flattenSidebarTree([loaded(collection('C', [f, a]))]); + expect(itemsByUid.get('fol-x')).toBe(f); + expect(itemsByUid.get('app-x')).toBe(a); + expect(itemsByUid.get('req-x')).toBe(r); + }); + it('collectionsByUid resolves the collection to its live object', () => { + const c = collection('C', [], { uid: 'col-x' }); + const { collectionsByUid } = flattenSidebarTree([loaded(c)]); + expect(collectionsByUid.get('col-x')).toBe(c); + }); + it('does not index items hidden by a collapsed parent (not walked)', () => { + const { itemsByUid } = flattenSidebarTree([loaded(collection('C', [folder('f1', [request('hidden', { uid: 'req-h' })], { collapsed: true })]))]); + expect(itemsByUid.has('req-h')).toBe(false); + }); +}); + +describe('buildIndexes', () => { + it('maps item uid and collection uid to row index', () => { + const c = collection('C', [request('r1', { uid: 'req-x' })], { uid: 'col-x' }); + const rows = flatten([loaded(c)]); + const { rowIndexByItemUid, rowIndexByCollectionUid } = buildIndexes(rows); + expect(rows[rowIndexByItemUid.get('req-x')].kind).toBe('request'); + expect(rowIndexByCollectionUid.get('col-x')).toBe(0); + }); + it('item-uid map targets the item row, not its example rows', () => { + const c = collection('C', [request('r1', { uid: 'req-x', examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }] })]); + const { rowIndexByItemUid } = buildIndexes(flatten([loaded(c)])); + const rows = flatten([loaded(c)]); + expect(rows[rowIndexByItemUid.get('req-x')].kind).toBe('request'); + }); + it('indexes example rows by exampleUid', () => { + const c = collection('C', [request('r1', { uid: 'req-x', examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }] })]); + const rows = flatten([loaded(c)]); + const { rowIndexByItemUid } = buildIndexes(rows); + expect(rows[rowIndexByItemUid.get('ex1')].kind).toBe('example'); + }); +}); diff --git a/packages/bruno-app/src/utils/collections/search.spec.js b/packages/bruno-app/src/utils/collections/search.spec.js new file mode 100644 index 00000000000..04008395e64 --- /dev/null +++ b/packages/bruno-app/src/utils/collections/search.spec.js @@ -0,0 +1,61 @@ +import { + doesRequestMatchSearchText, + doesFolderHaveItemsMatchSearchText, + doesCollectionHaveItemsMatchingSearchText +} from './search'; + +const createRequest = (name, props = {}) => ({ + uid: name, + name, + type: 'http-request', + request: {}, + ...props +}); + +const createFolder = (name, items = []) => ({ + uid: name, + name, + type: 'folder', + items +}); + +describe('whether a request matches the search text', () => { + it('matches request names case-insensitively', () => { + expect(doesRequestMatchSearchText(createRequest('GetUser'), 'user')).toBe(true); + expect(doesRequestMatchSearchText(createRequest('GetUser'), 'xyz')).toBe(false); + }); +}); + +describe('whether a folder contains a matching request', () => { + it('matches requests nested inside folders', () => { + const folder = createFolder('root', [ + createFolder('subfolder', [createRequest('login')]), + createRequest('health') + ]); + + expect(doesFolderHaveItemsMatchSearchText(folder, 'login')).toBeTruthy(); + expect(doesFolderHaveItemsMatchSearchText(folder, 'zzz')).toBeFalsy(); + }); + + it('ignores transient requests', () => { + const folder = createFolder('root', [ + createRequest('login', { isTransient: true }) + ]); + + expect(doesFolderHaveItemsMatchSearchText(folder, 'login')).toBeFalsy(); + }); +}); + +describe('whether a collection contains a matching request', () => { + it('matches requests anywhere in the collection tree', () => { + const collection = { + items: [ + createFolder('folder', [createRequest('deep-login')]), + createRequest('health') + ] + }; + + expect(doesCollectionHaveItemsMatchingSearchText(collection, 'login')).toBeTruthy(); + expect(doesCollectionHaveItemsMatchingSearchText(collection, 'zzz')).toBeFalsy(); + }); +}); From 2e14f549b5f601d10ed62365cfc7290a0d340b48 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 08:17:14 +0530 Subject: [PATCH 02/17] extracted CollectionItemRow from CollectionItem and CollectionRow from Collection --- .../CollectionItemRow/index.jsx | 859 +++++++++++++++++ .../Collection/CollectionItem/index.js | 898 +----------------- .../Collection/CollectionRow/index.jsx | 644 +++++++++++++ .../Sidebar/Collections/Collection/index.js | 667 +------------ 4 files changed, 1558 insertions(+), 1510 deletions(-) create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx new file mode 100644 index 00000000000..afd8f994e92 --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -0,0 +1,859 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import range from 'lodash/range'; +import classnames from 'classnames'; +import { useDrag, useDrop } from 'react-dnd'; +import { getEmptyImage } from 'react-dnd-html5-backend'; +import { + IconChevronRight, + IconDots, + IconFilePlus, + IconFolderPlus, + IconPlayerPlay, + IconEdit, + IconCopy, + IconClipboard, + IconCode, + IconFolder, + IconTrash, + IconSettings, + IconInfoCircle, + IconTerminal2, + IconAppWindow, + IconEyeOff +} from '@tabler/icons'; +import { useSelector, useDispatch } 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 { 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 { 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 ExampleIcon from 'components/Icons/ExampleIcon'; +import { + getTabUidForItem as getTabUidForItemSelector, + isTabForItemActive as isTabForItemActiveSelector, + isTabForItemPresent as isTabForItemPresentSelector +} from 'src/selectors/tab'; +import { isEqual } from 'lodash'; +import { + canCollectionItemBeDropped, + determineCollectionItemDrop, + getInitialExampleName, + findParentItemInCollection, + getSelectionInfo, + getSortedDraggedItems +} from 'utils/collections/index'; +import { getRevealInFolderLabel } from 'utils/common/platform'; +import CreateExampleModal from 'components/ResponseExample/CreateExampleModal'; +import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; +import ActionIcon from 'ui/ActionIcon'; +import MenuDropdown from 'ui/MenuDropdown'; +import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import useKeybinding from 'hooks/useKeybinding'; +import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; +import useMultiSelectDragDisabled from 'hooks/useMultiSelectDragDisabled'; +import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections/index'; + +const CollectionItemRow = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu, children }) => { + 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 isSidebarDragging = useSelector((state) => state.app.isDragging); + const allCollections = useSelector((state) => state.collections.collections); + const collection = allCollections?.find((c) => c.uid === collectionUid); + const { hasCopiedItems } = useSelector((state) => state.app.clipboard); + const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); + const isSelected = selectedSidebarUids.includes(item.uid); + const isMultiSelected = isSelected && selectedSidebarUids.length > 1; + const handleSelectionClick = useSidebarSelectionClick({ uid: item.uid, searchText }); + const workspaces = useSelector((state) => state.workspaces.workspaces); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid); + const collectionSortOrder = useSelector((state) => state.collections.collectionSortOrder); + const dispatch = useDispatch(); + + // When dragging a multi-selected row, carry all effectively-selected folders/requests + // (excluding collections) so dropping one moves the entire selection together. + const multiDragItems = useMemo(() => { + if (!isSelected || selectedSidebarUids.length < 2) return null; + const { effectiveSelection, hasCollection } = getSelectionInfo({ collections: allCollections, selectedUids: selectedSidebarUids }); + if (hasCollection) return null; + return effectiveSelection.map((entry) => ({ ...entry.item, sourceCollectionUid: entry.collectionUid })); + }, [isSelected, selectedSidebarUids, allCollections]); + + const isDragDisabled = useMultiSelectDragDisabled({ isSelected, selectedSidebarUids, allCollections }); + + // We use a single ref for drag and drop. + const ref = useRef(null); + const menuDropdownRef = useRef(null); + + const [renameItemModalOpen, setRenameItemModalOpen] = useState(false); + const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false); + const [ignoreItemModalOpen, setIgnoreItemModalOpen] = useState(false); + const [createExampleModalOpen, setCreateExampleModalOpen] = useState(false); + const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); + const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); + const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); + const [newAppModalOpen, setNewAppModalOpen] = useState(false); + const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); + const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); + const [examplesExpanded, setExamplesExpanded] = useState(false); + const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); + const hasSearchText = searchText && searchText?.trim()?.length; + const itemIsCollapsed = hasSearchText ? false : item.collapsed; + const isFolder = isItemAFolder(item); + + const isCloneable = isFolder || isItemARequest(item) || item.type === 'app'; + + // Check if request has examples (only for HTTP requests) + const hasExamples = isItemARequest(item) && item.type === 'http-request' && item.examples && item.examples.length > 0; + + // Sidebar shortcuts — only active when this sidebar item has keyboard focus + useKeybinding('cloneItem', () => { + handleCloneItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('copyItem', () => { + handleCopyItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('pasteItem', () => { + handlePasteItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('renameItem', () => { + setRenameItemModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('newRequest', () => { + if (!isFolder) return false; + setNewRequestModalOpen(true); + return false; + }, { enabled: isKeyboardFocused && isFolder, deps: [isKeyboardFocused, isFolder] }); + + const [dropType, setDropType] = useState(null); // 'above', 'inside' or 'below' + + const [{ isDragging }, drag, dragPreview] = useDrag({ + type: isDragDisabled ? 'disabled-drag' : 'collection-item', + item: { + ...item, + sourceCollectionUid: collectionUid, + wasSelected: isSelected, + ...(multiDragItems ? { multiSelectedItems: multiDragItems } : {}) + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging() + }), + options: { + dropEffect: 'move' + } + }); + + // 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, + hoverBoundingRect: ref.current?.getBoundingClientRect(), + clientOffset: monitor.getClientOffset() + }); + }; + + const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => { + return canCollectionItemBeDropped({ + draggedItem, + targetItem, + dropType, + collectionUid, + collectionPathname + }); + }; + + const canAnyItemBeDropped = ({ draggedItem, targetItem, dropType }) => { + const items = draggedItem.multiSelectedItems?.length > 0 ? draggedItem.multiSelectedItems : [draggedItem]; + return items.some((i) => canItemBeDropped({ draggedItem: i, targetItem, dropType })); + }; + + const [{ isOver, canDrop }, drop] = useDrop({ + accept: 'collection-item', + hover: (draggedItem, monitor) => { + const { uid: targetItemUid } = item; + const { uid: draggedItemUid } = draggedItem; + + if (draggedItemUid === targetItemUid) return; + + const dropType = resolveDropFromMonitor(monitor); + if (!dropType) { + setDropType(null); + return; + } + + const _canItemBeDropped = canAnyItemBeDropped({ draggedItem, targetItem: item, dropType }); + + setDropType(_canItemBeDropped ? dropType : null); + }, + drop: async (draggedItem, monitor) => { + const { uid: targetItemUid } = item; + const { uid: draggedItemUid } = draggedItem; + + if (draggedItemUid === targetItemUid) return; + + const dropType = resolveDropFromMonitor(monitor); + if (!dropType) return; + + if (!canAnyItemBeDropped({ draggedItem, targetItem: item, dropType })) return; + + const draggedItems = getSortedDraggedItems({ + draggedItem, + allCollections, + workspaces, + activeWorkspace, + collectionSortOrder, + searchText + }); + + // Filter out items that can't be dropped on this target + const validDraggedItems = draggedItems.filter((dragged) => { + if (dragged.uid === targetItemUid) return false; + return canItemBeDropped({ draggedItem: dragged, targetItem: item, dropType }); + }); + + if (validDraggedItems.length > 0) { + await dispatch(handleMultipleCollectionItemsDrop({ + targetItem: item, + draggedItems: validDraggedItems, + dropType, + collectionUid + })); + } + + if (draggedItem.wasSelected) { + dispatch(clearSidebarSelection()); + } + + setDropType(null); + }, + canDrop: (draggedItem, monitor) => { + if (draggedItem.uid === item.uid) return false; + + const dropType = resolveDropFromMonitor(monitor); + if (!dropType) return false; + + return canAnyItemBeDropped({ draggedItem, targetItem: item, dropType }); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop() + }) + }); + + drag(drop(ref)); + dragPreview(getEmptyImage(), { captureDraggingState: true }); + + useEffect(() => { + if (!isOver) { + setDropType(null); + } + }, [isOver]); + + const iconClassName = classnames({ + 'rotate-90': !itemIsCollapsed + }); + + const examplesIconClassName = classnames({ + 'rotate-90': examplesExpanded + }); + + const itemRowClassName = classnames('flex collection-item-name relative items-center', { + 'item-focused-in-tab': isTabForItemActive, + 'item-hovered': isOver && canDrop, + 'drop-target': isOver && canDrop && dropType === 'inside', + 'drop-target-above': isOver && canDrop && dropType === 'above', + 'drop-target-below': isOver && canDrop && dropType === 'below', + 'item-keyboard-focused': isKeyboardFocused, + 'collection-item-selected': isSelected, + 'drag-disabled': isDragDisabled + }); + + const handleRun = async () => { + dispatch(sendRequest(item, collectionUid)).catch((err) => + toast.custom((t) => toast.dismiss(t.id)} />, { + duration: 5000 + }) + ); + }; + + const handleClick = (event) => { + if (handleSelectionClick(event)) return; + if (event && event.detail != 1) return; + // scroll to the active tab + setTimeout(scrollToTheActiveTab, 50); + const isRequest = isItemARequest(item); + const isApp = item.type === 'app'; + if (isRequest || isApp) { + if (isTabForItemPresent) { + dispatch( + focusTab({ + uid: tabUidForItem || item.uid + }) + ); + return; + } + dispatch( + addTab({ + uid: item.uid, + collectionUid: collectionUid, + ...(isRequest ? { requestPaneTab: getDefaultRequestPaneTab(item) } : {}), + type: item.type, + pathname: item.pathname + }) + ); + } else { + dispatch( + addTab({ + uid: item.uid, + collectionUid: collectionUid, + type: 'folder-settings', + pathname: item.pathname + }) + ); + if (item.collapsed) { + dispatch( + toggleCollectionItem({ + itemUid: item.uid, + collectionUid: collectionUid + }) + ); + } + } + }; + + const handleFolderCollapse = (e) => { + e.stopPropagation(); + e.preventDefault(); + dispatch( + toggleCollectionItem({ + itemUid: item.uid, + collectionUid: collectionUid + }) + ); + }; + + // prevent the parent's double-click handler from firing + const handleFolderDoubleClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + const handleExamplesCollapse = (e) => { + e.stopPropagation(); + e.preventDefault(); + setExamplesExpanded(!examplesExpanded); + }; + + // prevent the parent's double-click handler from firing + const handleExamplesDoubleClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + // Handle right-click context menu + const handleContextMenu = (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (isMultiSelected) { + openBulkMenu(e); + return; + } + + menuDropdownRef.current?.show(); + }; + + const indents = range(item.depth); + + // Build menu items for MenuDropdown + const buildMenuItems = () => { + const items = []; + + if (isFolder) { + items.push( + { + id: 'new-request', + leftSection: IconFilePlus, + label: 'New Request', + onClick: () => setNewRequestModalOpen(true) + }, + { + id: 'new-folder', + leftSection: IconFolderPlus, + label: 'New Folder', + onClick: () => setNewFolderModalOpen(true) + }, + { + id: 'new-app', + leftSection: IconAppWindow, + label: 'New App', + onClick: () => setNewAppModalOpen(true) + }, + { + id: 'run', + leftSection: IconPlayerPlay, + label: 'Run', + onClick: () => setRunCollectionModalOpen(true) + } + ); + } + + if (isCloneable) { + items.push({ + id: 'clone', + leftSection: IconCopy, + label: 'Clone', + onClick: handleCloneItem + }); + } + + items.push({ + id: 'copy', + leftSection: IconCopy, + label: 'Copy', + onClick: handleCopyItem + }); + + if (isFolder && hasCopiedItems) { + items.push({ + id: 'paste', + leftSection: IconClipboard, + label: 'Paste', + onClick: handlePasteItem + }); + } + + items.push( + { + id: 'rename', + leftSection: IconEdit, + label: 'Rename', + onClick: () => setRenameItemModalOpen(true) + } + ); + if (!isFolder && isItemARequest(item) && !(item.type === 'http-request' || item.type === 'graphql-request')) { + items.push({ + id: 'run', + leftSection: IconPlayerPlay, + label: 'Run', + onClick: () => { + handleRun(); + } + }); + } + + if (!isFolder && (item.type === 'http-request' || item.type === 'graphql-request')) { + items.push({ + id: 'generate-code', + leftSection: IconCode, + label: 'Generate Code', + onClick: handleGenerateCode + }); + } + + if (!isFolder && isItemARequest(item) && item.type === 'http-request') { + items.push({ + id: 'create-example', + leftSection: ExampleIcon, + label: 'Create Example', + onClick: () => setCreateExampleModalOpen(true) + }); + } + + items.push( + { + id: 'show-in-folder', + leftSection: IconFolder, + label: getRevealInFolderLabel(), + onClick: handleShowInFolder + } + ); + + if (isFolder) { + items.push({ + id: 'ignore', + leftSection: IconEyeOff, + label: 'Ignore', + onClick: () => setIgnoreItemModalOpen(true) + }); + } + + items.push({ id: 'separator-1', type: 'divider' }); + + items.push({ + id: 'info', + leftSection: IconInfoCircle, + label: 'Info', + onClick: () => setItemInfoModalOpen(true) + }); + + if (isFolder) { + items.push( + { + id: 'settings', + leftSection: IconSettings, + label: 'Settings', + onClick: viewFolderSettings + }, + { + id: 'open-terminal', + leftSection: IconTerminal2, + label: 'Open in Terminal', + onClick: async () => { + const folderCwd = item.pathname || collectionPathname; + await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd); + } + } + ); + } + + items.push({ + id: 'delete', + leftSection: IconTrash, + label: 'Delete', + className: 'delete-item', + onClick: () => setDeleteItemModalOpen(true) + }); + + return items; + }; + + const className = classnames('flex flex-col w-full', { + 'is-sidebar-dragging': isSidebarDragging + }); + + if (searchText && searchText.length) { + if (isItemARequest(item)) { + if (!doesRequestMatchSearchText(item, searchText)) { + return null; + } + } else { + if (!doesFolderHaveItemsMatchSearchText(item, searchText)) { + return null; + } + } + } + + const handleDoubleClick = (event) => { + dispatch(makeTabPermanent({ uid: tabUidForItem || item.uid })); + }; + + const handleShowInFolder = () => { + dispatch(showInFolder(item.pathname)).catch((error) => { + console.error('Error opening the folder', error); + toast.error('Error opening the folder'); + }); + }; + + const handleCreateExample = async (name, description = '') => { + const exampleData = { + name: name, + description: description, + status: 200, + statusText: 'OK', + headers: [], + body: { + type: 'text', + content: '' + } + }; + + // Calculate the index where the example will be saved + const existingExamples = item.draft?.examples || item.examples || []; + const exampleIndex = existingExamples.length; + const exampleUid = uuid(); + + dispatch(addResponseExample({ + itemUid: item.uid, + collectionUid: collectionUid, + example: { + ...exampleData, + uid: exampleUid + } + })); + + // Save the request + await dispatch(saveRequest(item.uid, collectionUid, true)); + + // Task middleware will track this and open the example in a new tab once the file is reloaded + dispatch(insertTaskIntoQueue({ + uid: exampleUid, + type: 'OPEN_EXAMPLE', + collectionUid: collectionUid, + itemUid: item.uid, + exampleIndex: exampleIndex, + // Freshly created examples start blank, so open the tab in edit mode. + openInEditMode: true + })); + + toast.success(`Example "${name}" created successfully`); + setCreateExampleModalOpen(false); + }; + + const handleGenerateCode = () => { + if ( + (item?.request?.url !== '') + || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '') + ) { + setGenerateCodeItemModalOpen(true); + } else { + toast.error('URL is required'); + } + }; + + const viewFolderSettings = () => { + if (isItemAFolder(item)) { + if (isTabForItemPresent) { + dispatch(focusTab({ uid: tabUidForItem || item.uid })); + return; + } + dispatch( + addTab({ + uid: item.uid, + collectionUid, + type: 'folder-settings', + pathname: item.pathname + }) + ); + } + }; + + const handleCopyItem = () => { + dispatch(copyRequest(item)); + toast.success(`${getItemTypeLabel(item)} copied`); + }; + + // One-click clone: display name becomes " copy"; the filesystem name + // uniqueness is resolved silently by electron. + const handleCloneItem = () => { + if (!isCloneable) return; + dispatch(cloneItem(`${item.name} copy`, sanitizeName(`${item.name} copy`), item.uid, collectionUid)) + .then(() => toast.success(`${getItemTypeLabel(item)} cloned!`)) + .catch((err) => toast.error(formatIpcError(err) || `An error occurred while cloning the ${getItemTypeLabel(item).toLowerCase()}`)); + }; + + const handlePasteItem = () => { + // Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder + let targetFolderUid = item.uid; + if (!isFolder) { + const parentFolder = findParentItemInCollection(collection, item.uid); + targetFolderUid = parentFolder ? parentFolder.uid : null; + } + + dispatch(pasteItem(collectionUid, targetFolderUid)) + .then(() => { + toast.success('Item pasted successfully'); + }) + .catch((err) => { + toast.error(formatIpcError(err) || 'An error occurred while pasting the item'); + }); + }; + + const handleFocus = () => { + setIsKeyboardFocused(true); + // For folders, set the folder path; for requests, set empty string (no terminal) + dispatch(setFocusedSidebarPath(isFolder ? item.pathname : '')); + }; + + const handleBlur = () => { + setIsKeyboardFocused(false); + dispatch(setFocusedSidebarPath(null)); + }; + + return ( + + {renameItemModalOpen && ( + setRenameItemModalOpen(false)} /> + )} + {deleteItemModalOpen && ( + setDeleteItemModalOpen(false)} + /> + )} + {ignoreItemModalOpen && ( + setIgnoreItemModalOpen(false)} /> + )} + {newRequestModalOpen && ( + setNewRequestModalOpen(false)} /> + )} + {newFolderModalOpen && ( + setNewFolderModalOpen(false)} /> + )} + {newAppModalOpen && ( + setNewAppModalOpen(false)} /> + )} + {runCollectionModalOpen && ( + setRunCollectionModalOpen(false)} /> + )} + {generateCodeItemModalOpen && ( + setGenerateCodeItemModalOpen(false)} /> + )} + {itemInfoModalOpen && ( + setItemInfoModalOpen(false)} /> + )} + setCreateExampleModalOpen(false)} + onSave={handleCreateExample} + title="Create Response Example" + initialName={getInitialExampleName(item)} + /> +
+
+ {indents && indents.length + ? indents.map((i) => ( +
+  {/* Indent */} +
+ )) + : null} +
+ + {isFolder ? ( + + + + ) : hasExamples ? ( + + + + ) : null} + +
+ + + {item.name} + +
+
+ {!isDragging && !isMultiSelected && ( +
+ + + + + +
+ )} +
+
+ + {children} + + {/* Show examples when expanded (only for HTTP requests) */} + {isItemARequest(item) && item.type === 'http-request' && examplesExpanded && hasExamples && ( +
+ {(item.examples || []).map((example, index) => { + return ( + + ); + })} +
+ )} +
+ ); +}; + +export default React.memo(CollectionItemRow); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 5a9c9a6d1a7..d9908821f17 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -1,871 +1,54 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; +import React 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'; -import { - IconChevronRight, - IconDots, - IconFilePlus, - IconFolderPlus, - IconPlayerPlay, - IconEdit, - IconCopy, - IconClipboard, - IconCode, - IconFolder, - IconTrash, - IconSettings, - IconInfoCircle, - IconTerminal2, - IconAppWindow, - IconEyeOff -} from '@tabler/icons'; import { useSelector, useDispatch } 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 { 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 { 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 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 { - canCollectionItemBeDropped, - determineCollectionItemDrop, - getInitialExampleName, - findParentItemInCollection, - getSelectionInfo, - getSortedDraggedItems -} from 'utils/collections/index'; +import { isItemAFolder, isItemARequest } from 'utils/tabs'; import { sortByNameThenSequence } from 'utils/common/index'; -import { getRevealInFolderLabel } from 'utils/common/platform'; -import CreateExampleModal from 'components/ResponseExample/CreateExampleModal'; -import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal'; -import ActionIcon from 'ui/ActionIcon'; -import MenuDropdown from 'ui/MenuDropdown'; +import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; -import useKeybinding from 'hooks/useKeybinding'; -import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; -import useMultiSelectDragDisabled from 'hooks/useMultiSelectDragDisabled'; -import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections/index'; +import MenuDropdown from 'ui/MenuDropdown'; +import CollectionItemRow from './CollectionItemRow'; +/** + * Thin recursive wrapper around CollectionItemRow. The row renders the item itself (name, + * chevron, menu, drag/drop, multi-select, examples); this wrapper computes the item's grouped + * children and, when the folder is expanded, renders them recursively as the row's `children`. + */ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu }) => { - 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 isSidebarDragging = useSelector((state) => state.app.isDragging); + const dispatch = useDispatch(); const allCollections = useSelector((state) => state.collections.collections); const collection = allCollections?.find((c) => c.uid === collectionUid); - const { hasCopiedItems } = useSelector((state) => state.app.clipboard); - const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); - const isSelected = selectedSidebarUids.includes(item.uid); - const isMultiSelected = isSelected && selectedSidebarUids.length > 1; - const handleSelectionClick = useSidebarSelectionClick({ uid: item.uid, searchText }); - const workspaces = useSelector((state) => state.workspaces.workspaces); - const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); - const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid); - const collectionSortOrder = useSelector((state) => state.collections.collectionSortOrder); - const dispatch = useDispatch(); - - // When dragging a multi-selected row, carry all effectively-selected folders/requests - // (excluding collections) so dropping one moves the entire selection together. - const multiDragItems = useMemo(() => { - if (!isSelected || selectedSidebarUids.length < 2) return null; - const { effectiveSelection, hasCollection } = getSelectionInfo({ collections: allCollections, selectedUids: selectedSidebarUids }); - if (hasCollection) return null; - return effectiveSelection.map((entry) => ({ ...entry.item, sourceCollectionUid: entry.collectionUid })); - }, [isSelected, selectedSidebarUids, allCollections]); - - const isDragDisabled = useMultiSelectDragDisabled({ isSelected, selectedSidebarUids, allCollections }); - - // We use a single ref for drag and drop. - const ref = useRef(null); - const menuDropdownRef = useRef(null); + const { dropdownContainerRef } = useSidebarAccordion(); - const [renameItemModalOpen, setRenameItemModalOpen] = useState(false); - const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false); - const [ignoreItemModalOpen, setIgnoreItemModalOpen] = useState(false); - const [createExampleModalOpen, setCreateExampleModalOpen] = useState(false); - const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); - const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); - const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); - const [newAppModalOpen, setNewAppModalOpen] = useState(false); - const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); - const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); - const [examplesExpanded, setExamplesExpanded] = useState(false); - const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; const isFolder = isItemAFolder(item); - const isCloneable = isFolder || isItemARequest(item) || item.type === 'app'; - - // Check if request has examples (only for HTTP requests) - const hasExamples = isItemARequest(item) && item.type === 'http-request' && item.examples && item.examples.length > 0; - - // Sidebar shortcuts — only active when this sidebar item has keyboard focus - useKeybinding('cloneItem', () => { - handleCloneItem(); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - useKeybinding('copyItem', () => { - handleCopyItem(); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - useKeybinding('pasteItem', () => { - handlePasteItem(); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - useKeybinding('renameItem', () => { - setRenameItemModalOpen(true); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - useKeybinding('newRequest', () => { - if (!isFolder) return false; - setNewRequestModalOpen(true); - return false; - }, { enabled: isKeyboardFocused && isFolder, deps: [isKeyboardFocused, isFolder] }); - - const [dropType, setDropType] = useState(null); // 'above', 'inside' or 'below' - - const [{ isDragging }, drag, dragPreview] = useDrag({ - type: isDragDisabled ? 'disabled-drag' : 'collection-item', - item: { - ...item, - sourceCollectionUid: collectionUid, - wasSelected: isSelected, - ...(multiDragItems ? { multiSelectedItems: multiDragItems } : {}) - }, - collect: (monitor) => ({ - isDragging: monitor.isDragging() - }), - options: { - dropEffect: 'move' - } - }); - - // 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, - hoverBoundingRect: ref.current?.getBoundingClientRect(), - clientOffset: monitor.getClientOffset() - }); - }; - - const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => { - return canCollectionItemBeDropped({ - draggedItem, - targetItem, - dropType, - collectionUid, - collectionPathname - }); - }; - - const canAnyItemBeDropped = ({ draggedItem, targetItem, dropType }) => { - const items = draggedItem.multiSelectedItems?.length > 0 ? draggedItem.multiSelectedItems : [draggedItem]; - return items.some((i) => canItemBeDropped({ draggedItem: i, targetItem, dropType })); - }; - - const [{ isOver, canDrop }, drop] = useDrop({ - accept: 'collection-item', - hover: (draggedItem, monitor) => { - const { uid: targetItemUid } = item; - const { uid: draggedItemUid } = draggedItem; - - if (draggedItemUid === targetItemUid) return; - - const dropType = resolveDropFromMonitor(monitor); - if (!dropType) { - setDropType(null); - return; - } - - const _canItemBeDropped = canAnyItemBeDropped({ draggedItem, targetItem: item, dropType }); - - setDropType(_canItemBeDropped ? dropType : null); - }, - drop: async (draggedItem, monitor) => { - const { uid: targetItemUid } = item; - const { uid: draggedItemUid } = draggedItem; - - if (draggedItemUid === targetItemUid) return; - - const dropType = resolveDropFromMonitor(monitor); - if (!dropType) return; - - if (!canAnyItemBeDropped({ draggedItem, targetItem: item, dropType })) return; - - const draggedItems = getSortedDraggedItems({ - draggedItem, - allCollections, - workspaces, - activeWorkspace, - collectionSortOrder, - searchText - }); - - // Filter out items that can't be dropped on this target - const validDraggedItems = draggedItems.filter((dragged) => { - if (dragged.uid === targetItemUid) return false; - return canItemBeDropped({ draggedItem: dragged, targetItem: item, dropType }); - }); - - if (validDraggedItems.length > 0) { - await dispatch(handleMultipleCollectionItemsDrop({ - targetItem: item, - draggedItems: validDraggedItems, - dropType, - collectionUid - })); - } - - if (draggedItem.wasSelected) { - dispatch(clearSidebarSelection()); - } - - setDropType(null); - }, - canDrop: (draggedItem, monitor) => { - if (draggedItem.uid === item.uid) return false; - - const dropType = resolveDropFromMonitor(monitor); - if (!dropType) return false; - - return canAnyItemBeDropped({ draggedItem, targetItem: item, dropType }); - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop() - }) - }); - - drag(drop(ref)); - dragPreview(getEmptyImage(), { captureDraggingState: true }); - - useEffect(() => { - if (!isOver) { - setDropType(null); - } - }, [isOver]); - - const iconClassName = classnames({ - 'rotate-90': !itemIsCollapsed - }); - - const examplesIconClassName = classnames({ - 'rotate-90': examplesExpanded - }); - - const itemRowClassName = classnames('flex collection-item-name relative items-center', { - 'item-focused-in-tab': isTabForItemActive, - 'item-hovered': isOver && canDrop, - 'drop-target': isOver && canDrop && dropType === 'inside', - 'drop-target-above': isOver && canDrop && dropType === 'above', - 'drop-target-below': isOver && canDrop && dropType === 'below', - 'item-keyboard-focused': isKeyboardFocused, - 'collection-item-selected': isSelected, - 'drag-disabled': isDragDisabled - }); - - const handleRun = async () => { - dispatch(sendRequest(item, collectionUid)).catch((err) => - toast.custom((t) => toast.dismiss(t.id)} />, { - duration: 5000 - }) - ); - }; - - const handleClick = (event) => { - if (handleSelectionClick(event)) return; - if (event && event.detail != 1) return; - // scroll to the active tab - setTimeout(scrollToTheActiveTab, 50); - const isRequest = isItemARequest(item); - const isApp = item.type === 'app'; - if (isRequest || isApp) { - if (isTabForItemPresent) { - dispatch( - focusTab({ - uid: tabUidForItem || item.uid - }) - ); - return; - } - dispatch( - addTab({ - uid: item.uid, - collectionUid: collectionUid, - ...(isRequest ? { requestPaneTab: getDefaultRequestPaneTab(item) } : {}), - type: item.type, - pathname: item.pathname - }) - ); - } else { - dispatch( - addTab({ - uid: item.uid, - collectionUid: collectionUid, - type: 'folder-settings', - pathname: item.pathname - }) - ); - if (item.collapsed) { - dispatch( - toggleCollectionItem({ - itemUid: item.uid, - collectionUid: collectionUid - }) - ); - } - } - }; - - const handleFolderCollapse = (e) => { - e.stopPropagation(); - e.preventDefault(); - dispatch( - toggleCollectionItem({ - itemUid: item.uid, - collectionUid: collectionUid - }) - ); - }; - - // prevent the parent's double-click handler from firing - const handleFolderDoubleClick = (e) => { - e.stopPropagation(); - e.preventDefault(); - }; - - const handleExamplesCollapse = (e) => { - e.stopPropagation(); - e.preventDefault(); - setExamplesExpanded(!examplesExpanded); - }; - - // prevent the parent's double-click handler from firing - const handleExamplesDoubleClick = (e) => { - e.stopPropagation(); - e.preventDefault(); - }; - - // Handle right-click context menu - const handleContextMenu = (e) => { - e.preventDefault(); - e.stopPropagation(); - - if (isMultiSelected) { - openBulkMenu(e); - return; - } - - menuDropdownRef.current?.show(); - }; - - const indents = range(item.depth); - - // Build menu items for MenuDropdown - const buildMenuItems = () => { - const items = []; - - if (isFolder) { - items.push( - { - id: 'new-request', - leftSection: IconFilePlus, - label: 'New Request', - onClick: () => setNewRequestModalOpen(true) - }, - { - id: 'new-folder', - leftSection: IconFolderPlus, - label: 'New Folder', - onClick: () => setNewFolderModalOpen(true) - }, - { - id: 'new-app', - leftSection: IconAppWindow, - label: 'New App', - onClick: () => setNewAppModalOpen(true) - }, - { - id: 'run', - leftSection: IconPlayerPlay, - label: 'Run', - onClick: () => setRunCollectionModalOpen(true) - } - ); - } - - if (isCloneable) { - items.push({ - id: 'clone', - leftSection: IconCopy, - label: 'Clone', - onClick: handleCloneItem - }); - } - - items.push({ - id: 'copy', - leftSection: IconCopy, - label: 'Copy', - onClick: handleCopyItem - }); - - if (isFolder && hasCopiedItems) { - items.push({ - id: 'paste', - leftSection: IconClipboard, - label: 'Paste', - onClick: handlePasteItem - }); - } - - items.push( - { - id: 'rename', - leftSection: IconEdit, - label: 'Rename', - onClick: () => setRenameItemModalOpen(true) - } - ); - if (!isFolder && isItemARequest(item) && !(item.type === 'http-request' || item.type === 'graphql-request')) { - items.push({ - id: 'run', - leftSection: IconPlayerPlay, - label: 'Run', - onClick: () => { - handleRun(); - } - }); - } - - if (!isFolder && (item.type === 'http-request' || item.type === 'graphql-request')) { - items.push({ - id: 'generate-code', - leftSection: IconCode, - label: 'Generate Code', - onClick: handleGenerateCode - }); - } - - if (!isFolder && isItemARequest(item) && item.type === 'http-request') { - items.push({ - id: 'create-example', - leftSection: ExampleIcon, - label: 'Create Example', - onClick: () => setCreateExampleModalOpen(true) - }); - } - - items.push( - { - id: 'show-in-folder', - leftSection: IconFolder, - label: getRevealInFolderLabel(), - onClick: handleShowInFolder - } - ); - - if (isFolder) { - items.push({ - id: 'ignore', - leftSection: IconEyeOff, - label: 'Ignore', - onClick: () => setIgnoreItemModalOpen(true) - }); - } - - items.push({ id: 'separator-1', type: 'divider' }); - - items.push({ - id: 'info', - leftSection: IconInfoCircle, - label: 'Info', - onClick: () => setItemInfoModalOpen(true) - }); - - if (isFolder) { - items.push( - { - id: 'settings', - leftSection: IconSettings, - label: 'Settings', - onClick: viewFolderSettings - }, - { - id: 'open-terminal', - leftSection: IconTerminal2, - label: 'Open in Terminal', - onClick: async () => { - const folderCwd = item.pathname || collectionPathname; - await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd); - } - } - ); - } - - items.push({ - id: 'delete', - leftSection: IconTrash, - label: 'Delete', - className: 'delete-item', - onClick: () => setDeleteItemModalOpen(true) - }); - - return items; - }; - - const className = classnames('flex flex-col w-full', { - 'is-sidebar-dragging': isSidebarDragging - }); - - if (searchText && searchText.length) { - if (isItemARequest(item)) { - if (!doesRequestMatchSearchText(item, searchText)) { - return null; - } - } else { - if (!doesFolderHaveItemsMatchSearchText(item, searchText)) { - return null; - } - } - } - - const handleDoubleClick = (event) => { - dispatch(makeTabPermanent({ uid: tabUidForItem || item.uid })); - }; - - // Sort items by their "seq" property. - const sortItemsBySequence = (items = []) => { - return items.sort((a, b) => a.seq - b.seq); - }; - - const handleShowInFolder = () => { - dispatch(showInFolder(item.pathname)).catch((error) => { - console.error('Error opening the folder', error); - toast.error('Error opening the folder'); - }); - }; - - const handleCreateExample = async (name, description = '') => { - const exampleData = { - name: name, - description: description, - status: 200, - statusText: 'OK', - headers: [], - body: { - type: 'text', - content: '' - } - }; - - // Calculate the index where the example will be saved - const existingExamples = item.draft?.examples || item.examples || []; - const exampleIndex = existingExamples.length; - const exampleUid = uuid(); - - dispatch(addResponseExample({ - itemUid: item.uid, - collectionUid: collectionUid, - example: { - ...exampleData, - uid: exampleUid - } - })); - - // Save the request - await dispatch(saveRequest(item.uid, collectionUid, true)); - - // Task middleware will track this and open the example in a new tab once the file is reloaded - dispatch(insertTaskIntoQueue({ - uid: exampleUid, - type: 'OPEN_EXAMPLE', - collectionUid: collectionUid, - itemUid: item.uid, - exampleIndex: exampleIndex, - // Freshly created examples start blank, so open the tab in edit mode. - openInEditMode: true - })); - - toast.success(`Example "${name}" created successfully`); - 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 appItems = [...filter(item.items, (i) => i.type === 'app' && !i.isTransient)].sort((a, b) => a.seq - b.seq); + const requestItems = [...filter(item.items, (i) => isItemARequest(i) && !i.isTransient)].sort((a, b) => a.seq - b.seq); + const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !appItems?.length && !requestItems?.length; const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid }); - const handleGenerateCode = () => { - if ( - (item?.request?.url !== '') - || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '') - ) { - setGenerateCodeItemModalOpen(true); - } else { - toast.error('URL is required'); - } - }; - - const viewFolderSettings = () => { - if (isItemAFolder(item)) { - if (isTabForItemPresent) { - dispatch(focusTab({ uid: tabUidForItem || item.uid })); - return; - } - dispatch( - addTab({ - uid: item.uid, - collectionUid, - type: 'folder-settings', - pathname: item.pathname - }) - ); - } - }; - - const handleCopyItem = () => { - dispatch(copyRequest(item)); - toast.success(`${getItemTypeLabel(item)} copied`); - }; - - // One-click clone: display name becomes " copy"; the filesystem name - // uniqueness is resolved silently by electron. - const handleCloneItem = () => { - if (!isCloneable) return; - dispatch(cloneItem(`${item.name} copy`, sanitizeName(`${item.name} copy`), item.uid, collectionUid)) - .then(() => toast.success(`${getItemTypeLabel(item)} cloned!`)) - .catch((err) => toast.error(formatIpcError(err) || `An error occurred while cloning the ${getItemTypeLabel(item).toLowerCase()}`)); - }; - - const handlePasteItem = () => { - // Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder - let targetFolderUid = item.uid; - if (!isFolder) { - const parentFolder = findParentItemInCollection(collection, item.uid); - targetFolderUid = parentFolder ? parentFolder.uid : null; - } - - dispatch(pasteItem(collectionUid, targetFolderUid)) - .then(() => { - toast.success('Item pasted successfully'); - }) - .catch((err) => { - toast.error(formatIpcError(err) || 'An error occurred while pasting the item'); - }); - }; - - const handleFocus = () => { - setIsKeyboardFocused(true); - // For folders, set the folder path; for requests, set empty string (no terminal) - dispatch(setFocusedSidebarPath(isFolder ? item.pathname : '')); - }; - - const handleBlur = () => { - setIsKeyboardFocused(false); - dispatch(setFocusedSidebarPath(null)); - }; - return ( - - {renameItemModalOpen && ( - setRenameItemModalOpen(false)} /> - )} - {deleteItemModalOpen && ( - setDeleteItemModalOpen(false)} - /> - )} - {ignoreItemModalOpen && ( - setIgnoreItemModalOpen(false)} /> - )} - {newRequestModalOpen && ( - setNewRequestModalOpen(false)} /> - )} - {newFolderModalOpen && ( - setNewFolderModalOpen(false)} /> - )} - {newAppModalOpen && ( - setNewAppModalOpen(false)} /> - )} - {runCollectionModalOpen && ( - setRunCollectionModalOpen(false)} /> - )} - {generateCodeItemModalOpen && ( - setGenerateCodeItemModalOpen(false)} /> - )} - {itemInfoModalOpen && ( - setItemInfoModalOpen(false)} /> - )} - setCreateExampleModalOpen(false)} - onSave={handleCreateExample} - title="Create Response Example" - initialName={getInitialExampleName(item)} - /> -
-
- {indents && indents.length - ? indents.map((i) => ( -
-  {/* Indent */} -
- )) - : null} -
- - {isFolder ? ( - - - - ) : hasExamples ? ( - - - - ) : null} - -
- - - {item.name} - -
-
- {!isDragging && !isMultiSelected && ( -
- - - - - -
- )} -
-
+ {!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} + {folderItems.map((i) => ( + + ))} + {appItems.map((i) => ( + + ))} + {requestItems.map((i) => ( + + ))} {showEmptyFolderMessage ? (
{range(item.depth + 1).map((i) => ( @@ -888,24 +71,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, o ) : null}
) : null} - - {/* Show examples when expanded (only for HTTP requests) */} - {isItemARequest(item) && item.type === 'http-request' && examplesExpanded && hasExamples && ( -
- {(item.examples || []).map((example, index) => { - return ( - - ); - })} -
- )} - + ); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx new file mode 100644 index 00000000000..c7b7ed9441b --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx @@ -0,0 +1,644 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import classnames from 'classnames'; +import { uuid } from 'utils/common'; +import { useDrop, useDrag } from 'react-dnd'; +import { getEmptyImage } from 'react-dnd-html5-backend'; +import { + IconChevronRight, + IconDots, + IconLoader2, + IconFilePlus, + IconFolderPlus, + IconCopy, + IconClipboard, + IconPlayerPlay, + IconEdit, + IconShare, + IconFoldDown, + IconX, + IconSettings, + IconTerminal2, + IconFolder, + IconBook, + IconServer, + IconFileArrowRight, + IconAppWindow +} from '@tabler/icons'; +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 { 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 RemoveCollections from '../RemoveCollections'; +import MoveToWorkspace from '../MoveToWorkspace'; +import { isPathExternalToBasePath } from 'utils/common/path'; +import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search'; +import { getSortedDraggedItems, getSelectionInfo } from 'utils/collections'; +import { isTabForItemActive } from 'src/selectors/tab'; + +import RenameCollection from '../RenameCollection'; +import StyledWrapper from '../StyledWrapper'; +import CloneCollection from '../CloneCollection'; +import { scrollToTheActiveTab } from 'utils/tabs'; +import ShareCollection from 'components/ShareCollection/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 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'; +import useMultiSelectDragDisabled from 'hooks/useMultiSelectDragDisabled'; + +const CollectionRow = ({ collection, searchText, openBulkMenu, children }) => { + const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER); + const { dropdownContainerRef } = useSidebarAccordion(); + const [showNewFolderModal, setShowNewFolderModal] = useState(false); + const [showNewRequestModal, setShowNewRequestModal] = useState(false); + const [showNewAppModal, setShowNewAppModal] = useState(false); + const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false); + const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false); + const [showShareCollectionModal, setShowShareCollectionModal] = useState(false); + const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false); + const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); + const [showMoveToWorkspaceModal, setShowMoveToWorkspaceModal] = useState(false); + const [showCreateMockServerModal, setShowCreateMockServerModal] = useState(false); + const [dropType, setDropType] = useState(null); + const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); + const dispatch = useDispatch(); + const isLoading = collection.isLoading; + const collectionRef = useRef(null); + + const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid })); + const { hasCopiedItems } = useSelector((state) => state.app.clipboard); + const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); + const isSelected = selectedSidebarUids.includes(collection.uid); + const isMultiSelected = isSelected && selectedSidebarUids.length > 1; + const handleSelectionClick = useSidebarSelectionClick({ uid: collection.uid, searchText }); + const menuDropdownRef = useRef(null); + + // 'Move into Workspace' is available for collections opened from outside the current workspace. + const activeWorkspace = useSelector((state) => + state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid) + ); + const workspaces = useSelector((state) => state.workspaces.workspaces); + const collectionSortOrder = useSelector((state) => state.collections.collectionSortOrder); + const allCollections = useSelector((state) => state.collections.collections); + const isMoveToWorkspaceVisible = isPathExternalToBasePath(activeWorkspace?.pathname, collection.pathname); + + const isDragDisabled = useMultiSelectDragDisabled({ isSelected, selectedSidebarUids, allCollections }); + + // When dragging a multi-selected collection, carry all other selected collections along + // so dropping one reorders the entire selection together. Mixed selections (a collection + // alongside a folder/request) are drag-disabled entirely, so this only ever needs to + // handle collection-only selections. + const multiDragItems = useMemo(() => { + if (!isSelected || !selectedSidebarUids || selectedSidebarUids.length < 2) return null; + const { effectiveSelection, hasFolder, hasRequest } = getSelectionInfo({ collections: allCollections, selectedUids: selectedSidebarUids }); + if (hasFolder || hasRequest) return null; + const collectionEntries = effectiveSelection.filter((entry) => entry.type === 'collection'); + return collectionEntries.map((entry) => entry.collection); + }, [isSelected, selectedSidebarUids, allCollections]); + + // Open the OpenAPI Sync tab + const openOpenAPISyncTab = () => { + ensureCollectionIsMounted(); + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'openapi-sync' + }) + ); + }; + + const openMockServerDashboard = () => { + ensureCollectionIsMounted(); + setShowCreateMockServerModal(true); + }; + + const handleRun = () => { + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'collection-runner' + }) + ); + }; + + const ensureCollectionIsMounted = () => { + if (collection.mountStatus === 'mounted' || collection.mountStatus === 'mounting') { + return; + } + dispatch(mountCollection({ + collectionUid: collection.uid, + collectionPathname: collection.pathname, + brunoConfig: collection.brunoConfig + })); + }; + + const hasSearchText = searchText && searchText?.trim()?.length; + const collectionIsCollapsed = hasSearchText ? false : collection.collapsed; + + const iconClassName = classnames({ + 'rotate-90': !collectionIsCollapsed + }); + + const handleClick = (event) => { + if (handleSelectionClick(event)) return; + if (event.detail != 1) return; + + // Check if the click came from the chevron icon + const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon'); + + setTimeout(scrollToTheActiveTab, 50); + + ensureCollectionIsMounted(); + + if (collection.collapsed) { + dispatch(toggleCollection(collection.uid)); + // Set default jsSandboxMode to 'safe' if not present and save to disk + if (!collection.securityConfig?.jsSandboxMode) { + dispatch(saveCollectionSecurityConfig(collection.uid, { + jsSandboxMode: 'safe' + })); + } + } + + if (!isChevronClick) { + dispatch( + addTab({ + uid: collection.uid, + collectionUid: collection.uid, + type: 'collection-settings' + }) + ); + } + }; + + const handleDoubleClick = (_event) => { + dispatch(makeTabPermanent({ uid: collection.uid })); + }; + + const handleCollectionCollapse = (e) => { + e.stopPropagation(); + e.preventDefault(); + ensureCollectionIsMounted(); + dispatch(toggleCollection(collection.uid)); + }; + + // prevent the parent's double-click handler from firing + const handleCollectionDoubleClick = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + + const handleRightClick = (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (isMultiSelected) { + openBulkMenu(event); + return; + } + + // Otherwise, show the regular menu dropdown + const _menuDropdown = menuDropdownRef.current; + if (_menuDropdown) { + _menuDropdown.toggle(); + } + }; + + const handleCollapseFullCollection = () => { + dispatch(collapseFullCollection({ collectionUid: collection.uid })); + }; + + const viewCollectionSettings = () => { + dispatch( + addTab({ + uid: collection.uid, + collectionUid: collection.uid, + type: 'collection-settings' + }) + ); + }; + + const handleShowInFolder = () => { + dispatch(showInFolder(collection.pathname)).catch((error) => { + console.error('Error opening the folder', error); + toast.error('Error opening the folder'); + }); + }; + + const handlePasteItem = () => { + dispatch(pasteItem(collection.uid, null)) + .then(() => { + toast.success('Item pasted successfully'); + }) + .catch((err) => { + toast.error(err ? err.message : 'An error occurred while pasting the item'); + }); + }; + + // Sidebar shortcuts — only active when this collection has keyboard focus + useKeybinding('cloneItem', () => { + setShowCloneCollectionModalOpen(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('renameItem', () => { + setShowRenameCollectionModal(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('pasteItem', () => { + handlePasteItem(); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + useKeybinding('newRequest', () => { + setShowNewRequestModal(true); + return false; + }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); + + const handleFocus = () => { + setIsKeyboardFocused(true); + dispatch(setFocusedSidebarPath(collection.pathname)); + }; + + const handleBlur = () => { + setIsKeyboardFocused(false); + dispatch(setFocusedSidebarPath(null)); + }; + + const isCollectionItem = (itemType) => { + return itemType === 'collection-item'; + }; + + const [{ isDragging }, drag, dragPreview] = useDrag({ + type: isDragDisabled ? 'disabled-drag' : 'collection', + item: { + ...collection, + wasSelected: isSelected, + ...(multiDragItems ? { multiSelectedItems: multiDragItems } : {}) + }, + collect: (monitor) => ({ + isDragging: monitor.isDragging() + }), + options: { + dropEffect: 'move' + } + }); + + const [{ isOver }, drop] = useDrop({ + accept: ['collection', 'collection-item'], + hover: (_draggedItem, monitor) => { + const itemType = monitor.getItemType(); + if (isCollectionItem(itemType)) { + // For collection items, always show full highlight (inside drop) + setDropType('inside'); + } else { + // For collections, show line indicator (above drop) + setDropType('above'); + } + }, + drop: async (draggedItem, monitor) => { + const itemType = monitor.getItemType(); + if (isCollectionItem(itemType)) { + // Lazy-unmounted workspace collections are droppable in the sidebar but + // have no watcher yet — mount first so the move writes through and the UI updates. + if (collection.mountStatus !== 'mounted' && collection.mountStatus !== 'mounting') { + await dispatch(mountCollection({ + collectionUid: collection.uid, + collectionPathname: collection.pathname, + brunoConfig: collection.brunoConfig + })); + } + + const draggedItems = getSortedDraggedItems({ + draggedItem, + allCollections, + workspaces, + activeWorkspace, + collectionSortOrder, + searchText + }); + + const validDraggedItems = draggedItems.filter((dragged) => dragged.uid !== collection.uid); + + if (validDraggedItems.length > 0) { + dispatch(handleMultipleCollectionItemsDrop({ targetItem: collection, draggedItems: validDraggedItems, dropType: 'inside', collectionUid: collection.uid })); + } + + if (draggedItem.wasSelected) { + dispatch(clearSidebarSelection()); + } + } else { + const draggedItems = getSortedDraggedItems({ + draggedItem, + allCollections, + workspaces, + activeWorkspace, + collectionSortOrder, + searchText + }); + + const validDraggedItems = draggedItems.filter((dragged) => dragged.uid !== collection.uid); + + for (const dragged of validDraggedItems) { + await dispatch(moveCollectionAndPersist({ draggedItem: dragged, targetItem: collection })); + } + + if (draggedItem.wasSelected) { + dispatch(clearSidebarSelection()); + } + } + setDropType(null); + }, + canDrop: (draggedItem) => { + if (draggedItem.uid === collection.uid) return false; + return !draggedItem.multiSelectedItems?.some((i) => i.uid === collection.uid); + }, + collect: (monitor) => ({ + isOver: monitor.isOver() + }) + }); + + 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 + + if (searchText && searchText.length) { + if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) { + return null; + } + } + + const collectionRowClassName = classnames( + 'flex py-1 collection-name items-center relative', + { + 'item-hovered': isOver && dropType === 'above', // For collection-to-collection moves (show line) + 'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area) + 'collection-focused-in-tab': isCollectionFocused && !isKeyboardFocused, + 'collection-keyboard-focused': isKeyboardFocused, + 'collection-selected': isSelected, + 'drag-disabled': isDragDisabled + } + ); + + const menuItems = [ + { + id: 'new-request', + leftSection: IconFilePlus, + label: 'New Request', + onClick: () => { + ensureCollectionIsMounted(); + setShowNewRequestModal(true); + } + }, + { + id: 'new-folder', + leftSection: IconFolderPlus, + label: 'New Folder', + onClick: () => { + ensureCollectionIsMounted(); + setShowNewFolderModal(true); + } + }, + { + id: 'new-app', + leftSection: IconAppWindow, + label: 'New App', + onClick: () => { + ensureCollectionIsMounted(); + setShowNewAppModal(true); + } + }, + { + id: 'run', + leftSection: IconPlayerPlay, + label: 'Run', + onClick: () => { + ensureCollectionIsMounted(); + handleRun(); + } + }, + { + id: 'clone', + leftSection: IconCopy, + label: 'Clone', + testId: 'clone-collection', + onClick: () => { + setShowCloneCollectionModalOpen(true); + } + }, + { + id: 'sync-openapi', + leftSection: OpenAPISyncIcon, + label: 'OpenAPI', + onClick: openOpenAPISyncTab + }, + ...(hasCopiedItems + ? [ + { + id: 'paste', + leftSection: IconClipboard, + label: 'Paste', + onClick: handlePasteItem + } + ] + : []), + { + id: 'rename', + leftSection: IconEdit, + label: 'Rename', + onClick: () => { + setShowRenameCollectionModal(true); + } + }, + { + id: 'share', + leftSection: IconShare, + label: 'Share', + onClick: () => { + ensureCollectionIsMounted(); + setShowShareCollectionModal(true); + } + }, + { + id: 'generate-docs', + leftSection: IconBook, + label: 'Generate Docs', + onClick: () => { + ensureCollectionIsMounted(); + setShowGenerateDocumentationModal(true); + } + }, + { + id: 'collapse', + leftSection: IconFoldDown, + label: 'Collapse', + onClick: handleCollapseFullCollection + }, + { + id: 'show-in-folder', + leftSection: IconFolder, + label: getRevealInFolderLabel(), + onClick: handleShowInFolder + }, + ...(isMockServerEnabled ? [{ + id: 'create-mock-server', + leftSection: IconServer, + label: 'Create Mock server', + rightSection: Beta, + onClick: openMockServerDashboard + }] : []), + { + id: 'divider-1', + type: 'divider' + }, + { + id: 'settings', + leftSection: IconSettings, + label: 'Settings', + onClick: viewCollectionSettings + }, + { + id: 'terminal', + leftSection: IconTerminal2, + label: 'Open in Terminal', + onClick: async () => { + const collectionCwd = collection.pathname; + await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd); + } + }, + ...(isMoveToWorkspaceVisible + ? [ + { + id: 'move-to-workspace', + leftSection: IconFileArrowRight, + label: 'Move into Workspace', + testId: 'move-collection-to-workspace', + onClick: () => { + setShowMoveToWorkspaceModal(true); + } + } + ] + : []), + { + id: 'remove', + leftSection: IconX, + label: 'Remove', + onClick: () => { + setShowRemoveCollectionModal(true); + } + } + ]; + + return ( + + {showNewRequestModal && setShowNewRequestModal(false)} />} + {showNewFolderModal && setShowNewFolderModal(false)} />} + {showNewAppModal && setShowNewAppModal(false)} />} + {showRenameCollectionModal && ( + setShowRenameCollectionModal(false)} /> + )} + {showRemoveCollectionModal && ( + setShowRemoveCollectionModal(false)} /> + )} + {showMoveToWorkspaceModal && ( + setShowMoveToWorkspaceModal(false)} /> + )} + {showShareCollectionModal && ( + setShowShareCollectionModal(false)} /> + )} + {showGenerateDocumentationModal && ( + setShowGenerateDocumentationModal(false)} /> + )} + {showCloneCollectionModalOpen && ( + setShowCloneCollectionModalOpen(false)} /> + )} + {showCreateMockServerModal && ( + setShowCreateMockServerModal(false)} + /> + )} +
+
+ + + + + {isLoading ? : null} +
+ {!isDragging && !isMultiSelected && ( +
+
+ + + + + +
+
+ )} +
+ {children} +
+ ); +}; + +export default CollectionRow; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 3b15e73e53e..1cc6bd248f7 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -1,406 +1,35 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; -import classnames from 'classnames'; -import { uuid } from 'utils/common'; +import React, { useState, useEffect } from 'react'; import filter from 'lodash/filter'; -import { useDrop, useDrag } from 'react-dnd'; -import { getEmptyImage } from 'react-dnd-html5-backend'; -import { - IconChevronRight, - IconDots, - IconLoader2, - IconFilePlus, - IconFolderPlus, - IconCopy, - IconClipboard, - IconPlayerPlay, - IconEdit, - IconShare, - IconFoldDown, - IconX, - IconSettings, - IconTerminal2, - IconFolder, - IconBook, - IconServer, - IconFileArrowRight, - IconAppWindow -} from '@tabler/icons'; -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 { 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 { isPathExternalToBasePath } from 'utils/common/path'; -import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search'; -import { isItemAFolder, isItemARequest, getSortedDraggedItems, getSelectionInfo } from 'utils/collections'; -import { isTabForItemActive } from 'src/selectors/tab'; - -import RenameCollection from './RenameCollection'; -import StyledWrapper from './StyledWrapper'; -import CloneCollection from './CloneCollection'; -import { scrollToTheActiveTab } from 'utils/tabs'; -import ShareCollection from 'components/ShareCollection/index'; -import GenerateDocumentation from './GenerateDocumentation'; +import { useDispatch } from 'react-redux'; +import { isItemAFolder, isItemARequest } from 'utils/collections'; import { sortByNameThenSequence } from 'utils/common/index'; -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'; -import useMultiSelectDragDisabled from 'hooks/useMultiSelectDragDisabled'; +import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; +import MenuDropdown from 'ui/MenuDropdown'; +import CollectionRow from './CollectionRow'; +import CollectionItem from './CollectionItem'; -// Delay before showing empty collection state (ms) -// This prevents flicker from race condition between loading state and item batch updates +// Delay before showing the empty-collection state (ms). Prevents flicker from the race between +// the collection's loading flag and the item batch arriving over IPC. const EMPTY_STATE_DELAY_MS = 300; +/** + * Thin recursive wrapper around CollectionRow. The row renders the collection header itself + * (name, chevron, menu, drag/drop, multi-select); this wrapper computes the collection's grouped + * children and, when expanded, renders them recursively as the row's `children`. + */ const Collection = ({ collection, searchText, openBulkMenu }) => { - const isMockServerEnabled = useBetaFeature(BETA_FEATURES.MOCK_SERVER); - const { dropdownContainerRef } = useSidebarAccordion(); - const [showNewFolderModal, setShowNewFolderModal] = useState(false); - const [showNewRequestModal, setShowNewRequestModal] = useState(false); - const [showNewAppModal, setShowNewAppModal] = useState(false); - const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false); - const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false); - const [showShareCollectionModal, setShowShareCollectionModal] = useState(false); - const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false); - const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false); - const [showMoveToWorkspaceModal, setShowMoveToWorkspaceModal] = useState(false); - 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); - const selectedSidebarUids = useSelector((state) => state.collections.selectedSidebarUids); - const isSelected = selectedSidebarUids.includes(collection.uid); - const isMultiSelected = isSelected && selectedSidebarUids.length > 1; - const handleSelectionClick = useSidebarSelectionClick({ uid: collection.uid, searchText }); - const menuDropdownRef = useRef(null); - - // 'Move into Workspace' is available for collections opened from outside the current workspace. - const activeWorkspace = useSelector((state) => - state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid) - ); - const workspaces = useSelector((state) => state.workspaces.workspaces); - const collectionSortOrder = useSelector((state) => state.collections.collectionSortOrder); - const allCollections = useSelector((state) => state.collections.collections); - const isMoveToWorkspaceVisible = isPathExternalToBasePath(activeWorkspace?.pathname, collection.pathname); - - const isDragDisabled = useMultiSelectDragDisabled({ isSelected, selectedSidebarUids, allCollections }); - - // When dragging a multi-selected collection, carry all other selected collections along - // so dropping one reorders the entire selection together. Mixed selections (a collection - // alongside a folder/request) are drag-disabled entirely, so this only ever needs to - // handle collection-only selections. - const multiDragItems = useMemo(() => { - if (!isSelected || !selectedSidebarUids || selectedSidebarUids.length < 2) return null; - const { effectiveSelection, hasFolder, hasRequest } = getSelectionInfo({ collections: allCollections, selectedUids: selectedSidebarUids }); - if (hasFolder || hasRequest) return null; - const collectionEntries = effectiveSelection.filter((entry) => entry.type === 'collection'); - return collectionEntries.map((entry) => entry.collection); - }, [isSelected, selectedSidebarUids, allCollections]); - - // Open the OpenAPI Sync tab - const openOpenAPISyncTab = () => { - ensureCollectionIsMounted(); - dispatch( - addTab({ - uid: uuid(), - collectionUid: collection.uid, - type: 'openapi-sync' - }) - ); - }; - - const openMockServerDashboard = () => { - ensureCollectionIsMounted(); - setShowCreateMockServerModal(true); - }; - - const handleRun = () => { - dispatch( - addTab({ - uid: uuid(), - collectionUid: collection.uid, - type: 'collection-runner' - }) - ); - }; - - const ensureCollectionIsMounted = () => { - if (collection.mountStatus === 'mounted' || collection.mountStatus === 'mounting') { - return; - } - dispatch(mountCollection({ - collectionUid: collection.uid, - collectionPathname: collection.pathname, - brunoConfig: collection.brunoConfig - })); - }; + const { dropdownContainerRef } = useSidebarAccordion(); const hasSearchText = searchText && searchText?.trim()?.length; const collectionIsCollapsed = hasSearchText ? false : collection.collapsed; + const isLoading = collection.isLoading; - const iconClassName = classnames({ - 'rotate-90': !collectionIsCollapsed - }); - - const handleClick = (event) => { - if (handleSelectionClick(event)) return; - if (event.detail != 1) return; - - // Check if the click came from the chevron icon - const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon'); - - setTimeout(scrollToTheActiveTab, 50); - - ensureCollectionIsMounted(); - - if (collection.collapsed) { - dispatch(toggleCollection(collection.uid)); - // Set default jsSandboxMode to 'safe' if not present and save to disk - if (!collection.securityConfig?.jsSandboxMode) { - dispatch(saveCollectionSecurityConfig(collection.uid, { - jsSandboxMode: 'safe' - })); - } - } - - if (!isChevronClick) { - dispatch( - addTab({ - uid: collection.uid, - collectionUid: collection.uid, - type: 'collection-settings' - }) - ); - } - }; - - const handleDoubleClick = (_event) => { - dispatch(makeTabPermanent({ uid: collection.uid })); - }; - - const handleCollectionCollapse = (e) => { - e.stopPropagation(); - e.preventDefault(); - ensureCollectionIsMounted(); - dispatch(toggleCollection(collection.uid)); - }; - - // prevent the parent's double-click handler from firing - const handleCollectionDoubleClick = (e) => { - e.stopPropagation(); - e.preventDefault(); - }; - - const handleRightClick = (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (isMultiSelected) { - openBulkMenu(event); - return; - } - - // Otherwise, show the regular menu dropdown - const _menuDropdown = menuDropdownRef.current; - if (_menuDropdown) { - _menuDropdown.toggle(); - } - }; - - const handleCollapseFullCollection = () => { - dispatch(collapseFullCollection({ collectionUid: collection.uid })); - }; - - const viewCollectionSettings = () => { - dispatch( - addTab({ - uid: collection.uid, - collectionUid: collection.uid, - type: 'collection-settings' - }) - ); - }; - - const handleShowInFolder = () => { - dispatch(showInFolder(collection.pathname)).catch((error) => { - console.error('Error opening the folder', error); - toast.error('Error opening the folder'); - }); - }; - - const handlePasteItem = () => { - dispatch(pasteItem(collection.uid, null)) - .then(() => { - toast.success('Item pasted successfully'); - }) - .catch((err) => { - toast.error(err ? err.message : 'An error occurred while pasting the item'); - }); - }; - - // Sidebar shortcuts — only active when this collection has keyboard focus - useKeybinding('cloneItem', () => { - setShowCloneCollectionModalOpen(true); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - useKeybinding('renameItem', () => { - setShowRenameCollectionModal(true); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - useKeybinding('pasteItem', () => { - handlePasteItem(); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - useKeybinding('newRequest', () => { - setShowNewRequestModal(true); - return false; - }, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] }); - - const handleFocus = () => { - setIsKeyboardFocused(true); - dispatch(setFocusedSidebarPath(collection.pathname)); - }; - - const handleBlur = () => { - setIsKeyboardFocused(false); - dispatch(setFocusedSidebarPath(null)); - }; - - const isCollectionItem = (itemType) => { - return itemType === 'collection-item'; - }; - - const [{ isDragging }, drag, dragPreview] = useDrag({ - type: isDragDisabled ? 'disabled-drag' : 'collection', - item: { - ...collection, - wasSelected: isSelected, - ...(multiDragItems ? { multiSelectedItems: multiDragItems } : {}) - }, - collect: (monitor) => ({ - isDragging: monitor.isDragging() - }), - options: { - dropEffect: 'move' - } - }); - - const [{ isOver }, drop] = useDrop({ - accept: ['collection', 'collection-item'], - hover: (_draggedItem, monitor) => { - const itemType = monitor.getItemType(); - if (isCollectionItem(itemType)) { - // For collection items, always show full highlight (inside drop) - setDropType('inside'); - } else { - // For collections, show line indicator (above drop) - setDropType('above'); - } - }, - drop: async (draggedItem, monitor) => { - const itemType = monitor.getItemType(); - if (isCollectionItem(itemType)) { - // Lazy-unmounted workspace collections are droppable in the sidebar but - // have no watcher yet — mount first so the move writes through and the UI updates. - if (collection.mountStatus !== 'mounted' && collection.mountStatus !== 'mounting') { - await dispatch(mountCollection({ - collectionUid: collection.uid, - collectionPathname: collection.pathname, - brunoConfig: collection.brunoConfig - })); - } - - const draggedItems = getSortedDraggedItems({ - draggedItem, - allCollections, - workspaces, - activeWorkspace, - collectionSortOrder, - searchText - }); - - const validDraggedItems = draggedItems.filter((dragged) => dragged.uid !== collection.uid); - - if (validDraggedItems.length > 0) { - dispatch(handleMultipleCollectionItemsDrop({ targetItem: collection, draggedItems: validDraggedItems, dropType: 'inside', collectionUid: collection.uid })); - } - - if (draggedItem.wasSelected) { - dispatch(clearSidebarSelection()); - } - } else { - const draggedItems = getSortedDraggedItems({ - draggedItem, - allCollections, - workspaces, - activeWorkspace, - collectionSortOrder, - searchText - }); - - const validDraggedItems = draggedItems.filter((dragged) => dragged.uid !== collection.uid); - - for (const dragged of validDraggedItems) { - await dispatch(moveCollectionAndPersist({ draggedItem: dragged, targetItem: collection })); - } - - if (draggedItem.wasSelected) { - dispatch(clearSidebarSelection()); - } - } - setDropType(null); - }, - canDrop: (draggedItem) => { - if (draggedItem.uid === collection.uid) return false; - return !draggedItem.multiSelectedItems?.some((i) => i.uid === collection.uid); - }, - collect: (monitor) => ({ - isOver: monitor.isOver() - }) - }); - - 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]); + // Only count persisted requests/folders/apps; transients and file items don't affect empty state. + const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i) || i.type === 'app')).length || 0; - // Debounce showing empty state to prevent flicker - // Race condition: isLoading can become false before items batch arrives from IPC + const [showEmptyState, setShowEmptyState] = useState(false); useEffect(() => { const isMounted = collection.mountStatus === 'mounted'; const hasItems = itemCount > 0; @@ -414,264 +43,14 @@ const Collection = ({ collection, searchText, openBulkMenu }) => { return () => clearTimeout(timer); }, [itemCount, isLoading, collection.mountStatus]); - if (searchText && searchText.length) { - if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) { - return null; - } - } - - const collectionRowClassName = classnames( - 'flex py-1 collection-name items-center relative', - { - 'item-hovered': isOver && dropType === 'above', // For collection-to-collection moves (show line) - 'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area) - 'collection-focused-in-tab': isCollectionFocused && !isKeyboardFocused, - 'collection-keyboard-focused': isKeyboardFocused, - 'collection-selected': isSelected, - 'drag-disabled': isDragDisabled - } - ); - - // 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 requestItems = [...filter(collection.items, (i) => isItemARequest(i) && !i.isTransient)].sort((a, b) => a.seq - b.seq); + const appItems = [...filter(collection.items, (i) => i.type === 'app' && !i.isTransient)].sort((a, b) => a.seq - b.seq); 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', - leftSection: IconFilePlus, - label: 'New Request', - onClick: () => { - ensureCollectionIsMounted(); - setShowNewRequestModal(true); - } - }, - { - id: 'new-folder', - leftSection: IconFolderPlus, - label: 'New Folder', - onClick: () => { - ensureCollectionIsMounted(); - setShowNewFolderModal(true); - } - }, - { - id: 'new-app', - leftSection: IconAppWindow, - label: 'New App', - onClick: () => { - ensureCollectionIsMounted(); - setShowNewAppModal(true); - } - }, - { - id: 'run', - leftSection: IconPlayerPlay, - label: 'Run', - onClick: () => { - ensureCollectionIsMounted(); - handleRun(); - } - }, - { - id: 'clone', - leftSection: IconCopy, - label: 'Clone', - testId: 'clone-collection', - onClick: () => { - setShowCloneCollectionModalOpen(true); - } - }, - { - id: 'sync-openapi', - leftSection: OpenAPISyncIcon, - label: 'OpenAPI', - onClick: openOpenAPISyncTab - }, - ...(hasCopiedItems - ? [ - { - id: 'paste', - leftSection: IconClipboard, - label: 'Paste', - onClick: handlePasteItem - } - ] - : []), - { - id: 'rename', - leftSection: IconEdit, - label: 'Rename', - onClick: () => { - setShowRenameCollectionModal(true); - } - }, - { - id: 'share', - leftSection: IconShare, - label: 'Share', - onClick: () => { - ensureCollectionIsMounted(); - setShowShareCollectionModal(true); - } - }, - { - id: 'generate-docs', - leftSection: IconBook, - label: 'Generate Docs', - onClick: () => { - ensureCollectionIsMounted(); - setShowGenerateDocumentationModal(true); - } - }, - { - id: 'collapse', - leftSection: IconFoldDown, - label: 'Collapse', - onClick: handleCollapseFullCollection - }, - { - id: 'show-in-folder', - leftSection: IconFolder, - label: getRevealInFolderLabel(), - onClick: handleShowInFolder - }, - ...(isMockServerEnabled ? [{ - id: 'create-mock-server', - leftSection: IconServer, - label: 'Create Mock server', - rightSection: Beta, - onClick: openMockServerDashboard - }] : []), - { - id: 'divider-1', - type: 'divider' - }, - { - id: 'settings', - leftSection: IconSettings, - label: 'Settings', - onClick: viewCollectionSettings - }, - { - id: 'terminal', - leftSection: IconTerminal2, - label: 'Open in Terminal', - onClick: async () => { - const collectionCwd = collection.pathname; - await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd); - } - }, - ...(isMoveToWorkspaceVisible - ? [ - { - id: 'move-to-workspace', - leftSection: IconFileArrowRight, - label: 'Move into Workspace', - testId: 'move-collection-to-workspace', - onClick: () => { - setShowMoveToWorkspaceModal(true); - } - } - ] - : []), - { - id: 'remove', - leftSection: IconX, - label: 'Remove', - onClick: () => { - setShowRemoveCollectionModal(true); - } - } - ]; - return ( - - {showNewRequestModal && setShowNewRequestModal(false)} />} - {showNewFolderModal && setShowNewFolderModal(false)} />} - {showNewAppModal && setShowNewAppModal(false)} />} - {showRenameCollectionModal && ( - setShowRenameCollectionModal(false)} /> - )} - {showRemoveCollectionModal && ( - setShowRemoveCollectionModal(false)} /> - )} - {showMoveToWorkspaceModal && ( - setShowMoveToWorkspaceModal(false)} /> - )} - {showShareCollectionModal && ( - setShowShareCollectionModal(false)} /> - )} - {showGenerateDocumentationModal && ( - setShowGenerateDocumentationModal(false)} /> - )} - {showCloneCollectionModalOpen && ( - setShowCloneCollectionModalOpen(false)} /> - )} - {showCreateMockServerModal && ( - setShowCreateMockServerModal(false)} - /> - )} -
-
- - - - - {isLoading ? : null} -
- {!isDragging && !isMultiSelected && ( -
-
- - - - - -
-
- )} -
+
{!collectionIsCollapsed ? (
@@ -705,7 +84,7 @@ const Collection = ({ collection, searchText, openBulkMenu }) => {
) : null}
-
+ ); }; From 23665a25539bce216acd062f401486809b99caf6 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 08:22:22 +0530 Subject: [PATCH 03/17] moved examples expanded state from local to redux --- .../CollectionItemRow/index.jsx | 6 ++-- .../ReduxStore/slices/collections/index.js | 28 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index afd8f994e92..a84e8c0f5c0 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -26,7 +26,7 @@ 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 } 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'; @@ -129,7 +129,7 @@ const CollectionItemRow = ({ item, collectionUid, collectionPathname, searchText const [newAppModalOpen, setNewAppModalOpen] = useState(false); const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); - const [examplesExpanded, setExamplesExpanded] = useState(false); + const examplesExpanded = !!item.examplesExpanded; const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; @@ -393,7 +393,7 @@ const CollectionItemRow = ({ item, collectionUid, collectionPathname, searchText const handleExamplesCollapse = (e) => { e.stopPropagation(); e.preventDefault(); - setExamplesExpanded(!examplesExpanded); + dispatch(toggleRequestExamples({ collectionUid, itemUid: item.uid })); }; // prevent the parent's double-click handler from firing 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 5de893f346c..4fa9fb50aeb 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -723,7 +723,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) { @@ -1121,6 +1121,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); @@ -2628,7 +2639,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; @@ -2638,7 +2649,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; @@ -2892,7 +2903,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; @@ -2902,7 +2913,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; @@ -3775,7 +3786,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( @@ -3832,7 +3843,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) => @@ -3994,7 +4005,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: '', @@ -4238,6 +4249,7 @@ export const { expandItem, collapseItem, toggleCollectionItem, + toggleRequestExamples, requestUrlChanged, updateItemSettings, updateAuth, From 3af82f2ebba4e212d4155b4c270089ff2233b149 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 08:26:00 +0530 Subject: [PATCH 04/17] added empty row cta component --- .../SidebarRow/EmptyCtaRow/StyledWrapper.js | 22 ++++++++++ .../SidebarRow/EmptyCtaRow/index.jsx | 42 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/EmptyCtaRow/index.jsx 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); From 63f100640fec51b0e1c9e5a288fbf8f0b855eb1f Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 08:42:27 +0530 Subject: [PATCH 05/17] added virtuoso and created sidebar row component to render each row --- .../CollectionItemRow/index.jsx | 18 --- .../Collection/CollectionItem/index.js | 78 ------------- .../Sidebar/Collections/Collection/index.js | 91 --------------- .../Sidebar/Collections/SidebarRow/index.jsx | 105 ++++++++++++++++++ .../components/Sidebar/Collections/index.js | 83 +++++++++++--- 5 files changed, 171 insertions(+), 204 deletions(-) delete mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js delete mode 100644 packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js create mode 100644 packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index a84e8c0f5c0..dc6433034ae 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -46,7 +46,6 @@ 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 ExampleIcon from 'components/Icons/ExampleIcon'; import { getTabUidForItem as getTabUidForItemSelector, @@ -835,23 +834,6 @@ const CollectionItemRow = ({ item, collectionUid, collectionPathname, searchText
{children} - - {/* Show examples when expanded (only for HTTP requests) */} - {isItemARequest(item) && item.type === 'http-request' && examplesExpanded && hasExamples && ( -
- {(item.examples || []).map((example, index) => { - return ( - - ); - })} -
- )}
); }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js deleted file mode 100644 index d9908821f17..00000000000 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; -import range from 'lodash/range'; -import filter from 'lodash/filter'; -import { useSelector, useDispatch } from 'react-redux'; -import { isItemAFolder, isItemARequest } from 'utils/tabs'; -import { sortByNameThenSequence } from 'utils/common/index'; -import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; -import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; -import MenuDropdown from 'ui/MenuDropdown'; -import CollectionItemRow from './CollectionItemRow'; - -/** - * Thin recursive wrapper around CollectionItemRow. The row renders the item itself (name, - * chevron, menu, drag/drop, multi-select, examples); this wrapper computes the item's grouped - * children and, when the folder is expanded, renders them recursively as the row's `children`. - */ -const CollectionItem = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu }) => { - const dispatch = useDispatch(); - const allCollections = useSelector((state) => state.collections.collections); - const collection = allCollections?.find((c) => c.uid === collectionUid); - const { dropdownContainerRef } = useSidebarAccordion(); - - const hasSearchText = searchText && searchText?.trim()?.length; - const itemIsCollapsed = hasSearchText ? false : item.collapsed; - const isFolder = isItemAFolder(item); - - const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient)); - const appItems = [...filter(item.items, (i) => i.type === 'app' && !i.isTransient)].sort((a, b) => a.seq - b.seq); - const requestItems = [...filter(item.items, (i) => isItemARequest(i) && !i.isTransient)].sort((a, b) => a.seq - b.seq); - const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !appItems?.length && !requestItems?.length; - const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid }); - - return ( - - {!itemIsCollapsed ? ( -
- {folderItems.map((i) => ( - - ))} - {appItems.map((i) => ( - - ))} - {requestItems.map((i) => ( - - ))} - {showEmptyFolderMessage ? ( -
- {range(item.depth + 1).map((i) => ( -
-   -
- ))} -
- - - -
-
- ) : null} -
- ) : null} -
- ); -}; - -export default React.memo(CollectionItem); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js deleted file mode 100644 index 1cc6bd248f7..00000000000 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import filter from 'lodash/filter'; -import { useDispatch } from 'react-redux'; -import { isItemAFolder, isItemARequest } from 'utils/collections'; -import { sortByNameThenSequence } from 'utils/common/index'; -import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest'; -import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'; -import MenuDropdown from 'ui/MenuDropdown'; -import CollectionRow from './CollectionRow'; -import CollectionItem from './CollectionItem'; - -// Delay before showing the empty-collection state (ms). Prevents flicker from the race between -// the collection's loading flag and the item batch arriving over IPC. -const EMPTY_STATE_DELAY_MS = 300; - -/** - * Thin recursive wrapper around CollectionRow. The row renders the collection header itself - * (name, chevron, menu, drag/drop, multi-select); this wrapper computes the collection's grouped - * children and, when expanded, renders them recursively as the row's `children`. - */ -const Collection = ({ collection, searchText, openBulkMenu }) => { - const dispatch = useDispatch(); - const { dropdownContainerRef } = useSidebarAccordion(); - - const hasSearchText = searchText && searchText?.trim()?.length; - const collectionIsCollapsed = hasSearchText ? false : collection.collapsed; - const isLoading = collection.isLoading; - - // Only count persisted requests/folders/apps; transients and file items don't affect empty state. - const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i) || i.type === 'app')).length || 0; - - const [showEmptyState, setShowEmptyState] = useState(false); - 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]); - - const requestItems = [...filter(collection.items, (i) => isItemARequest(i) && !i.isTransient)].sort((a, b) => a.seq - b.seq); - const appItems = [...filter(collection.items, (i) => i.type === 'app' && !i.isTransient)].sort((a, b) => a.seq - b.seq); - const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient)); - const showEmptyCollectionMessage = showEmptyState && !hasSearchText; - const emptyStateMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: null }); - - return ( - -
- {!collectionIsCollapsed ? ( -
- {folderItems?.map?.((i) => { - return ; - })} - {appItems?.map?.((i) => { - return ; - })} - {requestItems?.map?.((i) => { - return ; - })} - {showEmptyCollectionMessage ? ( -
-
-   -
-
- - - -
-
- ) : null} -
- ) : null} -
-
- ); -}; - -export default Collection; 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..a3c3ad4b01b --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -0,0 +1,105 @@ +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 = ({ row, searchText, openBulkMenu, itemsByUid, collectionsByUid, ghostsByPath }) => { + switch (row.kind) { + case 'collection': { + const collection = collectionsByUid.get(row.collectionUid); + if (!collection) return null; + return ; + } + case 'folder': + case 'app': + case 'request': { + const item = itemsByUid.get(row.itemUid); + if (!item) return null; + return ( + + ); + } + case 'empty-cta': { + const collection = collectionsByUid.get(row.collectionUid); + return ; + } + case 'ghost': { + const entry = ghostsByPath.get(row.collectionPathname); + if (!entry) return null; + return ; + } + case 'example': { + const item = itemsByUid.get(row.itemUid); + 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 + && 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 4ef0db36bbe..e994474e8ea 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 } 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 } = 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,38 @@ 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]); + + const { rowIndexByItemUid, rowIndexByCollectionUid } = useMemo(() => buildIndexes(rows), [rows]); + + // Resolve the active tab's row index. ref lets the scroll effect read the current index + // without depending on it, so rows shifting above the active row don't re-fire the scroll. + const rowIndex = rowIndexByItemUid.get(activeTabUid); + const activeRowIndex = activeTabUid !== null + ? (rowIndex ?? rowIndexByCollectionUid.get(activeTabUid) ?? null) + : null; + const activeRowIndexRef = useRef(activeRowIndex); + activeRowIndexRef.current = activeRowIndex; + + useEffect(() => { + const index = activeRowIndexRef.current; + if (index === null) return; + virtuosoRef.current?.scrollIntoView({ index, behavior: 'auto' }); + }, [activeTabUid]); + const handleContainerClick = (e) => { if (e.currentTarget === e.target) { dispatch(clearSidebarSelection()); @@ -58,23 +93,37 @@ 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) => ( + + )} + />
From a2f16ce3cb4835a8a1cd8f76cdc757128c98345f Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 09:30:09 +0530 Subject: [PATCH 06/17] fixed test locators --- .../components/Sidebar/Collections/index.js | 7 +- ...-collection-cross-format-drag-drop.spec.ts | 10 +- .../cross-collection-drag-drop-folder.spec.ts | 20 +-- ...cross-collection-drag-drop-request.spec.ts | 30 +--- .../global-env-import.spec.ts | 4 +- .../duplicate-operation-names-fix.spec.ts | 2 +- .../operation-name-with-newlines-fix.spec.ts | 2 +- tests/import/wsdl/import-wsdl.spec.ts | 24 +-- .../empty-state-cta/empty-state-cta.spec.ts | 3 +- tests/utils/page/actions.ts | 49 ++++-- tests/utils/page/mounting.ts | 155 ++++++++---------- tests/utils/page/runner.ts | 24 +-- tests/utils/page/sidebar/index.ts | 10 +- 13 files changed, 148 insertions(+), 192 deletions(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index e994474e8ea..b999822f871 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -66,8 +66,13 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis virtuosoRef.current?.scrollIntoView({ index, behavior: 'auto' }); }, [activeTabUid]); + // Clear the multi-selection when the user clicks empty sidebar space. With virtualization + // the empty area below the rows belongs to Virtuoso's internal scroller (not this container), + // so `currentTarget === target` never holds. Instead, clear on any click that didn't land + // inside a selectable row. const handleContainerClick = (e) => { - if (e.currentTarget === e.target) { + const onRow = e.target.closest('[data-testid="sidebar-collection-item-row"], [data-testid="sidebar-collection-row'); + if (!onRow) { dispatch(clearSidebarSelection()); } }; diff --git a/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts b/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts index dcb2d7f7d7d..897fa025aec 100644 --- a/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts +++ b/tests/collection/moving-requests/cross-collection-cross-format-drag-drop.spec.ts @@ -20,10 +20,7 @@ test.describe('Cross-Format Collection Drag and Drop', () => { // Expand the bru collection and locate the request await page.locator('#sidebar-collection-name').filter({ hasText: 'bru-collection' }).click(); - const bruCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'bru-collection' }) - .locator('..'); + const bruCollectionContainer = page.locator('[data-collection-id="bru-collection"]'); const bruRequest = bruCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await expect(bruRequest).toBeVisible(); @@ -32,10 +29,7 @@ test.describe('Cross-Format Collection Drag and Drop', () => { await bruRequest.dragTo(ymlCollection); // Verify the request appears in the yml collection (increase timeout for file watcher processing) - const ymlCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'yml-collection' }) - .locator('..'); + const ymlCollectionContainer = page.locator('[data-collection-id="yml-collection"]'); // The yml collection may need to be expanded after the drop const ymlCollectionItems = ymlCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }); // Wait for file watcher to process the new file, then expand collection if needed diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts index 64104758e80..bd393233354 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts @@ -43,19 +43,13 @@ test.describe('Cross-Collection Drag and Drop for folder', () => { await sourceFolder.dragTo(targetCollection); // Verify the folder has been moved to the target collection - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect( targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-folder' }) ).toBeVisible(); // Verify the folder (and its request) is no longer in the source collection. - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); await expect( sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-folder' }) ).not.toBeVisible(); @@ -98,20 +92,14 @@ test.describe('Cross-Collection Drag and Drop for folder', () => { await expect(page.getByText(/already exists/i)).toHaveCount(0); // The folder is moved out of the source collection. - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); await expect( sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'folder-1' }) ).toHaveCount(0); // The target now shows two "folder-1" entries (the original and the moved one; // the directory name was silently suffixed on disk). - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect( targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'folder-1' }) ).toHaveCount(2); diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts index 4929c8f7299..165b8dad790 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts @@ -23,10 +23,7 @@ test.describe('Cross-Collection Drag and Drop', () => { await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible(); // Locate the request in source collection - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); const sourceRequest = sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await expect(sourceRequest).toBeVisible(); @@ -39,10 +36,7 @@ test.describe('Cross-Collection Drag and Drop', () => { // Verify the request has been moved to the target collection // Check that the request now appears under target collection - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toBeVisible(); // Verify the request is no longer in the source collection @@ -71,14 +65,8 @@ test.describe('Cross-Collection Drag and Drop', () => { // Go back to source collection to drag the request await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click(); - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); const sourceRequest = sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await expect(sourceRequest).toBeVisible(); @@ -110,10 +98,7 @@ test.describe('Cross-Collection Drag and Drop', () => { await createCollection(page, 'target-collection', await createTmpDir('target-collection')); // Open the request to create a tab - const sourceCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'source-collection' }) - .locator('..'); + const sourceCollectionContainer = page.locator('[data-collection-id="source-collection"]'); const sourceRequest = sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName }).first(); await sourceRequest.click(); @@ -129,10 +114,7 @@ test.describe('Cross-Collection Drag and Drop', () => { await expect(requestTab).not.toBeVisible(); // Verify the request appears in the target collection - const targetCollectionContainer = page - .locator('.collection-name') - .filter({ hasText: 'target-collection' }) - .locator('..'); + const targetCollectionContainer = page.locator('[data-collection-id="target-collection"]'); await expect(targetCollectionContainer.locator('.collection-item-name').filter({ hasText: requestName })).toBeVisible(); }); }); diff --git a/tests/environments/import-environment/global-env-import.spec.ts b/tests/environments/import-environment/global-env-import.spec.ts index 61a7633ac77..5aca88682b4 100644 --- a/tests/environments/import-environment/global-env-import.spec.ts +++ b/tests/environments/import-environment/global-env-import.spec.ts @@ -68,7 +68,7 @@ test.describe('Global Environment Import Tests', () => { await envTab.hover(); await envTab.getByTestId('request-tab-close-icon').click({ force: true }); - await page.locator('#collection-environment-test-collection .collection-item-name').first().click(); + await page.locator('[data-collection-id="environment-test-collection"] .collection-item-name').first().click(); await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts/{{userId}}'); await page.locator('[data-testid="send-arrow-icon"]').click(); await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' }); @@ -79,7 +79,7 @@ test.describe('Global Environment Import Tests', () => { await expect(responsePane).toContainText('"userId": 1'); // Test POST request - await page.locator('#collection-environment-test-collection .collection-item-name').nth(1).click(); + await page.locator('[data-collection-id="environment-test-collection"] .collection-item-name').nth(1).click(); await expect(page.locator('#request-url .CodeMirror-line')).toContainText('{{host}}/posts'); await page.locator('[data-testid="send-arrow-icon"]').click(); await page.locator('[data-testid="response-status-code"]').waitFor({ state: 'visible' }); diff --git a/tests/import/openapi/duplicate-operation-names-fix.spec.ts b/tests/import/openapi/duplicate-operation-names-fix.spec.ts index 655e170d30f..6b46e80cc57 100644 --- a/tests/import/openapi/duplicate-operation-names-fix.spec.ts +++ b/tests/import/openapi/duplicate-operation-names-fix.spec.ts @@ -40,6 +40,6 @@ test.describe('OpenAPI Duplicate Names Handling', () => { await page.locator('#sidebar-collection-name').getByText('Duplicate Test Collection').click(); // verify that all 3 requests were imported correctly despite duplicate operation names - await expect(page.locator('#collection-duplicate-test-collection .collection-item-name')).toHaveCount(3); + await expect(page.locator('[data-collection-id="duplicate-test-collection"] .collection-item-name')).toHaveCount(3); }); }); diff --git a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts index c086ebecdb9..c995af99ef9 100644 --- a/tests/import/openapi/operation-name-with-newlines-fix.spec.ts +++ b/tests/import/openapi/operation-name-with-newlines-fix.spec.ts @@ -40,6 +40,6 @@ test.describe('OpenAPI Newline Handling', () => { // verify that all requests were imported correctly despite newlines in operation names // the parser should clean up the operation names and create valid request names - await expect(page.locator('#collection-newline-test-collection .collection-item-name')).toHaveCount(2); + await expect(page.locator('[data-collection-id="newline-test-collection"] .collection-item-name')).toHaveCount(2); }); }); diff --git a/tests/import/wsdl/import-wsdl.spec.ts b/tests/import/wsdl/import-wsdl.spec.ts index a139004297d..faec11ebe94 100644 --- a/tests/import/wsdl/import-wsdl.spec.ts +++ b/tests/import/wsdl/import-wsdl.spec.ts @@ -49,20 +49,20 @@ test.describe('Import WSDL Collection', () => { await openCollection(page, 'TestWSDLServiceXML'); // verify that all requests were imported correctly - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name')).toHaveCount(1); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name')).toHaveCount(1); }); await test.step('Verify that folders and requests were imported correctly', async () => { - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('UserService')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('UserService')).toBeVisible(); // open the user service folder - await page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('UserService').click(); + await page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('UserService').click(); - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('GetUser')).toBeVisible(); - await expect(page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('CreateUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('GetUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('CreateUser')).toBeVisible(); }); await test.step('Verify the GetUser request is imported correctly', async () => { - await page.locator('#collection-testwsdlservicexml .collection-item-name').getByText('GetUser').click(); + await page.locator('[data-collection-id="testwsdlservicexml"] .collection-item-name').getByText('GetUser').click(); await expect(page.locator('.request-tab.active').getByText('GetUser')).toBeVisible(); await expect(page.locator('#request-url').getByText('http://example.com/soap/userservice')).toBeVisible(); }); @@ -110,20 +110,20 @@ test.describe('Import WSDL Collection', () => { await openCollection(page, 'TestWSDLServiceJSON'); // verify that all requests were imported correctly - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name')).toHaveCount(1); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name')).toHaveCount(1); }); await test.step('Verify that folders and requests were imported correctly', async () => { - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('UserService')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('UserService')).toBeVisible(); // open the user service folder - await page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('UserService').click(); + await page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('UserService').click(); - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('GetUser')).toBeVisible(); - await expect(page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('CreateUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('GetUser')).toBeVisible(); + await expect(page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('CreateUser')).toBeVisible(); }); await test.step('Verify the CreateUser request is imported correctly', async () => { - await page.locator('#collection-testwsdlservicejson .collection-item-name').getByText('CreateUser').click(); + await page.locator('[data-collection-id="testwsdlservicejson"] .collection-item-name').getByText('CreateUser').click(); await expect(page.locator('.request-tab.active').getByText('CreateUser')).toBeVisible(); await expect(page.locator('#request-url').getByText('http://example.com/soap/userservice')).toBeVisible(); }); diff --git a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts index c6edeab549e..86f761d6630 100644 --- a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts +++ b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts @@ -15,7 +15,8 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { // Scope an assertion to a single collection — pageWithUserData reuses one app // across the describe block, and multiple expanded collections would otherwise // make `getByTestId('add-request-cta')` match more than one element. - const collectionScope = (page: Page, name: string) => page.locator(`#collection-${name}`); + const collectionScope = (page: Page, name: string) => + page.locator(`[data-collection-id="${name.replace(/\s+/g, '-').toLowerCase()}"]`); const expandCollection = async (name: string) => { const collection = locators.sidebar.collection(name); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 2d57e857414..4bcc832c3d8 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -121,8 +121,24 @@ const closeAllCollections = async (page) => { * @param collectionName - The name of the collection to open * @returns void */ +// sidebar is virtualized, opening a request lower in the list scrolls the collection header +// out of the viewport, and Virtuoso unmounts it once it passes the overscan. +// Reset the list to the top so the header row is rendered before we locate it. +const revealCollectionsTop = async (page: Page) => { + const collections = page.getByTestId('collections'); + if (!(await collections.count())) return; + + await collections.evaluate((root) => { + const scroller + = root.querySelector('[data-testid="sidebar-collections-scroller"]') + || Array.from(root.querySelectorAll('*')).find((el) => el.scrollHeight > el.clientHeight); + (scroller as HTMLElement | undefined)?.scrollTo({ top: 0 }); + }); +}; + const openCollection = async (page: Page, collectionName: string) => { await test.step(`Open collection "${collectionName}"`, async () => { + await revealCollectionsTop(page); await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click(); }); }; @@ -551,11 +567,10 @@ const deleteRequest = async (page, requestName: string, collectionName: string) // Click on the collection first to open it if it's closed await locators.sidebar.collection(collectionName).click(); - // Find the request within the collection's context - // Use the collection container (.collection-name) scoped to sidebar to scope the search - const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName }); - const collectionWrapper = collectionContainer.locator('..'); - const request = collectionWrapper.locator('.collection-item-name').filter({ hasText: requestName }); + const request = page + .locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`) + .locator('.collection-item-name') + .filter({ hasText: requestName }); await request.hover(); await request.locator('.menu-icon').click(); @@ -785,7 +800,7 @@ const createFolder = async ( // Scope to the parent so same-named folders in other collections don't trip strict mode. const parentScope = isCollection ? locators.sidebar.collectionScope(parentName) - : locators.sidebar.folder(parentName).locator('..'); + : locators.sidebar.folderScope(parentName); await expect(parentScope.locator('.collection-item-name').filter({ hasText: folderName })).toBeVisible(); }); }; @@ -1212,8 +1227,10 @@ const openRequest = async (page: Page, collectionName: string, requestName: stri await test.step(`Navigate to collection "${collectionName}" and open request "${requestName}"`, async () => { const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName }); await collectionContainer.click(); - const collectionWrapper = collectionContainer.locator('..'); - const request = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName }); + const request = page + .locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`) + .getByTestId('sidebar-collection-item-row') + .filter({ hasText: requestName }); if (!persist) { await request.click(); } else { @@ -1233,8 +1250,10 @@ const openfolder = async (page: Page, collectionName: string, folderName: string await test.step(`Open folder "${folderName}" in collection "${collectionName}"`, async () => { const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName }); await collectionContainer.click(); - const collectionWrapper = collectionContainer.locator('..'); - const folder = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: folderName }); + const folder = page + .locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`) + .getByTestId('sidebar-collection-item-row') + .filter({ hasText: folderName }); if (!persist) { await folder.click(); } else { @@ -1282,6 +1301,7 @@ const selectFolderScriptPaneTab = async (page: Page, tabName: 'pre-request' | 'p */ const openCollectionSettings = async (page: Page, collectionName: string, { persist = false } = {}) => { await test.step(`Open collection settings for "${collectionName}"`, async () => { + await revealCollectionsTop(page); const locators = buildCommonLocators(page); const collection = locators.sidebar.collection(collectionName); if (!persist) { @@ -1367,11 +1387,10 @@ const openFolderRequest = async (page: Page, collectionName: string, folderName: const { sidebar, tabs } = buildCommonLocators(page); const collectionRow = sidebar.collectionRow(collectionName); await collectionRow.click(); - const collectionWrapper = collectionRow.locator('..'); - const folder = collectionWrapper.locator('.collection-item-name').filter({ has: page.getByText(folderName, { exact: true }) }); + const folder = sidebar.collectionScope(collectionName).locator('.collection-item-name').filter({ has: page.getByText(folderName, { exact: true }) }); await folder.waitFor({ state: 'visible' }); await folder.click(); - const request = collectionWrapper.locator('.collection-item-name').filter({ has: page.getByText(requestName, { exact: true }) }); + const request = sidebar.folderScope(folderName).locator('.collection-item-name').filter({ has: page.getByText(requestName, { exact: true }) }); await request.waitFor({ state: 'visible' }); await request.click(); await expect(tabs.activeRequestTab()).toContainText(requestName); @@ -2413,7 +2432,7 @@ const createExampleFromSidebar = async (page: Page, requestName: string, example const openExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, index: number = 0) => { const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first(); - const requestBranch = requestRow.locator('..'); + const requestBranch = page.locator(`[data-parent-name="${requestName}"]`); const exampleRow = requestBranch .locator('.collection-item-name') .filter({ has: page.locator('.example-icon') }) @@ -2534,7 +2553,7 @@ const openRequestInFolder = async (page: Page, folderName: string, requestName: const { sidebar } = buildCommonLocators(page); await sidebar.folder(folderName).click(); - const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..'); + const folderWrapper = page.locator(`[data-parent-name="${folderName}"]`); const escapedName = requestName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const requestRow = folderWrapper.locator('.collection-item-name').filter({ has: page.locator('.item-name').filter({ hasText: new RegExp(`^${escapedName}$`) }) diff --git a/tests/utils/page/mounting.ts b/tests/utils/page/mounting.ts index 57f32071387..a8ea71f9899 100644 --- a/tests/utils/page/mounting.ts +++ b/tests/utils/page/mounting.ts @@ -23,12 +23,12 @@ export const buildCollectionTreeLocators = (page: Page) => { has: page.locator('#sidebar-collection-name', { hasText: name }) }); - const itemScope = (collectionName?: string) => collectionName - ? collectionRow(collectionName).locator('..') - : page; + const collectionScope = (name: string) => page.locator(`[data-collection-id="${name.replace(/\s+/g, '-').toLowerCase()}"]`); + const itemScope = (collectionName?: string) => collectionName ? collectionScope(collectionName) : page; return { - /** + collectionScope, + /** * Collection-level locators */ collection: { @@ -201,13 +201,7 @@ export const getCollectionItemCount = async ( collectionName: string ): Promise => { const locators = buildCollectionTreeLocators(page); - - // Get the parent wrapper that contains the collection and its items - const collectionWrapper = locators.collection.row(collectionName).locator('..'); - - // Count all collection items within this collection - const items = collectionWrapper.getByTestId('sidebar-collection-item-row'); - return await items.count(); + return await locators.item.allRows(collectionName).count(); }; /** @@ -223,95 +217,76 @@ export const getCollectionTreeStructure = async ( const locators = buildCollectionTreeLocators(page); return await test.step(`Get tree structure for collection "${collectionName}"`, async () => { - const collectionRow = locators.collection.row(collectionName); - - // Ensure collection is expanded - const isExpanded = await locators.collection.isExpanded(collectionName); - if (!isExpanded) { - await collectionRow.click(); + // Ensure the collection is expanded. + if (!(await locators.collection.isExpanded(collectionName))) { + await locators.collection.row(collectionName).click(); } - - // Wait for collection to finish mounting after expansion await waitForCollectionMount(page, collectionName); - // Collection structure: - // StyledWrapper > [collection-row, children-wrapper > inner-container > items] - // Get the sibling div that contains the children (not the collection row itself) - const collectionWrapper = collectionRow.locator('..'); - const childrenContainer = collectionWrapper.locator(':scope > div:not([data-testid="sidebar-collection-row"]) > div').first(); - - const items = await extractItemsFromContainer(page, childrenContainer, collectionName); + // Expand every folder so the whole subtree is present in the flat, virtualized list. + await expandAllFolders(page, collectionName, locators); + + // The sidebar is a flat, DFS-ordered list of rows. reconstruct the tree from each row's + // indent depth (number of `.indent-block` spacers). + const flat: FlatItem[] = []; + for (const row of await locators.item.allRows(collectionName).all()) { + const name = (await locators.item.getNameFromRow(row).innerText()).trim(); + const isFolder = (await locators.item.isFolderRow(row).count()) > 0; + const depth = await row.locator('.indent-block').count(); + let method: string | undefined; + if (!isFolder) { + const badge = row.locator('.mr-1 span').first(); + method = (await badge.count()) > 0 ? (await badge.innerText()).trim().toUpperCase() : undefined; + } + flat.push({ name, isFolder, depth, method }); + } - return { - name: collectionName, - items - }; + return { name: collectionName, items: buildTreeFromFlat(flat) }; }); }; -/** - * Helper function to extract items from a container (collection or folder). - */ -async function extractItemsFromContainer( - page: Page, - container: ReturnType, - collectionName?: string -): Promise { - const locators = buildCollectionTreeLocators(page); - const items: CollectionTreeItem[] = []; - - // Get direct child StyledWrappers, each contains one item - // Structure: container > StyledWrapper > [item-row, children-div?] - const childWrappers = container.locator(':scope > div:has([data-testid="sidebar-collection-item-row"])'); - const count = await childWrappers.count(); +type FlatItem = { name: string; isFolder: boolean; depth: number; method?: string }; - for (let i = 0; i < count; i++) { - const wrapper = childWrappers.nth(i); - const itemRow = wrapper.getByTestId('sidebar-collection-item-row').first(); - const itemName = (await locators.item.getNameFromRow(itemRow).innerText()).trim(); - - // Check if it's a folder by looking for folder chevron within this specific row - const isFolder = await locators.item.isFolderRow(itemRow).count() > 0; - - if (isFolder) { - // It's a folder - expand it via the chevron in this exact row to avoid - // matching same-named folders elsewhere in the tree. - const folderChevron = locators.item.isFolderRow(itemRow); - const rowIsExpanded = await itemRow.locator('.rotate-90').count() > 0; - if (!rowIsExpanded) { - await folderChevron.click(); - await expect.poll(async () => await itemRow.locator('.rotate-90').count() > 0).toBe(true); +/** Expand every collapsed folder in the collection */ +async function expandAllFolders( + page: Page, + collectionName: string, + locators: ReturnType +): Promise { + for (let pass = 0; pass < 200; pass++) { + const chevrons = locators.item.allRows(collectionName).getByTestId('folder-chevron'); + const total = await chevrons.count(); + let clicked = false; + for (let i = 0; i < total; i++) { + const chevron = chevrons.nth(i); + const expanded = await chevron.evaluate((el) => el.classList.contains('rotate-90')).catch(() => true); + if (!expanded) { + await chevron.click(); + await page.waitForTimeout(50); + clicked = true; + break; } + } + if (!clicked) break; + } +} - // Children are in a sibling div after the item row (within the same wrapper) - // Structure: wrapper > [item-row, children-container] - const childrenContainer = wrapper.locator(':scope > div:not([data-testid="sidebar-collection-item-row"])').first(); - const hasChildren = await childrenContainer.count() > 0; - const nestedItems = hasChildren ? await extractItemsFromContainer(page, childrenContainer, collectionName) : []; - - items.push({ - name: itemName, - type: 'folder', - items: nestedItems - }); +/** Rebuild the nested tree from a flat, DFS-ordered list of rows keyed by indent depth. */ +function buildTreeFromFlat(flat: FlatItem[]): CollectionTreeItem[] { + const root: CollectionTreeItem[] = []; + const stack: { depth: number; items: CollectionTreeItem[] }[] = [{ depth: 0, items: root }]; + for (const r of flat) { + while (stack.length > 1 && stack[stack.length - 1].depth >= r.depth) stack.pop(); + const parent = stack[stack.length - 1].items; + if (r.isFolder) { + const node: CollectionTreeItem = { name: r.name, type: 'folder', items: [] }; + parent.push(node); + stack.push({ depth: r.depth, items: node.items as CollectionTreeItem[] }); } else { - // It's a request - read the method badge from this exact row to avoid - // colliding with same-named requests elsewhere. - const methodBadge = itemRow.locator('.mr-1 span').first(); - let method = ''; - if (await methodBadge.count() > 0) { - method = (await methodBadge.innerText()).trim().toUpperCase(); - } - - items.push({ - name: itemName, - type: 'request', - method: method || undefined - }); + parent.push({ name: r.name, type: 'request', method: r.method }); } } - - return items; + return root; } /** @@ -367,7 +342,7 @@ export const waitForItemCount = async ( const locators = buildCollectionTreeLocators(page); await test.step(`Wait for ${expectedCount} items in collection "${collectionName}"`, async () => { - const collectionWrapper = locators.collection.row(collectionName).locator('..'); + const collectionWrapper = locators.collectionScope(collectionName); const items = collectionWrapper.getByTestId('sidebar-collection-item-row'); await expect(items).toHaveCount(expectedCount, { timeout }); @@ -382,7 +357,7 @@ export const waitForItemCount = async ( */ export const hasErrorItems = async (page: Page, collectionName: string): Promise => { const locators = buildCollectionTreeLocators(page); - const collectionWrapper = locators.collection.row(collectionName).locator('..'); + const collectionWrapper = locators.collectionScope(collectionName); // Look for error indicators (typically a red icon or error class) const errorIndicators = collectionWrapper.locator('.item-error, .error-indicator, [class*="error"]'); @@ -397,7 +372,7 @@ export const hasErrorItems = async (page: Page, collectionName: string): Promise */ export const getErrorItemNames = async (page: Page, collectionName: string): Promise => { const locators = buildCollectionTreeLocators(page); - const collectionWrapper = locators.collection.row(collectionName).locator('..'); + const collectionWrapper = locators.collectionScope(collectionName); const errorItems = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ has: page.locator('.item-error, .error-indicator, [class*="error"]') diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index 92a05910187..46da3f65312 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -155,17 +155,12 @@ export const openRunnerResultTimeline = async (page: Page, requestName: string) */ export const runFolder = async (page: Page, collectionName: string, folderPath: string[]) => { await test.step(`Run folder "${folderPath.join('/')}" in "${collectionName}"`, async () => { - // Scope to the specific collection by its DOM id (collection-) - const collectionId = `collection-${collectionName.replace(/\s+/g, '-').toLowerCase()}`; - const collectionContainer = page.locator(`#${collectionId}`); - await collectionContainer.waitFor({ state: 'visible', timeout: 5000 }); - - // Walk down the folder path, scoping each step to the previous folder's container. - // Each CollectionItem renders as a StyledWrapper div containing: - // - div.collection-item-name (the row with chevron, name, menu) - // - div (children container when expanded) - // We scope to the parent wrapper so the next folder lookup is unambiguous. - let scope = collectionContainer; + // Flat, virtualized sidebar: scope by `data-collection-id` / `data-parent-name` rather than DOM nesting. + const collectionScope = page.locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`); + await collectionScope.first().waitFor({ state: 'visible', timeout: 5000 }); + + let scope = collectionScope; + let targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[0] }).first(); for (const folderName of folderPath) { const row = scope.locator('.collection-item-name').filter({ hasText: folderName }).first(); await row.waitFor({ state: 'visible', timeout: 5000 }); @@ -177,12 +172,11 @@ export const runFolder = async (page: Page, collectionName: string, folderPath: await chevron.click(); } - // Scope to this folder's wrapper (parent of the row) for the next iteration - scope = row.locator('..'); + targetRow = row; + scope = page.locator(`[data-parent-name="${folderName}"]`); } - // The target folder row is the last one we found — hover to reveal menu - const targetRow = scope.locator('.collection-item-name').filter({ hasText: folderPath[folderPath.length - 1] }).first(); + // The deepest folder row we found — hover to reveal its menu. await targetRow.hover(); // Click the menu icon diff --git a/tests/utils/page/sidebar/index.ts b/tests/utils/page/sidebar/index.ts index 0a594901de8..fdbe3f37cd0 100644 --- a/tests/utils/page/sidebar/index.ts +++ b/tests/utils/page/sidebar/index.ts @@ -12,7 +12,7 @@ export const buildSidebarLocators = (page: Page) => { const collectionRow = (name: string) => page.getByTestId('sidebar-collection-row').filter({ hasText: name }); const itemRow = (name: string) => page.getByTestId('sidebar-collection-item-row').filter({ has: itemByName(name) }); - const collectionScope = (name: string) => page.locator(`#collection-${name.replace(/\s+/g, '-').toLowerCase()}`); + const collectionScope = (name: string) => page.locator(`[data-collection-id="${name.replace(/\s+/g, '-').toLowerCase()}"]`); return { collectionsContainer: () => page.getByTestId('collections'), @@ -21,11 +21,7 @@ export const buildSidebarLocators = (page: Page) => { request: (name: string) => page.locator('.collection-item-name').filter({ hasText: name }), collectionChevron: (name: string) => collectionRow(name).getByTestId('collection-chevron'), folderRequest: (folderName: string, requestName: string) => { - // Find the folder's collection-item-name, then navigate to its parent wrapper container (StyledWrapper), - // and search for the request within that container's descendants. - // Using .locator('..') gets the parent element of the folder's collection-item-name div. - const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..'); - return folderWrapper.locator('.collection-item-name').filter({ hasText: requestName }); + return page.locator(`[data-parent-name="${folderName}"]`).locator('.collection-item-name').filter({ hasText: requestName }); }, closeAllCollectionsButton: () => page.getByTestId('collections-header-actions-menu-close-all'), collectionRow, @@ -59,6 +55,8 @@ export const buildSidebarLocators = (page: Page) => { page.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName }).getByTestId('request-item-chevron'), example: (name: string) => page.getByTestId('sidebar-response-example-item').filter({ hasText: name }), collectionScope, + collectionScopeByUid: (collectionUid: string) => page.locator(`[data-collection-uid="${collectionUid}"]`), + folderScope: (folderName: string) => page.locator(`[data-parent-name="${folderName}"]`), scopedItem: function (collectionName: string, itemName: string) { return this.collectionScope(collectionName).locator('.item-name').and(page.getByTitle(itemName, { exact: true })); }, From b18b73fd112bdcf49f513d5c564d784bd5408fce Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 11:03:57 +0530 Subject: [PATCH 07/17] fixed closing bracket --- packages/bruno-app/src/components/Sidebar/Collections/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index b999822f871..874c1a89154 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -71,7 +71,7 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis // so `currentTarget === target` never holds. Instead, clear on any click that didn't land // inside a selectable row. const handleContainerClick = (e) => { - const onRow = e.target.closest('[data-testid="sidebar-collection-item-row"], [data-testid="sidebar-collection-row'); + const onRow = e.target.closest('[data-testid="sidebar-collection-item-row"], [data-testid="sidebar-collection-row"]'); if (!onRow) { dispatch(clearSidebarSelection()); } From a370a029dfc6f372101866b5f993dc0be77d56e0 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 12:15:02 +0530 Subject: [PATCH 08/17] fixed handle click outside on muli select sidebar --- .../bruno-app/src/components/Sidebar/Collections/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index 874c1a89154..3cd8a94f7fb 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -66,11 +66,10 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis virtuosoRef.current?.scrollIntoView({ index, behavior: 'auto' }); }, [activeTabUid]); - // Clear the multi-selection when the user clicks empty sidebar space. With virtualization - // the empty area below the rows belongs to Virtuoso's internal scroller (not this container), - // so `currentTarget === target` never holds. Instead, clear on any click that didn't land - // inside a selectable row. + // Clear the multi-selection on empty-space clicks (not on a row). The `contains` guard drops + // events React propagates here from portaled modals/menus in , which sit outside the sidebar. const handleContainerClick = (e) => { + if (!e.currentTarget.contains(e.target)) return; const onRow = e.target.closest('[data-testid="sidebar-collection-item-row"], [data-testid="sidebar-collection-row"]'); if (!onRow) { dispatch(clearSidebarSelection()); From 860c257213fba03f8a7b73cea3002177d4cd5d1c Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 12:48:02 +0530 Subject: [PATCH 09/17] collection environment table lag fixes --- .../EnvironmentVariablesTable/index.js | 60 +++++++++---------- .../EnvironmentVariables/index.js | 15 +++-- .../src/components/MultiLineEditor/index.js | 21 ++++--- .../bruno-app/src/utils/collections/index.js | 2 + 4 files changed, 51 insertions(+), 47 deletions(-) diff --git a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js index 7bf607c8eb8..810f0af06bd 100644 --- a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js +++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js @@ -174,6 +174,19 @@ const EnvVarValueCell = ({ ); }; +const ErrorMessage = React.memo(({ id, error }) => { + if (!error) { + return null; + } + + return ( + + + + + ); +}); + const EnvironmentVariablesTable = ({ environment, collection, @@ -313,12 +326,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; // Preserve the actual active environment so variable existence and @@ -538,32 +551,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; @@ -959,6 +946,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 ( <> @@ -1007,7 +1000,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 f9ebaaf888b..c63e7479884 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, searchQu 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, searchQu 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, searchQu }); }); 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 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/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index be88d999bf0..2b73035d286 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1435,6 +1435,8 @@ export const maskInputValue = (value) => { }; export const getTreePathFromCollectionToItem = (collection, _item) => { + if (!_item?.uid) return []; + let path = []; let item = findItemInCollection(collection, _item?.uid); while (item) { From f5832ad72c6cc1be0cd1055e943c8ad77ba0b497 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Mon, 7 Sep 2026 14:32:00 +0530 Subject: [PATCH 10/17] fixed style issue in examples --- .../Collection/CollectionItem/ExampleItem/StyledWrapper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..c4c894f9b39 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 @@ -17,7 +17,7 @@ const StyledWrapper = styled.div` } } - .collection-item-name { + &.collection-item-name { height: 1.6rem; cursor: pointer; user-select: none; From 7d23802dfcbef6fdb36c0a19633a310bf49fe36a Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Tue, 8 Sep 2026 11:57:18 +0530 Subject: [PATCH 11/17] removed addDepth as we have depth from flat sidebar items --- .../CollectionItem/CollectionItemRow/index.jsx | 4 ++-- .../Collection/CollectionItem/ExampleItem/index.js | 8 ++++---- .../Sidebar/Collections/SidebarRow/index.jsx | 3 ++- .../ReduxStore/slices/collections/index.js | 6 ------ packages/bruno-app/src/utils/collections/index.js | 14 -------------- 5 files changed, 8 insertions(+), 27 deletions(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index dc6433034ae..9f3c006d681 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -72,7 +72,7 @@ import useSidebarSelectionClick from 'hooks/useSidebarSelectionClick'; import useMultiSelectDragDisabled from 'hooks/useMultiSelectDragDisabled'; import { clearSidebarSelection } from 'providers/ReduxStore/slices/collections/index'; -const CollectionItemRow = ({ item, collectionUid, collectionPathname, searchText, openBulkMenu, children }) => { +const CollectionItemRow = ({ item, depth, collectionUid, collectionPathname, searchText, openBulkMenu, children }) => { const { dropdownContainerRef } = useSidebarAccordion(); const selectorInput = { itemUid: item.uid, @@ -414,7 +414,7 @@ const CollectionItemRow = ({ item, collectionUid, collectionPathname, searchText menuDropdownRef.current?.show(); }; - const indents = range(item.depth); + const indents = range(depth); // Build menu items for MenuDropdown const buildMenuItems = () => { 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..ab9ee35008e 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); diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx index a3c3ad4b01b..95f81bbdd9c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -39,6 +39,7 @@ const renderRow = ({ row, searchText, openBulkMenu, itemsByUid, collectionsByUid return ( ; + return ; } default: return null; 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 4fa9fb50aeb..ab83ad47d31 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, forOwn, concat, filter, each, cloneDeep, get, set, findIndex import { createSlice } from '@reduxjs/toolkit'; import { hexy as hexdump } from 'hexy'; import { - addDepth, areItemsTheSameExceptSeqUpdate, collapseAllItemsInCollection, deleteItemInCollection, @@ -271,7 +270,6 @@ export const collectionsSlice = createSlice({ collection.lastAction = null; collapseAllItemsInCollection(collection); - addDepth(collection.items); if (!collectionUids.includes(collection.uid)) { state.collections.push(collection); } @@ -482,7 +480,6 @@ export const collectionsSlice = createSlice({ item.items.push(action.payload.item); } } - addDepth(collection.items); } }, deleteItem: (state, action) => { @@ -3128,7 +3125,6 @@ export const collectionsSlice = createSlice({ }); } } - addDepth(collection.items); } }, collectionAddDirectoryEvent: (state, action) => { @@ -3180,7 +3176,6 @@ export const collectionsSlice = createSlice({ } currentSubItems = childItem.items; }); - addDepth(collection.items); } }, collectionChangeFileEvent: (state, action) => { @@ -3775,7 +3770,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; diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 2b73035d286..0b4429bd622 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -19,20 +19,6 @@ const replaceTabsWithSpaces = (str, numSpaces = 2) => { return str.replaceAll('\t', ' '.repeat(numSpaces)); }; -export const addDepth = (items = []) => { - const depth = (itms, initialDepth) => { - each(itms, (i) => { - i.depth = initialDepth; - - if (i.items && i.items.length) { - depth(i.items, initialDepth + 1); - } - }); - }; - - depth(items, 1); -}; - const setCollapsedRecursively = (items, collapsed) => { each(items, (i) => { i.collapsed = collapsed; From c7d46eba9ef5965121e6b1ccff7f7a410e71bfc1 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Wed, 9 Sep 2026 09:18:21 +0530 Subject: [PATCH 12/17] resolved code review blockers and suggestions --- .../{ => CollectionItemRow}/StyledWrapper.js | 13 ---------- .../CollectionItemRow/index.jsx | 14 +--------- .../ExampleItem/StyledWrapper.js | 6 ++++- .../CollectionItem/ExampleItem/index.js | 10 ------- .../{ => CollectionRow}/StyledWrapper.js | 13 ---------- .../Collection/CollectionRow/index.jsx | 19 +++----------- .../Sidebar/Collections/SidebarRow/index.jsx | 26 +++++++++---------- .../components/Sidebar/Collections/index.js | 22 +++++++--------- .../src/utils/collections/collectionSlug.js | 7 +++++ .../utils/collections/flattenSidebarTree.js | 6 ++--- .../empty-state-cta/empty-state-cta.spec.ts | 3 ++- tests/utils/page/actions.ts | 7 ++--- tests/utils/page/mounting.ts | 3 ++- tests/utils/page/runner.ts | 3 ++- tests/utils/page/sidebar/index.ts | 3 ++- 15 files changed, 52 insertions(+), 103 deletions(-) rename packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/{ => CollectionItemRow}/StyledWrapper.js (92%) rename packages/bruno-app/src/components/Sidebar/Collections/Collection/{ => CollectionRow}/StyledWrapper.js (90%) create mode 100644 packages/bruno-app/src/utils/collections/collectionSlug.js 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/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index 9f3c006d681..8053e7ecb2a 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -33,7 +33,6 @@ 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'; @@ -42,7 +41,7 @@ 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 StyledWrapper from './StyledWrapper'; import NetworkError from 'components/ResponsePane/NetworkError/index'; import CollectionItemInfo from '../CollectionItemInfo/index'; import CollectionItemIcon from '../CollectionItemIcon'; @@ -184,17 +183,6 @@ const CollectionItemRow = ({ item, depth, collectionUid, collectionPathname, sea } }); - // 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, 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 c4c894f9b39..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; 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 ab9ee35008e..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 @@ -64,16 +64,6 @@ const ExampleItem = ({ example, item, collection, depth }) => { 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/CollectionRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx index c7b7ed9441b..9d5eb6e2049 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import classnames from 'classnames'; import { uuid } from 'utils/common'; import { useDrop, useDrag } from 'react-dnd'; @@ -42,7 +42,7 @@ import { getSortedDraggedItems, getSelectionInfo } from 'utils/collections'; import { isTabForItemActive } from 'src/selectors/tab'; import RenameCollection from '../RenameCollection'; -import StyledWrapper from '../StyledWrapper'; +import StyledWrapper from './StyledWrapper'; import CloneCollection from '../CloneCollection'; import { scrollToTheActiveTab } from 'utils/tabs'; import ShareCollection from 'components/ShareCollection/index'; @@ -377,19 +377,6 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children }) => { 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 - if (searchText && searchText.length) { if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) { return null; @@ -558,7 +545,7 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children }) => { ]; return ( - + {showNewRequestModal && setShowNewRequestModal(false)} />} {showNewFolderModal && setShowNewFolderModal(false)} />} {showNewAppModal && setShowNewAppModal(false)} />} diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx index 95f81bbdd9c..e7e8ae65627 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -24,21 +24,22 @@ const resolveRowObject = ({ row, itemsByUid, collectionsByUid, ghostsByPath }) = } }; -const renderRow = ({ row, searchText, openBulkMenu, itemsByUid, collectionsByUid, ghostsByPath }) => { +const renderRow = (props) => { + const { row, searchText, openBulkMenu, collectionsByUid } = props; + const resolved = resolveRowObject(props); + switch (row.kind) { case 'collection': { - const collection = collectionsByUid.get(row.collectionUid); - if (!collection) return null; - return ; + if (!resolved) return null; + return ; } case 'folder': case 'app': case 'request': { - const item = itemsByUid.get(row.itemUid); - if (!item) return null; + if (!resolved) return null; return ( ; + return ; } case 'ghost': { - const entry = ghostsByPath.get(row.collectionPathname); - if (!entry) return null; - return ; + if (!resolved) return null; + return ; } case 'example': { - const item = itemsByUid.get(row.itemUid); + const item = resolved; const collection = collectionsByUid.get(row.collectionUid); const example = item?.examples?.[row.exampleIndex]; if (!item || !collection || !example) return null; @@ -74,6 +73,7 @@ const SidebarRow = (props) => { if (inner === null) return null; return (
buildIndexes(rows), [rows]); - // Resolve the active tab's row index. ref lets the scroll effect read the current index - // without depending on it, so rows shifting above the active row don't re-fire the scroll. + // 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; - const activeRowIndexRef = useRef(activeRowIndex); - activeRowIndexRef.current = activeRowIndex; useEffect(() => { - const index = activeRowIndexRef.current; - if (index === null) return; - virtuosoRef.current?.scrollIntoView({ index, behavior: 'auto' }); + if (activeRowIndex === null) return; + virtuosoRef.current?.scrollIntoView({ index: activeRowIndex, behavior: 'smooth' }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTabUid]); - // Clear the multi-selection on empty-space clicks (not on a row). The `contains` guard drops - // events React propagates here from portaled modals/menus in , which sit outside the sidebar. + // 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.contains(e.target)) return; - const onRow = e.target.closest('[data-testid="sidebar-collection-item-row"], [data-testid="sidebar-collection-row"]'); - if (!onRow) { - dispatch(clearSidebarSelection()); - } + if (e.target.closest('[data-sidebar-row]')) return; + dispatch(clearSidebarSelection()); }; if (!sidebarEntries.length) { 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 index 42732a20355..dc5bb8a7666 100644 --- a/packages/bruno-app/src/utils/collections/flattenSidebarTree.js +++ b/packages/bruno-app/src/utils/collections/flattenSidebarTree.js @@ -1,4 +1,5 @@ import { isItemAFolder, isItemARequest } from './index'; +import { collectionSlug } from './collectionSlug'; import { sortByNameThenSequence } from 'utils/common/index'; import { doesRequestMatchSearchText, @@ -187,10 +188,7 @@ const flattenCollection = ({ } // Used for readable test selectors. collectionUid remains the unique identity. - const slugifyCollectionName = (name) => - (name || '').replace(/\s+/g, '-').toLowerCase(); - - const collectionId = slugifyCollectionName(collection.name); + const collectionId = collectionSlug(collection.name); appendRow({ id: `col:${collection.uid}`, diff --git a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts index 86f761d6630..ec68e355514 100644 --- a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts +++ b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from '../../../playwright'; +import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; import { buildCommonLocators, closeAllCollections } from '../../utils/page'; test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { @@ -16,7 +17,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { // across the describe block, and multiple expanded collections would otherwise // make `getByTestId('add-request-cta')` match more than one element. const collectionScope = (page: Page, name: string) => - page.locator(`[data-collection-id="${name.replace(/\s+/g, '-').toLowerCase()}"]`); + page.locator(`[data-collection-id="${collectionSlug(name)}"]`); const expandCollection = async (name: string) => { const collection = locators.sidebar.collection(name); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 4bcc832c3d8..b3a3db88972 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -1,4 +1,5 @@ import { test, expect, Page, Locator, ElectronApplication, waitForReadyPage as waitForReadyPageImpl } from '../../../playwright'; +import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; import process from 'node:process'; import * as path from 'path'; import * as fs from 'fs'; @@ -568,7 +569,7 @@ const deleteRequest = async (page, requestName: string, collectionName: string) await locators.sidebar.collection(collectionName).click(); const request = page - .locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`) + .locator(`[data-collection-id="${collectionSlug(collectionName)}"]`) .locator('.collection-item-name') .filter({ hasText: requestName }); @@ -1228,7 +1229,7 @@ const openRequest = async (page: Page, collectionName: string, requestName: stri const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName }); await collectionContainer.click(); const request = page - .locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`) + .locator(`[data-collection-id="${collectionSlug(collectionName)}"]`) .getByTestId('sidebar-collection-item-row') .filter({ hasText: requestName }); if (!persist) { @@ -1251,7 +1252,7 @@ const openfolder = async (page: Page, collectionName: string, folderName: string const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName }); await collectionContainer.click(); const folder = page - .locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`) + .locator(`[data-collection-id="${collectionSlug(collectionName)}"]`) .getByTestId('sidebar-collection-item-row') .filter({ hasText: folderName }); if (!persist) { diff --git a/tests/utils/page/mounting.ts b/tests/utils/page/mounting.ts index a8ea71f9899..0b2bf495c2e 100644 --- a/tests/utils/page/mounting.ts +++ b/tests/utils/page/mounting.ts @@ -1,4 +1,5 @@ import { test, expect, Page, ElectronApplication } from '../../../playwright'; +import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; /** * Collection tree item structure for assertions @@ -23,7 +24,7 @@ export const buildCollectionTreeLocators = (page: Page) => { has: page.locator('#sidebar-collection-name', { hasText: name }) }); - const collectionScope = (name: string) => page.locator(`[data-collection-id="${name.replace(/\s+/g, '-').toLowerCase()}"]`); + const collectionScope = (name: string) => page.locator(`[data-collection-id="${collectionSlug(name)}"]`); const itemScope = (collectionName?: string) => collectionName ? collectionScope(collectionName) : page; return { diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index 46da3f65312..9747bb5230b 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -1,4 +1,5 @@ import { Page, expect, test } from '../../../playwright'; +import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; import { buildCommonLocators, buildSandboxLocators } from './locators'; /** @@ -156,7 +157,7 @@ export const openRunnerResultTimeline = async (page: Page, requestName: string) export const runFolder = async (page: Page, collectionName: string, folderPath: string[]) => { await test.step(`Run folder "${folderPath.join('/')}" in "${collectionName}"`, async () => { // Flat, virtualized sidebar: scope by `data-collection-id` / `data-parent-name` rather than DOM nesting. - const collectionScope = page.locator(`[data-collection-id="${collectionName.replace(/\s+/g, '-').toLowerCase()}"]`); + const collectionScope = page.locator(`[data-collection-id="${collectionSlug(collectionName)}"]`); await collectionScope.first().waitFor({ state: 'visible', timeout: 5000 }); let scope = collectionScope; diff --git a/tests/utils/page/sidebar/index.ts b/tests/utils/page/sidebar/index.ts index fdbe3f37cd0..ef520c77ef2 100644 --- a/tests/utils/page/sidebar/index.ts +++ b/tests/utils/page/sidebar/index.ts @@ -1,4 +1,5 @@ import { Locator, Page } from '../../../../playwright'; +import { collectionSlug } from '../../../../packages/bruno-app/src/utils/collections/collectionSlug'; export type EmptyStateRequestType = 'http' | 'graphql' | 'grpc' | 'websocket'; @@ -12,7 +13,7 @@ export const buildSidebarLocators = (page: Page) => { const collectionRow = (name: string) => page.getByTestId('sidebar-collection-row').filter({ hasText: name }); const itemRow = (name: string) => page.getByTestId('sidebar-collection-item-row').filter({ has: itemByName(name) }); - const collectionScope = (name: string) => page.locator(`[data-collection-id="${name.replace(/\s+/g, '-').toLowerCase()}"]`); + const collectionScope = (name: string) => page.locator(`[data-collection-id="${collectionSlug(name)}"]`); return { collectionsContainer: () => page.getByTestId('collections'), From 11a700e2aac4c09fbd182086a245f5b2ba3160f9 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Wed, 9 Sep 2026 09:18:59 +0530 Subject: [PATCH 13/17] example chevron icon search text --- .../Collection/CollectionItem/CollectionItemRow/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index 8053e7ecb2a..6d93fbd997c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -781,7 +781,7 @@ const CollectionItemRow = ({ item, depth, collectionUid, collectionPathname, sea data-testid="folder-chevron" /> - ) : hasExamples ? ( + ) : hasExamples && !hasSearchText ? ( Date: Wed, 9 Sep 2026 09:32:25 +0530 Subject: [PATCH 14/17] multi drag prop fixes --- .../Sidebar/Collections/SidebarRow/index.jsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx index e7e8ae65627..85c85b1e22c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -25,13 +25,21 @@ const resolveRowObject = ({ row, itemsByUid, collectionsByUid, ghostsByPath }) = }; const renderRow = (props) => { - const { row, searchText, openBulkMenu, collectionsByUid } = props; + const { row, searchText, openBulkMenu, collectionsByUid, isMultiDragDisabled, multiDragCollections, multiDragItems } = props; const resolved = resolveRowObject(props); switch (row.kind) { case 'collection': { if (!resolved) return null; - return ; + return ( + + ); } case 'folder': case 'app': @@ -45,6 +53,8 @@ const renderRow = (props) => { collectionPathname={row.collectionPathname} searchText={searchText} openBulkMenu={openBulkMenu} + isMultiDragDisabled={isMultiDragDisabled} + multiDragItems={multiDragItems} /> ); } From aa5f88ec0a82c395aed0aacf6a958540fb44d32e Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Wed, 9 Sep 2026 11:47:52 +0530 Subject: [PATCH 15/17] test fixes --- .../Sidebar/Collections/SidebarRow/index.jsx | 3 ++ .../collections/flattenSidebarTree.spec.js | 2 +- .../empty-state-cta/empty-state-cta.spec.ts | 29 +++++++---------- tests/utils/page/actions.ts | 12 ++----- tests/utils/page/mounting.ts | 32 ++++++++----------- 5 files changed, 32 insertions(+), 46 deletions(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx index 85c85b1e22c..515b5946c80 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -109,6 +109,9 @@ const areEqual = (prev, next) => { && 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) ); }; diff --git a/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js b/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js index 54655fcd389..447a5c53bc2 100644 --- a/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js +++ b/packages/bruno-app/src/utils/collections/flattenSidebarTree.spec.js @@ -166,8 +166,8 @@ describe('buildIndexes', () => { }); it('item-uid map targets the item row, not its example rows', () => { const c = collection('C', [request('r1', { uid: 'req-x', examplesExpanded: true, examples: [{ uid: 'ex1', name: 'ok' }] })]); - const { rowIndexByItemUid } = buildIndexes(flatten([loaded(c)])); const rows = flatten([loaded(c)]); + const { rowIndexByItemUid } = buildIndexes(rows); expect(rows[rowIndexByItemUid.get('req-x')].kind).toBe('request'); }); it('indexes example rows by exampleUid', () => { diff --git a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts index ec68e355514..eb0a1081c12 100644 --- a/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts +++ b/tests/sidebar/empty-state-cta/empty-state-cta.spec.ts @@ -1,5 +1,4 @@ -import { test, expect, Page } from '../../../playwright'; -import { collectionSlug } from '../../../packages/bruno-app/src/utils/collections/collectionSlug'; +import { test, expect } from '../../../playwright'; import { buildCommonLocators, closeAllCollections } from '../../utils/page'; test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { @@ -13,12 +12,6 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { await closeAllCollections(page); }); - // Scope an assertion to a single collection — pageWithUserData reuses one app - // across the describe block, and multiple expanded collections would otherwise - // make `getByTestId('add-request-cta')` match more than one element. - const collectionScope = (page: Page, name: string) => - page.locator(`[data-collection-id="${collectionSlug(name)}"]`); - const expandCollection = async (name: string) => { const collection = locators.sidebar.collection(name); await collection.waitFor({ state: 'visible' }); @@ -33,7 +26,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'empty-bru').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('empty-bru').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -43,7 +36,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'empty-yml').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('empty-yml').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -55,7 +48,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'bru-with-js').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('bru-with-js').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -65,7 +58,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is visible at collection root', async () => { - await expect(collectionScope(page, 'yml-with-js').getByTestId('add-request-cta')).toBeVisible(); + await expect(locators.sidebar.collectionScope('yml-with-js').getByTestId('add-request-cta')).toBeVisible(); }); }); @@ -78,7 +71,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'bru-with-request').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('bru-with-request').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -89,7 +82,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'yml-with-request').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('yml-with-request').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -100,7 +93,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'bru-folder-with-js').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('bru-folder-with-js').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -111,7 +104,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify CTA is not rendered at collection root', async () => { - await expect(collectionScope(page, 'yml-with-folder').getByTestId('add-request-cta')).toHaveCount(0); + await expect(locators.sidebar.collectionScope('yml-with-folder').getByTestId('add-request-cta')).toHaveCount(0); }); }); @@ -126,7 +119,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify folder-level CTA is visible', async () => { - await expect(collectionScope(page, 'bru-folder-with-js').getByTestId('add-request-cta-folder')).toBeVisible(); + await expect(locators.sidebar.collectionScope('bru-folder-with-js').getByTestId('add-request-cta-folder')).toBeVisible(); }); }); @@ -139,7 +132,7 @@ test.describe.serial('Sidebar empty-state "+ Add request" CTA', () => { }); await test.step('Verify folder-level CTA is visible', async () => { - await expect(collectionScope(page, 'yml-with-folder').getByTestId('add-request-cta-folder')).toBeVisible(); + await expect(locators.sidebar.collectionScope('yml-with-folder').getByTestId('add-request-cta-folder')).toBeVisible(); }); }); }); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 3ab3d536211..ca3b4b0f9c3 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -126,15 +126,9 @@ const closeAllCollections = async (page) => { // out of the viewport, and Virtuoso unmounts it once it passes the overscan. // Reset the list to the top so the header row is rendered before we locate it. const revealCollectionsTop = async (page: Page) => { - const collections = page.getByTestId('collections'); - if (!(await collections.count())) return; - - await collections.evaluate((root) => { - const scroller - = root.querySelector('[data-testid="sidebar-collections-scroller"]') - || Array.from(root.querySelectorAll('*')).find((el) => el.scrollHeight > el.clientHeight); - (scroller as HTMLElement | undefined)?.scrollTo({ top: 0 }); - }); + const scroller = page.getByTestId('sidebar-collections-scroller'); + if (!(await scroller.count())) return; + await scroller.evaluate((el) => el.scrollTo({ top: 0 })); }; const openCollection = async (page: Page, collectionName: string) => { diff --git a/tests/utils/page/mounting.ts b/tests/utils/page/mounting.ts index 0b2bf495c2e..be725f1a853 100644 --- a/tests/utils/page/mounting.ts +++ b/tests/utils/page/mounting.ts @@ -225,7 +225,7 @@ export const getCollectionTreeStructure = async ( await waitForCollectionMount(page, collectionName); // Expand every folder so the whole subtree is present in the flat, virtualized list. - await expandAllFolders(page, collectionName, locators); + await expandAllFolders(collectionName, locators); // The sidebar is a flat, DFS-ordered list of rows. reconstruct the tree from each row's // indent depth (number of `.indent-block` spacers). @@ -248,27 +248,23 @@ export const getCollectionTreeStructure = async ( type FlatItem = { name: string; isFolder: boolean; depth: number; method?: string }; -/** Expand every collapsed folder in the collection */ +/** Expand every collapsed folder in the collection. */ async function expandAllFolders( - page: Page, collectionName: string, locators: ReturnType ): Promise { - for (let pass = 0; pass < 200; pass++) { - const chevrons = locators.item.allRows(collectionName).getByTestId('folder-chevron'); - const total = await chevrons.count(); - let clicked = false; - for (let i = 0; i < total; i++) { - const chevron = chevrons.nth(i); - const expanded = await chevron.evaluate((el) => el.classList.contains('rotate-90')).catch(() => true); - if (!expanded) { - await chevron.click(); - await page.waitForTimeout(50); - clicked = true; - break; - } - } - if (!clicked) break; + const collapsedChevrons = () => + locators.item.allRows(collectionName).locator('[data-testid="folder-chevron"]:not(.rotate-90)'); + + // Expand the first collapsed folder until all folders are expanded. Pin the clicked chevron so + // re-resolving `.first()` after the click doesn't target a different row. + // Poll `rotate-90` to confirm the expansion. + while ((await collapsedChevrons().count()) > 0) { + const chevron = collapsedChevrons().first(); + const handle = await chevron.elementHandle(); + if (!handle) continue; + await chevron.click(); + await expect.poll(() => handle.evaluate((el) => el.classList.contains('rotate-90'))).toBe(true); } } From 4df827a84fa6c711223a77fe2998f51f9111a633 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Wed, 9 Sep 2026 12:50:38 +0530 Subject: [PATCH 16/17] refactor code --- .../Collection/CollectionItem/CollectionItemRow/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index 8e4468b6cd9..dc37c5d16fb 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -127,7 +127,7 @@ const CollectionItemRow = ({ const [newAppModalOpen, setNewAppModalOpen] = useState(false); const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); - const examplesExpanded = !!item.examplesExpanded; + const examplesExpanded = Boolean(item.examplesExpanded); const [isKeyboardFocused, setIsKeyboardFocused] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; From d30eefda7409a9731c846c143f339f7c1d8798d3 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Sun, 13 Sep 2026 12:23:31 +0530 Subject: [PATCH 17/17] feat(selectors): create memoized selectors and subscribe to --- eslint.config.js | 43 ++++++++ .../components/AppPreviewKeepAlive/index.js | 55 +++++----- .../src/components/GlobalSearchModal/index.js | 30 ++--- .../WsBody/SingleWSMessage/index.js | 12 +- .../RequestTabPanel/TabPanelErrorBoundary.js | 9 +- .../src/components/RequestTabPanel/index.js | 57 ++++------ .../RequestTabs/CollectionHeader/index.js | 54 +++++---- .../src/components/RequestTabs/index.js | 30 ++--- .../CollectionItemRow/index.jsx | 36 +++--- .../Collection/CollectionRow/index.jsx | 13 ++- .../Collections/SelectCollection/index.js | 3 +- .../components/Sidebar/Collections/index.js | 11 +- .../Sections/CollectionsSection/index.js | 26 +++-- .../src/components/StatusBar/index.js | 9 +- .../CollectionsList/index.js | 3 +- .../src/hooks/useTabPaneBoundaries/index.js | 9 +- .../bruno-app/src/providers/Hotkeys/index.js | 62 ++++++----- .../bruno-app/src/selectors/collections.js | 82 ++++++++++++++ .../src/selectors/collections.spec.js | 103 ++++++++++++++++++ packages/bruno-app/src/selectors/tab.js | 31 ++++++ packages/bruno-app/src/selectors/tab.spec.js | 73 ++++++++++++- .../src/test-utils/buildTestState.js | 59 ++++++++++ 22 files changed, 600 insertions(+), 210 deletions(-) create mode 100644 packages/bruno-app/src/selectors/collections.js create mode 100644 packages/bruno-app/src/selectors/collections.spec.js create mode 100644 packages/bruno-app/src/test-utils/buildTestState.js 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/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/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/CollectionItemRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx index dc37c5d16fb..2e2a8f13b30 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemRow/index.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import range from 'lodash/range'; import classnames from 'classnames'; import { useDrag, useDrop } from 'react-dnd'; @@ -21,7 +21,7 @@ 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'; @@ -51,7 +51,7 @@ import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; -import { isEqual } from 'lodash'; +import { selectCollectionByUid } from 'src/selectors/collections'; import { canCollectionItemBeDropped, determineCollectionItemDrop, @@ -81,23 +81,22 @@ const CollectionItemRow = ({ multiDragItems: multiDragItemsForSelection }) => { const { dropdownContainerRef } = useSidebarAccordion(); - const selectorInput = { - itemUid: item.uid, - itemPathname: item.pathname, - collectionUid - }; - - const _isTabForItemActiveSelector = isTabForItemActiveSelector(selectorInput); - const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual); - - const _isTabForItemPresentSelector = isTabForItemPresentSelector(selectorInput); - const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual); - - 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); @@ -675,6 +674,7 @@ const CollectionItemRow = ({ // 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; } diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx index 9b07a3c99f8..457792f6026 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionRow/index.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import classnames from 'classnames'; import { uuid } from 'utils/common'; import { useDrop, useDrag } from 'react-dnd'; @@ -27,7 +27,7 @@ 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'; @@ -77,7 +77,8 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children, isMulti const isLoading = collection.isLoading; const collectionRef = useRef(null); - 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); @@ -91,7 +92,7 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children, isMulti ); 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; @@ -315,7 +316,7 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children, isMulti const draggedItems = getSortedDraggedItems({ draggedItem, - allCollections, + allCollections: store.getState().collections.collections, workspaces, activeWorkspace, collectionSortOrder, @@ -334,7 +335,7 @@ const CollectionRow = ({ collection, searchText, openBulkMenu, children, isMulti } else { const draggedItems = getSortedDraggedItems({ draggedItem, - allCollections, + allCollections: store.getState().collections.collections, workspaces, activeWorkspace, collectionSortOrder, 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/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index 2ad60950933..a8b9dcdc674 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -12,12 +12,17 @@ import { flattenSidebarTree, buildIndexes } from 'utils/collections/flattenSideb 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 activeTabUid = useSelector((state) => state.tabs.activeTabUid); + 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); 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/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;