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.
This commit is contained in:
Abhishek S Lal
2026-03-13 23:48:46 +05:30
committed by GitHub
parent 1e25825e74
commit 384bf4f190
17 changed files with 265 additions and 130 deletions

3
.gitignore vendored
View File

@@ -51,6 +51,9 @@ bruno.iml
.cursor
.claude
.codex
.agents
.agent
skills-lock.json
# Playwright
/blob-report/

View File

@@ -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');

View File

@@ -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 = ({
)}
/>
</div>
) : isLoading ? (
<div className="sync-review-empty-state mt-5">
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your collection with the last synced spec...</p>
</div>
) : !hasStoredSpec ? (
<>
<div className="spec-update-banner warning">
<div className="banner-left">
<div className="status-dot warning" />
<span className="banner-title">
{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'}
</span>
</div>
</div>
<div className="sync-review-empty-state mt-5">
<IconClock size={40} className="empty-state-icon" />
<h4>{lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}</h4>
<p>{lastSyncDate
? 'Restore the latest spec from the source to track future changes.'
: 'Once you sync your collection with the spec, changes will appear here.'}
</p>
</div>
</>
) : (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />

View File

@@ -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,
<Button
type="submit"
size="sm"
disabled={mode === 'url' ? !isValidUrl(sourceUrl.trim()) : !sourceUrl.trim()}
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
loading={isLoading}
>
Connect

View File

@@ -2,17 +2,18 @@ import { useState, useRef } from 'react';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import Modal from 'components/Modal';
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';
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const isUrl = isValidUrl(sourceUrl);
const normalizedSourceUrl = (sourceUrl || '').trim();
const isUrl = isHttpUrl(normalizedSourceUrl);
const initialMode = isUrl ? 'url' : 'file';
const [mode, setMode] = useState(initialMode);
const [url, setUrl] = useState(isUrl ? (sourceUrl || '') : '');
const [filePath, setFilePath] = useState(isUrl ? '' : sourceUrl);
const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : '');
const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl);
const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false);
const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5);
const [isSaving, setIsSaving] = useState(false);
@@ -21,7 +22,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
const intervals = [5, 15, 30, 60];
const effectiveSource = mode === 'file' ? filePath : url.trim();
const canSave = mode === 'file' ? !!effectiveSource : isValidUrl(effectiveSource.trim());
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
const handleSave = async () => {
setIsSaving(true);
@@ -84,7 +85,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
toast.error('The selected file is not a valid OpenAPI specification');
toast.error('The selected file is not a valid OpenAPI 3.x specification');
return;
}
const path = window.ipcRenderer.getFilePath(file);

View File

@@ -15,7 +15,7 @@ const DisconnectSyncModal = ({ onConfirm, onClose }) => {
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
</p>
<div className="disconnect-actions">
<Button variant="ghost" onClick={onClose}>
<Button variant="ghost" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button color="danger" onClick={onConfirm}>

View File

@@ -1,3 +1,6 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
@@ -23,8 +26,20 @@ const OpenAPISyncHeader = ({
const sourceIsLocal = !isHttpUrl(sourceUrl);
const canCheck = !!sourceUrl?.trim();
const title = spec?.info?.title || 'Unknown API';
const version = spec?.info?.version || '-';
// Resolve relative file paths to absolute for display
const [displayPath, setDisplayPath] = useState(sourceUrl);
useEffect(() => {
if (sourceIsLocal && sourceUrl) {
window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname)
.then((resolved) => setDisplayPath(resolved))
.catch(() => setDisplayPath(sourceUrl));
} else {
setDisplayPath(sourceUrl);
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {
if (!sourceUrl) return;
@@ -111,7 +126,7 @@ const OpenAPISyncHeader = ({
type="button"
onClick={revealInFolder}
>
{sourceUrl}
{displayPath}
</button>
) : (
<a

View File

@@ -1,24 +1,14 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { countEndpoints } from '../utils';
import moment from 'moment';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import Help from 'components/Help';
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
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;
};
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']
};
}

View File

@@ -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 }) => {
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
<span className="diff-column-label">Updated Spec</span>
</div>
<div ref={diffRef}></div>
{isRendering && (
<div className="text-diff-loading">
<IconLoader2 className="animate-spin" size={20} strokeWidth={1.5} />
<span>Loading diff...</span>
</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
</>
) : (
<div className="text-diff-empty">No text diff available.</div>

View File

@@ -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
</Button>
</div>
) : remoteDrift && (
) : (
<div className="mt-5">
<SyncReviewPage
specDrift={specDrift}
@@ -133,6 +132,7 @@ const SpecStatusSection = ({
collectionUid={collection.uid}
newSpec={specDrift?.newSpec}
isSyncing={isSyncing}
isLoading={isLoading}
onApplySync={handleApplySync}
/>
</div>

View File

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

View File

@@ -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 = ({
<div className="sync-review-body">
{!hasRemoteUpdates ? (
<div className="sync-review-empty-state">
<IconRefresh size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The collection matches the latest spec. Nothing to sync.</p>
{isLoading ? (
<>
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your last synced spec with the latest spec...</p>
</>
) : (
<>
<IconCheck size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The spec endpoints have not been updated since the last sync.</p>
</>
)}
</div>
) : (
<div className="endpoints-review-sections">
@@ -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}

View File

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

View File

@@ -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' && (
<div className="sync-tab-content">
{collectionDrift && !collectionDrift.noStoredSpec ? (
<CollectionStatusSection
collection={collection}
collectionDrift={collectionDrift}
reloadDrift={reloadDrift}
specDrift={specDrift}
storedSpec={storedSpec}
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
/>
) : !isDriftLoading && !isLoading && (
<>
<div className="spec-update-banner warning">
<div className="banner-left">
<div className="status-dot warning" />
<span className="banner-title">
{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'}
</span>
</div>
</div>
<div className="sync-review-empty-state mt-5">
<IconClock size={40} className="empty-state-icon" />
<h4>{openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}</h4>
<p>{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.'}
</p>
</div>
</>
)}
<CollectionStatusSection
collection={collection}
collectionDrift={collectionDrift}
reloadDrift={reloadDrift}
specDrift={specDrift}
storedSpec={storedSpec}
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
isLoading={isDriftLoading || isLoading}
/>
</div>
)}

View File

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

View File

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

View File

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