mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 15:44:13 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
88
tests/workspace/close-tab-stays-in-workspace.spec.ts
Normal file
88
tests/workspace/close-tab-stays-in-workspace.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user