diff --git a/packages/bruno-app/src/components/GlobalSearchModal/index.js b/packages/bruno-app/src/components/GlobalSearchModal/index.js
index 647d02732..ff891cacf 100644
--- a/packages/bruno-app/src/components/GlobalSearchModal/index.js
+++ b/packages/bruno-app/src/components/GlobalSearchModal/index.js
@@ -267,14 +267,16 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
uid: result.item.uid,
collectionUid: result.collectionUid,
requestPaneTab: getDefaultRequestPaneTab(result.item),
- type: 'request'
+ type: result.item.type,
+ pathname: result.item.pathname
}));
}
} else if (result.type === SEARCH_TYPES.FOLDER) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
- type: 'folder-settings'
+ type: 'folder-settings',
+ pathname: result.item.pathname
}));
} else if (result.type === SEARCH_TYPES.COLLECTION) {
dispatch(addTab({
diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js
index e97bce7ed..d886eaa5b 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotFound/index.js
@@ -17,15 +17,12 @@ const RequestNotFound = ({ itemUid }) => {
};
useEffect(() => {
- setTimeout(() => {
+ const timer = setTimeout(() => {
setShowErrorMessage(true);
}, 300);
+ return () => clearTimeout(timer);
}, []);
- // add a delay component in react that shows a loading spinner
- // and then shows the error message after a delay
- // this will prevent the error message from flashing on the screen
-
if (!showErrorMessage) {
return null;
}
diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestTabPanelLoading/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestTabPanelLoading/index.js
new file mode 100644
index 000000000..bd46ea589
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestTabPanelLoading/index.js
@@ -0,0 +1,13 @@
+import React from 'react';
+import { IconLoader2 } from '@tabler/icons';
+
+const RequestTabPanelLoading = ({ name }) => {
+ return (
+
+
+ Loading {name ? `"${name}"` : 'request'}...
+
+ );
+};
+
+export default RequestTabPanelLoading;
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index 138f20dfa..b8334a7dd 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import find from 'lodash/find';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
@@ -7,8 +7,8 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
-import { findItemInCollection } from 'utils/collections';
-import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections';
+import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
@@ -26,6 +26,7 @@ import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
+import RequestTabPanelLoading from './RequestTabPanelLoading';
import FolderNotFound from './FolderNotFound';
import ExampleNotFound from './ExampleNotFound';
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
@@ -63,7 +64,7 @@ const RequestTabPanel = () => {
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
- const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
+ const isRequestTab = focusedTab && ['http-request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
useKeybinding('sendRequest', (e) => {
e?.preventDefault?.();
e?.stopPropagation?.();
@@ -94,6 +95,11 @@ const RequestTabPanel = () => {
});
const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
+
+ const isItemsLoading = useMemo(() => {
+ return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
+ }, [collection?.mountStatus, collection]);
+
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
@@ -321,16 +327,34 @@ const RequestTabPanel = () => {
}
if (focusedTab.type === 'response-example') {
- const item = findItemInCollection(collection, focusedTab.itemUid);
- const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid);
-
- if (!example) {
- return ;
+ let item = findItemInCollection(collection, focusedTab.itemUid);
+ if (!item && focusedTab.pathname) {
+ item = findItemInCollectionByPathname(collection, focusedTab.pathname);
}
- return ;
+
+ let example = null;
+ if (item?.examples) {
+ example = item.examples.find((ex) => ex.uid === focusedTab.uid);
+ if (!example && focusedTab.exampleName) {
+ example = item.examples.find((ex) => ex.name === focusedTab.exampleName);
+ }
+ }
+
+ if (example) {
+ return ;
+ }
+
+ const displayName = focusedTab.exampleName || focusedTab.name;
+ if (displayName && isItemsLoading) {
+ return ;
+ }
+ return ;
}
- const item = findItemInCollection(collection, activeTabUid);
+ let item = findItemInCollection(collection, activeTabUid);
+ if (!item && focusedTab.pathname) {
+ item = findItemInCollectionByPathname(collection, focusedTab.pathname);
+ }
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';
@@ -355,16 +379,23 @@ const RequestTabPanel = () => {
}
if (focusedTab.type === 'folder-settings') {
- const folder = findItemInCollection(collection, focusedTab.folderUid);
- if (!folder) {
- return ;
+ let folder = findItemInCollection(collection, focusedTab.folderUid);
+ if (!folder && focusedTab.pathname) {
+ folder = findItemInCollectionByPathname(collection, focusedTab.pathname);
}
- return (
-
-
-
- );
+ if (folder) {
+ return (
+
+ ;
+
+ );
+ }
+
+ if (focusedTab.name && isItemsLoading) {
+ return ;
+ }
+ return ;
}
if (focusedTab.type === 'environment-settings') {
@@ -380,14 +411,17 @@ const RequestTabPanel = () => {
}
if (!item || !item.uid) {
- return ;
+ const showLoading = focusedTab.name && isItemsLoading;
+ return showLoading
+ ?
+ : ;
}
- if (item?.partial) {
+ if (item.partial) {
return ;
}
- if (item?.loading) {
+ if (item.loading) {
return ;
}
diff --git a/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js
index 8536a8d23..fad3f9979 100644
--- a/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js
@@ -1,12 +1,13 @@
-import React, { useState, useRef, useMemo } from 'react';
+import React, { useState, useRef, useMemo, useEffect } from 'react';
import { useDispatch } from 'react-redux';
-import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
+import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
-import { hasExampleChanges, findItemInCollection } from 'utils/collections';
+import { hasExampleChanges, findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections';
import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
+import RequestTabLoading from '../RequestTab/RequestTabLoading';
import StyledWrapper from '../RequestTab/StyledWrapper';
import GradientCloseButton from '../RequestTab/GradientCloseButton';
@@ -16,11 +17,32 @@ const ExampleTab = ({ tab, collection }) => {
const dropdownTippyRef = useRef();
- // Get item and example data
- const item = findItemInCollection(collection, tab.itemUid);
- const example = useMemo(() => item?.examples?.find((ex) => ex.uid === tab.uid), [item?.examples, tab.uid]);
+ let item = findItemInCollection(collection, tab.itemUid);
+ if (!item && tab.pathname) {
+ item = findItemInCollectionByPathname(collection, tab.pathname);
+ }
- const hasChanges = useMemo(() => hasExampleChanges(item, tab.uid), [item, tab.uid]);
+ const example = useMemo(() => {
+ if (!item?.examples) return null;
+ const byUid = item.examples.find((ex) => ex.uid === tab.uid);
+ if (byUid) return byUid;
+ if (tab.exampleName) {
+ return item.examples.find((ex) => ex.name === tab.exampleName);
+ }
+ return null;
+ }, [item?.examples, tab.uid, tab.exampleName]);
+
+ const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]);
+
+ const isItemsLoading = useMemo(() => {
+ return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
+ }, [collection?.mountStatus, collection]);
+
+ useEffect(() => {
+ if (example && example.uid !== tab.uid) {
+ dispatch(syncTabUid({ oldUid: tab.uid, newUid: example.uid }));
+ }
+ }, [example, tab.uid, dispatch]);
const handleCloseClick = (event) => {
event.stopPropagation();
@@ -63,6 +85,8 @@ const ExampleTab = ({ tab, collection }) => {
};
if (!item || !example) {
+ const displayName = tab.exampleName || tab.name;
+ const showLoading = displayName && isItemsLoading;
return (
{
}
}}
>
-
+ {showLoading ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabLoading/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabLoading/index.js
new file mode 100644
index 000000000..5fdb8c74d
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/RequestTabLoading/index.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import GradientCloseButton from '../GradientCloseButton';
+
+/**
+ * RequestTabLoading
+ *
+ * Displays a loading placeholder for a tab while its collection is mounting
+ * or the item is still being loaded. Shows the stored name from the snapshot.
+ */
+const RequestTabLoading = ({ handleCloseClick, name }) => {
+ return (
+ <>
+
+ {name}
+
+
+ >
+ );
+};
+
+export default RequestTabLoading;
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js
index bf4c34b0b..8efbac1b7 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/StyledWrapper.js
@@ -23,6 +23,7 @@ const StyledWrapper = styled.div`
position: relative;
overflow: hidden;
white-space: nowrap;
+ text-overflow: ellipsis;
font-size: 0.8125rem;
// so that the name does not cutoff when italicized
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
index 9d7b5b170..3ab080c27 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
@@ -8,12 +8,13 @@ import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
-import { findItemInCollection, hasRequestChanges } from 'utils/collections';
+import { findItemInCollection, findItemInCollectionByPathname, hasRequestChanges, areItemsLoading } from 'utils/collections';
import ConfirmRequestClose from './ConfirmRequestClose';
import ConfirmCollectionClose from './ConfirmCollectionClose';
import ConfirmFolderClose from './ConfirmFolderClose';
import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnvironment';
import RequestTabNotFound from './RequestTabNotFound';
+import RequestTabLoading from './RequestTabLoading';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown';
@@ -40,7 +41,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const menuDropdownRef = useRef();
- const item = findItemInCollection(collection, tab.uid);
+ let item = findItemInCollection(collection, tab.uid);
+ if (!item && tab.pathname) {
+ item = findItemInCollectionByPathname(collection, tab.pathname);
+ }
const method = useMemo(() => {
if (!item) return;
@@ -58,6 +62,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
+ const isItemsLoading = useMemo(() => {
+ return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
+ }, [collection?.mountStatus, collection]);
+
const isWS = item?.type === 'ws-request';
useEffect(() => {
@@ -143,7 +151,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmCollectionClose(true);
};
- const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
+ let folder = folderUid ? findItemInCollection(collection, folderUid) : null;
+ if (!folder && tab.type === 'folder-settings' && tab.pathname) {
+ folder = findItemInCollectionByPathname(collection, tab.pathname);
+ }
const handleCloseFolderSettings = (event) => {
if (!folder?.draft) {
@@ -433,7 +444,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
{tab.type === 'folder-settings' && !folder ? (
-
+ tab.name && isItemsLoading
+ ?
+ :
) : tab.type === 'folder-settings' ? (
dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} />
) : tab.type === 'collection-settings' ? (
@@ -467,9 +480,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
if (!item) {
+ const showLoading = tab.name && isItemsLoading;
return (
{
if (e.button === 1) {
@@ -480,7 +494,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
-
+ {showLoading ? (
+
+ ) : (
+
+ )}
);
}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js
index 510e2b018..996b2d35a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js
@@ -24,7 +24,6 @@ import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext'
const ExampleItem = ({ example, item, collection }) => {
const { dropdownContainerRef } = useSidebarAccordion();
const dispatch = useDispatch();
- // Check if this example is the active tab
const activeTabUid = useSelector((state) => state.tabs?.activeTabUid);
const isExampleActive = activeTabUid === example.uid;
const [editName, setEditName] = useState(example.name || '');
@@ -39,11 +38,12 @@ const ExampleItem = ({ example, item, collection }) => {
const handleExampleClick = () => {
dispatch(addTab({
- uid: example.uid, // Use example.uid as the tab uid
- exampleUid: example.uid,
+ uid: example.uid,
collectionUid: collection.uid,
type: 'response-example',
- itemUid: item.uid
+ itemUid: item.uid,
+ pathname: item.pathname,
+ exampleName: example.name
}));
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
index 7f1fbe70c..b2778acab 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
@@ -252,7 +252,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
uid: item.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab(item),
- type: 'request'
+ type: item.type,
+ pathname: item.pathname
})
);
} else {
@@ -260,7 +261,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
addTab({
uid: item.uid,
collectionUid: collectionUid,
- type: 'folder-settings'
+ type: 'folder-settings',
+ pathname: item.pathname
})
);
if (item.collapsed) {
@@ -552,7 +554,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
addTab({
uid: item.uid,
collectionUid,
- type: 'folder-settings'
+ type: 'folder-settings',
+ pathname: item.pathname
})
);
}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js
index 0522c2327..cf1522a49 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -6,12 +6,22 @@ import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import CollectionSearch from './CollectionSearch/index';
import InlineCollectionCreator from './InlineCollectionCreator';
-import { normalizePath } from 'utils/common/path';
+import path, { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
+const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
+
+const getSidebarEntryName = (entry) => {
+ if (entry.kind === 'loaded') {
+ return entry.collection?.name || '';
+ }
+
+ return entry.entry?.name || path.basename(entry.entry?.path || '');
+};
+
const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => {
const [searchText, setSearchText] = useState('');
- const { collections } = useSelector((state) => state.collections);
+ const { collections, collectionSortOrder } = useSelector((state) => state.collections);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default');
@@ -40,8 +50,16 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
entries.push({ kind: 'ghost', entry: wc, key: `ghost:${wc.path}` });
}
}
+ if (collectionSortOrder === 'alphabetical') {
+ return [...entries].sort((a, b) => collator.compare(getSidebarEntryName(a), getSidebarEntryName(b)));
+ }
+
+ if (collectionSortOrder === 'reverseAlphabetical') {
+ return [...entries].sort((a, b) => -collator.compare(getSidebarEntryName(a), getSidebarEntryName(b)));
+ }
+
return entries;
- }, [activeWorkspace, collections, workspaces, isDefaultWorkspace]);
+ }, [activeWorkspace, collections, workspaces, isDefaultWorkspace, collectionSortOrder]);
if (!sidebarEntries.length) {
return (
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index 84b95e1a6..d91729405 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -28,7 +28,8 @@ import {
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import {
workspaceOpenedEvent,
- workspaceConfigUpdatedEvent
+ workspaceConfigUpdatedEvent,
+ hydrateSnapshotForOpenedCollection
} from 'providers/ReduxStore/slices/workspaces/actions';
import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces';
import toast from 'react-hot-toast';
@@ -120,8 +121,12 @@ const useIpcEvents = () => {
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
- const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
- dispatch(openCollectionEvent(uid, pathname, brunoConfig));
+ const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', async (pathname, uid, brunoConfig) => {
+ try {
+ await dispatch(openCollectionEvent(uid, pathname, brunoConfig));
+ } finally {
+ dispatch(hydrateSnapshotForOpenedCollection(pathname));
+ }
});
const removeOpenWorkspaceListener = ipcRenderer.on('main:workspace-opened', (workspacePath, workspaceUid, workspaceConfig) => {
diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js
index 1bb26bffc..a367acbd4 100644
--- a/packages/bruno-app/src/providers/ReduxStore/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/index.js
@@ -13,12 +13,13 @@ import apiSpecReducer from './slices/apiSpec';
import openapiSyncReducer from './slices/openapi-sync';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
import { autosaveMiddleware } from './middlewares/autosave/middleware';
+import { snapshotMiddleware } from './middlewares/snapshot/middleware';
const isDevEnv = () => {
return import.meta.env.MODE === 'development';
};
-let middleware = [tasksMiddleware.middleware, draftDetectMiddleware, autosaveMiddleware];
+let middleware = [tasksMiddleware.middleware, draftDetectMiddleware, autosaveMiddleware, snapshotMiddleware];
if (isDevEnv()) {
middleware = [...middleware, debugMiddleware.middleware];
}
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js
new file mode 100644
index 000000000..e4c21b540
--- /dev/null
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js
@@ -0,0 +1,304 @@
+/**
+ * Snapshot Middleware
+ *
+ * Automatically saves app state to disk when relevant state changes occur.
+ * Uses debouncing to prevent excessive disk writes.
+ */
+
+import {
+ SAVE_TRIGGERS,
+ shouldExcludeTab,
+ serializeTab,
+ serializeActiveTab,
+ getCollectionEnvironmentPath,
+ hydrateSnapshotLookups
+} from 'utils/snapshot';
+import { normalizePath } from 'utils/common/path';
+import { TAB_IDENFIERS as DEVTOOL_TABS } from 'providers/ReduxStore/slices/logs';
+
+const { ipcRenderer } = window;
+
+// Debounce timer reference
+let saveTimer = null;
+const DEBOUNCE_MS = 1000;
+
+const COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING = {
+ default: 'default',
+ alphabetical: 'alphabetical',
+ reverseAlphabetical: 'reverseAlphabetical'
+};
+
+const normalizeCollectionSortOrder = (sortOrder) => {
+ return COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING[sortOrder] || 'default';
+};
+
+const normalizeWorkspaceSorting = (sorting) => {
+ return COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING[sorting] || 'default';
+};
+
+const getWorkspaceCollectionSnapshotKey = (workspacePathname, collectionPathname) => {
+ const normalizedCollectionPathname = normalizePath(collectionPathname);
+ if (!normalizedCollectionPathname) {
+ return '';
+ }
+
+ return `${normalizePath(workspacePathname || '')}::${normalizedCollectionPathname}`;
+};
+
+/**
+ * Serialize the current app state into a snapshot format
+ * Persists array-based schema and supports lookup hydration.
+ */
+const serializeSnapshot = async (state) => {
+ const { workspaces, collections, tabs, logs, globalEnvironments } = state;
+ const snapshotHydration = state.app?.snapshotHydration;
+ const activeWorkspaceCollectionSortOrder = normalizeCollectionSortOrder(collections.collectionSortOrder);
+
+ // Get existing snapshot to preserve data for collections not currently loaded
+ let existingSnapshot = null;
+ try {
+ existingSnapshot = await ipcRenderer.invoke('renderer:snapshot:get');
+ } catch (err) {
+ // Ignore - will create fresh snapshot
+ }
+
+ const existingSnapshotLookups = hydrateSnapshotLookups(existingSnapshot || {});
+
+ // Build a set of scratch collection UIDs to exclude
+ const scratchCollectionUids = new Set(
+ (workspaces.workspaces || [])
+ .map((w) => w.scratchCollectionUid)
+ .filter(Boolean)
+ );
+
+ const activeWorkspace = workspaces.workspaces.find(
+ (w) => w.uid === workspaces.activeWorkspaceUid
+ );
+
+ const activeWorkspaceCollectionPaths = new Set(
+ (activeWorkspace?.collections || [])
+ .map((collection) => collection?.path)
+ .filter(Boolean)
+ .map((collectionPath) => normalizePath(collectionPath))
+ );
+
+ const existingDevTools = existingSnapshot?.extras?.devTools ?? {};
+
+ const snapshot = {
+ activeWorkspacePath: activeWorkspace?.pathname || null,
+ extras: {
+ devTools: {
+ open: logs.isConsoleOpen,
+ activeTab: logs.activeTab ?? existingDevTools.activeTab ?? 'terminal',
+ tabs: Object.assign(existingDevTools.tabs, {
+ [logs.activeTab]: {}
+ })
+ }
+ },
+ workspaces: [],
+ collections: []
+ };
+
+ // Track which workspace+collection entries we've serialized from Redux
+ const serializedCollectionKeys = new Set();
+
+ (workspaces.workspaces || []).forEach((workspace) => {
+ if (!workspace.pathname) return;
+
+ const workspaceCollectionPaths = (workspace.collections || []).map((c) => c.path).filter(Boolean);
+ const normalizedWorkspacePaths = workspaceCollectionPaths.map((p) => normalizePath(p));
+ const isActiveWorkspace = workspace.uid === workspaces.activeWorkspaceUid;
+ const existingWorkspace = existingSnapshotLookups.workspacesByPath[normalizePath(workspace.pathname)];
+
+ // Resolve lastActiveCollectionPathname
+ let lastActiveCollectionPathname = null;
+
+ if (isActiveWorkspace) {
+ const activeTab = tabs.tabs.find((t) => t.uid === tabs.activeTabUid);
+ const activeCollection = activeTab
+ ? (collections.collections || []).find((c) => c.uid === activeTab.collectionUid)
+ : null;
+ const normalizedPathname = activeCollection?.pathname ? normalizePath(activeCollection.pathname) : null;
+
+ lastActiveCollectionPathname = normalizedPathname && normalizedWorkspacePaths.includes(normalizedPathname)
+ ? normalizedPathname
+ : null;
+ } else {
+ // For non-active workspaces, preserve from existing snapshot
+ lastActiveCollectionPathname = existingWorkspace?.lastActiveCollectionPathname || null;
+ }
+
+ const workspaceSorting = isActiveWorkspace
+ ? activeWorkspaceCollectionSortOrder
+ : normalizeWorkspaceSorting(existingWorkspace?.sorting);
+
+ snapshot.workspaces.push({
+ pathname: workspace.pathname,
+ environment: '',
+ lastActiveCollectionPathname,
+ sorting: workspaceSorting,
+ collections: [...workspaceCollectionPaths]
+ });
+ });
+
+ (collections.collections || []).forEach((collection) => {
+ // Skip scratch collections and collections without pathname
+ if (!collection.pathname || scratchCollectionUids.has(collection.uid)) {
+ return;
+ }
+
+ const normalizedPath = normalizePath(collection.pathname);
+
+ // Persist tab state only for the active workspace's collections.
+ // For non-active workspaces, preserve the last persisted snapshot entries.
+ if (activeWorkspace && !activeWorkspaceCollectionPaths.has(normalizedPath)) {
+ return;
+ }
+
+ const workspacePathname = activeWorkspace?.pathname || '';
+ const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, collection.pathname);
+ if (collectionSnapshotKey) {
+ serializedCollectionKeys.add(collectionSnapshotKey);
+ }
+
+ // Get transient directory for this collection to filter transient tabs
+ const transientDirectory = collections.tempDirectories?.[collection.uid];
+
+ // Filter and serialize tabs, excluding transient requests
+ const collectionTabs = (tabs.tabs || [])
+ .filter((t) => t.collectionUid === collection.uid && !shouldExcludeTab(t, transientDirectory))
+ .map((t) => serializeTab(t, collection));
+
+ const activeTabInCollection = (tabs.tabs || []).find(
+ (t) => t.collectionUid === collection.uid && t.uid === tabs.activeTabUid && !shouldExcludeTab(t, transientDirectory)
+ );
+
+ const selectedEnvironment = (collection.environments || []).find((env) => env.uid === collection.activeEnvironmentUid);
+ const environmentPath = getCollectionEnvironmentPath(collection, selectedEnvironment, '');
+
+ snapshot.collections.push({
+ pathname: collection.pathname,
+ workspacePathname,
+ environment: {
+ collection: environmentPath,
+ global: globalEnvironments.activeGlobalEnvironmentUid || ''
+ },
+ environmentPath,
+ selectedEnvironment: selectedEnvironment?.name || '',
+ isOpen: !collection.collapsed,
+ isMounted: collection.mountStatus === 'mounted',
+ activeTab: serializeActiveTab(activeTabInCollection, collection),
+ tabs: collectionTabs
+ });
+ });
+
+ const pendingHydrationPaths = new Set(
+ (snapshotHydration?.pendingCollectionPathnames || []).map((pathname) => normalizePath(pathname))
+ );
+
+ // Preserve collections from existing snapshot that aren't currently loaded in Redux
+ // and collections that are still pending hydration during workspace switch.
+ const existingCollections = Object.values(existingSnapshotLookups.collectionsByWorkspaceAndPath || {});
+ const fallbackCollections = Object.values(existingSnapshotLookups.collectionsByPath || {});
+
+ (existingCollections.length > 0 ? existingCollections : fallbackCollections).forEach((existingCollection) => {
+ const normalizedPath = normalizePath(existingCollection.pathname || '');
+ const workspacePathname = existingCollection.workspacePathname || '';
+ const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, existingCollection.pathname);
+ const isSerializedCollection = collectionSnapshotKey && serializedCollectionKeys.has(collectionSnapshotKey);
+ const shouldPreservePendingHydration = pendingHydrationPaths.has(normalizedPath)
+ && activeWorkspace?.pathname
+ && normalizePath(workspacePathname) === normalizePath(activeWorkspace.pathname);
+
+ if (!normalizedPath || (isSerializedCollection && !shouldPreservePendingHydration)) {
+ return;
+ }
+
+ const existingTabs = (collectionSnapshotKey && existingSnapshotLookups.tabsByWorkspaceAndCollectionPath?.[collectionSnapshotKey])
+ || existingSnapshotLookups.tabsByCollectionPath?.[normalizedPath];
+
+ snapshot.collections.push({
+ pathname: existingCollection.pathname,
+ workspacePathname,
+ environment: {
+ collection: existingCollection.environment?.collection || existingCollection.environmentPath || '',
+ global: existingCollection.environment?.global || ''
+ },
+ environmentPath: existingCollection.environment?.collection || existingCollection.environmentPath || '',
+ selectedEnvironment: existingCollection.selectedEnvironment || '',
+ isOpen: typeof existingCollection.isOpen === 'boolean' ? existingCollection.isOpen : false,
+ isMounted: typeof existingCollection.isMounted === 'boolean' ? existingCollection.isMounted : false,
+ activeTab: existingTabs?.activeTab || existingCollection.activeTab || null,
+ tabs: Array.isArray(existingTabs?.tabs)
+ ? existingTabs.tabs
+ : (Array.isArray(existingCollection.tabs) ? existingCollection.tabs : [])
+ });
+ });
+
+ return snapshot;
+};
+
+/**
+ * Schedule a debounced save of the snapshot
+ */
+const scheduleSave = (getState) => {
+ if (saveTimer) {
+ clearTimeout(saveTimer);
+ }
+
+ saveTimer = setTimeout(async () => {
+ try {
+ const state = getState();
+
+ if (!state.app?.snapshotReady) {
+ saveTimer = null;
+ return;
+ }
+
+ const snapshot = await serializeSnapshot(state);
+ await ipcRenderer.invoke('renderer:snapshot:save', snapshot);
+ } catch (err) {
+ console.error('Failed to save snapshot:', err);
+ }
+ saveTimer = null;
+ }, DEBOUNCE_MS);
+};
+
+const flushSnapshotNow = async (getState) => {
+ try {
+ const state = getState();
+ const snapshot = await serializeSnapshot(state);
+ await ipcRenderer.invoke('renderer:snapshot:save', snapshot);
+ } catch (err) {
+ console.error('Failed to flush snapshot:', err);
+ }
+};
+
+/**
+ * Snapshot middleware
+ * Only saves after app signals it's ready (snapshotReady = true)
+ */
+export const snapshotMiddleware = ({ getState }) => (next) => (action) => {
+ const wasSnapshotReady = getState().app.snapshotReady;
+ const result = next(action);
+
+ if (action.type === 'app/setSnapshotReady' && action.payload === false && wasSnapshotReady) {
+ if (saveTimer) {
+ clearTimeout(saveTimer);
+ saveTimer = null;
+ }
+
+ void flushSnapshotNow(getState);
+ return result;
+ }
+
+ // Only save if snapshot is ready (app has finished initial loading)
+ const state = getState();
+ if (state.app.snapshotReady && SAVE_TRIGGERS.has(action.type)) {
+ scheduleSave(getState);
+ }
+
+ return result;
+};
+
+export default snapshotMiddleware;
diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
index 90e6c79df..ac8c41fe1 100644
--- a/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
+++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/tasks/middleware.js
@@ -76,10 +76,11 @@ taskMiddleware.startListening({
if (example) {
listenerApi.dispatch(addTab({
uid: example.uid,
- exampleUid: example.uid,
collectionUid: collection.uid,
type: 'response-example',
- itemUid: item.uid
+ itemUid: item.uid,
+ pathname: item.pathname,
+ exampleName: example.name
}));
}
}
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
index ad5f7d294..69abb02ef 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js
@@ -1,12 +1,20 @@
import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import brunoClipboard from 'utils/bruno-clipboard';
+import { normalizePath } from 'utils/common/path';
import { addTab, focusTab } from './tabs';
import { clearPersistedScope } from 'hooks/usePersistedState/PersistedScopeProvider';
const initialState = {
isDragging: false,
idbConnectionReady: false,
+ snapshotReady: false,
+ snapshotHydration: {
+ workspaceUid: null,
+ pendingCollectionPathnames: [],
+ activeCollectionPathname: null,
+ startedAt: null
+ },
leftSidebarWidth: 250,
sidebarCollapsed: false,
showSidebarSearch: false,
@@ -80,6 +88,46 @@ export const appSlice = createSlice({
idbConnectionReady: (state) => {
state.idbConnectionReady = true;
},
+ setSnapshotReady: (state, action) => {
+ state.snapshotReady = action.payload;
+ },
+ startSnapshotHydrationSession: (state, action) => {
+ const {
+ workspaceUid = null,
+ pendingCollectionPathnames = [],
+ activeCollectionPathname = null
+ } = action.payload || {};
+ const normalizedPathnames = [...new Set(
+ pendingCollectionPathnames
+ .filter(Boolean)
+ .map((pathname) => normalizePath(pathname))
+ )];
+
+ state.snapshotHydration = {
+ workspaceUid,
+ pendingCollectionPathnames: normalizedPathnames,
+ activeCollectionPathname: activeCollectionPathname ? normalizePath(activeCollectionPathname) : null,
+ startedAt: Date.now()
+ };
+ },
+ markSnapshotCollectionHydrated: (state, action) => {
+ const pathname = action.payload?.pathname;
+ if (!pathname) {
+ return;
+ }
+
+ const normalizedPathname = normalizePath(pathname);
+ state.snapshotHydration.pendingCollectionPathnames = state.snapshotHydration.pendingCollectionPathnames
+ .filter((pendingPathname) => normalizePath(pendingPathname) !== normalizedPathname);
+ },
+ clearSnapshotHydrationSession: (state) => {
+ state.snapshotHydration = {
+ workspaceUid: null,
+ pendingCollectionPathnames: [],
+ activeCollectionPathname: null,
+ startedAt: null
+ };
+ },
refreshScreenWidth: (state) => {
state.screenWidth = window.innerWidth;
},
@@ -195,6 +243,10 @@ export const appSlice = createSlice({
export const {
idbConnectionReady,
+ setSnapshotReady,
+ startSnapshotHydrationSession,
+ markSnapshotCollectionHydrated,
+ clearSnapshotHydrationSession,
refreshScreenWidth,
updateLeftSidebarWidth,
updateIsDragging,
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 55b920749..1e9842e10 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, focusTab, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs';
+import { closeAllCollectionTabs, closeTabs as _closeTabs, focusTab, restoreTabs, reopenLastClosedTab } from 'providers/ReduxStore/slices/tabs';
import { clearOpenApiSyncTabState } from 'providers/ReduxStore/slices/openapi-sync';
import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { resolveRequestFilename } from 'utils/common/platform';
@@ -74,7 +74,6 @@ import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'uti
import {
getGlobalEnvironmentVariables,
findCollectionByPathname,
- findEnvironmentInCollectionByName,
getReorderedItemsInTargetDirectory,
resetSequencesInFolder,
getReorderedItemsInSourceDirectory,
@@ -92,6 +91,12 @@ import { updateSettingsSelectedTab } from './index';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { getTabToFocusForCurrentWorkspace } from 'providers/ReduxStore/slices/workspaces/getTabToFocusForCurrentWorkspace';
import { clearPersistedScope } from 'hooks/usePersistedState/PersistedScopeProvider';
+import {
+ getCollectionEnvironmentPath,
+ findCollectionEnvironmentFromSnapshot,
+ hydrateCollectionTabs,
+ hydrateSnapshotLookups
+} from 'utils/snapshot';
// generate a unique names
const generateUniqueName = (originalName, existingItems, isFolder) => {
@@ -2338,16 +2343,20 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
const collectionCopy = cloneDeep(collection);
- const environmentName = environmentUid ? findEnvironmentInCollection(collectionCopy, environmentUid)?.name : null;
+ const environment = environmentUid ? findEnvironmentInCollection(collectionCopy, environmentUid) : null;
- if (environmentUid && !environmentName) {
+ if (environmentUid && !environment) {
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:update-ui-state-snapshot', {
type: 'COLLECTION_ENVIRONMENT',
- data: { collectionPath: collection?.pathname, environmentName }
+ data: {
+ collectionPath: collection?.pathname,
+ environmentPath: getCollectionEnvironmentPath(collection, environment),
+ selectedEnvironment: environment?.name || ''
+ }
});
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
@@ -2581,7 +2590,20 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
dispatch(workspaceEnvUpdateEvent({ processEnvVariables: workspaceProcessEnvVariables }));
- resolve();
+ const workspacePathname = activeWorkspace?.pathname || null;
+
+ ipcRenderer.invoke('renderer:snapshot:get')
+ .then((snapshot) => hydrateSnapshotLookups(snapshot || {}))
+ .then((snapshotLookups) => hydrateCollectionTabs(
+ existingCollection,
+ dispatch,
+ restoreTabs,
+ snapshotLookups,
+ workspacePathname,
+ true
+ ))
+ .catch(() => null)
+ .finally(resolve);
return;
}
@@ -2714,10 +2736,18 @@ export const collectionAddEnvFileEvent = (payload) => (dispatch, getState) => {
environmentSchema
.validate(environment)
- .then(() =>
+ .then(() => {
+ const environmentWithPath = {
+ ...environment,
+ pathname: meta?.pathname || environment?.pathname
+ };
+
+ return environmentWithPath;
+ })
+ .then((environmentWithPath) =>
dispatch(
_collectionAddEnvFileEvent({
- environment,
+ environment: environmentWithPath,
collectionUid: meta.collectionUid
})
)
@@ -2841,17 +2871,16 @@ export const hydrateCollectionWithUiStateSnapshot = (payload) => (dispatch, getS
resolve();
return;
}
- const { pathname, selectedEnvironment } = collectionSnapshotData;
+ const { pathname } = collectionSnapshotData;
const collection = findCollectionByPathname(state.collections.collections, pathname);
const collectionCopy = cloneDeep(collection);
const collectionUid = collectionCopy?.uid;
// update selected environment
- if (selectedEnvironment) {
- const environment = findEnvironmentInCollectionByName(collectionCopy, selectedEnvironment);
- if (environment) {
- dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
- }
+ const environment = findCollectionEnvironmentFromSnapshot(collectionCopy, collectionSnapshotData);
+
+ if (environment) {
+ dispatch(_selectEnvironment({ environmentUid: environment?.uid, collectionUid }));
}
// todo: add any other redux state that you want to save
@@ -2984,14 +3013,19 @@ export const loadLargeRequest
};
export const mountCollection
- = ({ collectionUid, collectionPathname, brunoConfig }) =>
+ = ({ collectionUid, collectionPathname, brunoConfig, skipTabRestore = false, workspacePathname = null }) =>
(dispatch, getState) => {
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' }));
return new Promise(async (resolve, reject) => {
callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig })
- .then((transientDirPath) => {
+ .then(async (transientDirPath) => {
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }));
dispatch(addTransientDirectory({ collectionUid, pathname: transientDirPath }));
+
+ const collection = getState().collections.collections.find((c) => c.uid === collectionUid);
+ if (!skipTabRestore && collection?.pathname) {
+ await hydrateCollectionTabs(collection, dispatch, restoreTabs, null, workspacePathname);
+ }
})
.then(resolve)
.catch(() => {
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
index b389e826c..ed6cf95c8 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js
@@ -23,6 +23,7 @@ import toast from 'react-hot-toast';
import mime from 'mime-types';
import path from 'utils/common/path';
import { getUniqueTagsFromItems } from 'utils/collections/index';
+import { getCollectionEnvironmentPath } from 'utils/snapshot';
import * as exampleReducers from './exampleReducers';
// gRPC status code meanings
@@ -896,6 +897,13 @@ export const collectionsSlice = createSlice({
collection.collapsed = !collection.collapsed;
}
},
+ expandCollection: (state, action) => {
+ const collection = findCollectionByUid(state.collections, action.payload);
+
+ if (collection) {
+ collection.collapsed = false;
+ }
+ },
toggleCollectionItem: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -2888,6 +2896,7 @@ export const collectionsSlice = createSlice({
if (existingEnv) {
const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
existingEnv.name = environment.name;
+ existingEnv.pathname = environment.pathname;
existingEnv.variables = environment.variables;
existingEnv.color = environment.color;
/*
@@ -2915,9 +2924,19 @@ export const collectionsSlice = createSlice({
// Persist the selection to the UI state snapshot
const { ipcRenderer } = window;
if (ipcRenderer) {
+ const extension = collection?.brunoConfig?.version === '1' ? 'bru' : 'yml';
+ const environmentPath = environment?.pathname
+ || (environment?.name && collection?.pathname
+ ? path.join(collection.pathname, 'environments', `${environment.name}.${extension}`)
+ : null);
+
ipcRenderer.invoke('renderer:update-ui-state-snapshot', {
type: 'COLLECTION_ENVIRONMENT',
- data: { collectionPath: collection?.pathname, environmentName: environment.name }
+ data: {
+ collectionPath: collection?.pathname,
+ environmentPath: getCollectionEnvironmentPath(collection, environment, environmentPath),
+ selectedEnvironment: environment?.name || ''
+ }
});
}
}
@@ -3631,6 +3650,7 @@ export const {
newEphemeralHttpRequest,
collapseFullCollection,
toggleCollection,
+ expandCollection,
toggleCollectionItem,
requestUrlChanged,
updateItemSettings,
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/logs.js b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js
index 71e5b844a..c55cdef50 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/logs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js
@@ -1,5 +1,14 @@
import { createSlice } from '@reduxjs/toolkit';
+export const TABS = {
+ CONSOLE: 'console',
+ NETWORK: 'network',
+ PERFORMANCE: 'performance',
+ TERMINAL: 'terminal'
+};
+
+export const TAB_IDENFIERS = Object.values(TABS);
+
const initialState = {
logs: [],
debugErrors: [],
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
index 3b713120a..b70db7993 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js
@@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
+import { isActiveTab as checkIsActiveTab, deserializeTab } from 'utils/snapshot';
// todo: errors should be tracked in each slice and displayed as toasts
@@ -22,7 +23,7 @@ export const tabsSlice = createSlice({
initialState,
reducers: {
addTab: (state, action) => {
- const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, isTransient } = action.payload;
+ const { uid, collectionUid, type, requestPaneTab, preview, exampleUid, itemUid, pathname, exampleName, isTransient } = action.payload;
const nonReplaceableTabTypes = [
'variables',
@@ -59,10 +60,12 @@ export const tabsSlice = createSlice({
}
const lastTab = state.tabs[state.tabs.length - 1];
- if (state.tabs.length > 0 && lastTab.preview) {
+ if (state.tabs.length > 0 && lastTab.preview && lastTab.collectionUid === collectionUid) {
state.tabs[state.tabs.length - 1] = {
uid,
collectionUid,
+ type: type || 'request',
+ pathname: pathname || null,
requestPaneWidth: null,
requestPaneHeight: null,
requestPaneCollapsed: false,
@@ -74,13 +77,13 @@ export const tabsSlice = createSlice({
responseFormat: null,
responseViewTab: null,
scriptPaneTab: null,
- type: type || 'request',
preview: preview !== undefined
? preview
: !nonReplaceableTabTypes.includes(type),
...(uid ? { folderUid: uid } : {}),
...(exampleUid ? { exampleUid } : {}),
...(itemUid ? { itemUid } : {}),
+ ...(exampleName ? { exampleName } : {}),
...(isTransient ? { isTransient: true } : {})
};
@@ -91,6 +94,8 @@ export const tabsSlice = createSlice({
state.tabs.push({
uid,
collectionUid,
+ type: type || 'request',
+ pathname: pathname || null,
requestPaneWidth: null,
requestPaneHeight: null,
requestPaneCollapsed: false,
@@ -107,13 +112,13 @@ export const tabsSlice = createSlice({
tableColumnWidths: {},
scriptPaneTab: null,
docsEditing: false,
- type: type || 'request',
...(uid ? { folderUid: uid } : {}),
preview: preview !== undefined
? preview
: !nonReplaceableTabTypes.includes(type),
...(exampleUid ? { exampleUid } : {}),
...(itemUid ? { itemUid } : {}),
+ ...(exampleName ? { exampleName } : {}),
...(isTransient ? { isTransient: true } : {})
});
state.activeTabUid = uid;
@@ -396,6 +401,43 @@ export const tabsSlice = createSlice({
state.tabs = tabs;
},
+ syncTabUid: (state, action) => {
+ const { oldUid, newUid } = action.payload;
+ const tab = find(state.tabs, (t) => t.uid === oldUid);
+ if (tab) {
+ tab.uid = newUid;
+ if (state.activeTabUid === oldUid) {
+ state.activeTabUid = newUid;
+ }
+ }
+ },
+ restoreTabs: (state, action) => {
+ const { collection, tabs: snapshotTabs, activeTab } = action.payload;
+ const collectionUid = collection.uid;
+
+ const activeTabWasInCollection = state.tabs.some(
+ (t) => t.uid === state.activeTabUid && t.collectionUid === collectionUid
+ );
+
+ state.tabs = state.tabs.filter((t) => t.collectionUid !== collectionUid);
+
+ if (activeTabWasInCollection) {
+ state.activeTabUid = null;
+ }
+
+ (snapshotTabs || []).forEach((snapshotTab) => {
+ const tab = deserializeTab(snapshotTab, collection);
+ state.tabs.push(tab);
+
+ if (checkIsActiveTab(tab, activeTab, collection)) {
+ state.activeTabUid = tab.uid;
+ }
+ });
+
+ if (!state.activeTabUid) {
+ state.activeTabUid = state.tabs.find((t) => t.collectionUid === collectionUid)?.uid || null;
+ }
+ },
reopenLastClosedTab: (state, action) => {
const collectionUid = action.payload?.collectionUid;
// Find the last closed tab for this collection (LIFO). If no collectionUid is
@@ -442,6 +484,8 @@ export const {
expandRequestPane,
expandResponsePane,
reorderTabs,
+ syncTabUid,
+ restoreTabs,
reopenLastClosedTab,
updateQueryBuilderOpen,
updateQueryBuilderWidth,
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 88ea6f28a..7563cf666 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js
@@ -8,16 +8,43 @@ import {
updateWorkspaceLoadingState,
setWorkspaceScratchCollection
} from '../workspaces';
-import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
-import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
+import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent, mountCollection } from '../collections/actions';
+import { removeCollection, addTransientDirectory, updateCollectionMountStatus, expandCollection, sortCollections } from '../collections';
import { sanitizeName } from 'utils/common/regex';
import { clearCollectionState } from '../openapi-sync';
import { updateGlobalEnvironments } from '../global-environments';
-import { addTab, focusTab } from '../tabs';
+import { addTab, restoreTabs } from '../tabs';
+import {
+ setSnapshotReady,
+ startSnapshotHydrationSession,
+ markSnapshotCollectionHydrated,
+ clearSnapshotHydrationSession
+} 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 } from 'utils/snapshot';
import toast from 'react-hot-toast';
const { ipcRenderer } = window;
+let snapshotHydrationTimer = null;
+const SNAPSHOT_HYDRATION_LONG_STOP_GUARD_MS = 5 * 60 * 1000;
+
+const COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING = {
+ default: 'default',
+ alphabetical: 'alphabetical',
+ reverseAlphabetical: 'reverseAlphabetical'
+};
+
+const normalizeCollectionSortOrder = (sorting) => {
+ return COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING[sorting] || 'default';
+};
+
+const clearSnapshotHydrationTimeout = () => {
+ if (snapshotHydrationTimer) {
+ clearTimeout(snapshotHydrationTimer);
+ snapshotHydrationTimer = null;
+ }
+};
const transformCollection = async (collection, type) => {
switch (type) {
@@ -327,10 +354,13 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
return dispatch(openMultipleCollections(collectionPaths, { workspacePath }));
};
+ let updatedWorkspace = null;
+ let openedCollectionPaths = [];
+
try {
const shouldRefreshCollections = workspace.collections?.some((collection) => collection.notFoundLocally);
await dispatch(loadWorkspaceCollections(workspace.uid, shouldRefreshCollections));
- const updatedWorkspace = await dispatch((_, getState) => getState().workspaces.workspaces.find((w) => w.uid === workspace.uid));
+ updatedWorkspace = await dispatch((_, getState) => getState().workspaces.workspaces.find((w) => w.uid === workspace.uid));
if (updatedWorkspace?.collections?.length > 0) {
const alreadyOpenCollections = await dispatch((_, getState) =>
@@ -343,21 +373,165 @@ const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
.filter((p) => p && !alreadyOpenCollections.includes(normalizePath(p)));
const uniqueCollectionPaths = [...new Map(
- collectionPaths.map((p) => [normalizePath(p), p])
+ collectionPaths.map((collectionPath) => [normalizePath(collectionPath), collectionPath])
).values()];
if (uniqueCollectionPaths.length > 0) {
- await openCollectionsFunction(uniqueCollectionPaths, updatedWorkspace.pathname);
+ const openResult = await openCollectionsFunction(uniqueCollectionPaths, updatedWorkspace.pathname);
+ openedCollectionPaths = Array.isArray(openResult?.opened)
+ ? openResult.opened
+ : uniqueCollectionPaths;
+
+ if (Array.isArray(openResult?.failed) && openResult.failed.length > 0) {
+ console.warn('Some workspace collections failed to open during switch:', openResult.failed);
+ }
+
+ if (Array.isArray(openResult?.invalid) && openResult.invalid.length > 0) {
+ console.warn('Some workspace collection paths were invalid during switch:', openResult.invalid);
+ }
}
}
// Load API specs for this workspace
await dispatch(loadWorkspaceApiSpecs(workspace.uid));
+
+ return {
+ updatedWorkspace,
+ openedCollectionPaths
+ };
} catch (error) {
console.error('Failed to load workspace collections:', error);
+
+ return {
+ updatedWorkspace,
+ openedCollectionPaths
+ };
}
};
+const maybeCompleteSnapshotHydrationSession = (dispatch, getState) => {
+ const state = getState();
+ const snapshotHydration = state.app.snapshotHydration;
+
+ if (!snapshotHydration?.workspaceUid) {
+ return false;
+ }
+
+ if (state.workspaces.activeWorkspaceUid !== snapshotHydration.workspaceUid) {
+ clearSnapshotHydrationTimeout();
+ dispatch(clearSnapshotHydrationSession());
+ return false;
+ }
+
+ if (snapshotHydration.pendingCollectionPathnames.length > 0) {
+ return false;
+ }
+
+ clearSnapshotHydrationTimeout();
+ dispatch(setSnapshotReady(true));
+ dispatch(clearSnapshotHydrationSession());
+ return true;
+};
+
+const scheduleSnapshotHydrationTimeout = (dispatch, getState, workspaceUid) => {
+ clearSnapshotHydrationTimeout();
+
+ snapshotHydrationTimer = setTimeout(() => {
+ const state = getState();
+ const session = state.app.snapshotHydration;
+
+ if (!session?.workspaceUid || session.workspaceUid !== workspaceUid) {
+ return;
+ }
+
+ const pendingCount = session.pendingCollectionPathnames.length;
+ if (pendingCount > 0) {
+ console.warn(
+ `Snapshot hydration timeout for workspace ${workspaceUid}. `
+ + `Proceeding with ${pendingCount} collection(s) still pending.`
+ );
+ }
+
+ dispatch(setSnapshotReady(true));
+ dispatch(clearSnapshotHydrationSession());
+ clearSnapshotHydrationTimeout();
+ }, SNAPSHOT_HYDRATION_LONG_STOP_GUARD_MS);
+};
+
+export const hydrateSnapshotForOpenedCollection = (collectionPathname) => {
+ return async (dispatch, getState) => {
+ if (!collectionPathname) {
+ return;
+ }
+
+ const state = getState();
+ const snapshotHydration = state.app.snapshotHydration;
+
+ if (!snapshotHydration?.workspaceUid) {
+ return;
+ }
+
+ if (state.workspaces.activeWorkspaceUid !== snapshotHydration.workspaceUid) {
+ clearSnapshotHydrationTimeout();
+ dispatch(clearSnapshotHydrationSession());
+ return;
+ }
+
+ const normalizedCollectionPath = normalizePath(collectionPathname);
+ const isPendingHydration = snapshotHydration.pendingCollectionPathnames.some(
+ (pathname) => normalizePath(pathname) === normalizedCollectionPath
+ );
+
+ if (!isPendingHydration) {
+ return;
+ }
+
+ const collection = state.collections.collections.find(
+ (c) => c.pathname && normalizePath(c.pathname) === normalizedCollectionPath
+ );
+
+ if (!collection) {
+ return;
+ }
+
+ const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === snapshotHydration.workspaceUid);
+ const activeWorkspacePathname = activeWorkspace?.pathname || null;
+
+ await hydrateTabs([collection], dispatch, restoreTabs, null, activeWorkspacePathname);
+
+ if (
+ snapshotHydration.activeCollectionPathname
+ && normalizePath(snapshotHydration.activeCollectionPathname) === normalizedCollectionPath
+ ) {
+ dispatch(expandCollection(collection.uid));
+
+ const needsMount = collection.mountStatus !== 'mounted' && collection.mountStatus !== 'mounting';
+ if (needsMount) {
+ await dispatch(mountCollection({
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname,
+ brunoConfig: collection.brunoConfig,
+ skipTabRestore: true,
+ workspacePathname: activeWorkspacePathname
+ })).catch((err) => console.error('Failed to mount active collection:', err));
+ }
+
+ const activeTab = await getActiveTabFromSnapshot(
+ collection.pathname,
+ collection,
+ null,
+ activeWorkspacePathname
+ );
+ if (activeTab) {
+ dispatch(addTab(activeTab));
+ }
+ }
+
+ dispatch(markSnapshotCollectionHydrated({ pathname: collection.pathname }));
+ maybeCompleteSnapshotHydrationSession(dispatch, getState);
+ };
+};
+
export const loadWorkspaceApiSpecs = (workspaceUid) => {
return async (dispatch, getState) => {
try {
@@ -393,53 +567,135 @@ export const loadWorkspaceApiSpecs = (workspaceUid) => {
export const switchWorkspace = (workspaceUid) => {
return async (dispatch, getState) => {
- dispatch(setActiveWorkspace(workspaceUid));
-
- const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
-
- if (!workspace) {
- return;
- }
+ clearSnapshotHydrationTimeout();
+ dispatch(setSnapshotReady(false));
+ dispatch(clearSnapshotHydrationSession());
try {
- const { ipcRenderer } = window;
+ dispatch(setActiveWorkspace(workspaceUid));
- const result = await ipcRenderer.invoke('renderer:get-global-environments',
- {
- workspaceUid,
- workspacePath: workspace.pathname
- });
+ const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
+ if (!workspace) {
+ return;
+ }
- const globalEnvironments = result?.globalEnvironments || [];
- const activeGlobalEnvironmentUid = result?.activeGlobalEnvironmentUid || null;
+ const fullSnapshot = await ipcRenderer.invoke('renderer:snapshot:get').catch(() => null);
+ const snapshotLookups = hydrateSnapshotLookups(fullSnapshot || {});
+ const workspaceSnapshot = workspace.pathname
+ ? snapshotLookups.workspacesByPath[normalizePath(workspace.pathname)] || null
+ : null;
+ const snapshotCollectionSortOrder = normalizeCollectionSortOrder(workspaceSnapshot?.sorting);
+ dispatch(sortCollections({ order: snapshotCollectionSortOrder }));
- dispatch(updateGlobalEnvironments({ globalEnvironments, activeGlobalEnvironmentUid }));
+ // Load global environments
+ const envResult = await ipcRenderer.invoke('renderer:get-global-environments', {
+ workspaceUid,
+ workspacePath: workspace.pathname
+ }).catch(() => null);
+
+ dispatch(updateGlobalEnvironments({
+ globalEnvironments: envResult?.globalEnvironments || [],
+ activeGlobalEnvironmentUid: envResult?.activeGlobalEnvironmentUid || null
+ }));
+
+ // Mount scratch collection and load workspace collections
+ const scratchCollection = await dispatch(mountScratchCollection(workspaceUid));
+ const { updatedWorkspace, openedCollectionPaths } = await loadWorkspaceCollectionsForSwitch(dispatch, workspace);
+
+ const latestWorkspace = updatedWorkspace || getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
+ const workspaceCollectionPaths = [...new Map(
+ (latestWorkspace?.collections || [])
+ .map((workspaceCollection) => workspaceCollection.path)
+ .filter(Boolean)
+ .map((collectionPath) => [normalizePath(collectionPath), collectionPath])
+ ).values()];
+ const workspaceCollectionPathSet = new Set(
+ workspaceCollectionPaths.map((collectionPath) => normalizePath(collectionPath))
+ );
+
+ // Hydrate tabs for workspace collections currently present in Redux
+ const collections = getState().collections.collections.filter(
+ (c) => c.pathname
+ && c.uid !== scratchCollection?.uid
+ && workspaceCollectionPathSet.has(normalizePath(c.pathname))
+ );
+ await hydrateTabs(collections, dispatch, restoreTabs, snapshotLookups, workspace.pathname || 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' }));
+ }
+
+ // Restore active collection from snapshot using lastActiveCollectionPathname
+ const lastActiveCollectionPathname = workspaceSnapshot?.lastActiveCollectionPathname || null;
+ const activeCollection = lastActiveCollectionPathname
+ ? getState().collections.collections.find((c) => normalizePath(c.pathname) === normalizePath(lastActiveCollectionPathname))
+ : null;
+
+ if (activeCollection) {
+ dispatch(expandCollection(activeCollection.uid));
+
+ const needsMount = activeCollection.mountStatus !== 'mounted' && activeCollection.mountStatus !== 'mounting';
+ if (needsMount) {
+ await dispatch(mountCollection({
+ collectionUid: activeCollection.uid,
+ collectionPathname: activeCollection.pathname,
+ brunoConfig: activeCollection.brunoConfig,
+ skipTabRestore: true,
+ workspacePathname: workspace.pathname || null
+ })).catch((err) => console.error('Failed to mount active collection:', err));
+ }
+
+ // Focus the active tab from the collection's tab snapshot
+ const activeTab = await getActiveTabFromSnapshot(
+ activeCollection.pathname,
+ activeCollection,
+ snapshotLookups,
+ workspace.pathname || null
+ );
+
+ if (activeTab) {
+ dispatch(addTab(activeTab));
+ } else if (scratchCollection?.uid) {
+ dispatch(addTab({ uid: `${scratchCollection.uid}-overview`, collectionUid: scratchCollection.uid, type: 'workspaceOverview' }));
+ }
+ } else if (scratchCollection?.uid) {
+ // No active collection, focus the workspace overview tab
+ dispatch(addTab({ uid: `${scratchCollection.uid}-overview`, collectionUid: scratchCollection.uid, type: 'workspaceOverview' }));
+ }
+
+ const openWorkspaceCollectionPaths = new Set(
+ getState().collections.collections
+ .filter((c) => c.pathname && workspaceCollectionPathSet.has(normalizePath(c.pathname)))
+ .map((c) => normalizePath(c.pathname))
+ );
+
+ const expectedHydrationCollectionPathnames = Array.isArray(openedCollectionPaths) && openedCollectionPaths.length > 0
+ ? openedCollectionPaths
+ : [];
+
+ const pendingCollectionPathnames = expectedHydrationCollectionPathnames
+ .filter((collectionPath) => !openWorkspaceCollectionPaths.has(normalizePath(collectionPath)));
+
+ dispatch(startSnapshotHydrationSession({
+ workspaceUid,
+ pendingCollectionPathnames,
+ activeCollectionPathname: lastActiveCollectionPathname || null
+ }));
+
+ const completed = maybeCompleteSnapshotHydrationSession(dispatch, getState);
+ if (!completed && pendingCollectionPathnames.length > 0) {
+ scheduleSnapshotHydrationTimeout(dispatch, getState, workspaceUid);
+ }
} catch (error) {
- dispatch(updateGlobalEnvironments({ globalEnvironments: [], activeGlobalEnvironmentUid: null }));
- }
-
- const scratchCollection = await dispatch(mountScratchCollection(workspaceUid));
- await loadWorkspaceCollectionsForSwitch(dispatch, workspace);
-
- if (scratchCollection?.uid) {
- const overviewTabUid = `${scratchCollection.uid}-overview`;
- const environmentsTabUid = `${scratchCollection.uid}-environments`;
-
- dispatch(addTab({
- uid: overviewTabUid,
- collectionUid: scratchCollection.uid,
- type: 'workspaceOverview'
- }));
-
- dispatch(addTab({
- uid: environmentsTabUid,
- collectionUid: scratchCollection.uid,
- type: 'workspaceEnvironments'
- }));
-
- dispatch(focusTab({
- uid: overviewTabUid
- }));
+ console.error('Failed to switch workspace:', error);
+ } finally {
+ const state = getState();
+ const hasHydrationSession = Boolean(state.app.snapshotHydration?.workspaceUid);
+ if (!state.app.snapshotReady && !hasHydrationSession) {
+ dispatch(setSnapshotReady(true));
+ }
}
};
};
@@ -545,11 +801,35 @@ export const workspaceOpenedEvent = (workspacePath, workspaceUid, workspaceConfi
} catch (error) {
}
- // If this is the default workspace or no workspace is active yet, switch to it
const state = getState();
const activeWorkspaceUid = state.workspaces.activeWorkspaceUid;
- if (!activeWorkspaceUid || workspaceConfig.type === 'default') {
+ let shouldSwitch = false;
+ try {
+ const snapshot = await ipcRenderer.invoke('renderer:snapshot:get');
+ const activeWorkspacePath = snapshot?.activeWorkspacePath;
+
+ const currentState = getState();
+ if (!currentState.app.snapshotReady && snapshot?.extras?.devTools) {
+ const { open } = snapshot.extras.devTools;
+ if (open) {
+ dispatch(openConsole());
+ } else {
+ dispatch(closeConsole());
+ }
+ const { activeTab = 'terminal' } = snapshot.extras.devTools;
+ dispatch(setActiveDevToolsTab(activeTab));
+ }
+
+ if (activeWorkspacePath) {
+ shouldSwitch = workspacePath === activeWorkspacePath;
+ } else {
+ shouldSwitch = !activeWorkspaceUid || workspaceConfig.type === 'default';
+ }
+ } catch (err) {
+ shouldSwitch = !activeWorkspaceUid || workspaceConfig.type === 'default';
+ }
+ if (shouldSwitch) {
dispatch(switchWorkspace(workspaceUid));
}
};
diff --git a/packages/bruno-app/src/utils/snapshot/index.js b/packages/bruno-app/src/utils/snapshot/index.js
new file mode 100644
index 000000000..246d0ba29
--- /dev/null
+++ b/packages/bruno-app/src/utils/snapshot/index.js
@@ -0,0 +1,550 @@
+import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections';
+import path, { normalizePath } from 'utils/common/path';
+import { uuid } from 'utils/common';
+
+const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
+
+const REQUEST_TAB_TYPES = new Set(['http-request', 'graphql-request', 'grpc-request', 'ws-request']);
+const SINGLETON_TAB_TYPES = new Set([
+ 'variables',
+ 'collection-runner',
+ 'collection-settings',
+ 'collection-overview',
+ 'environment-settings',
+ 'openapi-sync',
+ 'openapi-spec'
+]);
+
+const NON_REPLACEABLE_SINGLETON_TAB_TYPES = new Set([
+ 'collection-runner',
+ 'variables',
+ 'openapi-sync',
+ 'openapi-spec'
+]);
+
+export const SAVE_TRIGGERS = new Map([
+ ['app/setSnapshotReady', null],
+ ['tabs/addTab', null],
+ ['tabs/closeTabs', null],
+ ['tabs/focusTab', null],
+ ['tabs/closeAllCollectionTabs', null],
+ ['tabs/reorderTabs', null],
+ ['tabs/makeTabPermanent', null],
+ ['tabs/updateRequestPaneTab', null],
+ ['tabs/updateRequestPaneTabWidth', null],
+ ['tabs/updateRequestPaneTabHeight', null],
+ ['tabs/updateResponsePaneTab', null],
+ ['tabs/updateResponsePaneScrollPosition', null],
+ ['tabs/updateResponseFormat', null],
+ ['tabs/updateResponseViewTab', null],
+ ['tabs/updateScriptPaneTab', null],
+ ['tabs/updateRequestBodyScrollPosition', null],
+ ['workspaces/setActiveWorkspace', null],
+ ['collections/selectEnvironment', null],
+ ['collections/sortCollections', null],
+ ['collections/updateCollectionMountStatus', null],
+ ['collections/toggleCollection', null],
+ ['collections/expandCollection', null],
+ ['logs/openConsole', null],
+ ['logs/closeConsole', null],
+ ['logs/setActiveTab', null]
+]);
+
+export const isRequestTab = (type) => REQUEST_TAB_TYPES.has(type);
+
+export const shouldExcludeTab = (tab, transientDirectory) => {
+ return transientDirectory && tab.pathname?.startsWith(transientDirectory);
+};
+
+const normalizeSnapshotPathRef = (value) => {
+ if (typeof value !== 'string' || value.length === 0) {
+ return null;
+ }
+
+ return value.replace(/\\/g, '/').replace(/\/+$/, '');
+};
+
+const getWorkspaceCollectionSnapshotKey = (workspacePathname, collectionPathname) => {
+ const normalizedCollectionPathname = normalizePath(collectionPathname);
+ if (!normalizedCollectionPathname) {
+ return '';
+ }
+
+ return `${normalizePath(workspacePathname || '')}::${normalizedCollectionPathname}`;
+};
+
+const isCollectionSharedAcrossWorkspaces = (snapshotLookups = {}, collectionPathname) => {
+ const normalizedCollectionPathname = normalizePath(collectionPathname);
+ if (!normalizedCollectionPathname) {
+ return false;
+ }
+
+ return snapshotLookups?.sharedCollectionPathnames?.has(normalizedCollectionPathname) ?? false;
+};
+
+const normalizeCollectionSnapshotEntry = (pathname, entry = {}, tabsEntry = {}) => {
+ const environment = isObject(entry.environment) ? entry.environment : {};
+ const fallbackEnvironmentPath = entry.environmentPath ?? entry.selectedEnvironment;
+
+ const collectionEnvironmentPath = typeof environment.collection === 'string'
+ ? environment.collection
+ : (typeof fallbackEnvironmentPath === 'string' ? fallbackEnvironmentPath : '');
+
+ return {
+ pathname,
+ workspacePathname: typeof entry.workspacePathname === 'string' ? entry.workspacePathname : '',
+ environment: {
+ collection: collectionEnvironmentPath,
+ global: typeof environment.global === 'string' ? environment.global : ''
+ },
+ environmentPath: typeof entry.environmentPath === 'string' ? entry.environmentPath : collectionEnvironmentPath,
+ selectedEnvironment: typeof entry.selectedEnvironment === 'string' ? entry.selectedEnvironment : '',
+ isOpen: typeof entry.isOpen === 'boolean' ? entry.isOpen : false,
+ isMounted: typeof entry.isMounted === 'boolean' ? entry.isMounted : false,
+ activeTab: tabsEntry.activeTab ?? entry.activeTab ?? null,
+ tabs: Array.isArray(tabsEntry.tabs)
+ ? tabsEntry.tabs.filter((tab) => isObject(tab))
+ : (Array.isArray(entry.tabs) ? entry.tabs.filter((tab) => isObject(tab)) : [])
+ };
+};
+
+const normalizeWorkspaceSnapshotEntry = (pathname, entry = {}) => {
+ const collections = Array.isArray(entry.collections) ? entry.collections.filter((collectionPathname) => typeof collectionPathname === 'string') : [];
+
+ return {
+ pathname,
+ lastActiveCollectionPathname: typeof entry.lastActiveCollectionPathname === 'string'
+ ? entry.lastActiveCollectionPathname
+ : null,
+ sorting: typeof entry.sorting === 'string' ? entry.sorting : 'default',
+ collections
+ };
+};
+
+export const hydrateSnapshotLookups = (snapshot = {}) => {
+ const collectionsByPath = {};
+ const tabsByCollectionPath = {};
+ const collectionsByWorkspaceAndPath = {};
+ const tabsByWorkspaceAndCollectionPath = {};
+ const workspacesByPath = {};
+
+ if (Array.isArray(snapshot.collections)) {
+ snapshot.collections.forEach((collectionEntry) => {
+ if (!isObject(collectionEntry) || typeof collectionEntry.pathname !== 'string') {
+ return;
+ }
+
+ const collection = normalizeCollectionSnapshotEntry(collectionEntry.pathname, collectionEntry);
+ const normalizedCollectionPathname = normalizePath(collection.pathname);
+ if (!normalizedCollectionPathname) {
+ return;
+ }
+
+ const workspaceCollectionKey = getWorkspaceCollectionSnapshotKey(
+ collection.workspacePathname,
+ collection.pathname
+ );
+
+ collectionsByPath[normalizedCollectionPathname] = {
+ pathname: collection.pathname,
+ workspacePathname: typeof collection.workspacePathname === 'string' ? collection.workspacePathname : '',
+ environment: collection.environment,
+ environmentPath: collection.environmentPath,
+ selectedEnvironment: collection.selectedEnvironment,
+ isOpen: collection.isOpen,
+ isMounted: collection.isMounted
+ };
+
+ tabsByCollectionPath[normalizedCollectionPathname] = {
+ pathname: collection.pathname,
+ activeTab: collection.activeTab,
+ tabs: collection.tabs
+ };
+
+ if (workspaceCollectionKey) {
+ collectionsByWorkspaceAndPath[workspaceCollectionKey] = {
+ pathname: collection.pathname,
+ workspacePathname: typeof collection.workspacePathname === 'string' ? collection.workspacePathname : '',
+ environment: collection.environment,
+ environmentPath: collection.environmentPath,
+ selectedEnvironment: collection.selectedEnvironment,
+ isOpen: collection.isOpen,
+ isMounted: collection.isMounted
+ };
+
+ tabsByWorkspaceAndCollectionPath[workspaceCollectionKey] = {
+ pathname: collection.pathname,
+ workspacePathname: typeof collection.workspacePathname === 'string' ? collection.workspacePathname : '',
+ activeTab: collection.activeTab,
+ tabs: collection.tabs
+ };
+ }
+ });
+ }
+
+ const collectionWorkspaceCounts = new Map();
+
+ if (Array.isArray(snapshot.workspaces)) {
+ snapshot.workspaces.forEach((workspaceEntry) => {
+ if (!isObject(workspaceEntry) || typeof workspaceEntry.pathname !== 'string') {
+ return;
+ }
+
+ const workspace = normalizeWorkspaceSnapshotEntry(workspaceEntry.pathname, workspaceEntry);
+ const normalizedWorkspacePath = normalizePath(workspace.pathname);
+ if (!normalizedWorkspacePath) {
+ return;
+ }
+
+ workspacesByPath[normalizedWorkspacePath] = {
+ pathname: workspace.pathname,
+ lastActiveCollectionPathname: workspace.lastActiveCollectionPathname,
+ sorting: workspace.sorting,
+ collections: workspace.collections
+ };
+
+ workspace.collections.forEach((collectionPathname) => {
+ const normalizedCollectionPath = normalizePath(collectionPathname);
+ if (!normalizedCollectionPath) {
+ return;
+ }
+
+ if (collectionsByPath[normalizedCollectionPath]) {
+ collectionsByPath[normalizedCollectionPath] = {
+ ...collectionsByPath[normalizedCollectionPath],
+ workspacePathname: workspace.pathname
+ };
+ }
+
+ collectionWorkspaceCounts.set(
+ normalizedCollectionPath,
+ (collectionWorkspaceCounts.get(normalizedCollectionPath) || 0) + 1
+ );
+ });
+ });
+ }
+
+ const sharedCollectionPathnames = new Set();
+ collectionWorkspaceCounts.forEach((count, normalizedPath) => {
+ if (count > 1) sharedCollectionPathnames.add(normalizedPath);
+ });
+
+ return {
+ collectionsByPath,
+ tabsByCollectionPath,
+ collectionsByWorkspaceAndPath,
+ tabsByWorkspaceAndCollectionPath,
+ hasWorkspaceScopedTabs: Object.keys(tabsByWorkspaceAndCollectionPath).length > 0,
+ sharedCollectionPathnames,
+ workspacesByPath
+ };
+};
+
+const getTabsSnapshotFromLookups = (
+ collectionPathname,
+ snapshotLookups = {},
+ workspacePathname = null,
+ strictWorkspaceScope = false
+) => {
+ const normalizedPathname = normalizePath(collectionPathname);
+ if (!normalizedPathname) {
+ return null;
+ }
+
+ if (workspacePathname) {
+ const workspaceCollectionKey = getWorkspaceCollectionSnapshotKey(workspacePathname, collectionPathname);
+ const workspaceTabsEntry = snapshotLookups?.tabsByWorkspaceAndCollectionPath?.[workspaceCollectionKey];
+ if (workspaceTabsEntry) {
+ return {
+ activeTab: workspaceTabsEntry.activeTab,
+ tabs: Array.isArray(workspaceTabsEntry.tabs) ? workspaceTabsEntry.tabs : []
+ };
+ }
+
+ if (strictWorkspaceScope) {
+ return {
+ activeTab: null,
+ tabs: []
+ };
+ }
+
+ if (snapshotLookups?.hasWorkspaceScopedTabs) {
+ return {
+ activeTab: null,
+ tabs: []
+ };
+ }
+
+ if (isCollectionSharedAcrossWorkspaces(snapshotLookups, collectionPathname)) {
+ return {
+ activeTab: null,
+ tabs: []
+ };
+ }
+ }
+
+ const tabsEntry = snapshotLookups?.tabsByCollectionPath?.[normalizedPathname];
+ if (!tabsEntry) {
+ return null;
+ }
+
+ return {
+ activeTab: tabsEntry.activeTab,
+ tabs: Array.isArray(tabsEntry.tabs) ? tabsEntry.tabs : []
+ };
+};
+
+export const getCollectionEnvironmentPath = (collection, environment, defaultValue = null) => {
+ if (!environment) {
+ return defaultValue;
+ }
+
+ if (typeof environment.pathname === 'string' && environment.pathname.length > 0) {
+ return normalizePath(environment.pathname);
+ }
+
+ if (!environment.name || !collection?.pathname) {
+ return environment.name || defaultValue;
+ }
+
+ const extension = collection.format === 'yml' ? 'yml' : 'bru';
+ return normalizePath(path.join(collection.pathname, 'environments', `${environment.name}.${extension}`));
+};
+
+export const findCollectionEnvironmentFromSnapshot = (collection, snapshotData = {}) => {
+ const { environmentPath, selectedEnvironment } = snapshotData;
+
+ const normalizedEnvironmentPath = normalizeSnapshotPathRef(environmentPath);
+
+ if ((!normalizedEnvironmentPath && !selectedEnvironment) || !Array.isArray(collection?.environments)) {
+ return null;
+ }
+
+ return collection.environments.find((environment) => {
+ const environmentPathRef = normalizeSnapshotPathRef(environment?.pathname);
+
+ if (normalizedEnvironmentPath && environmentPathRef === normalizedEnvironmentPath) {
+ return true;
+ }
+
+ if (normalizedEnvironmentPath && environment?.uid === normalizedEnvironmentPath) {
+ return true;
+ }
+
+ if (normalizedEnvironmentPath && environment?.name === normalizedEnvironmentPath) {
+ return true;
+ }
+
+ return selectedEnvironment && environment?.name === selectedEnvironment;
+ }) || null;
+};
+
+const getAccessor = (tab) => {
+ if (tab.type === 'response-example') return 'pathname::exampleName';
+ if (SINGLETON_TAB_TYPES.has(tab.type)) return 'type';
+ return 'pathname';
+};
+
+export const serializeTab = (tab, collection) => {
+ const accessor = getAccessor(tab);
+ const serialized = {
+ type: tab.type,
+ accessor,
+ permanent: !tab.preview
+ };
+
+ if (accessor === 'pathname') {
+ const item = findItemInCollection(collection, tab.uid);
+ serialized.pathname = item?.pathname || tab.pathname;
+ if (item?.name || tab.name) {
+ serialized.name = item?.name || tab.name;
+ }
+ } else if (accessor === 'pathname::exampleName') {
+ const item = findItemInCollection(collection, tab.itemUid);
+ serialized.pathname = item?.pathname || tab.pathname;
+ serialized.exampleName = tab.exampleName;
+ if (tab.name) {
+ serialized.name = tab.name;
+ }
+ }
+
+ const isRequest = isRequestTab(tab.type);
+ if (isRequest && tab.requestPaneTab !== undefined) {
+ serialized.request = {
+ tab: tab.requestPaneTab,
+ width: tab.requestPaneWidth,
+ height: tab.requestPaneHeight
+ };
+ }
+
+ if (isRequest && tab.responsePaneTab !== undefined) {
+ serialized.response = {
+ tab: tab.responsePaneTab,
+ format: tab.responseFormat,
+ viewTab: tab.responseViewTab
+ };
+ }
+
+ return serialized;
+};
+
+export const serializeActiveTab = (tab, collection) => {
+ if (!tab) return null;
+
+ const accessor = getAccessor(tab);
+
+ if (accessor === 'pathname') {
+ const item = findItemInCollection(collection, tab.uid);
+ return { accessor, value: item?.pathname || tab.pathname };
+ }
+
+ if (accessor === 'pathname::exampleName') {
+ const item = findItemInCollection(collection, tab.itemUid);
+ const pathname = item?.pathname || tab.pathname;
+ return { accessor, value: `${pathname}::${tab.exampleName}` };
+ }
+
+ return { accessor: 'type', value: tab.type };
+};
+
+export const isActiveTab = (tab, activeTab, collection) => {
+ if (!activeTab) return false;
+
+ const { accessor, value } = activeTab;
+
+ if (accessor === 'type') {
+ return tab.type === value;
+ }
+
+ if (accessor === 'pathname') {
+ const item = findItemInCollection(collection, tab.uid);
+ return item?.pathname === value || tab.pathname === value;
+ }
+
+ if (accessor === 'pathname::exampleName') {
+ const item = findItemInCollection(collection, tab.itemUid);
+ const pathname = item?.pathname || tab.pathname;
+ return `${pathname}::${tab.exampleName}` === value;
+ }
+
+ return false;
+};
+
+export const deserializeTab = (snapshotTab, collection) => {
+ const { accessor, pathname, exampleName, type } = snapshotTab;
+
+ const tab = {
+ collectionUid: collection.uid,
+ type,
+ preview: !snapshotTab.permanent,
+ name: snapshotTab.name || null,
+ pathname: pathname || null,
+ requestPaneTab: snapshotTab.request?.tab || 'params',
+ requestPaneWidth: snapshotTab.request?.width || null,
+ requestPaneHeight: snapshotTab.request?.height || null,
+ responsePaneTab: snapshotTab.response?.tab || 'response',
+ responseFormat: snapshotTab.response?.format || null,
+ responseViewTab: snapshotTab.response?.viewTab || null,
+ responsePaneScrollPosition: null,
+ scriptPaneTab: null
+ };
+
+ if (accessor === 'pathname' && pathname) {
+ const item = findItemInCollectionByPathname(collection, pathname);
+ tab.uid = item?.uid || pathname;
+ if (type === 'folder-settings') {
+ tab.folderUid = item?.uid || pathname;
+ }
+ } else if (accessor === 'pathname::exampleName' && pathname && exampleName) {
+ const item = findItemInCollectionByPathname(collection, pathname);
+ const example = item?.examples?.find((ex) => ex.name === exampleName);
+ tab.uid = example?.uid || `${pathname}::${exampleName}`;
+ tab.itemUid = item?.uid || pathname;
+ tab.exampleName = exampleName;
+ } else if (accessor === 'type') {
+ const collectionUidFromSnapshot = typeof snapshotTab.collection === 'string' && snapshotTab.collection.length > 0
+ ? snapshotTab.collection
+ : (typeof snapshotTab.collectionUid === 'string' && snapshotTab.collectionUid.length > 0
+ ? snapshotTab.collectionUid
+ : null);
+
+ if (type === 'collection-settings') {
+ tab.uid = collectionUidFromSnapshot || collection.uid;
+ } else if (NON_REPLACEABLE_SINGLETON_TAB_TYPES.has(type)) {
+ tab.uid = uuid();
+ } else {
+ tab.uid = type;
+ }
+ }
+
+ return tab;
+};
+
+export const hydrateCollectionTabs = async (
+ collection,
+ dispatch,
+ restoreTabs,
+ snapshotLookups = null,
+ workspacePathname = null,
+ strictWorkspaceScope = false
+) => {
+ const { ipcRenderer } = window;
+
+ const tabsSnapshot = getTabsSnapshotFromLookups(
+ collection.pathname,
+ snapshotLookups,
+ workspacePathname,
+ strictWorkspaceScope
+ )
+ || await ipcRenderer.invoke('renderer:snapshot:get-tabs', collection.pathname, workspacePathname).catch(() => null);
+
+ const hasPersistedTabs = Array.isArray(tabsSnapshot?.tabs) && tabsSnapshot.tabs.length > 0;
+ const hasPersistedActiveTab = Boolean(tabsSnapshot?.activeTab);
+ const shouldRestoreEmptyWorkspaceScopedTabs = Boolean(workspacePathname) && (
+ strictWorkspaceScope
+ || Boolean(snapshotLookups?.hasWorkspaceScopedTabs)
+ || isCollectionSharedAcrossWorkspaces(snapshotLookups, collection.pathname)
+ );
+
+ if (
+ tabsSnapshot
+ && Array.isArray(tabsSnapshot.tabs)
+ && (hasPersistedTabs || hasPersistedActiveTab || shouldRestoreEmptyWorkspaceScopedTabs)
+ ) {
+ dispatch(restoreTabs({
+ collection,
+ tabs: tabsSnapshot.tabs,
+ activeTab: tabsSnapshot.activeTab
+ }));
+ }
+};
+
+export const hydrateTabs = async (collections, dispatch, restoreTabs, snapshotLookups = null, workspacePathname = null) => {
+ await Promise.all(
+ collections.map((collection) => hydrateCollectionTabs(collection, dispatch, restoreTabs, snapshotLookups, workspacePathname))
+ );
+};
+
+export const getActiveTabFromSnapshot = async (collectionPathname, collection, snapshotLookups = null, workspacePathname = null) => {
+ const { ipcRenderer } = window;
+
+ const tabsSnapshot = getTabsSnapshotFromLookups(collectionPathname, snapshotLookups, workspacePathname)
+ || await ipcRenderer.invoke('renderer:snapshot:get-tabs', collectionPathname, workspacePathname).catch(() => null);
+
+ if (!tabsSnapshot?.activeTab || !tabsSnapshot?.tabs?.length) return null;
+
+ const { accessor, value } = tabsSnapshot.activeTab;
+ let snapshotTab = null;
+
+ if (accessor === 'type') {
+ snapshotTab = tabsSnapshot.tabs.find((t) => t.type === value);
+ } else if (accessor === 'pathname') {
+ snapshotTab = tabsSnapshot.tabs.find((t) => t.pathname === value);
+ } else if (accessor === 'pathname::exampleName') {
+ snapshotTab = tabsSnapshot.tabs.find((t) => `${t.pathname}::${t.exampleName}` === value);
+ }
+
+ if (!snapshotTab) return null;
+
+ return deserializeTab(snapshotTab, collection);
+};
diff --git a/packages/bruno-app/src/utils/snapshot/index.spec.js b/packages/bruno-app/src/utils/snapshot/index.spec.js
new file mode 100644
index 000000000..62843c41c
--- /dev/null
+++ b/packages/bruno-app/src/utils/snapshot/index.spec.js
@@ -0,0 +1,389 @@
+const { describe, it, expect, jest } = require('@jest/globals');
+
+jest.mock('nanoid', () => {
+ let counter = 0;
+
+ return {
+ customAlphabet: jest.fn(() => () => {
+ counter += 1;
+ return `uuid-${counter}`;
+ })
+ };
+});
+
+const { deserializeTab, hydrateSnapshotLookups, hydrateCollectionTabs } = require('./index');
+
+describe('hydrateSnapshotLookups', () => {
+ it('builds lookup maps from array-based snapshot schema', () => {
+ const snapshot = {
+ workspaces: [
+ {
+ pathname: '/workspaces/main',
+ sorting: 'default',
+ collections: ['/collections/a']
+ }
+ ],
+ collections: [
+ {
+ pathname: '/collections/a',
+ environment: {
+ collection: '/collections/a/environments/Prod.yml',
+ global: 'Global'
+ },
+ selectedEnvironment: 'Prod',
+ isOpen: true,
+ isMounted: false,
+ activeTab: { accessor: 'type', value: 'variables' },
+ tabs: [{ type: 'variables', accessor: 'type', permanent: false }]
+ }
+ ]
+ };
+
+ const lookups = hydrateSnapshotLookups(snapshot);
+ const collection = lookups.collectionsByPath['/collections/a'];
+ const workspace = lookups.workspacesByPath['/workspaces/main'];
+ const tabs = lookups.tabsByCollectionPath['/collections/a'];
+
+ expect(collection).toMatchObject({
+ pathname: '/collections/a',
+ workspacePathname: '/workspaces/main',
+ environment: {
+ collection: '/collections/a/environments/Prod.yml',
+ global: 'Global'
+ },
+ environmentPath: '/collections/a/environments/Prod.yml',
+ selectedEnvironment: 'Prod',
+ isOpen: true,
+ isMounted: false
+ });
+
+ expect(workspace).toMatchObject({
+ pathname: '/workspaces/main',
+ sorting: 'default',
+ collections: ['/collections/a']
+ });
+
+ expect(tabs).toMatchObject({
+ pathname: '/collections/a',
+ activeTab: { accessor: 'type', value: 'variables' }
+ });
+ expect(Array.isArray(tabs.tabs)).toBe(true);
+ expect(tabs.tabs).toHaveLength(1);
+ });
+
+ it('builds lookup maps from map-based snapshot schema for backward compatibility', () => {
+ const snapshot = {
+ workspaces: [
+ {
+ pathname: '/workspaces/main',
+ sorting: 'default',
+ collections: ['/collections/a']
+ }
+ ],
+ collections: [
+ {
+ pathname: '/collections/a',
+ environmentPath: 'Prod',
+ isOpen: true,
+ isMounted: true,
+ activeTab: { accessor: 'type', value: 'variables' },
+ tabs: [{ type: 'variables', accessor: 'type', permanent: false }]
+ }
+ ]
+ };
+
+ const lookups = hydrateSnapshotLookups(snapshot);
+ const collection = lookups.collectionsByPath['/collections/a'];
+ const workspace = lookups.workspacesByPath['/workspaces/main'];
+ const tabs = lookups.tabsByCollectionPath['/collections/a'];
+
+ expect(collection).toMatchObject({
+ pathname: '/collections/a',
+ workspacePathname: '/workspaces/main',
+ environment: {
+ collection: 'Prod',
+ global: ''
+ },
+ environmentPath: 'Prod',
+ selectedEnvironment: '',
+ isOpen: true,
+ isMounted: true
+ });
+
+ expect(workspace).toMatchObject({
+ pathname: '/workspaces/main',
+ sorting: 'default',
+ collections: ['/collections/a']
+ });
+
+ expect(tabs).toMatchObject({
+ pathname: '/collections/a',
+ activeTab: { accessor: 'type', value: 'variables' }
+ });
+ expect(Array.isArray(tabs.tabs)).toBe(true);
+ expect(tabs.tabs).toHaveLength(1);
+
+ const windowsSnapshot = {
+ workspaces: [
+ {
+ pathname: 'C:\\workspace',
+ sorting: 'default',
+ collections: ['C:\\workspace\\collection']
+ }
+ ],
+ collections: [
+ {
+ pathname: 'C:\\workspace\\collection',
+ selectedEnvironment: 'Prod',
+ isOpen: true,
+ isMounted: true
+ }
+ ]
+ };
+
+ const windowsLookups = hydrateSnapshotLookups(windowsSnapshot);
+ expect(windowsLookups.collectionsByPath['C:/workspace/collection']).toMatchObject({
+ workspacePathname: 'C:\\workspace',
+ selectedEnvironment: 'Prod'
+ });
+ });
+
+ it('keeps workspace-scoped tab snapshots when same collection exists in multiple workspaces', () => {
+ const sharedCollectionPath = '/collections/shared';
+ const workspaceAPath = '/workspaces/a';
+ const workspaceBPath = '/workspaces/b';
+
+ const snapshot = {
+ workspaces: [
+ {
+ pathname: workspaceAPath,
+ sorting: 'default',
+ collections: [sharedCollectionPath]
+ },
+ {
+ pathname: workspaceBPath,
+ sorting: 'default',
+ collections: [sharedCollectionPath]
+ }
+ ],
+ collections: [
+ {
+ pathname: sharedCollectionPath,
+ workspacePathname: workspaceAPath,
+ environment: {
+ collection: '',
+ global: ''
+ },
+ selectedEnvironment: '',
+ isOpen: true,
+ isMounted: false,
+ activeTab: { accessor: 'pathname', value: '/collections/shared/ReqA' },
+ tabs: [{ type: 'http-request', accessor: 'pathname', pathname: '/collections/shared/ReqA', permanent: true }]
+ },
+ {
+ pathname: sharedCollectionPath,
+ workspacePathname: workspaceBPath,
+ environment: {
+ collection: '',
+ global: ''
+ },
+ selectedEnvironment: '',
+ isOpen: true,
+ isMounted: false,
+ activeTab: { accessor: 'pathname', value: '/collections/shared/ReqB' },
+ tabs: [{ type: 'http-request', accessor: 'pathname', pathname: '/collections/shared/ReqB', permanent: true }]
+ }
+ ]
+ };
+
+ const lookups = hydrateSnapshotLookups(snapshot);
+ const keyA = `${workspaceAPath}::${sharedCollectionPath}`;
+ const keyB = `${workspaceBPath}::${sharedCollectionPath}`;
+
+ expect(lookups.tabsByWorkspaceAndCollectionPath[keyA]).toMatchObject({
+ activeTab: { accessor: 'pathname', value: '/collections/shared/ReqA' },
+ tabs: [{ pathname: '/collections/shared/ReqA' }]
+ });
+
+ expect(lookups.tabsByWorkspaceAndCollectionPath[keyB]).toMatchObject({
+ activeTab: { accessor: 'pathname', value: '/collections/shared/ReqB' },
+ tabs: [{ pathname: '/collections/shared/ReqB' }]
+ });
+
+ expect(lookups.hasWorkspaceScopedTabs).toBe(true);
+ });
+});
+
+describe('deserializeTab', () => {
+ const collection = {
+ uid: 'collection-uid',
+ pathname: '/collections/a'
+ };
+
+ it('restores collection-settings tab uid from collection uid', () => {
+ const snapshotTab = {
+ type: 'collection-settings',
+ accessor: 'type',
+ permanent: true,
+ collection: 'collection-from-snapshot'
+ };
+
+ const tab = deserializeTab(snapshotTab, collection);
+ expect(tab.uid).toBe('collection-from-snapshot');
+ });
+
+ it('generates unique uid for non-replaceable singleton type tabs', () => {
+ const snapshotTab = {
+ type: 'variables',
+ accessor: 'type',
+ permanent: false
+ };
+
+ const firstTab = deserializeTab(snapshotTab, collection);
+ const secondTab = deserializeTab(snapshotTab, collection);
+
+ expect(firstTab.uid).not.toBe('variables');
+ expect(secondTab.uid).not.toBe('variables');
+ expect(firstTab.uid).not.toBe(secondTab.uid);
+ });
+});
+
+describe('hydrateCollectionTabs', () => {
+ beforeEach(() => {
+ global.window = {
+ ipcRenderer: {
+ invoke: jest.fn().mockResolvedValue(null)
+ }
+ };
+ });
+
+ afterEach(() => {
+ delete global.window;
+ });
+
+ it('does not restore tabs when snapshot has no tab state', async () => {
+ const snapshot = {
+ collections: [
+ {
+ pathname: '/collections/legacy',
+ selectedEnvironment: 'local'
+ }
+ ]
+ };
+ const lookups = hydrateSnapshotLookups(snapshot);
+ const dispatch = jest.fn();
+ const restoreTabs = jest.fn();
+
+ await hydrateCollectionTabs(
+ { uid: 'collection-uid', pathname: '/collections/legacy' },
+ dispatch,
+ restoreTabs,
+ lookups,
+ null,
+ true
+ );
+
+ expect(dispatch).not.toHaveBeenCalled();
+ });
+
+ it('restores empty tab state for shared collection workspace isolation', async () => {
+ const snapshot = {
+ workspaces: [
+ {
+ pathname: '/workspaces/a',
+ collections: ['/collections/shared']
+ },
+ {
+ pathname: '/workspaces/b',
+ collections: ['/collections/shared']
+ }
+ ],
+ collections: [
+ {
+ pathname: '/collections/shared',
+ workspacePathname: '/workspaces/a',
+ tabs: [
+ {
+ type: 'http-request',
+ accessor: 'pathname',
+ pathname: '/collections/shared/request-a.bru',
+ permanent: true
+ }
+ ],
+ activeTab: {
+ accessor: 'pathname',
+ value: '/collections/shared/request-a.bru'
+ }
+ },
+ {
+ pathname: '/collections/shared',
+ workspacePathname: '/workspaces/b',
+ tabs: [],
+ activeTab: null
+ }
+ ]
+ };
+ const lookups = hydrateSnapshotLookups(snapshot);
+ const dispatch = jest.fn();
+ const restoreTabs = jest.fn((payload) => ({
+ type: 'tabs/restoreTabs',
+ payload
+ }));
+
+ await hydrateCollectionTabs(
+ { uid: 'collection-uid', pathname: '/collections/shared' },
+ dispatch,
+ restoreTabs,
+ lookups,
+ '/workspaces/b',
+ true
+ );
+
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ expect(restoreTabs).toHaveBeenCalledWith(expect.objectContaining({
+ tabs: [],
+ activeTab: null
+ }));
+ });
+
+ it('restores tabs when snapshot has persisted tabs', async () => {
+ const snapshot = {
+ collections: [
+ {
+ pathname: '/collections/legacy',
+ selectedEnvironment: 'local',
+ tabs: [
+ {
+ type: 'http-request',
+ accessor: 'pathname',
+ pathname: '/collections/legacy/request.bru',
+ permanent: true
+ }
+ ],
+ activeTab: {
+ accessor: 'pathname',
+ value: '/collections/legacy/request.bru'
+ }
+ }
+ ]
+ };
+ const lookups = hydrateSnapshotLookups(snapshot);
+ const dispatch = jest.fn();
+ const restoreTabs = jest.fn((payload) => ({
+ type: 'tabs/restoreTabs',
+ payload
+ }));
+
+ await hydrateCollectionTabs(
+ { uid: 'collection-uid', pathname: '/collections/legacy' },
+ dispatch,
+ restoreTabs,
+ lookups,
+ null,
+ true
+ );
+
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ expect(restoreTabs).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/bruno-electron/src/app/collection-watcher.js b/packages/bruno-electron/src/app/collection-watcher.js
index 12ed78aac..23aef361b 100644
--- a/packages/bruno-electron/src/app/collection-watcher.js
+++ b/packages/bruno-electron/src/app/collection-watcher.js
@@ -22,7 +22,7 @@ const { getRequestUid } = require('../cache/requestUids');
const { decryptStringSafe } = require('../utils/encryption');
const { setBrunoConfig } = require('../store/bruno-config');
const EnvironmentSecretsStore = require('../store/env-secrets');
-const UiStateSnapshot = require('../store/ui-state-snapshot');
+const snapshotManager = require('../services/snapshot');
const { parseFileMeta, hydrateRequestWithUuid } = require('../utils/collection');
const { parseLargeRequestWithRedaction } = require('../utils/parse');
const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig');
@@ -636,10 +636,17 @@ const onWatcherSetupComplete = (win, watchPath, collectionUid, watcher) => {
// Mark discovery as complete
watcher.completeCollectionDiscovery(win, collectionUid);
- const UiStateSnapshotStore = new UiStateSnapshot();
- const collectionsSnapshotState = UiStateSnapshotStore.getCollections();
- const collectionSnapshotState = collectionsSnapshotState?.find((c) => c?.pathname && path.normalize(c.pathname) === path.normalize(watchPath));
- win.webContents.send('main:hydrate-app-with-ui-state-snapshot', collectionSnapshotState);
+ const collectionSnapshotState = snapshotManager.getCollection(watchPath);
+
+ const hydratePayload = collectionSnapshotState
+ ? {
+ pathname: watchPath,
+ environmentPath: collectionSnapshotState?.environment?.collection || '',
+ selectedEnvironment: collectionSnapshotState?.selectedEnvironment || ''
+ }
+ : null;
+
+ win.webContents.send('main:hydrate-app-with-ui-state-snapshot', hydratePayload);
};
class CollectionWatcher {
diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js
index 7327c7ddd..c877c77c7 100644
--- a/packages/bruno-electron/src/app/collections.js
+++ b/packages/bruno-electron/src/app/collections.js
@@ -128,14 +128,24 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
win.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
+ return {
+ path: collectionPath,
+ opened: true,
+ alreadyOpen: true,
+ uid
+ };
} catch (err) {
if (!options.dontSendDisplayErrors) {
win.webContents.send('main:display-error', {
message: err.message || 'An error occurred while opening the local collection'
});
}
+ return {
+ path: collectionPath,
+ opened: false,
+ error: err.message || 'An error occurred while opening the local collection'
+ };
}
- return;
}
try {
@@ -155,17 +165,33 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
win.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', win, collectionPath, uid, brunoConfig);
+ return {
+ path: collectionPath,
+ opened: true,
+ alreadyOpen: false,
+ uid
+ };
} catch (err) {
if (!options.dontSendDisplayErrors) {
win.webContents.send('main:display-error', {
message: err.message || 'An error occurred while opening the local collection'
});
}
+ return {
+ path: collectionPath,
+ opened: false,
+ error: err.message || 'An error occurred while opening the local collection'
+ };
}
};
const openCollectionsByPathname = async (win, watcher, collectionPaths, options = {}) => {
const seenPaths = new Set();
+ const result = {
+ opened: [],
+ failed: [],
+ invalid: []
+ };
for (const collectionPath of collectionPaths) {
const resolvedPath = path.isAbsolute(collectionPath)
@@ -179,11 +205,22 @@ const openCollectionsByPathname = async (win, watcher, collectionPaths, options
seenPaths.add(normalizedPath);
if (isDirectory(resolvedPath)) {
- await openCollection(win, watcher, resolvedPath, options);
+ const openResult = await openCollection(win, watcher, resolvedPath, options);
+ if (openResult?.opened) {
+ result.opened.push(openResult.path);
+ } else {
+ result.failed.push({
+ path: resolvedPath,
+ error: openResult?.error || 'Failed to open collection'
+ });
+ }
} else {
console.error(`Cannot open unknown folder: "${resolvedPath}"`);
+ result.invalid.push(resolvedPath);
}
}
+
+ return result;
};
module.exports = {
diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js
index 256e252e3..7ebb0ee0f 100644
--- a/packages/bruno-electron/src/index.js
+++ b/packages/bruno-electron/src/index.js
@@ -39,6 +39,7 @@ const registerNetworkIpc = require('./ipc/network');
const registerCollectionsIpc = require('./ipc/collection');
const registerFilesystemIpc = require('./ipc/filesystem');
const registerPreferencesIpc = require('./ipc/preferences');
+const registerSnapshotIpc = require('./ipc/snapshot');
const registerSystemMonitorIpc = require('./ipc/system-monitor');
const registerWorkspaceIpc = require('./ipc/workspace');
const registerApiSpecIpc = require('./ipc/apiSpec');
@@ -462,6 +463,7 @@ app.on('ready', async () => {
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);
registerCollectionsIpc(mainWindow, collectionWatcher);
registerPreferencesIpc(mainWindow, collectionWatcher);
+ registerSnapshotIpc();
registerWorkspaceIpc(mainWindow, workspaceWatcher);
registerApiSpecIpc(mainWindow, apiSpecWatcher);
registerNotificationsIpc(mainWindow, collectionWatcher);
diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js
index 5e08a5e6b..09dd7af62 100644
--- a/packages/bruno-electron/src/ipc/collection.js
+++ b/packages/bruno-electron/src/ipc/collection.js
@@ -63,7 +63,7 @@ const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../c
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
const EnvironmentSecretsStore = require('../store/env-secrets');
const CollectionSecurityStore = require('../store/collection-security');
-const UiStateSnapshotStore = require('../store/ui-state-snapshot');
+const snapshotManager = require('../services/snapshot');
const interpolateVars = require('./network/interpolate-vars');
const { interpolateString } = require('./network/interpolate-string');
const { getEnvVars, getTreePathFromCollectionToItem, mergeVars, parseBruFileMeta, hydrateRequestWithUuid, transformRequestToSaveToFilesystem } = require('../utils/collection');
@@ -79,7 +79,6 @@ const { saveSpecAndUpdateMetadata, cleanupSpecFilesForCollection } = require('./
const environmentSecretsStore = new EnvironmentSecretsStore();
const collectionSecurityStore = new CollectionSecurityStore();
-const uiStateSnapshotStore = new UiStateSnapshotStore();
// size and file count limits to determine whether the bru files in the collection should be loaded asynchronously or not.
const MAX_COLLECTION_SIZE_IN_MB = 20;
@@ -1076,16 +1075,24 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
ipcMain.handle('renderer:open-multiple-collections', async (e, collectionPaths, options = {}) => {
if (watcher && mainWindow) {
- await openCollectionsByPathname(mainWindow, watcher, collectionPaths);
+ const result = await openCollectionsByPathname(mainWindow, watcher, collectionPaths, options);
if (options.workspacePath) {
const { setCollectionWorkspace } = require('../store/process-env');
const { generateUidBasedOnHash } = require('../utils/common');
- for (const collectionPath of collectionPaths) {
+ for (const collectionPath of result?.opened || []) {
const collectionUid = generateUidBasedOnHash(collectionPath);
setCollectionWorkspace(collectionUid, options.workspacePath);
}
}
+
+ return result;
}
+
+ return {
+ opened: [],
+ failed: [],
+ invalid: []
+ };
});
ipcMain.handle('renderer:set-collection-workspace', (event, collectionUid, workspacePath) => {
@@ -1630,9 +1637,9 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
}
});
- ipcMain.handle('renderer:update-ui-state-snapshot', (event, { type, data }) => {
+ ipcMain.handle('renderer:update-ui-state-snapshot', async (event, { type, data }) => {
try {
- uiStateSnapshotStore.update({ type, data });
+ await snapshotManager.update({ type, data });
} catch (error) {
throw new Error(error.message);
}
diff --git a/packages/bruno-electron/src/ipc/snapshot.js b/packages/bruno-electron/src/ipc/snapshot.js
new file mode 100644
index 000000000..0272ed904
--- /dev/null
+++ b/packages/bruno-electron/src/ipc/snapshot.js
@@ -0,0 +1,18 @@
+const { ipcMain } = require('electron');
+const snapshotManager = require('../services/snapshot');
+
+const registerSnapshotIpc = () => {
+ ipcMain.handle('renderer:snapshot:get', async () => {
+ return snapshotManager.getSnapshot();
+ });
+
+ ipcMain.handle('renderer:snapshot:get-tabs', async (event, collectionPathname, workspacePathname) => {
+ return snapshotManager.getTabs(collectionPathname, workspacePathname);
+ });
+
+ ipcMain.handle('renderer:snapshot:save', async (event, data) => {
+ return snapshotManager.saveSnapshot(data);
+ });
+};
+
+module.exports = registerSnapshotIpc;
diff --git a/packages/bruno-electron/src/services/snapshot/index.js b/packages/bruno-electron/src/services/snapshot/index.js
new file mode 100644
index 000000000..9f305bba5
--- /dev/null
+++ b/packages/bruno-electron/src/services/snapshot/index.js
@@ -0,0 +1,565 @@
+const Store = require('electron-store');
+const fs = require('fs');
+const path = require('path');
+const yup = require('yup');
+
+const SNAPSHOT_VERSION = '0.0.1';
+const ENV_FILE_EXTENSIONS = ['bru', 'yml', 'yaml'];
+
+const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value);
+
+const normalizeLookupKey = (pathname) => {
+ if (typeof pathname !== 'string' || !pathname) {
+ return null;
+ }
+
+ return path.normalize(pathname);
+};
+
+const buildWorkspaceCollectionLookupKey = (workspacePathname, collectionPathname) => {
+ const normalizedCollectionPath = normalizeLookupKey(collectionPathname);
+ if (!normalizedCollectionPath) {
+ return null;
+ }
+
+ const normalizedWorkspacePath = normalizeLookupKey(workspacePathname || '');
+ return `${normalizedWorkspacePath || ''}::${normalizedCollectionPath}`;
+};
+
+const tabSchema = yup.object({
+ type: yup.string().required(),
+ accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'type']).required(),
+ pathname: yup.string().nullable(),
+ permanent: yup.boolean().required(),
+ name: yup.string().optional(),
+ exampleName: yup.string().optional(),
+ request: yup.object({
+ tab: yup.string(),
+ width: yup.number().nullable(),
+ height: yup.number().nullable()
+ }).optional(),
+ response: yup.object({
+ tab: yup.string(),
+ format: yup.string().nullable(),
+ viewTab: yup.string().nullable()
+ }).optional()
+});
+
+const activeTabSchema = yup.object({
+ accessor: yup.string().oneOf(['pathname', 'pathname::exampleName', 'type']).required(),
+ value: yup.string().required()
+});
+
+const collectionSchema = yup.object({
+ pathname: yup.string().required(),
+ workspacePathname: yup.string().optional().default(''),
+ environment: yup.object({
+ collection: yup.string(),
+ global: yup.string()
+ }).required(),
+ environmentPath: yup.string().optional(),
+ selectedEnvironment: yup.string().optional(),
+ isOpen: yup.boolean().required(),
+ isMounted: yup.boolean().required(),
+ activeTab: activeTabSchema.nullable(),
+ tabs: yup.array().of(tabSchema).required()
+});
+
+const workspaceSchema = yup.object({
+ pathname: yup.string().required(),
+ environment: yup.string().defined(),
+ lastActiveCollectionPathname: yup.string().nullable(),
+ sorting: yup.mixed().oneOf(['alphabetical', 'reverseAlphabetical', 'default']),
+ collections: yup.array().of(yup.string()).optional()
+});
+
+const devToolsSchema = yup.object({
+ open: yup.boolean().required(),
+ activeTab: yup.string().defined(),
+ tabs: yup.object().shape({
+ console: yup.object().shape({}).optional(),
+ network: yup.object().shape({}).optional(),
+ performance: yup.object().shape({}).optional(),
+ terminal: yup.object().shape({}).optional()
+ })
+});
+
+const snapshotSchema = yup.object({
+ version: yup.string().defined(),
+ activeWorkspacePath: yup.string().nullable(),
+ extras: yup.object({
+ devTools: devToolsSchema.required()
+ }).required(),
+ workspaces: yup.array().of(workspaceSchema).required(),
+ collections: yup.array().of(collectionSchema).required()
+});
+
+const emptySnapshot = {
+ version: SNAPSHOT_VERSION,
+ activeWorkspacePath: null,
+ extras: {
+ devTools: {
+ open: false
+ }
+ },
+ workspaces: [],
+ collections: []
+};
+
+class SnapshotManager {
+ constructor() {
+ this.store = new Store({
+ name: 'ui-state-snapshot',
+ clearInvalidConfig: true,
+ defaults: emptySnapshot
+ });
+ this._lookupCache = null;
+ }
+
+ // --- Reads ---
+
+ getSnapshot() {
+ return this._normalizeSnapshot(this.store.store);
+ }
+
+ getCollection(pathname) {
+ const normalizedPath = normalizeLookupKey(pathname);
+ if (!normalizedPath) {
+ return null;
+ }
+
+ const { collectionsByPath } = this._buildLookupMaps();
+ const collection = collectionsByPath[normalizedPath];
+
+ if (!collection) {
+ return null;
+ }
+
+ return {
+ pathname: collection.pathname,
+ workspacePathname: collection.workspacePathname,
+ environment: collection.environment,
+ environmentPath: collection.environmentPath,
+ selectedEnvironment: collection.selectedEnvironment,
+ isOpen: collection.isOpen,
+ isMounted: collection.isMounted
+ };
+ }
+
+ getTabs(collectionPathname, workspacePathname = null) {
+ const normalizedPath = normalizeLookupKey(collectionPathname);
+ if (!normalizedPath) {
+ return null;
+ }
+
+ const { tabsByCollectionPath, tabsByWorkspaceAndCollectionPath } = this._buildLookupMaps();
+ const workspaceCollectionKey = buildWorkspaceCollectionLookupKey(workspacePathname, collectionPathname);
+
+ let tabsEntry = workspaceCollectionKey ? tabsByWorkspaceAndCollectionPath[workspaceCollectionKey] : null;
+ if (!tabsEntry) {
+ tabsEntry = tabsByCollectionPath[normalizedPath];
+ }
+
+ if (!tabsEntry) {
+ return null;
+ }
+
+ return {
+ activeTab: tabsEntry.activeTab,
+ tabs: tabsEntry.tabs
+ };
+ }
+
+ // --- Writes ---
+
+ saveSnapshot(data) {
+ try {
+ const normalizedSnapshot = this._normalizeSnapshot(data);
+ snapshotSchema.validateSync(normalizedSnapshot, { strict: false });
+ this.store.store = normalizedSnapshot;
+ this._lookupCache = null;
+ return true;
+ } catch (err) {
+ console.error('Failed to save snapshot:', err.message);
+ return false;
+ }
+ }
+
+ setCollection(pathname, data) {
+ const normalizedPath = normalizeLookupKey(pathname);
+ if (!normalizedPath) {
+ return;
+ }
+
+ const snapshot = this._normalizeSnapshot(this.store.store);
+ const existingCollection = snapshot.collections.find((collection) => normalizeLookupKey(collection.pathname) === normalizedPath);
+
+ const mergedCollection = {
+ ...(existingCollection || {}),
+ ...(isObject(data) ? data : {}),
+ environment: {
+ ...(isObject(existingCollection?.environment) ? existingCollection.environment : {}),
+ ...(isObject(data?.environment) ? data.environment : {})
+ },
+ activeTab: data?.activeTab ?? existingCollection?.activeTab,
+ tabs: data?.tabs ?? existingCollection?.tabs,
+ environmentPath: data?.environmentPath ?? existingCollection?.environmentPath,
+ selectedEnvironment: data?.selectedEnvironment ?? existingCollection?.selectedEnvironment
+ };
+
+ const normalizedCollection = this._normalizeCollectionEntry(pathname, mergedCollection);
+ const collectionIndex = snapshot.collections.findIndex((collection) => normalizeLookupKey(collection.pathname) === normalizedPath);
+
+ if (collectionIndex === -1) {
+ snapshot.collections.push(normalizedCollection);
+ } else {
+ snapshot.collections[collectionIndex] = normalizedCollection;
+ }
+
+ this.store.store = snapshot;
+ this._lookupCache = null;
+ }
+
+ async update({ type, data }) {
+ switch (type) {
+ case 'COLLECTION_ENVIRONMENT':
+ await this.updateCollectionEnvironment(data || {});
+ break;
+ default:
+ break;
+ }
+ }
+
+ async updateCollectionEnvironment({ collectionPath, environmentPath, selectedEnvironment }) {
+ if (!collectionPath) {
+ return;
+ }
+
+ const existingCollection = this.getCollection(collectionPath) || {};
+ const existingEnvironment = isObject(existingCollection.environment) ? existingCollection.environment : {};
+ const incomingEnvironmentRef = environmentPath === undefined ? selectedEnvironment : environmentPath;
+ const normalizedEnvironmentPath = await this._normalizeCollectionEnvironmentRefAsync(collectionPath, incomingEnvironmentRef);
+
+ this.setCollection(collectionPath, {
+ workspacePathname: typeof existingCollection.workspacePathname === 'string' ? existingCollection.workspacePathname : '',
+ environment: {
+ collection: normalizedEnvironmentPath,
+ global: typeof existingEnvironment.global === 'string' ? existingEnvironment.global : ''
+ },
+ environmentPath: normalizedEnvironmentPath,
+ selectedEnvironment: typeof selectedEnvironment === 'string' ? selectedEnvironment : '',
+ isOpen: typeof existingCollection.isOpen === 'boolean' ? existingCollection.isOpen : false,
+ isMounted: typeof existingCollection.isMounted === 'boolean' ? existingCollection.isMounted : false
+ });
+ }
+
+ _normalizeSnapshot(snapshot = {}) {
+ return {
+ version: snapshot.version ?? SNAPSHOT_VERSION,
+ activeWorkspacePath: typeof snapshot.activeWorkspacePath === 'string' ? snapshot.activeWorkspacePath : null,
+ extras: {
+ devTools: this._normalizeDevTools(snapshot?.extras?.devTools)
+ },
+ workspaces: this._normalizeWorkspaceList(snapshot.workspaces),
+ collections: this._normalizeCollectionList(snapshot.collections, snapshot.tabs)
+ };
+ }
+
+ _normalizeDevTools(devTools = {}) {
+ const devToolKeys = [
+ 'console',
+ 'network',
+ 'performance',
+ 'terminal'
+ ];
+
+ const _snapshotEntry = {
+ open: typeof devTools?.open === 'boolean' ? devTools.open : false,
+ activeTab: devTools.activeTab,
+ tabs: {}
+ };
+
+ devToolKeys.forEach((key) => {
+ if (key in devTools) {
+ _snapshotEntry.tabs[key] = devTools.tabs[key];
+ }
+ });
+
+ return _snapshotEntry;
+ }
+
+ _normalizeWorkspaceList(workspaces) {
+ const workspaceMap = new Map();
+
+ if (Array.isArray(workspaces)) {
+ workspaces.forEach((workspace) => {
+ if (!isObject(workspace) || typeof workspace.pathname !== 'string') {
+ return;
+ }
+
+ const normalizedWorkspace = this._normalizeWorkspaceEntry(workspace.pathname, workspace);
+ const normalizedPath = normalizeLookupKey(workspace.pathname);
+
+ if (normalizedPath) {
+ workspaceMap.set(normalizedPath, normalizedWorkspace);
+ }
+ });
+ } else if (isObject(workspaces)) {
+ Object.entries(workspaces).forEach(([workspacePathname, workspace]) => {
+ const normalizedWorkspace = this._normalizeWorkspaceEntry(workspacePathname, workspace);
+ const normalizedPath = normalizeLookupKey(workspacePathname);
+
+ if (normalizedPath) {
+ workspaceMap.set(normalizedPath, normalizedWorkspace);
+ }
+ });
+ }
+
+ return [...workspaceMap.values()];
+ }
+
+ _normalizeWorkspaceEntry(pathname, workspace = {}) {
+ const collections = this._normalizeCollectionPathList(workspace.collections);
+
+ return {
+ pathname,
+ environment: typeof workspace.environment === 'string' ? workspace.environment : '',
+ lastActiveCollectionPathname: typeof workspace.lastActiveCollectionPathname === 'string'
+ ? workspace.lastActiveCollectionPathname
+ : null,
+ sorting: typeof workspace.sorting === 'string' ? workspace.sorting : 'default',
+ collections
+ };
+ }
+
+ _normalizeCollectionPathList(collectionPaths) {
+ if (!Array.isArray(collectionPaths)) {
+ return [];
+ }
+
+ const dedupedPaths = new Map();
+
+ collectionPaths.forEach((collectionPath) => {
+ const rawPath = typeof collectionPath === 'string'
+ ? collectionPath
+ : (isObject(collectionPath) && typeof collectionPath.path === 'string' ? collectionPath.path : null);
+
+ if (!rawPath) {
+ return;
+ }
+
+ const normalizedPath = normalizeLookupKey(rawPath);
+ if (!normalizedPath || dedupedPaths.has(normalizedPath)) {
+ return;
+ }
+
+ dedupedPaths.set(normalizedPath, rawPath);
+ });
+
+ return [...dedupedPaths.values()];
+ }
+
+ _normalizeCollectionList(collections, tabs) {
+ const collectionMap = new Map();
+
+ if (Array.isArray(collections)) {
+ collections.forEach((collection) => {
+ if (!isObject(collection) || typeof collection.pathname !== 'string') {
+ return;
+ }
+
+ const normalizedCollection = this._normalizeCollectionEntry(collection.pathname, collection);
+ const normalizedPath = normalizeLookupKey(collection.pathname);
+ const workspaceCollectionKey = buildWorkspaceCollectionLookupKey(
+ normalizedCollection.workspacePathname,
+ normalizedCollection.pathname
+ );
+
+ if (workspaceCollectionKey) {
+ collectionMap.set(workspaceCollectionKey, normalizedCollection);
+ } else if (normalizedPath) {
+ collectionMap.set(normalizedPath, normalizedCollection);
+ }
+ });
+
+ return [...collectionMap.values()];
+ }
+
+ const collectionEntries = collections ?? [];
+ const tabsEntries = tabs ?? [];
+ const collectionPathnames = new Set([...collectionEntries, ...tabsEntries]);
+
+ collectionPathnames.forEach((collectionPathname) => {
+ const normalizedCollection = this._normalizeCollectionEntry(
+ collectionPathname,
+ collectionEntries[collectionPathname],
+ tabsEntries[collectionPathname]
+ );
+ const normalizedPath = normalizeLookupKey(collectionPathname);
+
+ if (normalizedPath) {
+ collectionMap.set(normalizedPath, normalizedCollection);
+ }
+ });
+
+ return [...collectionMap.values()];
+ }
+
+ _normalizeCollectionEntry(pathname, collection = {}, tabsEntry = {}) {
+ const environment = isObject(collection.environment) ? collection.environment : {};
+ const tabsEntryObject = isObject(tabsEntry) ? tabsEntry : {};
+ const collectionEnvironmentRef = environment.collection ?? collection.environmentPath ?? collection.selectedEnvironment;
+ const normalizedEnvironmentPath = this._normalizeCollectionEnvironmentRef(pathname, collectionEnvironmentRef);
+
+ const tabs = Array.isArray(tabsEntryObject.tabs)
+ ? tabsEntryObject.tabs.filter((tab) => isObject(tab))
+ : (Array.isArray(collection.tabs) ? collection.tabs.filter((tab) => isObject(tab)) : []);
+
+ return {
+ pathname,
+ workspacePathname: typeof collection.workspacePathname === 'string' ? collection.workspacePathname : '',
+ environment: {
+ collection: normalizedEnvironmentPath,
+ global: typeof environment.global === 'string' ? environment.global : ''
+ },
+ environmentPath: normalizedEnvironmentPath,
+ selectedEnvironment: typeof collection.selectedEnvironment === 'string' ? collection.selectedEnvironment : '',
+ isOpen: typeof collection.isOpen === 'boolean' ? collection.isOpen : false,
+ isMounted: typeof collection.isMounted === 'boolean' ? collection.isMounted : false,
+ activeTab: this._normalizeActiveTab(tabsEntryObject.activeTab ?? collection.activeTab),
+ tabs
+ };
+ }
+
+ _normalizeCollectionEnvironmentRef(collectionPath, environmentRef) {
+ if (typeof environmentRef !== 'string') {
+ return '';
+ }
+
+ const trimmedRef = environmentRef.trim();
+ if (!trimmedRef) {
+ return '';
+ }
+
+ if (path.isAbsolute(trimmedRef)) {
+ return path.normalize(trimmedRef);
+ }
+
+ return trimmedRef;
+ }
+
+ async _normalizeCollectionEnvironmentRefAsync(collectionPath, environmentRef) {
+ if (typeof environmentRef !== 'string') {
+ return '';
+ }
+
+ const trimmedRef = environmentRef.trim();
+ if (!trimmedRef) {
+ return '';
+ }
+
+ if (path.isAbsolute(trimmedRef)) {
+ return path.normalize(trimmedRef);
+ }
+
+ const resolvedEnvironmentPath = await this._resolveEnvironmentPathByNameAsync(collectionPath, trimmedRef);
+ if (resolvedEnvironmentPath) {
+ return resolvedEnvironmentPath;
+ }
+
+ return trimmedRef;
+ }
+
+ async _resolveEnvironmentPathByNameAsync(collectionPath, environmentName) {
+ if (typeof collectionPath !== 'string' || !collectionPath || typeof environmentName !== 'string' || !environmentName) {
+ return null;
+ }
+
+ const environmentsDir = path.join(collectionPath, 'environments');
+ try {
+ await fs.promises.access(environmentsDir, fs.constants.F_OK);
+ } catch {
+ return null;
+ }
+
+ for (const extension of ENV_FILE_EXTENSIONS) {
+ const environmentFilePath = path.join(environmentsDir, `${environmentName}.${extension}`);
+ try {
+ await fs.promises.access(environmentFilePath, fs.constants.F_OK);
+ return path.normalize(environmentFilePath);
+ } catch {
+ // keep checking other supported extensions
+ }
+ }
+
+ return null;
+ }
+
+ _normalizeActiveTab(activeTab) {
+ if (!isObject(activeTab)) {
+ return null;
+ }
+
+ if (!['pathname', 'pathname::exampleName', 'type'].includes(activeTab.accessor) || typeof activeTab.value !== 'string') {
+ return null;
+ }
+
+ return {
+ accessor: activeTab.accessor,
+ value: activeTab.value
+ };
+ }
+
+ _buildLookupMaps() {
+ if (this._lookupCache) {
+ return this._lookupCache;
+ }
+
+ const normalizedSnapshot = this._normalizeSnapshot(this.store.store);
+ const workspacesByPath = {};
+ const collectionsByPath = {};
+ const tabsByCollectionPath = {};
+ const tabsByWorkspaceAndCollectionPath = {};
+
+ normalizedSnapshot.workspaces.forEach((workspace) => {
+ const normalizedPath = normalizeLookupKey(workspace.pathname);
+ if (!normalizedPath) {
+ return;
+ }
+
+ workspacesByPath[normalizedPath] = workspace;
+ });
+
+ normalizedSnapshot.collections.forEach((collection) => {
+ const normalizedPath = normalizeLookupKey(collection.pathname);
+ if (!normalizedPath) {
+ return;
+ }
+
+ collectionsByPath[normalizedPath] = collection;
+ tabsByCollectionPath[normalizedPath] = {
+ activeTab: collection.activeTab,
+ tabs: collection.tabs
+ };
+
+ const workspaceCollectionKey = buildWorkspaceCollectionLookupKey(collection.workspacePathname, collection.pathname);
+ if (workspaceCollectionKey) {
+ tabsByWorkspaceAndCollectionPath[workspaceCollectionKey] = {
+ activeTab: collection.activeTab,
+ tabs: collection.tabs
+ };
+ }
+ });
+
+ this._lookupCache = {
+ workspacesByPath,
+ collectionsByPath,
+ tabsByCollectionPath,
+ tabsByWorkspaceAndCollectionPath
+ };
+
+ return this._lookupCache;
+ }
+}
+
+module.exports = new SnapshotManager();
diff --git a/packages/bruno-electron/src/store/ui-state-snapshot.js b/packages/bruno-electron/src/store/ui-state-snapshot.js
deleted file mode 100644
index 565b952ab..000000000
--- a/packages/bruno-electron/src/store/ui-state-snapshot.js
+++ /dev/null
@@ -1,60 +0,0 @@
-const Store = require('electron-store');
-
-class UiStateSnapshotStore {
- constructor() {
- this.store = new Store({
- name: 'ui-state-snapshot',
- clearInvalidConfig: true
- });
- }
-
- getCollections() {
- return this.store.get('collections') || [];
- }
-
- saveCollections(collections) {
- this.store.set('collections', collections);
- }
-
- getCollectionByPathname({ pathname }) {
- let collections = this.getCollections();
-
- let collection = collections.find((c) => c?.pathname === pathname);
- if (!collection) {
- collection = { pathname };
- collections.push(collection);
- this.saveCollections(collections);
- }
-
- return collection;
- }
-
- setCollectionByPathname({ collection }) {
- let collections = this.getCollections();
-
- collections = collections.filter((c) => c?.pathname !== collection.pathname);
- collections.push({ ...collection });
- this.saveCollections(collections);
-
- return collection;
- }
-
- updateCollectionEnvironment({ collectionPath, environmentName }) {
- const collection = this.getCollectionByPathname({ pathname: collectionPath });
- collection.selectedEnvironment = environmentName;
- this.setCollectionByPathname({ collection });
- }
-
- update({ type, data }) {
- switch (type) {
- case 'COLLECTION_ENVIRONMENT':
- const { collectionPath, environmentName } = data;
- this.updateCollectionEnvironment({ collectionPath, environmentName });
- break;
- default:
- break;
- }
- }
-}
-
-module.exports = UiStateSnapshotStore;
diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js
index bbb549163..515cfbd5c 100644
--- a/packages/bruno-schema/src/collections/index.js
+++ b/packages/bruno-schema/src/collections/index.js
@@ -28,7 +28,8 @@ const environmentSchema = Yup.object({
uid: uidSchema,
name: Yup.string().min(1).required('name is required'),
variables: Yup.array().of(environmentVariablesSchema).required('variables are required'),
- color: Yup.string().nullable().optional()
+ color: Yup.string().nullable().optional(),
+ pathname: Yup.string().nullable()
})
.noUnknown(true)
.strict();
diff --git a/tests/snapshots/basic.spec.ts b/tests/snapshots/basic.spec.ts
new file mode 100644
index 000000000..b84a9cc92
--- /dev/null
+++ b/tests/snapshots/basic.spec.ts
@@ -0,0 +1,801 @@
+import path from 'path';
+import fs from 'fs';
+import { test, expect, closeElectronApp, type Page } from '../../playwright';
+import {
+ createCollection,
+ createRequest,
+ openRequest,
+ openCollection,
+ switchWorkspace,
+ selectRequestPaneTab
+} from '../utils/page';
+import { buildCommonLocators } from '../utils/page/locators';
+
+/**
+ * Helper: read the snapshot JSON from the user data directory.
+ * electron-store saves it as `ui-state-snapshot.json`.
+ */
+const readSnapshot = (userDataPath: string) => {
+ const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
+ if (!fs.existsSync(snapshotPath)) return null;
+ return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
+};
+
+const clickCollectionsSortAction = async (page: Page, times: number = 1) => {
+ for (let i = 0; i < times; i += 1) {
+ await page.getByTestId('collections-header-actions-menu').click();
+ await page.getByTestId('collections-header-actions-menu-sort').click();
+ }
+};
+
+const expectSidebarCollectionOrder = async (page: Page, expectedNames: string[]) => {
+ const rows = page.getByTestId('sidebar-collection-row');
+
+ await expect.poll(async () => {
+ const actualNames: string[] = [];
+ const count = await rows.count();
+
+ for (let i = 0; i < count; i += 1) {
+ const name = await rows.nth(i).locator('#sidebar-collection-name').textContent();
+ if (name) {
+ actualNames.push(name.trim());
+ }
+ }
+
+ return actualNames;
+ }).toEqual(expectedNames);
+};
+
+const expectSnapshotWorkspaceSortings = async (userDataPath: string, expectedSortings: string[]) => {
+ await expect.poll(() => {
+ const snapshot = readSnapshot(userDataPath);
+ const sortings = Array.isArray(snapshot?.workspaces)
+ ? snapshot.workspaces.map((workspace) => workspace?.sorting).filter(Boolean)
+ : [];
+
+ return sortings.sort();
+ }).toEqual([...expectedSortings].sort());
+};
+
+// ─── Tab Persistence ────────────────────────────────────────────────────────
+
+test.describe('Snapshot: Tab Persistence', () => {
+ test('open tabs are restored after app restart in the same order', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-tabs-order');
+ 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 two requests and open both', async () => {
+ await createCollection(page, 'TestCol', colPath);
+ await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await createRequest(page, 'ReqBeta', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true });
+ await openRequest(page, 'TestCol', 'ReqBeta', { 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 restored in order', 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);
+ // Wait for snapshot hydration to restore tabs
+ await expect(locators.tabs.requestTab('ReqAlpha')).toBeVisible({ timeout: 15000 });
+ await expect(locators.tabs.requestTab('ReqBeta')).toBeVisible({ timeout: 10000 });
+
+ // Verify order: ReqAlpha before ReqBeta
+ const tabs = page2.locator('.request-tab .tab-label');
+ const tabTexts = await tabs.allTextContents();
+ const alphaIndex = tabTexts.findIndex((t) => t.includes('ReqAlpha'));
+ const betaIndex = tabTexts.findIndex((t) => t.includes('ReqBeta'));
+ expect(alphaIndex).toBeGreaterThanOrEqual(0);
+ expect(betaIndex).toBeGreaterThan(alphaIndex);
+
+ await closeElectronApp(app2);
+ });
+ });
+
+ test('active tab is remembered after restart', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-active-tab');
+ 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 two requests and focus ReqAlpha', async () => {
+ await createCollection(page, 'TestCol', colPath);
+ await createRequest(page, 'ReqAlpha', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await createRequest(page, 'ReqBeta', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'TestCol', 'ReqBeta', { persist: true });
+ await openRequest(page, 'TestCol', 'ReqAlpha', { persist: true });
+
+ const locators = buildCommonLocators(page);
+ await expect(locators.tabs.activeRequestTab()).toContainText('ReqAlpha');
+ });
+
+ await test.step('Close and restart app', async () => {
+ await page.waitForTimeout(2000);
+ await closeElectronApp(app);
+ });
+
+ await test.step('Verify ReqAlpha is the active 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.activeRequestTab()).toContainText('ReqAlpha', { timeout: 10000 });
+
+ await closeElectronApp(app2);
+ });
+ });
+
+ test('closed tab stays closed after restart', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-closed-tab');
+ 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 two requests, open both, close one', async () => {
+ await createCollection(page, 'TestCol', colPath);
+ await createRequest(page, 'ReqKeep', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await createRequest(page, 'ReqClose', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'TestCol', 'ReqKeep', { persist: true });
+ await openRequest(page, 'TestCol', 'ReqClose', { persist: true });
+
+ const locators = buildCommonLocators(page);
+ await locators.tabs.closeTab('ReqClose').click({ force: true });
+ await expect(locators.tabs.requestTab('ReqClose')).not.toBeVisible();
+ });
+
+ await test.step('Close and restart app', async () => {
+ await page.waitForTimeout(2000);
+ await closeElectronApp(app);
+ });
+
+ await test.step('Verify ReqClose is not restored', 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('ReqKeep')).toBeVisible({ timeout: 10000 });
+ await expect(locators.tabs.requestTab('ReqClose')).not.toBeVisible();
+
+ await closeElectronApp(app2);
+ });
+ });
+
+ test('request pane tab selection persists after restart', async ({ launchElectronApp, createTmpDir }) => {
+ test.setTimeout(60000);
+ const userDataPath = await createTmpDir('snap-pane-tab');
+ 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 request and switch to Headers tab', async () => {
+ await createCollection(page, 'TestCol', colPath);
+ await createRequest(page, 'Req1', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'TestCol', 'Req1', { persist: true });
+ await selectRequestPaneTab(page, 'Headers');
+ });
+
+ await test.step('Close and restart app', async () => {
+ await page.waitForTimeout(2000);
+ await closeElectronApp(app);
+ });
+
+ await test.step('Verify Headers tab is still selected', 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);
+ // The active collection's tabs should be auto-restored by switchWorkspace
+ await expect(locators.tabs.requestTab('Req1')).toBeVisible({ timeout: 15000 });
+ // Click the request tab to make it active and show its pane
+ await locators.tabs.requestTab('Req1').click({ force: true });
+
+ // The active request pane tab should be Headers
+ const requestPane = page2.locator('.request-pane > .px-4');
+ await expect(requestPane).toBeVisible({ timeout: 5000 });
+ const headersTab = requestPane.locator('.tabs').getByRole('tab', { name: 'Headers' });
+ await expect(headersTab).toHaveClass(/active/, { timeout: 5000 });
+
+ await closeElectronApp(app2);
+ });
+ });
+});
+
+// ─── Workspace State ────────────────────────────────────────────────────────
+
+test.describe('Snapshot: Workspace State', () => {
+ test('active workspace is remembered after restart', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-active-ws');
+ const workspaceBPath = await createTmpDir('workspace-b');
+ const WORKSPACE_YML = [
+ 'opencollection: 1.0.0',
+ 'info:',
+ ' name: WorkspaceB',
+ ' type: workspace',
+ 'collections:',
+ 'specs: []',
+ 'docs: \'\'',
+ ''
+ ].join('\n');
+ fs.writeFileSync(path.join(workspaceBPath, 'workspace.yml'), WORKSPACE_YML);
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await test.step('Open WorkspaceB and switch to it', async () => {
+ await app.evaluate(
+ ({ dialog }, targetPath: string) => {
+ (dialog as any).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('Close and restart app', async () => {
+ // Wait for debounced snapshot save to flush
+ await page.waitForTimeout(2000);
+ await closeElectronApp(app);
+ });
+
+ await test.step('Verify WorkspaceB is still active', async () => {
+ const app2 = await launchElectronApp({ userDataPath });
+ const page2 = await app2.firstWindow();
+ await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await expect(page2.getByTestId('workspace-name')).toHaveText('WorkspaceB', { timeout: 10000 });
+
+ await closeElectronApp(app2);
+ });
+ });
+
+ test('workspace collection sorting persists across workspace switches and restart', async ({ launchElectronApp, createTmpDir }) => {
+ test.setTimeout(90000);
+ const userDataPath = await createTmpDir('snap-ws-collection-sorting');
+
+ const defaultColZPath = await createTmpDir('default-col-zulu');
+ const defaultColAPath = await createTmpDir('default-col-alpha');
+ const secondWorkspacePath = await createTmpDir('workspace-sorting-b');
+ const secondColMPath = await createTmpDir('ws2-col-middle');
+ const secondColAPath = await createTmpDir('ws2-col-alpha');
+
+ const WORKSPACE_YML = [
+ 'opencollection: 1.0.0',
+ 'info:',
+ ' name: WorkspaceB',
+ ' type: workspace',
+ 'collections:',
+ 'specs: []',
+ 'docs: \'\'',
+ ''
+ ].join('\n');
+ fs.writeFileSync(path.join(secondWorkspacePath, 'workspace.yml'), WORKSPACE_YML);
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await test.step('Create collections in default workspace and set A-Z sort', async () => {
+ await createCollection(page, 'Zulu', defaultColZPath);
+ await createCollection(page, 'Alpha', defaultColAPath);
+
+ await expectSidebarCollectionOrder(page, ['Zulu', 'Alpha']);
+
+ await clickCollectionsSortAction(page, 1);
+ await expectSidebarCollectionOrder(page, ['Alpha', 'Zulu']);
+ await expectSnapshotWorkspaceSortings(userDataPath, ['alphabetical']);
+ });
+
+ await test.step('Open second workspace and set Z-A sort', async () => {
+ await app.evaluate(
+ ({ dialog }, targetPath: string) => {
+ (dialog as any).showOpenDialog = () =>
+ Promise.resolve({ canceled: false, filePaths: [targetPath] });
+ },
+ secondWorkspacePath
+ );
+
+ 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 createCollection(page, 'Middle', secondColMPath);
+ await createCollection(page, 'AlphaWS2', secondColAPath);
+
+ await expectSidebarCollectionOrder(page, ['Middle', 'AlphaWS2']);
+
+ await clickCollectionsSortAction(page, 1);
+ await expectSidebarCollectionOrder(page, ['AlphaWS2', 'Middle']);
+
+ await clickCollectionsSortAction(page, 1);
+ await expectSidebarCollectionOrder(page, ['Middle', 'AlphaWS2']);
+ await expectSnapshotWorkspaceSortings(userDataPath, ['alphabetical', 'reverseAlphabetical']);
+ });
+
+ await test.step('Switch back and forth, verify per-workspace sort restore', async () => {
+ await switchWorkspace(page, 'My Workspace');
+ await expectSidebarCollectionOrder(page, ['Alpha', 'Zulu']);
+
+ await switchWorkspace(page, 'WorkspaceB');
+ await expectSidebarCollectionOrder(page, ['Middle', 'AlphaWS2']);
+ });
+
+ await test.step('Restart app and verify per-workspace sort still persists', async () => {
+ await page.waitForTimeout(2000);
+ await closeElectronApp(app);
+
+ const app2 = await launchElectronApp({ userDataPath });
+ const page2 = await app2.firstWindow();
+ await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await expect(page2.getByTestId('workspace-name')).toHaveText('WorkspaceB', { timeout: 10000 });
+ await expectSidebarCollectionOrder(page2, ['Middle', 'AlphaWS2']);
+
+ await switchWorkspace(page2, 'My Workspace');
+ await expectSidebarCollectionOrder(page2, ['Alpha', 'Zulu']);
+
+ await closeElectronApp(app2);
+ });
+ });
+
+ test('each workspace remembers its own active collection', async ({ launchElectronApp, createTmpDir }) => {
+ test.setTimeout(60000);
+ const userDataPath = await createTmpDir('snap-ws-active-col');
+ const colAPath = await createTmpDir('col-a');
+ const colBPath = await createTmpDir('col-b');
+ const workspaceBPath = await createTmpDir('workspace-b');
+ const WORKSPACE_YML = [
+ 'opencollection: 1.0.0',
+ 'info:',
+ ' name: WorkspaceB',
+ ' type: workspace',
+ 'collections:',
+ 'specs: []',
+ 'docs: \'\'',
+ ''
+ ].join('\n');
+ fs.writeFileSync(path.join(workspaceBPath, 'workspace.yml'), WORKSPACE_YML);
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await test.step('Create ColA with request in default workspace', async () => {
+ await createCollection(page, 'ColA', colAPath);
+ await createRequest(page, 'ReqA', 'ColA', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'ColA', 'ReqA', { persist: true });
+ });
+
+ await test.step('Open WorkspaceB', async () => {
+ await app.evaluate(
+ ({ dialog }, targetPath: string) => {
+ (dialog as any).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 with request in WorkspaceB', async () => {
+ await createCollection(page, 'ColB', colBPath);
+ await createRequest(page, 'ReqB', 'ColB', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'ColB', 'ReqB', { persist: true });
+ });
+
+ await test.step('Switch back to default workspace and verify ColA tabs restored', async () => {
+ // Wait for snapshot to save before switching
+ await page.waitForTimeout(2000);
+ await switchWorkspace(page, 'My Workspace');
+ // Wait for collections to load, then click collection to mount and restore tabs
+ const locators = buildCommonLocators(page);
+ await expect(locators.sidebar.collection('ColA')).toBeVisible({ timeout: 10000 });
+ await openCollection(page, 'ColA');
+ await expect(locators.tabs.requestTab('ReqA')).toBeVisible({ timeout: 15000 });
+ });
+
+ await test.step('Switch to WorkspaceB and verify ColB tabs restored', async () => {
+ await page.waitForTimeout(2000);
+ await switchWorkspace(page, 'WorkspaceB');
+ const locators = buildCommonLocators(page);
+ await expect(locators.sidebar.collection('ColB')).toBeVisible({ timeout: 10000 });
+ await openCollection(page, 'ColB');
+ await expect(locators.tabs.requestTab('ReqB')).toBeVisible({ timeout: 15000 });
+ });
+
+ await closeElectronApp(app);
+ });
+});
+
+// ─── Collection State ───────────────────────────────────────────────────────
+
+test.describe('Snapshot: Collection State', () => {
+ test('collection expanded state persists after restart', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-col-expanded');
+ 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 open a request (expands it)', async () => {
+ await createCollection(page, 'TestCol', colPath);
+ await createRequest(page, 'Req1', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'TestCol', 'Req1', { persist: true });
+
+ // Verify collection is expanded (request is visible in sidebar)
+ const locators = buildCommonLocators(page);
+ await expect(locators.sidebar.request('Req1')).toBeVisible();
+ });
+
+ await test.step('Close and restart app', async () => {
+ await page.waitForTimeout(2000);
+ await closeElectronApp(app);
+ });
+
+ await test.step('Verify collection is still expanded', 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);
+ // The active collection should be expanded, showing items in sidebar
+ await expect(locators.sidebar.request('Req1')).toBeVisible({ timeout: 10000 });
+
+ await closeElectronApp(app2);
+ });
+ });
+});
+
+// ─── Multi-Workspace Tab Isolation ──────────────────────────────────────────
+
+test.describe('Snapshot: Multi-Workspace Tab Isolation', () => {
+ test('tabs from workspace A do not leak into workspace B after restart', async ({ launchElectronApp, createTmpDir }) => {
+ test.setTimeout(60000);
+ const userDataPath = await createTmpDir('snap-tab-isolation');
+ const colAPath = await createTmpDir('col-a');
+ const colBPath = await createTmpDir('col-b');
+ const workspaceBPath = await createTmpDir('workspace-b');
+ const WORKSPACE_YML = [
+ 'opencollection: 1.0.0',
+ 'info:',
+ ' name: WorkspaceB',
+ ' type: workspace',
+ 'collections:',
+ 'specs: []',
+ 'docs: \'\'',
+ ''
+ ].join('\n');
+ fs.writeFileSync(path.join(workspaceBPath, 'workspace.yml'), WORKSPACE_YML);
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await test.step('Create ReqA in default workspace', async () => {
+ await createCollection(page, 'ColA', colAPath);
+ await createRequest(page, 'ReqA', 'ColA', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'ColA', 'ReqA', { persist: true });
+ });
+
+ await test.step('Switch to WorkspaceB and create ReqB', async () => {
+ await app.evaluate(
+ ({ dialog }, targetPath: string) => {
+ (dialog as any).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 createCollection(page, 'ColB', colBPath);
+ await createRequest(page, 'ReqB', 'ColB', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'ColB', 'ReqB', { 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 WorkspaceB tabs do not show ReqA', async () => {
+ const app2 = await launchElectronApp({ userDataPath });
+ const page2 = await app2.firstWindow();
+ await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ // App should restore to WorkspaceB (last active)
+ await expect(page2.getByTestId('workspace-name')).toHaveText('WorkspaceB', { timeout: 10000 });
+
+ const locators = buildCommonLocators(page2);
+ // Wait for collection to load, then click to bring its tabs into view
+ await expect(locators.sidebar.collection('ColB')).toBeVisible({ timeout: 10000 });
+ await openCollection(page2, 'ColB');
+ await expect(locators.tabs.requestTab('ReqB')).toBeVisible({ timeout: 10000 });
+ await expect(locators.tabs.requestTab('ReqA')).not.toBeVisible();
+
+ await test.step('Switch to default workspace and verify ReqA, not ReqB', async () => {
+ await switchWorkspace(page2, 'My Workspace');
+ // Wait for collections to load in the workspace
+ await expect(locators.sidebar.collection('ColA')).toBeVisible({ timeout: 10000 });
+ await openCollection(page2, 'ColA');
+ await expect(locators.tabs.requestTab('ReqA')).toBeVisible({ timeout: 15000 });
+ await expect(locators.tabs.requestTab('ReqB')).not.toBeVisible();
+ });
+
+ await closeElectronApp(app2);
+ });
+ });
+
+ test('same collection in two workspaces keeps tabs isolated after restart', async ({ launchElectronApp, createTmpDir }) => {
+ test.setTimeout(90000);
+
+ const userDataPath = await createTmpDir('snap-tab-isolation-shared-col');
+ const sharedColPath = await createTmpDir('shared-col');
+ const workspaceBPath = await createTmpDir('workspace-b-shared-col');
+
+ const WORKSPACE_YML = [
+ 'opencollection: 1.0.0',
+ 'info:',
+ ' name: WorkspaceB',
+ ' type: workspace',
+ 'collections:',
+ 'specs: []',
+ 'docs: \'\'',
+ ''
+ ].join('\n');
+ fs.writeFileSync(path.join(workspaceBPath, 'workspace.yml'), WORKSPACE_YML);
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await test.step('Create shared collection in default workspace and open ReqA', async () => {
+ await createCollection(page, 'SharedCol', sharedColPath);
+ await createRequest(page, 'ReqA', 'SharedCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await createRequest(page, 'ReqB', 'SharedCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'SharedCol', 'ReqA', { persist: true });
+ });
+
+ const sharedCollectionPath = path.join(sharedColPath, 'SharedCol');
+
+ await test.step('Open WorkspaceB and add the same collection path', async () => {
+ await app.evaluate(
+ ({ dialog }, targetPath: string) => {
+ (dialog as any).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 app.evaluate(
+ ({ dialog }, targetPath: string) => {
+ (dialog as any).showOpenDialog = () =>
+ Promise.resolve({ canceled: false, filePaths: [targetPath] });
+ },
+ sharedCollectionPath
+ );
+
+ await page.getByTestId('collections-header-add-menu').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Open collection' }).click();
+
+ const locators = buildCommonLocators(page);
+ await expect(locators.sidebar.collection('SharedCol')).toBeVisible({ timeout: 10000 });
+ });
+
+ await test.step('In WorkspaceB open ReqB', async () => {
+ await openCollection(page, 'SharedCol');
+ await openRequest(page, 'SharedCol', 'ReqB', { persist: true });
+ });
+
+ await test.step('Close and restart app', async () => {
+ // snapshot saving is done in the background at 1000ms debounce
+ await page.waitForTimeout(2000);
+ await closeElectronApp(app);
+ });
+
+ await test.step('Verify tab isolation for same collection across workspaces', async () => {
+ const app2 = await launchElectronApp({ userDataPath });
+ const page2 = await app2.firstWindow();
+ await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await expect(page2.getByTestId('workspace-name')).toHaveText('WorkspaceB', { timeout: 10000 });
+
+ const locators = buildCommonLocators(page2);
+ await expect(locators.sidebar.collection('SharedCol')).toBeVisible({ timeout: 10000 });
+ await openCollection(page2, 'SharedCol');
+ await expect(locators.tabs.requestTab('ReqB')).toBeVisible({ timeout: 10000 });
+ await expect(locators.tabs.requestTab('ReqA')).not.toBeVisible();
+
+ await switchWorkspace(page2, 'My Workspace');
+ await expect(locators.sidebar.collection('SharedCol')).toBeVisible({ timeout: 10000 });
+ await openCollection(page2, 'SharedCol');
+ await expect(locators.tabs.requestTab('ReqA')).toBeVisible({ timeout: 15000 });
+ await expect(locators.tabs.requestTab('ReqB')).not.toBeVisible();
+
+ await closeElectronApp(app2);
+ });
+ });
+});
+
+// ─── DevTools State ─────────────────────────────────────────────────────────
+
+test.describe('Snapshot: DevTools State', () => {
+ test('devtools open state and active tab persist after restart', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-devtools');
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ await test.step('Open devtools and switch to Performance tab', async () => {
+ const devToolsButton = page.locator('button[data-trigger="dev-tools"]');
+ await devToolsButton.click();
+ await expect(page.locator('.console-header')).toBeVisible();
+
+ const performanceTab = page.locator('.console-tab').filter({ hasText: 'Performance' });
+ await performanceTab.click();
+ await expect(performanceTab).toHaveClass(/active/);
+ });
+
+ 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 devtools is open with Performance tab active', async () => {
+ const app2 = await launchElectronApp({ userDataPath });
+ const page2 = await app2.firstWindow();
+ await page2.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ // DevTools should be open
+ await expect(page2.locator('.console-header')).toBeVisible({ timeout: 10000 });
+
+ // Performance tab should be active
+ const performanceTab = page2.locator('.console-tab').filter({ hasText: 'Performance' });
+ await expect(performanceTab).toHaveClass(/active/, { timeout: 5000 });
+
+ await closeElectronApp(app2);
+ });
+ });
+});
+
+// ─── Edge Cases ─────────────────────────────────────────────────────────────
+
+test.describe('Snapshot: Edge Cases', () => {
+ test('fresh app launch with no snapshot file loads cleanly', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-fresh');
+
+ // Ensure no snapshot file exists
+ const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
+ if (fs.existsSync(snapshotPath)) {
+ fs.unlinkSync(snapshotPath);
+ }
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ // App should load the default workspace without errors
+ await expect(page.getByTestId('workspace-name')).toBeVisible({ timeout: 10000 });
+
+ await closeElectronApp(app);
+ });
+
+ test('corrupt snapshot file results in graceful recovery', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-corrupt');
+
+ // Write invalid JSON to snapshot file
+ const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
+ fs.writeFileSync(snapshotPath, '{ invalid json !!!', 'utf-8');
+
+ const app = await launchElectronApp({ userDataPath });
+ const page = await app.firstWindow();
+ await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
+
+ // App should recover and show default workspace
+ await expect(page.getByTestId('workspace-name')).toBeVisible({ timeout: 10000 });
+
+ await closeElectronApp(app);
+ });
+});
+
+// ─── Snapshot File Structure ────────────────────────────────────────────────
+
+test.describe('Snapshot: File Structure', () => {
+ test('snapshot file uses array-based structure with correct keys', async ({ launchElectronApp, createTmpDir }) => {
+ const userDataPath = await createTmpDir('snap-structure');
+ 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 open a request', async () => {
+ await createCollection(page, 'TestCol', colPath);
+ await createRequest(page, 'Req1', 'TestCol', { url: 'https://echo.usebruno.com', method: 'GET' });
+ await openRequest(page, 'TestCol', 'Req1', { persist: true });
+ });
+
+ // Give the debounced save time to flush
+ const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
+ await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true);
+
+ await test.step('Close app and inspect snapshot file', async () => {
+ await closeElectronApp(app);
+
+ const snapshot = readSnapshot(userDataPath);
+ expect(snapshot).not.toBeNull();
+
+ // Top-level keys should exist
+ expect(snapshot).toHaveProperty('activeWorkspacePath');
+ expect(snapshot).toHaveProperty('extras');
+ expect(snapshot).toHaveProperty('extras.devTools');
+ expect(snapshot).toHaveProperty('workspaces');
+ expect(snapshot).toHaveProperty('collections');
+
+ // workspaces and collections should be arrays
+ expect(Array.isArray(snapshot.workspaces)).toBe(true);
+ expect(Array.isArray(snapshot.collections)).toBe(true);
+
+ // There should be at least one workspace
+ expect(snapshot.workspaces.length).toBeGreaterThanOrEqual(1);
+
+ // Workspace should have expected shape
+ const firstWorkspace = snapshot.workspaces[0];
+ expect(firstWorkspace).toHaveProperty('pathname');
+ expect(firstWorkspace).toHaveProperty('lastActiveCollectionPathname');
+ expect(firstWorkspace).toHaveProperty('sorting');
+ expect(firstWorkspace).toHaveProperty('collections');
+ expect(Array.isArray(firstWorkspace.collections)).toBe(true);
+
+ // There should be at least one collection
+ expect(snapshot.collections.length).toBeGreaterThanOrEqual(1);
+
+ // Collection should have expected shape
+ const firstCollection = snapshot.collections[0];
+ expect(firstCollection).toHaveProperty('pathname');
+ expect(firstCollection).toHaveProperty('workspacePathname');
+ expect(firstCollection).toHaveProperty('environment');
+ expect(firstCollection).toHaveProperty('environmentPath');
+ expect(firstCollection).toHaveProperty('selectedEnvironment');
+ expect(firstCollection).toHaveProperty('isOpen');
+ expect(firstCollection).toHaveProperty('isMounted');
+ expect(firstCollection).toHaveProperty('tabs');
+ expect(Array.isArray(firstCollection.tabs)).toBe(true);
+ expect(firstCollection).toHaveProperty('activeTab');
+ });
+ });
+});