mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: snapshot issues with global tabs (#7942)
* chore: fix for sidebar state * fix: global and sidebar state sync * fix: re-priroritise how tab uid is synced
This commit is contained in:
@@ -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?.();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
59
tests/snapshots/global-tabs.spec.ts
Normal file
59
tests/snapshots/global-tabs.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
tests/snapshots/sidebar-state.spec.ts
Normal file
80
tests/snapshots/sidebar-state.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user