+ {modifiedCount > 0 && Updated: {modifiedCount}}
{addedCount > 0 && Added: {addedCount}}
- {modifiedCount > 0 && Updated: {modifiedCount}}
{removedCount > 0 && Removed: {removedCount}}
{versionLabel && {versionLabel}}
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js
index 0a3d5af85..066f9e2bd 100644
--- a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js
@@ -2,7 +2,8 @@ import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
IconCheck,
- IconRefresh
+ IconRefresh,
+ IconAlertTriangle
} from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
@@ -35,28 +36,19 @@ const SpecStatusSection = ({
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
}
if (error || specDrift?.isValid === false) {
- return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: [] };
+ return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
}
if (!specDrift) {
return null;
- // TODO: re-enable success banner
- // if (!lastSyncedAt) return null;
- // return {
- // variant: 'success', message: 'Spec is up to date', actions: [],
- // version: storedSpec?.info?.version,
- // lastChecked: moment(lastCheckedAt || lastSyncedAt).fromNow()
- // };
}
if (specDrift.storedSpecMissing) {
if (!lastSyncedAt) {
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
}
- if (specDrift.hasRemoteChanges) {
- return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
- }
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
}
- if (specDrift.hasRemoteChanges) {
+ const hasEndpointUpdates = (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
+ if (hasEndpointUpdates) {
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
@@ -71,7 +63,7 @@ const SpecStatusSection = ({
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
// };
return null;
- }, [isLoading, fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
+ }, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
return (
<>
{bannerState && (
@@ -93,8 +85,8 @@ const SpecStatusSection = ({
{bannerState.changes && (
+ {bannerState.changes.modified > 0 && {bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated}
{bannerState.changes.added > 0 && {bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added}
- {bannerState.changes.modified > 0 && {bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated}
{bannerState.changes.removed > 0 && {bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed}
)}
@@ -113,7 +105,13 @@ const SpecStatusSection = ({
)}
- {specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
+ {(error || fileNotFound || specDrift?.isValid === false) ? (
+
Last Synced Spec not found in storage
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js
index a606d8e97..d98673038 100644
--- a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js
@@ -28,8 +28,18 @@ import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'provide
* - specRemovedEndpoints: removed from spec, still in collection
*/
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
- const specAddedEndpoints = remoteDrift.missing || [];
- const specRemovedEndpoints = remoteDrift.localOnly || [];
+ // Only show endpoints as "New in Spec" if they were actually added to the spec
+ // (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
+ // still exist in both stored and remote spec should not appear here — they belong
+ // in "Collection Changes" only.
+ const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
+ const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
+
+ // Only show endpoints as "Removed from Spec" if they were actually in the stored spec
+ // (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
+ // the spec should not appear here — they belong in "Collection Changes" only.
+ const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
+ const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
// Build lookup sets to determine who changed each modified endpoint
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
@@ -154,10 +164,7 @@ const SyncReviewPage = ({
// Accepted — changes that will be applied
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
- addGroup('Endpoints to update', 'update', [
- ...specUpdatedEndpoints.filter(isAccepted),
- ...localUpdatedEndpoints.filter(isAccepted)
- ]);
+ addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
// Skipped — changes that will be preserved as-is
@@ -167,7 +174,7 @@ const SyncReviewPage = ({
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
return groups;
- }, [specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, decisions]);
+ }, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
const handleConfirmApply = () => {
setShowConfirmation(false);
@@ -187,7 +194,6 @@ const SyncReviewPage = ({
onApplySync({
endpointDecisions: decisions,
- removedIds: [],
localOnlyIds,
// Pass filtered categorized endpoints for performSync to construct the right backend diff
newToCollection: filteredAddedEndpoints,
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js
index d4e8a4763..2edaf6b52 100644
--- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js
@@ -90,6 +90,7 @@ 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;
@@ -328,8 +329,31 @@ const useOpenAPISync = (collection) => {
}
};
- // Reload drift — passed to useEndpointActions so it can refresh after actions
- const reloadDrift = () => loadCollectionDrift({ clear: true });
+ // Keep ref in sync so reloadDrift always reads the latest specDrift
+ specDriftRef.current = specDrift;
+
+ // Reload both drifts — passed to useEndpointActions so it can refresh after actions.
+ // Uses specDriftRef to avoid stale closure over specDrift state.
+ const reloadDrift = async () => {
+ await loadCollectionDrift({ clear: true });
+ // Refresh remoteDrift if we have a remote spec cached from the last check
+ const currentSpecDrift = specDriftRef.current;
+ if (currentSpecDrift?.newSpec) {
+ try {
+ const { ipcRenderer } = window;
+ const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
+ collectionPath: collection.pathname,
+ brunoConfig: collection.brunoConfig,
+ compareSpec: currentSpecDrift.newSpec
+ });
+ if (!remoteComparison.error) {
+ setRemoteDrift(remoteComparison);
+ }
+ } catch (err) {
+ console.error('Error reloading remote drift:', err);
+ }
+ }
+ };
// Save connection settings from the modal
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js
index dac2c893b..11690fe30 100644
--- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js
@@ -14,13 +14,13 @@ const useSyncFlow = ({
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
- const performSync = async (selections = { removedIds: [], localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
+ const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
setShowConfirmModal(false);
setIsSyncing(true);
setError(null);
const {
- removedIds = [], localOnlyIds = [], endpointDecisions: decisions = {},
+ localOnlyIds = [], endpointDecisions: decisions = {},
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
} = selections;
@@ -49,9 +49,7 @@ const useSyncFlow = ({
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
filteredDiff = {
...specDrift,
- removed: removedIds.length > 0
- ? (specDrift?.removed || []).filter((ep) => removedIds.includes(ep.id))
- : []
+ removed: [] // Removals handled via localOnlyToRemove
};
localOnlyToRemove = localOnlyIds.length > 0
@@ -69,7 +67,7 @@ const useSyncFlow = ({
collectionPath: collection.pathname,
sourceUrl: sourceUrl.trim(),
addNewRequests: mode !== 'spec-only',
- removeDeletedRequests: removedIds.length > 0 || localOnlyIds.length > 0,
+ removeDeletedRequests: localOnlyIds.length > 0,
diff: filteredDiff,
localOnlyToRemove,
driftedToReset,
@@ -113,10 +111,21 @@ const useSyncFlow = ({
setPendingSyncMode(null);
};
+ // Only treat endpoints as spec changes if they actually changed in the spec
+ // (not locally-added/deleted endpoints that were never in or removed from the spec)
+ const specAddedIds = useMemo(() => {
+ return new Set((specDrift?.added || []).map((ep) => ep.id));
+ }, [specDrift]);
+
+ const specRemovedIds = useMemo(() => {
+ return new Set((specDrift?.removed || []).map((ep) => ep.id));
+ }, [specDrift]);
+
const handleConfirmModalSync = () => {
- const localOnlyIds = (remoteDrift?.localOnly || []).map((ep) => ep.id);
+ const localOnlyIds = (remoteDrift?.localOnly || [])
+ .filter((ep) => specRemovedIds.has(ep.id))
+ .map((ep) => ep.id);
performSync({
- removedIds: [],
localOnlyIds,
endpointDecisions: {}
}, pendingSyncMode || 'sync');
@@ -125,17 +134,19 @@ const useSyncFlow = ({
const confirmGroups = useMemo(() => {
if (!remoteDrift) return [];
const groups = [];
- if (remoteDrift.missing?.length > 0) {
- groups.push({ label: 'New endpoints to add', type: 'add', endpoints: remoteDrift.missing });
+ const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
+ if (actuallyAdded.length > 0) {
+ groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
}
if (remoteDrift.modified?.length > 0) {
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
}
- if (remoteDrift.localOnly?.length > 0) {
- groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: remoteDrift.localOnly });
+ const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
+ if (actuallyRemoved.length > 0) {
+ groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
}
return groups;
- }, [remoteDrift]);
+ }, [remoteDrift, specAddedIds, specRemovedIds]);
return {
isSyncing, showConfirmModal, confirmGroups,
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/index.js
index 2a23d6dd4..af35a41ef 100644
--- a/packages/bruno-app/src/components/OpenAPISyncTab/index.js
+++ b/packages/bruno-app/src/components/OpenAPISyncTab/index.js
@@ -130,7 +130,6 @@ const OpenAPISyncTab = ({ collection }) => {
onTabSelect={setActiveTab}
error={error}
isLoading={isLoading}
- fileNotFound={fileNotFound}
onOpenSettings={() => setShowSettingsModal(true)}
/>
diff --git a/packages/bruno-electron/src/ipc/openapi-sync.js b/packages/bruno-electron/src/ipc/openapi-sync.js
index e2c0076d8..ea16656b3 100644
--- a/packages/bruno-electron/src/ipc/openapi-sync.js
+++ b/packages/bruno-electron/src/ipc/openapi-sync.js
@@ -509,6 +509,139 @@ const buildSpecItemsMap = (collectionItems) => {
return map;
};
+/**
+ * Recursively extracts all key paths from a parsed JSON value (dot-notation).
+ * Used to compare JSON body structure/schema without comparing values.
+ */
+const extractJsonKeys = (obj, prefix = '') => {
+ const keys = [];
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
+ for (const key of Object.keys(obj)) {
+ const fullKey = prefix ? `${prefix}.${key}` : key;
+ keys.push(fullKey);
+ keys.push(...extractJsonKeys(obj[key], fullKey));
+ }
+ } else if (Array.isArray(obj) && obj.length > 0) {
+ // Only inspect first element (spec arrays always have one template item)
+ keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
+ }
+ return keys;
+};
+
+/**
+ * Compare two Bruno-format requests field-by-field.
+ * Returns { hasDiff, changes } where changes is an array of human-readable strings.
+ */
+const compareRequestFields = (specRequest, actualRequest) => {
+ // Compare parameters by name:type pairs (catches query<->path type changes)
+ const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
+ const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
+
+ // Compare headers (by name)
+ const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
+ const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
+
+ // Check for differences
+ const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
+ const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
+
+ // Check body mode difference
+ const specBodyMode = specRequest.body?.mode || 'none';
+ const actualBodyMode = actualRequest.body?.mode || 'none';
+ const bodyDiff = specBodyMode !== actualBodyMode;
+
+ // Check auth mode difference
+ const specAuthMode = specRequest.auth?.mode || 'none';
+ const actualAuthMode = actualRequest.auth?.mode || 'none';
+ const authDiff = specAuthMode !== actualAuthMode;
+
+ // Check auth config differences when auth modes match
+ let authConfigDiff = false;
+ if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
+ if (specAuthMode === 'apikey') {
+ const specApikey = specRequest.auth?.apikey || {};
+ const actualApikey = actualRequest.auth?.apikey || {};
+ authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
+ } else if (specAuthMode === 'oauth2') {
+ const specOauth2 = specRequest.auth?.oauth2 || {};
+ const actualOauth2 = actualRequest.auth?.oauth2 || {};
+ const grantType = specOauth2.grantType || actualOauth2.grantType;
+ const commonFields = ['grantType', 'scope'];
+ const grantTypeFields = {
+ authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
+ implicit: [...commonFields, 'authorizationUrl'],
+ password: [...commonFields, 'accessTokenUrl'],
+ client_credentials: [...commonFields, 'accessTokenUrl']
+ };
+ const fields = grantTypeFields[grantType] || commonFields;
+ authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
+ }
+ }
+
+ // Check form field names when body modes match and mode is form-based
+ let formFieldsDiff = false;
+ let specFormFieldNames = [];
+ let actualFormFieldNames = [];
+ if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
+ if (specBodyMode === 'multipartForm') {
+ specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
+ actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
+ } else {
+ specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
+ actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
+ }
+ formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
+ }
+
+ // Check JSON body structure when both sides use json mode
+ let jsonBodyDiff = false;
+ if (!bodyDiff && specBodyMode === 'json') {
+ try {
+ const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
+ const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
+ if (specJson !== null && actualJson !== null) {
+ const specKeys = extractJsonKeys(specJson).sort();
+ const actualKeys = extractJsonKeys(actualJson).sort();
+ jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
+ } else if ((specJson === null) !== (actualJson === null)) {
+ jsonBodyDiff = true;
+ }
+ } catch (e) {
+ // Malformed JSON — skip structural comparison
+ }
+ }
+
+ const hasDiff = paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff;
+
+ const changes = [];
+ if (hasDiff) {
+ if (paramsDiff) {
+ const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
+ const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
+ if (addedParams.length) changes.push(`+${addedParams.length} params`);
+ if (removedParams.length) changes.push(`-${removedParams.length} params`);
+ }
+ if (headersDiff) {
+ const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
+ const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
+ if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
+ if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
+ }
+ if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
+ if (authDiff) changes.push(`auth: ${actualAuthMode}`);
+ if (authConfigDiff) changes.push('auth config');
+ if (formFieldsDiff) {
+ const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
+ const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
+ if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
+ if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
+ }
+ if (jsonBodyDiff) changes.push('body schema');
+ }
+
+ return { hasDiff, changes };
+};
+
/**
* Load the stored spec for a collection and convert it to Bruno collection format.
* Throws if no stored spec file exists.
@@ -549,127 +682,49 @@ const registerOpenAPISyncIpc = (mainWindow) => {
collectionUid, collectionPath, sourceUrl, environmentContext
}) => {
try {
- // Get the title/name from the spec
- const getSpecTitle = (spec) => {
- return spec?.info?.title || null;
- };
+ // Compare two OpenAPI specs by converting both to Bruno format and using field-level comparison.
+ // This ensures specDrift uses the same comparison sensitivity as collectionDrift/remoteDrift.
+ const compareSpecs = (oldSpec, newSpec, groupBy) => {
+ // Convert both specs to Bruno collection format
+ const oldBruno = oldSpec ? openApiToBruno(oldSpec, { groupBy }) : { items: [] };
+ const newBruno = newSpec ? openApiToBruno(newSpec, { groupBy }) : { items: [] };
- const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
-
- const normalizePath = (pathStr) => {
- return pathStr
- .replace(/{([^}]+)}/g, ':$1')
- .replace(/\/+/g, '/')
- .replace(/\/$/, '');
- };
-
- const extractEndpoints = (spec) => {
- const endpoints = [];
- if (!spec || !spec.paths) return endpoints;
-
- // Get base URL from servers
- const baseUrl = spec.servers?.[0]?.url || '';
-
- Object.entries(spec.paths).forEach(([pathStr, methods]) => {
- if (!methods || typeof methods !== 'object') return;
-
- Object.entries(methods).forEach(([method, operation]) => {
- if (!HTTP_METHODS.includes(method.toLowerCase())) return;
-
- // Extract parameters
- const parameters = operation?.parameters || [];
- const pathParams = parameters.filter((p) => p.in === 'path');
- const queryParams = parameters.filter((p) => p.in === 'query');
- const headerParams = parameters.filter((p) => p.in === 'header');
-
- // Extract request body
- const requestBody = operation?.requestBody;
- const bodyContent = requestBody?.content;
- const bodySchema = bodyContent?.['application/json']?.schema
- || bodyContent?.['application/x-www-form-urlencoded']?.schema
- || bodyContent?.['multipart/form-data']?.schema;
- const bodyExample = bodyContent?.['application/json']?.example
- || bodyContent?.['application/json']?.examples;
-
- // Extract responses
- const responses = operation?.responses || {};
-
- endpoints.push({
- id: `${method.toUpperCase()}:${normalizePath(pathStr)}`,
- method: method.toUpperCase(),
- path: pathStr,
- normalizedPath: normalizePath(pathStr),
- operationId: operation?.operationId || null,
- summary: operation?.summary || null,
- description: operation?.description || null,
- tags: operation?.tags || [],
- deprecated: operation?.deprecated || false,
- // Detailed info for UI
- details: {
- parameters: {
- path: pathParams,
- query: queryParams,
- header: headerParams
- },
- requestBody: requestBody ? {
- required: requestBody.required || false,
- contentType: Object.keys(bodyContent || {})[0] || null,
- schema: bodySchema,
- example: bodyExample
- } : null,
- responses: Object.entries(responses).map(([code, resp]) => ({
- code,
- description: resp.description,
- schema: resp.content?.['application/json']?.schema
- }))
- },
- // Hash for comparison (MD5 for quick change detection)
- _hash: crypto.createHash('md5').update(JSON.stringify({
- parameters,
- requestBody: operation?.requestBody,
- responses: operation?.responses
- })).digest('hex')
- });
- });
- });
-
- return endpoints;
- };
-
- const compareSpecs = (oldSpec, newSpec) => {
- const oldEndpoints = extractEndpoints(oldSpec);
- const newEndpoints = extractEndpoints(newSpec);
-
- const oldEndpointMap = new Map(oldEndpoints.map((ep) => [ep.id, ep]));
- const newEndpointMap = new Map(newEndpoints.map((ep) => [ep.id, ep]));
+ // Build endpoint maps keyed by METHOD:normalizedPath
+ const oldItems = buildSpecItemsMap(oldBruno.items || []);
+ const newItems = buildSpecItemsMap(newBruno.items || []);
const added = [];
const removed = [];
const modified = [];
const unchanged = [];
- newEndpoints.forEach((endpoint) => {
- if (!oldEndpointMap.has(endpoint.id)) {
- added.push(endpoint);
+ for (const [id, newItem] of newItems) {
+ const colonIndex = id.indexOf(':');
+ const method = id.substring(0, colonIndex);
+ const urlPath = id.substring(colonIndex + 1);
+
+ if (!oldItems.has(id)) {
+ added.push({ id, method, path: urlPath, name: newItem.name });
} else {
- const oldEndpoint = oldEndpointMap.get(endpoint.id);
- // Check if endpoint was modified by comparing hashes
- if (oldEndpoint._hash !== endpoint._hash) {
- modified.push({
- ...endpoint,
- oldEndpoint: oldEndpoint
- });
+ const oldItem = oldItems.get(id);
+ const { hasDiff, changes } = compareRequestFields(oldItem.request, newItem.request);
+ if (hasDiff) {
+ modified.push({ id, method, path: urlPath, name: newItem.name, changes: changes.join(', ') });
} else {
- unchanged.push(endpoint);
+ unchanged.push({ id, method, path: urlPath, name: newItem.name });
}
}
- });
+ }
- oldEndpoints.forEach((endpoint) => {
- if (!newEndpointMap.has(endpoint.id)) {
- removed.push(endpoint);
+ for (const [id] of oldItems) {
+ if (!newItems.has(id)) {
+ const colonIndex = id.indexOf(':');
+ const method = id.substring(0, colonIndex);
+ const urlPath = id.substring(colonIndex + 1);
+ const oldItem = oldItems.get(id);
+ removed.push({ id, method, path: urlPath, name: oldItem.name });
}
- });
+ }
// Compare metadata (title, version, description)
const oldTitle = oldSpec?.info?.title || null;
@@ -746,8 +801,8 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
// Check for title/name changes
- const storedTitle = getSpecTitle(storedSpec);
- const newTitle = getSpecTitle(newSpec);
+ const storedTitle = storedSpec?.info?.title || null;
+ const newTitle = newSpec?.info?.title || null;
const titleChanged = storedSpec && storedTitle && newTitle && storedTitle !== newTitle;
// Generate hashes for quick change detection
@@ -755,7 +810,16 @@ const registerOpenAPISyncIpc = (mainWindow) => {
const remoteSpecHash = generateSpecHash(newSpec);
const hasRemoteChanges = storedSpecHash !== remoteSpecHash;
- const diff = compareSpecs(storedSpec, newSpec);
+ // Read groupBy from brunoConfig for consistent spec conversion
+ let groupBy = 'tags';
+ try {
+ const { brunoConfig } = loadBrunoConfig(collectionPath);
+ groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
+ } catch (e) {
+ // Default to 'tags' if brunoConfig is not available
+ }
+
+ const diff = compareSpecs(storedSpec, newSpec, groupBy);
// Detect remote spec format and determine correct filename
const remoteIsYaml = isYamlContent(newSpecContent);
@@ -801,23 +865,6 @@ const registerOpenAPISyncIpc = (mainWindow) => {
}
});
- // Recursively extracts all key paths from a parsed JSON value (dot-notation).
- // Used to compare JSON body structure/schema without comparing values.
- const extractJsonKeys = (obj, prefix = '') => {
- const keys = [];
- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
- for (const key of Object.keys(obj)) {
- const fullKey = prefix ? `${prefix}.${key}` : key;
- keys.push(fullKey);
- keys.push(...extractJsonKeys(obj[key], fullKey));
- }
- } else if (Array.isArray(obj) && obj.length > 0) {
- // Only inspect first element (spec arrays always have one template item)
- keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
- }
- return keys;
- };
-
// Collection Drift Detection - compare stored spec (converted to bru) vs actual .bru files
ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, brunoConfig: passedBrunoConfig, compareSpec }) => {
try {
@@ -936,113 +983,9 @@ const registerOpenAPISyncIpc = (mainWindow) => {
});
} else {
// Compare key fields to detect drift
- const specRequest = specItem.request;
-
- // Compare parameters by name:type pairs (catches query<->path type changes)
- const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
- const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
-
- // Compare headers (by name)
- const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
- const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
-
- // Check for differences
- const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
- const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
-
- // Check body mode difference
- const specBodyMode = specRequest.body?.mode || 'none';
- const actualBodyMode = actualRequest.body?.mode || 'none';
- const bodyDiff = specBodyMode !== actualBodyMode;
-
- // Check auth mode difference
- const specAuthMode = specRequest.auth?.mode || 'none';
- const actualAuthMode = actualRequest.auth?.mode || 'none';
- const authDiff = specAuthMode !== actualAuthMode;
-
- // Check auth config differences when auth modes match
- let authConfigDiff = false;
- if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
- if (specAuthMode === 'apikey') {
- const specApikey = specRequest.auth?.apikey || {};
- const actualApikey = actualRequest.auth?.apikey || {};
- authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
- } else if (specAuthMode === 'oauth2') {
- const specOauth2 = specRequest.auth?.oauth2 || {};
- const actualOauth2 = actualRequest.auth?.oauth2 || {};
- const grantType = specOauth2.grantType || actualOauth2.grantType;
- const commonFields = ['grantType', 'scope'];
- const grantTypeFields = {
- authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
- implicit: [...commonFields, 'authorizationUrl'],
- password: [...commonFields, 'accessTokenUrl'],
- client_credentials: [...commonFields, 'accessTokenUrl']
- };
- const fields = grantTypeFields[grantType] || commonFields;
- authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
- }
- }
-
- // Check form field names when body modes match and mode is form-based
- let formFieldsDiff = false;
- let specFormFieldNames = [];
- let actualFormFieldNames = [];
- if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
- if (specBodyMode === 'multipartForm') {
- // For multipartForm, compare name:type pairs to catch text<->file changes
- specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
- actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
- } else {
- // For formUrlEncoded, all fields are text — compare by name only
- specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
- actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
- }
- formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
- }
-
- // Check JSON body structure when both sides use json mode
- let jsonBodyDiff = false;
- if (!bodyDiff && specBodyMode === 'json') {
- try {
- const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
- const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
- if (specJson !== null && actualJson !== null) {
- const specKeys = extractJsonKeys(specJson).sort();
- const actualKeys = extractJsonKeys(actualJson).sort();
- jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
- } else if ((specJson === null) !== (actualJson === null)) {
- jsonBodyDiff = true;
- }
- } catch (e) {
- // Malformed JSON — skip structural comparison
- }
- }
-
- if (paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff) {
- const changes = [];
- if (paramsDiff) {
- const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
- const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
- if (addedParams.length) changes.push(`+${addedParams.length} params`);
- if (removedParams.length) changes.push(`-${removedParams.length} params`);
- }
- if (headersDiff) {
- const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
- const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
- if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
- if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
- }
- if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
- if (authDiff) changes.push(`auth: ${actualAuthMode}`);
- if (authConfigDiff) changes.push('auth config');
- if (formFieldsDiff) {
- const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
- const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
- if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
- if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
- }
- if (jsonBodyDiff) changes.push('body schema');
+ const { hasDiff, changes } = compareRequestFields(specItem.request, actualRequest);
+ if (hasDiff) {
result.modified.push({
id,
method,