fix(openapi-sync): simplify IPC calls, fix state priorities, and improve stored spec missing UX (#7489)

* refactor(OpenAPISyncTab): remove unused props and streamline IPC calls

- Eliminated unnecessary sourceUrl prop from various components and hooks in the OpenAPISyncTab.
- Improved pretty-printing logic in OpenAPISpecTab to handle non-JSON content gracefully.
- Updated IPC calls to remove redundant parameters, enhancing code clarity and maintainability.

* feat(OpenAPISyncTab): enhance user interaction and visual feedback

- Added onTabSelect prop to OpenAPISyncTab for improved tab navigation.
- Updated color properties in StyledWrapper for better consistency with theme.
- Replaced IconClock with IconAlertTriangle in CollectionStatusSection for clearer status indication.
- Enhanced messaging in OverviewSection and SpecStatusSection to provide clearer user guidance.
- Introduced handleRestoreSpec function in useSyncFlow for better spec restoration handling.

* fix(OpenAPISyncTab): update button labels for clarity in OverviewSection

- Changed button label from 'restore' to 'spec-details' for better context.
- Updated the button text from 'View Details' to 'Go to Spec Updates' to enhance user understanding of navigation options.

* refactor(OpenAPISyncTab): remove unused props and streamline component logic

- Eliminated unnecessary props from OpenAPISyncTab, CollectionStatusSection, and SpecStatusSection for cleaner code.
- Removed commented-out code in OverviewSection and SpecStatusSection to enhance readability.
- Introduced posixifyPath utility function in filesystem.js to standardize path formatting.

* fix(OpenAPISyncTab): update openapi config handling to support array format

- Modified the logic in loadBrunoConfig to handle openapi as an array, ensuring consistent resolution of source URLs for all entries. This change improves the configuration handling for OpenAPI specifications.

* fix(OpenAPISyncTab): improve openapi config handling and merge logic

- Updated loadBrunoConfig to ensure openapi is treated as an array, enhancing source URL resolution.
- Modified mergeWithUserValues to handle cases where specItems may be undefined, improving robustness in merging user values with specifications.
This commit is contained in:
Abhishek S Lal
2026-03-16 18:14:53 +05:30
committed by GitHub
parent 7e717768d2
commit 1877119b81
12 changed files with 167 additions and 221 deletions

View File

@@ -9,6 +9,7 @@ import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
*/
const prettyPrintSpec = (content) => {
if (!content) return content;
if (content.trimStart()[0] !== '{') return content;
try {
return fastJsonFormat(content);
} catch {
@@ -32,8 +33,7 @@ const OpenAPISpecTab = ({ collection }) => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
collectionPath: collection.pathname,
sourceUrl
collectionPath: collection.pathname
});
if (result.error) {
// Local file not found — fall back to fetching from remote URL

View File

@@ -5,7 +5,7 @@ import {
IconTrash,
IconArrowBackUp,
IconExternalLink,
IconClock,
IconAlertTriangle,
IconInfoCircle,
IconLoader2
} from '@tabler/icons';
@@ -25,7 +25,8 @@ const CollectionStatusSection = ({
storedSpec,
lastSyncDate,
onOpenEndpoint,
isLoading
isLoading,
onTabSelect
}) => {
const {
pendingAction, setPendingAction,
@@ -71,8 +72,6 @@ const CollectionStatusSection = ({
variant: 'muted',
message: 'Collection has changes since last sync',
badges: { modifiedCount, missingCount, localOnlyCount },
version,
lastSyncDate,
actions: ['revert-all']
};
}
@@ -89,12 +88,6 @@ const CollectionStatusSection = ({
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">
{bannerState.message}
{bannerState.version && (
<> &middot; <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
)}
{bannerState.lastSyncDate && (
<span className="checked-text"> &middot; Synced {moment(bannerState.lastSyncDate).fromNow()}</span>
)}
</span>
{bannerState.badges && (
<span className="banner-details">
@@ -117,7 +110,7 @@ const CollectionStatusSection = ({
{hasDrift && (
<div className="sync-info-notice mt-4">
<IconInfoCircle size={14} className="sync-info-icon" />
<span><span className="whats-updated-title">What's tracked:</span> Changes to URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
<span><span className="whats-updated-title">What's tracked:</span> Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
</div>
)}
@@ -222,26 +215,15 @@ const CollectionStatusSection = ({
<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">
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}</h4>
<p>{lastSyncDate
? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.'
: 'Once you sync your collection with the spec, local changes will appear here.'}
</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => onTabSelect('spec-updates')}>Go to Spec Updates</Button>
</div>
) : (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />

View File

@@ -12,7 +12,6 @@ import {
} from '@tabler/icons';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import ActionIcon from 'ui/ActionIcon/index';
import MenuDropdown from 'ui/MenuDropdown';
import Help from 'components/Help';

View File

@@ -21,7 +21,7 @@ const SUMMARY_CARDS = [
key: 'inSync',
label: 'In Sync with Spec',
color: 'green',
tooltip: 'Endpoints that currently match the latest spec'
tooltip: 'Endpoints that currently match the latest spec from the source'
},
{
key: 'changed',
@@ -46,10 +46,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const activeError = error || reduxError;
const version = storedSpec?.info?.version ?? specMeta?.version;
const newVersion = specDrift?.newVersion;
const hasVersionChange = version && newVersion && version !== newVersion;
const endpointCount = countEndpoints(storedSpec) ?? specMeta?.endpointCount ?? null;
const version = specMeta?.version;
const endpointCount = specMeta?.endpointCount ?? null;
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
const groupBy = openApiSyncConfig?.groupBy || 'tags';
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
@@ -90,7 +88,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
};
const details = [
{ label: 'Spec Version', value: hasVersionChange ? `v${version} → v${newVersion}` : version ? `v${version}` : '' },
{ label: 'Spec Version', value: version ? `v${version}` : '' },
{ label: 'Endpoints in Spec', value: endpointCount != null ? endpointCount : '' },
{ label: 'Last Synced At', value: lastSyncDate ? moment(lastSyncDate).fromNow() : '', tooltip: lastSyncDate ? moment(lastSyncDate).format('MMMM D, YYYY [at] h:mm A') : undefined },
{ label: 'Folder Grouping', value: capitalize(groupBy) },
@@ -121,19 +119,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
buttons: ['review']
};
}
if (specDrift?.storedSpecMissing && lastSyncDate) {
return {
variant: 'warning',
title: 'Last synced spec not found',
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.',
buttons: ['restore']
};
}
if (!hasDriftData) return null;
if (hasSpecUpdates && hasCollectionChanges) {
return {
variant: 'warning',
title: `The API spec has new updates${versionInfo} and the collection has changes`,
title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`,
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
buttons: ['sync', 'changes']
};
@@ -141,11 +130,20 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
if (hasSpecUpdates) {
return {
variant: 'warning',
title: `The API spec has new updates${versionInfo}`,
title: `OpenAPI spec has new updates${versionInfo}`,
subtitle: 'New or changed requests are available.',
buttons: ['sync']
};
}
if (specDrift?.storedSpecMissing && lastSyncDate) {
return {
variant: 'warning',
title: 'Last synced spec not found',
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.',
buttons: ['spec-details']
};
}
if (!hasDriftData) return null;
if (hasCollectionChanges) {
return {
variant: 'muted',
@@ -154,12 +152,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
buttons: ['changes']
};
}
// return {
// variant: 'success',
// title: 'Collection is in sync with the spec',
// subtitle: null,
// buttons: []
// };
return null;
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
@@ -195,14 +187,9 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
Review and Sync Collection
</Button>
)}
{bannerState.buttons.includes('restore') && (
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
Restore Spec File
</Button>
)}
{bannerState.buttons.includes('spec-details') && (
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
View Details
Go to Spec Updates
</Button>
)}
{bannerState.buttons.includes('open-settings') && (

View File

@@ -3,7 +3,8 @@ import { useSelector } from 'react-redux';
import {
IconCheck,
IconRefresh,
IconAlertTriangle
IconAlertTriangle,
IconClock
} from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
@@ -23,14 +24,20 @@ const SpecStatusSection = ({
const {
isSyncing, showConfirmModal, confirmGroups,
handleSyncNow, handleApplySync, cancelConfirmModal, handleConfirmModalSync
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
} = useSyncFlow({
collection, specDrift, remoteDrift, collectionDrift,
sourceUrl, setError, checkForUpdates: onCheck
setError, checkForUpdates: onCheck
});
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
const hasRemoteUpdates = remoteDrift && (
(remoteDrift.missing?.length || 0)
+ (remoteDrift.modified?.length || 0)
+ (remoteDrift.localOnly?.length || 0)
) > 0;
const bannerState = useMemo(() => {
if (fileNotFound) {
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
@@ -41,13 +48,12 @@ const SpecStatusSection = ({
if (!specDrift) {
return null;
}
if (specDrift.storedSpecMissing) {
if (!lastSyncedAt) {
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
}
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
return null;
}
const hasEndpointUpdates = (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
const hasEndpointUpdates = specDrift.storedSpecMissing
? hasRemoteUpdates
: (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
if (hasEndpointUpdates) {
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
@@ -57,13 +63,8 @@ const SpecStatusSection = ({
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
};
}
// return {
// variant: 'success', message: 'Spec is up to date', actions: [],
// version: specDrift.newVersion || storedSpec?.info?.version || specDrift.storedVersion,
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
// };
return null;
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
return (
<>
{bannerState && (
@@ -92,9 +93,6 @@ const SpecStatusSection = ({
)}
</div>
<div className="banner-actions">
{bannerState.actions.includes('quick-sync') && (
<Button size="xs" onClick={handleSyncNow}>Restore Spec File</Button>
)}
{bannerState.actions.includes('open-settings') && (
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
Update connection settings
@@ -111,12 +109,12 @@ const SpecStatusSection = ({
<h4>Unable to check for updates</h4>
<p>Fix the connection issue above and check again.</p>
</div>
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
<div className="sync-review-empty-state mt-5">
<IconRefresh size={40} className="empty-state-icon" />
<h4>Last Synced Spec not found in storage</h4>
<p>The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.</p>
<Button className="mt-4" color="warning" onClick={handleSyncNow} loading={isSyncing}>
<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. You can restore the spec file to track local collection changes.</p>
<Button className="mt-4" color="warning" onClick={handleRestoreSpec} loading={isSyncing}>
Restore Spec File
</Button>
</div>

View File

@@ -625,7 +625,7 @@ const StyledWrapper = styled.div`
.settings-label {
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.subtext0};
color: ${(props) => props.theme.text};
display: block;
margin-bottom: 5px;
}
@@ -670,7 +670,7 @@ const StyledWrapper = styled.div`
.toggle-description {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
margin-top: 2px;
}
@@ -1251,7 +1251,6 @@ const StyledWrapper = styled.div`
.disconnect-modal {
.disconnect-message {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
margin-bottom: 1.5rem;
}
@@ -1281,7 +1280,7 @@ const StyledWrapper = styled.div`
.action-confirm-modal {
.confirm-message {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
line-height: 1.5;
margin-bottom: 1.5rem;
}

View File

@@ -32,14 +32,12 @@ const useOpenAPISync = (collection) => {
const updateStoredSpec = (spec) => {
setStoredSpec(spec);
if (spec) {
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec.info?.title || null,
version: spec.info?.version || null,
endpointCount: countEndpoints(spec)
}));
}
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
version: spec?.info?.version || null,
endpointCount: spec ? countEndpoints(spec) : null
}));
};
// Flatten collection items including nested items in folders
@@ -100,8 +98,7 @@ const useOpenAPISync = (collection) => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig
collectionPath: collection.pathname
});
if (!result.error) {
@@ -150,9 +147,7 @@ const useOpenAPISync = (collection) => {
}
setSpecDrift(result);
if (result.storedSpec) {
updateStoredSpec(result.storedSpec);
}
updateStoredSpec(result.storedSpec || null);
// Update Redux store so toolbar status stays in sync
dispatch(setCollectionUpdate({
@@ -166,7 +161,6 @@ const useOpenAPISync = (collection) => {
if (result.newSpec) {
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig,
compareSpec: result.newSpec
});
if (remoteComparison.error) {
@@ -271,7 +265,6 @@ const useOpenAPISync = (collection) => {
if (result.newSpec) {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig,
compareSpec: result.newSpec
});
@@ -284,8 +277,7 @@ const useOpenAPISync = (collection) => {
// Collection matches — save spec file silently to complete setup
await ipcRenderer.invoke('renderer:save-openapi-spec', {
collectionPath: collection.pathname,
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2),
sourceUrl: trimmedUrl
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
});
}
}
@@ -304,7 +296,6 @@ const useOpenAPISync = (collection) => {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
collectionPath: collection.pathname,
sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl,
deleteSpecFile: true
});
setSourceUrl('');
@@ -343,7 +334,6 @@ const useOpenAPISync = (collection) => {
const { ipcRenderer } = window;
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig,
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
@@ -380,7 +370,6 @@ const useOpenAPISync = (collection) => {
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
collectionPath: collection.pathname,
oldSourceUrl: openApiSyncConfig?.sourceUrl,
config: {
sourceUrl: newUrl,
autoCheck,

View File

@@ -6,7 +6,7 @@ import { formatIpcError } from 'utils/common/error';
const useSyncFlow = ({
collection, specDrift, remoteDrift, collectionDrift,
sourceUrl, setError, checkForUpdates
setError, checkForUpdates
}) => {
const dispatch = useDispatch();
@@ -65,7 +65,6 @@ const useSyncFlow = ({
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl: sourceUrl.trim(),
addNewRequests: mode !== 'spec-only',
removeDeletedRequests: localOnlyIds.length > 0,
diff: filteredDiff,
@@ -121,6 +120,13 @@ const useSyncFlow = ({
return new Set((specDrift?.removed || []).map((ep) => ep.id));
}, [specDrift]);
const handleRestoreSpec = () => {
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
.map((ep) => ep.id);
performSync({ localOnlyIds, endpointDecisions: {} }, 'sync');
};
const handleConfirmModalSync = () => {
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
@@ -150,7 +156,7 @@ const useSyncFlow = ({
return {
isSyncing, showConfirmModal, confirmGroups,
handleSyncNow,
handleSyncNow, handleRestoreSpec,
handleApplySync, cancelConfirmModal, handleConfirmModalSync
};
};

View File

@@ -129,7 +129,6 @@ const OpenAPISyncTab = ({ collection }) => {
remoteDrift={remoteDrift}
onTabSelect={setActiveTab}
error={error}
isLoading={isLoading}
onOpenSettings={() => setShowSettingsModal(true)}
/>
<p className="beta-feedback-inline">
@@ -157,6 +156,7 @@ const OpenAPISyncTab = ({ collection }) => {
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
isLoading={isDriftLoading || isLoading}
onTabSelect={setActiveTab}
/>
</div>
)}

View File

@@ -1271,11 +1271,10 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
// Save OpenAPI spec file for sync support
if (rawOpenAPISpec && brunoConfig.openapi?.length) {
const importSourceUrl = brunoConfig.openapi[0].sourceUrl;
const specContent = typeof rawOpenAPISpec === 'string'
? rawOpenAPISpec
: JSON.stringify(rawOpenAPISpec, null, 2);
await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl: importSourceUrl });
await saveSpecAndUpdateMetadata({ collectionPath, specContent });
}
const { size, filesCount } = await getCollectionStats(collectionPath);

View File

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

View File

@@ -501,8 +501,11 @@ const scanForBrunoFiles = async (dir) => {
return brunoFolders;
};
const posixifyPath = (p) => (p ? p.replace(/\\/g, '/') : p);
module.exports = {
DEFAULT_GITIGNORE,
posixifyPath,
isValidPathname,
exists,
isSymbolicLink,