Skip to content

Commit b1f08b5

Browse files
fix(snapshot): active workspace restoration from snapshot (usebruno#8392)
* feat(workspaces): implement active workspace restoration from snapshot - Added a new action to restore the active workspace from a snapshot. - Integrated workspace restoration into IPC events for improved user experience. - Refactored workspace loading logic to prioritize valid workspaces and handle invalid paths. - Introduced utility functions for workspace path validation and normalization. - Added tests to ensure correct functionality of workspace restoration and validation logic. * feat(snapshot): refactor snapshot serialization and add environment preservation logic - Moved snapshot serialization logic to a new file for better organization. - Implemented environment preservation for collections during snapshot serialization. - Added utility functions to handle collection environment checks and serialization. - Introduced tests to validate the new serialization behavior and environment preservation. - Enhanced existing tests to cover edge cases for unmounted collections and environment persistence. * Add tests for missing and invalid environment files * feat(snapshot): refactor devTools normalization and add saveToken to IPC handler * refactor(snapshot): remove saveToken from IPC handler for renderer:snapshot:save * feat(snapshot): implement collection deduplication logic in SnapshotManager and add corresponding tests * feat(snapshot): enhance workspace-specific environment handling for shared collections * feat(snapshot): remove sourceWorkspacePathname from snapshot serialization and related tests * feat(snapshot): remove sourceWorkspacePathname from SnapshotManager and add tests for shared collection environment restoration * feat(snapshot): refactor collection deduplication tests and enhance snapshot seeding utility * feat(snapshot): enhance workspace path normalization and improve snapshot environment tests --------- Co-authored-by: engineering-bruno <engineering@usebruno.com>
1 parent b6187b4 commit b1f08b5

23 files changed

Lines changed: 1751 additions & 423 deletions

File tree

packages/bruno-app/src/providers/App/useIpcEvents.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUi
3030
import {
3131
workspaceOpenedEvent,
3232
workspaceConfigUpdatedEvent,
33-
hydrateSnapshotForOpenedCollection
33+
hydrateSnapshotForOpenedCollection,
34+
restoreActiveWorkspaceFromSnapshot
3435
} from 'providers/ReduxStore/slices/workspaces/actions';
3536
import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces';
3637
import toast from 'react-hot-toast';
@@ -116,7 +117,6 @@ const useIpcEvents = () => {
116117
dispatch(apiSpecChangeFileEvent({ data: val }));
117118
}
118119
};
119-
120120
ipcRenderer.invoke('renderer:ready');
121121

122122
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
@@ -135,6 +135,10 @@ const useIpcEvents = () => {
135135
dispatch(workspaceOpenedEvent(workspacePath, workspaceUid, workspaceConfig));
136136
});
137137

138+
const removeWorkspacesReadyListener = ipcRenderer.on('main:workspaces-ready', () => {
139+
dispatch(restoreActiveWorkspaceFromSnapshot());
140+
});
141+
138142
const removeWorkspaceConfigUpdatedListener = ipcRenderer.on('main:workspace-config-updated', (workspacePath, workspaceUid, workspaceConfig) => {
139143
dispatch(workspaceConfigUpdatedEvent(workspacePath, workspaceUid, workspaceConfig));
140144
});
@@ -382,6 +386,7 @@ const useIpcEvents = () => {
382386
removeApiSpecTreeUpdateListener();
383387
removeOpenCollectionListener();
384388
removeOpenWorkspaceListener();
389+
removeWorkspacesReadyListener();
385390
removeWorkspaceConfigUpdatedListener();
386391
removeWorkspaceEnvironmentAddedListener();
387392
removeWorkspaceEnvironmentChangedListener();

packages/bruno-app/src/providers/ReduxStore/middlewares/snapshot/middleware.js

Lines changed: 2 additions & 244 deletions
Original file line numberDiff line numberDiff line change
@@ -5,255 +5,14 @@
55
* Uses debouncing to prevent excessive disk writes.
66
*/
77

8-
import {
9-
SAVE_TRIGGERS,
10-
shouldExcludeTab,
11-
serializeTab,
12-
serializeActiveTab,
13-
getCollectionEnvironmentPath,
14-
hydrateSnapshotLookups
15-
} from 'utils/snapshot';
16-
import { normalizePath } from 'utils/common/path';
17-
import { TAB_IDENFIERS as DEVTOOL_TABS } from 'providers/ReduxStore/slices/logs';
8+
import { SAVE_TRIGGERS } from 'utils/snapshot';
9+
import { serializeSnapshot } from './serializeSnapshot';
1810

1911
const { ipcRenderer } = window;
2012

21-
// Debounce timer reference
2213
let saveTimer = null;
2314
const DEBOUNCE_MS = 1000;
2415

25-
const COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING = {
26-
default: 'default',
27-
alphabetical: 'alphabetical',
28-
reverseAlphabetical: 'reverseAlphabetical'
29-
};
30-
31-
const normalizeCollectionSortOrder = (sortOrder) => {
32-
return COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING[sortOrder] || 'default';
33-
};
34-
35-
const normalizeWorkspaceSorting = (sorting) => {
36-
return COLLECTION_SORT_ORDER_BY_WORKSPACE_SORTING[sorting] || 'default';
37-
};
38-
39-
const getWorkspaceCollectionSnapshotKey = (workspacePathname, collectionPathname) => {
40-
const normalizedCollectionPathname = normalizePath(collectionPathname);
41-
if (!normalizedCollectionPathname) {
42-
return '';
43-
}
44-
45-
return `${normalizePath(workspacePathname || '')}::${normalizedCollectionPathname}`;
46-
};
47-
48-
/**
49-
* Serialize the current app state into a snapshot format
50-
* Persists array-based schema and supports lookup hydration.
51-
*/
52-
const serializeSnapshot = async (state) => {
53-
const { workspaces, collections, tabs, logs, globalEnvironments } = state;
54-
const snapshotHydration = state.app?.snapshotHydration;
55-
const activeWorkspaceCollectionSortOrder = normalizeCollectionSortOrder(collections.collectionSortOrder);
56-
57-
// Get existing snapshot to preserve data for collections not currently loaded
58-
let existingSnapshot = null;
59-
try {
60-
existingSnapshot = await ipcRenderer.invoke('renderer:snapshot:get');
61-
} catch (err) {
62-
// Ignore - will create fresh snapshot
63-
}
64-
65-
const existingSnapshotLookups = hydrateSnapshotLookups(existingSnapshot || {});
66-
67-
// Build a set of scratch collection UIDs to exclude
68-
const scratchCollectionUids = new Set(
69-
(workspaces.workspaces || [])
70-
.map((w) => w.scratchCollectionUid)
71-
.filter(Boolean)
72-
);
73-
74-
const activeWorkspace = workspaces.workspaces.find(
75-
(w) => w.uid === workspaces.activeWorkspaceUid
76-
);
77-
78-
const activeWorkspaceCollectionPaths = new Set(
79-
(activeWorkspace?.collections || [])
80-
.map((collection) => collection?.path)
81-
.filter(Boolean)
82-
.map((collectionPath) => normalizePath(collectionPath))
83-
);
84-
85-
const existingDevTools = existingSnapshot?.extras?.devTools ?? {};
86-
87-
const snapshot = {
88-
activeWorkspacePath: activeWorkspace?.pathname || null,
89-
extras: {
90-
devTools: {
91-
open: logs.isConsoleOpen,
92-
activeTab: logs.activeTab ?? existingDevTools.activeTab ?? 'terminal',
93-
tabs: Object.assign(existingDevTools.tabs, {
94-
[logs.activeTab]: {}
95-
})
96-
}
97-
},
98-
workspaces: [],
99-
collections: []
100-
};
101-
102-
// Track which workspace+collection entries we've serialized from Redux
103-
const serializedCollectionKeys = new Set();
104-
105-
(workspaces.workspaces || []).forEach((workspace) => {
106-
if (!workspace.pathname) return;
107-
108-
const workspaceCollectionPaths = (workspace.collections || []).map((c) => c.path).filter(Boolean);
109-
const normalizedWorkspacePaths = workspaceCollectionPaths.map((p) => normalizePath(p));
110-
const isActiveWorkspace = workspace.uid === workspaces.activeWorkspaceUid;
111-
const existingWorkspace = existingSnapshotLookups.workspacesByPath[normalizePath(workspace.pathname)];
112-
113-
// Resolve lastActiveCollectionPathname
114-
let lastActiveCollectionPathname = null;
115-
116-
if (isActiveWorkspace) {
117-
const activeTab = tabs.tabs.find((t) => t.uid === tabs.activeTabUid);
118-
const activeCollection = activeTab
119-
? (collections.collections || []).find((c) => c.uid === activeTab.collectionUid)
120-
: null;
121-
const normalizedPathname = activeCollection?.pathname ? normalizePath(activeCollection.pathname) : null;
122-
123-
lastActiveCollectionPathname = normalizedPathname && normalizedWorkspacePaths.includes(normalizedPathname)
124-
? normalizedPathname
125-
: null;
126-
} else {
127-
// For non-active workspaces, preserve from existing snapshot
128-
lastActiveCollectionPathname = existingWorkspace?.lastActiveCollectionPathname || null;
129-
}
130-
131-
const workspaceSorting = isActiveWorkspace
132-
? activeWorkspaceCollectionSortOrder
133-
: normalizeWorkspaceSorting(existingWorkspace?.sorting);
134-
135-
snapshot.workspaces.push({
136-
pathname: workspace.pathname,
137-
environment: '',
138-
lastActiveCollectionPathname,
139-
sorting: workspaceSorting,
140-
collections: [...workspaceCollectionPaths]
141-
});
142-
});
143-
144-
(collections.collections || []).forEach((collection) => {
145-
// Skip scratch collections and collections without pathname
146-
if (!collection.pathname || scratchCollectionUids.has(collection.uid)) {
147-
return;
148-
}
149-
150-
const normalizedPath = normalizePath(collection.pathname);
151-
152-
// Persist tab state only for the active workspace's collections.
153-
// For non-active workspaces, preserve the last persisted snapshot entries.
154-
if (activeWorkspace && !activeWorkspaceCollectionPaths.has(normalizedPath)) {
155-
return;
156-
}
157-
158-
const workspacePathname = activeWorkspace?.pathname || '';
159-
const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, collection.pathname);
160-
const existingCollection = (collectionSnapshotKey && existingSnapshotLookups.collectionsByWorkspaceAndPath?.[collectionSnapshotKey])
161-
|| existingSnapshotLookups.collectionsByPath?.[normalizedPath]
162-
|| null;
163-
if (collectionSnapshotKey) {
164-
serializedCollectionKeys.add(collectionSnapshotKey);
165-
}
166-
167-
// Get transient directory for this collection to filter transient tabs
168-
const transientDirectory = collections.tempDirectories?.[collection.uid];
169-
170-
// Filter and serialize tabs, excluding transient requests
171-
const collectionTabs = (tabs.tabs || [])
172-
.filter((t) => t.collectionUid === collection.uid && !shouldExcludeTab(t, transientDirectory))
173-
.map((t) => serializeTab(t, collection));
174-
175-
const activeTabInCollection = (tabs.tabs || []).find(
176-
(t) => t.collectionUid === collection.uid && t.uid === tabs.activeTabUid && !shouldExcludeTab(t, transientDirectory)
177-
);
178-
179-
const selectedEnvironment = (collection.environments || []).find((env) => env.uid === collection.activeEnvironmentUid);
180-
const environmentPathFromRedux = getCollectionEnvironmentPath(collection, selectedEnvironment, '');
181-
const selectedEnvironmentFromRedux = selectedEnvironment?.name || '';
182-
const existingEnvironmentPath = existingCollection?.environment?.collection || existingCollection?.environmentPath || '';
183-
const existingSelectedEnvironment = existingCollection?.selectedEnvironment || '';
184-
const shouldPreserveExistingEnvironment = collection.mountStatus !== 'mounted'
185-
&& !environmentPathFromRedux
186-
&& !selectedEnvironmentFromRedux;
187-
const environmentPath = shouldPreserveExistingEnvironment ? existingEnvironmentPath : environmentPathFromRedux;
188-
const selectedEnvironmentName = shouldPreserveExistingEnvironment
189-
? existingSelectedEnvironment
190-
: selectedEnvironmentFromRedux;
191-
192-
snapshot.collections.push({
193-
pathname: collection.pathname,
194-
workspacePathname,
195-
environment: {
196-
collection: environmentPath,
197-
global: globalEnvironments.activeGlobalEnvironmentUid || ''
198-
},
199-
environmentPath,
200-
selectedEnvironment: selectedEnvironmentName,
201-
isOpen: !collection.collapsed,
202-
isMounted: collection.mountStatus === 'mounted',
203-
activeTab: serializeActiveTab(activeTabInCollection, collection),
204-
tabs: collectionTabs
205-
});
206-
});
207-
208-
const pendingHydrationPaths = new Set(
209-
(snapshotHydration?.pendingCollectionPathnames || []).map((pathname) => normalizePath(pathname))
210-
);
211-
212-
// Preserve collections from existing snapshot that aren't currently loaded in Redux
213-
// and collections that are still pending hydration during workspace switch.
214-
const existingCollections = Object.values(existingSnapshotLookups.collectionsByWorkspaceAndPath || {});
215-
const fallbackCollections = Object.values(existingSnapshotLookups.collectionsByPath || {});
216-
217-
(existingCollections.length > 0 ? existingCollections : fallbackCollections).forEach((existingCollection) => {
218-
const normalizedPath = normalizePath(existingCollection.pathname || '');
219-
const workspacePathname = existingCollection.workspacePathname || '';
220-
const collectionSnapshotKey = getWorkspaceCollectionSnapshotKey(workspacePathname, existingCollection.pathname);
221-
const isSerializedCollection = collectionSnapshotKey && serializedCollectionKeys.has(collectionSnapshotKey);
222-
const shouldPreservePendingHydration = pendingHydrationPaths.has(normalizedPath)
223-
&& activeWorkspace?.pathname
224-
&& normalizePath(workspacePathname) === normalizePath(activeWorkspace.pathname);
225-
226-
if (!normalizedPath || (isSerializedCollection && !shouldPreservePendingHydration)) {
227-
return;
228-
}
229-
230-
const existingTabs = (collectionSnapshotKey && existingSnapshotLookups.tabsByWorkspaceAndCollectionPath?.[collectionSnapshotKey])
231-
|| existingSnapshotLookups.tabsByCollectionPath?.[normalizedPath];
232-
233-
snapshot.collections.push({
234-
pathname: existingCollection.pathname,
235-
workspacePathname,
236-
environment: {
237-
collection: existingCollection.environment?.collection || existingCollection.environmentPath || '',
238-
global: existingCollection.environment?.global || ''
239-
},
240-
environmentPath: existingCollection.environment?.collection || existingCollection.environmentPath || '',
241-
selectedEnvironment: existingCollection.selectedEnvironment || '',
242-
isOpen: typeof existingCollection.isOpen === 'boolean' ? existingCollection.isOpen : false,
243-
isMounted: typeof existingCollection.isMounted === 'boolean' ? existingCollection.isMounted : false,
244-
activeTab: existingTabs?.activeTab || existingCollection.activeTab || null,
245-
tabs: Array.isArray(existingTabs?.tabs)
246-
? existingTabs.tabs
247-
: (Array.isArray(existingCollection.tabs) ? existingCollection.tabs : [])
248-
});
249-
});
250-
251-
return snapshot;
252-
};
253-
254-
/**
255-
* Schedule a debounced save of the snapshot
256-
*/
25716
const scheduleSave = (getState) => {
25817
if (saveTimer) {
25918
clearTimeout(saveTimer);
@@ -305,7 +64,6 @@ export const snapshotMiddleware = ({ getState }) => (next) => (action) => {
30564
return result;
30665
}
30766

308-
// Only save if snapshot is ready (app has finished initial loading)
30967
const state = getState();
31068
if (state.app.snapshotReady && SAVE_TRIGGERS.has(action.type)) {
31169
scheduleSave(getState);

0 commit comments

Comments
 (0)