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
This commit is contained in:
Sundram
2026-06-16 20:01:52 +05:30
committed by GitHub
parent 07c7348666
commit 05ab2661fa
6 changed files with 942 additions and 53 deletions

View File

@@ -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 && (
<div className="review-row-diff">
{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 && (
<div className="diff-loading">
<IconLoader2 size={16} className="spinning" />
<span>Loading diff...</span>
</div>
)}
{error && (
{error && !diffData && (
<div className="diff-error">
Error: {error}
</div>
)}
{diffData && !isLoading && !error && (
{diffData && !error && (
<EndpointVisualDiff
oldData={diffData.oldData}
newData={diffData.newData}

View File

@@ -1770,6 +1770,7 @@ const StyledWrapper = styled.div`
.bulk-actions {
display: flex;
gap: 0.5rem;
user-select: none; /* these are controls, not selectable text (e.g. double-click on the info icon) */
}
.bulk-btn {
@@ -1804,6 +1805,77 @@ const StyledWrapper = styled.div`
}
}
/* the three Preserve elements read as one control: label + info + toggle */
.preserve-values-control {
display: inline-flex;
align-items: center;
margin-right: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: ${(props) => 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;

View File

@@ -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 = ({
</div>
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
<div className="bulk-actions">
<div className="preserve-values-control">
<button
type="button"
role="switch"
aria-pressed={preserveValues}
className={`preserve-toggle ${preserveValues ? 'active' : ''}`}
onClick={() => setPreserveValues((v) => !v)}
>
<span className="preserve-toggle-knob" />
</button>
<span className="preserve-values-label">Preserve values</span>
<Help icon="info" size={12} placement="top" width={260}>
When enabled, your edited values are preserved during sync. When disabled, all values are updated to match the OpenAPI spec.
</Help>
</div>
{specDrift?.unifiedDiff && (
<button
className="bulk-btn"
@@ -329,6 +346,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>
@@ -353,6 +371,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>
@@ -377,6 +396,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>

View File

@@ -14,7 +14,7 @@ const useSyncFlow = ({
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync', preserveValues = true) => {
setShowConfirmModal(false);
setIsSyncing(true);
setError(null);
@@ -71,7 +71,8 @@ const useSyncFlow = ({
localOnlyToRemove,
driftedToReset,
mode,
endpointDecisions: decisions
endpointDecisions: decisions,
preserveValues
});
setPendingSyncMode(null);
@@ -102,7 +103,7 @@ const useSyncFlow = ({
const handleApplySync = (selections) => {
const mode = pendingSyncMode || 'sync';
setPendingSyncMode(null);
performSync(selections, mode);
performSync(selections, mode, selections?.preserveValues ?? true);
};
const cancelConfirmModal = () => {