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 b91fb443074ca0d6cf6d77050bba98356d82e705 Mon Sep 17 00:00:00 2001 From: Sachin Thakur Date: Sun, 13 Sep 2026 12:41:34 +0530 Subject: [PATCH 17/17] resolved merge conflicts --- .../Sidebar/Collections/SidebarRow/index.jsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 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 515b5946c80..1a1b4537dcc 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx +++ b/packages/bruno-app/src/components/Sidebar/Collections/SidebarRow/index.jsx @@ -25,7 +25,7 @@ const resolveRowObject = ({ row, itemsByUid, collectionsByUid, ghostsByPath }) = }; const renderRow = (props) => { - const { row, searchText, openBulkMenu, collectionsByUid, isMultiDragDisabled, multiDragCollections, multiDragItems } = props; + const { row, searchText, openBulkMenu, collectionsByUid, isCollectionMultiDragDisabled, isItemMultiDragDisabled, multiDragCollections, multiDragItems } = props; const resolved = resolveRowObject(props); switch (row.kind) { @@ -36,7 +36,7 @@ const renderRow = (props) => { collection={resolved} searchText={searchText} openBulkMenu={openBulkMenu} - isMultiDragDisabled={isMultiDragDisabled} + isCollectionMultiDragDisabled={isCollectionMultiDragDisabled} multiDragCollections={multiDragCollections} /> ); @@ -53,7 +53,8 @@ const renderRow = (props) => { collectionPathname={row.collectionPathname} searchText={searchText} openBulkMenu={openBulkMenu} - isMultiDragDisabled={isMultiDragDisabled} + isItemMultiDragDisabled={isItemMultiDragDisabled} + multiDragCollections={multiDragCollections} multiDragItems={multiDragItems} /> ); @@ -70,7 +71,19 @@ const renderRow = (props) => { const collection = collectionsByUid.get(row.collectionUid); const example = item?.examples?.[row.exampleIndex]; if (!item || !collection || !example) return null; - return ; + return ( + + ); } default: return null; @@ -109,7 +122,8 @@ const areEqual = (prev, next) => { && a.collectionPathname === b.collectionPathname && a.exampleIndex === b.exampleIndex && prev.searchText === next.searchText - && prev.isMultiDragDisabled === next.isMultiDragDisabled + && prev.isCollectionMultiDragDisabled === next.isCollectionMultiDragDisabled + && prev.isItemMultiDragDisabled === next.isItemMultiDragDisabled && prev.multiDragCollections === next.multiDragCollections && prev.multiDragItems === next.multiDragItems && resolveRowObject(prev) === resolveRowObject(next)