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
This commit is contained in:
Sanjai Kumar
2026-02-27 15:38:13 +05:30
committed by GitHub
parent 27e22bd857
commit 5dd684f7a3
4 changed files with 326 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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 };
}

View File

@@ -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();
});
});

View File

@@ -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);
}
});
});