From 6f82eae80f7157e24978891f4fa34cb7e76cb3f0 Mon Sep 17 00:00:00 2001 From: Abhishek S Lal Date: Fri, 13 Mar 2026 23:48:46 +0530 Subject: [PATCH] feat: improve OpenAPI Sync tab UX and fix sync flow bugs (#7467) * fix: specify OpenAPI 3.x in error messages for file uploads and URL validation Updated error messages in ConnectSpecForm and ConnectionSettingsModal to clarify that only OpenAPI 3.x specifications are valid. Enhanced useOpenAPISync hook to reflect the same specificity in error handling for invalid URLs. * feat(OpenAPISpecTab): add pretty-printing for JSON content in API spec viewer Implemented a new function to pretty-print JSON content for improved readability in the OpenAPISpecTab component. This enhancement ensures that JSON specifications are displayed in a more user-friendly format while leaving YAML content unchanged. * feat(OpenAPISyncHeader): resolve and display absolute file paths for local sources Added functionality to resolve relative file paths to absolute paths for better user experience in the OpenAPISyncHeader component. Implemented state management and side effects to handle path resolution based on the source URL, enhancing the display of local file paths. * feat(OpenAPISyncTab): enhance collection status display and add endpoint counting utility Refactored the CollectionStatusSection to streamline the display of collection drift status, integrating loading states and improved messaging for initial sync scenarios. Introduced a new utility function to count HTTP endpoints in OpenAPI specifications, enhancing the overall functionality of the OpenAPISyncTab. Additionally, updated the OpenAPISyncHeader and OverviewSection to utilize stored specification metadata for better user experience. * refactor: improve OpenAPI Sync endpoint handling - Enhanced the logic for adding new requests by ensuring existing files are verified before removal to prevent accidental deletions. - Streamlined the process of adding new endpoints, including checks for existing files and merging requests to maintain user customizations. - Added comments for clarity on the purpose of changes, particularly regarding filename collision prevention and file content verification. * style(OpenAPISyncTab): update styles for improved visual feedback - Changed background color for the 'type-spec-modified' class to a warning color for better distinction. - Updated text color and background for the SyncReviewPage to enhance readability and visual hierarchy. - Adjusted default expanded states for endpoint sections to improve user experience during sync reviews. * chore: update .gitignore and enhance OpenAPISyncTab components - Added new entries to .gitignore for agent-related files and skills-lock.json. - Modified StyledWrapper to improve overflow handling and added sticky headers for better visibility. - Introduced loading state in SpecDiffModal with a spinner for improved user feedback during rendering. * feat(OpenAPISpecTab): integrate fast-json-format for improved JSON rendering - Replaced the JSON parsing and stringifying logic with fast-json-format for better performance in pretty-printing API specifications. - Updated StyledWrapper in OpenAPISyncTab to change background and text colors for enhanced visual consistency. - Modified DisconnectSyncModal button to include a secondary color for improved visibility during user interactions. * fix(OpenAPISyncTab): correct punctuation in status messages and subtitles - Removed unnecessary trailing periods in messages related to syncing and restoring specifications across CollectionStatusSection and OverviewSection components. - Updated SyncReviewPage to correct grammatical error in the description of spec updates. * fix(OpenAPISyncTab): update URL validation to use isHttpUrl - Replaced isValidUrl with isHttpUrl in ConnectSpecForm and ConnectionSettingsModal components to ensure only valid HTTP URLs are accepted. - Updated the logic for enabling the save button based on the new URL validation method. * fix(OpenAPISyncTab): normalize source URL before validation - Trimmed the source URL in ConnectionSettingsModal to ensure consistent validation with isHttpUrl. - Updated state initialization for URL and filePath to use the normalized source URL, improving handling of user input. --- .gitignore | 3 + .../src/components/OpenAPISpecTab/index.js | 17 +++- .../CollectionStatusSection/index.js | 37 ++++++++- .../OpenAPISyncTab/ConnectSpecForm/index.js | 6 +- .../ConnectionSettingsModal/index.js | 13 ++-- .../DisconnectSyncModal/index.js | 2 +- .../OpenAPISyncTab/OpenAPISyncHeader/index.js | 21 ++++- .../OpenAPISyncTab/OverviewSection/index.js | 29 +++---- .../OpenAPISyncTab/SpecDiffModal/index.js | 19 ++++- .../OpenAPISyncTab/SpecStatusSection/index.js | 4 +- .../OpenAPISyncTab/StyledWrapper.js | 40 ++++++++-- .../OpenAPISyncTab/SyncReviewPage/index.js | 25 ++++-- .../OpenAPISyncTab/hooks/useOpenAPISync.js | 26 +++++-- .../src/components/OpenAPISyncTab/index.js | 43 +++-------- .../src/components/OpenAPISyncTab/utils.js | 16 ++++ .../ReduxStore/slices/openapi-sync.js | 17 +++- .../bruno-electron/src/ipc/openapi-sync.js | 77 +++++++++++-------- 17 files changed, 265 insertions(+), 130 deletions(-) create mode 100644 packages/bruno-app/src/components/OpenAPISyncTab/utils.js diff --git a/.gitignore b/.gitignore index 7b7a88b30..9ebdddea1 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,9 @@ bruno.iml .cursor .claude .codex +.agents +.agent +skills-lock.json # Playwright /blob-report/ diff --git a/packages/bruno-app/src/components/OpenAPISpecTab/index.js b/packages/bruno-app/src/components/OpenAPISpecTab/index.js index 484ecdba4..245608134 100644 --- a/packages/bruno-app/src/components/OpenAPISpecTab/index.js +++ b/packages/bruno-app/src/components/OpenAPISpecTab/index.js @@ -1,8 +1,21 @@ import React, { useState, useEffect, useCallback } from 'react'; import { IconLoader2, IconCloud } from '@tabler/icons'; +import fastJsonFormat from 'fast-json-format'; import SpecViewer from 'components/ApiSpecPanel/SpecViewer'; import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper'; +/** + * Pretty-print JSON content for readable display. YAML content is returned as-is. + */ +const prettyPrintSpec = (content) => { + if (!content) return content; + try { + return fastJsonFormat(content); + } catch { + return content; + } +}; + const OpenAPISpecTab = ({ collection }) => { const [specContent, setSpecContent] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -37,14 +50,14 @@ const OpenAPISpecTab = ({ collection }) => { } }); if (fetchResult.content) { - setSpecContent(fetchResult.content); + setSpecContent(prettyPrintSpec(fetchResult.content)); setIsRemote(true); return; } } setError(result.error); } else { - setSpecContent(result.content); + setSpecContent(prettyPrintSpec(result.content)); } } catch (err) { setError(err.message || 'Failed to read spec file'); diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js index f96d313a2..1b2ef9463 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js @@ -6,14 +6,14 @@ import { IconArrowBackUp, IconExternalLink, IconClock, - IconInfoCircle + IconInfoCircle, + IconLoader2 } from '@tabler/icons'; import moment from 'moment'; import Button from 'ui/Button'; import StatusBadge from 'ui/StatusBadge'; import Modal from 'components/Modal'; import EndpointChangeSection from '../EndpointChangeSection'; -import EndpointItem from '../EndpointChangeSection/EndpointItem'; import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow'; import useEndpointActions from '../hooks/useEndpointActions'; @@ -24,7 +24,8 @@ const CollectionStatusSection = ({ specDrift, storedSpec, lastSyncDate, - onOpenEndpoint + onOpenEndpoint, + isLoading }) => { const { pendingAction, setPendingAction, @@ -39,7 +40,8 @@ const CollectionStatusSection = ({ } = useEndpointActions(collection, collectionDrift, reloadDrift); const spec = storedSpec || specDrift?.newSpec; - const hasDrift = !!collectionDrift && (collectionDrift.modified?.length > 0 + const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec; + const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0 || collectionDrift.missing?.length > 0 || collectionDrift.localOnly?.length > 0); @@ -211,6 +213,33 @@ const CollectionStatusSection = ({ )} /> + ) : isLoading ? ( +
+ +

Checking for updates

+

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.'} +

+
+ ) : (
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js index 5069272a5..687852d67 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConnectSpecForm/index.js @@ -1,7 +1,7 @@ import { useState, useRef } from 'react'; import { IconCheck } from '@tabler/icons'; import Button from 'ui/Button'; -import { isValidUrl } from 'utils/url/index'; +import { isHttpUrl } from 'utils/url/index'; import { isOpenApiSpec } from 'utils/importers/openapi-collection'; import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader'; @@ -77,7 +77,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError, try { const data = await parseFileAsJsonOrYaml(file); if (!isOpenApiSpec(data)) { - setError('The selected file is not a valid OpenAPI specification'); + setError('The selected file is not a valid OpenAPI 3.x specification'); return; } const filePath = window.ipcRenderer.getFilePath(file); @@ -100,7 +100,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError, ) : ( { - if (!spec?.paths) return null; - let count = 0; - for (const path of Object.values(spec.paths)) { - for (const key of Object.keys(path)) { - if (HTTP_METHODS.includes(key.toLowerCase())) count++; - } - } - return count; -}; - const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str; const SUMMARY_CARDS = [ @@ -54,23 +44,22 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0]; const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error); + const specMeta = useSelector(selectStoredSpecMeta(collection.uid)); const activeError = error || reduxError; - const version = storedSpec?.info?.version; - const endpointCount = countEndpoints(storedSpec); + const version = storedSpec?.info?.version ?? specMeta?.version; + const endpointCount = countEndpoints(storedSpec) ?? specMeta?.endpointCount ?? null; const lastSyncDate = openApiSyncConfig?.lastSyncDate; const groupBy = openApiSyncConfig?.groupBy || 'tags'; const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false; const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5; // Endpoint Summary counts - // Total/In Sync: always compare against remote spec + // Total: from collection items in Redux; In Sync: from remote spec comparison // Changed/Conflicts: compare against stored spec in AppData (0 on initial sync) const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec; - const totalInCollection = remoteDrift - ? (remoteDrift.inSync?.length || 0) + (remoteDrift.modified?.length || 0) + (remoteDrift.localOnly?.length || 0) - : null; + const totalInCollection = getTotalRequestCountInCollection(collection); const inSyncCount = remoteDrift ? (remoteDrift.inSync?.length || 0) @@ -131,7 +120,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r 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..', + subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.', buttons: ['restore'] }; } diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js index 9981232fa..2aeb2e289 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecDiffModal/index.js @@ -1,11 +1,13 @@ -import { useRef, useEffect } from 'react'; +import { useRef, useEffect, useState } from 'react'; import { useTheme } from 'providers/Theme/index'; +import { IconLoader2 } from '@tabler/icons'; import Modal from 'components/Modal'; import StatusBadge from 'ui/StatusBadge'; const SpecDiffModal = ({ specDrift, onClose }) => { const diffRef = useRef(null); const { displayedTheme } = useTheme(); + const [isRendering, setIsRendering] = useState(true); const addedCount = specDrift?.added?.length || 0; const modifiedCount = specDrift?.modified?.length || 0; @@ -17,7 +19,11 @@ const SpecDiffModal = ({ specDrift, onClose }) => { useEffect(() => { const { Diff2Html } = window; - if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) return; + if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) { + setIsRendering(false); + return; + } + setIsRendering(true); const diffHtml = Diff2Html.html(specDrift.unifiedDiff, { drawFileList: false, matching: 'lines', @@ -29,6 +35,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => { }); // Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js) diffRef.current.innerHTML = diffHtml; + setIsRendering(false); }, [displayedTheme, specDrift?.unifiedDiff]); return ( @@ -60,7 +67,13 @@ const SpecDiffModal = ({ specDrift, onClose }) => { {specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'} Updated Spec
-
+ {isRendering && ( +
+ + Loading diff... +
+ )} +
) : (
No text diff available.
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js index 98f2e7a03..0a3d5af85 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SpecStatusSection/index.js @@ -4,7 +4,6 @@ import { IconCheck, IconRefresh } from '@tabler/icons'; -import moment from 'moment'; import Button from 'ui/Button'; import StatusBadge from 'ui/StatusBadge'; import ConfirmSyncModal from '../ConfirmSyncModal'; @@ -123,7 +122,7 @@ const SpecStatusSection = ({ Restore Spec File
- ) : remoteDrift && ( + ) : (
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js index 51ff3c57e..f6c301781 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js @@ -970,7 +970,7 @@ const StyledWrapper = styled.div` &.type-local-only { background: ${(props) => props.theme.colors.text.muted}; } &.type-in-sync { background: ${(props) => props.theme.colors.text.green}; } &.type-conflict { background: ${(props) => props.theme.colors.text.danger}; } - &.type-spec-modified { background: ${(props) => props.theme.colors.text.info}; } + &.type-spec-modified { background: ${(props) => props.theme.colors.text.warning}; } &.type-collection-drift { background: ${(props) => props.theme.colors.text.warning}; } } @@ -988,8 +988,8 @@ const StyledWrapper = styled.div` height: 1.25rem; padding: 0 0.3rem; font-size: ${(props) => props.theme.font.size.xs}; - color: ${(props) => props.theme.colors.text.subtext0}; - background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.colors.text.subtext1}; + background: ${(props) => props.theme.background.surface1}; border-radius: 999px; } @@ -1504,11 +1504,15 @@ const StyledWrapper = styled.div` .text-diff-container { border-radius: ${(props) => props.theme.border.radius.sm}; border: 1px solid ${(props) => props.theme.border.border1}; - overflow: hidden; + overflow: auto; .diff-column-headers { display: flex; border-bottom: 1px solid ${(props) => props.theme.border.border1}; + position: sticky; + top: 0; + z-index: 2; + background: ${(props) => props.theme.bg}; .diff-column-label { flex: 1; @@ -1640,6 +1644,16 @@ const StyledWrapper = styled.div` } } + .text-diff-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + color: ${(props) => props.theme.colors.text.muted}; + font-size: ${(props) => props.theme.font.size.sm}; + } + .text-diff-empty { padding: 2rem; text-align: center; @@ -1662,8 +1676,9 @@ const StyledWrapper = styled.div` } .spec-diff-body { - max-height: calc(80vh - 140px); - overflow: auto; + .text-diff-container { + max-height: calc(80vh - 140px); + } } } @@ -1721,6 +1736,15 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.status.info.text}; background: ${(props) => props.theme.status.info.background}; } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + .spinner-icon { + animation: spin 1s linear infinite; + } } .sync-review-body { @@ -2190,7 +2214,7 @@ const StyledWrapper = styled.div` align-self: stretch; gap: 2px; padding: 2px; - background: ${(props) => props.theme.background.surface2}; + background: ${(props) => props.theme.background.surface1}; border-radius: ${(props) => props.theme.border.radius.md}; } @@ -2198,7 +2222,7 @@ const StyledWrapper = styled.div` padding: 0 0.65rem; font-size: ${(props) => props.theme.font.size.sm}; font-weight: 500; - color: ${(props) => props.theme.colors.text.muted}; + color: ${(props) => props.theme.text}; background: transparent; border: none; border-radius: calc(${(props) => props.theme.border.radius.md} - 3px); diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js index f2837733d..a606d8e97 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js @@ -6,7 +6,7 @@ import { IconArrowRight, IconArrowsDiff, IconInfoCircle, - IconRefresh + IconLoader2 } from '@tabler/icons'; import Button from 'ui/Button'; import StatusBadge from 'ui/StatusBadge'; @@ -73,6 +73,7 @@ const SyncReviewPage = ({ collectionUid, newSpec, isSyncing, + isLoading, onApplySync }) => { const dispatch = useDispatch(); @@ -250,9 +251,19 @@ const SyncReviewPage = ({
{!hasRemoteUpdates ? (
- -

No updates from the spec

-

The collection matches the latest spec. Nothing to sync.

+ {isLoading ? ( + <> + +

Checking for updates

+

Comparing your last synced spec with the latest spec...

+ + ) : ( + <> + +

No updates from the spec

+

The spec endpoints have not been updated since the last sync.

+ + )}
) : (
@@ -264,7 +275,7 @@ const SyncReviewPage = ({ title="Updated in Spec" type="spec-modified" endpoints={specUpdatedEndpoints} - defaultExpanded={hasConflicts} + defaultExpanded={true} expandableLayout subtitle="The spec has updates for these endpoints" headerExtra={conflictCount > 0 ? ( @@ -300,7 +311,7 @@ const SyncReviewPage = ({ title="New in Spec" type="added" endpoints={specAddedEndpoints} - defaultExpanded={false} + defaultExpanded={true} expandableLayout subtitle="New endpoints from the spec" collectionUid={collectionUid} @@ -324,7 +335,7 @@ const SyncReviewPage = ({ title="Removed from Spec" type="removed" endpoints={specRemovedEndpoints} - defaultExpanded={false} + defaultExpanded={true} expandableLayout subtitle="These endpoints are in your collection but not in the spec" collectionUid={collectionUid} diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js index 1e7bf328f..d4e8a4763 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js @@ -3,11 +3,12 @@ import { useDispatch, useSelector } from 'react-redux'; import toast from 'react-hot-toast'; import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs'; import { getDefaultRequestPaneTab } from 'utils/collections'; -import { clearCollectionState, setCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync'; +import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync'; import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common'; import { isHttpUrl } from 'utils/url/index'; import { flattenItems } from 'utils/collections/index'; import { formatIpcError } from 'utils/common/error'; +import { countEndpoints } from '../utils'; const useOpenAPISync = (collection) => { const dispatch = useDispatch(); @@ -29,6 +30,18 @@ const useOpenAPISync = (collection) => { const isConfigured = !!openApiSyncConfig?.sourceUrl; + const updateStoredSpec = (spec) => { + setStoredSpec(spec); + if (spec) { + dispatch(setStoredSpecMeta({ + collectionUid: collection.uid, + title: spec.info?.title || null, + version: spec.info?.version || null, + endpointCount: countEndpoints(spec) + })); + } + }; + // Flatten collection items including nested items in folders const allHttpItems = useMemo(() => { return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request'); @@ -113,6 +126,7 @@ const useOpenAPISync = (collection) => { setFileNotFound(false); setSpecDrift(null); setRemoteDrift(null); + setCollectionDrift(null); try { const { ipcRenderer } = window; @@ -136,7 +150,7 @@ const useOpenAPISync = (collection) => { setSpecDrift(result); if (result.storedSpec) { - setStoredSpec(result.storedSpec); + updateStoredSpec(result.storedSpec); } // Update Redux store so toolbar status stays in sync @@ -211,11 +225,11 @@ const useOpenAPISync = (collection) => { try { const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl }); if (specType !== 'openapi') { - setError('The URL does not point to a valid OpenAPI specification'); + setError('The URL does not point to a valid OpenAPI 3.x specification'); return; } } catch { - setError('The URL does not point to a valid OpenAPI specification'); + setError('The URL does not point to a valid OpenAPI 3.x specification'); return; } } @@ -328,11 +342,11 @@ const useOpenAPISync = (collection) => { try { ({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl })); } catch { - toast.error('The URL does not point to a valid OpenAPI specification'); + toast.error('The URL does not point to a valid OpenAPI 3.x specification'); throw new Error('Invalid OpenAPI specification'); } if (specType !== 'openapi') { - toast.error('The URL does not point to a valid OpenAPI specification'); + toast.error('The URL does not point to a valid OpenAPI 3.x specification'); throw new Error('Invalid OpenAPI specification'); } } diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/index.js index 91335c23c..2a23d6dd4 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/index.js @@ -3,7 +3,6 @@ import { useDispatch, useSelector } from 'react-redux'; import { v4 as uuid } from 'uuid'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync'; -import { IconClock } from '@tabler/icons'; import ResponsiveTabs from 'ui/ResponsiveTabs'; import StyledWrapper from './StyledWrapper'; import OpenAPISyncHeader from './OpenAPISyncHeader'; @@ -150,38 +149,16 @@ const OpenAPISyncTab = ({ collection }) => { {activeTab === 'collection-changes' && (
- {collectionDrift && !collectionDrift.noStoredSpec ? ( - - ) : !isDriftLoading && !isLoading && ( - <> -
-
-
- - {openApiSyncConfig?.lastSyncDate - ? 'Last synced spec is required to show collection changes. Restore the latest spec from the source to track future changes..' - : 'Collection changes will be available after the initial sync'} - -
-
-
- -

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

-

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

-
- - )} +
)} diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/utils.js b/packages/bruno-app/src/components/OpenAPISyncTab/utils.js new file mode 100644 index 000000000..259124410 --- /dev/null +++ b/packages/bruno-app/src/components/OpenAPISyncTab/utils.js @@ -0,0 +1,16 @@ +const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']; + +/** + * Count the number of HTTP endpoints in an OpenAPI spec. + * Returns null if the spec has no paths (e.g. spec is null/undefined). + */ +export const countEndpoints = (spec) => { + if (!spec?.paths) return null; + let count = 0; + for (const path of Object.values(spec.paths)) { + for (const key of Object.keys(path)) { + if (HTTP_METHODS.includes(key.toLowerCase())) count++; + } + } + return count; +}; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js b/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js index d10cf54f8..1cfea9261 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/openapi-sync.js @@ -9,7 +9,9 @@ const initialState = { // Last poll timestamp lastPollTime: null, // Map of collectionUid -> { activeTab, expandedSections, expandedRows } - tabUiState: {} + tabUiState: {}, + // Map of collectionUid -> { title, version, endpointCount } (persists across tab navigations) + storedSpecMeta: {} }; export const openapiSyncSlice = createSlice({ @@ -33,6 +35,11 @@ export const openapiSyncSlice = createSlice({ const { collectionUid } = action.payload; delete state.collectionUpdates[collectionUid]; delete state.tabUiState[collectionUid]; + delete state.storedSpecMeta[collectionUid]; + }, + setStoredSpecMeta: (state, action) => { + const { collectionUid, title, version, endpointCount } = action.payload; + state.storedSpecMeta[collectionUid] = { title, version, endpointCount }; }, setPollingEnabled: (state, action) => { state.pollingEnabled = action.payload; @@ -116,7 +123,8 @@ export const { toggleRowExpanded, setLastPollTime, setReviewDecision, - setReviewDecisions + setReviewDecisions, + setStoredSpecMeta } = openapiSyncSlice.actions; // Lightweight thunk for polling — only checks hash, no deep comparison @@ -199,4 +207,9 @@ export const selectTabUiState = (collectionUid) => (state) => { return state.openapiSync?.tabUiState?.[collectionUid] || {}; }; +// Selector for stored spec metadata (title, version, endpointCount) +export const selectStoredSpecMeta = (collectionUid) => (state) => { + return state.openapiSync?.storedSpecMeta?.[collectionUid] || null; +}; + export default openapiSyncSlice.reducer; diff --git a/packages/bruno-electron/src/ipc/openapi-sync.js b/packages/bruno-electron/src/ipc/openapi-sync.js index 67fdfe020..e2c0076d8 100644 --- a/packages/bruno-electron/src/ipc/openapi-sync.js +++ b/packages/bruno-electron/src/ipc/openapi-sync.js @@ -1316,35 +1316,8 @@ const registerOpenAPISyncIpc = (mainWindow) => { } } - if (addNewRequests && diff.added?.length > 0 && newCollection) { - for (const endpoint of diff.added) { - const normalizedPath = normalizeUrlPath(endpoint.path); - const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path); - const newItem = result?.item; - - if (newItem) { - // Check if endpoint already exists in collection (prevents overwriting user customizations) - const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath); - - if (existingFile) { - const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem); - const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat }); - await writeFile(existingFile.filePath, content); - } else { - // Truly new — create file in the appropriate folder - let targetFolder = collectionPath; - if (result.folderName && groupBy === 'tags') { - targetFolder = await ensureTagFolder(collectionPath, result.folderName, format); - } - - const requestContent = await stringifyRequestViaWorker(newItem, { format }); - const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`; - await writeFile(path.join(targetFolder, sanitizedFilename), requestContent); - } - } - } - } - + // Remove endpoints before adding new ones to avoid filename collisions + // (e.g., when a path is renamed but the summary stays the same, both generate the same filename) if (removeDeletedRequests && diff.removed?.length > 0) { const findAndRemoveRequest = (dirPath) => { if (!fs.existsSync(dirPath)) return; @@ -1389,6 +1362,8 @@ const registerOpenAPISyncIpc = (mainWindow) => { } // Remove local-only endpoints (endpoints in collection but not in spec) + // Verify file content before deleting — the file may have been modified by the user + // between the drift scan and sync execution, making the pre-computed filePath stale. if (localOnlyToRemove?.length > 0) { for (const endpoint of localOnlyToRemove) { if (endpoint.filePath) { @@ -1398,7 +1373,49 @@ const registerOpenAPISyncIpc = (mainWindow) => { continue; } if (fs.existsSync(fullPath)) { - fs.unlinkSync(fullPath); + try { + const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru'; + const content = fs.readFileSync(fullPath, 'utf8'); + const parsed = parseRequest(content, { format: fileFormat }); + if (parsed?.request) { + const fileMethod = parsed.request.method?.toUpperCase(); + const fileUrlPath = normalizeUrlPath(parsed.request.url); + if (fileMethod === endpoint.method && fileUrlPath === endpoint.path) { + fs.unlinkSync(fullPath); + } + } + } catch (err) { + console.error(`[OpenAPI Sync] Error verifying file before removal ${endpoint.filePath}:`, err); + } + } + } + } + } + + if (addNewRequests && diff.added?.length > 0 && newCollection) { + for (const endpoint of diff.added) { + const normalizedPath = normalizeUrlPath(endpoint.path); + const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path); + const newItem = result?.item; + + if (newItem) { + // Check if endpoint already exists in collection (prevents overwriting user customizations) + const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath); + + if (existingFile) { + const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem); + const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat }); + await writeFile(existingFile.filePath, content); + } else { + // Truly new — create file in the appropriate folder + let targetFolder = collectionPath; + if (result.folderName && groupBy === 'tags') { + targetFolder = await ensureTagFolder(collectionPath, result.folderName, format); + } + + const requestContent = await stringifyRequestViaWorker(newItem, { format }); + const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`; + await writeFile(path.join(targetFolder, sanitizedFilename), requestContent); } } }