refactor(OpenAPISyncTab): streamline component logic and enhance user feedback (#7483)

- Removed unused props and improved error handling in OpenAPISyncTab components.
- Updated messaging in CollectionStatusSection and OverviewSection for clarity.
- Enhanced the SpecDiffModal to provide better visual feedback on changes.
- Refactored sync flow logic to ensure accurate endpoint categorization and improved performance.
- Added new utility functions for better handling of spec changes and endpoint comparisons.
This commit is contained in:
Abhishek S Lal
2026-03-14 01:15:10 +05:30
committed by GitHub
parent 1ab296f1e3
commit 83ddfc33d2
9 changed files with 275 additions and 288 deletions

View File

@@ -71,6 +71,8 @@ const CollectionStatusSection = ({
variant: 'muted',
message: 'Collection has changes since last sync',
badges: { modifiedCount, missingCount, localOnlyCount },
version,
lastSyncDate,
actions: ['revert-all']
};
}
@@ -244,7 +246,7 @@ const CollectionStatusSection = ({
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />
<h4>No changes in collection</h4>
<p>The collection matches the last synced spec. Nothing to review.</p>
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
</div>
)}
{/* Action confirmation modal */}

View File

@@ -6,7 +6,6 @@ import { countEndpoints } from '../utils';
import moment from 'moment';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import Help from 'components/Help';
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
@@ -40,7 +39,7 @@ const SUMMARY_CARDS = [
}
];
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, fileNotFound, onOpenSettings }) => {
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
@@ -48,6 +47,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const activeError = error || reduxError;
const version = storedSpec?.info?.version ?? specMeta?.version;
const newVersion = specDrift?.newVersion;
const hasVersionChange = version && newVersion && version !== newVersion;
const endpointCount = countEndpoints(storedSpec) ?? specMeta?.endpointCount ?? null;
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
const groupBy = openApiSyncConfig?.groupBy || 'tags';
@@ -89,7 +90,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
};
const details = [
{ label: 'Spec Version', value: version ? `v${version}` : '' },
{ label: 'Spec Version', value: hasVersionChange ? `v${version} → v${newVersion}` : version ? `v${version}` : '' },
{ label: 'Endpoints in Spec', value: endpointCount != null ? endpointCount : '' },
{ label: 'Last Synced At', value: lastSyncDate ? moment(lastSyncDate).fromNow() : '', tooltip: lastSyncDate ? moment(lastSyncDate).format('MMMM D, YYYY [at] h:mm A') : undefined },
{ label: 'Folder Grouping', value: capitalize(groupBy) },
@@ -100,6 +101,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const hasSpecUpdates = specUpdatesPending > 0;
const bannerState = useMemo(() => {
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
if (activeError) {
return {
variant: 'danger',
@@ -128,7 +133,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
if (hasSpecUpdates && hasCollectionChanges) {
return {
variant: 'warning',
title: 'The API spec has new updates and the collection has changes',
title: `The API spec has new updates${versionInfo} and the collection has changes`,
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
buttons: ['sync', 'changes']
};
@@ -136,7 +141,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
if (hasSpecUpdates) {
return {
variant: 'warning',
title: 'The API spec has new updates',
title: `The API spec has new updates${versionInfo}`,
subtitle: 'New or changed requests are available.',
buttons: ['sync']
};
@@ -156,7 +161,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
// buttons: []
// };
return null;
}, [activeError, fileNotFound, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, lastSyncDate]);
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
return (
<div className="overview-section">
@@ -168,12 +173,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
? <IconCheck size={16} className="status-check-icon" />
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">{bannerState.title}</span>
{bannerState.showBadge && (
<StatusBadge status="info" radius="full">{specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'}</StatusBadge>
)}
{bannerState.showChangesBadge && (
<StatusBadge status="warning" radius="full">{changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'}</StatusBadge>
)}
</div>
{bannerState.subtitle && (
<p className="banner-subtitle">{bannerState.subtitle}</p>
@@ -201,6 +200,11 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
Restore Spec File
</Button>
)}
{bannerState.buttons.includes('spec-details') && (
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
View Details
</Button>
)}
{bannerState.buttons.includes('open-settings') && (
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Update connection settings

View File

@@ -47,8 +47,8 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
>
<div className="spec-diff-modal">
<div className="spec-diff-badges">
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{modifiedCount > 0 && <StatusBadge status="info">Updated: {modifiedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>

View File

@@ -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 = ({
</span>
{bannerState.changes && (
<span className="banner-details">
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="info" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
</span>
)}
@@ -113,7 +105,13 @@ const SpecStatusSection = ({
</div>
)}
{specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
{(error || fileNotFound || specDrift?.isValid === false) ? (
<div className="sync-review-empty-state mt-5">
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>Unable to check for updates</h4>
<p>Fix the connection issue above and check again.</p>
</div>
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
<div className="sync-review-empty-state mt-5">
<IconRefresh size={40} className="empty-state-icon" />
<h4>Last Synced Spec not found in storage</h4>

View File

@@ -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,

View File

@@ -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 }) => {

View File

@@ -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,

View File

@@ -130,7 +130,6 @@ const OpenAPISyncTab = ({ collection }) => {
onTabSelect={setActiveTab}
error={error}
isLoading={isLoading}
fileNotFound={fileNotFound}
onOpenSettings={() => setShowSettingsModal(true)}
/>
<p className="beta-feedback-inline">

View File

@@ -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,