From fc47a739cdf45634028b199e7d0a23ac335a5f50 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 27 Feb 2026 00:32:41 +0000 Subject: [PATCH 01/12] diff --- package-lock.json | 10 + package.json | 1 + src/components/JsonEditor/JsonEditor.scss | 2 +- src/dashboard/Data/Config/Config.react.js | 22 +- .../Data/Config/ConfigConflictDiff.react.js | 226 ++++++++++++++++++ .../Data/Config/ConfigConflictDiff.scss | 82 +++++++ .../Data/Config/ConfigDialog.react.js | 43 +++- src/lib/tests/ConfigConflictDiff.test.js | 98 ++++++++ testing/preprocessor.js | 1 + 9 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 src/dashboard/Data/Config/ConfigConflictDiff.react.js create mode 100644 src/dashboard/Data/Config/ConfigConflictDiff.scss create mode 100644 src/lib/tests/ConfigConflictDiff.test.js diff --git a/package-lock.json b/package-lock.json index 3c36b061f8..3383f1e7ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "copy-to-clipboard": "3.3.3", "core-js": "3.48.0", "csrf-sync": "4.2.1", + "diff": "8.0.3", "expr-eval-fork": "3.0.1", "express": "5.2.1", "express-session": "1.18.2", @@ -14738,6 +14739,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-match-patch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", diff --git a/package.json b/package.json index 1e878adf2f..8b14f19914 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "copy-to-clipboard": "3.3.3", "core-js": "3.48.0", "csrf-sync": "4.2.1", + "diff": "8.0.3", "expr-eval-fork": "3.0.1", "express": "5.2.1", "express-session": "1.18.2", diff --git a/src/components/JsonEditor/JsonEditor.scss b/src/components/JsonEditor/JsonEditor.scss index e0d35af85a..cab49ea0df 100644 --- a/src/components/JsonEditor/JsonEditor.scss +++ b/src/components/JsonEditor/JsonEditor.scss @@ -90,7 +90,7 @@ .inputLayer { display: block; width: 100%; - min-width: calc(var(--modal-min-width) * (1 - var(--modal-label-ratio))); + min-width: 100%; background: transparent; color: transparent; caret-color: #333; diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js index a067a4c15c..7c4b09f3a6 100644 --- a/src/dashboard/Data/Config/Config.react.js +++ b/src/dashboard/Data/Config/Config.react.js @@ -7,6 +7,7 @@ */ import { ActionTypes } from 'lib/stores/ConfigStore'; import Button from 'components/Button/Button.react'; +import ConfigConflictDiff from 'dashboard/Data/Config/ConfigConflictDiff.react'; import ConfigDialog from 'dashboard/Data/Config/ConfigDialog.react'; import DeleteParameterDialog from 'dashboard/Data/Config/DeleteParameterDialog.react'; import AddArrayEntryDialog from 'dashboard/Data/Config/AddArrayEntryDialog.react'; @@ -276,10 +277,12 @@ class Config extends TableView { this.setState({ confirmModalOpen: false })} + title={`Conflict: ${this.confirmData?.name || 'parameter'}`} + showCancel={false} + continueText="Accept server version" + showContinue={true} + onContinue={() => this.setState({ confirmModalOpen: false })} + confirmText="Keep my edits" onConfirm={() => { this.setState({ confirmModalOpen: false }); this.saveParam({ @@ -287,11 +290,13 @@ class Config extends TableView { override: true, }); }} + width={700} > -
- This parameter changed while you were editing it. If you continue, the latest changes - will be lost and replaced with your version. Do you want to proceed? -
+
); } @@ -549,6 +554,7 @@ class Config extends TableView { type, masterKeyOnly, }; + this.confirmServerValue = currentValueAfter; return; } diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.react.js b/src/dashboard/Data/Config/ConfigConflictDiff.react.js new file mode 100644 index 0000000000..6892c30303 --- /dev/null +++ b/src/dashboard/Data/Config/ConfigConflictDiff.react.js @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import React from 'react'; +import { diffLines, diffChars } from 'diff'; +import styles from 'dashboard/Data/Config/ConfigConflictDiff.scss'; + +/** + * Serialize a config value to a string suitable for diffing. + * Both server and user values go through this to ensure consistent formatting. + */ +function serializeForDiff(value, type, isUserValue = false) { + if (value === null || value === undefined) { + return 'null'; + } + + switch (type) { + case 'Object': + case 'Array': { + // User values from ConfigDialog are already JSON strings for Object/Array + if (isUserValue && typeof value === 'string') { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + return JSON.stringify(value, null, 2); + } + case 'Boolean': + return String(value); + case 'Number': + return String(value); + case 'Date': { + if (typeof value === 'string') { + return value; + } + if (value instanceof Date) { + return value.toISOString(); + } + if (value && value.iso) { + return value.iso; + } + return String(value); + } + case 'GeoPoint': { + if (value && typeof value.toJSON === 'function') { + const json = value.toJSON(); + return JSON.stringify({ latitude: json.latitude, longitude: json.longitude }, null, 2); + } + if (value && (value.latitude !== undefined || value.longitude !== undefined)) { + return JSON.stringify({ latitude: value.latitude, longitude: value.longitude }, null, 2); + } + return JSON.stringify(value, null, 2); + } + case 'File': { + if (value && typeof value.toJSON === 'function') { + const json = value.toJSON(); + return JSON.stringify({ name: json.name, url: json.url }, null, 2); + } + if (value && (value._name !== undefined || value.name !== undefined)) { + return JSON.stringify({ name: value._name || value.name, url: value._url || value.url }, null, 2); + } + return JSON.stringify(value, null, 2); + } + case 'String': + default: + return String(value); + } +} + +/** + * Render a line with character-level highlighting. + * charDiffs is the result of diffChars() for this line pair. + * side is 'removed' or 'added'. + */ +function renderCharHighlightedContent(charDiffs, side) { + return charDiffs.map((part, i) => { + if (part.added && side === 'added') { + return {part.value}; + } + if (part.removed && side === 'removed') { + return {part.value}; + } + if (!part.added && !part.removed) { + return {part.value}; + } + return null; + }); +} + +/** + * ConfigConflictDiff displays a GitHub-style unified diff between + * the server's latest value and the user's edited value. + */ +const ConfigConflictDiff = ({ serverValue, userValue, type }) => { + const serverStr = serializeForDiff(serverValue, type, false); + const userStr = serializeForDiff(userValue, type, true); + + const lineDiffs = diffLines(serverStr, userStr); + + // Build diff lines with character-level highlighting + const rows = []; + let oldLineNum = 1; + let newLineNum = 1; + + for (let i = 0; i < lineDiffs.length; i++) { + const part = lineDiffs[i]; + const lines = part.value.replace(/\n$/, '').split('\n'); + + if (part.removed) { + // Check if next part is an addition (paired change for char-level diff) + const nextPart = lineDiffs[i + 1]; + const hasCharDiff = nextPart && nextPart.added; + let charDiffResult = null; + + if (hasCharDiff) { + charDiffResult = diffChars(part.value, nextPart.value); + } + + for (const line of lines) { + rows.push({ + type: 'removed', + oldNum: oldLineNum++, + newNum: null, + prefix: '-', + content: line, + charDiffs: charDiffResult, + charSide: 'removed', + singleLineDiff: lines.length === 1, + }); + } + + if (hasCharDiff) { + const addedLines = nextPart.value.replace(/\n$/, '').split('\n'); + for (const line of addedLines) { + rows.push({ + type: 'added', + oldNum: null, + newNum: newLineNum++, + prefix: '+', + content: line, + charDiffs: charDiffResult, + charSide: 'added', + singleLineDiff: addedLines.length === 1, + }); + } + i++; // Skip the next (added) part since we handled it + } + } else if (part.added) { + for (const line of lines) { + rows.push({ + type: 'added', + oldNum: null, + newNum: newLineNum++, + prefix: '+', + content: line, + charDiffs: null, + charSide: null, + singleLineDiff: false, + }); + } + } else { + for (const line of lines) { + rows.push({ + type: 'context', + oldNum: oldLineNum++, + newNum: newLineNum++, + prefix: ' ', + content: line, + charDiffs: null, + charSide: null, + singleLineDiff: false, + }); + } + } + } + + if (rows.length === 0 || (rows.length === 1 && rows[0].type === 'context' && rows[0].content === '')) { + return
Values are identical — no differences found.
; + } + + // For character-level highlighting within a single line, + // we need to map charDiffs to individual lines. Since diffChars + // operates on the full block text, for single-line values we can + // highlight directly. For multi-line, we show line-level coloring only. + const renderContent = (row) => { + if (row.charDiffs && row.singleLineDiff) { + return renderCharHighlightedContent(row.charDiffs, row.charSide); + } + return row.content; + }; + + const lineStyle = (row) => { + if (row.type === 'removed') return styles.lineRemoved; + if (row.type === 'added') return styles.lineAdded; + return styles.lineContext; + }; + + return ( +
+
+ Server version + Your edits +
+ + + {rows.map((row, idx) => ( + + + + + + + ))} + +
{row.oldNum ?? ''}{row.newNum ?? ''}{row.prefix}{renderContent(row)}
+
+ ); +}; + +export default ConfigConflictDiff; diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.scss b/src/dashboard/Data/Config/ConfigConflictDiff.scss new file mode 100644 index 0000000000..1a4b6cb7c6 --- /dev/null +++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss @@ -0,0 +1,82 @@ +.container { + border: 1px solid #d1d5da; + border-radius: 4px; + margin: 12px 20px; + overflow: auto; + max-height: 400px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 20px; +} + +.lineNumber { + width: 1%; + min-width: 40px; + padding: 0 8px; + text-align: right; + color: rgba(27, 31, 36, 0.3); + vertical-align: top; + user-select: none; + border-right: 1px solid #d1d5da; +} + +.prefix { + width: 1%; + padding: 0 4px; + user-select: none; + vertical-align: top; +} + +.content { + padding: 0 8px; + white-space: pre-wrap; + word-break: break-all; +} + +.lineRemoved { + background-color: #ffeef0; +} + +.lineAdded { + background-color: #e6ffec; +} + +.lineContext { + background-color: #ffffff; +} + +.charRemoved { + background-color: #fdaeb7; + border-radius: 2px; +} + +.charAdded { + background-color: #abf2bc; + border-radius: 2px; +} + +.header { + padding: 8px 16px; + background-color: #fafbfc; + border-bottom: 1px solid #d1d5da; + font-size: 13px; + color: #24292e; + display: flex; + justify-content: space-between; +} + +.headerLabel { + font-weight: 600; +} + +.emptyDiff { + padding: 16px 20px; + color: #586069; + font-style: italic; + text-align: center; +} diff --git a/src/dashboard/Data/Config/ConfigDialog.react.js b/src/dashboard/Data/Config/ConfigDialog.react.js index a59a6efe55..7f55964e0f 100644 --- a/src/dashboard/Data/Config/ConfigDialog.react.js +++ b/src/dashboard/Data/Config/ConfigDialog.react.js @@ -26,6 +26,7 @@ import semver from 'semver/preload.js'; import { dateStringUTC } from 'lib/DateUtils'; import LoaderContainer from 'components/LoaderContainer/LoaderContainer.react'; import ServerConfigStorage from 'lib/ServerConfigStorage'; +import ConfigConflictDiff from 'dashboard/Data/Config/ConfigConflictDiff.react'; import { CurrentApp } from 'context/currentApp'; const FORMATTING_CONFIG_KEY = 'config.formatting.syntax'; @@ -126,6 +127,7 @@ export default class ConfigDialog extends React.Component { masterKeyOnly: false, selectedIndex: null, wordWrap: false, + showDiff: false, error: null, syntaxColors: null, }; @@ -142,6 +144,7 @@ export default class ConfigDialog extends React.Component { masterKeyOnly: props.masterKeyOnly, selectedIndex: 0, wordWrap: false, + showDiff: false, error: initialError, syntaxColors: null, }; @@ -449,6 +452,27 @@ export default class ConfigDialog extends React.Component { { detectNonPrintable: effectiveDetectNonPrintable, detectNonAlphanumeric: effectiveDetectNonAlphanumeric, detectRegex: effectiveDetectRegex } )} /> + {this.state.showDiff && this.props.param.length > 0 && ( + + } + input={ + { try { return JSON.parse(this.props.value); } catch { return this.props.value; } })() + : this.props.value + } + userValue={this.state.value} + type={this.state.type} + /> + } + /> + )} { /* @@ -500,6 +524,8 @@ export default class ConfigDialog extends React.Component { ); const isJsonType = this.state.type === 'Object' || this.state.type === 'Array'; + const isDiffableType = isJsonType || this.state.type === 'String'; + const isExistingParam = this.props.param && this.props.param.length > 0; const customFooter = (
@@ -515,7 +541,7 @@ export default class ConfigDialog extends React.Component { onClick={this.compactValue.bind(this)} disabled={!this.canFormatValue()} /> -
-
+