diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js index 95ff2ebdc..f7325de98 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js @@ -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 () => { diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js index c14800892..9f5d46f8b 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js @@ -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; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js index d98673038..07d25b8c9 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js @@ -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); diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js index d1ebb4af1..86779b229 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js @@ -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); 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 197464eb8..e33c19202 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -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 { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js b/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js index 1cfea9261..c311b3c96 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js @@ -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;