From 1877119b81a9b6d2d65dc605e73d5237425ed8e1 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Mon, 16 Mar 2026 18:14:53 +0530 Subject: [PATCH] fix(openapi-sync): simplify IPC calls, fix state priorities, and improve stored spec missing UX (#7489) * refactor(OpenAPISyncTab): remove unused props and streamline IPC calls - Eliminated unnecessary sourceUrl prop from various components and hooks in the OpenAPISyncTab. - Improved pretty-printing logic in OpenAPISpecTab to handle non-JSON content gracefully. - Updated IPC calls to remove redundant parameters, enhancing code clarity and maintainability. * feat(OpenAPISyncTab): enhance user interaction and visual feedback - Added onTabSelect prop to OpenAPISyncTab for improved tab navigation. - Updated color properties in StyledWrapper for better consistency with theme. - Replaced IconClock with IconAlertTriangle in CollectionStatusSection for clearer status indication. - Enhanced messaging in OverviewSection and SpecStatusSection to provide clearer user guidance. - Introduced handleRestoreSpec function in useSyncFlow for better spec restoration handling. * fix(OpenAPISyncTab): update button labels for clarity in OverviewSection - Changed button label from 'restore' to 'spec-details' for better context. - Updated the button text from 'View Details' to 'Go to Spec Updates' to enhance user understanding of navigation options. * refactor(OpenAPISyncTab): remove unused props and streamline component logic - Eliminated unnecessary props from OpenAPISyncTab, CollectionStatusSection, and SpecStatusSection for cleaner code. - Removed commented-out code in OverviewSection and SpecStatusSection to enhance readability. - Introduced posixifyPath utility function in filesystem.js to standardize path formatting. * fix(OpenAPISyncTab): update openapi config handling to support array format - Modified the logic in loadBrunoConfig to handle openapi as an array, ensuring consistent resolution of source URLs for all entries. This change improves the configuration handling for OpenAPI specifications. * fix(OpenAPISyncTab): improve openapi config handling and merge logic - Updated loadBrunoConfig to ensure openapi is treated as an array, enhancing source URL resolution. - Modified mergeWithUserValues to handle cases where specItems may be undefined, improving robustness in merging user values with specifications. --- .../src/components/OpenAPISpecTab/index.js | 4 +- .../CollectionStatusSection/index.js | 44 ++-- .../OpenAPISyncTab/OpenAPISyncHeader/index.js | 1 - .../OpenAPISyncTab/OverviewSection/index.js | 45 ++-- .../OpenAPISyncTab/SpecStatusSection/index.js | 44 ++-- .../OpenAPISyncTab/StyledWrapper.js | 7 +- .../OpenAPISyncTab/hooks/useOpenAPISync.js | 29 +-- .../OpenAPISyncTab/hooks/useSyncFlow.js | 12 +- .../src/components/OpenAPISyncTab/index.js | 2 +- packages/bruno-electron/src/ipc/collection.js | 3 +- .../bruno-electron/src/ipc/openapi-sync.js | 194 ++++++++---------- .../bruno-electron/src/utils/filesystem.js | 3 + 12 files changed, 167 insertions(+), 221 deletions(-) diff --git a/packages/bruno-app/src/components/OpenAPISpecTab/index.js b/packages/bruno-app/src/components/OpenAPISpecTab/index.js index 245608134..8a67c9514 100644 --- a/packages/bruno-app/src/components/OpenAPISpecTab/index.js +++ b/packages/bruno-app/src/components/OpenAPISpecTab/index.js @@ -9,6 +9,7 @@ import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper'; */ const prettyPrintSpec = (content) => { if (!content) return content; + if (content.trimStart()[0] !== '{') return content; try { return fastJsonFormat(content); } catch { @@ -32,8 +33,7 @@ const OpenAPISpecTab = ({ collection }) => { try { const { ipcRenderer } = window; const result = await ipcRenderer.invoke('renderer:read-openapi-spec', { - collectionPath: collection.pathname, - sourceUrl + collectionPath: collection.pathname }); if (result.error) { // Local file not found — fall back to fetching from remote URL diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js index ae7fdf580..f6aab9309 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js @@ -5,7 +5,7 @@ import { IconTrash, IconArrowBackUp, IconExternalLink, - IconClock, + IconAlertTriangle, IconInfoCircle, IconLoader2 } from '@tabler/icons'; @@ -25,7 +25,8 @@ const CollectionStatusSection = ({ storedSpec, lastSyncDate, onOpenEndpoint, - isLoading + isLoading, + onTabSelect }) => { const { pendingAction, setPendingAction, @@ -71,8 +72,6 @@ const CollectionStatusSection = ({ variant: 'muted', message: 'Collection has changes since last sync', badges: { modifiedCount, missingCount, localOnlyCount }, - version, - lastSyncDate, actions: ['revert-all'] }; } @@ -89,12 +88,6 @@ const CollectionStatusSection = ({ :
} {bannerState.message} - {bannerState.version && ( - <> · v{bannerState.version} - )} - {bannerState.lastSyncDate && ( - · Synced {moment(bannerState.lastSyncDate).fromNow()} - )} {bannerState.badges && ( @@ -117,7 +110,7 @@ const CollectionStatusSection = ({ {hasDrift && (
- What's tracked: Changes to URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here. + What's tracked: Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.
)} @@ -222,26 +215,15 @@ const CollectionStatusSection = ({

Comparing your collection with the last synced spec...

) : !hasStoredSpec ? ( - <> -
-
-
- - {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'} - -
-
-
- -

{lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}

-

{lastSyncDate - ? 'Restore the latest spec from the source to track future changes.' - : 'Once you sync your collection with the spec, changes will appear here.'} -

-
- +
+ +

{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}

+

{lastSyncDate + ? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.' + : 'Once you sync your collection with the spec, local changes will appear here.'} +

+ +
) : (
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js index c995019f6..95ff2ebdc 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js @@ -12,7 +12,6 @@ import { } from '@tabler/icons'; import toast from 'react-hot-toast'; import Button from 'ui/Button'; -import StatusBadge from 'ui/StatusBadge'; import ActionIcon from 'ui/ActionIcon/index'; import MenuDropdown from 'ui/MenuDropdown'; import Help from 'components/Help'; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js index 6c7708ab6..c14800892 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OverviewSection/index.js @@ -21,7 +21,7 @@ const SUMMARY_CARDS = [ key: 'inSync', label: 'In Sync with Spec', color: 'green', - tooltip: 'Endpoints that currently match the latest spec' + tooltip: 'Endpoints that currently match the latest spec from the source' }, { key: 'changed', @@ -46,10 +46,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r const specMeta = useSelector(selectStoredSpecMeta(collection.uid)); 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 version = specMeta?.version; + const endpointCount = specMeta?.endpointCount ?? null; const lastSyncDate = openApiSyncConfig?.lastSyncDate; const groupBy = openApiSyncConfig?.groupBy || 'tags'; const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false; @@ -90,7 +88,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r }; const details = [ - { label: 'Spec Version', value: hasVersionChange ? `v${version} → v${newVersion}` : version ? `v${version}` : '–' }, + { label: 'Spec Version', value: 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) }, @@ -121,19 +119,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r buttons: ['review'] }; } - if (specDrift?.storedSpecMissing && lastSyncDate) { - return { - variant: 'warning', - title: 'Last synced spec not found', - subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.', - buttons: ['restore'] - }; - } - if (!hasDriftData) return null; if (hasSpecUpdates && hasCollectionChanges) { return { variant: 'warning', - title: `The API spec has new updates${versionInfo} and the collection has changes`, + title: `OpenAPI 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'] }; @@ -141,11 +130,20 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r if (hasSpecUpdates) { return { variant: 'warning', - title: `The API spec has new updates${versionInfo}`, + title: `OpenAPI spec has new updates${versionInfo}`, subtitle: 'New or changed requests are available.', buttons: ['sync'] }; } + if (specDrift?.storedSpecMissing && lastSyncDate) { + return { + variant: 'warning', + title: 'Last synced spec not found', + subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.', + buttons: ['spec-details'] + }; + } + if (!hasDriftData) return null; if (hasCollectionChanges) { return { variant: 'muted', @@ -154,12 +152,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r buttons: ['changes'] }; } - // return { - // variant: 'success', - // title: 'Collection is in sync with the spec', - // subtitle: null, - // buttons: [] - // }; return null; }, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]); @@ -195,14 +187,9 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r Review and Sync Collection )} - {bannerState.buttons.includes('restore') && ( - - )} {bannerState.buttons.includes('spec-details') && ( )} {bannerState.buttons.includes('open-settings') && ( diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js index 066f9e2bd..6f47d833f 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js @@ -3,7 +3,8 @@ import { useSelector } from 'react-redux'; import { IconCheck, IconRefresh, - IconAlertTriangle + IconAlertTriangle, + IconClock } from '@tabler/icons'; import Button from 'ui/Button'; import StatusBadge from 'ui/StatusBadge'; @@ -23,14 +24,20 @@ const SpecStatusSection = ({ const { isSyncing, showConfirmModal, confirmGroups, - handleSyncNow, handleApplySync, cancelConfirmModal, handleConfirmModalSync + handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync } = useSyncFlow({ collection, specDrift, remoteDrift, collectionDrift, - sourceUrl, setError, checkForUpdates: onCheck + setError, checkForUpdates: onCheck }); const lastSyncedAt = openApiSyncConfig?.lastSyncDate; + const hasRemoteUpdates = remoteDrift && ( + (remoteDrift.missing?.length || 0) + + (remoteDrift.modified?.length || 0) + + (remoteDrift.localOnly?.length || 0) + ) > 0; + const bannerState = useMemo(() => { if (fileNotFound) { return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] }; @@ -41,13 +48,12 @@ const SpecStatusSection = ({ if (!specDrift) { return null; } - if (specDrift.storedSpecMissing) { - if (!lastSyncedAt) { - return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] }; - } - return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] }; + if (specDrift.storedSpecMissing && !hasRemoteUpdates) { + return null; } - const hasEndpointUpdates = (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0; + const hasEndpointUpdates = specDrift.storedSpecMissing + ? hasRemoteUpdates + : (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})` @@ -57,13 +63,8 @@ const SpecStatusSection = ({ changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 } }; } - // return { - // variant: 'success', message: 'Spec is up to date', actions: [], - // version: specDrift.newVersion || storedSpec?.info?.version || specDrift.storedVersion, - // lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now' - // }; return null; - }, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]); + }, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]); return ( <> {bannerState && ( @@ -92,9 +93,6 @@ const SpecStatusSection = ({ )}
- {bannerState.actions.includes('quick-sync') && ( - - )} {bannerState.actions.includes('open-settings') && (
- ) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? ( + ) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
- -

Last Synced Spec not found in storage

-

The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.

-
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js index f6c301781..45027e37f 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js @@ -625,7 +625,7 @@ const StyledWrapper = styled.div` .settings-label { font-size: 11px; font-weight: 600; - color: ${(props) => props.theme.colors.text.subtext0}; + color: ${(props) => props.theme.text}; display: block; margin-bottom: 5px; } @@ -670,7 +670,7 @@ const StyledWrapper = styled.div` .toggle-description { font-size: 11px; - color: ${(props) => props.theme.colors.text.muted}; + color: ${(props) => props.theme.text}; margin-top: 2px; } @@ -1251,7 +1251,6 @@ const StyledWrapper = styled.div` .disconnect-modal { .disconnect-message { font-size: ${(props) => props.theme.font.size.sm}; - color: ${(props) => props.theme.colors.text.muted}; line-height: 1.5; margin-bottom: 1.5rem; } @@ -1281,7 +1280,7 @@ const StyledWrapper = styled.div` .action-confirm-modal { .confirm-message { font-size: ${(props) => props.theme.font.size.sm}; - color: ${(props) => props.theme.colors.text.muted}; + color: ${(props) => props.theme.text}; line-height: 1.5; margin-bottom: 1.5rem; } diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js index 2edaf6b52..d1ebb4af1 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js @@ -32,14 +32,12 @@ const useOpenAPISync = (collection) => { 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) - })); - } + dispatch(setStoredSpecMeta({ + collectionUid: collection.uid, + title: spec?.info?.title || null, + version: spec?.info?.version || null, + endpointCount: spec ? countEndpoints(spec) : null + })); }; // Flatten collection items including nested items in folders @@ -100,8 +98,7 @@ const useOpenAPISync = (collection) => { try { const { ipcRenderer } = window; const result = await ipcRenderer.invoke('renderer:get-collection-drift', { - collectionPath: collection.pathname, - brunoConfig: collection.brunoConfig + collectionPath: collection.pathname }); if (!result.error) { @@ -150,9 +147,7 @@ const useOpenAPISync = (collection) => { } setSpecDrift(result); - if (result.storedSpec) { - updateStoredSpec(result.storedSpec); - } + updateStoredSpec(result.storedSpec || null); // Update Redux store so toolbar status stays in sync dispatch(setCollectionUpdate({ @@ -166,7 +161,6 @@ const useOpenAPISync = (collection) => { if (result.newSpec) { const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', { collectionPath: collection.pathname, - brunoConfig: collection.brunoConfig, compareSpec: result.newSpec }); if (remoteComparison.error) { @@ -271,7 +265,6 @@ const useOpenAPISync = (collection) => { if (result.newSpec) { const drift = await ipcRenderer.invoke('renderer:get-collection-drift', { collectionPath: collection.pathname, - brunoConfig: collection.brunoConfig, compareSpec: result.newSpec }); @@ -284,8 +277,7 @@ const useOpenAPISync = (collection) => { // Collection matches — save spec file silently to complete setup await ipcRenderer.invoke('renderer:save-openapi-spec', { collectionPath: collection.pathname, - specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2), - sourceUrl: trimmedUrl + specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2) }); } } @@ -304,7 +296,6 @@ const useOpenAPISync = (collection) => { const { ipcRenderer } = window; await ipcRenderer.invoke('renderer:remove-openapi-sync-config', { collectionPath: collection.pathname, - sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl, deleteSpecFile: true }); setSourceUrl(''); @@ -343,7 +334,6 @@ const useOpenAPISync = (collection) => { const { ipcRenderer } = window; const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', { collectionPath: collection.pathname, - brunoConfig: collection.brunoConfig, compareSpec: currentSpecDrift.newSpec }); if (!remoteComparison.error) { @@ -380,7 +370,6 @@ const useOpenAPISync = (collection) => { await ipcRenderer.invoke('renderer:update-openapi-sync-config', { collectionPath: collection.pathname, - oldSourceUrl: openApiSyncConfig?.sourceUrl, config: { sourceUrl: newUrl, autoCheck, diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js index 11690fe30..a9b7bfd8a 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useSyncFlow.js @@ -6,7 +6,7 @@ import { formatIpcError } from 'utils/common/error'; const useSyncFlow = ({ collection, specDrift, remoteDrift, collectionDrift, - sourceUrl, setError, checkForUpdates + setError, checkForUpdates }) => { const dispatch = useDispatch(); @@ -65,7 +65,6 @@ const useSyncFlow = ({ await ipcRenderer.invoke('renderer:apply-openapi-sync', { collectionUid: collection.uid, collectionPath: collection.pathname, - sourceUrl: sourceUrl.trim(), addNewRequests: mode !== 'spec-only', removeDeletedRequests: localOnlyIds.length > 0, diff: filteredDiff, @@ -121,6 +120,13 @@ const useSyncFlow = ({ return new Set((specDrift?.removed || []).map((ep) => ep.id)); }, [specDrift]); + const handleRestoreSpec = () => { + const localOnlyIds = (remoteDrift?.localOnly || []) + .filter((ep) => specRemovedIds.has(ep.id)) + .map((ep) => ep.id); + performSync({ localOnlyIds, endpointDecisions: {} }, 'sync'); + }; + const handleConfirmModalSync = () => { const localOnlyIds = (remoteDrift?.localOnly || []) .filter((ep) => specRemovedIds.has(ep.id)) @@ -150,7 +156,7 @@ const useSyncFlow = ({ return { isSyncing, showConfirmModal, confirmGroups, - handleSyncNow, + handleSyncNow, handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync }; }; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/index.js index af35a41ef..6a8f70513 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/index.js @@ -129,7 +129,6 @@ const OpenAPISyncTab = ({ collection }) => { remoteDrift={remoteDrift} onTabSelect={setActiveTab} error={error} - isLoading={isLoading} onOpenSettings={() => setShowSettingsModal(true)} />

@@ -157,6 +156,7 @@ const OpenAPISyncTab = ({ collection }) => { lastSyncDate={openApiSyncConfig?.lastSyncDate} onOpenEndpoint={openEndpointInTab} isLoading={isDriftLoading || isLoading} + onTabSelect={setActiveTab} />

)} diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 2bcc6ffcd..a25902207 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -1271,11 +1271,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { // Save OpenAPI spec file for sync support if (rawOpenAPISpec && brunoConfig.openapi?.length) { - const importSourceUrl = brunoConfig.openapi[0].sourceUrl; const specContent = typeof rawOpenAPISpec === 'string' ? rawOpenAPISpec : JSON.stringify(rawOpenAPISpec, null, 2); - await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl: importSourceUrl }); + await saveSpecAndUpdateMetadata({ collectionPath, specContent }); } const { size, filesCount } = await getCollectionStats(collectionPath); diff --git a/packages/bruno-electron/src/ipc/openapi-sync.js b/packages/bruno-electron/src/ipc/openapi-sync.js index ea16656b3..c9657880e 100644 --- a/packages/bruno-electron/src/ipc/openapi-sync.js +++ b/packages/bruno-electron/src/ipc/openapi-sync.js @@ -12,7 +12,7 @@ const { stringifyFolder } = require('@usebruno/filestore'); const { openApiToBruno } = require('@usebruno/converters'); -const { writeFile, sanitizeName, getCollectionFormat } = require('../utils/filesystem'); +const { writeFile, sanitizeName, getCollectionFormat, posixifyPath } = require('../utils/filesystem'); const { getEnvVars } = require('../utils/collection'); const { getProcessEnvVars } = require('../store/process-env'); const { getCertsAndProxyConfig } = require('./network/cert-utils'); @@ -84,6 +84,11 @@ const isValidHttpUrl = (urlString) => { const isLocalFilePath = (str) => !isValidHttpUrl(str) && typeof str === 'string' && str.length > 0; +const resolveSourceUrl = (collectionPath, sourceUrl) => { + if (!sourceUrl || isValidHttpUrl(sourceUrl)) return sourceUrl; + return path.resolve(collectionPath, sourceUrl); +}; + /** * Get the directory where OpenAPI spec files are stored in AppData. */ @@ -127,8 +132,8 @@ const getSpecEntriesForCollection = (collectionPath) => { /** * Get the spec entry for a specific sourceUrl within a collection. */ -const getSpecEntryForUrl = (collectionPath, sourceUrl) => { - return getSpecEntriesForCollection(collectionPath).find((e) => e.sourceUrl === sourceUrl) || null; +const getSpecEntryForUrl = (collectionPath) => { + return getSpecEntriesForCollection(collectionPath)[0] || null; }; /** @@ -260,6 +265,14 @@ const loadBrunoConfig = (collectionPath) => { brunoConfig = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8')); } + // Resolve relative openapi sourceUrls to absolute so all callers get consistent paths + if (Array.isArray(brunoConfig?.openapi)) { + brunoConfig.openapi = brunoConfig.openapi.map((entry) => ({ + ...entry, + sourceUrl: resolveSourceUrl(collectionPath, entry.sourceUrl) + })); + } + return { format, brunoConfig, collectionRoot }; }; @@ -267,12 +280,23 @@ const loadBrunoConfig = (collectionPath) => { * Save bruno config to disk (bruno.json or opencollection.yml). */ const saveBrunoConfig = async (collectionPath, format, brunoConfig, collectionRoot) => { + // Convert absolute openapi sourceUrls back to collection-relative for git-shareability + const configToSave = { ...brunoConfig }; + if (Array.isArray(configToSave?.openapi)) { + configToSave.openapi = configToSave.openapi.map((entry) => ({ + ...entry, + sourceUrl: (entry.sourceUrl && !isValidHttpUrl(entry.sourceUrl)) + ? posixifyPath(path.relative(collectionPath, entry.sourceUrl)) + : entry.sourceUrl + })); + } + if (format === 'yml') { - const content = await stringifyCollection(collectionRoot, brunoConfig, { format }); + const content = await stringifyCollection(collectionRoot, configToSave, { format }); await writeFile(path.join(collectionPath, 'opencollection.yml'), content); } else { const brunoJsonPath = path.join(collectionPath, 'bruno.json'); - await writeFile(brunoJsonPath, JSON.stringify(brunoConfig, null, 2)); + await writeFile(brunoJsonPath, JSON.stringify(configToSave, null, 2)); } }; @@ -346,9 +370,9 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => { const specsDir = getSpecsDir(); await fsExtra.ensureDir(specsDir); + const resolvedUrl = resolveSourceUrl(collectionPath, sourceUrl); const meta = loadSpecMetadata(); - const entries = meta[collectionPath] || []; - const existingEntry = entries.find((e) => e.sourceUrl === sourceUrl); + const existingEntry = (meta[collectionPath] || [])[0]; let filename; if (existingEntry) { @@ -358,10 +382,12 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => { // Generate a new UUID filename based on content type const ext = isYamlContent(content) ? 'yaml' : 'json'; filename = `${crypto.randomUUID()}.${ext}`; - meta[collectionPath] = [...entries, { filename, sourceUrl }]; - saveSpecMetadata(meta); } + // Always replace with a single entry (one spec per collection for now) + meta[collectionPath] = [{ filename, sourceUrl: resolvedUrl }]; + saveSpecMetadata(meta); + await writeFile(path.join(specsDir, filename), content); }; @@ -369,8 +395,9 @@ const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => { * Save an OpenAPI spec file and update sync metadata (lastSyncDate, specHash) in brunoConfig. * Shared by both the IPC handler (connect flow) and the import flow. */ -const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUrl }) => { +const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent }) => { const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); + const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl }); @@ -383,14 +410,9 @@ const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUr const specHash = generateSpecHash(parsedSpec); const lastSyncDate = new Date().toISOString(); - const openapi = brunoConfig.openapi || []; - const idx = openapi.findIndex((e) => e.sourceUrl === sourceUrl); - if (idx !== -1) { - openapi[idx] = { ...openapi[idx], lastSyncDate, specHash }; - } else { - openapi.push({ sourceUrl, lastSyncDate, specHash }); - } - brunoConfig.openapi = openapi; + if (brunoConfig.openapi?.[0]) { + brunoConfig.openapi[0] = { ...brunoConfig.openapi[0], lastSyncDate, specHash }; + }; await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); }; @@ -417,7 +439,7 @@ const cleanupSpecFilesForCollection = (collectionPath) => { * Only preserves the user's enabled state; values come from the spec. */ const mergeWithUserValues = (specItems, existingItems) => { - return specItems?.map((specItem) => { + return (specItems || []).map((specItem) => { const existing = (existingItems || []).find( (e) => e.name === specItem.name && e.value === specItem.value ); @@ -440,7 +462,12 @@ const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } = return { ...existingRequest, request: { - ...specItem.request, + ...existingRequest.request, + url: specItem.request.url, + method: specItem.request.method, + body: specItem.request.body, + auth: specItem.request.auth, + docs: specItem.request.docs, params: mergedParams || [], headers: mergedHeaders || [] } @@ -648,7 +675,7 @@ const compareRequestFields = (specRequest, actualRequest) => { */ const loadStoredSpecCollection = (collectionPath, brunoConfig) => { const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; - const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath, sourceUrl) : null; + const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath) : null; const specPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null; if (!specPath || !fs.existsSync(specPath)) { @@ -761,7 +788,7 @@ const registerOpenAPISyncIpc = (mainWindow) => { }; }; - const specEntry = getSpecEntryForUrl(collectionPath, sourceUrl); + const specEntry = getSpecEntryForUrl(collectionPath); const storedSpecPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null; let storedSpec = null; @@ -866,18 +893,13 @@ const registerOpenAPISyncIpc = (mainWindow) => { }); // 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 }) => { + ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, compareSpec }) => { try { - // Use passed brunoConfig if available, otherwise read from disk let brunoConfig; - if (passedBrunoConfig) { - brunoConfig = passedBrunoConfig; - } else { - try { - ({ brunoConfig } = loadBrunoConfig(collectionPath)); - } catch (err) { - return { error: err.message }; - } + try { + ({ brunoConfig } = loadBrunoConfig(collectionPath)); + } catch (err) { + return { error: err.message }; } // Load spec to compare against — use compareSpec if provided, otherwise read stored spec from disk @@ -888,7 +910,7 @@ const registerOpenAPISyncIpc = (mainWindow) => { specToCompare = compareSpec; } else { const driftSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; - const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath, driftSourceUrl) : null; + const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath) : null; const storedSpecPath = driftSpecEntry ? path.join(getSpecsDir(), driftSpecEntry.filename) : null; if (!storedSpecPath || !fs.existsSync(storedSpecPath)) { @@ -1057,7 +1079,7 @@ const registerOpenAPISyncIpc = (mainWindow) => { let specToUse = newSpec; if (!specToUse) { const diffSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; - const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath, diffSourceUrl) : null; + const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath) : null; const storedSpecPath = diffSpecEntry ? path.join(getSpecsDir(), diffSpecEntry.filename) : null; if (storedSpecPath && fs.existsSync(storedSpecPath)) { const content = fs.readFileSync(storedSpecPath, 'utf8'); @@ -1135,9 +1157,10 @@ const registerOpenAPISyncIpc = (mainWindow) => { }); // Sync modes: 'spec-only' | 'reset' | 'sync' (default) - ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, sourceUrl, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => { + ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => { try { const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); + const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl; // Mode: spec-only - Just save the spec, don't touch collection if (mode === 'spec-only') { @@ -1147,16 +1170,13 @@ const registerOpenAPISyncIpc = (mainWindow) => { } // Update sync metadata - const openapi = brunoConfig.openapi || []; - const specOnlyIdx = openapi.findIndex((e) => e.sourceUrl === sourceUrl); - if (specOnlyIdx !== -1) { - openapi[specOnlyIdx] = { - ...openapi[specOnlyIdx], + if (brunoConfig.openapi?.[0]) { + brunoConfig.openapi[0] = { + ...brunoConfig.openapi[0], lastSyncDate: new Date().toISOString(), specHash: generateSpecHash(diff.newSpec) }; } - brunoConfig.openapi = openapi; await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); @@ -1165,8 +1185,7 @@ const registerOpenAPISyncIpc = (mainWindow) => { // Mode: reset - Save spec and reset all endpoints to spec (preserve tests/scripts) if (mode === 'reset' && diff.newSpec) { - const openapiEntryReset = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl); - const groupBy = openapiEntryReset?.groupBy || 'tags'; + const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags'; const newCollection = openApiToBruno(diff.newSpec, { groupBy }); // Build map of spec items by endpoint ID @@ -1231,16 +1250,13 @@ const registerOpenAPISyncIpc = (mainWindow) => { await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl }); // Update sync metadata - const openapiReset = brunoConfig.openapi || []; - const resetIdx = openapiReset.findIndex((e) => e.sourceUrl === sourceUrl); - if (resetIdx !== -1) { - openapiReset[resetIdx] = { - ...openapiReset[resetIdx], + if (brunoConfig.openapi?.[0]) { + brunoConfig.openapi[0] = { + ...brunoConfig.openapi[0], lastSyncDate: new Date().toISOString(), specHash: generateSpecHash(diff.newSpec) }; } - brunoConfig.openapi = openapiReset; await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); @@ -1248,8 +1264,7 @@ const registerOpenAPISyncIpc = (mainWindow) => { } // Mode: sync (default) — compute shared values once - const syncEntry = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl); - const groupBy = syncEntry?.groupBy || 'tags'; + const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags'; let newCollection; if (diff.newSpec) { try { @@ -1396,7 +1411,7 @@ const registerOpenAPISyncIpc = (mainWindow) => { // Reuse newCollection if available, otherwise fall back to stored spec let driftCollection = newCollection; if (!driftCollection) { - const applySpecEntry = getSpecEntryForUrl(collectionPath, sourceUrl); + const applySpecEntry = getSpecEntryForUrl(collectionPath); const storedSpecPath = applySpecEntry ? path.join(getSpecsDir(), applySpecEntry.filename) : null; if (storedSpecPath && fs.existsSync(storedSpecPath)) { try { @@ -1445,20 +1460,17 @@ const registerOpenAPISyncIpc = (mainWindow) => { await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl }); } - const openapiSync = brunoConfig.openapi || []; - const syncIdx = openapiSync.findIndex((e) => e.sourceUrl === sourceUrl); - if (syncIdx !== -1) { + if (brunoConfig.openapi?.[0]) { const updated = { - ...openapiSync[syncIdx], + ...brunoConfig.openapi[0], lastSyncDate: new Date().toISOString() }; // Only update specHash when we have a valid newSpec, otherwise preserve existing hash if (diff.newSpec) { updated.specHash = generateSpecHash(diff.newSpec); } - openapiSync[syncIdx] = updated; + brunoConfig.openapi[0] = updated; } - brunoConfig.openapi = openapiSync; await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); @@ -1470,7 +1482,7 @@ const registerOpenAPISyncIpc = (mainWindow) => { }); // Update OpenAPI sync configuration (e.g., source URL) - ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, oldSourceUrl, config }) => { + ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, config }) => { try { const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); @@ -1493,37 +1505,18 @@ const registerOpenAPISyncIpc = (mainWindow) => { throw new Error('Invalid URL: only http and https URLs are allowed'); } - // Convert absolute local file paths to collection-relative (git-shareable) - if (path.isAbsolute(sanitizedConfig.sourceUrl)) { - sanitizedConfig.sourceUrl = path.relative(collectionPath, sanitizedConfig.sourceUrl); - } + // Resolve to absolute for consistent internal handling (saveBrunoConfig converts back to relative) + sanitizedConfig.sourceUrl = resolveSourceUrl(collectionPath, sanitizedConfig.sourceUrl); - // If sourceUrl is changing, remove the old entry and its metadata - const openapi = brunoConfig.openapi || []; - if (oldSourceUrl && oldSourceUrl !== sanitizedConfig.sourceUrl) { - const filteredOpenapi = openapi.filter((e) => e.sourceUrl !== oldSourceUrl); - brunoConfig.openapi = filteredOpenapi; - // Clean up metadata entry for old sourceUrl (keep spec file for potential re-use) - const meta = loadSpecMetadata(); - if (meta[collectionPath]) { - meta[collectionPath] = meta[collectionPath].filter((e) => e.sourceUrl !== oldSourceUrl); - if (meta[collectionPath].length === 0) delete meta[collectionPath]; - saveSpecMetadata(meta); - } - } - - // Apply defaults for new entries - const updatedOpenapi = brunoConfig.openapi || []; - const idx = updatedOpenapi.findIndex((e) => e.sourceUrl === sanitizedConfig.sourceUrl); - const isNewEntry = idx === -1; - if (isNewEntry) { + // Update or create the single openapi entry + const existingEntry = brunoConfig.openapi?.[0]; + if (existingEntry) { + brunoConfig.openapi = [{ ...existingEntry, ...sanitizedConfig }]; + } else { if (!('autoCheck' in sanitizedConfig)) sanitizedConfig.autoCheck = true; if (!('autoCheckInterval' in sanitizedConfig)) sanitizedConfig.autoCheckInterval = 5; - updatedOpenapi.push(sanitizedConfig); - } else { - updatedOpenapi[idx] = { ...updatedOpenapi[idx], ...sanitizedConfig }; + brunoConfig.openapi = [sanitizedConfig]; } - brunoConfig.openapi = updatedOpenapi; // Save updated config await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); @@ -1536,9 +1529,9 @@ const registerOpenAPISyncIpc = (mainWindow) => { }); // Save OpenAPI spec file and update sync metadata (used by both connect and import flows) - ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent, sourceUrl }) => { + ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent }) => { try { - await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl }); + await saveSpecAndUpdateMetadata({ collectionPath, specContent }); return { success: true }; } catch (error) { console.error('Error saving OpenAPI spec file:', error); @@ -1566,9 +1559,9 @@ const registerOpenAPISyncIpc = (mainWindow) => { }); // Read stored OpenAPI spec file from AppData - ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath, sourceUrl }) => { + ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath }) => { try { - const entry = getSpecEntryForUrl(collectionPath, sourceUrl); + const entry = getSpecEntryForUrl(collectionPath); if (!entry) return { error: 'Spec file not found' }; const specPath = path.join(getSpecsDir(), entry.filename); if (!fs.existsSync(specPath)) return { error: 'Spec file not found' }; @@ -1579,31 +1572,22 @@ const registerOpenAPISyncIpc = (mainWindow) => { }); // Remove OpenAPI sync configuration (disconnect sync) - ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, sourceUrl, deleteSpecFile = false }) => { + ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, deleteSpecFile = false }) => { try { const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath); - // Remove matching openapi entry from config array - if (brunoConfig.openapi?.length) { - brunoConfig.openapi = brunoConfig.openapi.filter((e) => e.sourceUrl !== sourceUrl); - if (brunoConfig.openapi.length === 0) { - delete brunoConfig.openapi; - } - } - - // Save updated config + // Remove openapi config + delete brunoConfig.openapi; await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot); - // Remove spec file from AppData if user opted in + // Remove spec file and metadata for this collection const meta = loadSpecMetadata(); - const entries = meta[collectionPath] || []; - const entry = entries.find((e) => e.sourceUrl === sourceUrl); + const entry = (meta[collectionPath] || [])[0]; if (entry && deleteSpecFile) { const specPath = path.join(getSpecsDir(), entry.filename); if (fs.existsSync(specPath)) fs.unlinkSync(specPath); } - meta[collectionPath] = entries.filter((e) => e.sourceUrl !== sourceUrl); - if (meta[collectionPath].length === 0) delete meta[collectionPath]; + delete meta[collectionPath]; saveSpecMetadata(meta); return { success: true }; diff --git a/packages/bruno-electron/src/utils/filesystem.js b/packages/bruno-electron/src/utils/filesystem.js index 53b364901..9a56525ae 100644 --- a/packages/bruno-electron/src/utils/filesystem.js +++ b/packages/bruno-electron/src/utils/filesystem.js @@ -501,8 +501,11 @@ const scanForBrunoFiles = async (dir) => { return brunoFolders; }; +const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p); + module.exports = { DEFAULT_GITIGNORE, + posixifyPath, isValidPathname, exists, isSymbolicLink,