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); } } }