From cbed13a3468eef6768a610e2ae7cccaab03f84ae Mon Sep 17 00:00:00 2001 From: Sid Date: Tue, 7 Jul 2026 00:09:38 +0530 Subject: [PATCH] feat(snapshot): implement active workspace tab type tracking (#8487) * feat(snapshot): implement active workspace tab type tracking and restoration * feat(snapshot): introduce workspace tab types and update related logic --- .../middlewares/snapshot/serializeSnapshot.js | 15 +++++++- .../snapshot/serializeSnapshot.spec.js | 34 +++++++++++++++++++ .../ReduxStore/slices/workspaces/actions.js | 19 +++++++++-- .../bruno-app/src/utils/snapshot/index.js | 11 ++++++ .../src/services/snapshot/index.js | 6 ++++ tests/snapshots/global-tabs.spec.ts | 28 +++++++++++++++ 6 files changed, 109 insertions(+), 4 deletions(-) diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.js index ace64c696..ed20b5f82 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.js @@ -3,7 +3,8 @@ import { serializeTab, serializeActiveTab, getCollectionEnvironmentPath, - hydrateSnapshotLookups + hydrateSnapshotLookups, + WORKSPACE_TAB_TYPES } from 'utils/snapshot'; import { normalizePath } from 'utils/common/path'; @@ -133,6 +134,7 @@ export const serializeSnapshot = async (state, options = {}) => { const existingWorkspace = existingSnapshotLookups.workspacesByPath[normalizePath(workspace.pathname)]; let lastActiveCollectionPathname = null; + let activeWorkspaceTabType = null; if (isActiveWorkspace) { const activeTab = tabs.tabs.find((t) => t.uid === tabs.activeTabUid); @@ -144,8 +146,18 @@ export const serializeSnapshot = async (state, options = {}) => { lastActiveCollectionPathname = normalizedPathname && normalizedWorkspacePaths.includes(normalizedPathname) ? normalizedPathname : null; + + if ( + activeTab + && workspace.scratchCollectionUid + && activeTab.collectionUid === workspace.scratchCollectionUid + && WORKSPACE_TAB_TYPES.has(activeTab.type) + ) { + activeWorkspaceTabType = activeTab.type; + } } else { lastActiveCollectionPathname = existingWorkspace?.lastActiveCollectionPathname || null; + activeWorkspaceTabType = existingWorkspace?.activeWorkspaceTabType || null; } const workspaceSorting = isActiveWorkspace @@ -157,6 +169,7 @@ export const serializeSnapshot = async (state, options = {}) => { environment: '', lastActiveCollectionPathname, sorting: workspaceSorting, + activeWorkspaceTabType, collections: [...workspaceCollectionPaths] }); }); diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.spec.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.spec.js index 74bb3ead1..8c335cc69 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.spec.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/serializeSnapshot.spec.js @@ -125,6 +125,40 @@ describe('shouldPreserveCollectionEnvironmentInSnapshot', () => { }); }); +describe('serializeSnapshot workspace tab restoration', () => { + it('records the active workspace tab type when the scratch collection tab is focused', async () => { + const scratchCollectionUid = 'scratch-1'; + const state = makeState(); + state.workspaces.workspaces[0].scratchCollectionUid = scratchCollectionUid; + state.tabs.tabs = [{ uid: `${scratchCollectionUid}-environments`, collectionUid: scratchCollectionUid, type: 'workspaceEnvironments' }]; + state.tabs.activeTabUid = `${scratchCollectionUid}-environments`; + + const snapshot = await serializeSnapshot(state, { + getExistingSnapshot: async () => null + }); + + expect(snapshot.workspaces[0]).toMatchObject({ + activeWorkspaceTabType: 'workspaceEnvironments' + }); + }); + + it('does not record an active workspace tab type when a non-workspace tab is focused', async () => { + const scratchCollectionUid = 'scratch-1'; + const state = makeState(); + state.workspaces.workspaces[0].scratchCollectionUid = scratchCollectionUid; + state.tabs.tabs = [{ uid: 'req-1', collectionUid: COLLECTION_PATH, type: 'http-request' }]; + state.tabs.activeTabUid = 'req-1'; + + const snapshot = await serializeSnapshot(state, { + getExistingSnapshot: async () => null + }); + + expect(snapshot.workspaces[0]).toMatchObject({ + activeWorkspaceTabType: null + }); + }); +}); + describe('serializeSnapshot collection environment preservation', () => { it('creates a safe first-run snapshot when no existing snapshot is available', async () => { const snapshot = await serializeSnapshot(makeState(), { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index eb659d955..14ac5cfa5 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -22,7 +22,7 @@ import { } from '../app'; import { openConsole, closeConsole, setActiveTab as setActiveDevToolsTab, TAB_IDENFIERS as DEVTOOL_TABS } from '../logs'; import { normalizePath } from 'utils/common/path'; -import { hydrateTabs, getActiveTabFromSnapshot, hydrateSnapshotLookups, getCollectionSnapshotFromLookups } from 'utils/snapshot'; +import { hydrateTabs, getActiveTabFromSnapshot, hydrateSnapshotLookups, getCollectionSnapshotFromLookups, WORKSPACE_TAB_UID_SUFFIX_BY_TYPE } from 'utils/snapshot'; import toast from 'react-hot-toast'; import { closeAiSidebar } from '../chat'; @@ -640,10 +640,23 @@ export const switchWorkspace = (workspaceUid) => { )); })); + let requestedWorkspaceTabType = null; + // Add workspace tabs if (scratchCollection?.uid) { dispatch(addTab({ uid: `${scratchCollection.uid}-overview`, collectionUid: scratchCollection.uid, type: 'workspaceOverview' })); dispatch(addTab({ uid: `${scratchCollection.uid}-environments`, collectionUid: scratchCollection.uid, type: 'workspaceEnvironments' })); + + requestedWorkspaceTabType = workspaceSnapshot?.activeWorkspaceTabType; + const requestedWorkspaceTabSuffix = WORKSPACE_TAB_UID_SUFFIX_BY_TYPE[requestedWorkspaceTabType]; + if (requestedWorkspaceTabSuffix) { + const requestedWorkspaceTabUid = `${scratchCollection.uid}-${requestedWorkspaceTabSuffix}`; + dispatch(addTab({ + uid: requestedWorkspaceTabUid, + collectionUid: scratchCollection.uid, + type: requestedWorkspaceTabType + })); + } } // Restore active collection from snapshot using lastActiveCollectionPathname @@ -676,10 +689,10 @@ export const switchWorkspace = (workspaceUid) => { if (activeTab) { dispatch(addTab(activeTab)); - } else if (scratchCollection?.uid) { + } else if (scratchCollection?.uid && !requestedWorkspaceTabType) { dispatch(addTab({ uid: `${scratchCollection.uid}-overview`, collectionUid: scratchCollection.uid, type: 'workspaceOverview' })); } - } else if (scratchCollection?.uid) { + } else if (scratchCollection?.uid && !requestedWorkspaceTabType) { // No active collection, focus the workspace overview tab dispatch(addTab({ uid: `${scratchCollection.uid}-overview`, collectionUid: scratchCollection.uid, type: 'workspaceOverview' })); } diff --git a/packages/bruno-app/src/utils/snapshot/index.js b/packages/bruno-app/src/utils/snapshot/index.js index 8a8a9cd77..e5ef5cb67 100644 --- a/packages/bruno-app/src/utils/snapshot/index.js +++ b/packages/bruno-app/src/utils/snapshot/index.js @@ -30,6 +30,13 @@ const IGNORED_TAB_TYPES = new Set([ 'v4-migration' ]); +export const WORKSPACE_TAB_UID_SUFFIX_BY_TYPE = { + workspaceOverview: 'overview', + workspaceEnvironments: 'environments' +}; + +export const WORKSPACE_TAB_TYPES = new Set(Object.keys(WORKSPACE_TAB_UID_SUFFIX_BY_TYPE)); + export const SAVE_TRIGGERS = new Map([ ['app/setSnapshotReady', null], ['tabs/addTab', null], @@ -143,6 +150,9 @@ const normalizeWorkspaceSnapshotEntry = (pathname, entry = {}) => { ? entry.lastActiveCollectionPathname : null, sorting: typeof entry.sorting === 'string' ? entry.sorting : 'default', + activeWorkspaceTabType: WORKSPACE_TAB_TYPES.has(entry.activeWorkspaceTabType) + ? entry.activeWorkspaceTabType + : null, collections }; }; @@ -226,6 +236,7 @@ export const hydrateSnapshotLookups = (snapshot = {}) => { pathname: workspace.pathname, lastActiveCollectionPathname: workspace.lastActiveCollectionPathname, sorting: workspace.sorting, + activeWorkspaceTabType: workspace.activeWorkspaceTabType, collections: workspace.collections }; diff --git a/packages/bruno-electron/src/services/snapshot/index.js b/packages/bruno-electron/src/services/snapshot/index.js index e9f16cf23..d8c29c373 100644 --- a/packages/bruno-electron/src/services/snapshot/index.js +++ b/packages/bruno-electron/src/services/snapshot/index.js @@ -5,6 +5,7 @@ const yup = require('yup'); const SNAPSHOT_VERSION = '0.0.1'; const ENV_FILE_EXTENSIONS = ['bru', 'yml', 'yaml']; +const WORKSPACE_TAB_TYPES = ['workspaceOverview', 'workspaceEnvironments']; const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); @@ -71,6 +72,7 @@ const workspaceSchema = yup.object({ pathname: yup.string().required(), environment: yup.string().defined(), lastActiveCollectionPathname: yup.string().nullable(), + activeWorkspaceTabType: yup.string().oneOf([...WORKSPACE_TAB_TYPES, null]).nullable(), sorting: yup.mixed().oneOf(['alphabetical', 'reverseAlphabetical', 'default']), collections: yup.array().of(yup.string()).optional() }); @@ -193,6 +195,7 @@ class SnapshotManager { this.store.delete('activeWorkspacePath'); this.store.set('workspaces', (this.store.store?.workspaces ?? []).map((d) => { d.lastActiveCollectionPathname = undefined; + d.activeWorkspaceTabType = undefined; return d; })); this.store.set('collections', (this.store.store?.collections ?? []).map((d) => { @@ -461,6 +464,9 @@ class SnapshotManager { lastActiveCollectionPathname: typeof workspace.lastActiveCollectionPathname === 'string' ? workspace.lastActiveCollectionPathname : null, + activeWorkspaceTabType: WORKSPACE_TAB_TYPES.includes(workspace.activeWorkspaceTabType) + ? workspace.activeWorkspaceTabType + : null, sorting: typeof workspace.sorting === 'string' ? workspace.sorting : 'default', collections }; diff --git a/tests/snapshots/global-tabs.spec.ts b/tests/snapshots/global-tabs.spec.ts index 704de2397..da7178cda 100644 --- a/tests/snapshots/global-tabs.spec.ts +++ b/tests/snapshots/global-tabs.spec.ts @@ -55,4 +55,32 @@ test.describe('Snapshot: Global Tab Restoration', () => { await closeElectronApp(app2); }); }); + + test('workspace-level active tab is restored after restart', async ({ launchElectronApp, createTmpDir }) => { + const userDataPath = await createTmpDir('snap-workspace-active-tab'); + + const app = await launchElectronApp({ userDataPath }); + const page = await waitForReadyPage(app); + + await test.step('Switch to workspace environments tab', async () => { + const locators = buildCommonLocators(page); + await locators.tabs.requestTab('Environments').click(); + await expect(locators.tabs.activeRequestTab()).toContainText('Environments'); + }); + + await test.step('Close and restart app', async () => { + await page.waitForTimeout(2000); + await closeElectronApp(app); + }); + + await test.step('Verify workspace environments is restored as active tab', async () => { + const app2 = await launchElectronApp({ userDataPath }); + const page2 = await waitForReadyPage(app2); + + const locators2 = buildCommonLocators(page2); + await expect(locators2.tabs.activeRequestTab()).toContainText('Environments', { timeout: 15000 }); + + await closeElectronApp(app2); + }); + }); });