mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 14:44:07 +00:00
Centralize OpenAPI sync state in Redux (#7876)
* Centralize OpenAPI sync state in Redux Move per-collection OpenAPI sync state into the Redux slice and update callers. Adds storedSpec and drift maps and reducers (setDrift, clearDrift, setStoredSpec, clearOpenApiSyncTabState), removes the old diff payload from collectionUpdates, and keeps storedSpecMeta. Components and hooks were updated to use inline selectors (state.openapiSync...) instead of exported selectors, and useOpenAPISync was refactored to persist drift/storedSpec to Redux, use the store to read tabs, and call setDrift/setStoredSpec rather than keeping duplicate local state. The collections closeTabs action now clears openapi-sync state for closing openapi-sync tabs so transient state is dropped when tabs are closed. Small variable renames and minor logic adjustments to use the new shape were included. * Enhance useOpenAPISync hook with comments for clarity on store usage. This update clarifies that tabs are read-only within handlers to prevent unnecessary re-renders when using useSelector.
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import {
|
||||
IconCopy,
|
||||
IconDotsVertical,
|
||||
@@ -37,7 +36,7 @@ const OpenAPISyncHeader = ({
|
||||
}
|
||||
}, [sourceUrl, sourceIsLocal, collection.pathname]);
|
||||
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
|
||||
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
|
||||
|
||||
const copyUrl = async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { countEndpoints } from '../utils';
|
||||
import moment from 'moment';
|
||||
@@ -43,7 +42,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
|
||||
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
|
||||
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
|
||||
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
|
||||
const activeError = error || reduxError;
|
||||
|
||||
const version = specMeta?.version;
|
||||
|
||||
@@ -15,7 +15,7 @@ import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRo
|
||||
import ConfirmSyncModal from '../ConfirmSyncModal';
|
||||
import SpecDiffModal from '../SpecDiffModal';
|
||||
import Help from 'components/Help';
|
||||
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { setReviewDecision, setReviewDecisions } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
|
||||
/**
|
||||
* Categorize remoteDrift endpoints using three-way merge.
|
||||
@@ -87,7 +87,7 @@ const SyncReviewPage = ({
|
||||
onApplySync
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabUiState = useSelector(selectTabUiState(collectionUid));
|
||||
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector, useStore } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getDefaultRequestPaneTab } from 'utils/collections';
|
||||
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import {
|
||||
clearCollectionState,
|
||||
setCollectionUpdate,
|
||||
setStoredSpec,
|
||||
setStoredSpecMeta,
|
||||
setDrift
|
||||
} from 'providers/ReduxStore/slices/openapi-sync';
|
||||
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
|
||||
import { isHttpUrl } from 'utils/url/index';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
@@ -19,19 +26,23 @@ const useOpenAPISync = (collection) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [fileNotFound, setFileNotFound] = useState(false);
|
||||
const [specDrift, setSpecDrift] = useState(null);
|
||||
// Collection drift state
|
||||
const [collectionDrift, setCollectionDrift] = useState(null);
|
||||
const [remoteDrift, setRemoteDrift] = useState(null);
|
||||
const [isDriftLoading, setIsDriftLoading] = useState(false);
|
||||
const [storedSpec, setStoredSpec] = useState(null);
|
||||
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const drift = useSelector((state) => state.openapiSync?.drift?.[collection.uid] || null);
|
||||
const specDrift = drift?.specDrift || null;
|
||||
const collectionDrift = drift?.collectionDrift || null;
|
||||
const remoteDrift = drift?.remoteDrift || null;
|
||||
const storedSpec = useSelector((state) => state.openapiSync?.storedSpec?.[collection.uid] || null);
|
||||
|
||||
const updateDrift = (patch) => dispatch(setDrift({ collectionUid: collection.uid, patch }));
|
||||
|
||||
// useStore: tabs are read only inside handlers — useSelector would re-render on every tab change.
|
||||
const store = useStore();
|
||||
|
||||
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
||||
|
||||
const updateStoredSpec = (spec) => {
|
||||
setStoredSpec(spec);
|
||||
dispatch(setStoredSpec({ collectionUid: collection.uid, spec }));
|
||||
dispatch(setStoredSpecMeta({
|
||||
collectionUid: collection.uid,
|
||||
title: spec?.info?.title || null,
|
||||
@@ -72,6 +83,7 @@ const useOpenAPISync = (collection) => {
|
||||
const openEndpointInTab = (endpointId) => {
|
||||
const itemUid = endpointUidMap[endpointId];
|
||||
if (!itemUid) return;
|
||||
const tabs = store.getState().tabs?.tabs || [];
|
||||
const existingTab = tabs.find((t) => t.uid === itemUid);
|
||||
if (existingTab) {
|
||||
dispatch(focusTab({ uid: itemUid }));
|
||||
@@ -86,14 +98,13 @@ const useOpenAPISync = (collection) => {
|
||||
}
|
||||
};
|
||||
|
||||
const prevItemCountRef = useRef(httpItemCount);
|
||||
const isDriftLoadingRef = useRef(false);
|
||||
const specDriftRef = useRef(specDrift);
|
||||
|
||||
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
||||
if (isDriftLoadingRef.current && !clear) return;
|
||||
isDriftLoadingRef.current = true;
|
||||
if (clear) setCollectionDrift(null);
|
||||
if (clear) updateDrift({ collectionDrift: null });
|
||||
setIsDriftLoading(true);
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -102,7 +113,7 @@ const useOpenAPISync = (collection) => {
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
setCollectionDrift(result);
|
||||
updateDrift({ collectionDrift: result, itemCountAtLastFetch: httpItemCount });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading collection drift:', err);
|
||||
@@ -122,9 +133,7 @@ const useOpenAPISync = (collection) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setFileNotFound(false);
|
||||
setSpecDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setCollectionDrift(null);
|
||||
updateDrift({ fetching: true });
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -146,14 +155,13 @@ const useOpenAPISync = (collection) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setSpecDrift(result);
|
||||
updateDrift({ specDrift: result, lastChecked: Date.now() });
|
||||
updateStoredSpec(result.storedSpec || null);
|
||||
|
||||
// Update Redux store so toolbar status stays in sync
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: result.isValid !== false && result.hasChanges,
|
||||
diff: result,
|
||||
error: result.isValid === false ? result.error : null
|
||||
}));
|
||||
|
||||
@@ -167,7 +175,7 @@ const useOpenAPISync = (collection) => {
|
||||
console.error('Error computing remote drift:', remoteComparison.error);
|
||||
setError(remoteComparison.error);
|
||||
} else {
|
||||
setRemoteDrift(remoteComparison);
|
||||
updateDrift({ remoteDrift: remoteComparison });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,24 +189,25 @@ const useOpenAPISync = (collection) => {
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: false,
|
||||
diff: null,
|
||||
error: formatIpcError(err) || 'Failed to check for updates'
|
||||
}));
|
||||
} finally {
|
||||
updateDrift({ fetching: false });
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isConfigured) {
|
||||
if (isConfigured && !drift?.specDrift && !drift?.fetching) {
|
||||
checkForUpdates();
|
||||
}
|
||||
}, [isConfigured]);
|
||||
|
||||
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
|
||||
// Reload drift when the collection's HTTP item count differs from what was recorded at the last fetch.
|
||||
useEffect(() => {
|
||||
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
|
||||
prevItemCountRef.current = httpItemCount;
|
||||
if (!isConfigured) return;
|
||||
const cachedCount = drift?.itemCountAtLastFetch;
|
||||
if (cachedCount !== undefined && cachedCount !== httpItemCount && !drift?.fetching) {
|
||||
loadCollectionDrift();
|
||||
}
|
||||
}, [httpItemCount, isConfigured]);
|
||||
@@ -245,7 +254,7 @@ const useOpenAPISync = (collection) => {
|
||||
});
|
||||
|
||||
if (result.isValid === false) {
|
||||
setSpecDrift(result);
|
||||
updateDrift({ specDrift: result });
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
@@ -263,15 +272,15 @@ const useOpenAPISync = (collection) => {
|
||||
|
||||
// Check if collection already matches the spec
|
||||
if (result.newSpec) {
|
||||
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
const initialDrift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
||||
collectionPath: collection.pathname,
|
||||
compareSpec: result.newSpec
|
||||
});
|
||||
|
||||
const isInSync = !drift.error
|
||||
&& (!drift.missing || drift.missing.length === 0)
|
||||
&& (!drift.modified || drift.modified.length === 0)
|
||||
&& (!drift.localOnly || drift.localOnly.length === 0);
|
||||
const isInSync = !initialDrift.error
|
||||
&& (!initialDrift.missing || initialDrift.missing.length === 0)
|
||||
&& (!initialDrift.modified || initialDrift.modified.length === 0)
|
||||
&& (!initialDrift.localOnly || initialDrift.localOnly.length === 0);
|
||||
|
||||
if (isInSync) {
|
||||
// Collection matches — save spec file silently to complete setup
|
||||
@@ -299,15 +308,12 @@ const useOpenAPISync = (collection) => {
|
||||
deleteSpecFile: true
|
||||
});
|
||||
setSourceUrl('');
|
||||
setSpecDrift(null);
|
||||
setCollectionDrift(null);
|
||||
setRemoteDrift(null);
|
||||
setStoredSpec(null);
|
||||
|
||||
// Clear Redux state for this collection
|
||||
dispatch(clearCollectionState({ collectionUid: collection.uid }));
|
||||
|
||||
// Close the openapi-spec tab if open (spec file no longer exists)
|
||||
const tabs = store.getState().tabs?.tabs || [];
|
||||
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
|
||||
if (specTab) {
|
||||
dispatch(closeTabs({ tabUids: [specTab.uid] }));
|
||||
@@ -337,7 +343,7 @@ const useOpenAPISync = (collection) => {
|
||||
compareSpec: currentSpecDrift.newSpec
|
||||
});
|
||||
if (!remoteComparison.error) {
|
||||
setRemoteDrift(remoteComparison);
|
||||
updateDrift({ remoteDrift: remoteComparison });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reloading remote drift:', err);
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
|
||||
import { each } from 'lodash';
|
||||
import { closeAllCollectionTabs, closeTabs as _closeTabs, focusTab, 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';
|
||||
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
|
||||
@@ -3170,6 +3171,9 @@ export const ensureActiveTabInCurrentWorkspace = () => (dispatch, getState) => {
|
||||
/**
|
||||
* Close tabs and delete any transient request files from the filesystem.
|
||||
* This thunk wraps the closeTabs reducer to handle transient file cleanup automatically.
|
||||
* Also drops openapi-sync redux state (drift, storedSpec, tabUiState) for any
|
||||
* openapi-sync tab that's about to close — collected BEFORE the close so we can
|
||||
* still read the closing tabs' collectionUids from state.
|
||||
*/
|
||||
export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
@@ -3195,6 +3199,10 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
|
||||
}
|
||||
});
|
||||
|
||||
const closingOpenApiSyncCollectionUids = (state.tabs?.tabs || [])
|
||||
.filter((t) => tabUids.includes(t.uid) && t.type === 'openapi-sync' && t.collectionUid)
|
||||
.map((t) => t.collectionUid);
|
||||
|
||||
// Close the tabs first
|
||||
await dispatch(_closeTabs({ tabUids }));
|
||||
|
||||
@@ -3206,6 +3214,11 @@ export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
|
||||
// Dispatch is synchronous; state is already updated by _closeTabs above.
|
||||
await dispatch(ensureActiveTabInCurrentWorkspace());
|
||||
|
||||
// Drop openapi-sync per-collection state (drift, storedSpec, tabUiState) for any closed openapi-sync tabs.
|
||||
for (const collectionUid of closingOpenApiSyncCollectionUids) {
|
||||
dispatch(clearOpenApiSyncTabState({ collectionUid }));
|
||||
}
|
||||
|
||||
// Delete transient files after tabs are closed
|
||||
for (const [tempDir, filePaths] of Object.entries(transientByTempDir)) {
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,9 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
const initialState = {
|
||||
// Map of collectionUid -> { hasUpdates, diff, lastChecked, error }
|
||||
// Map of collectionUid -> { hasUpdates, lastChecked, error }
|
||||
// Lightweight indicator state for the toolbar status badge — fed by
|
||||
// background polling. Full drift data lives in `drift` (this slice).
|
||||
collectionUpdates: {},
|
||||
// Whether App level OpenAPI polling is enabled
|
||||
pollingEnabled: true,
|
||||
@@ -10,8 +12,12 @@ const initialState = {
|
||||
lastPollTime: null,
|
||||
// Map of collectionUid -> { activeTab, expandedSections, expandedRows }
|
||||
tabUiState: {},
|
||||
// Map of collectionUid -> { title, version, endpointCount } (persists across tab navigations)
|
||||
storedSpecMeta: {}
|
||||
// Map of collectionUid -> { title, version, endpointCount }
|
||||
storedSpecMeta: {},
|
||||
// Map of collectionUid -> full parsed OpenAPI spec object
|
||||
storedSpec: {},
|
||||
// Map of collectionUid -> { specDrift, collectionDrift, remoteDrift, fetching, lastChecked }
|
||||
drift: {}
|
||||
};
|
||||
|
||||
export const openapiSyncSlice = createSlice({
|
||||
@@ -19,10 +25,9 @@ export const openapiSyncSlice = createSlice({
|
||||
initialState,
|
||||
reducers: {
|
||||
setCollectionUpdate: (state, action) => {
|
||||
const { collectionUid, hasUpdates, diff, error } = action.payload;
|
||||
const { collectionUid, hasUpdates, error } = action.payload;
|
||||
state.collectionUpdates[collectionUid] = {
|
||||
hasUpdates,
|
||||
diff,
|
||||
error,
|
||||
lastChecked: Date.now()
|
||||
};
|
||||
@@ -36,6 +41,30 @@ export const openapiSyncSlice = createSlice({
|
||||
delete state.collectionUpdates[collectionUid];
|
||||
delete state.tabUiState[collectionUid];
|
||||
delete state.storedSpecMeta[collectionUid];
|
||||
delete state.storedSpec[collectionUid];
|
||||
delete state.drift[collectionUid];
|
||||
},
|
||||
setDrift: (state, action) => {
|
||||
const { collectionUid, patch } = action.payload;
|
||||
state.drift[collectionUid] = { ...state.drift[collectionUid], ...patch };
|
||||
},
|
||||
clearDrift: (state, action) => {
|
||||
const { collectionUid } = action.payload;
|
||||
delete state.drift[collectionUid];
|
||||
},
|
||||
clearOpenApiSyncTabState: (state, action) => {
|
||||
const { collectionUid } = action.payload;
|
||||
delete state.drift[collectionUid];
|
||||
delete state.storedSpec[collectionUid];
|
||||
delete state.tabUiState[collectionUid];
|
||||
},
|
||||
setStoredSpec: (state, action) => {
|
||||
const { collectionUid, spec } = action.payload;
|
||||
if (spec === null || spec === undefined) {
|
||||
delete state.storedSpec[collectionUid];
|
||||
} else {
|
||||
state.storedSpec[collectionUid] = spec;
|
||||
}
|
||||
},
|
||||
setStoredSpecMeta: (state, action) => {
|
||||
const { collectionUid, title, version, endpointCount } = action.payload;
|
||||
@@ -124,7 +153,11 @@ export const {
|
||||
setLastPollTime,
|
||||
setReviewDecision,
|
||||
setReviewDecisions,
|
||||
setStoredSpecMeta
|
||||
setStoredSpec,
|
||||
setStoredSpecMeta,
|
||||
setDrift,
|
||||
clearDrift,
|
||||
clearOpenApiSyncTabState
|
||||
} = openapiSyncSlice.actions;
|
||||
|
||||
// Lightweight thunk for polling — only checks hash, no deep comparison
|
||||
@@ -152,7 +185,6 @@ export const checkCollectionForUpdates = (collection) => async (dispatch) => {
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: result.hasUpdates || false,
|
||||
diff: null,
|
||||
error: result.error || null
|
||||
}));
|
||||
|
||||
@@ -162,7 +194,6 @@ export const checkCollectionForUpdates = (collection) => async (dispatch) => {
|
||||
dispatch(setCollectionUpdate({
|
||||
collectionUid: collection.uid,
|
||||
hasUpdates: false,
|
||||
diff: null,
|
||||
error: error.message
|
||||
}));
|
||||
return null;
|
||||
@@ -202,14 +233,4 @@ export const checkActiveWorkspaceCollectionsForUpdates = () => async (dispatch,
|
||||
dispatch(setLastPollTime(Date.now()));
|
||||
};
|
||||
|
||||
// Selector to get UI state for a specific collection's sync tab
|
||||
export const selectTabUiState = (collectionUid) => (state) => {
|
||||
return state.openapiSync?.tabUiState?.[collectionUid] || {};
|
||||
};
|
||||
|
||||
// Selector for stored spec metadata (title, version, endpointCount)
|
||||
export const selectStoredSpecMeta = (collectionUid) => (state) => {
|
||||
return state.openapiSync?.storedSpecMeta?.[collectionUid] || null;
|
||||
};
|
||||
|
||||
export default openapiSyncSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user