From 05ab2661fa35805f0ddfb0923747e05af188ab88 Mon Sep 17 00:00:00 2001 From: Sundram Date: Tue, 16 Jun 2026 20:01:52 +0530 Subject: [PATCH] feat(openapi-sync): preserve user-configured request values on sync (#8204) Reconcile request structure against the spec on sync while preserving the user's values (JSON body, params, headers, auth) and {{var}} references for fields that still exist. A "Preserve values" toggle (default on) on the Spec Updates review controls it; turning it off lets spec values overwrite. The diff preview's EXPECTED column shows the post-merge result so unchanged values do not render as changes. - field-level merge for JSON body (by key path), form fields and params/headers (by name, duplicate names paired positionally), preserving value + enabled - {{var}} masking so interpolated bodies parse, merge and restore safely, using a private-use sentinel that never collides with body text - auth merged by mode: same mode keeps the user's values and adds spec-introduced fields; a mode change takes the spec. compareRequestFields compares auth by mode only, so preserved auth values no longer mark the collection out of sync - preserveValues threaded through apply and diff-preview IPC handlers - reset path left unchanged; scripts/tests/assertions preserved in sync and reset - 67 unit tests covering the merge helpers and masking edge cases --- .../ExpandableEndpointRow.js | 52 +- .../OpenAPISyncTab/StyledWrapper.js | 72 +++ .../OpenAPISyncTab/SyncReviewPage/index.js | 22 +- .../OpenAPISyncTab/hooks/useSyncFlow.js | 7 +- .../bruno-electron/src/ipc/openapi-sync.js | 292 ++++++++-- .../tests/ipc/openapi-sync-merge.spec.js | 550 ++++++++++++++++++ 6 files changed, 942 insertions(+), 53 deletions(-) create mode 100644 packages/bruno-electron/tests/ipc/openapi-sync-merge.spec.js diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js index 95c3aede8..08a39f947 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/EndpointChangeSection/ExpandableEndpointRow.js @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { IconChevronRight, @@ -15,7 +15,7 @@ import Help from 'components/Help'; import EndpointVisualDiff from './EndpointVisualDiff'; // Expandable row - can be used with or without decision buttons -const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => { +const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions, preserveValues = true }) => { const dispatch = useDispatch(); const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`; const isExpanded = useSelector((state) => { @@ -25,9 +25,15 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio const [diffData, setDiffData] = useState(null); const [error, setError] = useState(null); - const loadDiffData = useCallback(async () => { - if (diffData) return; + // Monotonic id so a superseded in-flight fetch (e.g. the user flips the + // Preserve toggle mid-request) can't overwrite the latest result. + const requestIdRef = useRef(0); + const loadDiffData = useCallback(async () => { + // No internal diffData guard: both callers (the expand effect and handleToggle) + // already gate on !diffData. Guarding here would capture a stale diffData from + // the render that recreated this callback and silently skip the toggle re-fetch. + const requestId = ++requestIdRef.current; setIsLoading(true); setError(null); @@ -36,20 +42,45 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', { collectionPath, endpointId: endpoint.id, - newSpec + newSpec, + preserveValues }); + if (requestId !== requestIdRef.current) return; // superseded by a newer fetch if (result.error) { setError(result.error); } else { setDiffData(result); } } catch (err) { + if (requestId !== requestIdRef.current) return; setError(formatIpcError(err) || 'Failed to load diff data'); } finally { + if (requestId === requestIdRef.current) setIsLoading(false); + } + }, [collectionPath, endpoint.id, newSpec, preserveValues]); + + // Re-fetch the preview when the preserve toggle changes — the EXPECTED column + // depends on it. Expanded rows re-fetch in place (the old diff stays visible + // and swaps when the new one arrives, so the row never blanks). Collapsed rows + // just drop their cache so the next expand fetches fresh — invisible to the user. + const didMountPreserve = useRef(false); + useEffect(() => { + if (!didMountPreserve.current) { + didMountPreserve.current = true; + return; + } + if (isExpanded) { + loadDiffData(); // bumps requestId, keeps old diff until the new one lands + } else { + requestIdRef.current++; // invalidate any in-flight fetch + setDiffData(null); + setError(null); setIsLoading(false); } - }, [collectionPath, endpoint.id, newSpec]); + // Intentionally only re-run when the toggle flips — not on isExpanded/loadDiffData + // changes, which the dedicated load effect + handleToggle already cover. + }, [preserveValues]); // Load diff data when expanded (e.g. restored from Redux state) useEffect(() => { @@ -126,18 +157,21 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio {isExpanded && (
- {isLoading && ( + {/* Spinner only on the initial load. A re-fetch (e.g. toggling Preserve) + keeps the previous diff visible and swaps it in place, so the row + never blanks/flickers. */} + {isLoading && !diffData && !error && (
Loading diff...
)} - {error && ( + {error && !diffData && (
Error: {error}
)} - {diffData && !isLoading && !error && ( + {diffData && !error && ( props.theme.font.size.xs}; + color: ${(props) => props.theme.text}; + + .preserve-values-label { + white-space: nowrap; + } + + /* the shared InfoCircle icon ships a hardcoded ml-2 (8px); override it + so the info icon sits tight to the label. It is a hover-only tooltip + affordance, not a button — use a help cursor and never show a + click/focus box around it. */ + svg { + margin-left: 4px; + cursor: help; + } + svg:focus, + svg:focus-visible, + span:focus, + span:focus-visible { + outline: none; + box-shadow: none; + background: transparent; + } + + /* compact themed track + knob toggle, sized to the button row height */ + .preserve-toggle { + margin-right: 4px; /* space between the toggle and the label */ + width: 26px; + height: 14px; + border-radius: 7px; + border: none; + padding: 0; + flex-shrink: 0; + position: relative; + cursor: pointer; + transition: background 0.2s; + background: ${(props) => props.theme.colors.text.muted}; + + &.active { + background: ${(props) => props.theme.button2.color.primary.bg}; + } + + &:focus-visible { + outline: 2px solid ${(props) => props.theme.button2.color.primary.bg}; + outline-offset: 2px; + } + + .preserve-toggle-knob { + width: 10px; + height: 10px; + border-radius: 50%; + background: #fff; + position: absolute; + top: 2px; + left: 2px; + transition: left 0.2s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + } + + &.active .preserve-toggle-knob { + left: 14px; + } + } + } + .sync-review-body { flex: 1; overflow-y: auto; diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js index 5b2e34053..890885ddb 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/SyncReviewPage/index.js @@ -88,6 +88,7 @@ const SyncReviewPage = ({ }) => { const dispatch = useDispatch(); const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {}); + const [preserveValues, setPreserveValues] = useState(true); const [showConfirmation, setShowConfirmation] = useState(false); const [showSpecDiffModal, setShowSpecDiffModal] = useState(false); const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false); @@ -210,7 +211,8 @@ const SyncReviewPage = ({ newToCollection: filteredAddedEndpoints, specUpdates: filteredSpecChanges, resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'), - localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming') + localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming'), + preserveValues }); }; @@ -238,6 +240,21 @@ const SyncReviewPage = ({
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
+
+ + Preserve values + + When enabled, your edited values are preserved during sync. When disabled, all values are updated to match the OpenAPI spec. + +
{specDrift?.unifiedDiff && (