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