mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: ui state snapshots (#7794)
* feat(snapshot): add session snapshot persistence and restoration - Add snapshot middleware to persist UI state (tabs, workspaces, environments) - Add SnapshotManager service in electron for atomic snapshot storage - Add accessor-based tab serialization using pathname for reliable restoration - Add loading states for tabs while collections are mounting - Add hydrateTabs to restore tabs from snapshots on app load - Add devTools state persistence (console open/height/tab) * fix(snapshot): preserve unloaded collections and fix async serialization Make serializeSnapshot async to fetch existing snapshot before saving, ensuring collections not currently loaded in Redux are preserved. Fix activeTab serialization to pass collection object instead of just UID. * refactor(snapshot): rewrite storage to map-based schema with granular IPC Replace array-based snapshot storage with key-value maps keyed by pathname for O(1) lookups. Separate tabs into their own top-level map, decoupled from collections. - Rewrite SnapshotManager with map-based schema and granular read/write methods (getWorkspace, getTabs, setCollection, removeWorkspace, etc.) - Add 12 granular IPC handlers (renderer:snapshot:*) replacing 4 coarse ones - Update middleware serialization to produce maps; remove activeCollectionUidCache in favor of lastActiveCollectionPathname on workspace objects - Fix mountCollection passing collectionUid instead of collection to restoreTabs - Preserve non-active workspace state from existing snapshot on save * wip * refactor: allow migration of old snapshot * refactor: trim down redundancy * fix: for workspace state * feat: fix for finalised schema * fix: schema cleanup * chore: simplify * chore: wait on hydration to finish * chore: snapshot changes to schema * chore: fix typo * fix: switch schema for saving and restoring extras * chore: correctness changes * chore: add active in the schema check * chore: fix lint * chore: comments * chore: make writes async * fix: sorting and cross workspace shared collection fixes * chore: add version * fix: wait on hydration * chore: fix backward compat for ui-snapshots * chore: dead code removal * Fix optional chaining in snapshot lookup --------- Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { IconLoader2 } from '@tabler/icons';
|
||||
|
||||
const RequestTabPanelLoading = ({ name }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
|
||||
<IconLoader2 className="animate-spin" size={24} strokeWidth={1.5} />
|
||||
<span>Loading {name ? `"${name}"` : 'request'}...</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestTabPanelLoading;
|
||||
@@ -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 <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
|
||||
let item = findItemInCollection(collection, focusedTab.itemUid);
|
||||
if (!item && focusedTab.pathname) {
|
||||
item = findItemInCollectionByPathname(collection, focusedTab.pathname);
|
||||
}
|
||||
return <ResponseExample item={item} collection={collection} example={example} />;
|
||||
|
||||
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 <ResponseExample item={item} collection={collection} example={example} />;
|
||||
}
|
||||
|
||||
const displayName = focusedTab.exampleName || focusedTab.name;
|
||||
if (displayName && isItemsLoading) {
|
||||
return <RequestTabPanelLoading name={displayName} />;
|
||||
}
|
||||
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
|
||||
}
|
||||
|
||||
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 <FolderNotFound folderUid={focusedTab.folderUid} />;
|
||||
let folder = findItemInCollection(collection, focusedTab.folderUid);
|
||||
if (!folder && focusedTab.pathname) {
|
||||
folder = findItemInCollectionByPathname(collection, focusedTab.pathname);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<FolderSettings collection={collection} folder={folder} />
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
if (folder) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<FolderSettings collection={collection} folder={folder} />;
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (focusedTab.name && isItemsLoading) {
|
||||
return <RequestTabPanelLoading name={focusedTab.name} />;
|
||||
}
|
||||
return <FolderNotFound folderUid={focusedTab.folderUid} />;
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'environment-settings') {
|
||||
@@ -380,14 +411,17 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (!item || !item.uid) {
|
||||
return <RequestNotFound itemUid={activeTabUid} />;
|
||||
const showLoading = focusedTab.name && isItemsLoading;
|
||||
return showLoading
|
||||
? <RequestTabPanelLoading name={focusedTab.name} />
|
||||
: <RequestNotFound itemUid={activeTabUid} />;
|
||||
}
|
||||
|
||||
if (item?.partial) {
|
||||
if (item.partial) {
|
||||
return <RequestNotLoaded item={item} collection={collection} />;
|
||||
}
|
||||
|
||||
if (item?.loading) {
|
||||
if (item.loading) {
|
||||
return <RequestIsLoading item={item} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container"
|
||||
@@ -76,7 +100,11 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
{showLoading ? (
|
||||
<RequestTabLoading handleCloseClick={handleCloseClick} name={displayName} />
|
||||
) : (
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="flex items-baseline tab-label">
|
||||
<span className="tab-name" title={name}>{name}</span>
|
||||
</div>
|
||||
<GradientCloseButton onClick={handleCloseClick} hasChanges={false} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestTabLoading;
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
tab.name && isItemsLoading
|
||||
? <RequestTabLoading handleCloseClick={handleCloseClick} name={tab.name} />
|
||||
: <RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
) : tab.type === 'folder-settings' ? (
|
||||
<SpecialTab handleCloseClick={handleCloseFolderSettings} handleDoubleClick={() => 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 (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container"
|
||||
className="flex items-center justify-between tab-container px-2"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
@@ -480,7 +494,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
{showLoading ? (
|
||||
<RequestTabLoading handleCloseClick={handleCloseClick} name={tab.name} />
|
||||
) : (
|
||||
<RequestTabNotFound handleCloseClick={handleCloseClick} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
550
packages/bruno-app/src/utils/snapshot/index.js
Normal file
550
packages/bruno-app/src/utils/snapshot/index.js
Normal file
@@ -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);
|
||||
};
|
||||
389
packages/bruno-app/src/utils/snapshot/index.spec.js
Normal file
389
packages/bruno-app/src/utils/snapshot/index.spec.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
packages/bruno-electron/src/ipc/snapshot.js
Normal file
18
packages/bruno-electron/src/ipc/snapshot.js
Normal file
@@ -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;
|
||||
565
packages/bruno-electron/src/services/snapshot/index.js
Normal file
565
packages/bruno-electron/src/services/snapshot/index.js
Normal file
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
801
tests/snapshots/basic.spec.ts
Normal file
801
tests/snapshots/basic.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user