@@ -264,7 +275,7 @@ const SyncReviewPage = ({
title="Updated in Spec"
type="spec-modified"
endpoints={specUpdatedEndpoints}
- defaultExpanded={hasConflicts}
+ defaultExpanded={true}
expandableLayout
subtitle="The spec has updates for these endpoints"
headerExtra={conflictCount > 0 ? (
@@ -300,7 +311,7 @@ const SyncReviewPage = ({
title="New in Spec"
type="added"
endpoints={specAddedEndpoints}
- defaultExpanded={false}
+ defaultExpanded={true}
expandableLayout
subtitle="New endpoints from the spec"
collectionUid={collectionUid}
@@ -324,7 +335,7 @@ const SyncReviewPage = ({
title="Removed from Spec"
type="removed"
endpoints={specRemovedEndpoints}
- defaultExpanded={false}
+ defaultExpanded={true}
expandableLayout
subtitle="These endpoints are in your collection but not in the spec"
collectionUid={collectionUid}
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js
index 1e7bf328f..d4e8a4763 100644
--- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js
@@ -3,11 +3,12 @@ import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab } from 'utils/collections';
-import { clearCollectionState, setCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
+import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
import { formatIpcError } from 'utils/common/error';
+import { countEndpoints } from '../utils';
const useOpenAPISync = (collection) => {
const dispatch = useDispatch();
@@ -29,6 +30,18 @@ const useOpenAPISync = (collection) => {
const isConfigured = !!openApiSyncConfig?.sourceUrl;
+ const updateStoredSpec = (spec) => {
+ setStoredSpec(spec);
+ if (spec) {
+ dispatch(setStoredSpecMeta({
+ collectionUid: collection.uid,
+ title: spec.info?.title || null,
+ version: spec.info?.version || null,
+ endpointCount: countEndpoints(spec)
+ }));
+ }
+ };
+
// Flatten collection items including nested items in folders
const allHttpItems = useMemo(() => {
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
@@ -113,6 +126,7 @@ const useOpenAPISync = (collection) => {
setFileNotFound(false);
setSpecDrift(null);
setRemoteDrift(null);
+ setCollectionDrift(null);
try {
const { ipcRenderer } = window;
@@ -136,7 +150,7 @@ const useOpenAPISync = (collection) => {
setSpecDrift(result);
if (result.storedSpec) {
- setStoredSpec(result.storedSpec);
+ updateStoredSpec(result.storedSpec);
}
// Update Redux store so toolbar status stays in sync
@@ -211,11 +225,11 @@ const useOpenAPISync = (collection) => {
try {
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
if (specType !== 'openapi') {
- setError('The URL does not point to a valid OpenAPI specification');
+ setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
} catch {
- setError('The URL does not point to a valid OpenAPI specification');
+ setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
}
@@ -328,11 +342,11 @@ const useOpenAPISync = (collection) => {
try {
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
} catch {
- toast.error('The URL does not point to a valid OpenAPI specification');
+ toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
if (specType !== 'openapi') {
- toast.error('The URL does not point to a valid OpenAPI specification');
+ toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
}
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/index.js
index 91335c23c..2a23d6dd4 100644
--- a/packages/bruno-app/src/components/OpenAPISyncTab/index.js
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/index.js
@@ -3,7 +3,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuid } from 'uuid';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
-import { IconClock } from '@tabler/icons';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import StyledWrapper from './StyledWrapper';
import OpenAPISyncHeader from './OpenAPISyncHeader';
@@ -150,38 +149,16 @@ const OpenAPISyncTab = ({ collection }) => {
{activeTab === 'collection-changes' && (
- {collectionDrift && !collectionDrift.noStoredSpec ? (
-
- ) : !isDriftLoading && !isLoading && (
- <>
-
-
-
-
- {openApiSyncConfig?.lastSyncDate
- ? 'Last synced spec is required to show collection changes. Restore the latest spec from the source to track future changes..'
- : 'Collection changes will be available after the initial sync'}
-
-
-
-
-
-
{openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}
-
{openApiSyncConfig?.lastSyncDate
- ? 'Restore the latest spec from the source to track future changes..'
- : 'Once you sync your collection with the spec, changes will appear here.'}
-
-
- >
- )}
+
)}
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/utils.js b/packages/bruno-app/src/components/OpenAPISyncTab/utils.js
new file mode 100644
index 000000000..259124410
--- /dev/null
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/utils.js
@@ -0,0 +1,16 @@
+const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
+
+/**
+ * Count the number of HTTP endpoints in an OpenAPI spec.
+ * Returns null if the spec has no paths (e.g. spec is null/undefined).
+ */
+export const countEndpoints = (spec) => {
+ if (!spec?.paths) return null;
+ let count = 0;
+ for (const path of Object.values(spec.paths)) {
+ for (const key of Object.keys(path)) {
+ if (HTTP_METHODS.includes(key.toLowerCase())) count++;
+ }
+ }
+ return count;
+};
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 d10cf54f8..1cfea9261 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js
@@ -9,7 +9,9 @@ const initialState = {
// Last poll timestamp
lastPollTime: null,
// Map of collectionUid -> { activeTab, expandedSections, expandedRows }
- tabUiState: {}
+ tabUiState: {},
+ // Map of collectionUid -> { title, version, endpointCount } (persists across tab navigations)
+ storedSpecMeta: {}
};
export const openapiSyncSlice = createSlice({
@@ -33,6 +35,11 @@ export const openapiSyncSlice = createSlice({
const { collectionUid } = action.payload;
delete state.collectionUpdates[collectionUid];
delete state.tabUiState[collectionUid];
+ delete state.storedSpecMeta[collectionUid];
+ },
+ setStoredSpecMeta: (state, action) => {
+ const { collectionUid, title, version, endpointCount } = action.payload;
+ state.storedSpecMeta[collectionUid] = { title, version, endpointCount };
},
setPollingEnabled: (state, action) => {
state.pollingEnabled = action.payload;
@@ -116,7 +123,8 @@ export const {
toggleRowExpanded,
setLastPollTime,
setReviewDecision,
- setReviewDecisions
+ setReviewDecisions,
+ setStoredSpecMeta
} = openapiSyncSlice.actions;
// Lightweight thunk for polling — only checks hash, no deep comparison
@@ -199,4 +207,9 @@ 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;
diff --git a/packages/bruno-electron/src/ipc/openapi-sync.js b/packages/bruno-electron/src/ipc/openapi-sync.js
index 67fdfe020..e2c0076d8 100644
--- a/packages/bruno-electron/src/ipc/openapi-sync.js
+++ b/packages/bruno-electron/src/ipc/openapi-sync.js
@@ -1316,35 +1316,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
}
- if (addNewRequests && diff.added?.length > 0 && newCollection) {
- for (const endpoint of diff.added) {
- const normalizedPath = normalizeUrlPath(endpoint.path);
- const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
- const newItem = result?.item;
-
- if (newItem) {
- // Check if endpoint already exists in collection (prevents overwriting user customizations)
- const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
-
- if (existingFile) {
- const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
- const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
- await writeFile(existingFile.filePath, content);
- } else {
- // Truly new — create file in the appropriate folder
- let targetFolder = collectionPath;
- if (result.folderName && groupBy === 'tags') {
- targetFolder = await ensureTagFolder(collectionPath, result.folderName, format);
- }
-
- const requestContent = await stringifyRequestViaWorker(newItem, { format });
- const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`;
- await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
- }
- }
- }
- }
-
+ // Remove endpoints before adding new ones to avoid filename collisions
+ // (e.g., when a path is renamed but the summary stays the same, both generate the same filename)
if (removeDeletedRequests && diff.removed?.length > 0) {
const findAndRemoveRequest = (dirPath) => {
if (!fs.existsSync(dirPath)) return;
@@ -1389,6 +1362,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
// Remove local-only endpoints (endpoints in collection but not in spec)
+ // Verify file content before deleting — the file may have been modified by the user
+ // between the drift scan and sync execution, making the pre-computed filePath stale.
if (localOnlyToRemove?.length > 0) {
for (const endpoint of localOnlyToRemove) {
if (endpoint.filePath) {
@@ -1398,7 +1373,49 @@ const registerOpenAPISyncIpc = (mainWindow) => {
continue;
}
if (fs.existsSync(fullPath)) {
- fs.unlinkSync(fullPath);
+ try {
+ const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru';
+ const content = fs.readFileSync(fullPath, 'utf8');
+ const parsed = parseRequest(content, { format: fileFormat });
+ if (parsed?.request) {
+ const fileMethod = parsed.request.method?.toUpperCase();
+ const fileUrlPath = normalizeUrlPath(parsed.request.url);
+ if (fileMethod === endpoint.method && fileUrlPath === endpoint.path) {
+ fs.unlinkSync(fullPath);
+ }
+ }
+ } catch (err) {
+ console.error(`[OpenAPI Sync] Error verifying file before removal ${endpoint.filePath}:`, err);
+ }
+ }
+ }
+ }
+ }
+
+ if (addNewRequests && diff.added?.length > 0 && newCollection) {
+ for (const endpoint of diff.added) {
+ const normalizedPath = normalizeUrlPath(endpoint.path);
+ const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
+ const newItem = result?.item;
+
+ if (newItem) {
+ // Check if endpoint already exists in collection (prevents overwriting user customizations)
+ const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
+
+ if (existingFile) {
+ const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
+ const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
+ await writeFile(existingFile.filePath, content);
+ } else {
+ // Truly new — create file in the appropriate folder
+ let targetFolder = collectionPath;
+ if (result.folderName && groupBy === 'tags') {
+ targetFolder = await ensureTagFolder(collectionPath, result.folderName, format);
+ }
+
+ const requestContent = await stringifyRequestViaWorker(newItem, { format });
+ const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`;
+ await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
}
}
}