From 5dd684f7a345e20feb530b62f6986a7054b1c2be Mon Sep 17 00:00:00 2001 From: Sanjai Kumar <161328623+sanjaikumar-bruno@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:38:13 +0530 Subject: [PATCH] fix: wrong workspace request shown after closing tab (#7259) * feat: add ensureActiveTabInCurrentWorkspace action and improve tab management * refactor: enhance tab focus logic for current workspace and improve tab management * tests: implement test for getTabToFocusForCurrentWorkspace logic and added a playwright * refactor: improve comments for tab management logic and enhance workspace tab focus handling in tests * refactor: ensure active tab in current workspace after collection removal and enhance tests for tab focus logic * trigger build --- .../ReduxStore/slices/collections/actions.js | 28 +++- .../getTabToFocusForCurrentWorkspace.js | 69 +++++++++ .../getTabToFocusForCurrentWorkspace.spec.js | 142 ++++++++++++++++++ .../close-tab-stays-in-workspace.spec.ts | 88 +++++++++++ 4 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.js create mode 100644 packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.spec.js create mode 100644 tests/workspace/close-tab-stays-in-workspace.spec.ts diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index c6e65ad17..779baa42a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -65,7 +65,7 @@ import { } from './index'; import { each } from 'lodash'; -import { closeAllCollectionTabs, closeTabs as _closeTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs'; +import { closeAllCollectionTabs, closeTabs as _closeTabs, focusTab, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs'; import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces'; import { resolveRequestFilename } from 'utils/common/platform'; import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index'; @@ -89,6 +89,7 @@ import { resolveInheritedAuth } from 'utils/auth'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import { updateSettingsSelectedTab } from './index'; import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; +import { getTabToFocusForCurrentWorkspace } from 'providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace'; // generate a unique names const generateUniqueName = (originalName, existingItems, isFolder) => { @@ -2362,6 +2363,8 @@ export const removeCollection = (collectionUid) => (dispatch, getState) => { })); } + dispatch(ensureActiveTabInCurrentWorkspace()); + // Only remove from Redux if no workspaces remain if (!remainingWorkspaces || remainingWorkspaces.length === 0) { return waitForNextTick().then(() => { @@ -3105,6 +3108,25 @@ export const scanForBrunoFiles = (dir) => (dispatch, getState) => { }); }; +/** + * If the current active tab belongs to another workspace, focus a tab in the current workspace. + */ +export const ensureActiveTabInCurrentWorkspace = () => (dispatch, getState) => { + const state = getState(); + const result = getTabToFocusForCurrentWorkspace(state); + if (!result) { + return; // Already in workspace, no active workspace, or unfixable (no workspace tabs and no scratch). + } + if (result.addOverviewFirst && result.scratchCollectionUid) { + dispatch(addTab({ + uid: result.uid, + collectionUid: result.scratchCollectionUid, + type: 'workspaceOverview' + })); + } + dispatch(focusTab({ uid: result.uid })); +}; + /** * Close tabs and delete any transient request files from the filesystem. * This thunk wraps the closeTabs reducer to handle transient file cleanup automatically. @@ -3136,6 +3158,10 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => { // Close the tabs first await dispatch(_closeTabs({ tabUids })); + // After close, the reducer may have set active tab to one from another workspace. Ensure it belongs to this workspace: prefer any open in-workspace tab, then workspace overview if none. + // Dispatch is synchronous; state is already updated by _closeTabs above. + await dispatch(ensureActiveTabInCurrentWorkspace()); + // Delete transient files after tabs are closed for (const [tempDir, filePaths] of Object.entries(transientByTempDir)) { try { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.js new file mode 100644 index 000000000..0274adbc8 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.js @@ -0,0 +1,69 @@ +import filter from 'lodash/filter'; +import find from 'lodash/find'; +import last from 'lodash/last'; +import { normalizePath } from 'utils/common/path'; + +/** + * Returns the set of collection UIDs that belong to the given workspace + * (scratch collection + collections whose path is in workspace.collections). + */ +export function getWorkspaceCollectionUids(state, workspace) { + if (!workspace) { + return new Set(); + } + const uids = new Set(); + if (workspace.scratchCollectionUid) { + uids.add(workspace.scratchCollectionUid); + } + const workspacePaths = new Set( + (workspace.collections || []) + .filter((wc) => wc.path) + .map((wc) => normalizePath(wc.path)) + ); + state.collections?.collections?.forEach((c) => { + if (!c.pathname) return; + if (workspacePaths.has(normalizePath(c.pathname))) { + uids.add(c.uid); + } + }); + return uids; +} + +/** + * Returns the tab to focus so the active tab is in the current workspace, or null if no change needed. + * Returns { uid } or { uid, addOverviewFirst: true, scratchCollectionUid }. + */ +export function getTabToFocusForCurrentWorkspace(state) { + const activeTabUid = state.tabs?.activeTabUid; + if (!activeTabUid || !state.tabs?.tabs?.length) { + return null; + } + const activeTab = find(state.tabs.tabs, (t) => t.uid === activeTabUid); + if (!activeTab) { + return null; + } + const activeWorkspace = state.workspaces?.workspaces?.find( + (w) => w.uid === state.workspaces?.activeWorkspaceUid + ); + if (!activeWorkspace) { + return null; + } + const workspaceCollectionUids = getWorkspaceCollectionUids(state, activeWorkspace); + if (workspaceCollectionUids.has(activeTab.collectionUid)) { + return null; + } + const inWorkspaceTabs = filter(state.tabs.tabs, (t) => workspaceCollectionUids.has(t.collectionUid)); + if (inWorkspaceTabs.length > 0) { + return { uid: last(inWorkspaceTabs).uid }; + } + const scratchCollectionUid = activeWorkspace.scratchCollectionUid; + if (!scratchCollectionUid) { + return null; // No tabs in current workspace and no scratch; cannot focus a valid tab. + } + const overviewTabUid = `${scratchCollectionUid}-overview`; + const overviewTabExists = state.tabs.tabs.some((t) => t.uid === overviewTabUid); + if (overviewTabExists) { + return { uid: overviewTabUid }; + } + return { uid: overviewTabUid, addOverviewFirst: true, scratchCollectionUid }; +} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.spec.js new file mode 100644 index 000000000..242f111ea --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace.spec.js @@ -0,0 +1,142 @@ +import { getTabToFocusForCurrentWorkspace } from './getTabToFocusForCurrentWorkspace'; + +function buildState(overrides = {}) { + const wsA = { + uid: 'workspace-a', + scratchCollectionUid: 'scratch-a', + collections: [{ path: '/path/col-a' }] + }; + const wsB = { + uid: 'workspace-b', + scratchCollectionUid: 'scratch-b', + collections: [{ path: '/path/col-b' }] + }; + const colA = { uid: 'col-a', pathname: '/path/col-a' }; + const colB = { uid: 'col-b', pathname: '/path/col-b' }; + + return { + tabs: { + activeTabUid: null, + tabs: [] + }, + workspaces: { + activeWorkspaceUid: 'workspace-b', + workspaces: [wsA, wsB] + }, + collections: { + collections: [colA, colB] + }, + ...overrides + }; +} + +describe('getTabToFocusForCurrentWorkspace', () => { + it('returns null when there is no active tab', () => { + const state = buildState(); + expect(getTabToFocusForCurrentWorkspace(state)).toBeNull(); + }); + + it('returns null when there are no tabs', () => { + const state = buildState({ tabs: { activeTabUid: 'tab-1', tabs: [] } }); + expect(getTabToFocusForCurrentWorkspace(state)).toBeNull(); + }); + + it('returns null when active tab is already in current workspace', () => { + const state = buildState({ + tabs: { + activeTabUid: 'req-b', + tabs: [ + { uid: 'req-a', collectionUid: 'col-a' }, + { uid: 'req-b', collectionUid: 'col-b' } + ] + } + }); + expect(getTabToFocusForCurrentWorkspace(state)).toBeNull(); + }); + + it('returns in-workspace tab when active tab is from another workspace', () => { + const state = buildState({ + tabs: { + activeTabUid: 'req-a', + tabs: [ + { uid: 'req-a', collectionUid: 'col-a' }, + { uid: 'req-b', collectionUid: 'col-b' }, + { uid: 'scratch-b-overview', collectionUid: 'scratch-b', type: 'workspaceOverview' } + ] + } + }); + const result = getTabToFocusForCurrentWorkspace(state); + expect(result).not.toBeNull(); + expect(result.uid).toBe('scratch-b-overview'); + expect(result.addOverviewFirst).toBeUndefined(); + }); + + it('returns last in-workspace tab when multiple request tabs exist in current workspace', () => { + const state = buildState({ + tabs: { + activeTabUid: 'req-a', + tabs: [ + { uid: 'req-a', collectionUid: 'col-a' }, + { uid: 'req-b1', collectionUid: 'col-b' }, + { uid: 'req-b2', collectionUid: 'col-b' } + ] + } + }); + const result = getTabToFocusForCurrentWorkspace(state); + expect(result).not.toBeNull(); + expect(result.uid).toBe('req-b2'); + }); + + it('treats active tab with no collectionUid as not in workspace and returns in-workspace tab', () => { + const state = buildState({ + tabs: { + activeTabUid: 'malformed', + tabs: [ + { uid: 'malformed' }, + { uid: 'req-b', collectionUid: 'col-b' } + ] + } + }); + const result = getTabToFocusForCurrentWorkspace(state); + expect(result).not.toBeNull(); + expect(result.uid).toBe('req-b'); + }); + + it('returns overview with addOverviewFirst when no in-workspace tabs and overview missing', () => { + const state = buildState({ + tabs: { + activeTabUid: 'req-a', + tabs: [ + { uid: 'req-a', collectionUid: 'col-a' } + ] + } + }); + const result = getTabToFocusForCurrentWorkspace(state); + expect(result).not.toBeNull(); + expect(result.uid).toBe('scratch-b-overview'); + expect(result.addOverviewFirst).toBe(true); + expect(result.scratchCollectionUid).toBe('scratch-b'); + }); + + it('returns null when no in-workspace tabs and no scratch', () => { + const wsBNoScratch = { + uid: 'workspace-b', + scratchCollectionUid: null, + collections: [{ path: '/path/col-b' }] + }; + const state = buildState({ + workspaces: { + activeWorkspaceUid: 'workspace-b', + workspaces: [ + { uid: 'workspace-a', scratchCollectionUid: 'scratch-a', collections: [{ path: '/path/col-a' }] }, + wsBNoScratch + ] + }, + tabs: { + activeTabUid: 'req-a', + tabs: [{ uid: 'req-a', collectionUid: 'col-a' }] + } + }); + expect(getTabToFocusForCurrentWorkspace(state)).toBeNull(); + }); +}); diff --git a/tests/workspace/close-tab-stays-in-workspace.spec.ts b/tests/workspace/close-tab-stays-in-workspace.spec.ts new file mode 100644 index 000000000..9c5f2fbdd --- /dev/null +++ b/tests/workspace/close-tab-stays-in-workspace.spec.ts @@ -0,0 +1,88 @@ +import path from 'path'; +import fs from 'fs'; +import { test, expect, closeElectronApp } from '../../playwright'; +import { + createCollection, + createRequest, + openRequest +} from '../utils/page'; +import { buildCommonLocators } from '../utils/page/locators'; + +const WORKSPACE_YML_WORKSPACEB = [ + 'opencollection: 1.0.0', + 'info:', + ' name: WorkspaceB', + ' type: workspace', + 'collections:', + 'specs: []', + 'docs: \'\'', + '' +].join('\n'); + +test.describe('Close tab stays in workspace', () => { + test('after closing last request tab in WorkspaceB, active tab is not from WorkspaceA and workspace stays WorkspaceB', async ({ + launchElectronApp, + createTmpDir + }) => { + const userDataPath = await createTmpDir('close-tab-two-workspace'); + const colAPath = await createTmpDir('col-a'); + const colBPath = await createTmpDir('col-b'); + const workspaceBPath = await createTmpDir('workspace-b'); + fs.writeFileSync(path.join(workspaceBPath, 'workspace.yml'), WORKSPACE_YML_WORKSPACEB); + + let app; + try { + app = await launchElectronApp({ userDataPath }); + const page = await app.firstWindow(); + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + + await test.step('Create ColA/ReqA in default workspace and open ReqA', async () => { + await createCollection(page, 'ColA', colAPath); + await createRequest(page, 'ReqA', 'ColA', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'ColA', 'ReqA'); + const locators = buildCommonLocators(page); + await expect(locators.tabs.activeRequestTab()).toContainText('ReqA'); + await locators.request.sendButton().click(); + await expect(locators.response.statusCode()).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Stub open-dialog and switch to WorkspaceB', async () => { + await app.evaluate( + ({ dialog }, targetPath: string) => { + (dialog as { showOpenDialog: typeof dialog.showOpenDialog }).showOpenDialog = () => + Promise.resolve({ canceled: false, filePaths: [targetPath] }); + }, + workspaceBPath + ); + await page.getByTestId('workspace-menu').click(); + await page.locator('.dropdown-item').filter({ hasText: 'Open workspace' }).click(); + await expect(page.getByTestId('workspace-name')).toHaveText('WorkspaceB', { timeout: 10000 }); + }); + + await test.step('Create ColB/ReqB in WorkspaceB and open ReqB', async () => { + await createCollection(page, 'ColB', colBPath); + await createRequest(page, 'ReqB', 'ColB', { url: 'https://echo.usebruno.com', method: 'GET' }); + await openRequest(page, 'ColB', 'ReqB'); + const locators = buildCommonLocators(page); + await expect(locators.tabs.activeRequestTab()).toContainText('ReqB'); + await locators.request.sendButton().click(); + await expect(locators.response.statusCode()).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Close ReqB tab', async () => { + const locators = buildCommonLocators(page); + await locators.tabs.closeTab('ReqB').click({ force: true }); + }); + + await test.step('Active tab must not show ReqA and workspace must still be WorkspaceB', async () => { + const locators = buildCommonLocators(page); + const activeTab = locators.tabs.activeRequestTab(); + await expect(activeTab).toBeVisible({ timeout: 5000 }); + await expect(activeTab).not.toContainText('ReqA'); + await expect(page.getByTestId('workspace-name')).toHaveText('WorkspaceB'); + }); + } finally { + if (app) await closeElectronApp(app); + } + }); +});