diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index b8334a7dd..caaeb6682 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -64,7 +64,7 @@ const RequestTabPanel = () => { const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); - const isRequestTab = focusedTab && ['http-request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type); + const isRequestTab = focusedTab && ['request', 'http-request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type); useKeybinding('sendRequest', (e) => { e?.preventDefault?.(); e?.stopPropagation?.(); 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 b2778acab..9394bea69 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 @@ -45,7 +45,11 @@ import CollectionItemIcon from './CollectionItemIcon'; import ExampleItem from './ExampleItem'; import ExampleIcon from 'components/Icons/ExampleIcon'; import { scrollToTheActiveTab } from 'utils/tabs'; -import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; +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 { calculateDraggedItemNewPathname, getInitialExampleName, findParentItemInCollection } from 'utils/collections/index'; @@ -60,12 +64,21 @@ import useKeybinding from 'hooks/useKeybinding'; const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => { const { dropdownContainerRef } = useSidebarAccordion(); - const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid }); + const selectorInput = { + itemUid: item.uid, + itemPathname: item.pathname, + collectionUid + }; + + const _isTabForItemActiveSelector = isTabForItemActiveSelector(selectorInput); const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual); - const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid }); + 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 collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); @@ -242,7 +255,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) if (isTabForItemPresent) { dispatch( focusTab({ - uid: item.uid + uid: tabUidForItem || item.uid }) ); return; @@ -468,7 +481,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) } const handleDoubleClick = (event) => { - dispatch(makeTabPermanent({ uid: item.uid })); + dispatch(makeTabPermanent({ uid: tabUidForItem || item.uid })); }; // Sort items by their "seq" property. @@ -547,7 +560,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const viewFolderSettings = () => { if (isItemAFolder(item)) { if (isTabForItemPresent) { - dispatch(focusTab({ uid: item.uid })); + dispatch(focusTab({ uid: tabUidForItem || item.uid })); return; } dispatch( diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index b70db7993..281bd98fe 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -18,6 +18,28 @@ const tabTypeAlreadyExists = (tabs, collectionUid, type) => { return find(tabs, (tab) => tab.collectionUid === collectionUid && tab.type === type); }; +const findTabByPathname = (tabs, { collectionUid, pathname, type, exampleName }) => { + if (!pathname || !collectionUid || !type) { + return null; + } + + return find(tabs, (tab) => { + if (tab.collectionUid !== collectionUid) { + return false; + } + + if (tab.pathname !== pathname || tab.type !== type) { + return false; + } + + if (type === 'response-example') { + return tab.exampleName === exampleName; + } + + return true; + }); +}; + export const tabsSlice = createSlice({ name: 'tabs', initialState, @@ -43,6 +65,12 @@ export const tabsSlice = createSlice({ return; } + const existingPathnameTab = findTabByPathname(state.tabs, { collectionUid, pathname, type, exampleName }); + if (existingPathnameTab) { + state.activeTabUid = existingPathnameTab.uid; + return; + } + if (nonReplaceableTabTypes.includes(type)) { const existingTab = tabTypeAlreadyExists(state.tabs, collectionUid, type); if (existingTab) { diff --git a/packages/bruno-app/src/selectors/tab.js b/packages/bruno-app/src/selectors/tab.js index a38382c16..0676966ac 100644 --- a/packages/bruno-app/src/selectors/tab.js +++ b/packages/bruno-app/src/selectors/tab.js @@ -1,9 +1,55 @@ import { createSelector } from '@reduxjs/toolkit'; -export const isTabForItemActive = ({ itemUid }) => createSelector([ - (state) => state.tabs?.activeTabUid -], (activeTabUid) => activeTabUid === itemUid); - -export const isTabForItemPresent = ({ itemUid }) => createSelector([ +export const getTabUidForItem = ({ itemUid, itemPathname, collectionUid }) => createSelector([ (state) => state.tabs.tabs -], (tabs) => tabs.some((tab) => tab.uid === itemUid)); +], (tabs) => { + const tabByUid = tabs.find((tab) => tab.uid === itemUid && (!collectionUid || tab.collectionUid === collectionUid)); + if (tabByUid) { + return tabByUid.uid; + } + + if (!itemPathname) { + return null; + } + + const tabByPathname = tabs.find((tab) => tab.pathname === itemPathname && (!collectionUid || tab.collectionUid === collectionUid)); + return tabByPathname?.uid || null; +}); + +export const isTabForItemActive = ({ itemUid, itemPathname, collectionUid }) => createSelector([ + (state) => state.tabs?.activeTabUid, + (state) => state.tabs.tabs +], (activeTabUid, tabs) => { + if (!activeTabUid) { + return false; + } + + const activeTab = tabs.find((tab) => tab.uid === activeTabUid); + if (!activeTab) { + return false; + } + + if (collectionUid && activeTab.collectionUid !== collectionUid) { + return false; + } + + if (activeTabUid === itemUid) { + return true; + } + + if (!itemPathname) { + return false; + } + + return activeTab.pathname === itemPathname; +}); + +export const isTabForItemPresent = ({ itemUid, itemPathname, collectionUid }) => createSelector([ + (state) => state.tabs.tabs +], (tabs) => tabs.some((tab) => { + if (collectionUid && tab.collectionUid !== collectionUid) { + return false; + } + + return tab.uid === itemUid || (itemPathname && tab.pathname === itemPathname); +})); diff --git a/packages/bruno-app/src/utils/snapshot/index.js b/packages/bruno-app/src/utils/snapshot/index.js index 246d0ba29..10d77c8ee 100644 --- a/packages/bruno-app/src/utils/snapshot/index.js +++ b/packages/bruno-app/src/utils/snapshot/index.js @@ -11,6 +11,10 @@ const SINGLETON_TAB_TYPES = new Set([ 'collection-settings', 'collection-overview', 'environment-settings', + 'global-environment-settings', + 'preferences', + 'workspaceOverview', + 'workspaceEnvironments', 'openapi-sync', 'openapi-spec' ]); @@ -449,6 +453,12 @@ export const deserializeTab = (snapshotTab, collection) => { scriptPaneTab: null }; + const isCollectionScopedSingleton = type === 'preferences' + || type === 'environment-settings' + || type === 'global-environment-settings'; + + const needsTypeBasedFallback = accessor === 'type' || (accessor === 'pathname' && !pathname && isCollectionScopedSingleton); + if (accessor === 'pathname' && pathname) { const item = findItemInCollectionByPathname(collection, pathname); tab.uid = item?.uid || pathname; @@ -461,7 +471,7 @@ export const deserializeTab = (snapshotTab, collection) => { tab.uid = example?.uid || `${pathname}::${exampleName}`; tab.itemUid = item?.uid || pathname; tab.exampleName = exampleName; - } else if (accessor === 'type') { + } else if (needsTypeBasedFallback) { const collectionUidFromSnapshot = typeof snapshotTab.collection === 'string' && snapshotTab.collection.length > 0 ? snapshotTab.collection : (typeof snapshotTab.collectionUid === 'string' && snapshotTab.collectionUid.length > 0 @@ -470,6 +480,12 @@ export const deserializeTab = (snapshotTab, collection) => { if (type === 'collection-settings') { tab.uid = collectionUidFromSnapshot || collection.uid; + } else if (type === 'preferences') { + tab.uid = `${collection.uid}-preferences`; + } else if (type === 'environment-settings') { + tab.uid = `${collection.uid}-environment-settings`; + } else if (type === 'global-environment-settings') { + tab.uid = `${collection.uid}-global-environment-settings`; } else if (NON_REPLACEABLE_SINGLETON_TAB_TYPES.has(type)) { tab.uid = uuid(); } else { diff --git a/packages/bruno-app/src/utils/snapshot/index.spec.js b/packages/bruno-app/src/utils/snapshot/index.spec.js index 62843c41c..7da307776 100644 --- a/packages/bruno-app/src/utils/snapshot/index.spec.js +++ b/packages/bruno-app/src/utils/snapshot/index.spec.js @@ -246,6 +246,39 @@ describe('deserializeTab', () => { expect(secondTab.uid).not.toBe('variables'); expect(firstTab.uid).not.toBe(secondTab.uid); }); + + it('restores preferences uid scoped to collection uid', () => { + const snapshotTab = { + type: 'preferences', + accessor: 'type', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collection); + expect(tab.uid).toBe('collection-uid-preferences'); + }); + + it('restores global environment settings uid scoped to collection uid', () => { + const snapshotTab = { + type: 'global-environment-settings', + accessor: 'type', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collection); + expect(tab.uid).toBe('collection-uid-global-environment-settings'); + }); + + it('falls back to type-based uid restore for collection-scoped singleton tabs missing pathname', () => { + const snapshotTab = { + type: 'preferences', + accessor: 'pathname', + permanent: true + }; + + const tab = deserializeTab(snapshotTab, collection); + expect(tab.uid).toBe('collection-uid-preferences'); + }); }); describe('hydrateCollectionTabs', () => { diff --git a/tests/snapshots/global-tabs.spec.ts b/tests/snapshots/global-tabs.spec.ts new file mode 100644 index 000000000..fe459751c --- /dev/null +++ b/tests/snapshots/global-tabs.spec.ts @@ -0,0 +1,59 @@ +import { test, expect, closeElectronApp } from '../../playwright'; +import { + createCollection, + createRequest, + openRequest, + createEnvironment +} from '../utils/page'; +import { buildCommonLocators } from '../utils/page/locators'; + +test.describe('Snapshot: Global Tab Restoration', () => { + test('preferences and global environment tabs are restored and reusable after restart', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-global-tabs'); + const colPath = await createTmpDir('col'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + const locators = buildCommonLocators(page); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create collection and open singleton tabs', async () => { + await createCollection(page, 'TestCol', colPath); + await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true }); + + await createEnvironment(page, 'GlobalSnapEnv', 'global'); + await expect(locators.tabs.requestTab('Global Environments')).toHaveCount(1); + + await locators.openPreferences().click(); + await expect(locators.tabs.requestTab('Preferences')).toHaveCount(1); + }); + + await test.step('Close and restart app', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + }); + + await test.step('Verify restored singleton tabs can be focused without duplication', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + const locators2 = buildCommonLocators(page2); + + await expect(locators2.tabs.requestTab('Preferences')).toHaveCount(1, { timeout: 15000 }); + await expect(locators2.tabs.requestTab('Global Environments')).toHaveCount(1, { timeout: 15000 }); + + await locators2.tabs.requestTab('Preferences').click(); + await expect(locators2.tabs.activeRequestTab()).toContainText('Preferences'); + + await locators2.tabs.requestTab('Global Environments').click(); + await expect(locators2.tabs.activeRequestTab()).toContainText('Global Environments'); + + await locators2.openPreferences().click(); + await expect(locators2.tabs.requestTab('Preferences')).toHaveCount(1); + + await closeElectronApp(app2); + }); + }); +}); diff --git a/tests/snapshots/sidebar-state.spec.ts b/tests/snapshots/sidebar-state.spec.ts new file mode 100644 index 000000000..99d39f79e --- /dev/null +++ b/tests/snapshots/sidebar-state.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, closeElectronApp, type Page } from '../../playwright'; +import { + createCollection, + createRequest, + openRequest +} from '../utils/page'; +import { buildCommonLocators } from '../utils/page/locators'; + +test.describe('Snapshot: Sidebar-Tab Restoration', () => { + test('open tabs are restored after app restart and tied to the sidebar items', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-sidebar-state'); + const colPath = await createTmpDir('col'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create collection with a request open it', async () => { + await createCollection(page, 'TestCol', colPath); + await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true }); + }); + + await test.step('Close and restart app', async () => { + // Wait for debounced snapshot save to flush + await page.waitForTimeout(2000); + await closeElectronApp(app); + }); + + await test.step('Verify tabs have opened and are tied to the sidebar', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + const locators = buildCommonLocators(page2); + await openRequest(page2, 'TestCol', 'ReqAlpha', { persist: true }); + + await expect(locators.tabs.requestTab('ReqAlpha')).toHaveCount(1); + + await closeElectronApp(app2); + }); + }); + + test('restored request tab is reused on subsequent sidebar clicks', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-sidebar-reuse'); + const colPath = await createTmpDir('col'); + + const app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create collection and keep one request tab open', async () => { + await createCollection(page, 'TestCol', colPath); + await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true }); + }); + + await test.step('Close and restart app', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + }); + + await test.step('Click request from sidebar and reuse existing tab', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await app2.firstWindow(); + await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + const locators = buildCommonLocators(page2); + await expect(locators.tabs.requestTab('ReqAlpha')).toHaveCount(1, { timeout: 15000 }); + + await openRequest(page2, 'TestCol', 'ReqAlpha'); + await expect(locators.tabs.requestTab('ReqAlpha')).toHaveCount(1); + + await openRequest(page2, 'TestCol', 'ReqAlpha', { persist: true }); + await expect(locators.tabs.requestTab('ReqAlpha')).toHaveCount(1); + + await closeElectronApp(app2); + }); + }); +}); diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 30ae35da4..f1b86dc7c 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -5,6 +5,7 @@ export const buildCommonLocators = (page: Page) => ({ saveButton: () => page .locator('.infotip') .filter({ hasText: /^Save/ }), + openPreferences: () => page.getByRole('button', { name: 'Open Preferences' }), sidebar: { collectionsContainer: () => page.getByTestId('collections'), collection: (name: string) => page.locator('#sidebar-collection-name').filter({ hasText: name }),